diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 1b495c417..5598d05e1 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -52,6 +52,7 @@ import { checkNeedsReassessment, checkNeedsRunUat, } from "./auto-prompts.js"; +import { resolveModelWithFallbacksForUnit } from "./preferences-models.js"; // ─── Types ──────────────────────────────────────────────────────────────── @@ -423,6 +424,7 @@ export const DISPATCH_RULES: DispatchRule[] = [ midTitle, researchReadySlices, basePath, + resolveModelWithFallbacksForUnit("subagent")?.primary, ), }; }, @@ -510,6 +512,7 @@ export const DISPATCH_RULES: DispatchRule[] = [ sid, sTitle, basePath, + resolveModelWithFallbacksForUnit("subagent")?.primary, ), }; }, @@ -548,6 +551,7 @@ export const DISPATCH_RULES: DispatchRule[] = [ const sid = state.activeSlice.id; const sTitle = state.activeSlice.title; const maxParallel = reactiveConfig.max_parallel ?? 2; + const subagentModel = reactiveConfig.subagent_model ?? resolveModelWithFallbacksForUnit("subagent")?.primary; // Dry-run mode: max_parallel=1 means graph is derived and logged but // execution remains sequential @@ -618,6 +622,7 @@ export const DISPATCH_RULES: DispatchRule[] = [ sTitle, selected, basePath, + subagentModel, ), }; } catch (err) { diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 28217afd6..557cd77e7 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -1926,6 +1926,7 @@ export async function buildReassessRoadmapPrompt( export async function buildReactiveExecutePrompt( mid: string, midTitle: string, sid: string, sTitle: string, readyTaskIds: string[], base: string, + subagentModel?: string, ): Promise { const { loadSliceTaskIO, deriveTaskGraph, graphMetrics } = await import("./reactive-graph.js"); @@ -1970,10 +1971,11 @@ export async function buildReactiveExecutePrompt( { carryForwardPaths: depPaths }, ); + const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : ""; subagentSections.push([ `### ${tid}: ${tTitle}`, "", - "Use this as the prompt for a `subagent` call:", + `Use this as the prompt for a \`subagent\` call${modelSuffix}:`, "", "```", taskPrompt, @@ -2049,15 +2051,17 @@ export async function buildParallelResearchSlicesPrompt( midTitle: string, slices: Array<{ id: string; title: string }>, basePath: string, + subagentModel?: string, ): Promise { // Build individual research-slice prompts for each slice const subagentSections: string[] = []; + const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : ""; for (const slice of slices) { const slicePrompt = await buildResearchSlicePrompt(mid, midTitle, slice.id, slice.title, basePath); subagentSections.push([ `### ${slice.id}: ${slice.title}`, "", - "Use this as the prompt for a `subagent` call (agent: `gsd-executor` or the default agent):", + `Use this as the prompt for a \`subagent\` call${modelSuffix} (agent: \`gsd-executor\` or the default agent):`, "", "```", slicePrompt, @@ -2077,6 +2081,7 @@ export async function buildParallelResearchSlicesPrompt( export async function buildGateEvaluatePrompt( mid: string, midTitle: string, sid: string, sTitle: string, base: string, + subagentModel?: string, ): Promise { // Pull only the gates this turn actually owns (Q3/Q4). Filter via the // registry so that scope:"slice" gates owned by other turns (Q8) can't @@ -2128,10 +2133,11 @@ export async function buildGateEvaluatePrompt( "- `findings`: detailed markdown findings (or empty if omitted)", ].join("\n"); + const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : ""; subagentSections.push([ `### ${def.id}: ${def.question}`, "", - "Use this as the prompt for a `subagent` call:", + `Use this as the prompt for a \`subagent\` call${modelSuffix}:`, "", "```", subPrompt, diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index 3a336f9ee..bf31468e9 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -9,6 +9,7 @@ import { debugTime } from "../debug-logger.js"; import { loadPrompt, getTemplatesDir } from "../prompt-loader.js"; import { readForensicsMarker } from "../forensics.js"; import { resolveAllSkillReferences, renderPreferencesForSystemPrompt, loadEffectiveGSDPreferences } from "../preferences.js"; +import { resolveModelWithFallbacksForUnit } from "../preferences-models.js"; import { resolveSkillReference } from "../preferences-skills.js"; import { resolveGsdRootFile, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, relSliceFile, relSlicePath, relTaskFile } from "../paths.js"; import { ensureCodebaseMapFresh, readCodebaseMap } from "../codebase-generator.js"; @@ -175,7 +176,13 @@ export async function buildBeforeAgentStartResult( const forensicsInjection = !injection ? buildForensicsContextInjection(process.cwd(), event.prompt) : null; const worktreeBlock = buildWorktreeContextBlock(); - const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`; + + const subagentModelConfig = resolveModelWithFallbacksForUnit("subagent"); + const subagentModelBlock = subagentModelConfig + ? `\n\n## Subagent Model\n\nWhen spawning subagents via the \`subagent\` tool, always pass \`model: "${subagentModelConfig.primary}"\` in the tool call parameters. Never omit this — always specify it explicitly.` + : ""; + + const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}${subagentModelBlock}`; stopContextTimer({ systemPromptSize: fullSystem.length, diff --git a/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts b/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts new file mode 100644 index 000000000..9c17c13a0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts @@ -0,0 +1,267 @@ +/** + * Regression tests for subagent model preference wiring. + * + * Fixes: subagent_model config in reactive_execution was validated and stored + * but never passed through to subagent dispatch instruction strings, so the + * executing agent autonomously chose "sonnet" instead of the configured model. + * + * Issue: gsd-build/gsd-2#4078 + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { validatePreferences } from "../preferences-validation.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const promptsSrc = readFileSync(join(__dirname, "..", "auto-prompts.ts"), "utf-8"); +const dispatchSrc = readFileSync(join(__dirname, "..", "auto-dispatch.ts"), "utf-8"); + +// ─── Preference Validation ──────────────────────────────────────────────── + +test("reactive_execution: subagent_model is preserved in validated preferences", () => { + const result = validatePreferences({ + reactive_execution: { + enabled: true, + max_parallel: 2, + isolation_mode: "same-tree", + subagent_model: "claude-opus-4-6", + }, + }); + assert.equal(result.errors.length, 0); + assert.equal( + result.preferences.reactive_execution?.subagent_model, + "claude-opus-4-6", + "subagent_model should be preserved through validation", + ); +}); + +test("reactive_execution: subagent_model rejects empty string", () => { + const result = validatePreferences({ + reactive_execution: { + enabled: true, + max_parallel: 2, + isolation_mode: "same-tree", + subagent_model: "", + } as any, + }); + assert.ok( + result.errors.some((e) => e.includes("subagent_model")), + "empty subagent_model should produce a validation error", + ); +}); + +// ─── Structural: Prompt Builders Accept subagentModel ──────────────────── + +test("buildReactiveExecutePrompt: accepts subagentModel parameter", () => { + const fnStart = promptsSrc.indexOf("export async function buildReactiveExecutePrompt"); + assert.ok(fnStart !== -1, "buildReactiveExecutePrompt should be exported"); + const signature = promptsSrc.slice(fnStart, fnStart + 300); + assert.ok( + signature.includes("subagentModel"), + "buildReactiveExecutePrompt should accept a subagentModel parameter", + ); +}); + +test("buildParallelResearchSlicesPrompt: accepts subagentModel parameter", () => { + const fnStart = promptsSrc.indexOf("export async function buildParallelResearchSlicesPrompt"); + assert.ok(fnStart !== -1, "buildParallelResearchSlicesPrompt should be exported"); + const signature = promptsSrc.slice(fnStart, fnStart + 300); + assert.ok( + signature.includes("subagentModel"), + "buildParallelResearchSlicesPrompt should accept a subagentModel parameter", + ); +}); + +test("buildGateEvaluatePrompt: accepts subagentModel parameter", () => { + const fnStart = promptsSrc.indexOf("export async function buildGateEvaluatePrompt"); + assert.ok(fnStart !== -1, "buildGateEvaluatePrompt should be exported"); + const signature = promptsSrc.slice(fnStart, fnStart + 300); + assert.ok( + signature.includes("subagentModel"), + "buildGateEvaluatePrompt should accept a subagentModel parameter", + ); +}); + +// ─── Structural: Instruction Strings Inject Model ──────────────────────── + +test("buildReactiveExecutePrompt: instruction string uses subagentModel when set", () => { + const fnStart = promptsSrc.indexOf("export async function buildReactiveExecutePrompt"); + const fnEnd = promptsSrc.indexOf("\nexport async function", fnStart + 1); + const fnBody = promptsSrc.slice(fnStart, fnEnd); + assert.ok( + fnBody.includes("subagentModel"), + "buildReactiveExecutePrompt body should reference subagentModel", + ); + // The instruction line must be dynamic (not a plain string literal) + assert.ok( + !fnBody.includes('"Use this as the prompt for a `subagent` call:"'), + "instruction should not be a plain static string — model must be injectable", + ); +}); + +test("buildParallelResearchSlicesPrompt: instruction string uses subagentModel when set", () => { + const fnStart = promptsSrc.indexOf("export async function buildParallelResearchSlicesPrompt"); + const fnEnd = promptsSrc.indexOf("\nexport async function", fnStart + 1); + const fnBody = promptsSrc.slice(fnStart, fnEnd); + assert.ok( + fnBody.includes("subagentModel"), + "buildParallelResearchSlicesPrompt body should reference subagentModel", + ); +}); + +test("buildGateEvaluatePrompt: instruction string uses subagentModel when set", () => { + const fnStart = promptsSrc.indexOf("export async function buildGateEvaluatePrompt"); + const fnEnd = promptsSrc.indexOf("\nexport async function", fnStart + 1); + const fnBody = promptsSrc.slice(fnStart, fnEnd); + assert.ok( + fnBody.includes("subagentModel"), + "buildGateEvaluatePrompt body should reference subagentModel", + ); +}); + +// ─── Structural: Dispatch Wires Model to Prompt Builders ───────────────── + +test("auto-dispatch: passes model to buildReactiveExecutePrompt", () => { + // Find the reactive-execute dispatch rule + const ruleStart = dispatchSrc.indexOf("reactive-execute (parallel dispatch)"); + assert.ok(ruleStart !== -1, "reactive-execute dispatch rule should exist"); + const ruleBlock = dispatchSrc.slice(ruleStart, ruleStart + 1000); + assert.ok( + ruleBlock.includes("subagent_model") || ruleBlock.includes("subagentModel"), + "reactive-execute rule should resolve and pass the subagent model", + ); +}); + +test("auto-dispatch: passes model to buildParallelResearchSlicesPrompt", () => { + const callIdx = dispatchSrc.indexOf("buildParallelResearchSlicesPrompt("); + assert.ok(callIdx !== -1, "buildParallelResearchSlicesPrompt call should exist"); + // The call site should pass a model argument (not just 4 args) + const callSite = dispatchSrc.slice(callIdx, callIdx + 300); + assert.ok( + callSite.includes("subagentModel") || callSite.includes("resolveModelWithFallbacksForUnit"), + "buildParallelResearchSlicesPrompt call should include model argument", + ); +}); + +test("auto-dispatch: passes model to buildGateEvaluatePrompt", () => { + const callIdx = dispatchSrc.indexOf("buildGateEvaluatePrompt("); + assert.ok(callIdx !== -1, "buildGateEvaluatePrompt call should exist"); + const callSite = dispatchSrc.slice(callIdx, callIdx + 300); + assert.ok( + callSite.includes("subagentModel") || callSite.includes("resolveModelWithFallbacksForUnit"), + "buildGateEvaluatePrompt call should include model argument", + ); +}); + +// ─── Integration: Prompt Output Contains Model String ──────────────────── + +test("buildReactiveExecutePrompt: output contains model string when subagentModel provided", async (t) => { + const { buildReactiveExecutePrompt } = await import("../auto-prompts.ts"); + const repo = mkdtempSync(join(tmpdir(), "gsd-subagent-model-reactive-")); + t.after(() => rmSync(repo, { recursive: true, force: true })); + + const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01"); + mkdirSync(join(gsd, "tasks"), { recursive: true }); + + writeFileSync( + join(gsd, "S01-PLAN.md"), + [ + "# S01: Test Slice", + "", + "**Goal:** Verify model injection", + "**Demo:** Model appears in subagent prompt", + "", + "## Tasks", + "", + "- [ ] **T01: Task One** `est:15m`", + " Do something.", + "", + ].join("\n"), + ); + + writeFileSync( + join(gsd, "tasks", "T01-PLAN.md"), + [ + "# T01: Task One", + "", + "## Description", + "Do something.", + "", + "## Inputs", + "", + "- `src/config.json` — Config", + "", + "## Expected Output", + "", + "- `src/out.ts` — Result", + ].join("\n"), + ); + + const prompt = await buildReactiveExecutePrompt( + "M001", "Test Milestone", "S01", "Test Slice", + ["T01"], repo, "claude-opus-4-6", + ); + + assert.ok( + prompt.includes('model: "claude-opus-4-6"'), + `Prompt should contain model instruction. Got:\n${prompt.slice(0, 500)}`, + ); +}); + +test("buildReactiveExecutePrompt: no model instruction when subagentModel omitted", async (t) => { + const { buildReactiveExecutePrompt } = await import("../auto-prompts.ts"); + const repo = mkdtempSync(join(tmpdir(), "gsd-subagent-model-none-")); + t.after(() => rmSync(repo, { recursive: true, force: true })); + + const gsd = join(repo, ".gsd", "milestones", "M001", "slices", "S01"); + mkdirSync(join(gsd, "tasks"), { recursive: true }); + + writeFileSync( + join(gsd, "S01-PLAN.md"), + [ + "# S01: Test Slice", + "", + "**Goal:** Verify no model when omitted", + "**Demo:** No model string", + "", + "## Tasks", + "", + "- [ ] **T01: Task One** `est:15m`", + " Do something.", + "", + ].join("\n"), + ); + + writeFileSync( + join(gsd, "tasks", "T01-PLAN.md"), + [ + "# T01: Task One", + "", + "## Description", + "Do something.", + "", + "## Inputs", + "", + "- `src/config.json` — Config", + "", + "## Expected Output", + "", + "- `src/out.ts` — Result", + ].join("\n"), + ); + + const prompt = await buildReactiveExecutePrompt( + "M001", "Test Milestone", "S01", "Test Slice", + ["T01"], repo, + // no subagentModel + ); + + assert.ok( + !prompt.includes('with model:'), + "Prompt should not contain model instruction when subagentModel is omitted", + ); +});