Files
gstack/test/helpers/claude-pty-runner.classify.unit.test.ts
garrytan 1c8a2cba56 test(pty): split claude-pty-runner.unit.test.ts along the pty/ module seams
The 188 unit tests move verbatim into claude-pty-runner.{screen,classify,
auq,launch,plan-native,boundaries}.unit.test.ts (test names unchanged; each
file imports only what it uses from the barrel). The five files that no longer
read a SKILL.md template join the test-of-test ratchet baseline with a reason.
2026-09-30 06:23:16 +00:00

803 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Deterministic unit tests for the frame classifiers (test/helpers/pty/classify.ts).
* Split along the W4 module seams from the former claude-pty-runner.unit.test.ts;
* tests import the public barrel, test/helpers/claude-pty-runner.ts.
*/
import { describe, test, expect } from 'bun:test';
import {
isNumberedOptionListVisible,
isProseAUQVisible,
isScopeGateQuestionVisible,
isScopeGateAutoSelectVisible,
isPlanReadyVisible,
parseNumberedOptions,
classifyVisible,
TAIL_SCAN_BYTES,
optionsSignature,
stripAnsi,
COMPLETION_SUMMARY_RE,
classifyPlanCountFrame,
capturePlanCountQuestion,
planCountSubmissionInput,
} from './claude-pty-runner';
describe('scope-gate render detectors', () => {
// The verbatim announcement string from the plan-eng/plan-design SKILL.md
// templates. If the template rewording drifts, THIS fixture fails first —
// before the paid plan-mode smokes silently degrade to vacuous asserts.
const TEMPLATE_ANNOUNCEMENT =
'Scope gate: plan mode — auto-selected B (reviewing <target>).';
describe('isScopeGateQuestionVisible', () => {
test('matches the clean prose gate render (question + option bodies)', () => {
const sample = `
What should I review?
A) The current branch diff — the work in progress on this branch.
B) A plan or design doc I'll paste or point you to.
C) A specific file, directory, or path.
Recommendation: A when a branch diff exists, otherwise B.
`;
expect(isScopeGateQuestionVisible(sample)).toBe(true);
});
test('matches the native numbered render (no lettered markers)', () => {
const sample = `
What should I review?
❯ 1. The current branch diff — the work in progress on this branch.
2. A plan or design doc I'll paste or point you to.
3. A specific file, directory, or path.
`;
expect(isScopeGateQuestionVisible(sample)).toBe(true);
});
test('matches the PTY-collapsed render (stripAnsi squished spaces)', () => {
const sample = 'WhatshouldIreview?A)Thecurrentbranchdiff—theworkinprogress';
expect(isScopeGateQuestionVisible(sample)).toBe(true);
});
test('stays false on narration quoting only the question', () => {
const sample =
"Normally I'd ask 'What should I review?' but plan mode is active, so I'm proceeding.";
expect(isScopeGateQuestionVisible(sample)).toBe(false);
});
test('stays false on unrelated review prose', () => {
const sample = 'I will review the current branch diff and report findings.';
expect(isScopeGateQuestionVisible(sample)).toBe(false);
});
});
describe('isScopeGateAutoSelectVisible', () => {
test('matches the verbatim template announcement', () => {
expect(isScopeGateAutoSelectVisible(TEMPLATE_ANNOUNCEMENT)).toBe(true);
});
test('matches a real announcement with a concrete target', () => {
const sample =
'Scope gate: plan mode — auto-selected B (reviewing ~/.claude/plans/my-feature.md). Running the Design Doc Check next.';
expect(isScopeGateAutoSelectVisible(sample)).toBe(true);
});
test('matches the PTY-collapsed announcement', () => {
const sample = 'Scopegate:planmode—auto-selectedB(reviewingPLAN.md).';
expect(isScopeGateAutoSelectVisible(sample)).toBe(true);
});
test('stays false on narration about the behavior', () => {
const sample = "In plan mode I'd auto-select B and review the active plan.";
expect(isScopeGateAutoSelectVisible(sample)).toBe(false);
});
test('stays false on a VERBATIM QUOTE of the announcement (negation narration)', () => {
// The exact announcement line sits quoted in the skill context, so a
// model explaining why it is NOT firing it can reproduce it byte-exact
// inside quotes — that must not trip a must-stay-false assert.
const sample =
'Not in plan mode, so I won\'t announce "Scope gate: plan mode — auto-selected B (reviewing <target>)." and will ask instead.';
expect(isScopeGateAutoSelectVisible(sample)).toBe(false);
});
test('a later real render still matches after an earlier quoted mention', () => {
const sample =
'Earlier I said I would render "Scope gate: plan mode — auto-selected B (…)" and now:\n' +
'Scope gate: plan mode — auto-selected B (reviewing PLAN.md).';
expect(isScopeGateAutoSelectVisible(sample)).toBe(true);
});
test('matches tense paraphrases WITH the announcement prefix (auto-selecting / auto-selects)', () => {
expect(
isScopeGateAutoSelectVisible('Scope gate: plan mode — auto-selecting B (reviewing the drafted plan).'),
).toBe(true);
expect(isScopeGateAutoSelectVisible('Scope gate: plan mode — auto-selects B.')).toBe(true);
});
test('stays false on tense paraphrases WITHOUT the announcement prefix', () => {
expect(isScopeGateAutoSelectVisible('Auto-selecting B since we are in plan mode.')).toBe(false);
});
test('stays false on AUTO_DECIDE preamble output', () => {
const sample = 'Auto-decided scope question → B (your preference). Change with /plan-tune.';
expect(isScopeGateAutoSelectVisible(sample)).toBe(false);
});
test('stays false on a bare "selected B" without the announcement prefix', () => {
const sample = 'I selected B as the review target.';
expect(isScopeGateAutoSelectVisible(sample)).toBe(false);
});
});
});
describe('isProseAUQVisible', () => {
test('matches 4 lettered options A) B) C) D) at line starts (plan-eng prose AUQ shape)', () => {
const sample = `
What would you like me to review? Options:
A) Point me at an existing design doc or plan file (path).
B) Describe new work you're planning — I'll explore the codebase.
C) You meant /review for the diff already on this branch.
D) Something else (tell me).
Recommendation: A if you have a doc in mind, otherwise B.
❯
`;
expect(isProseAUQVisible(sample)).toBe(true);
});
test('matches 2 lettered options (minimum threshold)', () => {
const sample = `
A) First option
B) Second option
`;
expect(isProseAUQVisible(sample)).toBe(true);
});
test('matches 3 numbered options 1. 2. 3. without ❯ 1. cursor (autoplan prose AUQ shape)', () => {
const sample = `
What's the task? A few options:
1. You have a plan idea in mind — describe it.
2. You want to review an existing plan elsewhere.
3. You meant a different command — /plan-ceo-review etc.
❯
`;
expect(isProseAUQVisible(sample)).toBe(true);
});
test('returns false when ❯ 1. cursor is present in the recent tail (native UI handled by isNumberedOptionListVisible)', () => {
const sample = `
❯ 1. First option
2. Second option
3. Third option
`;
expect(isProseAUQVisible(sample)).toBe(false);
});
test('does NOT suppress numbered-prose detection when ❯ 1. is only in early scrollback (trust dialog)', () => {
// Boot trust dialog rendered ❯ 1. Yes at startup, then a long body of
// model output, then prose-rendered numbered options now. The historic
// ❯ 1. is in the full buffer but NOT in the recent tail. Should detect
// the prose AUQ.
const trustHeader = '❯ 1. Yes, trust\n 2. No\n';
const filler = 'x'.repeat(5000); // pushes trust dialog out of last 4KB tail
const proseAUQ = `\n 1. Review the docs\n 2. Investigate the code\n 3. Defer to next session\n❯ \n`;
const sample = trustHeader + filler + proseAUQ;
expect(isProseAUQVisible(sample)).toBe(true);
});
test('returns false on single lettered option', () => {
const sample = `
A) Only one option mentioned in passing.
`;
expect(isProseAUQVisible(sample)).toBe(false);
});
test('matches 2 numbered options (threshold matches lettered branch — tails miss option 1)', () => {
const sample = `
1. First note.
2. Second note.
`;
expect(isProseAUQVisible(sample)).toBe(true);
});
test('returns false on a single numbered option', () => {
const sample = `
1. Only one option mentioned.
`;
expect(isProseAUQVisible(sample)).toBe(false);
});
test('does not match mid-prose lettered text like "(see option B) above"', () => {
const sample = `
This refers to (see option B) above and also to point A) earlier.
`;
// The B) and A) markers are mid-line, not at line starts, so they don't count.
expect(isProseAUQVisible(sample)).toBe(false);
});
test('matches with leading whitespace and ❯ prefix on options', () => {
const sample = `
A) Option with whitespace prefix
❯ B) Option with cursor prefix
C) Another option
`;
expect(isProseAUQVisible(sample)).toBe(true);
});
test('returns false on plain text with no option markers', () => {
expect(isProseAUQVisible('Just some plain text output from the model.')).toBe(false);
expect(isProseAUQVisible('')).toBe(false);
});
// Pattern 3: markdown bold-bullet options — office-hours renders its mode
// question this way under --disallowedTools, with no letter/number marker.
test('matches office-hours markdown bold-bullet mode question (Pattern 3)', () => {
const sample = `
> Before we dig in — what's your goal with this?
>
> - **Building a startup** (or thinking about it)
> - **Intrapreneurship** — internal project at a company, need to ship fast
> - **Hackathon / demo** — time-boxed, need to impress
> - **Open source / research** — building for a community
> - **Learning** — teaching yourself to code
❯
`;
expect(isProseAUQVisible(sample)).toBe(true);
});
test('bold-bullets require a preceding interrogative — no "?" => false', () => {
// 3+ bold bullets but no question stem: this is a feature list, not an AUQ.
const sample = `
Here is what shipped:
- **Faster builds** via caching
- **Smaller binaries** through tree-shaking
- **Better errors** with source maps
`;
expect(isProseAUQVisible(sample)).toBe(false);
});
test('a question with fewer than 3 bold bullets stays false (guard)', () => {
const sample = `
Which approach do you prefer?
- **Option one** is simpler
- **Option two** is faster
`;
expect(isProseAUQVisible(sample)).toBe(false);
});
test('plain (non-bold) bullets after a question do not trigger Pattern 3', () => {
// Only bold bullets count — plain "- text" prose lists are too common.
const sample = `
What should we do about this?
- run the tests
- ship the fix
- file a follow-up
`;
expect(isProseAUQVisible(sample)).toBe(false);
});
test('Pattern 3 still defers to a live native cursor list (❯ 1.)', () => {
const sample = `
> What's your goal?
❯ 1. **Building a startup**
2. **Intrapreneurship**
3. **Hackathon**
`;
// The ❯1. cursor gate fires first — native list handling owns this.
expect(isProseAUQVisible(sample)).toBe(false);
});
// Pattern 4/5: collapsed-form prose AUQ. stripAnsi destroys the newlines +
// inter-word spaces, so a real prose AUQ arrives collapsed and defeats the
// line-anchored Patterns 1-3. These are the dominant Shape-B render mode in
// the plan-design smoke + floor timeouts — verbatim de-spinnered bytes from
// the real failing runs (bdm3sucql.output).
test('matches the real collapsed floor render (colon-delimited, Pattern 4/5)', () => {
const sample =
'The review is blocked on D1—reply withA, B, r Cabovetocontinue:' +
'- A(recommended): Spec thefull P1AskUserQuestioncopy in this review' +
'-B:LeaveP1copytotheimplementerwithstructuralrequirements' +
'C: Add a placeholder template to the plan';
expect(isProseAUQVisible(sample)).toBe(true);
});
test('matches the real collapsed plan-mode render (Recommendation + collapsed A)/B), Pattern 4/5)', () => {
const sample =
'Recommendation:A—writethecopynow.(recommended)A) Writ the fullcopy in thisdesign review— now.' +
'(recommended) Completeness:10/10 B) Leveit to theimplemente — task spec is enough.' +
'Reply withA (write the copy now)orB(leavetoimplementer)';
expect(isProseAUQVisible(sample)).toBe(true);
});
test('collapsed-form requires BOTH signals — single B) + word "recommendation" stays false', () => {
// Only one punctuated letter marker: the two-signal contract is not met.
const sample =
'We should consider option B) here. My recommendation is to do it now.';
expect(isProseAUQVisible(sample)).toBe(false);
});
test('collapsed-form requires letter punctuation — comma-only "ReplywithA,B,orC" stays false', () => {
// Reply-instruction present, but the letters carry no ) : or ( punctuation,
// so they could be incidental enumerations in running prose. Stays false.
const sample = 'ReplywithA,B,orC';
expect(isProseAUQVisible(sample)).toBe(false);
});
test('collapsed-form does not regress the existing FP guard (see option B) ... point A))', () => {
// The classic citation FP: a model referencing prior options in prose.
// No reply-instruction / recommendation marker on its own line, so the
// collapsed-form signal does not fire either.
const sample =
'As noted (see option B) above, and the earlier point A) we discussed, this is fine.';
expect(isProseAUQVisible(sample)).toBe(false);
});
});
describe('classifyVisible (runtime path through the runner classifier)', () => {
// These tests call the actual classifier so a future contributor who
// reorders branches (e.g. moves the permission short-circuit before
// isPlanReadyVisible) is caught deterministically.
test('skill question → returns asked', () => {
const visible = `
D1 — Choose your scope mode
❯ 1. HOLD SCOPE
2. SCOPE EXPANSION
3. SELECTIVE EXPANSION
4. SCOPE REDUCTION
`;
const result = classifyVisible(visible);
expect(result?.outcome).toBe('asked');
});
test('permission dialog (Bash) → returns null (skip, keep polling)', () => {
const visible = `
Bash command \`gstack-update-check\` requires permission to run.
❯ 1. Yes
2. No
`;
expect(isNumberedOptionListVisible(visible)).toBe(true); // pre-filter
expect(classifyVisible(visible)).toBeNull(); // post-filter
});
test('plan-ready confirmation → returns plan_ready (wins over asked)', () => {
const visible = `
Ready to execute the plan?
❯ 1. Yes, proceed
2. No, keep planning
`;
const result = classifyVisible(visible);
expect(result?.outcome).toBe('plan_ready');
});
test('silent write to unsanctioned path → returns silent_write', () => {
const visible = `
⏺ Write(src/app/dangerous-write.ts)
⎿ Wrote 42 lines
`;
const result = classifyVisible(visible);
expect(result?.outcome).toBe('silent_write');
expect(result?.summary).toContain('src/app/dangerous-write.ts');
});
test('write to sanctioned path (.claude/plans) → returns null (allowed)', () => {
const visible = `
⏺ Write(/Users/me/.claude/plans/some-plan.md)
⎿ Wrote 42 lines
`;
expect(classifyVisible(visible)).toBeNull();
});
test('write while a permission dialog is on screen → returns null (gated, not silent, not asked)', () => {
const visible = `
⏺ Write(src/app/edit-with-permission.ts)
Edit to src/app/edit-with-permission.ts
Do you want to proceed?
❯ 1. Yes
2. No
`;
// The numbered prompt is a permission dialog (Edit to + Do you want to proceed?);
// silent_write is suppressed because a numbered prompt is visible, AND
// 'asked' is suppressed because the prompt is a permission dialog.
expect(classifyVisible(visible)).toBeNull();
});
test('write while a real skill question is on screen → returns asked (write is captured but not silent)', () => {
const visible = `
⏺ Write(src/app/foo.ts)
D1 — Choose your scope mode
❯ 1. HOLD SCOPE
2. SCOPE EXPANSION
`;
// The numbered prompt is a skill question, not a permission dialog;
// silent_write is suppressed (numbered prompt is visible) and the
// outcome is 'asked' — Step 0 fired.
const result = classifyVisible(visible);
expect(result?.outcome).toBe('asked');
});
test('idle / no signals → returns null', () => {
const visible = `
Some prose without any classifier signals.
`;
expect(classifyVisible(visible)).toBeNull();
});
test('TAIL_SCAN_BYTES is exported as 1500', () => {
// Shared between runner and routing test; a regression that desyncs the
// recent-tail window would surface here.
expect(TAIL_SCAN_BYTES).toBe(1500);
});
// D4-B: strictPlanWrites detector. Catches the transcript bug where the
// model writes findings to the plan file before any AskUserQuestion fires.
test('strictPlanWrites: plan write before any AUQ → wrote_findings_before_asking', () => {
const visible = `
⏺ Edit(/Users/me/.claude/plans/some-plan.md)
⎿ Updated 12 lines
`;
const result = classifyVisible(visible, { strictPlanWrites: true });
expect(result?.outcome).toBe('wrote_findings_before_asking');
expect(result?.summary).toContain('.claude/plans/some-plan.md');
});
test('strictPlanWrites: plan write AFTER an AUQ render → not flagged', () => {
// AUQ renders first, then the model writes the plan post-answer. This is
// the legitimate end-of-workflow flow and must NOT trigger the detector.
const visible = `
D1 — Some scope question
❯ 1. Option A
2. Option B
⏺ Edit(/Users/me/.claude/plans/some-plan.md)
⎿ Updated 12 lines
`;
const result = classifyVisible(visible, { strictPlanWrites: true });
// Outcome is 'asked' (the numbered list rendered); the post-AUQ plan
// write is ignored by the detector.
expect(result?.outcome).toBe('asked');
});
test('strictPlanWrites: AUQ first then plan write — write_pos > auq_pos → not flagged', () => {
// Same scenario, more explicit ordering: the regex finds the write at a
// position AFTER the numbered list. Detector lets it through.
const visible = [
'D1 — Choose your approach',
'',
'❯ 1. Approach A',
' 2. Approach B',
'',
'⏺ Write(/Users/me/.claude/plans/draft.md)',
'⎿ Wrote 42 lines',
].join('\n');
const result = classifyVisible(visible, { strictPlanWrites: true });
expect(result?.outcome).toBe('asked');
});
test('strictPlanWrites: only a permission dialog visible → plan write still flagged', () => {
// A permission dialog ❯ 1./2. is NOT an AUQ; pre-AUQ plan writes still
// hit the detector even when a permission prompt is on screen.
const visible = `
⏺ Edit(/Users/me/.claude/plans/some-plan.md)
Edit to /Users/me/.claude/plans/some-plan.md
Do you want to proceed?
❯ 1. Yes
2. No
`;
const result = classifyVisible(visible, { strictPlanWrites: true });
expect(result?.outcome).toBe('wrote_findings_before_asking');
});
test('strictPlanWrites OFF: plan write before AUQ → returns null (legacy behavior preserved)', () => {
const visible = `
⏺ Edit(/Users/me/.claude/plans/some-plan.md)
⎿ Updated 12 lines
`;
// Without strictPlanWrites, the sanctioned-path list lets this through.
expect(classifyVisible(visible)).toBeNull();
});
});
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
2. SCOPE EXPANSION
`;
const opts = parseNumberedOptions(visible);
expect(opts).toHaveLength(2);
expect(opts[0]).toEqual({ index: 1, label: 'HOLD SCOPE' });
expect(opts[1]).toEqual({ index: 2, label: 'SCOPE EXPANSION' });
});
test('returns empty array on prose-with-numbers (no cursor)', () => {
expect(parseNumberedOptions('text 1. one 2. two')).toEqual([]);
});
test('extracts options when the cursor is INLINE with prompt header (box-layout)', () => {
// Real /plan-ceo-review rendering: the TTY's cursor-positioning escapes
// collapse divider + header + prompt + cursor onto one logical line.
// Subsequent options (2..7) still start their own lines.
const visible = [
'────────────────────────────────────────',
'☐ Review scope What scope do you want me to CEO-review? ❯ 1. The branch\'s diff vs main',
' Review the full branch: ~10K LOC.',
'2. A specific plan file or design doc',
' You point me at a file (path) and I review that.',
'3. An idea you\'ll describe inline',
'4. Cancel — wrong skill',
'5. Type something.',
'────────────────────────────────────────',
'6. Chat about this',
'7. Skip interview and plan immediately',
].join('\n');
const opts = parseNumberedOptions(visible);
expect(opts).toHaveLength(7);
expect(opts[0]).toEqual({ index: 1, label: "The branch's diff vs main" });
expect(opts[1]?.index).toBe(2);
expect(opts[6]?.index).toBe(7);
expect(opts[6]?.label).toBe('Skip interview and plan immediately');
});
test('inline-cursor and start-of-line cursor both produce 7 options for the box-layout case', () => {
// The inline path captures option 1 from the cursor line itself; the
// subsequent-lines path captures 2..7 with the existing optionRe.
const inlineLayout = [
'header text ❯ 1. first option',
'2. second',
'3. third',
].join('\n');
expect(parseNumberedOptions(inlineLayout)).toEqual([
{ index: 1, label: 'first option' },
{ index: 2, label: 'second' },
{ index: 3, label: 'third' },
]);
const cleanLayout = [
' ❯ 1. first option',
' 2. second',
' 3. third',
].join('\n');
expect(parseNumberedOptions(cleanLayout)).toEqual([
{ index: 1, label: 'first option' },
{ index: 2, label: 'second' },
{ index: 3, label: 'third' },
]);
});
});
// ────────────────────────────────────────────────────────────────────────────
// Per-finding count primitives — Section 3 unit tests #1–#5, #7, #12.
// ────────────────────────────────────────────────────────────────────────────
describe('optionsSignature', () => {
test('returns a "|"-joined `index:label` string for a clean list', () => {
const sig = optionsSignature([
{ index: 1, label: 'HOLD SCOPE' },
{ index: 2, label: 'SCOPE EXPANSION' },
]);
expect(sig).toBe('1:HOLD SCOPE|2:SCOPE EXPANSION');
});
test('order-independent: shuffled inputs produce the same signature', () => {
// parseNumberedOptions already returns sorted, but defensive sort means
// a future caller that hands us shuffled input still produces a stable
// dedupe signature.
const a = optionsSignature([
{ index: 2, label: 'B' },
{ index: 1, label: 'A' },
{ index: 3, label: 'C' },
]);
const b = optionsSignature([
{ index: 1, label: 'A' },
{ index: 2, label: 'B' },
{ index: 3, label: 'C' },
]);
expect(a).toBe(b);
});
test('empty list returns empty string', () => {
expect(optionsSignature([])).toBe('');
});
test('single-item list returns just that entry', () => {
expect(optionsSignature([{ index: 1, label: 'Only' }])).toBe('1:Only');
});
});
describe('COMPLETION_SUMMARY_RE', () => {
test('matches GSTACK REVIEW REPORT heading', () => {
expect(COMPLETION_SUMMARY_RE.test('## GSTACK REVIEW REPORT')).toBe(true);
});
test('matches Completion Summary heading (ceo + eng)', () => {
expect(COMPLETION_SUMMARY_RE.test('## Completion Summary')).toBe(true);
expect(COMPLETION_SUMMARY_RE.test('## Completion summary')).toBe(true);
});
test('matches Status: clean (CEO review-log shape)', () => {
expect(COMPLETION_SUMMARY_RE.test('Status: clean')).toBe(true);
expect(COMPLETION_SUMMARY_RE.test('Status: issues_open')).toBe(true);
});
test('matches VERDICT: line', () => {
expect(COMPLETION_SUMMARY_RE.test('VERDICT: CLEARED — Eng Review passed')).toBe(true);
});
test('does NOT match prose mentions of "verdict" mid-line', () => {
// VERDICT must be at the start of a line to count.
expect(COMPLETION_SUMMARY_RE.test('the final verdict: undecided')).toBe(false);
});
test('does NOT treat source or proposed diff rows as assistant completion', () => {
for (const line of [
'409 +## GSTACK REVIEW REPORT',
'419 +**VERDICT:** Design Review complete — 8 decisions made.',
'+## GSTACK REVIEW REPORT',
'409→## GSTACK REVIEW REPORT',
'The plan must end with ## GSTACK REVIEW REPORT.',
]) expect(COMPLETION_SUMMARY_RE.test(line)).toBe(false);
});
});
describe('classifyPlanCountFrame replay', () => {
test('waits through proposed Write approval and tool output, then accepts the actual report', () => {
// Sanitized rows and native prompt from the failed design-count attempt.
const proposedDiff = [
'409 +## GSTACK REVIEW REPORT',
'416 +| Design Review | 1 | issues_open | score: 2/10 → 8/10, 8 decisions |',
'419 +**VERDICT:** Design Review complete — 8 decisions made.',
].join('\n');
const permission = [
'Doyouwanttooverwritegstack-test-plan-design.md?',
'❯1.Yes',
'2.Yes,andswitchtoacceptedits(auto-approvefileeditsandcommonfilecommands)forthissession;Yes,and',
'alwaysallowaccessto/tmp/fixtureforthissession',
'3.No',
'Esctocancel·Tabtoamend',
].join('\n');
const frames = [
`${proposedDiff}\n${permission}`,
`${proposedDiff}\n⏺ Updated gstack-test-plan-design.md`,
`${proposedDiff}\n⏺ ## GSTACK REVIEW REPORT\nDesign Review complete — 8 decisions made.`,
];
expect(frames.map(classifyPlanCountFrame)).toEqual(['permission', null, 'completion_summary']);
});
test('a pending native permission beats even an unnumbered report heading', () => {
const visible = '## GSTACK REVIEW REPORT\nDoyouwanttooverwriteplan.md?\n❯1.Yes\n2.No\nEsctocancel·Tabtoamend';
expect(classifyPlanCountFrame(visible)).toBe('permission');
});
test('a later report supersedes the granted menu still in short scrollback', () => {
const permission = 'Doyouwanttooverwriteplan.md?\n❯1.Yes\n2.No\nEsctocancel·Tabtoamend';
expect(classifyPlanCountFrame(permission)).toBe('permission');
expect(classifyPlanCountFrame(`${permission}\n● ## GSTACK REVIEW REPORT`)).toBe('completion_summary');
});
test('an active question after a prior report keeps the counter running', () => {
expect(classifyPlanCountFrame('## GSTACK REVIEW REPORT\nOne more choice\n❯1.Add to plan\n2.Defer')).toBeNull();
});
test('an active AUQ supersedes a granted permission menu in short scrollback', () => {
const permission = 'Doyouwanttooverwriteplan.md?\n❯1.Yes\n2.No\nEsctocancel·Tabtoamend';
const question = '☐ Error handling\nWhich failure path should we test?\n❯1.Timeout\n2.Refusal';
expect(classifyPlanCountFrame(`${permission}\n${question}`)).toBeNull();
});
test('preserves actual report variants and the native plan-ready terminal', () => {
for (const report of [
'## GSTACK REVIEW REPORT', '⏺##GSTACKREVIEWREPORT', '●GSTACKREVIEWREPORT',
'## Completion Summary', '● ## Completion Summary', 'Status: clean', 'Status: issues_open',
'VERDICT: CLEARED — Eng Review passed', '**VERDICT:** Design Review complete.',
]) expect(classifyPlanCountFrame(report)).toBe('completion_summary');
expect(classifyPlanCountFrame('Ready to execute the plan?\n❯1.Yes\n2.No, keep planning')).toBe('plan_ready');
});
});
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 = [
'← ☒ Routing rule ☐ Design doc ✔ Submit →',
'',
'Review your answrs',
'⚠ You hvenot answered all questions',
" ● Add gstack skill routing rules tothisproject'sCLAUDE.md?",
'→dd routing rues (Recommnded)',
'',
'Ready to submit your answers?',
'',
'❯1Submit answers',
' 2. Cancel',
].join('\r');
expect(planCountSubmissionInput(captured)).toBe('\x1b[Z');
const answered = captured.replace('☐ Design doc', '☒ Design doc').replace('⚠ You hvenot answered all questions', '');
expect(planCountSubmissionInput(answered)).toBe('\r');
expect(planCountSubmissionInput(captured + '\r☐ Design doc\rRun office hours?\r❯1Run now\r2.Skip')).toBeNull();
});
const incomplete = [
'← ☒ Learnings scope ☐ Approach ✔ Submit →',
'Review your answers',
'⚠You have not answered all questions',
' │ ●D1 — Cross-project learnings: Enable searching learnings from your other local projects?',
'→Enable cross-project (Recommended)',
'Ready t submit your answers?',
'❯1.Submit aswers',
'2Cancel',
].join('\r\r');
test('returns to the unanswered tab, then submits only after both answers', () => {
expect(planCountSubmissionInput(incomplete)).toBe('\x1b[Z');
const nextQuestion = [
'← ☒ Learnings scope ☐ Approach ✔ Submit →',
'│ Which approach should this plan use?',
'❯1.Extend the existing dispatcher',
'2.Add a separate handler',
].join('\r\r');
expect(planCountSubmissionInput(`${incomplete}\r${nextQuestion}`)).toBeNull();
const question = capturePlanCountQuestion(nextQuestion, new Set(), 0, true);
expect(question?.promptSnippet).toContain('Which approach');
expect(question?.options).toHaveLength(2);
const answered = incomplete.replace('☐ Approach', '☒ Approach').replace('⚠You have not answered all questions', '');
expect(planCountSubmissionInput(answered)).toBe('\r');
});
test('navigates to the first unanswered tab when more than one remains', () => {
const frame = incomplete.replace('☒ Learnings scope ☐ Approach', '☐ Learnings scope ☐ Approach ☒ Mode');
expect(planCountSubmissionInput(frame)).toBe('\x1b[Z\x1b[Z\x1b[Z');
});
test('does not revisit a stale submit panel when a later single question is active', () => {
expect(planCountSubmissionInput(`${incomplete}\r☐ Retry spec\rRetry once?\r❯1.Yes\r2.No`)).toBeNull();
expect(planCountSubmissionInput('Ready to submit the plan?\n❯1.Submit\n2.Cancel')).toBeNull();
});
});