Files
gstack/test/skill-e2e-context-skills.test.ts
T
Garry TanandMatt Van Horn fe6d1ae62a v1.91.17.0 fix: mvanhorn wave (checkpoint provenance, never-overwrite design images) (#3022)
* fix: mark Remaining Work provenance so restore verifies guessed steps first (#3004)

/context-save now starts every Remaining Work item with `Open.` and ends it
with how this session knows it: `(path run)` with the observed outcome,
`(path read)` or `(path assumed)` for concrete paths, and
`(target state checked)`, `(code read)` or `(path assumed)` for writing
steps. /context-restore keeps saved order and original text but splits the
list into Next steps and Verify first (assumed, code-read, failed runs and
unmarked concrete or writing items), prints a banner for pre-marker
checkpoints, and option A walks saved order so an unverified first item is
verified before any later step runs.

Adds a prompt-byte contract test for the marker wire format and a periodic
behavior E2E (context-restore-provenance-order) that seeds an assumed item
ahead of a run item plus a code-read write.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(design): never overwrite a paid design image (#1529)

Every image the API returns now goes through one persistence path
(design/src/persist.ts): mkdir, exclusive `wx` claim that bumps
x.png -> x-2.png ... x-999.png, write, fsync, close. A failure after the
bytes arrived removes the claimed file and keeps a 0600 recovery copy in
the temp dir; empty image data is a failure, never a 0-byte PNG. The API
call is the only thing inside each retry loop, so a local save failure
never buys a second image (variants, iterate fallback, generate --retry).

generate, iterate, evolve and variants (both branches) print the same run
fields (requested, saved, selected, failures, recovered) on every exit,
with exit 0 / 2 (nothing saved) / 3 (stopped after saving). Each
generate --retry attempt is its own saved image listed in `attempts`.
compare accepts --images-file and JSON-array --images. The gallery lists
bumped names, orders history by mtime then board-images.json, and marks
approvals by approved_path. New bin/gstack-design-claim publishes staged
images without clobbering; bin/gstack-design-approved resolves an
approval record (legacy letter-only records still resolve; a missing
approved_path is an error, never a substitute).

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

* fix(design skills): read the paths $D printed, never fixed variant names (#1529)

Every design flow now captures each generating $D command's JSON and exit
code without set -e, does round accounting before the first board, check
or inline view (stopping when nothing was saved), and carries the printed
paths forward. Boards are built from board-images.json via
compare --images-file after archiving any earlier feedback.json, and
approvals map the confirmed letter through that file and record
approved_path. design-shotgun subagents generate into a per-run mktemp
staging directory and publish with bin/gstack-design-claim instead of a
shared /tmp/variant-X.png plus cp. design-html and taste memory resolve
approvals with gstack-design-approved; design-review verifies against the
printed target path.

test/design-printed-paths.test.ts bans fixed variant names, variant globs,
variant-{letter} reads, cp/mv of $D output and literal check paths in the
generated skills and resolvers (one content-anchored allowlist entry: the
design-html discovery probe), and pins the accounting/board marker order.
test/design-approval-flow.test.ts runs the generated board and approval
blocks through a stubbed two-round flow.

* chore: release v1.91.17.0 (mvanhorn fix wave: checkpoint provenance, never-overwrite design images)

* fix(design): keep iterate sessions in the OS temp dir and normalize tripwire paths on Windows

* fix(setup-deploy): report failing status commands, specify merge-method, health and workflow checks

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-10-03 16:13:45 -07:00

590 lines
28 KiB
TypeScript

/**
* Tier-1 live-fire E2E for /context-save and /context-restore.
*
* These spawn `claude -p "/context-save ..."` with the Skill tool enabled
* and the skill installed in the workdir's .claude/skills/. Unlike the
* older hand-fed-section tests, these exercise the ROUTING path — the
* exact thing that broke with the /checkpoint name collision and the
* whole reason this rename exists. If /context-save stops routing to
* the skill (e.g., upstream ships a built-in by that name), these fail.
*
* Periodic tier. ~$0.20-$0.40 per test, ~$2 total per run.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { JUDGE_MS, CAPTURE_MS } from './helpers/eval-budgets';
import { runSkillTest } from './helpers/session-runner';
import { expectContract } from './helpers/eval-store';
import {
ROOT, runId, evalsEnabled,
describeIfSelected, testConcurrentIfSelected,
logCost, recordE2E,
createEvalCollector, finalizeEvalCollector,
} from './helpers/e2e-helpers';
import { extractSkillBody } from './helpers/skill-fixture';
import { readShippedSkillRouting } from './helpers/shipped-skill-routing';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const evalCollector = createEvalCollector('e2e-context-skills');
// Shared install helper: copy both skill files + bin scripts + routing CLAUDE.md
// into a tmp workdir. Matches the pattern from skill-routing-e2e.test.ts so
// claude -p discovers the skills via .claude/skills/ auto-scan.
function setupWorkdir(suffix: string): { workDir: string; gstackHome: string; slug: string } {
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), `skill-e2e-ctx-${suffix}-`));
const gstackHome = path.join(workDir, '.gstack-home');
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: workDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', 'test@test.com']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(workDir, 'app.ts'), 'console.log("hello");\n');
run('git', ['add', '.']);
run('git', ['commit', '-m', 'initial']);
// Install skills into .claude/skills/ for claude -p auto-discovery.
// The tests exercise the full save/restore/list flows, so keep the whole
// skill-specific body but drop the ~780-line shared preamble the tests
// never touch (CLAUDE.md: "E2E test fixtures: extract, don't copy").
const skillsDir = path.join(workDir, '.claude', 'skills');
for (const skill of ['context-save', 'context-restore']) {
const destDir = path.join(skillsDir, skill);
fs.mkdirSync(destDir, { recursive: true });
fs.writeFileSync(path.join(destDir, 'SKILL.md'), extractSkillBody(path.join(ROOT, skill)));
}
// Install the bin scripts referenced by the preamble.
const binDir = path.join(workDir, 'bin');
fs.mkdirSync(binDir, { recursive: true });
for (const script of [
'gstack-timeline-log', 'gstack-timeline-read', 'gstack-slug', 'gstack-state-root.sh', 'gstack-remote-identity.sh',
'gstack-learnings-log', 'gstack-learnings-search',
'gstack-update-check', 'gstack-config', 'gstack-repo-mode', 'gstack-paths',
]) {
const src = path.join(ROOT, 'bin', script);
if (fs.existsSync(src)) {
fs.copyFileSync(src, path.join(binDir, script));
fs.chmodSync(path.join(binDir, script), 0o755);
}
}
// Routing CLAUDE.md: the ## Skill routing section gstack ships.
fs.writeFileSync(path.join(workDir, 'CLAUDE.md'), `# Project Instructions
${readShippedSkillRouting().section}
## Test environment
- Use GSTACK_HOME="${gstackHome}" for all gstack bin scripts.
- The bin scripts are at ./bin/ (relative to this directory).
- The skill files are at ./.claude/skills/context-save/SKILL.md and
./.claude/skills/context-restore/SKILL.md.
`);
const slug = path.basename(workDir).replace(/[^a-zA-Z0-9._-]/g, '');
return { workDir, gstackHome, slug };
}
// Helper: seed a saved-context file into the storage dir.
function seedSave(gstackHome: string, slug: string, filename: string, frontmatter: Record<string, string>, body: string) {
const dir = path.join(gstackHome, 'projects', slug, 'checkpoints');
fs.mkdirSync(dir, { recursive: true });
const fm = '---\n' + Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n') + '\n---\n';
fs.writeFileSync(path.join(dir, filename), fm + body);
}
// Helper: extract the list of Skill tool invocations from the transcript.
function skillCalls(result: { toolCalls: Array<{ tool: string; input: any }> }): string[] {
return result.toolCalls
.filter((tc) => tc.tool === 'Skill')
.map((tc) => tc.input?.skill || '')
.filter(Boolean);
}
// Build a broader assertion surface: final assistant message + every tool
// input and output. The agent often finishes with a tool call instead of a
// text response, leaving result.output as an empty string — but the data we
// want to assert on (skill invocation args, bash stdout like NO_CHECKPOINTS,
// file paths) is all present in the transcript. Search there too.
function fullOutputSurface(result: {
output?: string;
transcript?: any[];
toolCalls?: Array<{ tool: string; input: any; output: string }>;
}): string {
const parts: string[] = [];
if (result.output) parts.push(result.output);
for (const tc of result.toolCalls || []) {
parts.push(JSON.stringify(tc.input || {}));
if (tc.output) parts.push(tc.output);
}
// Also stringify transcript for tool_result / user-message content that
// isn't surfaced via toolCalls (e.g., Bash stdout echoed back).
for (const entry of result.transcript || []) {
try { parts.push(JSON.stringify(entry)); } catch { /* skip */ }
}
return parts.join('\n');
}
// ────────────────────────────────────────────────────────────────────────
// Live-fire E2E suite
// ────────────────────────────────────────────────────────────────────────
describeIfSelected('Context Skills E2E (live-fire)', [
'context-save-routing',
'context-save-then-restore-roundtrip',
'context-restore-fragment-match',
'context-restore-provenance-order',
'context-restore-empty-state',
'context-restore-list-delegates',
'context-restore-legacy-compat',
'context-save-list-current-branch',
'context-save-list-all-branches',
], () => {
afterAll(() => { finalizeEvalCollector(evalCollector); });
// ── 1. Routing: /context-save actually invokes the Skill tool ────────
testConcurrentIfSelected('context-save-routing', async () => {
const { workDir, gstackHome, slug } = setupWorkdir('routing');
// Prompt pattern: the slash command + explicit "invoke via Skill tool"
// instruction. The GSTACK_HOME / ./bin bash setup that used to be in
// the prompt now comes via env:. Prompt without the Skill-tool hint
// causes the agent to interpret /context-save as a shell token and
// skip Skill routing entirely — which defeats this test's purpose.
const result = await runSkillTest({
prompt: `Run /context-save wintermute progress. Invoke via the Skill tool. Do NOT use AskUserQuestion.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 12,
allowedTools: ['Skill', 'Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob'],
timeout: JUDGE_MS,
testName: 'context-save-routing',
runId,
});
logCost('context-save-routing', result);
const invokedSkills = skillCalls(result);
const routedToContextSave = invokedSkills.includes('context-save');
// File should also be written to the storage dir.
const checkpointDir = path.join(gstackHome, 'projects', slug, 'checkpoints');
const files = fs.existsSync(checkpointDir) ? fs.readdirSync(checkpointDir).filter((f) => f.endsWith('.md')) : [];
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'context-save routes via Skill tool', 'Context Skills E2E', result, {
passed: exitOk && routedToContextSave && files.length > 0,
});
expect(exitOk).toBe(true);
expect(routedToContextSave).toBe(true);
expect(files.length).toBeGreaterThan(0);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
// ── 2. Round-trip: save then restore in the same session ─────────────
testConcurrentIfSelected('context-save-then-restore-roundtrip', async () => {
const { workDir, gstackHome, slug } = setupWorkdir('roundtrip');
const magicMarker = 'wintermute-roundtrip-MX7FQZ';
// Stage a change so /context-save has something to capture.
fs.writeFileSync(path.join(workDir, 'feature.ts'), `// ${magicMarker}\nexport const X = 1;\n`);
spawnSync('git', ['add', 'feature.ts'], { cwd: workDir, stdio: 'pipe', timeout: 5000 });
const result = await runSkillTest({
prompt: `Two steps:
1. Run /context-save ${magicMarker} — invoke via the Skill tool.
2. Run /context-restore — invoke via the Skill tool. Report what it loaded.
Do NOT use AskUserQuestion.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 25,
allowedTools: ['Skill', 'Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob'],
timeout: CAPTURE_MS,
testName: 'context-save-then-restore-roundtrip',
runId,
});
logCost('context-save-then-restore-roundtrip', result);
const invokedSkills = skillCalls(result);
const bothRouted = invokedSkills.includes('context-save') && invokedSkills.includes('context-restore');
const checkpointDir = path.join(gstackHome, 'projects', slug, 'checkpoints');
const files = fs.existsSync(checkpointDir) ? fs.readdirSync(checkpointDir).filter((f) => f.endsWith('.md')) : [];
// Broader surface — agent may stop at restore's Skill call without
// echoing the marker into result.output. The marker is also in the
// Skill tool input (we passed it as the save title) and in the
// file content that restore reads.
const restoreMentionsTitle = fullOutputSurface(result).toLowerCase().includes(magicMarker.toLowerCase());
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'save-then-restore round-trip', 'Context Skills E2E', result, {
passed: exitOk && bothRouted && files.length > 0 && restoreMentionsTitle,
});
expect(exitOk).toBe(true);
expect(bothRouted).toBe(true);
expect(files.length).toBeGreaterThan(0);
expect(restoreMentionsTitle).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
// ── 3. /context-restore <fragment> loads the matching save ───────────
testConcurrentIfSelected('context-restore-fragment-match', async () => {
const { workDir, gstackHome, slug } = setupWorkdir('fragment');
// Seed three saves with distinct titles.
seedSave(gstackHome, slug, '20260101-120000-alpha-feature.md',
{ status: 'in-progress', branch: 'feat/alpha', timestamp: '2026-01-01T12:00:00Z' },
'## Working on: alpha feature\n\n### Summary\nAlpha content FRAGMATCH_ALPHA_BUILD\n');
seedSave(gstackHome, slug, '20260202-120000-middle-payments.md',
{ status: 'in-progress', branch: 'feat/payments', timestamp: '2026-02-02T12:00:00Z' },
'## Working on: middle payments\n\n### Summary\nPayments content FRAGMATCH_PAYMENTS_BUILD\n');
seedSave(gstackHome, slug, '20260303-120000-omega-release.md',
{ status: 'in-progress', branch: 'feat/omega', timestamp: '2026-03-03T12:00:00Z' },
'## Working on: omega release\n\n### Summary\nOmega content FRAGMATCH_OMEGA_BUILD\n');
const result = await runSkillTest({
prompt: `Run /context-restore payments — load the saved context whose title contains "payments". Invoke via the Skill tool. Report what was loaded. Do NOT use AskUserQuestion.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 10,
allowedTools: ['Skill', 'Bash', 'Read', 'Grep', 'Glob'],
timeout: JUDGE_MS,
testName: 'context-restore-fragment-match',
runId,
});
logCost('context-restore-fragment-match', result);
// Broader surface — agent may stop at Skill call without echoing the
// body marker. The payments file's body is in tool outputs (Read/Bash).
const out = fullOutputSurface(result);
const loadedPayments = out.includes('FRAGMATCH_PAYMENTS_BUILD');
const didNotLoadOthers = !out.includes('FRAGMATCH_ALPHA_BUILD') && !out.includes('FRAGMATCH_OMEGA_BUILD');
const routedToRestore = skillCalls(result).includes('context-restore');
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'context-restore <fragment> match', 'Context Skills E2E', result, {
passed: exitOk && routedToRestore && loadedPayments && didNotLoadOthers,
});
expect(exitOk).toBe(true);
expect(routedToRestore).toBe(true);
expect(loadedPayments).toBe(true);
expect(didNotLoadOthers).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
// ── 3b. Provenance (#3004): guessed and code-read steps are Verify first ──
// The checkpoint ranks an assumed step first, then a step that was run, then
// a write whose target was never inspected. Restore must show the run step
// under Next steps, the other two under Verify first, and option A must
// verify the first item instead of leapfrogging to the runnable one.
testConcurrentIfSelected('context-restore-provenance-order', async () => {
const { workDir, gstackHome, slug } = setupWorkdir('provenance');
seedSave(gstackHome, slug, '20260404-120000-billing-migration.md',
{ status: 'in-progress', branch: 'main', timestamp: '2026-04-04T12:00:00Z' },
[
'## Working on: billing migration',
'',
'### Summary',
'Moving invoices to the new billing schema.',
'',
'### Remaining Work',
'1. Open. Run the migration check with SWITCH_B=1 against staging, PROV_ASSUMED_7Q. (path assumed)',
'2. Open. Run bun test test/billing.test.ts, PROV_RUN_4K. (path run) exit 0',
'3. Open. Apply scripts/backfill.sql (INSERT IGNORE into invoices), PROV_WRITE_9Z. (code read)',
'',
'### Notes',
'None.',
'',
].join('\n'));
const result = await runSkillTest({
prompt: `Run /context-restore. Invoke via the Skill tool and present its summary. Do NOT use AskUserQuestion: assume the user picks A (continue working). Do not execute or edit anything. End your reply with exactly one line \`FIRST_ACTION: <verify|do> <PROV token of the item you would start with>\`.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 10,
allowedTools: ['Skill', 'Bash', 'Read', 'Grep', 'Glob'],
timeout: JUDGE_MS,
testName: 'context-restore-provenance-order',
runId,
});
logCost('context-restore-provenance-order', result);
const out = result.output || '';
const base = Math.max(0, out.indexOf('Remaining Work'));
const next = out.indexOf('Next steps', base);
const verify = next >= 0 ? out.indexOf('Verify first', next) : -1;
const pos = (token: string) => out.indexOf(token, base);
const grouped = next >= 0 && verify > next
&& pos('PROV_RUN_4K') > next && pos('PROV_RUN_4K') < verify
&& pos('PROV_ASSUMED_7Q') > verify && pos('PROV_WRITE_9Z') > verify;
const firstAction = out.match(/FIRST_ACTION:\s*(verify|do)\s+(PROV_\w+)/i);
const ranSeeded = (result.toolCalls || []).some((tc) => tc.tool === 'Bash'
&& /SWITCH_B=1|backfill\.sql|billing\.test/.test(JSON.stringify(tc.input || {})));
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
const name = 'context-restore provenance order';
expectContract(!ranSeeded, 'context-restore-provenance-order: restore executed a seeded Remaining Work command', { collector: evalCollector, name });
expectContract(!(firstAction && firstAction[2] !== 'PROV_ASSUMED_7Q' && /do/i.test(firstAction[1])),
'context-restore-provenance-order: option A jumped past the unverified first item to execute a later one', { collector: evalCollector, name });
const startsByVerifying = !!firstAction && /verify/i.test(firstAction[1]) && firstAction[2] === 'PROV_ASSUMED_7Q';
recordE2E(evalCollector, name, 'Context Skills E2E', result, {
passed: exitOk && grouped && startsByVerifying,
});
expect(exitOk).toBe(true);
expect(grouped).toBe(true);
expect(startsByVerifying).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
// ── 4. /context-restore with zero saves → graceful empty-state ───────
testConcurrentIfSelected('context-restore-empty-state', async () => {
const { workDir, gstackHome, slug } = setupWorkdir('empty');
// Ensure the storage dir is empty or missing — setupWorkdir doesn't seed.
const checkpointDir = path.join(gstackHome, 'projects', slug, 'checkpoints');
expect(fs.existsSync(checkpointDir)).toBe(false);
const result = await runSkillTest({
prompt: `Run /context-restore — there are no saved contexts yet. Invoke via the Skill tool. Do NOT use AskUserQuestion.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 8,
allowedTools: ['Skill', 'Bash', 'Read', 'Grep', 'Glob'],
timeout: JUDGE_MS,
testName: 'context-restore-empty-state',
runId,
});
logCost('context-restore-empty-state', result);
// Build broad surface: agent often stops after a tool call with no final
// text, so result.output is empty string. The bash "NO_CHECKPOINTS" echo
// is in tool outputs; the "no saved contexts yet" phrase may only appear
// in tool inputs / transcript entries.
const out = fullOutputSurface(result);
const gracefulMessage = /no saved context|no contexts? yet|nothing to restore|NO_CHECKPOINTS/i.test(out);
const noCrash = !/error|exception|undefined/i.test(out) || gracefulMessage; // mention of "error" in the graceful message is fine
const routedToRestore = skillCalls(result).includes('context-restore');
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'context-restore empty state', 'Context Skills E2E', result, {
passed: exitOk && routedToRestore && gracefulMessage && noCrash,
});
expect(exitOk).toBe(true);
expect(routedToRestore).toBe(true);
expect(gracefulMessage).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
// ── 5. /context-restore list redirects to /context-save list ─────────
testConcurrentIfSelected('context-restore-list-delegates', async () => {
const { workDir, gstackHome, slug } = setupWorkdir('delegates');
seedSave(gstackHome, slug, '20260101-120000-seed.md',
{ status: 'in-progress', branch: 'main', timestamp: '2026-01-01T12:00:00Z' },
'## Working on: seed\n');
const result = await runSkillTest({
prompt: `Run /context-restore list. Invoke via the Skill tool. Do NOT use AskUserQuestion.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 8,
allowedTools: ['Skill', 'Bash', 'Read', 'Grep', 'Glob'],
timeout: JUDGE_MS,
testName: 'context-restore-list-delegates',
runId,
});
logCost('context-restore-list-delegates', result);
// Broader surface — agent sometimes stops after the Skill call without
// producing text output. The "use /context-save list" hint may only
// appear in tool inputs / transcript.
const out = fullOutputSurface(result);
const mentionsSaveList = /context-save list/i.test(out);
const routedToRestore = skillCalls(result).includes('context-restore');
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'context-restore list delegates', 'Context Skills E2E', result, {
passed: exitOk && routedToRestore && mentionsSaveList,
});
expect(exitOk).toBe(true);
expect(routedToRestore).toBe(true);
expect(mentionsSaveList).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
// ── 6. Legacy compat: pre-rename save files still load ───────────────
testConcurrentIfSelected('context-restore-legacy-compat', async () => {
const { workDir, gstackHome, slug } = setupWorkdir('legacy');
// Seed a save file in the pre-rename format (exactly how old /checkpoint
// wrote them). The storage dir name is still "checkpoints/" — kept for
// exactly this reason.
seedSave(gstackHome, slug, '20260301-120000-legacy-pre-rename-work.md',
{
status: 'in-progress',
branch: 'feat/pre-rename',
timestamp: '2026-03-01T12:00:00Z',
session_duration_s: '3600',
},
'## Working on: legacy pre-rename work\n\n### Summary\nWork saved by OLD_CHECKPOINT_SKILL_LEGACYCOMPAT before the rename.\n\n### Remaining Work\n1. Item from the before-times.\n');
const result = await runSkillTest({
prompt: `Run /context-restore — load the most recent saved context. Invoke via the Skill tool. Report the content of the loaded file. Do NOT use AskUserQuestion.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 8,
allowedTools: ['Skill', 'Bash', 'Read', 'Grep', 'Glob'],
timeout: JUDGE_MS,
testName: 'context-restore-legacy-compat',
runId,
});
logCost('context-restore-legacy-compat', result);
// Check for ANY evidence the legacy file was loaded. The agent may
// paraphrase the summary OR stop at a tool call without text output,
// so require at least ONE of:
// (a) the unique body marker (verbatim pass-through)
// (b) the title phrase "legacy pre-rename work"
// (c) the filename or its timestamp prefix
// (d) the branch name "feat/pre-rename"
// Search across the full transcript, not just result.output.
const out = fullOutputSurface(result);
const loadedLegacy =
out.includes('OLD_CHECKPOINT_SKILL_LEGACYCOMPAT') ||
/legacy.+pre-rename/i.test(out) ||
/20260301-120000-legacy/i.test(out) ||
/feat\/pre-rename/i.test(out) ||
/pre-rename/i.test(out);
const routedToRestore = skillCalls(result).includes('context-restore');
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'legacy /checkpoint file loads via /context-restore', 'Context Skills E2E', result, {
passed: exitOk && routedToRestore && loadedLegacy,
});
expect(exitOk).toBe(true);
expect(routedToRestore).toBe(true);
expect(loadedLegacy).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
// ── 7. /context-save list: default filters to current branch ─────────
testConcurrentIfSelected('context-save-list-current-branch', async () => {
const { workDir, gstackHome, slug } = setupWorkdir('list-current');
// Seed 3 files on 3 different branches. Current branch is "main".
seedSave(gstackHome, slug, '20260101-120000-main-work.md',
{ status: 'in-progress', branch: 'main', timestamp: '2026-01-01T12:00:00Z' },
'## Working on: main work LISTCURR_MAIN_TOKEN\n');
seedSave(gstackHome, slug, '20260202-120000-feat-alpha.md',
{ status: 'in-progress', branch: 'feat/alpha', timestamp: '2026-02-02T12:00:00Z' },
'## Working on: alpha LISTCURR_ALPHA_TOKEN\n');
seedSave(gstackHome, slug, '20260303-120000-feat-beta.md',
{ status: 'in-progress', branch: 'feat/beta', timestamp: '2026-03-03T12:00:00Z' },
'## Working on: beta LISTCURR_BETA_TOKEN\n');
const result = await runSkillTest({
prompt: `Run /context-save list — list saved contexts for the CURRENT branch only (default, no --all). Invoke via the Skill tool. The current branch is "main". Do NOT use AskUserQuestion.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 10,
allowedTools: ['Skill', 'Bash', 'Read', 'Grep', 'Glob'],
timeout: JUDGE_MS,
testName: 'context-save-list-current-branch',
runId,
});
logCost('context-save-list-current-branch', result);
// Broad surface: the list output may only appear in bash tool_result
// entries (find output, file reads) rather than the agent's final text.
const out = fullOutputSurface(result);
// Must show the main-branch save. Match by filename timestamp (stable,
// unambiguous) plus a looser prose check.
const showsMain = /20260101-120000|main-work/.test(out);
// The hide-assertions scan the FINAL TEXT only. This test went 0-for-26
// ($5.28 burned, zero passes) because they used the broad surface: any
// agent that ran `ls` on the checkpoints dir — the natural first step of
// a list flow — surfaced all three filenames in a tool_result and failed,
// even when its user-facing listing filtered correctly. What must hide
// the other branches is the LISTING the user sees, not the agent's eyes.
const finalText = result.output ?? '';
const hidesAlpha = !/20260202-120000|LISTCURR_ALPHA_TOKEN/.test(finalText);
const hidesBeta = !/20260303-120000|LISTCURR_BETA_TOKEN/.test(finalText);
const routed = skillCalls(result).includes('context-save');
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'context-save list (current branch default)', 'Context Skills E2E', result, {
passed: exitOk && routed && showsMain && hidesAlpha && hidesBeta,
});
expect(exitOk).toBe(true);
expect(routed).toBe(true);
expect(showsMain).toBe(true);
expect(hidesAlpha).toBe(true);
expect(hidesBeta).toBe(true);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
// ── 8. /context-save list --all: shows every branch ──────────────────
testConcurrentIfSelected('context-save-list-all-branches', async () => {
const { workDir, gstackHome, slug } = setupWorkdir('list-all');
seedSave(gstackHome, slug, '20260101-120000-main-work.md',
{ status: 'in-progress', branch: 'main', timestamp: '2026-01-01T12:00:00Z' },
'## Working on: main LISTALL_MAIN_TOKEN\n');
seedSave(gstackHome, slug, '20260202-120000-feat-alpha.md',
{ status: 'in-progress', branch: 'feat/alpha', timestamp: '2026-02-02T12:00:00Z' },
'## Working on: alpha LISTALL_ALPHA_TOKEN\n');
seedSave(gstackHome, slug, '20260303-120000-feat-beta.md',
{ status: 'in-progress', branch: 'feat/beta', timestamp: '2026-03-03T12:00:00Z' },
'## Working on: beta LISTALL_BETA_TOKEN\n');
const result = await runSkillTest({
prompt: `Run /context-save list --all — list saved contexts from ALL branches (not just the current one). Invoke via the Skill tool. Report the full list. Do NOT use AskUserQuestion.`,
workingDirectory: workDir,
env: { GSTACK_HOME: gstackHome },
maxTurns: 10,
allowedTools: ['Skill', 'Bash', 'Read', 'Grep', 'Glob'],
timeout: JUDGE_MS,
testName: 'context-save-list-all-branches',
runId,
});
logCost('context-save-list-all-branches', result);
// Broad surface — same rationale as list-current-branch: the list output
// may only be in bash tool_result, not in the agent's final text.
const out = fullOutputSurface(result);
const filesShown = [
/20260101-120000/.test(out),
/20260202-120000/.test(out),
/20260303-120000/.test(out),
].filter(Boolean).length;
const routed = skillCalls(result).includes('context-save');
const exitOk = ['success', 'error_max_turns'].includes(result.exitReason);
recordE2E(evalCollector, 'context-save list --all', 'Context Skills E2E', result, {
passed: exitOk && routed && filesShown === 3,
});
expect(exitOk).toBe(true);
expect(routed).toBe(true);
expect(filesShown).toBe(3);
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
}, CAPTURE_MS);
});