Merge pull request #3627 from jeremymcs/fix/3615-continue-context-injection

fix(gsd): inject task context for unstructured resume prompts (#3615)
This commit is contained in:
Jeremy McSpadden 2026-04-06 12:27:43 -05:00 committed by GitHub
commit d877e6e152
2 changed files with 191 additions and 0 deletions

View file

@ -264,6 +264,13 @@ function buildWorktreeContextBlock(): string {
return "";
}
/**
* Low-entropy resume intent patterns short phrases a user types to
* continue work after a pause, rate limit, or context reset (#3615).
* Tested against the trimmed, lowercased prompt with trailing punctuation stripped.
*/
const RESUME_INTENT_PATTERNS = /^(continue|resume|ok|go|go ahead|proceed|keep going|carry on|next|yes|yeah|yep|sure|do it|let's go|pick up where you left off)$/;
async function buildGuidedExecuteContextInjection(prompt: string, basePath: string): Promise<string | null> {
const executeMatch = prompt.match(/Execute the next task:\s+(T\d+)\s+\("([^"]+)"\)\s+in slice\s+(S\d+)\s+of milestone\s+(M\d+(?:-[a-z0-9]{6})?)/i);
if (executeMatch) {
@ -280,6 +287,27 @@ async function buildGuidedExecuteContextInjection(prompt: string, basePath: stri
}
}
// Fallback: low-entropy resume prompt (e.g., "continue", "ok", "go ahead")
// during an active executing task — inject task context so the agent
// doesn't rebuild from scratch (#3615).
// Intent-gated: only fire for short, resume-like prompts to avoid hijacking
// control/help/diagnostic prompts with unrelated execution context.
// Phase-gated: only fire during "executing" to avoid misrouting during
// replanning, gate evaluation, or other non-execution phases.
const trimmed = prompt.trim().toLowerCase().replace(/[.!?,]+$/g, "");
if (RESUME_INTENT_PATTERNS.test(trimmed)) {
const state = await deriveState(basePath);
if (state.phase === "executing" && state.activeTask && state.activeMilestone && state.activeSlice) {
return buildTaskExecutionContextInjection(
basePath,
state.activeMilestone.id,
state.activeSlice.id,
state.activeTask.id,
state.activeTask.title,
);
}
}
return null;
}

View file

@ -0,0 +1,163 @@
// GSD-2 — Regression test for #3615: unstructured "continue" must inject task context
// Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
/**
* Bug #3615: When a user types "continue" (or any bare text) to resume
* an in-progress session, buildGuidedExecuteContextInjection() only
* matched two hardcoded regex patterns (auto-dispatch and guided-resume).
* The function returned null for any other input, so no task context was
* injected causing the agent to rebuild everything from scratch and
* burn ~86k tokens.
*
* This test verifies:
* 1. Structural: the fallback exists with phase + intent guards
* 2. Behavioral: RESUME_INTENT_PATTERNS matches expected prompts and
* rejects non-resume prompts (control, help, diagnostic, etc.)
*/
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const systemContextSource = readFileSync(
join(__dirname, "..", "bootstrap", "system-context.ts"),
"utf-8",
);
// ── Structural tests ────────────────────────────────────────────────
describe("#3615 — structural: fallback exists with correct guards", () => {
const fnStart = systemContextSource.indexOf("async function buildGuidedExecuteContextInjection(");
assert.ok(fnStart >= 0, "should find buildGuidedExecuteContextInjection");
const fnEnd = systemContextSource.indexOf("\nasync function ", fnStart + 1);
const fnBody = fnEnd >= 0
? systemContextSource.slice(fnStart, fnEnd)
: systemContextSource.slice(fnStart);
test("has a deriveState fallback after the two regex branches", () => {
const deriveStateCalls = fnBody.match(/deriveState\(basePath\)/g);
assert.ok(
deriveStateCalls && deriveStateCalls.length >= 2,
`expected >=2 deriveState(basePath) calls, got ${deriveStateCalls?.length ?? 0}`,
);
});
test("fallback is phase-gated to executing only", () => {
const afterFallback = fnBody.indexOf("// Fallback:");
assert.ok(afterFallback >= 0, "should have a fallback comment");
const fallbackSection = fnBody.slice(afterFallback);
assert.ok(
fallbackSection.includes('state.phase === "executing"'),
'fallback must be gated on state.phase === "executing"',
);
});
test("fallback is intent-gated via RESUME_INTENT_PATTERNS", () => {
const afterFallback = fnBody.indexOf("// Fallback:");
const fallbackSection = fnBody.slice(afterFallback);
assert.ok(
fallbackSection.includes("RESUME_INTENT_PATTERNS"),
"fallback must check RESUME_INTENT_PATTERNS before deriveState",
);
});
test("fallback calls buildTaskExecutionContextInjection with derived state", () => {
const afterFallback = fnBody.indexOf("// Fallback:");
const fallbackSection = fnBody.slice(afterFallback);
assert.ok(
fallbackSection.includes("buildTaskExecutionContextInjection") &&
fallbackSection.includes("state.activeMilestone.id") &&
fallbackSection.includes("state.activeSlice.id") &&
fallbackSection.includes("state.activeTask.id"),
"fallback must call buildTaskExecutionContextInjection with state-derived IDs",
);
});
test("only one return null at the end", () => {
const returnNulls = fnBody.match(/return null;/g);
assert.ok(
returnNulls && returnNulls.length === 1,
`expected exactly 1 'return null' (at end after fallback), got ${returnNulls?.length ?? 0}`,
);
});
});
// ── Behavioral tests: RESUME_INTENT_PATTERNS ────────────────────────
describe("#3615 — behavioral: RESUME_INTENT_PATTERNS matches resume prompts", () => {
// Extract the regex from source so the test stays in sync
const patternMatch = systemContextSource.match(/const RESUME_INTENT_PATTERNS\s*=\s*\/(.+)\/;/);
assert.ok(patternMatch, "should find RESUME_INTENT_PATTERNS definition");
const pattern = new RegExp(patternMatch[1]);
// Helper: normalize prompt the same way the production code does
const normalize = (s: string) => s.trim().toLowerCase().replace(/[.!?,]+$/g, "");
const shouldMatch = [
"continue",
"Continue",
"CONTINUE",
"continue.",
"continue!",
"resume",
"ok",
"OK",
"Ok!",
"go",
"go ahead",
"Go ahead.",
"proceed",
"keep going",
"carry on",
"next",
"yes",
"yeah",
"yep",
"sure",
"do it",
"let's go",
"pick up where you left off",
" continue ", // whitespace padded
];
const shouldNotMatch = [
"help",
"status",
"/gsd auto",
"/gsd stats",
"what's the plan?",
"show me the logs",
"abort",
"stop",
"cancel",
"replan this slice",
"I think we should change the approach",
"can you explain what you just did?",
"run the tests",
"check the build",
"Execute the next task: T01",
"what files were changed",
"",
];
for (const prompt of shouldMatch) {
test(`matches resume prompt: "${prompt}"`, () => {
assert.ok(
pattern.test(normalize(prompt)),
`expected RESUME_INTENT_PATTERNS to match "${prompt}" (normalized: "${normalize(prompt)}")`,
);
});
}
for (const prompt of shouldNotMatch) {
test(`rejects non-resume prompt: "${prompt}"`, () => {
assert.ok(
!pattern.test(normalize(prompt)),
`expected RESUME_INTENT_PATTERNS to NOT match "${prompt}" (normalized: "${normalize(prompt)}")`,
);
});
}
});