fix: keep review input on the active native question

This commit is contained in:
Garry Tan committed 2026-09-08 16:49:27 +00:00
1 parent 74bef511f9
commit 5769be57a7
3 files changed
+368 -11

No files matched your search

+60 -10
View File
@@ -1224,7 +1224,45 @@ export function capturePlanCountQuestion(
seen: Set<string>,
observedAtMs: number,
preReview: boolean,
pending?: NativePlanQuestionCall,
): AskUserQuestionFingerprint | null {
const tail = stripPtyResidue(visible).replace(/\r+\n?/g, '\n').slice(-4096);
const cursor = [...tail.matchAll(/❯\s*1\./g)].at(-1);
// The options parser can fall back to ordinary numbered prose when an
// old cursor leaves its window. Do not pair that prose with the prompt
// parser's still-visible historical question and queue spurious input.
if (!cursor) return null;
if (pending && !pending.answered && !pending.failed && pending.questions.length === 1) {
const beforeCursor = tail.slice(0, cursor.index);
const header = [...beforeCursor.matchAll(/(?:^|\n)[\t │┃]*[☐□]([^\n│]*)/g)].at(-1);
const compact = (value: string) => value.replace(/\s+/g, '').toLowerCase();
const question = pending.questions[0]!;
const questionId = question.question.match(/<gstack-qid:[^>]+>/i)?.[0];
const identity = questionId ?? question.question;
const footer = /Enter\s*to\s*select\s*·\s*↑\/↓\s*to\s*navigate\s*·\s*Esc\s*to\s*cancel/i;
// A captured B menu lost the dot in option 2. The native call supplies
// its actual choices, but only after the matching boxed question,
// cursor, and complete navigation footer establish the active UI.
if (header && compact(header[1]!) === compact(question.header) &&
!/❯\s*[1-9]\./.test(beforeCursor.slice(header.index)) &&
compact(beforeCursor.slice(header.index)).includes(compact(identity)) &&
footer.test(tail.slice(cursor.index))) {
const fp = nativePlanCallFingerprint(pending, observedAtMs, preReview);
const renderedOptions = parseNumberedOptions(visible);
const renderedPrompt = parseQuestionPrompt(visible);
const renderedSignature = renderedOptions.length >= 2 && renderedPrompt
? auqFingerprint(renderedPrompt, renderedOptions) : null;
const alreadyHandled = seen.has(fp.signature) || Boolean(renderedSignature && seen.has(renderedSignature));
// Once answered, the same menu may linger while the native call is
// no longer pending, or the native record may arrive after the UI
// answer. Bind both identities before testing either direction.
if (renderedSignature) seen.add(renderedSignature);
seen.add(fp.signature);
if (alreadyHandled) return null;
return fp;
}
}
const options = parseNumberedOptions(visible);
if (options.length < 2) return null;
const promptSnippet = parseQuestionPrompt(visible);
@@ -1292,11 +1330,17 @@ export function planCountSubmissionInput(visible: string): string | null {
const bar = bars.at(-1);
if (!bar) return null;
const panel = visible.slice(bar.index + bar[0].length).replace(/\s+/g, '');
// Captured cursor redraws can omit the dot after 1 and the e in answers.
// The native tab bar and explicit Submit label still identify this control.
const cursor = [...panel.matchAll(/❯1\.?/g)].at(-1);
if (!/Reviewyouranswe?rs/i.test(panel) || !cursor ||
!/^❯1\.?Submit/i.test(panel.slice(cursor.index))) return null;
const cursor = [...panel.matchAll(/❯([1-9])\.?/g)].at(-1);
if (!/^Reviewyouranswe?rs/i.test(panel) || !cursor || cursor[1] !== '1') return null;
const beforeCursor = panel.slice(0, cursor.index);
// The native tab bar, review heading and immediately preceding question
// identify Submit even when its button caption loses letters in a redraw.
const readyPrompt = /Readytosubmityouranswers\?$/i.test(beforeCursor);
// Older captures lack an intact Ready prompt. Keep their exact Submit
// caption path only while no later question/menu has entered scrollback.
const legacyCaption = /^❯1\.?Submit/i.test(panel.slice(cursor.index)) &&
!/❯[1-9]|[☐☒]/.test(beforeCursor);
if (!readyPrompt && !legacyCaption) return null;
const tabs = [...bar[0].matchAll(/[☐☒]/g)].map((match) => match[0]);
const unanswered = tabs.indexOf('☐');
// At the Submit tab, move back to the first unanswered question. Only
@@ -1428,6 +1472,15 @@ export const ceoFirstReviewAUQ: Step0BoundaryPredicate = (fp) =>
}) ?? false;
export const engStep0Boundary: Step0BoundaryPredicate = (fp) =>
// The native complexity gate can say "Reduce scope or proceed as-is"
// without the historical "scope reduction recommendation" wording.
// It closes setup only once that exact scope question has an answer.
(fp.nativeCall?.answered === true && fp.nativeCall.questions.some(q =>
Boolean(fp.nativeCall?.answers?.[q.question]) && /^scope$/i.test(q.header.trim()) &&
/<gstack-qid:\s*plan-eng-scope-complexity\s*>/i.test(q.question) &&
/\bcomplexity\s+check\b/i.test(q.question) &&
q.options.some(o => /^proceed\s+as[- ]is\b/i.test(o.label)) &&
q.options.some(o => /^reduce\b/i.test(o.label)))) ||
/scope\s*reduction\s*recommendation|cross[\s-]*project\s*learnings/i.test(
fp.promptSnippet,
) ||
@@ -2394,15 +2447,12 @@ export async function runPlanSkillCounting(opts: {
);
}
// Numbered option list?
if (!isNumberedOptionListVisible(visible)) continue;
// Dedupe the complete question, not just its answer labels: separate
// findings often reuse the same Add to plan / Defer / Skip menu.
const fp = capturePlanCountQuestion(visible, seen, Date.now() - startedAt, !boundaryFired);
const pending = transcript.calls.find(c => !c.answered && !c.failed);
const fp = capturePlanCountQuestion(visible, seen, Date.now() - startedAt, !boundaryFired, pending);
if (!fp) continue;
// Press to advance — first AUQ may use the override pick.
const pending = transcript.calls.find(c => !c.answered && !c.failed);
const routing = pending?.questions.length === 1
? nativePlanCallFingerprint(pending, fp.observedAtMs, fp.preReview) : fp;
const prerequisitePick = planCountPrerequisitePick(routing);
+229
View File
@@ -49,6 +49,7 @@ import {
designStep0Boundary,
designFirstReviewAUQ,
planCountQuestionPhase,
nativePlanCallFingerprint,
devexStep0Boundary,
type ClaudePtyOptions,
type AskUserQuestionFingerprint,
@@ -720,6 +721,23 @@ describe('classifyVisible (runtime path through the runner classifier)', () => {
});
describe('parseNumberedOptions', () => {
test('does not combine an old AUQ prompt with the later ordinary test-case list', () => {
// B CEO retry, 2026-09-08: the old prompt cursor slid outside the
// option parser's 4KB window. Its prose fallback then supplied a new
// five-item test list while the prompt parser retained the old AUQ.
const visible = '☐Stripe event types\nWhich event should the handler accept?\n' +
'❯1.Specify one canonical event\n2.Accept all events\n' + '·'.repeat(4200) + '\n' +
'Minimum required test cases (all must be specified in the plan):\n' +
'1.Happypath:validcanonicalevent,knownuser→userupdated,emailsent\n' +
'2.Email failure:emailthrows→userupdated,errorlogged,HTTP200\n' +
'3.DB timeout: DB throws onuser update →exceptin ropagates, non-200\n' +
'4.Unkown event typ: non-canonical event→ HTTP200,nouserupdate\n' +
'5.Unknown user: valid event, usernotinDB→existingguard→HTTP200\n❯1\n';
const seen = new Set<string>();
expect(capturePlanCountQuestion(visible, seen, 0, false)).toBeNull();
expect(seen.size).toBe(0);
});
test('extracts options from a clean cursor list', () => {
const visible = `
❯ 1. HOLD SCOPE
@@ -787,6 +805,67 @@ describe('parseNumberedOptions', () => {
});
});
describe('pending native question on a damaged option render', () => {
// Exact final B CEO Test scope shape. The native call had been read in
// an in-progress snapshot, but option 2's missing dot prevented input.
const frame = [
'☐Test scope',
'│Section 6 (Tests) — Theplanhasnotestsforanewpaymentprocessingcodepath.Theexistingintegrationsuitehas',
'│never seen this handlerand cannotcatchregressionsinit.Minimumviabletestplanforminimalpatch:5unittests',
'│(happy path, mal failur, DB timeout, unknowneventtype,unknownuser).Shouldtheplanalsoincludeanintegration',
'│testhittingthefullwebhookstack?<gstack-qid:plan-ceo-test-scope>',
'❯1.Unittestsonlyfornow(recommended)',
'5 unit tests covering the criticalpaths. No integration stin v1.',
'2Uni tsts + one integration test',
'5 uit tsts + on ed-to-end integrationtestsendiga signe Stripeevent.',
'3.Integrationtestonly',
'4.Typesomething.',
'5. Chataboutthis',
'Enter to select · ↑/↓ to navigate · Esc to cancel',
'❯1',
].join('\n');
const pending = {
sessionId: '66fb6218-4a68-4f1a-a729-6407f14fd6b8',
toolUseId: 'toolu_017DicePqWNVyDsLCd2Y2MCi', answered: false,
questions: [{ header: 'Test scope', question: 'Should the plan also include an integration test hitting the full webhook stack? <gstack-qid:plan-ceo-test-scope>',
options: ['Unit tests only for now (recommended)', 'Unit tests + one integration test', 'Integration test only'].map(label => ({ label })) }],
};
test('uses lossless pending options after a positively matched native question has rendered', () => {
const seen = new Set<string>();
const captured = capturePlanCountQuestion(frame, seen, 0, false, pending);
expect(captured?.nativeCall).toBe(pending);
expect(captured?.options).toEqual(pending.questions[0].options.map((o, i) => ({ index: i + 1, label: o.label })));
expect(capturePlanCountQuestion(frame, seen, 1, false, pending)).toBeNull();
// A corrected redraw is still the same pending native question.
expect(capturePlanCountQuestion(frame.replace('2Uni tsts', '2.Unit tests'), seen, 2, false, pending)).toBeNull();
expect(capturePlanCountQuestion(frame.replace('2Uni tsts', '2.Unit tests'), seen, 3, false)).toBeNull();
expect(seen.has(captured!.signature)).toBe(true);
});
test('binds delayed native metadata to the already-answered visible question', () => {
const seen = new Set<string>();
const clean = frame.replace('2Uni tsts', '2.Unit tests');
expect(capturePlanCountQuestion(clean, seen, 0, false)).not.toBeNull();
expect(capturePlanCountQuestion(clean, seen, 1, false, pending)).toBeNull();
expect(capturePlanCountQuestion(frame, seen, 2, false, pending)).toBeNull();
});
test('requires pending single-question metadata, matching current header, cursor, and navigation footer', () => {
for (const call of [undefined, { ...pending, answered: true }, { ...pending, failed: true },
{ ...pending, questions: [...pending.questions, ...pending.questions] },
{ ...pending, questions: [{ ...pending.questions[0], header: 'Prior decision' }] },
{ ...pending, questions: [{ ...pending.questions[0], question: 'Different issue <gstack-qid:plan-ceo-different-test-scope>' }] }]) {
expect(capturePlanCountQuestion(frame, new Set(), 0, false, call)).toBeNull();
}
for (const altered of [frame.replace('☐Test scope', 'Test scope'), frame.replace('❯1.', '1.'),
frame.replace('Enter to select · ↑/↓ to navigate · Esc to cancel', ''),
frame + '\n☐Different question\n❯1.Waiting for its choices']) {
expect(capturePlanCountQuestion(altered, new Set(), 0, false, pending)).toBeNull();
}
});
});
describe('runPlanSkillObservation env passthrough surface', () => {
test('ClaudePtyOptions exposes env: Record<string, string>', () => {
// Type-level guard: this file would fail to compile if the env field
@@ -1357,6 +1436,26 @@ describe('classifyPlanCountFrame replay', () => {
});
describe('planCountSubmissionInput replay', () => {
test('uses the captured DevEx panel anchors when the Submit button label is damaged', () => {
const captured = [
'← ☒ Routing setup ☐ Cross-project ✔ Submit →',
'Review your answers',
'⚠You have not answere all questions',
' │ ●D1 — Shouldgstack add skill routingrulestothisproject\'sCLAUDE.md?<gstack-qid:routing-injection>',
'→dd routing rules (Recmmeded)',
'Ready to submit your answers?',
'❯1.Sbmi answers',
'2Cancel',
].join('\r\r');
expect(planCountSubmissionInput(captured)).toBe('\x1b[Z');
expect(planCountSubmissionInput(captured.replace('☐ Cross-project', '☒ Cross-project'))).toBe('\r');
expect(planCountSubmissionInput(captured + '\r☐ Retry spec\rRetry once?\r❯1.Yes\r2.No')).toBeNull();
expect(planCountSubmissionInput(captured + '\r☐ Proposal\rSend this proposal?\r❯1.Submit proposal\r2.Keep editing')).toBeNull();
expect(planCountSubmissionInput(captured + '\r☐ Retry spec\rRetry once?\r❯2.No\r3.Other')).toBeNull();
expect(planCountSubmissionInput(captured.replace('Review your answers', 'Review context'))).toBeNull();
expect(planCountSubmissionInput(captured.replace('Ready to submit your answers?', 'Read the proposed answers.'))).toBeNull();
});
test('the captured mode Submit panel with a damaged caption and dotless cursor returns to its unanswered tab', () => {
// Exact final active panel from targeted-a's SCOPE EXPANSION retry.
const captured = [
@@ -1550,6 +1649,136 @@ describe('Step0BoundaryPredicate per-skill', () => {
});
describe('engStep0Boundary', () => {
// Captured native question text, labels and answers from targeted-b's
// engineering retry. Descriptions are immaterial to the phase boundary.
const captured = [
{
"header": "Scope",
"question": "D1 — Multi-tenant Auth Refactor complexity check: 12 files + 4 new classes. Reduce scope or proceed as-is? <gstack-qid:plan-eng-scope-complexity>",
"options": [
"Proceed as-is",
"Reduce: cut TokenStore + RequestPolicy",
"Reduce: single-pass strangler"
],
"answer": "Proceed as-is"
},
{
"header": "Shared Cache",
"question": "D2 — Arch issue 1: AuthBroker and SessionMint both mutate a global shared AuthCache via module-level export. How should this be fixed? <gstack-qid:plan-eng-shared-mutable-cache>",
"options": [
"Inject AuthCache as a dependency (recommended)",
"Make mutations go through a single owner",
"Accept the risk for now, document it"
],
"answer": "Inject AuthCache as a dependency (recommended)"
},
{
"header": "TOCTOU",
"question": "D3 — Arch issue 2: TOCTOU window during tenant suspension. The plan says AuthCache invalidates entries on tenant suspension, but with two services mutating the cache, a token validation begun before suspension completes may still succeed after the tenant is suspended. How should this be addressed? <gstack-qid:plan-eng-toctou-suspension>",
"options": [
"Add suspension check at session issuance boundary (recommended)",
"Add invalidation ordering guarantee to the plan",
"Accept the window, note it as an edge case"
],
"answer": "Add suspension check at session issuance boundary (recommended)"
},
{
"header": "Error handling",
"question": "D4 — Code quality issue 1: validateAndDispatch() swallows three distinct error classes in nested catch blocks with no re-throw, logging, or metrics. Errors disappear silently. How should this be handled? <gstack-qid:plan-eng-error-swallowing>",
"options": [
"Refactor to flat error handling with explicit re-throw or typed result (recommended)",
"Add logging inside each catch, keep structure",
"Leave it, add a lint rule to catch new instances"
],
"answer": "Refactor to flat error handling with explicit re-throw or typed result (recommended)"
},
{
"header": "Test coverage",
"question": "D5 — Test issue 1: 0/18 code paths covered in the plan. The plan scopes tests to 'new components and their success/error paths' but omits: cache invalidation edge cases, all three catch blocks in validateAndDispatch(), and the 5 IDP call failure modes. Should the test scope be expanded? <gstack-qid:plan-eng-test-coverage>",
"options": [
"Expand test scope to cover all 18 paths (recommended)",
"Cover new paths only, defer legacy and edge cases",
"Accept current test scope as stated in the plan"
],
"answer": "Expand test scope to cover all 18 paths (recommended)"
},
{
"header": "IDP calls",
"question": "D6 — Performance issue 1: token validation makes 5 sequential IDP API calls. The plan acknowledges they are independent and could be parallelized via Promise.all. Should this be fixed in this PR or deferred? <gstack-qid:plan-eng-idp-sequential-calls>",
"options": [
"Parallelize now with Promise.all (recommended)",
"Defer to a follow-up PR, add a TODO",
"Add a concurrency cap via Promise.all with limit"
],
"answer": "Parallelize now with Promise.all (recommended)"
},
{
"header": "Cache bounds",
"question": "D7 — Performance issue 2 (medium confidence): AuthCache evicts on token expiry but the plan doesn't mention a max-size bound. In a high-tenant deployment, long-lived non-expiring tokens could grow the cache without bound. Is there already a size cap, or should one be added? <gstack-qid:plan-eng-cache-unbounded>",
"options": [
"Verify existing cap exists and document it in the plan",
"Add explicit max-size eviction policy to AuthCache (recommended)",
"Defer, this is a scaling concern not a correctness one"
],
"answer": "Verify existing cap exists and document it in the plan"
},
{
"header": "TODO",
"question": "D8 — TODO candidate: Auth failure observability. The plan replaces silently-swallowed errors with typed errors, but adds no metrics, logs, or alerts for auth failure patterns. This gap won't surface until production incidents occur. Add a TODO? <gstack-qid:plan-eng-todo-observability>",
"options": [
"Add to TODOS.md (recommended)",
"Build it now in this PR instead of deferring",
"Skip — not valuable enough"
],
"answer": "Add to TODOS.md (recommended)"
}
];
function nativeScopeFingerprint(index = 0): AskUserQuestionFingerprint {
const row = captured[index];
return nativePlanCallFingerprint({
sessionId: 'captured-eng-retry', toolUseId: `call-${index}`, answered: true,
questions: [{ header: row.header, question: row.question, options: row.options.map(label => ({ label })) }],
answers: { [row.question]: row.answer },
}, index, true);
}
test('keeps captured scope-complexity setup and counts the six following findings', () => {
let started = false;
const phases = captured.map((_, index) => {
const question = nativeScopeFingerprint(index);
const phase = planCountQuestionPhase(question, started, engStep0Boundary);
started = phase.reviewStarted;
return phase;
});
expect(phases[0]).toEqual({ preReview: true, reviewStarted: true });
expect(phases.slice(1, 7).filter(phase => !phase.preReview)).toHaveLength(6);
// The later answered observability TODO retains the existing phase policy.
expect(phases.filter(phase => !phase.preReview)).toHaveLength(7);
});
test('requires an answered native scope decision with its opposed scope choices', () => {
const original = nativeScopeFingerprint();
expect(engStep0Boundary(original)).toBe(true);
expect(engStep0Boundary({ ...original, nativeCall: undefined })).toBe(false);
const pending = structuredClone(original);
pending.nativeCall!.answered = false;
delete pending.nativeCall!.answers;
expect(engStep0Boundary(pending)).toBe(false);
const unansweredScope = structuredClone(original);
unansweredScope.nativeCall!.answers = { 'Separate answered setup question': 'Continue' };
expect(engStep0Boundary(unansweredScope)).toBe(false);
for (const alter of [
(q: any) => { q.header = 'Architecture'; },
(q: any) => { q.question = q.question.replace('plan-eng-scope-complexity', 'plan-eng-cache-complexity'); },
(q: any) => { q.options = [{ label: 'Change cache size' }, { label: 'Keep cache size' }]; },
]) {
const unrelated = structuredClone(original);
alter(unrelated.nativeCall!.questions[0]);
unrelated.nativeCall!.answers = { [unrelated.nativeCall!.questions[0].question]: captured[0].answer };
expect(engStep0Boundary(unrelated)).toBe(false);
}
});
test('FIRES on cross-project learnings prompt', () => {
const f = fp('Enable cross-project learnings on this machine?', ['Yes', 'No']);
expect(engStep0Boundary(f)).toBe(true);
+79 -1
View File
@@ -177,6 +177,8 @@ try {
{ name: 'caller-policy', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'prerequisite', skipIndex: 2, custom: true },
{ name: 'late-mode', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'late-mode' },
{ name: 'batched-mode', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'batched-mode' },
{ name: 'damaged-submit', skillName: 'plan-devex-review', prompt: '# Native DX plan', mode: 'damaged-submit' },
{ name: 'damaged-menu', skillName: 'plan-ceo-review', prompt: '# Native CEO plan', mode: 'damaged-menu' },
].map((item) => ({ ...item, record: path.join(dir, `${item.name}.jsonl`) }));
// The fake snapshots its environment BEFORE installing the first stdin
@@ -206,6 +208,14 @@ const answer = () => native('user', [{ type: 'tool_result', tool_use_id: 'questi
{ toolUseResult: { answers: Object.fromEntries(nativeQuestions.map(q => [q.question, q.options[0].label])) } });
const questionMetadata = (header, question, labels) => ({ header, question, options: labels.map(label => ({ label })) });
const modeQuestion = questionMetadata('Review mode', 'How deep should this DX review go? <gstack-qid:plan-devex-review-mode>', ['DX EXPANSION', 'DX POLISH', 'DX TRIAGE']);
const submitQuestions = [
questionMetadata('Routing setup', 'Should gstack add skill routing rules? <gstack-qid:routing-injection>', ['Add routing rules', 'Skip']),
questionMetadata('Cross-project', 'Enable cross-project learnings?', ['Enable cross-project', 'Keep project-scoped']),
];
const submitPanel = (answered) => '\r← ☒ Routing setup ' + (answered ? '☒' : '☐') + ' Cross-project ✔ Submit →\r' +
'Review your answers\r' + (answered ? '' : '⚠You have not answere all questions\r') +
'│ ●D1 — Should gstack add skill routing rules?\r→Add routing rules\r' +
'Ready to submit your answers?\r❯1.Sbmi answers\r2Cancel\r';
const skillDir = path.join(process.env.CLAUDE_CONFIG_DIR, 'skills', process.env.FIXTURE_SKILL);
const planPath = path.join(process.cwd(), 'PLAN.md');
const contextPath = path.join(process.cwd(), 'CLAUDE.md');
@@ -239,6 +249,41 @@ process.stdin.on('data', (data) => {
const input = data.toString('utf8');
record({ type: 'input', data: input });
if (!firstInput) {
if (process.env.FIXTURE_MODE === 'damaged-menu') {
if (question === 0) record({ type: 'input-during-prose', input });
else if (input.includes('\r')) {
record({ type: 'damaged-menu-answer', input });
answer();
process.stdout.write('\nGSTACK REVIEW REPORT\n');
}
return;
}
if (process.env.FIXTURE_MODE === 'damaged-submit') {
if (question === 0 && input === '\x1b[Z') {
question = 1;
record({ type: 'returned-to-unanswered' });
process.stdout.write('\r← ☒ Routing setup ☐ Cross-project ✔ Submit →\r' +
'│ Enable cross-project learnings?\r❯1.Enable cross-project\r2.Keep project-scoped\r');
} else if (question === 1) {
selected += input.replace(/\r/g, '');
if (input.includes('\r')) {
question = 2;
record({ type: 'answered-remaining-tab', selected });
process.stdout.write(submitPanel(true));
}
} else if (question === 2 && input === '\r') {
// Native JSONL can arrive only after the whole packet is submitted.
ask(submitQuestions);
native('user', [{ type: 'tool_result', tool_use_id: 'question-' + callId, content: 'Your questions have been answered.' }],
{ toolUseResult: { answers: {
[submitQuestions[0].question]: submitQuestions[0].options[0].label,
[submitQuestions[1].question]: submitQuestions[1].options[Number(selected) - 1]?.label,
} } });
record({ type: 'submitted-both-answers' });
process.stdout.write('\nGSTACK REVIEW REPORT\n');
} else record({ type: 'unexpected-submit-input', question, input });
return;
}
if (['late-mode', 'batched-mode'].includes(process.env.FIXTURE_MODE)) {
selected += input.replace(/\r/g, '');
if (input.includes('\r')) {
@@ -294,6 +339,23 @@ process.stdin.on('data', (data) => {
}
firstInput = false;
if (process.env.FIXTURE_MODE === 'exit') process.exit(7);
if (process.env.FIXTURE_MODE === 'damaged-menu') {
process.stdout.write('☐Stripe event types\nWhich event should the handler accept?\n❯1.Specify one canonical event\n2.Accept all events\n' +
'·'.repeat(4200) + '\nMinimum required test cases:\n1.Happy path\n2.Email failure\n3.DB timeout\n4.Unknown event\n5.Unknown user\n❯1\n');
completion = setTimeout(() => {
question = 1;
ask([questionMetadata('Test scope', 'Should the plan include an integration test? <gstack-qid:plan-ceo-test-scope>',
['Unit tests only for now (recommended)', 'Unit tests + one integration test', 'Integration test only'])]);
process.stdout.write('\n☐Test scope\nShould the plan include an integration test? <gstack-qid:plan-ceo-test-scope>\n' +
'❯1.Unittestsonlyfornow(recommended)\n2Uni tsts + one integration test\n3.Integrationtestonly\n' +
'Enter to select · ↑/↓ to navigate · Esc to cancel\n');
}, 4100);
return;
}
if (process.env.FIXTURE_MODE === 'damaged-submit') {
process.stdout.write(submitPanel(false));
return;
}
if (['late-mode', 'batched-mode'].includes(process.env.FIXTURE_MODE)) {
if (process.env.FIXTURE_MODE === 'batched-mode') ask([modeQuestion,
questionMetadata('Separate decision', 'Which fixture target should be used?', ['First target', 'Second target'])]);
@@ -425,8 +487,9 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
expect(startup.sections).toBe(fs.readFileSync(path.join(ROOT, item.skillName, 'sections/review-sections.md'), 'utf8'));
expect(events.filter((event) => event.type === 'input').map((event) => event.data).join(''))
.toBe(`/${item.skillName}\r` + (item.mode === 'prerequisite' ? `${item.custom ? 1 : 2}\r${item.skipIndex}\r`
: item.mode === 'damaged-submit' ? '\x1b[Z2\r\r'
: item.mode === 'batched-finding' ? '2\r1\r' : item.mode === 'batched-mode' ? '1\r'
: ['direct-finding', 'failed-call', 'permission', 'late-mode'].includes(item.mode) ? '2\r' : ''));
: ['direct-finding', 'failed-call', 'permission', 'late-mode', 'damaged-menu'].includes(item.mode) ? '2\r' : ''));
expect(fs.existsSync(startup.cwd)).toBe(false);
expect(fs.existsSync(startup.stateRoot)).toBe(false);
expect(() => process.kill(startup.pid, 0)).toThrow();
@@ -447,6 +510,12 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
expect(result.observation.fingerprints).toHaveLength(1);
expect(result.observation.fingerprints[0].nativeCall.questions).toHaveLength(item.mode === 'batched-finding' ? 2 : 1);
}
if (item.mode === 'damaged-menu') {
expect(events.filter(event => event.type === 'input-during-prose')).toEqual([]);
expect(events.filter(event => event.type === 'damaged-menu-answer').map(event => event.input)).toEqual(['2\r']);
expect(result.observation.fingerprints).toHaveLength(1);
expect(result.observation.fingerprints[0].nativeCall.questions[0].header).toBe('Test scope');
}
if (item.mode === 'failed-call') {
expect(result.observation.transcript.calls[0].failure).toContain('is_error');
expect(result.observation.transcript.calls[0].answered).toBe(false);
@@ -477,6 +546,15 @@ await Bun.write(${JSON.stringify(resultPath)}, JSON.stringify({ results, onboard
expect(call.questions).toHaveLength(item.mode === 'late-mode' ? 1 : 2);
expect(call.unansweredQuestionIndices).toEqual(item.mode === 'late-mode' ? [] : [1]);
}
if (item.mode === 'damaged-submit') {
expect(events.filter(event => ['returned-to-unanswered', 'answered-remaining-tab', 'submitted-both-answers', 'unexpected-submit-input'].includes(event.type)))
.toEqual([{ type: 'returned-to-unanswered' }, { type: 'answered-remaining-tab', selected: '2' }, { type: 'submitted-both-answers' }]);
expect(result.observation.step0Count).toBe(1);
expect(result.observation.reviewCount).toBe(0);
expect(result.observation.transcript.calls).toHaveLength(1);
expect(result.observation.transcript.calls[0].unansweredQuestionIndices).toEqual([]);
expect(result.observation.transcript.calls[0].questions).toHaveLength(2);
}
cwds.add(startup.cwd);
stateRoots.add(startup.stateRoot);
}