feat: cache-ordered prompt assembly and dashboard cache hit rate (#1094)

* feat: cache-ordered prompt assembly and dashboard cache hit rate

Add prompt section reordering for better Anthropic cache hit rates.
Sections are classified as static/semi-static/dynamic and reordered
so stable content appears first in the prefix.

- prompt-ordering.ts: section extraction, classification, and
  reordering by cache stability (static -> semi-static -> dynamic)
- auto.ts: wire reorderForCaching into dispatch with logged warnings
  on failure (not silent catch)
- auto-dashboard.ts: show cache hit rate percentage in progress widget
- dashboard-overlay.ts: show aggregate cache hit rate in status overlay
- auto-prompts.ts: respect compression_strategy preference before
  compressing carry-forward sections

Includes 12 tests for reorderForCaching and analyzeCacheEfficiency.

Split from #1083 per review feedback.

* fix: update source-reading tests for post-refactor file locations

triage-dispatch.test.ts: read auto-post-unit.ts (dispatch logic moved
from auto.ts) and update comment string matches to reflect renamed
section headers.

token-profile.test.ts: read preferences-types.ts, preferences-validation.ts,
and preferences-models.ts (GSDPreferences interface and validation logic
split from preferences.ts).
This commit is contained in:
Jeremy McSpadden 2026-03-18 00:31:20 -05:00 committed by GitHub
parent 76a834cdf6
commit b20e7b065a
8 changed files with 571 additions and 42 deletions

View file

@ -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 === "?"

View file

@ -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,

View file

@ -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;

View file

@ -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());

View file

@ -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<string, "static" | "semi-static" | "dynamic"> = {
// 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<string, number> = {
"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,
};
}

View file

@ -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",
);
});
});

View file

@ -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");
// ═══════════════════════════════════════════════════════════════════════════

View file

@ -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",
);
});