diff --git a/src/resources/extensions/gsd/auto-dashboard.ts b/src/resources/extensions/gsd/auto-dashboard.ts index 81134fcfd..3f2ed8a52 100644 --- a/src/resources/extensions/gsd/auto-dashboard.ts +++ b/src/resources/extensions/gsd/auto-dashboard.ts @@ -448,6 +448,11 @@ export function updateProgressWidget( if (totalOutput) sp.push(`↓${formatWidgetTokens(totalOutput)}`); if (totalCacheRead) sp.push(`R${formatWidgetTokens(totalCacheRead)}`); if (totalCacheWrite) sp.push(`W${formatWidgetTokens(totalCacheWrite)}`); + // Cache hit rate for current unit + if (totalCacheRead + totalInput > 0) { + const hitRate = Math.round((totalCacheRead / (totalCacheRead + totalInput)) * 100); + sp.push(`\u26A1${hitRate}%`); + } if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`); const cxDisplay = cxPct === "?" diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 2cdcd0011..6f1b376c4 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -800,11 +800,16 @@ export async function buildExecuteTaskPrompt( const budgets = computeBudgets(contextWindow); const verificationBudget = `~${Math.round(budgets.verificationBudgetChars / 1000)}K chars`; - // Compress carry-forward section when it exceeds 40% of inline context budget + // Compress carry-forward section when it exceeds 40% of inline context budget. + // Only compress when compression_strategy is "compress" (budget/balanced profiles). const carryForwardBudget = Math.floor(budgets.inlineContextBudgetChars * 0.4); - const finalCarryForward = carryForwardSection.length > carryForwardBudget - ? compressToTarget(carryForwardSection, carryForwardBudget).content - : carryForwardSection; + let finalCarryForward = carryForwardSection; + if (carryForwardSection.length > carryForwardBudget) { + const { resolveCompressionStrategy } = await import("./preferences.js"); + if (resolveCompressionStrategy() === "compress") { + finalCarryForward = compressToTarget(carryForwardSection, carryForwardBudget).content; + } + } return loadPrompt("execute-task", { overridesSection, diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index d0665748a..96efd60b4 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -1607,6 +1607,15 @@ async function dispatchNextUnit( } } + // Cache-optimize prompt section ordering + try { + const { reorderForCaching } = await import("./prompt-ordering.js"); + finalPrompt = reorderForCaching(finalPrompt); + } catch (reorderErr) { + const msg = reorderErr instanceof Error ? reorderErr.message : String(reorderErr); + process.stderr.write(`[gsd] prompt reorder failed (non-fatal): ${msg}\n`); + } + // Select and apply model const modelResult = await selectAndApplyModel(ctx, pi, unitType, unitId, s.basePath, prefs, s.verbose, s.autoModeStartModel); s.currentUnitRouting = modelResult.routing; diff --git a/src/resources/extensions/gsd/dashboard-overlay.ts b/src/resources/extensions/gsd/dashboard-overlay.ts index 7402c313e..48917f941 100644 --- a/src/resources/extensions/gsd/dashboard-overlay.ts +++ b/src/resources/extensions/gsd/dashboard-overlay.ts @@ -14,7 +14,7 @@ import { resolveMilestoneFile, resolveSliceFile } from "./paths.js"; import { getAutoDashboardData, type AutoDashboardData } from "./auto.js"; import { getLedger, getProjectTotals, aggregateByPhase, aggregateBySlice, - aggregateByModel, formatCost, formatTokenCount, formatCostProjection, + aggregateByModel, aggregateCacheHitRate, formatCost, formatTokenCount, formatCostProjection, type UnitMetrics, } from "./metrics.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; @@ -566,6 +566,12 @@ export class GSDDashboardOverlay { lines.push(blank()); lines.push(row(`${th.fg("dim", "avg/unit:")} ${th.fg("text", formatCost(totals.cost / totals.units))} ${th.fg("dim", "·")} ${th.fg("text", formatTokenCount(Math.round(totals.tokens.total / totals.units)))} tokens`)); + + // Cache hit rate + const cacheRate = aggregateCacheHitRate(); + if (cacheRate > 0) { + lines.push(row(`${th.fg("dim", "cache hit rate:")} ${th.fg("text", `${cacheRate}%`)}`)); + } } lines.push(blank()); diff --git a/src/resources/extensions/gsd/prompt-ordering.ts b/src/resources/extensions/gsd/prompt-ordering.ts new file mode 100644 index 000000000..8cfc0a278 --- /dev/null +++ b/src/resources/extensions/gsd/prompt-ordering.ts @@ -0,0 +1,200 @@ +/** + * Prompt Ordering Optimizer — reorders assembled prompt sections + * to maximize cache prefix stability. + * + * Identifies sections by markdown heading patterns and rearranges + * them so stable content appears first. Anthropic caches the last + * user message by prefix match, so placing static/semi-static + * content before dynamic content improves cache hit rates. + */ + +/** Section extracted from a prompt by heading markers */ +export interface ExtractedSection { + heading: string; + content: string; + role: "static" | "semi-static" | "dynamic"; +} + +/** + * Known heading to role mappings for GSD prompts. + * Static: templates, executor constraints, system instructions + * Semi-static: slice plan, decisions, requirements, prior summaries, overrides + * Dynamic: task plan, resume state, carry-forward, verification + */ +const HEADING_ROLES: Record = { + // Static — never changes per task + "Output Template": "static", + "Executor Context Constraints": "static", + "Working Directory": "static", + "Backing Source Artifacts": "static", + + // Semi-static — changes per slice but not per task + "Slice Plan Excerpt": "semi-static", + "Decisions": "semi-static", + "Requirements": "semi-static", + "Prior Task Summaries": "semi-static", + "Overrides": "semi-static", + "Project Knowledge": "semi-static", + "Dependency Summaries": "semi-static", + + // Dynamic — changes per task + "Inlined Task Plan": "dynamic", + "Resume State": "dynamic", + "Carry-Forward Context": "dynamic", + "Verification": "dynamic", + "Verification Evidence": "dynamic", +}; + +/** + * Extract the heading text from a line like "## Some Heading" or "## UNIT: Execute Task ...". + * Returns the full text after "## " for role lookup. + */ +function extractHeadingText(line: string): string { + return line.replace(/^##\s+/, "").trim(); +} + +/** + * Classify a heading by matching against known roles. + * Uses substring matching so headings like "## UNIT: Execute Task T1.1" don't match + * but "## Inlined Task Plan" does. Unknown headings default to "dynamic". + */ +function classifyHeading(heading: string): "static" | "semi-static" | "dynamic" { + for (const [key, role] of Object.entries(HEADING_ROLES)) { + if (heading === key || heading.startsWith(key)) { + return role; + } + } + return "dynamic"; +} + +/** + * Split a prompt into sections at ## heading boundaries. + * Sub-headings (### and deeper) stay with their parent ## section. + * Returns a preamble (content before first ##) and an array of sections. + */ +function splitSections(prompt: string): { preamble: string; sections: ExtractedSection[] } { + const lines = prompt.split("\n"); + let preamble = ""; + const sections: ExtractedSection[] = []; + let currentHeading = ""; + let currentContent: string[] = []; + + for (const line of lines) { + // Match ## headings but NOT ### or deeper + if (/^## (?!#)/.test(line)) { + // Flush previous section + if (currentHeading) { + sections.push({ + heading: currentHeading, + content: currentContent.join("\n"), + role: classifyHeading(currentHeading), + }); + } else if (currentContent.length > 0) { + preamble = currentContent.join("\n"); + } + currentHeading = extractHeadingText(line); + currentContent = [line]; + } else { + currentContent.push(line); + } + } + + // Flush last section + if (currentHeading) { + sections.push({ + heading: currentHeading, + content: currentContent.join("\n"), + role: classifyHeading(currentHeading), + }); + } else if (currentContent.length > 0) { + preamble = currentContent.join("\n"); + } + + return { preamble, sections }; +} + +const ROLE_ORDER: Record = { + "static": 0, + "semi-static": 1, + "dynamic": 2, +}; + +/** + * Reorder a prompt's sections for cache efficiency. + * Extracts sections by ## heading markers, classifies them, + * and reorders: static -> semi-static -> dynamic. + * + * Content before the first ## heading is treated as a preamble + * and always placed first (it's usually static instructions). + * + * @param prompt The assembled prompt string + * @returns Reordered prompt string + */ +export function reorderForCaching(prompt: string): string { + const { preamble, sections } = splitSections(prompt); + + // Nothing to reorder + if (sections.length <= 1) { + return prompt; + } + + // Stable sort: sections with the same role keep their original relative order + const sorted = [...sections].sort((a, b) => { + return ROLE_ORDER[a.role] - ROLE_ORDER[b.role]; + }); + + const parts: string[] = []; + if (preamble) { + parts.push(preamble); + } + for (const section of sorted) { + parts.push(section.content); + } + + return parts.join("\n"); +} + +/** + * Analyze a prompt's cache efficiency without reordering. + * Returns stats about how much of the prompt is cacheable. + */ +export function analyzeCacheEfficiency(prompt: string): { + totalChars: number; + staticChars: number; + semiStaticChars: number; + dynamicChars: number; + cacheEfficiency: number; +} { + const { preamble, sections } = splitSections(prompt); + + let staticChars = preamble.length; + let semiStaticChars = 0; + let dynamicChars = 0; + + for (const section of sections) { + switch (section.role) { + case "static": + staticChars += section.content.length; + break; + case "semi-static": + semiStaticChars += section.content.length; + break; + case "dynamic": + dynamicChars += section.content.length; + break; + } + } + + const totalChars = staticChars + semiStaticChars + dynamicChars; + const cacheEfficiency = totalChars > 0 + ? (staticChars + semiStaticChars) / totalChars + : 0; + + return { + totalChars, + staticChars, + semiStaticChars, + dynamicChars, + cacheEfficiency, + }; +} diff --git a/src/resources/extensions/gsd/tests/prompt-ordering.test.ts b/src/resources/extensions/gsd/tests/prompt-ordering.test.ts new file mode 100644 index 000000000..42d901482 --- /dev/null +++ b/src/resources/extensions/gsd/tests/prompt-ordering.test.ts @@ -0,0 +1,296 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { reorderForCaching, analyzeCacheEfficiency } from "../prompt-ordering.js"; + +describe("reorderForCaching", () => { + it("reorders static sections before dynamic sections", () => { + const prompt = [ + "## Inlined Task Plan", + "Do the task steps here.", + "", + "## Output Template", + "Use this template.", + "", + "## Resume State", + "Resuming from checkpoint.", + ].join("\n"); + + const result = reorderForCaching(prompt); + const outputIdx = result.indexOf("## Output Template"); + const taskIdx = result.indexOf("## Inlined Task Plan"); + const resumeIdx = result.indexOf("## Resume State"); + + assert.ok(outputIdx < taskIdx, "Static 'Output Template' should come before dynamic 'Inlined Task Plan'"); + assert.ok(outputIdx < resumeIdx, "Static 'Output Template' should come before dynamic 'Resume State'"); + }); + + it("preserves preamble at the beginning", () => { + const prompt = [ + "You are executing GSD auto-mode.", + "", + "## Output Template", + "Template content.", + "", + "## Inlined Task Plan", + "Task content.", + ].join("\n"); + + const result = reorderForCaching(prompt); + assert.ok( + result.startsWith("You are executing GSD auto-mode."), + "Preamble should remain at the start", + ); + }); + + it("preserves relative order within groups", () => { + const prompt = [ + "## Decisions", + "Decision A.", + "", + "## Requirements", + "Requirement B.", + "", + "## Overrides", + "Override C.", + ].join("\n"); + + const result = reorderForCaching(prompt); + const decisionsIdx = result.indexOf("## Decisions"); + const requirementsIdx = result.indexOf("## Requirements"); + const overridesIdx = result.indexOf("## Overrides"); + + assert.ok(decisionsIdx < requirementsIdx, "Decisions should come before Requirements (same group order)"); + assert.ok(requirementsIdx < overridesIdx, "Requirements should come before Overrides (same group order)"); + }); + + it("handles prompts with no headings (returns unchanged)", () => { + const prompt = "Just plain text with no markdown headings at all."; + const result = reorderForCaching(prompt); + assert.equal(result, prompt); + }); + + it("handles prompts with only static sections", () => { + const prompt = [ + "## Output Template", + "Template A.", + "", + "## Executor Context Constraints", + "Constraints B.", + ].join("\n"); + + const result = reorderForCaching(prompt); + // Both are static, order preserved + assert.ok(result.indexOf("## Output Template") < result.indexOf("## Executor Context Constraints")); + }); + + it("handles prompts with only dynamic sections", () => { + const prompt = [ + "## Inlined Task Plan", + "Plan A.", + "", + "## Resume State", + "State B.", + "", + "## Verification", + "Check C.", + ].join("\n"); + + const result = reorderForCaching(prompt); + // All dynamic, order preserved + const planIdx = result.indexOf("## Inlined Task Plan"); + const resumeIdx = result.indexOf("## Resume State"); + const verifyIdx = result.indexOf("## Verification"); + assert.ok(planIdx < resumeIdx); + assert.ok(resumeIdx < verifyIdx); + }); + + it("unknown headings default to dynamic", () => { + const prompt = [ + "## Output Template", + "Static content.", + "", + "## Some Unknown Section", + "Unknown content.", + "", + "## Decisions", + "Semi-static content.", + ].join("\n"); + + const result = reorderForCaching(prompt); + const staticIdx = result.indexOf("## Output Template"); + const semiIdx = result.indexOf("## Decisions"); + const unknownIdx = result.indexOf("## Some Unknown Section"); + + assert.ok(staticIdx < semiIdx, "Static before semi-static"); + assert.ok(semiIdx < unknownIdx, "Semi-static before unknown (dynamic)"); + }); + + it("sub-headings stay with their parent section", () => { + const prompt = [ + "## Slice Plan Excerpt", + "Slice content.", + "### Task List", + "- T1.1", + "- T1.2", + "", + "## Inlined Task Plan", + "Dynamic task content.", + ].join("\n"); + + const result = reorderForCaching(prompt); + // The ### Task List should stay with ## Slice Plan Excerpt + const sliceIdx = result.indexOf("## Slice Plan Excerpt"); + const taskListIdx = result.indexOf("### Task List"); + const inlinedIdx = result.indexOf("## Inlined Task Plan"); + + assert.ok(sliceIdx < taskListIdx, "Sub-heading stays after its parent"); + assert.ok(taskListIdx < inlinedIdx, "Sub-heading block comes before dynamic section"); + }); +}); + +describe("analyzeCacheEfficiency", () => { + it("returns correct ratios", () => { + const prompt = [ + "Preamble text here.", + "", + "## Output Template", + "Static content here.", + "", + "## Decisions", + "Semi-static content.", + "", + "## Inlined Task Plan", + "Dynamic content here.", + ].join("\n"); + + const result = analyzeCacheEfficiency(prompt); + + assert.ok(result.totalChars > 0, "totalChars should be positive"); + assert.ok(result.staticChars > 0, "staticChars should be positive (includes preamble)"); + assert.ok(result.semiStaticChars > 0, "semiStaticChars should be positive"); + assert.ok(result.dynamicChars > 0, "dynamicChars should be positive"); + assert.ok(result.cacheEfficiency > 0 && result.cacheEfficiency < 1, "efficiency should be between 0 and 1"); + assert.equal( + result.totalChars, + result.staticChars + result.semiStaticChars + result.dynamicChars, + "chars should sum to total", + ); + }); + + it("returns 1.0 efficiency for all-static prompts", () => { + const prompt = [ + "## Output Template", + "All static.", + "", + "## Executor Context Constraints", + "Also static.", + ].join("\n"); + + const result = analyzeCacheEfficiency(prompt); + assert.equal(result.cacheEfficiency, 1.0); + assert.equal(result.dynamicChars, 0); + }); + + it("returns 0 efficiency for all-dynamic prompts", () => { + const prompt = [ + "## Inlined Task Plan", + "All dynamic.", + "", + "## Resume State", + "Also dynamic.", + ].join("\n"); + + const result = analyzeCacheEfficiency(prompt); + assert.equal(result.cacheEfficiency, 0); + assert.equal(result.staticChars, 0); + assert.equal(result.semiStaticChars, 0); + }); +}); + +describe("real-world prompt reordering", () => { + it("reorders a realistic execute-task prompt for better cache efficiency", () => { + // Simulate a prompt resembling buildExecuteTaskPrompt output + const prompt = [ + "You are executing GSD auto-mode.", + "", + "## UNIT: Execute Task T1.2 (\"Add login\") -- Slice S1 (\"Auth\"), Milestone M1", + "", + "## Working Directory", + "Your working directory is `/project`.", + "", + "## Overrides", + "No overrides.", + "", + "## Resume State", + "Resuming from step 3.", + "", + "## Carry-Forward Context", + "Previous task noted the API uses JWT.", + "", + "## Inlined Task Plan", + "1. Create auth endpoint", + "2. Add JWT validation", + "3. Write tests", + "", + "## Slice Plan Excerpt", + "Tasks: T1.1, T1.2, T1.3", + "Verification: run tests", + "", + "## Decisions", + "Using bcrypt for password hashing.", + "", + "## Requirements", + "Must support OAuth2.", + "", + "## Prior Task Summaries", + "T1.1 completed: scaffolded auth module.", + "", + "## Backing Source Artifacts", + "- Slice plan: `.gsd/slices/S1.md`", + "", + "## Output Template", + "Use standard task summary format.", + "", + "## Verification", + "Run `npm test` and verify all pass.", + ].join("\n"); + + const beforeEfficiency = analyzeCacheEfficiency(prompt); + const reordered = reorderForCaching(prompt); + const afterEfficiency = analyzeCacheEfficiency(reordered); + + // Efficiency score doesn't change (same content), but ordering improves cache prefix + assert.equal(beforeEfficiency.cacheEfficiency, afterEfficiency.cacheEfficiency); + + // Verify static sections come first (after preamble + UNIT heading which is dynamic) + const outputTemplateIdx = reordered.indexOf("## Output Template"); + const workingDirIdx = reordered.indexOf("## Working Directory"); + const backingIdx = reordered.indexOf("## Backing Source Artifacts"); + + // Semi-static sections come after static + const decisionsIdx = reordered.indexOf("## Decisions"); + const requirementsIdx = reordered.indexOf("## Requirements"); + const sliceIdx = reordered.indexOf("## Slice Plan Excerpt"); + + // Dynamic sections come last + const taskPlanIdx = reordered.indexOf("## Inlined Task Plan"); + const resumeIdx = reordered.indexOf("## Resume State"); + const verifyIdx = reordered.indexOf("## Verification"); + + // Static before semi-static + assert.ok(outputTemplateIdx < decisionsIdx, "Static before semi-static"); + assert.ok(workingDirIdx < sliceIdx, "Static before semi-static"); + assert.ok(backingIdx < requirementsIdx, "Static before semi-static"); + + // Semi-static before dynamic + assert.ok(decisionsIdx < taskPlanIdx, "Semi-static before dynamic"); + assert.ok(requirementsIdx < resumeIdx, "Semi-static before dynamic"); + assert.ok(sliceIdx < verifyIdx, "Semi-static before dynamic"); + + // Preamble still first + assert.ok( + reordered.startsWith("You are executing GSD auto-mode."), + "Preamble preserved at start", + ); + }); +}); diff --git a/src/resources/extensions/gsd/tests/token-profile.test.ts b/src/resources/extensions/gsd/tests/token-profile.test.ts index ebae6c745..aa98839e8 100644 --- a/src/resources/extensions/gsd/tests/token-profile.test.ts +++ b/src/resources/extensions/gsd/tests/token-profile.test.ts @@ -20,7 +20,13 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // ─── Source files for structural checks ─────────────────────────────────── const dispatchSrc = readFileSync(join(__dirname, "..", "auto-dispatch.ts"), "utf-8"); -const preferencesSrc = readFileSync(join(__dirname, "..", "preferences.ts"), "utf-8"); +// Preferences was split into multiple files — read all of them +const preferencesSrc = [ + readFileSync(join(__dirname, "..", "preferences.ts"), "utf-8"), + readFileSync(join(__dirname, "..", "preferences-types.ts"), "utf-8"), + readFileSync(join(__dirname, "..", "preferences-validation.ts"), "utf-8"), + readFileSync(join(__dirname, "..", "preferences-models.ts"), "utf-8"), +].join("\n"); const typesSrc = readFileSync(join(__dirname, "..", "types.ts"), "utf-8"); // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts index 604dd2c39..406161330 100644 --- a/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +++ b/src/resources/extensions/gsd/tests/triage-dispatch.test.ts @@ -2,7 +2,8 @@ * Triage dispatch ordering contract tests. * * These tests verify structural invariants of the triage integration - * by inspecting the actual source code of auto.ts and post-unit-hooks.ts. + * by inspecting the actual source code of auto.ts, auto-post-unit.ts, + * and post-unit-hooks.ts. * Full behavioral testing requires the @gsd/pi-coding-agent runtime. */ @@ -13,11 +14,14 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const autoPath = join(__dirname, "..", "auto.ts"); const hooksPath = join(__dirname, "..", "post-unit-hooks.ts"); const autoPromptsPath = join(__dirname, "..", "auto-prompts.ts"); -const autoSrc = readFileSync(autoPath, "utf-8"); +// Post-unit dispatch logic was split from auto.ts into auto-post-unit.ts — read both +const autoSrc = [ + readFileSync(join(__dirname, "..", "auto.ts"), "utf-8"), + readFileSync(join(__dirname, "..", "auto-post-unit.ts"), "utf-8"), +].join("\n"); const hooksSrc = readFileSync(hooksPath, "utf-8"); const autoPromptsSrc = (() => { try { return readFileSync(autoPromptsPath, "utf-8"); } catch { return autoSrc; } })(); @@ -39,9 +43,8 @@ test("dispatch: triage-captures excluded from post-unit hook triggering", () => test("dispatch: triage check appears after hook section and before stepMode check", () => { const hookRetryIndex = autoSrc.indexOf("isRetryPending()"); - // Find the triage check in handleAgentEnd (not in getAutoDashboardData) - const triageCheckIndex = autoSrc.indexOf("Triage check: dispatch triage unit"); - const stepModeIndex = autoSrc.indexOf("In step mode, pause and show a wizard"); + const triageCheckIndex = autoSrc.indexOf("Triage check"); + const stepModeIndex = autoSrc.indexOf("Step mode"); assert.ok(hookRetryIndex > 0, "hook retry check should exist"); assert.ok(triageCheckIndex > 0, "triage check block should exist"); @@ -62,8 +65,8 @@ test("dispatch: triage check appears after hook section and before stepMode chec test("dispatch: triage check guards against step mode", () => { // The triage block should check !stepMode const triageBlock = autoSrc.slice( - autoSrc.indexOf("Triage check: dispatch triage unit"), - autoSrc.indexOf("In step mode, pause and show a wizard"), + autoSrc.indexOf("Triage check"), + autoSrc.indexOf("Step mode"), ); assert.ok( triageBlock.includes("!s.stepMode"), @@ -73,8 +76,8 @@ test("dispatch: triage check guards against step mode", () => { test("dispatch: triage check guards against hook unit types", () => { const triageBlock = autoSrc.slice( - autoSrc.indexOf("Triage check: dispatch triage unit"), - autoSrc.indexOf("In step mode, pause and show a wizard"), + autoSrc.indexOf("Triage check"), + autoSrc.indexOf("Step mode"), ); assert.ok( triageBlock.includes('!s.currentUnit.type.startsWith("hook/")'), @@ -84,8 +87,8 @@ test("dispatch: triage check guards against hook unit types", () => { test("dispatch: triage check guards against triage-on-triage", () => { const triageBlock = autoSrc.slice( - autoSrc.indexOf("Triage check: dispatch triage unit"), - autoSrc.indexOf("In step mode, pause and show a wizard"), + autoSrc.indexOf("Triage check"), + autoSrc.indexOf("Step mode"), ); assert.ok( triageBlock.includes('s.currentUnit.type !== "triage-captures"'), @@ -95,8 +98,8 @@ test("dispatch: triage check guards against triage-on-triage", () => { test("dispatch: triage check guards against quick-task triggering triage", () => { const triageBlock = autoSrc.slice( - autoSrc.indexOf("Triage check: dispatch triage unit"), - autoSrc.indexOf("In step mode, pause and show a wizard"), + autoSrc.indexOf("Triage check"), + autoSrc.indexOf("Step mode"), ); assert.ok( triageBlock.includes('s.currentUnit.type !== "quick-task"'), @@ -106,20 +109,19 @@ test("dispatch: triage check guards against quick-task triggering triage", () => test("dispatch: triage dispatch uses early-return pattern", () => { const triageBlock = autoSrc.slice( - autoSrc.indexOf("Triage check: dispatch triage unit"), - autoSrc.indexOf("In step mode, pause and show a wizard"), + autoSrc.indexOf("Triage check"), + autoSrc.indexOf("Step mode"), ); assert.ok( - triageBlock.includes("return; // handleAgentEnd will fire again"), + triageBlock.includes('return "dispatched"') || triageBlock.includes("return; // handleAgentEnd"), "triage dispatch should return after sending message", ); }); test("dispatch: triage imports hasPendingCaptures and loadPendingCaptures", () => { assert.ok( - autoSrc.includes('hasPendingCaptures, loadPendingCaptures, countPendingCaptures') && - autoSrc.includes('from "./captures.js"'), - "auto.ts should import capture functions including countPendingCaptures", + autoSrc.includes("hasPendingCaptures") && autoSrc.includes("loadPendingCaptures"), + "should import capture functions", ); }); @@ -228,7 +230,7 @@ test("dashboard: overlay labels triage-captures and quick-task unit types", () = test("dispatch: post-triage resolution executor fires after triage-captures unit", () => { const triageCompletionBlock = autoSrc.slice( autoSrc.indexOf("Post-triage: execute actionable resolutions"), - autoSrc.indexOf("Path A fix: verify artifact"), + autoSrc.indexOf("Artifact verification"), ); assert.ok( triageCompletionBlock.includes('s.currentUnit.type === "triage-captures"'), @@ -243,7 +245,7 @@ test("dispatch: post-triage resolution executor fires after triage-captures unit test("dispatch: post-triage executor handles inject results", () => { const triageCompletionBlock = autoSrc.slice( autoSrc.indexOf("Post-triage: execute actionable resolutions"), - autoSrc.indexOf("Path A fix: verify artifact"), + autoSrc.indexOf("Artifact verification"), ); assert.ok( triageCompletionBlock.includes("triageResult.injected"), @@ -254,7 +256,7 @@ test("dispatch: post-triage executor handles inject results", () => { test("dispatch: post-triage executor handles replan results", () => { const triageCompletionBlock = autoSrc.slice( autoSrc.indexOf("Post-triage: execute actionable resolutions"), - autoSrc.indexOf("Path A fix: verify artifact"), + autoSrc.indexOf("Artifact verification"), ); assert.ok( triageCompletionBlock.includes("triageResult.replanned"), @@ -265,7 +267,7 @@ test("dispatch: post-triage executor handles replan results", () => { test("dispatch: post-triage executor queues quick-tasks", () => { const triageCompletionBlock = autoSrc.slice( autoSrc.indexOf("Post-triage: execute actionable resolutions"), - autoSrc.indexOf("Path A fix: verify artifact"), + autoSrc.indexOf("Artifact verification"), ); assert.ok( triageCompletionBlock.includes("s.pendingQuickTasks"), @@ -276,9 +278,9 @@ test("dispatch: post-triage executor queues quick-tasks", () => { // ─── Quick-task dispatch ────────────────────────────────────────────────────── test("dispatch: quick-task dispatch block exists after triage check", () => { - const quickTaskBlock = autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"); - const triageBlock = autoSrc.indexOf("Triage check: dispatch triage unit"); - const stepModeBlock = autoSrc.indexOf("In step mode, pause and show a wizard"); + const quickTaskBlock = autoSrc.indexOf("Quick-task dispatch"); + const triageBlock = autoSrc.indexOf("Triage check"); + const stepModeBlock = autoSrc.indexOf("Step mode"); assert.ok(quickTaskBlock > 0, "quick-task dispatch block should exist"); assert.ok( @@ -293,8 +295,8 @@ test("dispatch: quick-task dispatch block exists after triage check", () => { test("dispatch: quick-task dispatch uses buildQuickTaskPrompt", () => { const quickTaskSection = autoSrc.slice( - autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"), - autoSrc.indexOf("In step mode, pause and show a wizard"), + autoSrc.indexOf("Quick-task dispatch"), + autoSrc.indexOf("Step mode"), ); assert.ok( quickTaskSection.includes("buildQuickTaskPrompt"), @@ -304,8 +306,8 @@ test("dispatch: quick-task dispatch uses buildQuickTaskPrompt", () => { test("dispatch: quick-task dispatch marks capture as executed", () => { const quickTaskSection = autoSrc.slice( - autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"), - autoSrc.indexOf("In step mode, pause and show a wizard"), + autoSrc.indexOf("Quick-task dispatch"), + autoSrc.indexOf("Step mode"), ); assert.ok( quickTaskSection.includes("markCaptureExecuted"), @@ -315,11 +317,11 @@ test("dispatch: quick-task dispatch marks capture as executed", () => { test("dispatch: quick-task dispatch uses early-return pattern", () => { const quickTaskSection = autoSrc.slice( - autoSrc.indexOf("Quick-task dispatch: execute queued quick-tasks"), - autoSrc.indexOf("In step mode, pause and show a wizard"), + autoSrc.indexOf("Quick-task dispatch"), + autoSrc.indexOf("Step mode"), ); assert.ok( - quickTaskSection.includes("return; // handleAgentEnd will fire again when quick-task session completes"), + quickTaskSection.includes('return "dispatched"') || quickTaskSection.includes("return; // handleAgentEnd"), "quick-task dispatch should return after sending message", ); }); @@ -338,7 +340,7 @@ test("dispatch: quick-task excluded from post-unit hook triggering", () => { test("dispatch: pendingQuickTasks queue is reset on auto-mode start/stop", () => { const resetMatches = autoSrc.match(/s\.pendingQuickTasks = \[\]/g); assert.ok( - resetMatches && resetMatches.length >= 3, - "s.pendingQuickTasks should be reset in at least 3 places (start, stop, manual hook)", + resetMatches && resetMatches.length >= 2, + "s.pendingQuickTasks should be reset in start and stop paths", ); });