From 5769be57a7887efde6a148c36209a5e7f46787f8 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 8 Sep 2026 16:49:27 +0000 Subject: [PATCH] fix: keep review input on the active native question --- test/helpers/claude-pty-runner.ts | 70 +++++- test/helpers/claude-pty-runner.unit.test.ts | 229 ++++++++++++++++++++ test/plan-count-fixture.test.ts | 80 ++++++- 3 files changed, 368 insertions(+), 11 deletions(-) diff --git a/test/helpers/claude-pty-runner.ts b/test/helpers/claude-pty-runner.ts index dbd9d9237..902be247f 100644 --- a/test/helpers/claude-pty-runner.ts +++ b/test/helpers/claude-pty-runner.ts @@ -1224,7 +1224,45 @@ export function capturePlanCountQuestion( seen: Set, 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(/]+>/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()) && + //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); diff --git a/test/helpers/claude-pty-runner.unit.test.ts b/test/helpers/claude-pty-runner.unit.test.ts index 8587c6bea..18eaf6145 100644 --- a/test/helpers/claude-pty-runner.unit.test.ts +++ b/test/helpers/claude-pty-runner.unit.test.ts @@ -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(); + 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?', + '❯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? ', + 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(); + 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(); + 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 ' }] }]) { + 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', () => { // 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?', + '→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? ", + "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? ", + "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? ", + "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? ", + "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? ", + "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? ", + "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? ", + "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? ", + "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); diff --git a/test/plan-count-fixture.test.ts b/test/plan-count-fixture.test.ts index ee003eb60..c11b6c62b 100644 --- a/test/plan-count-fixture.test.ts +++ b/test/plan-count-fixture.test.ts @@ -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? ', ['DX EXPANSION', 'DX POLISH', 'DX TRIAGE']); +const submitQuestions = [ + questionMetadata('Routing setup', 'Should gstack add skill routing rules? ', ['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? ', + ['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? \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); }