import { describe, expect, test } from 'bun:test'; import { autoplanRoutingSetupInput, autoplanSetupDecision } from './helpers/autoplan-setup-question'; import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { pathToFileURL } from 'node:url'; const CLIPPED_ROUTING_N = fs.readFileSync(path.join(import.meta.dir, 'fixtures/autoplan-routing-n-screen.txt'), 'utf8'); describe('current routing title survives a scrolled native header before metadata flushes', () => { test('exact N frame selects the offered Add action once with a native digit only', () => { expect(CLIPPED_ROUTING_N).not.toMatch(/[☐□]/); const seen = new Set(); const decision = autoplanSetupDecision(CLIPPED_ROUTING_N, seen); expect(decision.kind).toBe('input'); if (decision.kind !== 'input') throw Error('Expected native setup input'); expect(decision.input).toBe('1'); expect(seen.size).toBe(0); for (const signature of decision.signatures) seen.add(signature); expect(autoplanSetupDecision(CLIPPED_ROUTING_N, seen).kind).toBe('waiting'); expect(E2E_TOUCHFILES['autoplan-chain-pty']).toContain('test/fixtures/autoplan-routing-n-screen.txt'); }); test('equivalent direct title and reordered opposed choices retain picker binding', () => { const frame = CLIPPED_ROUTING_N.replace('D1 — Add skill', 'D9 — Add gstack skill'); const swapped = frame.replace('1. Add routing rules (Recommended)', '1. Skip, invoke manually') .replace('2. Skip, invoke manually', '2. Add routing rules (Recommended)'); expect(autoplanSetupDecision(frame, new Set())).toMatchObject({kind:'input',input:'1'}); expect(autoplanSetupDecision(swapped, new Set())).toMatchObject({kind:'input',input:'2'}); }); test('copied, stale, incomplete, ambiguous and substantive panels cannot borrow the top routing identity', () => { for (const frame of [ 'Example panel:\n' + CLIPPED_ROUTING_N, 'Quoted source:\n' + CLIPPED_ROUTING_N, '```text\n' + CLIPPED_ROUTING_N + '\n```', '~~~~text\n' + CLIPPED_ROUTING_N, CLIPPED_ROUTING_N.split('\n').map(line => ' ' + line).join('\n'), CLIPPED_ROUTING_N.split('\n').map(line => '> ' + line).join('\n'), CLIPPED_ROUTING_N + '\n⏺ Continuing the review.', CLIPPED_ROUTING_N.replace('Esc to cancel', 'Esc to'), CLIPPED_ROUTING_N.replace('❯ 1.', ' 1.'), CLIPPED_ROUTING_N.replace('❯ 1.', ' 1.').replace(' 2.', '❯ 2.'), CLIPPED_ROUTING_N.replace(' 2.', '❯ 2.'), CLIPPED_ROUTING_N.replace('1. Add', '1. [ ] Add'), CLIPPED_ROUTING_N.replace('│\n│ Project', '│ ← ☐ Routing ✔ Submit →\n│ Project'), CLIPPED_ROUTING_N.replace(' 4. Chat about this', ''), CLIPPED_ROUTING_N.replace('2. Skip, invoke manually', '2. Add routing rules (Recommended)'), CLIPPED_ROUTING_N.replace('2. Skip, invoke manually', '2. Delete routing and migrate the product'), CLIPPED_ROUTING_N.replace('routing-injection>', 'product-routing>'), CLIPPED_ROUTING_N.replace('routing-injection>', 'routing-injection'), CLIPPED_ROUTING_N.replace('│ Project/branch:', '│ \n│ Project/branch:'), CLIPPED_ROUTING_N.replace('Add skill routing rules to CLAUDE.md?', 'Choose the product API router for CLAUDE.md?'), CLIPPED_ROUTING_N.replace('Add skill routing rules to CLAUDE.md?', 'The spec quotes Add skill routing rules to CLAUDE.md?'), CLIPPED_ROUTING_N.replace('Add skill routing rules to CLAUDE.md?', 'Add skill routing rules to README.md?'), CLIPPED_ROUTING_N.replace(' ', '').replace('│ Net:', '│ Net:'), CLIPPED_ROUTING_N.replace('│ ELI10:', '│ ```text\n│ ELI10:'), CLIPPED_ROUTING_N.replace('│ ELI10:', '│ > Quoted source:\n│ ELI10:'), ]) expect(autoplanSetupDecision(frame, new Set()).kind, frame).not.toBe('input'); }); test('present native metadata keeps its full existing identity binding', () => { const before = CLIPPED_ROUTING_N.split('❯ 1.')[0]!.replace(/^[│┃] ?/gm, '').trim(); const call: any = {toolUseId:'n-routing',sessionId:'n',timestamp:'2026-09-09T01:10:05Z',answered:false,failed:false, questions:[{header:'Routing',question:before,options:[{label:'Add routing rules (Recommended)'},{label:'Skip, invoke manually'}]}]}; expect(autoplanSetupDecision(CLIPPED_ROUTING_N,new Set(),call)).toMatchObject({kind:'input',input:'1'}); for (const mutate of [ (q:any) => {q.failed=true;}, (q:any) => {q.answered=true;}, (q:any) => {q.questions=[];}, (q:any) => {q.questions.push(structuredClone(q.questions[0]));}, (q:any) => {q.questions[0].multiSelect=true;}, (q:any) => {q.questions[0].question='Unrelated finding ';}, (q:any) => {q.questions[0].options[1].label='Another choice';}, ]) {const changed=structuredClone(call);mutate(changed);expect(autoplanSetupDecision(CLIPPED_ROUTING_N,new Set(),changed).kind).not.toBe('input');} const seen=new Set(); const early=autoplanSetupDecision(CLIPPED_ROUTING_N,seen); if(early.kind!=='input')throw Error('Expected initial input'); for(const signature of early.signatures)seen.add(signature); expect(autoplanSetupDecision(CLIPPED_ROUTING_N,seen,call).kind).toBe('waiting'); }); }); test.skipIf(process.platform === 'win32')('real PTY clipped routing advances from the exact current panel with one digit and no Enter', async () => { const dir=fs.mkdtempSync(path.join(os.tmpdir(),'gstack-clipped-routing-')); const fake=path.join(dir,'fake-claude');const events=path.join(dir,'events.jsonl'); fs.writeFileSync(fake,`#!${process.execPath}\n`+String.raw` import * as fs from 'node:fs'; const emit=value=>fs.appendFileSync(process.env.ROUTING_EVENTS,JSON.stringify(value)+'\n'); emit({kind:'started',pid:process.pid}); process.stdin.setRawMode?.(true);process.stdin.resume(); process.stdin.on('data',data=>{emit({kind:'input',data:data.toString()});process.stdout.write('\r\nNATIVE_SETUP_ACCEPTED\r\n');}); process.stdout.write(fs.readFileSync(process.env.ROUTING_SCREEN,'utf8').replace(/\n/g,'\r\n')); process.on('SIGINT',()=>process.exit(0)); `);fs.chmodSync(fake,0o755); const worker=path.join(dir,'worker.ts');const resultFile=path.join(dir,'result.json'); const helper=(name:string)=>pathToFileURL(path.resolve(import.meta.dir,'helpers',name)).href; fs.writeFileSync(worker,` import * as fs from 'node:fs'; import {launchClaudePty,resolveClaudeBinary} from ${JSON.stringify(helper('claude-pty-runner.ts'))}; import {autoplanSetupDecision} from ${JSON.stringify(helper('autoplan-setup-question.ts'))}; if(resolveClaudeBinary()!==${JSON.stringify(fake)})throw Error('Fake binary binding failed before launch'); const session=await launchClaudePty({cwd:${JSON.stringify(dir)},observeScreen:true,timeoutMs:15000, env:{ROUTING_EVENTS:process.env.ROUTING_EVENTS,ROUTING_SCREEN:process.env.ROUTING_SCREEN}}); try{ await session.waitFor('Enter to select',{timeoutMs:10000,pollMs:20}); const screen=await session.currentScreen(); const decision=autoplanSetupDecision(screen,new Set()); if(decision.kind!=='input'||decision.input!=='1')throw Error('Expected current setup: '+JSON.stringify(decision)); session.send(decision.input); await session.waitFor('NATIVE_SETUP_ACCEPTED',{timeoutMs:3000,pollMs:20}); fs.writeFileSync(${JSON.stringify(resultFile)},JSON.stringify({screen,decision})); }finally{await session.close();} `); const child=Bun.spawn([process.execPath,worker],{env:{...process.env,BROWSE_TERMINAL_BINARY:fake, ROUTING_EVENTS:events,ROUTING_SCREEN:path.join(import.meta.dir,'fixtures/autoplan-routing-n-screen.txt')},stdout:'pipe',stderr:'pipe'}); const killer=setTimeout(()=>child.kill('SIGKILL'),17000); try { const [exit,stdout,stderr]=await Promise.all([child.exited,new Response(child.stdout).text(),new Response(child.stderr).text()]); expect(exit,stdout+stderr).toBe(0); const result=JSON.parse(fs.readFileSync(resultFile,'utf8')); expect(result.screen).not.toMatch(/[☐□]/); expect(result.decision).toMatchObject({kind:'input',input:'1'}); const recorded=fs.readFileSync(events,'utf8').trim().split('\n').map(line=>JSON.parse(line)); expect(recorded.filter(e=>e.kind==='input')).toEqual([{kind:'input',data:'1'}]); expect(()=>process.kill(recorded[0].pid,0)).toThrow(); } finally { clearTimeout(killer);child.kill('SIGKILL'); if(fs.existsSync(events)){ const pid=JSON.parse(fs.readFileSync(events,'utf8').split('\n')[0]!).pid; if(process.platform==='linux')try{if(fs.readFileSync('/proc/'+pid+'/cmdline','utf8').split('\0').includes(fake))process.kill(pid,'SIGKILL');}catch{} } fs.rmSync(dir,{recursive:true,force:true}); } },20000); // Sanitized terminal frame from the 2026-09-08 autoplan timeout. The qid is // visibly incomplete; the prompt body and explicit choices remain intact. const CAPTURE = [ '─'.repeat(120), 'Planning: /tmp/hermetic/.claude/plans/modular-bouncing-swing.md', '─'.repeat(120), ' ☐ Routing rules', "│ gstack works best when your project's CLAUDE.md includes skill routing rules. Add them now?", '│', '❯1.Addroutingrules(Recommended)', 'CreatesCLAUDE.mdwithskillroutingrulessogstackknowswhentoinvoke/office-hours,/autoplan,/ship,/qa,', "etc.automatically.We'lldothisafterthereview.", '2.Nothanks', "Skip—I'llinvokeskillsmanually.Youcanenablethislaterbyrunninggstack-configsetrouting_declinedfalse.", '3.Typesomething.', '4.Chataboutthis', 'Entertoselect·↑/↓tonavigate·Esctocancel', ].join('\r\r'); // Targeted-a stalled on this complete menu for the full test budget. Parsing // retained its identity and choices; the setup helper rejected their wording. const CURRENT_CAPTURE = [ ' ☐ Routing rules', '', 'Add gstack skill routing rules to CLAUDE.md? ', '', '❯1.AddtoCLAUDE.md(recommended)', '', 'Appendsa##SkillroutingsectiontoCLAUDE.mdandcommitsit.Futuresessionswillauto-invoketherightskill', '(/investigateforbugs,/shipforPRs,/qafortesting,etc.)withoutmanualinvocation.', '', '2.Skip—invokemanually', '', "Nofilechanges.You'llcontinuecallingskillsbyname.Canaddroutingruleslater.", '', '3.Typesomething.', '─'.repeat(120), '4.Chataboutthis', 'Entertoselect·↑/↓tonavigate·Esctocancel', ].join('\n'); // Targeted-b's first attempt stayed on this complete setup menu until its // 15-minute deadline. The parser retained the prompt and both labels, but // the setup selector rejected "No thanks, invoke manually". const B_CAPTURE = [ ' ☐ CLAUDE.md', '', '│ D1 — Add gstack skill routing rules to CLAUDE.md? ', '│', '│ELI10:ThisprojecthasnoCLAUDE.md.Thatfileiswheregstacklooksforroutingrules—instructionstellingClaude', '│Codewhichskilltoauto-invokeforwhichrequest(e.g."ship→/ship","bugs→/investigate").Withoutityoutype', '│theskillnameeverytime.Withit,gstackcanrecognizeyourintentandrouteautomatically.', '│', '│Stakesifweskip:Noauto-routing;youinvokeskillsmanuallyeachsession.', '│', '│Recommendation:A—one-timesetup,saveskeystrokesoneveryfuturesession.', '│Completeness:A=9/10,B=5/10', '', '❯1.AddroutingrulestoCLAUDE.md(Recommended)', 'AppendsthestandardgstackroutingblocktoanewCLAUDE.mdandcommitsit.Doneonce,activeforever.', '2.Nothanks,invokemanually', 'SkipCLAUDE.mdsetup.Youcontinuecalling/autoplan,/ship,/qa,etc.bynameeachtime.', '3.Typesomething.', '─'.repeat(120), '4.Chataboutthis', 'Entertoselect·↑/↓tonavigate·Esctocancel', ].join('\r\r'); // Fresh broad retry: the complete setup menu uses a noun for the manual // alternative. This is the same opposed setup action as "invoke manually". const FRESH_RETRY_CAPTURE = [ '☐Routingsetup', "│gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Addthemnow?", '❯1.Addroutingrules(Recommended)', 'AppendskillroutingrulestoCLAUDE.mdsoClaudeautomaticallyinvokestherightskillforproduct,engineering,', 'design,andshipworkflows.Willbedoneafterplanapproval(planmodeisactivenow).', '2.Nothanks,manualinvocation', "Skip—I'llinvokeskillsmanually.Thispromptwon'tappearagain.", '3.Typesomething.', '─'.repeat(120), '4.Chataboutthis', 'Entertoselect·↑/↓tonavigate·Esctocancel', ].join('\n'); describe('autoplan routing setup handling', () => { // Source-F retry's first complete frame preceded damaged terminal redraws. const F_SETUP_CAPTURE = [ 'Planning: /tmp/hermetic/.claude/plans/deep-coalescing-valiant.md', '☐Skillrouting', "│gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Addthemnow?", '❯1.AddroutingrulestoCLAUDE.md', 'AppendsskillroutingrulestoCLAUDE.mdsogstackauto-invokestherightskillforcommonrequests(review,ship,', 'investigate,etc.).Willbecommittedtotherepo.(recommended)', '2.Nothanks,skip', "I'llinvokeskillsmanually.Youcanaddroutinglater.", '3.Typesomething.', '4.Chataboutthis', 'Entertoselect·↑/↓tonavigate·Esctocancel', ].join('\r\r'); test('answers the captured combined decline action once, regardless of option order', () => { const seen = new Set(); expect(autoplanRoutingSetupInput(F_SETUP_CAPTURE, seen)).toBe('1'); expect(autoplanRoutingSetupInput(F_SETUP_CAPTURE, seen)).toBeNull(); const reordered = F_SETUP_CAPTURE.replace('❯1.AddroutingrulestoCLAUDE.md', '❯1.Nothanks,skip') .replace('2.Nothanks,skip', '2.AddroutingrulestoCLAUDE.md'); expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2'); expect(autoplanRoutingSetupInput(F_SETUP_CAPTURE.replace('Nothanks,skip', 'No thanks, skip—invoke skills manually'), new Set())).toBe('1'); }); test('does not infer a routing answer from damaged, ambiguous, or unrelated setup choices', () => { for (const frame of [ F_SETUP_CAPTURE.replace('Addroutingrules', 'Addrutingrules'), F_SETUP_CAPTURE.replace('Nothanks,skip', 'Nothank,skip'), F_SETUP_CAPTURE.replace('Nothanks,skip', 'No thanks, skip the review'), F_SETUP_CAPTURE.replace('Nothanks,skip', 'No thanks, skip then delete CLAUDE.md'), F_SETUP_CAPTURE.replace('3.Typesomething.', '3.Skip'), F_SETUP_CAPTURE.replace("gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Addthemnow?", 'Which routing design should the application use?'), ]) expect(autoplanRoutingSetupInput(frame, new Set()), frame).toBeNull(); }); test('answers the fresh retry manual-invocation setup once in either option order', () => { const seen = new Set(); expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE, seen)).toBe('1'); expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE, seen)).toBeNull(); const reordered = FRESH_RETRY_CAPTURE.replace('❯1.Addroutingrules(Recommended)', '❯1.Nothanks,manualinvocation') .replace('2.Nothanks,manualinvocation', '2.Addroutingrules(Recommended)'); expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2'); }); test('requires opposed manual setup actions and rejects ambiguous or unrelated choices', () => { for (const decline of [ 'No thanks, delete the file manually', 'No thanks, manual data migration', 'No thanks, invoke the deploy manually', 'Manual deployment invocation', 'Accept recommendation', 'No thanks, manual invocation then delete CLAUDE.md', ]) { const frame = FRESH_RETRY_CAPTURE.replace('Nothanks,manualinvocation', decline); expect(autoplanRoutingSetupInput(frame, new Set()), decline).toBeNull(); } expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE.replace('3.Typesomething.', '3.Add routing rules'), new Set())).toBeNull(); expect(autoplanRoutingSetupInput(FRESH_RETRY_CAPTURE.replace('3.Typesomething.', '3.Skip—invoke manually'), new Set())).toBeNull(); const review = FRESH_RETRY_CAPTURE.replace( "gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Addthemnow?", 'Which product routing design should we ship? ', ); expect(autoplanRoutingSetupInput(review, new Set())).toBeNull(); }); test('answers the captured setup once, using the full question identity', () => { const seen = new Set(); expect(autoplanRoutingSetupInput(CAPTURE, seen)).toBe('1'); expect(autoplanRoutingSetupInput(CAPTURE, seen)).toBeNull(); expect(autoplanRoutingSetupInput(CAPTURE.replace('works best', 'works best'), seen)).toBeNull(); }); test('chooses Add routing rules by label when option order changes', () => { const reordered = CAPTURE.replace('❯1.Addroutingrules(Recommended)', '❯1.Nothanks') .replace('2.Nothanks', '2.Add routing rules (Recommended)'); expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2'); }); test('accepts the full option labels captured from the subsequent live setup prompt', () => { const fullLabels = CAPTURE.replace('Addroutingrules(Recommended)', 'Add routing rules to CLAUDE.md (Recommended)') .replace('2.Nothanks', "2.No thanks, I'll invoke skills manually"); expect(autoplanRoutingSetupInput(fullLabels, new Set())).toBe('1'); expect(autoplanRoutingSetupInput(fullLabels.replace('CLAUDE.md (Recommended)', 'product routes (Recommended)'), new Set())).toBeNull(); expect(autoplanRoutingSetupInput(fullLabels.replace("I'll invoke skills manually", 'delete the existing rules'), new Set())).toBeNull(); }); test('answers the current captured CLAUDE.md setup, including reordered choices, once', () => { const seen = new Set(); expect(autoplanRoutingSetupInput(CURRENT_CAPTURE, seen)).toBe('1'); expect(autoplanRoutingSetupInput(CURRENT_CAPTURE, seen)).toBeNull(); const reordered = CURRENT_CAPTURE.replace('❯1.AddtoCLAUDE.md(recommended)', '❯1.Skip—invokemanually') .replace('2.Skip—invokemanually', '2.AddtoCLAUDE.md(recommended)'); expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2'); expect(autoplanRoutingSetupInput(CURRENT_CAPTURE.replace('to CLAUDE.md?', "to this project's CLAUDE.md?"), new Set())).toBe('1'); }); test('the current wording still requires both explicit setup choices and the CLAUDE.md target', () => { for (const frame of [ CURRENT_CAPTURE.replace('to CLAUDE.md?', 'to the application API?'), CURRENT_CAPTURE.replace('AddtoCLAUDE.md(recommended)', 'Acceptrecommendation'), CURRENT_CAPTURE.replace('Skip—invokemanually', 'Deferthisfinding'), CURRENT_CAPTURE.replace('AddtoCLAUDE.md(recommended)', 'Deletetheexistingroutingrules'), CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md?', 'Should we expand the current feature?'), ]) expect(autoplanRoutingSetupInput(frame, new Set())).toBeNull(); }); test('recognizes the native A retry packet with its abbreviated manual-decline label', () => { const retry = CAPTURE.replace('Addroutingrules(Recommended)', 'Add to CLAUDE.md (Recommended)') .replace('2.Nothanks', '2.No thanks, manual'); expect(autoplanRoutingSetupInput(retry, new Set())).toBe('1'); expect(autoplanRoutingSetupInput(retry.replace('No thanks, manual', 'No thanks, delete it'), new Set())).toBeNull(); }); test('answers the exact B timeout menu by its routing label, in either order', () => { const seen = new Set(); expect(autoplanRoutingSetupInput(B_CAPTURE, seen)).toBe('1'); expect(autoplanRoutingSetupInput(B_CAPTURE, seen)).toBeNull(); const reordered = B_CAPTURE.replace('❯1.AddroutingrulestoCLAUDE.md(Recommended)', '❯1.Nothanks,invokemanually') .replace('2.Nothanks,invokemanually', '2.AddroutingrulestoCLAUDE.md(Recommended)'); expect(autoplanRoutingSetupInput(reordered, new Set())).toBe('2'); expect(autoplanRoutingSetupInput(B_CAPTURE.replace('Nothanks,invokemanually', 'Nothanks,deletethefilemanually'), new Set())).toBeNull(); expect(autoplanRoutingSetupInput(B_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md?', 'Which routing design should the application use?'), new Set())).toBeNull(); }); test('recognizes the setup premise without depending on its closing sentence', () => { const openings = [ "gstack works best when your project's CLAUDE.md includes skill routing rules. Would you like to add them?", "gstack works best when your project's CLAUDE.md includes skill routing rules. Enable them for this repository?", 'Should we configure skill routing rules for gstack in CLAUDE.md?', 'Set up gstack skill routing rules in CLAUDE.md.', ]; for (const opening of openings) { const frame = CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md? ', opening); expect(autoplanRoutingSetupInput(frame, new Set()), opening).toBe('1'); } }); test('recognizes an intact setup qid with an explicit CLAUDE.md action and opposed manual decline', () => { const frame = CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md?', 'Configure this project’s CLAUDE.md?'); expect(autoplanRoutingSetupInput(frame, new Set())).toBe('1'); expect(autoplanRoutingSetupInput(frame.replace('gstack-qid:routing-injection', 'gstack-qid:product-routing'), new Set())).toBeNull(); expect(autoplanRoutingSetupInput(frame.replace('AddtoCLAUDE.md(recommended)', 'Acceptrecommendation'), new Set())).toBeNull(); expect(autoplanRoutingSetupInput(frame.replace('Skip—invokemanually', 'Deferthisfinding'), new Set())).toBeNull(); }); test('keeps generic review, quoted premises and different routing targets out of setup handling', () => { for (const question of [ 'Which dashboard layout should we ship?', 'Add routing rules to the application API? ', 'The plan quotes gstack CLAUDE.md skill routing rules. Which API design should we use?', 'The document references gstack skill routing rules in CLAUDE.md. Should we expand the feature?', ]) { const frame = CURRENT_CAPTURE.replace('Add gstack skill routing rules to CLAUDE.md? ', question); expect(autoplanRoutingSetupInput(frame, new Set()), question).toBeNull(); } }); test('waits for complete recognized choices rather than guessing a default', () => { expect(autoplanRoutingSetupInput(CAPTURE.replace('2.Nothanks', '2.Ask me later'), new Set())).toBeNull(); expect(autoplanRoutingSetupInput(CAPTURE.replace('Addroutingrules(Recommended)', 'Accept recommendation'), new Set())).toBeNull(); expect(autoplanRoutingSetupInput('❯1.Addroutingrules(Recommended)\r2.Nothanks', new Set())).toBeNull(); }); test('never answers review or taste questions, even with a routing qid or the same choices', () => { const prompts = [ 'Which visual direction should this settings page use?', 'Should the payment handler bypass the existing dispatcher?', 'Add routing rules to the product API now? ', 'The plan quotes CLAUDE.md skill routing rules. Should we change this feature?', ]; for (const prompt of prompts) { const frame = `☐ Review decision\r${prompt}\r❯1.Addroutingrules(Recommended)\r2.Nothanks`; expect(autoplanRoutingSetupInput(frame, new Set())).toBeNull(); } }); test('setup helper and captured-frame changes select the autoplan eval only', () => { for (const file of ['test/helpers/autoplan-setup-question.ts', 'test/autoplan-setup-question.test.ts', 'test/fixtures/autoplan-routing-n-screen.txt']) { expect(selectTests([file], E2E_TOUCHFILES).selected).toEqual(['autoplan-chain-pty']); } }); }); // Source-G's retry remained at this actual captured menu until shard timeout. // The action is intact; cumulative ANSI stripping loses the courtesy's 'o'. // A real xterm replay retains it in the prior screen cell. const G_ROUTING_CAPTURE = [ '☐Routingrules', "│gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Wouldyouliketoaddthem?", '❯1.AddroutingrulestoCLAUDE.md', 'AppendsstandardskillroutingrulestoCLAUDE.md(creatingitifabsent)andcommits.Meansgstackskillslike', '/autoplan,/ship,/qaetc.getinvokedautomaticallywhenthetaskmatches.(recommended)', "2. N thanks, I'll invokeskillsmanually", 'Skiprouting setup. You can re-enable later by removing the routing_declined flag.', '3.Typesomething.', '4.Chataboutthis', 'Enter toselect · ↑/↓ to navigate · Esc to cancel', ].join('\r'); describe('autoplan routing action survives courtesy repaint', () => { test('selects the explicit Add action once in the captured G menu, in both orders', () => { const seen = new Set(); expect(autoplanRoutingSetupInput(G_ROUTING_CAPTURE, seen)).toBe('1'); expect(autoplanRoutingSetupInput(G_ROUTING_CAPTURE, seen)).toBeNull(); const reversed = G_ROUTING_CAPTURE.replace('❯1.AddroutingrulestoCLAUDE.md', "❯1.N thanks, I'll invokeskillsmanually") .replace("2. N thanks, I'll invokeskillsmanually", '2.AddroutingrulestoCLAUDE.md'); expect(autoplanRoutingSetupInput(reversed, new Set())).toBe('2'); }); test('the actual manual-invocation action needs no courtesy formula', () => { for (const action of ['Manual invocation', 'Invoke skills manually', "I'll invoke skills manually", 'Thanks, invoke manually']) { expect(autoplanRoutingSetupInput(G_ROUTING_CAPTURE.replace("N thanks, I'll invokeskillsmanually", action), new Set()), action).toBe('1'); } }); test('still requires exact opposed setup actions and a genuine routing premise', () => { for (const label of [ 'N thanks', 'Invoke the deployment manually', 'N thanks, manual data migration', 'Delete CLAUDE.md, invoke skills manually', 'No thanks, invoke skills manually then delete CLAUDE.md', 'Skip the review, invoke skills manually', 'Skip the review thanks, invoke skills manually', ]) expect(autoplanRoutingSetupInput(G_ROUTING_CAPTURE.replace("N thanks, I'll invokeskillsmanually", label), new Set()), label).toBeNull(); for (const frame of [ G_ROUTING_CAPTURE.replace("gstackworksbestwhenyourproject'sCLAUDE.mdincludesskillroutingrules.Wouldyouliketoaddthem?", 'Which application router should we implement?'), G_ROUTING_CAPTURE.replace('AddroutingrulestoCLAUDE.md', 'AddrutingrulestoCLAUDE.md'), G_ROUTING_CAPTURE.replace('3.Typesomething.', '3.Invoke skills manually'), G_ROUTING_CAPTURE.replace('3.Typesomething.', '3.Add routing rules'), ]) expect(autoplanRoutingSetupInput(frame, new Set()), frame).toBeNull(); }); }); const PREREQUISITE_CAPTURE = " ☐ Design doc\n\n│ No design doc found for this branch. /office-hours produces a structured problem statement, premise challenge, and\n│ explored alternatives — it gives this review much sharper input to work with. Takes about 10 minutes. The design doc\n│ is per-feature, not per-product — it captures the thinking behind this specific change. Run /office-hours first?\n\n❯ 1. Run /office-hours now\n Runs /office-hours to produce a design doc first, then picks up the full autoplan review right after. (~10 min)\n 2. Skip — proceed with standard review\n Skips /office-hours and runs the autoplan review pipeline now using the existing plan file as input.\n 3. Type something.\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 4. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to cancel\n"; const prerequisiteQuestion = { header: 'Design doc', question: "No design doc found for this branch. /office-hours produces a structured problem statement, premise challenge, and explored alternatives — it gives this review much sharper input to work with. Takes about 10 minutes. The design doc is per-feature, not per-product — it captures the thinking behind this specific change. Run /office-hours first?", options: [{ label: 'Run /office-hours now' }, { label: 'Skip — proceed with standard review' }], }; const prerequisiteCall = () => ({ sessionId: 'prerequisite-session', toolUseId: 'prerequisite-call', answered: false, failed: false, questions: [structuredClone(prerequisiteQuestion)], }); function prerequisiteMenu(reverse = false) { if (!reverse) return PREREQUISITE_CAPTURE; return PREREQUISITE_CAPTURE .replace('1. Run /office-hours now', '1. Skip — proceed with standard review') .replace('2. Skip — proceed with standard review', '2. Run /office-hours now'); } describe('autoplan optional design-doc prerequisite', () => { test('the exact K native screen declines the optional prerequisite by label', () => { for (const reverse of [false, true]) { const frame = prerequisiteMenu(reverse); expect(autoplanRoutingSetupInput(frame, new Set())).toBe(reverse ? '1' : '2'); const native = prerequisiteCall(); if (reverse) native.questions[0]!.options.reverse(); expect(autoplanRoutingSetupInput(frame, new Set(), native)).toBe(reverse ? '1' : '2'); } }); test('quoted panels and menus followed by new output are not active input', () => { for (const frame of [ 'Example panel:\n```text\n' + PREREQUISITE_CAPTURE + '\n```\n', 'Example panel:\n~~~text\n' + PREREQUISITE_CAPTURE, 'Example panel:\n' + PREREQUISITE_CAPTURE, PREREQUISITE_CAPTURE.split('\n').map(line => ' ' + line).join('\n'), 'The document quotes this panel:\n────────────────────\n' + PREREQUISITE_CAPTURE, PREREQUISITE_CAPTURE + '\n⏺ Continuing the review without office hours.\n', PREREQUISITE_CAPTURE + '\n❯ 1. A new menu\n 2. Another choice\n', ]) for (const native of [undefined, prerequisiteCall()]) { expect(autoplanRoutingSetupInput(frame, new Set(), native)).toBeNull(); } expect(autoplanRoutingSetupInput('```text\nearlier real code\n```\n────────────────────\n' + PREREQUISITE_CAPTURE, new Set())).toBe('2'); }); test('late native identity does not re-answer the retained menu', () => { const seen = new Set(); expect(autoplanRoutingSetupInput(PREREQUISITE_CAPTURE, seen)).toBe('2'); expect(autoplanRoutingSetupInput(PREREQUISITE_CAPTURE, seen, prerequisiteCall())).toBeNull(); expect(autoplanRoutingSetupInput(PREREQUISITE_CAPTURE, seen)).toBeNull(); }); test('unrelated, failed, mixed and checkbox native calls do not borrow the setup menu', () => { for (const mutate of [ (call: ReturnType) => { call.questions[0]!.question = 'Should we change the dashboard design?'; }, (call: ReturnType) => { call.failed = true; }, (call: ReturnType) => { call.answered = true; }, (call: ReturnType) => { call.questions.push({ header:'Finding', question:'Fix missing auth?', options:[{label:'Fix it'},{label:'Defer'}] }); }, (call: ReturnType) => { Object.assign(call.questions[0]!, {multiSelect:true}); }, ]) { const native = prerequisiteCall(); mutate(native); const seen = new Set(); expect(autoplanRoutingSetupInput(PREREQUISITE_CAPTURE, seen, native)).toBeNull(); // Waiting for correct metadata must not mark an unanswered UI as sent. expect(autoplanRoutingSetupInput(PREREQUISITE_CAPTURE, seen, prerequisiteCall())).toBe('2'); } }); test('arbitrary skip, outside offers, mixed actions and prose examples remain unanswered', () => { for (const frame of [ PREREQUISITE_CAPTURE.replace('Skip — proceed with standard review', 'Skip this security check'), PREREQUISITE_CAPTURE.replaceAll('/office-hours', '/codex'), PREREQUISITE_CAPTURE.replace('3. Type something.', '3. Fix the missing authorization check'), PREREQUISITE_CAPTURE.replace('No design doc found for this branch.', 'A dashboard design issue was found.'), PREREQUISITE_CAPTURE.replace(' ☐ Design doc', 'Example choices:').replace('Enter to select · ↑/↓ to navigate · Esc to cancel', ''), ]) expect(autoplanRoutingSetupInput(frame, new Set())).toBeNull(); }); }); test.skipIf(process.platform === 'win32')('real PTY prerequisite answer survives early and deferred native records without a second key', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-autoplan-prereq-')); const fake = path.join(dir, 'fake-claude'); const worker = path.join(dir, 'worker.ts'); const resultFile = path.join(dir, 'result.json'); const cases = [false, true].flatMap(early => [false, true].map(reverse => { const name = `${early ? 'early' : 'deferred'}-${reverse ? 'reversed' : 'original'}`; const q = structuredClone(prerequisiteQuestion); if (reverse) q.options.reverse(); return { name, early, cwd: path.join(dir, name), record: path.join(dir, name + '.jsonl'), question: q, frame: prerequisiteMenu(reverse), expected: reverse ? '1' : '2' }; })); for (const item of cases) fs.mkdirSync(item.cwd); fs.writeFileSync(fake, `#!${process.execPath}\n` + String.raw` import * as fs from 'node:fs'; import * as path from 'node:path'; const item = JSON.parse(process.env.PREREQUISITE_REPLAY); const record = event => fs.appendFileSync(item.record, JSON.stringify(event) + '\n'); record({type:'startup',pid:process.pid}); const folder = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', 'fixture'); fs.mkdirSync(folder, {recursive:true}); const transcript = path.join(folder, item.name + '.jsonl'); let logged = false; function writeCall() { if (logged) return; logged = true; fs.appendFileSync(transcript, JSON.stringify({type:'assistant',sessionId:item.name,isSidechain:false,cwd:process.cwd(),timestamp:new Date().toISOString(), message:{role:'assistant',content:[{type:'tool_use',id:'prerequisite',name:'AskUserQuestion',input:{questions:[item.question]}}]}})+'\n'); } if (item.early) writeCall(); process.stdin.setRawMode?.(true); let answered = false; process.stdin.on('data', data => { record({type:'input',data:data.toString()}); for (const key of data.toString()) if (/^[12]$/.test(key) && !answered) { answered = true; writeCall(); const label = item.question.options[Number(key)-1].label; fs.appendFileSync(transcript, JSON.stringify({type:'user',sessionId:item.name,isSidechain:false,cwd:process.cwd(),timestamp:new Date().toISOString(), toolUseResult:{answers:{[item.question.question]:label}}, message:{role:'user',content:[{type:'tool_result',tool_use_id:'prerequisite',content:'answered'}]}})+'\n'); process.stdout.write('\x1b[2J\x1b[H'+item.frame+'\nSETUP_ANSWERED\n'); } }); process.stdout.write('\x1b[2J\x1b[H'+item.frame); process.on('SIGINT', () => process.exit(0)); process.stdin.resume(); `); fs.chmodSync(fake, 0o755); const moduleUrl = (name: string) => pathToFileURL(path.resolve(import.meta.dir, 'helpers', name)).href; fs.writeFileSync(worker, ` import {launchClaudePty} from ${JSON.stringify(moduleUrl('claude-pty-runner.ts'))}; import {autoplanRoutingSetupInput} from ${JSON.stringify(moduleUrl('autoplan-setup-question.ts'))}; import {readPlanCountTranscript} from ${JSON.stringify(moduleUrl('plan-count-transcript.ts'))}; const results = await Promise.all(${JSON.stringify(cases)}.map(async item => { const session = await launchClaudePty({cwd:item.cwd,observeScreen:true,timeoutMs:20000,env:{PREREQUISITE_REPLAY:JSON.stringify(item)}}); try { await session.waitFor('Enter to select', {timeoutMs:10000,pollMs:20}); const screen = await session.currentScreen(); const before = readPlanCountTranscript(session.hermeticConfigDir,item.cwd); const pending = before.calls.find(call => !call.answered && !call.failed); if (Boolean(pending) !== item.early) throw Error('Wrong initial native persistence state'); const seen = new Set(); const input = autoplanRoutingSetupInput(screen,seen,pending); if (input !== item.expected) throw Error('Expected skip input '+item.expected+', got '+JSON.stringify(input)); session.send(input); await session.waitFor('SETUP_ANSWERED', {timeoutMs:10000,pollMs:20}); const after = readPlanCountTranscript(session.hermeticConfigDir,item.cwd); const call = after.calls[0]; if (after.calls.length !== 1 || !call.answered) throw Error('Native answer was not persisted'); const retained = await session.currentScreen(); return {name:item.name,input,answer:call.answers[item.question.question], redraw:autoplanRoutingSetupInput(retained,seen), delayedIdentity:autoplanRoutingSetupInput(screen,seen,{...call,answered:false})}; } finally {await session.close();} })); await Bun.write(${JSON.stringify(resultFile)},JSON.stringify(results)); `); const child = Bun.spawn([process.execPath, worker], { env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: '1' }, stdout: 'pipe', stderr: 'pipe', }); const killer = setTimeout(() => child.kill('SIGKILL'), 25000); try { const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); expect(exit, stdout + stderr).toBe(0); expect(JSON.parse(fs.readFileSync(resultFile, 'utf8'))).toEqual(cases.map(item => ({ name:item.name,input:item.expected,answer:'Skip — proceed with standard review',redraw:null,delayedIdentity:null, }))); for (const item of cases) { const events = fs.readFileSync(item.record, 'utf8').trim().split('\n').map(line => JSON.parse(line)); expect(events.filter(event => event.type === 'input').map(event => event.data).join('')).toBe(item.expected); expect(() => process.kill(events[0].pid, 0)).toThrow(); } } finally { clearTimeout(killer); child.kill('SIGKILL'); for (const item of cases) { if (!fs.existsSync(item.record)) continue; const first = JSON.parse(fs.readFileSync(item.record, 'utf8').split('\n')[0]!); try { process.kill(first.pid, 'SIGKILL'); } catch { /* already reaped */ } } fs.rmSync(dir, {recursive:true,force:true}); } }, 30000); // Exact current viewport from source-M's routing stall. Owned temporary paths // are retained as display text; no fixture path is accessed by this replay. const M_ROUTING_CAPTURE = "\n\n❯ /autoplan\n\n● Starting the autoplan pipeline — running the preamble first.\n\n● Bash(_SS=\"$HOME/.claude/skills/gstack/bin/gstack-skill-start\"\n [ -x \"$_SS\" ] || _SS=\".claude/skills/gstack/bin/gstack-skill-start\"…)\n ⎿  SKILL_START_PROTO: 1\n BRANCH: main\n PROACTIVE: true \n … +54 lines (ctrl+o to expand)\n ⎿  Allowed by auto mode classifier\n\n● The preamble ran. SESSION_KIND is interactive, SESSION_ID is 1144263-1788912944-701e8cc4. There's a one-time routing\n instruction to handle first.\n\n Let me check if CLAUDE.md exists and explore the repo before presenting the routing question.\n\n Read 1 file, listed 1 directory (ctrl+o to expand)\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\nPlanning:\n/tmp/gstack-paid-shard-2DwzUD/tmp/gstack-hermetic-1144068-Ep9FFb/with-skills/.claude/plans/scalable-bouncing-moth.md\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ☐ Skill routing\n\n│ gstack works best when your project's CLAUDE.md includes skill routing rules. Should I add them now?\n│ \n\n❯ 1. Add routing rules (Recommended)\n Append skill routing rules to CLAUDE.md and commit it — /autoplan, /ship, /qa, and other skills will be suggested\n automatically when relevant.\n 2. No thanks, manual only\n Skip for now; you can invoke skills manually anytime. You won't be asked again.\n 3. Type something.\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n 4. Chat about this\n\nEnter to select · ↑/↓ to navigate · Esc to cancel\n"; describe('M routing manual-only action grammar', () => { test('answers the exact native panel once and preserves the Add choice in either order', () => { const seen = new Set(); expect(autoplanRoutingSetupInput(M_ROUTING_CAPTURE, seen)).toBe('1'); expect(autoplanRoutingSetupInput(M_ROUTING_CAPTURE, seen)).toBeNull(); const reversed = M_ROUTING_CAPTURE .replace('❯ 1. Add routing rules (Recommended)', '❯ 1. No thanks, manual only') .replace(' 2. No thanks, manual only', ' 2. Add routing rules (Recommended)'); expect(autoplanRoutingSetupInput(reversed, new Set())).toBe('2'); }); test('equivalent manual actions use the same grammar with or without a courtesy prefix', () => { for (const label of [ 'No thanks, manual', 'No thanks, manual only', 'Skip — manual only', 'Manual', 'Manual only', 'Manual-only', 'Manual invocation', 'Manual invocation only', 'No thanks, manual invocation only', 'Invoke skills manually only', "No thanks, I'll invoke skills manually only", ]) expect(autoplanRoutingSetupInput(M_ROUTING_CAPTURE.replace('No thanks, manual only', label), new Set()), label).toBe('1'); }); test('manual modifiers do not admit extra actions, other workflows or ambiguous choices', () => { for (const label of [ 'No thanks, manual data migration only', 'Manual deployment only', 'No thanks, invoke the deployment manually only', 'No thanks, manual only then delete CLAUDE.md', 'No thanks, skip the review', 'No thanks, proceed with implementation', 'No thanks, manual invocation only after deleting the rules', 'Manual only approval', ]) expect(autoplanRoutingSetupInput(M_ROUTING_CAPTURE.replace('No thanks, manual only', label), new Set()), label).toBeNull(); for (const frame of [ M_ROUTING_CAPTURE.replace(' 3. Type something.', ' 3. Manual only'), M_ROUTING_CAPTURE.replace(' 3. Type something.', ' 3. Add routing rules'), M_ROUTING_CAPTURE.replace('Add routing rules (Recommended)', 'Add product routes (Recommended)'), M_ROUTING_CAPTURE.replace('Add routing rules (Recommended)', 'Add ruting rules (Recommended)'), M_ROUTING_CAPTURE.replace("gstack works best when your project's CLAUDE.md includes skill routing rules. Should I add them now?", 'Which application API routing design should we choose?'), M_ROUTING_CAPTURE.replace("gstack works best when your project's CLAUDE.md includes skill routing rules. Should I add them now?", 'The plan quotes gstack skill routing rules in CLAUDE.md. Should we expand the feature?'), ]) expect(autoplanRoutingSetupInput(frame, new Set()), frame).toBeNull(); }); }); const UNSUPPORTED_ROUTING = M_ROUTING_CAPTURE.replace('No thanks, manual only', 'Ask me after this review'); const unsupportedNative = () => ({ sessionId: 'unsupported-routing', toolUseId: 'routing-call', answered: false, failed: false, questions: [{ header: 'Skill routing', question: "gstack works best when your project's CLAUDE.md includes skill routing rules. Should I add them now? ", options: [{label:'Add routing rules (Recommended)'},{label:'Ask me after this review'}] }], }); describe('unsupported setup diagnostic state', () => { test('a complete recognized unsupported setup fails explicitly without selecting an action', () => { const seen = new Set(); for (const pending of [undefined, unsupportedNative()]) { const result = autoplanSetupDecision(UNSUPPORTED_ROUTING, seen, pending); expect(result.kind).toBe('unsupported_setup'); if (result.kind === 'unsupported_setup') { expect(result.setup).toBe('routing'); expect(result.options).toEqual([{index:1,label:'Add routing rules (Recommended)'},{index:2,label:'Ask me after this review'}]); expect(result.identitySource).toBe(pending ? 'native-bound' : 'current-native-panel'); } expect(seen.size).toBe(0); } expect(autoplanSetupDecision(PREREQUISITE_CAPTURE.replace('Skip — proceed with standard review', 'Ask me later'), new Set()).kind).toBe('unsupported_setup'); }); test('supported input is pure until sent; redraw and delayed metadata then wait', () => { const seen = new Set(); const decision = autoplanSetupDecision(M_ROUTING_CAPTURE, seen); expect(decision.kind).toBe('input'); expect(seen.size).toBe(0); if (decision.kind !== 'input') throw Error('Expected supported setup'); expect(decision.input).toBe('1'); for (const signature of decision.signatures) seen.add(signature); expect(autoplanSetupDecision(M_ROUTING_CAPTURE, seen).kind).toBe('waiting'); const native = unsupportedNative(); native.questions[0]!.options[1]!.label = 'No thanks, manual only'; expect(autoplanSetupDecision(M_ROUTING_CAPTURE, seen, native).kind).toBe('waiting'); expect(autoplanSetupDecision(M_ROUTING_CAPTURE + '\n⏺ Continuing…', seen).kind).toBe('waiting'); expect(autoplanSetupDecision(PREREQUISITE_CAPTURE, new Set()).kind).toBe('input'); }); test('a substantive product or taste question mentioning office hours is not an unsupported prerequisite', () => { const fullQuestion = prerequisiteQuestion.question; const unsupported = PREREQUISITE_CAPTURE.replace('Skip — proceed with standard review', 'Ask me after this review'); for (const [prompt, first, second] of [ ['No design doc exists for /office-hours integration. Should we build X or defer Y?', 'Build X', 'Defer Y'], ['We should produce a design doc for /office-hours. Which visual style should this product use?', 'Minimal', 'Expressive'], ['No design doc exists for /office-hours integration. Should we build X or defer Y?', 'Run /office-hours now', 'Defer Y'], ['No design doc found. Run /office-hours first?', 'Run /office-hours now and delete the feature', 'Ask me later'], ]) { const native = prerequisiteCall(); native.questions[0]!.question = prompt!; native.questions[0]!.options = [{label:first!},{label:second!}]; // Reconstruct from the actual full native layout, including footer. const frame = unsupported.replace(/│ No design doc[\s\S]*?Run \/office-hours first\?/, prompt!) .replace('1. Run /office-hours now', '1. ' + first) .replace('2. Ask me after this review', '2. ' + second); for (const pending of [undefined, native]) { expect(autoplanSetupDecision(frame, new Set(), pending).kind, prompt).toBe('unrelated'); } } // Existing unsupported offer remains positively identified independently // of the unsupported opposite label; no exact question wording is needed. const native = prerequisiteCall(); native.questions[0]!.question = fullQuestion.replace('Run /office-hours first?', 'Would you like to run /office-hours now?'); native.questions[0]!.options[1]!.label = 'Ask me after this review'; expect(autoplanSetupDecision(unsupported.replace('Run /office-hours first?', 'Would you like to run /office-hours now?'), new Set(), native).kind).toBe('unsupported_setup'); }); test('routing identity still needs its explicit setup action before an unsupported failure', () => { for (const [first, second] of [['React', 'Vue'], ['Accept recommendation', 'Defer finding'], ['Add routing rules (Recommended)', 'Add routing rules (Recommended)']]) { const frame = UNSUPPORTED_ROUTING.replace('1. Add routing rules (Recommended)', '1. ' + first) .replace('2. Ask me after this review', '2. ' + second); const native = unsupportedNative(); native.questions[0]!.options = [{label:first!},{label:second!}]; for (const pending of [undefined,native]) expect(autoplanSetupDecision(frame,new Set(),pending).kind).toBe('waiting'); } }); test('incomplete, stale, quoted, indented or mixed UI cannot establish unsupported setup', () => { const panel = UNSUPPORTED_ROUTING.slice(UNSUPPORTED_ROUTING.indexOf(' ☐ Skill routing')); for (const frame of [ panel.replace('Enter to select · ↑/↓ to navigate · Esc to cancel', ''), panel.replace(' 2. Ask me after this review', ''), panel.replace(' 4. Chat about this', ''), panel.replace('❯ 1.', ' 1.'), panel.replace(' 2.', '❯ 2.'), panel.replace('1. Add', '1. [ ] Add'), panel.replace(' ☐ Skill routing', '← ☐ Skill routing ✔ Submit →'), panel + '\n⏺ Continuing the review now.', panel + '\n❯ 1. Different menu\n 2. Other choice', 'Example panel:\n' + panel, 'Quoted source:\n' + panel, '```text\n' + panel, '~~~~text\n```\n' + panel, panel.split('\n').map(line => ' ' + line).join('\n'), panel.split('\n').map(line => '> ' + line).join('\n'), ]) expect(autoplanSetupDecision(frame, new Set()).kind, frame).not.toBe('unsupported_setup'); expect(autoplanSetupDecision('```text\nearlier code\n```\n' + panel, new Set()).kind).toBe('unsupported_setup'); const product = panel.replace("gstack works best when your project's CLAUDE.md includes skill routing rules. Should I add them now?", 'Which product API router should we use?'); expect(autoplanSetupDecision(product, new Set()).kind).toBe('unrelated'); }); test('mismatched, failed, answered, empty and multi-question metadata cannot diagnose this panel', () => { for (const mutate of [ (call: ReturnType) => { call.failed = true; }, (call: ReturnType) => { call.answered = true; }, (call: ReturnType) => { call.questions = []; }, (call: ReturnType) => { call.questions.push(structuredClone(call.questions[0]!)); }, (call: ReturnType) => { Object.assign(call.questions[0]!, {multiSelect:true}); }, (call: ReturnType) => { call.questions[0]!.header = 'Other question'; }, (call: ReturnType) => { call.questions[0]!.question = 'Different question '; }, (call: ReturnType) => { call.questions[0]!.options[1]!.label = 'Different choice'; }, (call: ReturnType) => { call.questions[0]!.options[1]!.label = 'No thanks, manual only'; }, ]) { const native = unsupportedNative(); mutate(native); expect(autoplanSetupDecision(UNSUPPORTED_ROUTING, new Set(), native).kind).not.toBe('unsupported_setup'); } }); test('a supported native question clipped by the actual viewport preserves its existing input policy', async () => { const {createPtyScreen} = await import('./helpers/pty-screen'); const {matchesNativePlanQuestion} = await import('./helpers/claude-pty-runner'); const native = unsupportedNative(); native.questions[0]!.question += '\n' + Array.from({length:41}, (_,i) => `Routing context line ${i+1}: keep current project conventions and existing commands.`).join('\n'); native.questions[0]!.options[1]!.label = 'No thanks, invoke manually'; const frame = `☐ Skill routing\n${native.questions[0]!.question}\n❯ 1. Add routing rules (Recommended)\n 2. No thanks, invoke manually\n 3. Type something.\n 4. Chat about this\nEnter to select · ↑/↓ to navigate · Esc to cancel`; const screen = await createPtyScreen(120,40); try { screen.write(frame.replace(/\n/g,'\r\n')); const visible = await screen.read(); expect(visible).not.toContain('☐ Skill routing'); expect(matchesNativePlanQuestion(visible,native)).toBe(true); const seen = new Set(); const decision = autoplanSetupDecision(visible,seen,native); expect(decision.kind).toBe('input'); if (decision.kind !== 'input') throw new Error('Expected supported native input'); expect(decision.input).toBe('1'); expect(seen.size).toBe(0); for (const signature of decision.signatures) seen.add(signature); expect(autoplanSetupDecision(visible,seen,native).kind).toBe('waiting'); } finally { await screen.dispose(); } }); }); test.skipIf(process.platform === 'win32')('real PTY unsupported setup fails after ready with zero input and durable parsed evidence', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-unsupported-setup-')); const fake = path.join(dir, 'fake-claude'); const worker = path.join(dir, 'worker.ts'); const resultFile = path.join(dir, 'result.json'); const cases = [false, true].map(early => ({ name: early ? 'early' : 'deferred', early, cwd: path.join(dir, early ? 'early' : 'deferred'), events: path.join(dir, early ? 'early.jsonl' : 'deferred.jsonl'), evalDir: path.join(dir, early ? 'early-artifacts' : 'deferred-artifacts'), frame: UNSUPPORTED_ROUTING, native: unsupportedNative(), })); for (const item of cases) fs.mkdirSync(item.cwd); fs.writeFileSync(fake, `#!${process.execPath}\n` + String.raw` import * as fs from 'node:fs'; import * as path from 'node:path'; const item=JSON.parse(process.env.SETUP_DIAGNOSTIC_CASE); const event=value=>fs.appendFileSync(item.events,JSON.stringify(value)+'\n'); event({kind:'startup',pid:process.pid}); if(item.early){ const folder=path.join(process.env.CLAUDE_CONFIG_DIR,'projects','fixture');fs.mkdirSync(folder,{recursive:true}); fs.writeFileSync(path.join(folder,item.name+'.jsonl'),JSON.stringify({type:'assistant',sessionId:item.name,isSidechain:false,cwd:process.cwd(),timestamp:new Date().toISOString(),message:{role:'assistant',content:[{type:'tool_use',id:'setup',name:'AskUserQuestion',input:{questions:item.native.questions}}]}})+'\n'); } process.stdin.setRawMode?.(true); process.stdin.on('data',data=>event({kind:'input',data:data.toString()})); process.stdout.write('\x1b[2J\x1b[H'+item.frame); process.on('SIGINT',()=>process.exit(0));process.stdin.resume(); `); fs.chmodSync(fake, 0o755); const url = (name: string) => pathToFileURL(path.resolve(import.meta.dir, 'helpers', name)).href; fs.writeFileSync(worker, ` import * as fs from 'node:fs'; import {launchClaudePty} from ${JSON.stringify(url('claude-pty-runner.ts'))}; import {autoplanSetupDecision,autoplanRoutingSetupInput} from ${JSON.stringify(url('autoplan-setup-question.ts'))}; import {readPlanCountTranscript} from ${JSON.stringify(url('plan-count-transcript.ts'))}; import {createPlanCountSnapshotWriter} from ${JSON.stringify(url('plan-count-artifacts.ts'))}; const results=[]; for(const item of ${JSON.stringify(cases)}){ const session=await launchClaudePty({cwd:item.cwd,observeScreen:true,timeoutMs:20000,env:{SETUP_DIAGNOSTIC_CASE:JSON.stringify(item)}}); const result={name:item.name,config:session.hermeticConfigDir}; try{ await session.waitFor('Enter to select',{timeoutMs:10000,pollMs:20}); const viewport=await session.currentScreen(); const native=readPlanCountTranscript(session.hermeticConfigDir,item.cwd); const pending=native.calls.find(call=>!call.answered&&!call.failed); if(Boolean(pending)!==item.early)throw Error('Readiness did not establish expected metadata state'); result.legacyInput=autoplanRoutingSetupInput(viewport,new Set(),pending); const decision=autoplanSetupDecision(viewport,new Set(),pending); if(decision.kind==='input')throw Error('Unexpected guessed input'); if(decision.kind!=='unsupported_setup')throw Error('Expected unsupported_setup, got '+decision.kind); const save=createPlanCountSnapshotWriter({EVALS_RUN_ID:item.name,GSTACK_EVAL_DIR:item.evalDir}); Object.assign(result,save({skillName:'autoplan',cwd:item.cwd,claudeConfigDir:session.hermeticConfigDir,raw:session.rawOutput(),visible:session.visibleText(),viewport, observation:{state:'unsupported_setup',unsupportedSetup:decision,native,retention:'UI and parsed metadata only; full parent JSONL not guaranteed.'}})); throw Error('UNSUPPORTED_SETUP_DIAGNOSTIC: '+decision.prompt); }catch(error){result.failed=true;result.error=String(error);} finally{await session.close();fs.rmSync(item.cwd,{recursive:true,force:true});} results.push(result); } await Bun.write(${JSON.stringify(resultFile)},JSON.stringify(results)); process.exitCode=results.some(result=>result.failed)?1:0; `); const child = Bun.spawn([process.execPath, worker], { env: { ...process.env, BROWSE_TERMINAL_BINARY: fake, EVALS_HERMETIC: '1' }, stdout: 'pipe', stderr: 'pipe', }); const killer = setTimeout(() => child.kill('SIGKILL'), 25000); try { const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]); expect(exit, stdout + stderr).toBe(1); const results = JSON.parse(fs.readFileSync(resultFile, 'utf8')); expect(results.length).toBe(2); for (const [index, result] of results.entries()) { const item = cases[index]!; expect(result.failed).toBe(true); expect(result.error).toContain('UNSUPPORTED_SETUP_DIAGNOSTIC:'); expect(result.legacyInput).toBeNull(); expect(result.artifactError).toBeUndefined(); const artifact = JSON.parse(fs.readFileSync(path.join(result.artifactDir, 'observation.json'), 'utf8')); expect(artifact.state).toBe('unsupported_setup'); expect(artifact.native.calls.length).toBe(item.early ? 1 : 0); expect(artifact.retention).toContain('full parent JSONL not guaranteed'); expect(fs.readFileSync(path.join(result.artifactDir, 'terminal.screen.log'), 'utf8')).toContain('Ask me after this review'); expect(fs.readFileSync(path.join(result.artifactDir, 'terminal.raw.log'), 'utf8')).toContain('routing-injection'); expect(fs.existsSync(item.cwd)).toBe(false); expect(fs.existsSync(result.config)).toBe(false); const events = fs.readFileSync(item.events, 'utf8').trim().split('\n').map(line => JSON.parse(line)); expect(events.filter(event => event.kind === 'input')).toEqual([]); expect(() => process.kill(events[0].pid, 0)).toThrow(); } } finally { clearTimeout(killer); child.kill('SIGKILL'); for (const item of cases) { if (!fs.existsSync(item.events)) continue; const pid = JSON.parse(fs.readFileSync(item.events, 'utf8').split('\n')[0]!).pid; if (process.platform === 'linux') { try { if (fs.readFileSync('/proc/' + pid + '/cmdline', 'utf8').split('\0').includes(fake)) process.kill(pid, 'SIGKILL'); } catch { /* already reaped or PID no longer belongs to this fixture */ } } } fs.rmSync(dir, {recursive:true,force:true}); } }, 30000);