Files
gstack/test/autoplan-pending-question.test.ts
T
garrytan 6415690a18 test: retire the finding-count cluster and trim its helpers (C)
- C0/C1: the five never-green evals (skill-e2e-autoplan-chain and
  skill-e2e-plan-{ceo,eng,design,devex}-finding-count) failed on harness and
  budget, never on skill behavior; delete them, their touchfile/tier ids,
  AUTOPLAN_CHAIN_BUDGET and the dedicated eighth periodic slice (--slices 7).
- C2: delete the helper groups whose only paid consumers were those files
  (11 modules), trim claude-pty-runner and eng-seeded-coverage to the paid
  closure, and delete the free replay tests whose assertions exercised only
  that dead code (89 files, 135 orphaned fixtures). Blocks that used dead code
  only as input for a live subject keep their assertions: the multiSelect
  default moved to plan-review-decisions, runner PTY tests use inline caller
  policies, and the timer-safe budget checks moved to eng-finding-retry-budget.
- The eight production-touching files stay except ceo-current-decision-record
  (its template read only feeds the retired counter).
- CARVE_GUARDS.autoplan is behavioral 'none'; TODOS records the lost chain
  and per-finding cadence coverage with their re-entry tests.
2026-09-29 06:08:49 +00:00

242 lines
11 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import { createPendingQuestionRecorder, readPendingQuestion, recordPendingQuestion } from './helpers/plan-count-pending-question';
import { readPlanCountTranscript } from './helpers/plan-count-transcript';
import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles';
import captured from './fixtures/autoplan-routing-manual-skills-ac.json';
// Exact retained public question; hook envelopes and owned temp paths are
// synthetic controls. AC retry 2's unpublished native payload is unknown.
function fixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pending-auq-' quote $()-"));
const cwd = path.join(root, 'repo with spaces');
const config = path.join(root, 'config');
const project = path.join(config, 'projects', 'fixture');
fs.mkdirSync(cwd, { recursive: true });
fs.mkdirSync(project, { recursive: true });
const session = captured.call.sessionId;
const transcriptPath = path.join(project, `${session}.jsonl`);
const startedAt = Date.now();
fs.writeFileSync(transcriptPath, JSON.stringify({ cwd, sessionId: session, isSidechain: false,
timestamp: new Date().toISOString(), message: { role: 'assistant', content: [{ type: 'text', text: 'Review setup.' }] },
}) + '\n');
const recorder = createPendingQuestionRecorder(cwd, config);
const event = (kind = 'PreToolUse', id = captured.call.toolUseId) => ({ hook_event_name: kind,
tool_name: 'AskUserQuestion', session_id: session, tool_use_id: id, cwd,
transcript_path: transcriptPath, tool_input: { questions: structuredClone(captured.call.questions) },
});
const write = (value: unknown) => recordPendingQuestion(typeof value === 'string' ? value : JSON.stringify(value), recorder.file, cwd, config);
const transcript = () => readPlanCountTranscript(config, cwd);
const read = (native = transcript(), after = startedAt) => readPendingQuestion(recorder.file, cwd, config, after, native);
const dispose = () => { recorder.dispose(); fs.rmSync(root, { recursive: true, force: true }); };
return { root, cwd, config, recorder, startedAt, session, transcriptPath, event, write, read, transcript, dispose };
}
describe('opt-in pending native AskUserQuestion capture', () => {
test('a scoped request supplies pending identity, never an answer or coverage', () => {
const f = fixture();
try {
expect(f.read()).toBeUndefined();
f.write(f.event());
expect(f.read()).toMatchObject({ sessionId: f.session, toolUseId: captured.call.toolUseId,
source: 'pre_tool_use', answered: false, questions: captured.call.questions });
expect(f.read()?.answers).toBeUndefined();
expect(f.read()?.answeredAt).toBeUndefined();
} finally { f.dispose(); }
});
test.each(['PostToolUse', 'PostToolUseFailure'])('%s closes a request and a replay cannot reopen it', kind => {
const f = fixture();
try {
f.write(f.event());
expect(f.read()).toBeDefined();
f.write(f.event(kind));
expect(f.read()).toBeUndefined();
f.write(f.event());
expect(f.read()).toBeUndefined();
f.write(f.event('PreToolUse', 'toolu_new_owned_question'));
expect(f.read()?.toolUseId).toBe('toolu_new_owned_question');
} finally { f.dispose(); }
});
test('a duplicate pending request does not refresh its evidence timestamp', async () => {
const f = fixture();
try {
f.write(f.event());
expect(f.read()).toBeDefined();
const before = fs.readFileSync(f.recorder.file, 'utf8');
await Bun.sleep(5);
f.write(f.event());
expect(fs.readFileSync(f.recorder.file, 'utf8')).toBe(before);
} finally { f.dispose(); }
});
test('a completion observed before its request cannot reopen', () => {
const f = fixture();
try {
f.write(f.event('PostToolUse'));
f.write(f.event());
expect(f.read()).toBeUndefined();
} finally { f.dispose(); }
});
test('concurrent calls and conflicting same-call payloads poison the ambiguous pending capture', () => {
for (const sameId of [false, true]) {
const f = fixture();
try {
f.write(f.event());
const next = f.event('PreToolUse', sameId ? captured.call.toolUseId : 'toolu_other_pending');
if (sameId) next.tool_input.questions[0]!.question += ' Changed.';
f.write(next);
expect(f.read()).toBeUndefined();
f.write(f.event('PostToolUse'));
f.write(f.event('PreToolUse', 'toolu_later'));
expect(f.read()).toBeUndefined();
} finally { f.dispose(); }
}
});
test.each(['cwd', 'session', 'subagent'])('foreign %s cannot replace or clear the owned request', field => {
const f = fixture();
try {
f.write(f.event());
const prior = f.read();
for (const kind of ['PreToolUse', 'PostToolUse', 'PostToolUseFailure']) {
const event: Record<string, unknown> = f.event(kind);
if (field === 'cwd') event.cwd = path.join(f.root, 'foreign');
if (field === 'session') {
event.session_id = '3f7e6255-331b-4c3f-b5b6-cc9481be0548';
event.transcript_path = path.join(path.dirname(f.transcriptPath), `${event.session_id}.jsonl`);
fs.writeFileSync(event.transcript_path as string, '');
}
if (field === 'subagent') event.agent_id = 'foreign-subagent';
f.write(event);
expect(f.read()).toEqual(prior);
}
} finally { f.dispose(); }
});
test.each(['empty questions', 'too many questions', 'too few options', 'too many options', 'invalid option',
'oversize', 'outside transcript path', 'wrong tool name', 'missing cwd'])('malformed scoped %s fails closed', kind => {
const f = fixture();
try {
f.write(f.event());
const event = f.event();
const question = event.tool_input.questions[0]!;
if (kind === 'empty questions') event.tool_input.questions = [];
if (kind === 'too many questions') event.tool_input.questions = Array.from({ length: 5 }, () => structuredClone(question));
if (kind === 'too few options') question.options.splice(1);
if (kind === 'too many options') question.options = Array.from({ length: 5 }, (_, i) => ({ label: `Option ${i}`, description: '' }));
if (kind === 'invalid option') question.options[0]!.label = '';
if (kind === 'oversize') question.question = 'x'.repeat(128 * 1024);
if (kind === 'outside transcript path') event.transcript_path = path.join(f.root, `${f.session}.jsonl`);
if (kind === 'wrong tool name') event.tool_name = 'Write';
if (kind === 'missing cwd') delete (event as Partial<typeof event>).cwd;
f.write(event);
expect(f.read()).toBeUndefined();
f.write(f.event());
expect(f.read()).toBeUndefined();
} finally { f.dispose(); }
});
test('a late result for another call cannot clear the current pending request', () => {
const f = fixture();
try {
f.write(f.event());
const current = f.read();
f.write(f.event('PostToolUse', 'toolu_earlier_call'));
expect(f.read()).toEqual(current);
f.write(f.event('PreToolUse', 'toolu_earlier_call'));
expect(f.read()).toEqual(current);
} finally { f.dispose(); }
});
test('read requires the same ready native session, isolated directory and current epoch', () => {
const f = fixture();
try {
f.write(f.event());
const native = f.transcript();
expect(f.read(native)).toBeDefined();
expect(f.read(native, Date.now() + 1_000)).toBeUndefined();
for (const status of ['missing', 'error'] as const) {
expect(f.read({ ...native, status })).toBeUndefined();
}
const foreign = structuredClone(native);
foreign.assistantMessages[0]!.sessionId = '3f7e6255-331b-4c3f-b5b6-cc9481be0548';
expect(f.read(foreign)).toBeUndefined();
const mixed = structuredClone(native);
mixed.assistantMessages.push({ ...foreign.assistantMessages[0]! });
expect(f.read(mixed)).toBeUndefined();
expect(readPendingQuestion(undefined, f.cwd, f.config, f.startedAt, native)).toBeUndefined();
expect(readPendingQuestion(f.recorder.file, f.cwd, null, f.startedAt, native)).toBeUndefined();
expect(readPendingQuestion(f.recorder.file, path.join(f.root, 'other'), f.config, f.startedAt, native)).toBeUndefined();
} finally { f.dispose(); }
});
test('published native identity takes precedence even while unanswered or failed', () => {
const f = fixture();
try {
f.write(f.event());
for (const state of [{ answered: false }, { answered: true }, { answered: false, failed: true }]) {
const native = f.transcript();
native.calls.push({ ...structuredClone(captured.call), sessionId: f.session, ...state });
expect(f.read(native)).toBeUndefined();
}
} finally { f.dispose(); }
});
test('generated shell hooks are exact, bounded, correctly quoted and silent on valid or broken input', () => {
const f = fixture();
try {
expect(Object.keys(f.recorder.hooks).sort()).toEqual(['PostToolUse', 'PostToolUseFailure', 'PreToolUse']);
for (const kind of ['PreToolUse', 'PostToolUse', 'PostToolUseFailure'] as const) {
const matchers = f.recorder.hooks[kind];
expect(matchers).toHaveLength(1);
expect(matchers[0]!.matcher).toBe('^AskUserQuestion$');
expect(matchers[0]!.hooks).toHaveLength(1);
const hook = matchers[0]!.hooks[0]!;
expect(hook.timeout).toBe(5);
for (const input of [JSON.stringify(f.event(kind)), '{invalid json']) {
const result = spawnSync('bash', ['-c', hook.command], { cwd: f.cwd, input, encoding: 'utf8', timeout: 6_000 });
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
expect(result.stdout).toBe('');
}
}
} finally { f.dispose(); }
});
test('the helper and new free test select only the two opted-in workflows', () => {
for (const file of ['test/helpers/plan-count-pending-question.ts', 'test/autoplan-pending-question.test.ts']) {
expect(selectTests([file], E2E_TOUCHFILES, []).selected.sort()).toEqual(['plan-ceo-mode-routing']);
}
});
test('a hook whose input never ends closes within its own bound and remains silent', async () => {
const f = fixture();
const hook = f.recorder.hooks.PreToolUse[0]!.hooks[0]!;
const child = Bun.spawn(['bash', '-c', ['exec', hook.command].join(' ')], { cwd: f.cwd,
stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
// Use an actual shell command argument; no fixture string is interpolated.
let forced = false;
const timer = setTimeout(() => { forced = true; child.kill('SIGKILL'); }, 6_000);
try {
const [code, stdout] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
expect(forced).toBe(false);
expect(code).toBe(0);
expect(stdout).toBe('');
expect(f.read()).toBeUndefined();
expect(fs.existsSync(f.recorder.file + '.invalid')).toBe(true);
} finally {
clearTimeout(timer);
child.stdin.end();
child.kill('SIGKILL');
await child.exited;
f.dispose();
}
}, 7_000);
});