diff --git a/src/resources/extensions/gsd/complexity-classifier.ts b/src/resources/extensions/gsd/complexity-classifier.ts index 871dcd70d..0a72a8957 100644 --- a/src/resources/extensions/gsd/complexity-classifier.ts +++ b/src/resources/extensions/gsd/complexity-classifier.ts @@ -35,6 +35,13 @@ const UNIT_TYPE_TIERS: Record = { // Tier 1 — Light: compact verification turns "run-uat": "light", + // Subagent recon/mapping types: read-only filesystem scans that require no + // substantive reasoning. Only add entries here for verifiably read-only scout + // agents. Do NOT add security-auditor, code-reviewer, or analysis agents here + // -- those belong at standard or heavy (#subagent-tier-policy). + "subagent/codebase-mapper": "light", // read-only FS scan; no deep reasoning required + "subagent/scout": "light", // broad recon; analogous to codebase-mapper + // Tier 2 — Standard: research, routine discussion, slice completion // complete-slice can carry large inlined context; avoid routing it to the // cheapest "light" model by default (#4520). diff --git a/src/resources/extensions/gsd/model-router.ts b/src/resources/extensions/gsd/model-router.ts index 2166f3dc0..82352d306 100644 --- a/src/resources/extensions/gsd/model-router.ts +++ b/src/resources/extensions/gsd/model-router.ts @@ -90,6 +90,7 @@ export const MODEL_CAPABILITY_TIER: Record = { "claude-sonnet-4-6": "standard", "claude-sonnet-4-5-20250514": "standard", "claude-3-5-sonnet-latest": "standard", + "claude-sonnet-5": "standard", // GA on GitHub Copilot, Anthropic, Vertex, Bedrock "gpt-4o": "standard", "gpt-4.1": "standard", "gpt-5.1-codex-max": "standard", @@ -130,6 +131,7 @@ const MODEL_COST_PER_1K_INPUT: Record = { "claude-3-5-haiku-latest": 0.0008, "claude-sonnet-4-6": 0.003, "claude-sonnet-4-5-20250514": 0.003, + "claude-sonnet-5": 0.003, // $3.00/M input; matches Sonnet 4.x pricing "claude-opus-4-6": 0.005, "claude-opus-4-7": 0.005, "claude-opus-4-8": 0.005, @@ -176,6 +178,7 @@ export const MODEL_CAPABILITY_PROFILES: Record = { "claude-fable-5": { coding: 97, debugging: 92, research: 87, reasoning: 97, speed: 30, longContext: 85, instruction: 92 }, "claude-sonnet-4-6": { coding: 85, debugging: 80, research: 75, reasoning: 80, speed: 60, longContext: 75, instruction: 85 }, "claude-sonnet-4-5-20250514": { coding: 85, debugging: 80, research: 75, reasoning: 80, speed: 60, longContext: 75, instruction: 85 }, + "claude-sonnet-5": { coding: 90, debugging: 85, research: 80, reasoning: 87, speed: 55, longContext: 80, instruction: 88 }, "claude-3-5-sonnet-latest": { coding: 82, debugging: 78, research: 72, reasoning: 78, speed: 62, longContext: 70, instruction: 82 }, "claude-haiku-4-5": { coding: 60, debugging: 50, research: 45, reasoning: 50, speed: 95, longContext: 50, instruction: 75 }, "claude-3-5-haiku-latest": { coding: 60, debugging: 50, research: 45, reasoning: 50, speed: 95, longContext: 50, instruction: 75 }, diff --git a/src/resources/extensions/gsd/tests/complexity-classifier.test.ts b/src/resources/extensions/gsd/tests/complexity-classifier.test.ts index ff949268f..d5916049d 100644 --- a/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +++ b/src/resources/extensions/gsd/tests/complexity-classifier.test.ts @@ -204,3 +204,24 @@ describe("ClassificationResult taskMetadata", () => { assert.equal(typeof extractTaskMetadata, "function", "extractTaskMetadata should be a callable function"); }); }); + +// --- subagent recon tier regression --- +// Before this fix, all subagent/* unit types fell through to the +// `UNIT_TYPE_TIERS[unitType] ?? "standard"` default, causing codebase-mapper and +// scout to dispatch to claude-sonnet instead of claude-haiku. + +test("subagent/codebase-mapper classifies as light", () => { + const result = classifyUnitComplexity("subagent/codebase-mapper", "M001", "/tmp/fake"); + assert.equal(result.tier, "light", "codebase-mapper is read-only FS scanning - should be light tier"); +}); + +test("subagent/scout classifies as light", () => { + const result = classifyUnitComplexity("subagent/scout", "M001", "/tmp/fake"); + assert.equal(result.tier, "light", "scout is read-only recon - should be light tier"); +}); + +test("subagent/security-auditor is NOT light (stays at standard default)", () => { + // Deliberate: analysis agents must not be silently downgraded to haiku. + const result = classifyUnitComplexity("subagent/security-auditor", "M001", "/tmp/fake"); + assert.notEqual(result.tier, "light", "security-auditor requires reasoning - must not be light"); +}); diff --git a/src/resources/extensions/gsd/tests/model-router.test.ts b/src/resources/extensions/gsd/tests/model-router.test.ts index 84b96ab03..6232c9fa6 100644 --- a/src/resources/extensions/gsd/tests/model-router.test.ts +++ b/src/resources/extensions/gsd/tests/model-router.test.ts @@ -13,6 +13,7 @@ import { scoreEligibleModels, getEligibleModels, MODEL_CAPABILITY_PROFILES, + MODEL_CAPABILITY_TIER, } from "../model-router.js"; import type { DynamicRoutingConfig, RoutingDecision, ModelCapabilities } from "../model-router.js"; import type { ClassificationResult } from "../complexity-classifier.js"; @@ -1324,3 +1325,53 @@ describe("getModelTier unknown default", () => { assert.equal(heavyModels.length, 0, "Unknown model should NOT be in heavy tier"); }); }); + +// --- claude-sonnet-5 catalog regression (v1.12.0 gap) --- +// Discovered during Edelman Studio M010 model-routing config: claude-sonnet-5 was +// absent from MODEL_CAPABILITY_TIER, causing isKnownModel() to return false and +// the #2192 routing-bypass path to activate for any phase configured with sonnet-5. + +test("claude-sonnet-5 is classified as standard tier in MODEL_CAPABILITY_TIER", () => { + assert.equal( + MODEL_CAPABILITY_TIER["claude-sonnet-5"], + "standard", + "claude-sonnet-5 must be in the tier map so isKnownModel() returns true", + ); +}); + +test("claude-sonnet-5 as ceiling: standard task is NOT bypassed - routing applies normally", () => { + // With sonnet-5 now a known model, resolveModelForComplexity must NOT hit the + // #2192 bail-out. A standard task with a sonnet-5 ceiling should stay on sonnet-5. + const config = { + ...defaultRoutingConfig(), + enabled: true, + tier_models: { light: "claude-haiku-4-5", standard: "claude-sonnet-5", heavy: "claude-opus-4-8" }, + }; + const result = resolveModelForComplexity( + { tier: "standard", reason: "test", downgraded: false }, + { primary: "claude-sonnet-5", fallbacks: [] }, + config, + ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-4-8"], + ); + assert.equal(result.modelId, "claude-sonnet-5", "standard task with sonnet-5 ceiling should use sonnet-5"); + assert.equal(result.wasDowngraded, false, "should not be downgraded when task tier matches ceiling"); + assert.ok(!result.reason?.includes("not in the known tier map"), "must not hit #2192 bypass reason"); +}); + +test("claude-sonnet-5 as ceiling: light task IS downgraded to haiku - routing not bypassed", () => { + // Confirms that with sonnet-5 as a known model, dynamic routing correctly + // downgrades light tasks to tier_models.light (haiku) instead of bypassing. + const config = { + ...defaultRoutingConfig(), + enabled: true, + tier_models: { light: "claude-haiku-4-5", standard: "claude-sonnet-5", heavy: "claude-opus-4-8" }, + }; + const result = resolveModelForComplexity( + { tier: "light", reason: "test", downgraded: false }, + { primary: "claude-sonnet-5", fallbacks: [] }, + config, + ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-4-8"], + ); + assert.equal(result.modelId, "claude-haiku-4-5", "light task with sonnet-5 ceiling must downgrade to haiku"); + assert.equal(result.wasDowngraded, true); +});