Files
gstack/test/skill-e2e-plan-ceo-mode-routing.test.ts
T
Garry Tan 454423aeb3 v1.21.1.0 test: tighten plan-ceo-review smoke (Step 0 must fire) (#1255)
* test: extract classifyVisible() + permission-dialog filter in PTY runner

Pure classifier extracted from runPlanSkillObservation's polling loop so
unit tests can exercise the actual branch order with synthetic input
strings. Runner gains:

- env? passthrough on runPlanSkillObservation (forwarded to launchClaudePty).
  gstack-config does not yet honor env overrides; plumbing is in place for a
  future change to make tests hermetic.
- TAIL_SCAN_BYTES = 1500 exported constant. Replaces a duplicated magic
  number in test/skill-e2e-plan-ceo-mode-routing.test.ts so tuning stays
  in sync.
- isPermissionDialogVisible: the bare phrase "Do you want to proceed?" now
  requires a file-edit context co-trigger. Other clauses unchanged. Skill
  questions that contain the bare phrase are no longer mis-classified.
- classifyVisible(visible): pure function. Branch order silent_write →
  plan_ready → asked → null. Permission dialogs filtered out of the
  'asked' classification so a permission prompt cannot pose as a Step 0
  skill question.

Adds 24 unit tests covering all classifier branches, edge cases, and the
co-trigger contract.

* test: tighten plan-ceo-review smoke to require Step 0 fires first

Assertion narrows from ['asked', 'plan_ready'] to 'asked' only. Reaching
plan_ready first means the agent skipped Step 0 entirely and went
straight to ExitPlanMode — the regression we want to catch.

Why plan-ceo is special: unlike plan-eng / plan-design / plan-devex
(whose smokes legitimately reach plan_ready on certain branches without
asking), plan-ceo-review's template mandates Step 0A premise challenge
plus Step 0F mode selection BEFORE any plan write. There is no
legitimate path to plan_ready that does not first emit a skill-question
numbered prompt.

Failure message now branches on outcome (plan_ready vs timeout vs
silent_write) with a tailored diagnosis line per case. References the
skill template by section name ("Step 0 STOP rules", "One issue = one
AskUserQuestion call") instead of line numbers, so it survives template
edits.

Passes env: { QUESTION_TUNING: 'false', EXPLAIN_LEVEL: 'default' }
through the runner. Today this is advisory — gstack-config reads only
~/.gstack/config.yaml, not env vars — but the wiring is in place for a
future change. Documented honestly in the docstring.

Verified across 4 PTY runs: 3 pre-refactor + 1 post-refactor, all PASS.

* chore: capture v1.21.1.0 follow-ups in TODOS.md

- P2: per-finding AskUserQuestion count assertion (V2)
- P3: honor env vars in gstack-config so test isolation env actually works
- P3: path-confusion hardening on SANCTIONED_WRITE_SUBSTRINGS

All three surfaced during the v1.21.1.0 plan-eng-review and adversarial
review passes. Captured here so the design intent persists.

* chore: bump version and changelog (v1.21.1.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: extract MODE_RE + optionsSignature into PTY runner exports

Refactor prep for the upcoming per-finding AskUserQuestion count test
across plan-{ceo,eng,design,devex}-review. Both new tests and the existing
mode-routing test need the same mode regex and the same option-list
fingerprint dedupe — pulling them into one source of truth in
test/helpers/claude-pty-runner.ts so a fifth mode (or a tweak to the
fingerprint shape) updates everywhere instead of drifting per-test.

Mechanical: no behavior change in the mode-routing test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add per-finding count primitives + unit tests

Pure helpers landing ahead of runPlanSkillCounting:

  - parseQuestionPrompt(visible) — extract the 1-3 line prompt above
    the latest "❯ 1." cursor, normalize to a 240-char snippet
  - auqFingerprint(prompt, opts) — Bun.hash of normalized prompt + sorted
    options signature; distinct prompts with shared option labels
    (the generic A/B/C TODO menu) get distinct fingerprints
  - COMPLETION_SUMMARY_RE — terminal-signal regex matching all four
    plan-review skills' completion / verdict markers
  - assertReviewReportAtBottom(content) — checks "## GSTACK REVIEW
    REPORT" is present and is the last "## " heading in a plan file
  - Step0BoundaryPredicate type + four per-skill predicates
    (ceo / eng / design / devex) — fire on the answered AUQ's
    fingerprint, marking the end of Step 0 deterministically
    (event-based, not content-based, per Codex F7)

Plus 37 deterministic unit tests covering option-label collision
regression, prompt extraction edge cases, predicate positive AND
negative cases, and review-report-at-bottom triple-check
(missing / mid-file / multiple trailing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add runPlanSkillCounting PTY helper

Drives a plan-* skill end-to-end and counts distinct review-phase
AskUserQuestions. Composes the primitives from the previous commit:

  - Boot + auto-trust handler (existing launchClaudePty)
  - Send slash command alone, sleep 3s, send plan content as follow-up
    message (proven pattern from skill-e2e-plan-design-with-ui)
  - Poll loop with permission-dialog auto-grant, same-redraw skip,
    empty-prompt re-poll
  - Event-based Step-0 boundary via isLastStep0AUQ predicate fired on
    the answered AUQ's fingerprint (Codex F7 — boundary is observed
    event, not later rendered content)
  - Multi-signal terminals: hard ceiling, COMPLETION_SUMMARY_RE,
    plan_ready, silent_write, exited, timeout

Empty-prompt fingerprints are skipped per the contract documented in
auqFingerprint's unit tests — fingerprinting them would re-introduce
the option-label collision regression Codex F1 caught.

No E2E tests yet — those land in commit 5 with the four skill fixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: register four finding-count tests in touchfiles + tier map

Each new test depends on its skill template, the runner, and three
preamble resolvers (preamble.ts, generate-ask-user-format.ts,
generate-completion-status.ts) — those affect question cadence and
completion rendering, which is exactly what the test asserts on.

All four classified periodic. Sequential execution during calibration;
opt-in to concurrent only after measured comparison agrees (plan §D15).

Updated touchfiles.test.ts: plan-ceo-review/** now selects 19 tests
(was 18) because plan-ceo-finding-count joins the family.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add four per-finding count E2E tests (plan-ceo + eng + design + devex)

Each test drives its plan-* skill through Step 0 then asserts the
review-phase AskUserQuestion count falls in [N-1, N+2] for an N=5
seeded plan, plus D19: produced plan file ends with
"## GSTACK REVIEW REPORT" as its last "## " heading.

plan-ceo also runs a paired-finding positive control: 2 deliberately
related findings should still produce 2 distinct AUQs, not 1 batched.

Periodic-tier (gate-skipped without EVALS=1, EVALS_TIER=periodic).
Sequential execution by plan §D15. Each fixture is inline TypeScript
content delivered as a follow-up message after the slash command, per
the proven pattern at skill-e2e-plan-design-with-ui.test.ts.

Calibration loop (5 runs per skill) and the manual pre-merge negative
check (D7 + D12) are required before merge per plan §Verification.
NOT yet run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix parseNumberedOptions for inline-cursor box-layout AUQs

Calibration run 1 timed out with step0=0 review=0 because the parser
could not find the cursor in /plan-ceo-review's scope-selection AUQ.
The TTY's box-layout rendering inlines divider + header + prompt +
"1." onto one logical line — cursor escapes get stripped, leaving
text crushed onto a single line.

Cursor anchor regex changed from anchored to unanchored so it matches
mid-line. Cursor-line option extraction uses a non-anchored regex;
subsequent options stay with the original start-of-line parser.

parseQuestionPrompt picks up the inline prompt text BEFORE the cursor
on the cursor line (after stripping box-drawing chars + sigil) and
appends it after any walked-up multi-line prompt above.

Three new unit tests: clean-cursor still works, inline-cursor
extracts all 7 options, prompt extraction strips box chars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add firstAUQPick + plan-ceo skip-interview routing

Calibration run 1 surfaced a second issue beyond the parser bug: the
default pick of 1 on /plan-ceo-review's scope-selection AUQ routes
the agent to "branch diff vs main" — so it reviews the gstack PR
itself (recursive!) instead of the seeded fixture plan we sent.

Added firstAUQPick callback to runPlanSkillCounting. Override applies
only to the FIRST AUQ; subsequent presses keep using defaultPick.

ceoStep0Boundary now fires on either the mode-pick AUQ (existing path)
or any AUQ containing "Skip interview and plan immediately" — which
is the scope-selection AUQ. Picking that option bypasses Step 0 and
routes straight to review-phase using the chat-paste plan as context.

Plan-ceo test wires firstAUQPick = pickSkipInterview which finds the
"Skip interview" option by label. Falls back to "describe inline" if
the option labels change.

Two new unit tests: ceoStep0Boundary fires on the scope-selection
fixture; existing mode-pick fixture still fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 02:50:09 -07:00

213 lines
8.4 KiB
TypeScript
Raw 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.
/**
* /plan-ceo-review mode-routing E2E (periodic, paid, real-PTY).
*
* Asserts: when /plan-ceo-review reaches its Step 0F mode-selection
* AskUserQuestion and the user picks HOLD SCOPE or SCOPE EXPANSION,
* the downstream rendered output reflects that mode's distinctive
* posture language.
*
* Why this exists: existing tests verify that the question fires. Nothing
* verifies the answer actually routes. A regression where Step 0F shows
* the question but the agent ignores the choice (e.g. always defaults
* to EXPANSION) would not be caught by any prior test.
*
* Tier: periodic (not gate). Each run navigates 8-12 prior AskUserQuestions (telemetry,
* proactive, routing, vendoring, brain, office-hours, premise×3, approach)
* before reaching Step 0F. At ~30s per AskUserQuestion that's a 4-6 min navigation
* phase per case. The full 2-case suite runs ~12-15 min, $3-4. Too slow
* for gate-tier; weekly is fine.
*
* Mode coverage: HOLD SCOPE + SCOPE EXPANSION cover the two posture poles
* (rigor vs ambition). SELECTIVE EXPANSION and SCOPE REDUCTION are V2 once
* the navigation phase is shorter or has a deterministic fast-path through
* Step 0A/0C-bis.
*
* Posture assertions: each mode has distinct downstream language. The
* checks below are deliberately permissive — they catch the binary
* "did the mode posture even apply" question, not Opus-specific phrasing.
*
* HOLD SCOPE — "rigor" or "bulletproof" or "hold scope"
* SCOPE EXPANSION — "expansion" or "10x" or "delight" or "dream"
*/
import { describe, test } from 'bun:test';
import {
launchClaudePty,
isNumberedOptionListVisible,
isPermissionDialogVisible,
parseNumberedOptions,
isPlanReadyVisible,
MODE_RE,
optionsSignature,
TAIL_SCAN_BYTES,
type ClaudePtySession,
} from './helpers/claude-pty-runner';
const shouldRun = !!process.env.EVALS && process.env.EVALS_TIER === 'periodic';
const describeE2E = shouldRun ? describe : describe.skip;
interface ModeCase {
mode: 'HOLD SCOPE' | 'SCOPE EXPANSION';
/** Regex applied to visible-since-mode-pick text. At least one must match. */
postureRe: RegExp;
}
const CASES: ModeCase[] = [
{ mode: 'HOLD SCOPE', postureRe: /\b(rigor|bulletproof|hold\s*scope|maximum\s+rigor)\b/i },
{ mode: 'SCOPE EXPANSION', postureRe: /\b(expansion|10x|delight|dream|cathedral|opt[\s-]?in)\b/i },
];
/**
* Navigate prior AskUserQuestions by picking option 1 until we hit an AskUserQuestion whose
* options match one of the 4 mode names. Returns the option index
* matching `targetMode`, with the buffer marker pointing AT that AskUserQuestion.
*
* Throws if we don't reach the mode AskUserQuestion within `maxNav` prior AskUserQuestions or
* the overall budget.
*/
async function navigateToModeAskUserQuestion(
session: ClaudePtySession,
since: number,
targetMode: ModeCase['mode'],
opts: { maxNav?: number; budgetMs?: number } = {},
): Promise<{ modeIndex: number; visibleAtMode: string }> {
// /plan-ceo-review's mode AskUserQuestion (Step 0F) sits behind several preamble
// and Step 0A-0C-bis gates: telemetry, proactive, routing, vendoring,
// brain privacy, office-hours offer, premise challenge (3 questions),
// approach selection. 12 hops is the conservative ceiling.
const maxNav = opts.maxNav ?? 12;
const budgetMs = opts.budgetMs ?? 420_000;
const start = Date.now();
let priorAnswered = 0;
let lastSeenList: Array<{ index: number; label: string }> = [];
while (Date.now() - start < budgetMs) {
if (session.exited()) {
throw new Error(
`claude exited (code=${session.exitCode()}) during nav.\n` +
`Last visible:\n${session.visibleSince(since).slice(-2000)}`,
);
}
await Bun.sleep(2000);
const visible = session.visibleSince(since);
if (!isNumberedOptionListVisible(visible)) continue;
const opts = parseNumberedOptions(visible);
if (opts.length < 2) continue;
// Has the rendered list changed since last poll? If not, we're seeing
// the same prompt and shouldn't double-press.
const sig = optionsSignature(opts);
const lastSig = optionsSignature(lastSeenList);
if (sig === lastSig) continue;
lastSeenList = opts;
// Is THIS the mode AskUserQuestion?
if (opts.some(o => MODE_RE.test(o.label))) {
const target = opts.find(o => o.label.toUpperCase().includes(targetMode));
if (!target) {
throw new Error(
`Mode AskUserQuestion rendered but target "${targetMode}" not in option labels:\n` +
opts.map(o => ` ${o.index}. ${o.label}`).join('\n'),
);
}
return { modeIndex: target.index, visibleAtMode: visible };
}
// Permission dialog? Grant with "1" but don't count it against nav budget.
// Classify on the recent tail only — old permission text persists in
// visibleSince and would re-trigger forever.
//
// Note: runPlanSkillObservation has its own permission-dialog filter that
// simply skips classification (since it observes, doesn't drive). This nav
// loop drives the PTY directly via launchClaudePty and so owns its own
// dialog handling — granting with "1" so the workflow advances. Both
// paths share TAIL_SCAN_BYTES as the recent-tail window so tuning stays
// in sync.
if (isPermissionDialogVisible(visible.slice(-TAIL_SCAN_BYTES))) {
session.send('1\r');
await Bun.sleep(1500);
continue;
}
// Not the mode AskUserQuestion — answer with option 1 (recommended) and continue.
if (priorAnswered >= maxNav) {
throw new Error(
`Navigated ${maxNav} prior AskUserQuestions without reaching the mode AskUserQuestion. ` +
`Last list:\n${opts.map(o => ` ${o.index}. ${o.label}`).join('\n')}`,
);
}
priorAnswered++;
session.send('1\r');
// Give the agent a beat to advance before re-polling.
await Bun.sleep(2000);
}
throw new Error(`Mode AskUserQuestion not reached within ${budgetMs}ms`);
}
describeE2E('/plan-ceo-review mode routing (gate)', () => {
for (const c of CASES) {
test(
`mode "${c.mode}" routes to its distinctive posture`,
async () => {
const session = await launchClaudePty({
permissionMode: 'plan',
timeoutMs: 540_000,
});
try {
await Bun.sleep(8000);
const since = session.mark();
session.send('/plan-ceo-review\r');
const { modeIndex } = await navigateToModeAskUserQuestion(session, since, c.mode);
// Snapshot the visible buffer at mode-pick time, then send the index.
const sincePick = session.rawOutput().length;
session.send(`${modeIndex}\r`);
// Wait for downstream evidence: either next AskUserQuestion or plan_ready or
// a posture-distinctive substring shows up.
const budgetMs = 240_000;
const start = Date.now();
let postureMatched = false;
let downstreamSnapshot = '';
while (Date.now() - start < budgetMs) {
await Bun.sleep(2500);
if (session.exited()) {
throw new Error(
`claude exited (code=${session.exitCode()}) after mode pick.\n` +
`Downstream:\n${session.visibleSince(sincePick).slice(-2000)}`,
);
}
downstreamSnapshot = session.visibleSince(sincePick);
if (c.postureRe.test(downstreamSnapshot)) {
postureMatched = true;
break;
}
// Don't bail early on plan_ready alone — the posture text may
// arrive as the agent finishes writing the plan. Only break
// once we either match posture or run the clock.
if (
isPlanReadyVisible(downstreamSnapshot) &&
isNumberedOptionListVisible(downstreamSnapshot) &&
!c.postureRe.test(downstreamSnapshot)
) {
// Plan-ready AND a follow-up AskUserQuestion are both visible but
// posture text has not appeared yet. Keep polling for a bit.
}
}
if (!postureMatched) {
throw new Error(
`Mode "${c.mode}" routing FAILED: no posture match for ${c.postureRe.source}.\n` +
`--- downstream visible since mode pick (last 3KB) ---\n` +
downstreamSnapshot.slice(-3000),
);
}
} finally {
await session.close();
}
},
600_000,
);
}
});