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:
parent
76a834cdf6
commit
b20e7b065a
8 changed files with 571 additions and 42 deletions
|
|
@ -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 === "?"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
200
src/resources/extensions/gsd/prompt-ordering.ts
Normal file
200
src/resources/extensions/gsd/prompt-ordering.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
296
src/resources/extensions/gsd/tests/prompt-ordering.test.ts
Normal file
296
src/resources/extensions/gsd/tests/prompt-ordering.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue