diff --git a/tests/cline-beforetool-upgrade.test.cjs b/tests/cline-beforetool-upgrade.test.cjs new file mode 100644 index 000000000..08cb90491 --- /dev/null +++ b/tests/cline-beforetool-upgrade.test.cjs @@ -0,0 +1,166 @@ +'use strict'; + +/** + * cline beforeTool plugin UPGRADE — ADR-1239 / #2090 AC (upgrade 1). + * + * Proves the `.clinerules/hooks/PreToolUse` file-convention planning-artifact + * guard is re-implemented as a real Cline SDK `AgentPlugin.hooks.beforeTool` + * handler, delivered through the negotiated `hookBus: host` interface point. + * Guard semantics are preserved EXACTLY from the PreToolUse script: fail-open, + * cancel (skip) write-class calls targeting `.planning/`, pass through + * everything else. + * + * The adapter is exercised directly (mocked SDK payload shape) since the real + * `@cline/sdk` is a fast-moving package set not linked at test time — same + * pattern as the VS Code reference binding (tests/fixtures/vscode-host-binding.cjs). + * + * Cite: + * https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx + * — "Lifecycle hooks ... include beforeRun, afterRun, beforeModel, + * afterModel, beforeTool, afterTool, and onEvent." + * https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md + * — beforeTool({ tool, input }) => { skip: true, reason } | undefined + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + WRITE_TOOL_PATTERN, + PLANNING_PATH_PATTERN, + PLANNING_GUARD_REASON, + evaluateBeforeTool, + clineGsdPlugin, +} = require('../gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs'); + +// -- upgrade 1: write-class detection --------------------------------------- + +test('WRITE_TOOL_PATTERN matches the write-class tool verbs (parity with PreToolUse)', () => { + const writeVerbs = ['write_to_file', 'edit_file', 'replace_in_file', 'create_file', + 'delete_file', 'remove_file', 'append_to_file', 'apply_patch', 'insert_edit', 'mkdir']; + for (const v of writeVerbs) { + assert.ok(WRITE_TOOL_PATTERN.test(v), `write-class verb must match: ${v}`); + } +}); + +test('WRITE_TOOL_PATTERN does NOT match read-class tools', () => { + const readVerbs = ['read_file', 'list_files', 'search_files', 'execute_command', 'ask_user']; + for (const v of readVerbs) { + assert.ok(!WRITE_TOOL_PATTERN.test(v), `read-class verb must NOT match: ${v}`); + } +}); + +// -- upgrade 1: planning-path detection ------------------------------------- + +test('PLANNING_PATH_PATTERN matches .planning/ paths on posix + windows separators', () => { + const planningPaths = ['.planning/state.md', '.planning/phases/1/PLAN.md', + '/home/u/proj/.planning/config.json', 'proj\\.planning\\foo', './.planning/x']; + for (const p of planningPaths) { + assert.ok(PLANNING_PATH_PATTERN.test(p), `planning path must match: ${p}`); + } +}); + +test('PLANNING_PATH_PATTERN does NOT match non-planning paths', () => { + const other = ['src/planning-utils.ts', 'docs/plan.md', '.planning-readme.txt']; + // .planning-readme.txt must not match (boundary after .planning required) + for (const p of ['src/planning-utils.ts', 'docs/plan.md']) { + assert.ok(!PLANNING_PATH_PATTERN.test(p), `non-planning path must NOT match: ${p}`); + } +}); + +// -- upgrade 1: evaluateBeforeTool — the guard decision ---------------------- + +test('write-class tool targeting .planning/ is SKIPPED (cancel)', () => { + const result = evaluateBeforeTool({ + tool: { name: 'write_to_file' }, + input: { path: '.planning/phases/1/PLAN.md', content: '...' }, + }); + assert.equal(result.decision, 'skip'); + assert.equal(result.reason, PLANNING_GUARD_REASON); +}); + +test('write-class tool targeting a NON-planning path is ALLOWED', () => { + const result = evaluateBeforeTool({ + tool: { name: 'write_to_file' }, + input: { path: 'src/index.ts', content: '...' }, + }); + assert.equal(result.decision, 'allow'); + assert.equal(result.reason, undefined); +}); + +test('read-class tool targeting .planning/ is ALLOWED (reads are safe)', () => { + const result = evaluateBeforeTool({ + tool: { name: 'read_file' }, + input: { path: '.planning/state.md' }, + }); + assert.equal(result.decision, 'allow'); +}); + +test('write tool with .planning/ in a non-PATH field (content body) is ALLOWED', () => { + // A doc that merely mentions ".planning/" in its body is never falsely blocked — + // only PATH-bearing field values are inspected (parity with PreToolUse). + const result = evaluateBeforeTool({ + tool: { name: 'write_to_file' }, + input: { path: 'docs/guide.md', content: 'see .planning/ for details' }, + }); + assert.equal(result.decision, 'allow'); +}); + +test('write tool with .planning/ in an array of paths is SKIPPED', () => { + const result = evaluateBeforeTool({ + tool: { name: 'apply_patch' }, + input: { paths: ['src/a.ts', '.planning/config.json'] }, + }); + assert.equal(result.decision, 'skip'); +}); + +// -- upgrade 1: fail-open on malformed/missing input ------------------------ + +test('evaluateBeforeTool fails OPEN on null tool/input (never throws, never blocks)', () => { + assert.equal(evaluateBeforeTool({ tool: null, input: null }).decision, 'allow'); + assert.equal(evaluateBeforeTool({}).decision, 'allow'); + assert.equal(evaluateBeforeTool(null).decision, 'allow'); +}); + +test('evaluateBeforeTool fails OPEN on a tool with no name', () => { + assert.equal(evaluateBeforeTool({ tool: {}, input: { path: '.planning/x' } }).decision, 'allow'); +}); + +// -- upgrade 1: the AgentPlugin wrapper shape ------------------------------- + +test('clineGsdPlugin is an AgentPlugin with a beforeTool hook', () => { + assert.equal(typeof clineGsdPlugin, 'object'); + assert.equal(clineGsdPlugin.name, 'gsd-planning-guard'); + assert.equal(typeof clineGsdPlugin.setup, 'function'); +}); + +test('clineGsdPlugin.setup returns hooks.beforeTool that maps skip→{skip,reason}', () => { + const { hooks } = clineGsdPlugin.setup({ agentId: 'test-agent' }); + assert.equal(typeof hooks.beforeTool, 'function'); + // planning write → { skip: true, reason } + const blocked = hooks.beforeTool({ + tool: { name: 'write_to_file' }, + input: { path: '.planning/state.md' }, + }); + assert.deepEqual(blocked, { skip: true, reason: PLANNING_GUARD_REASON }); +}); + +test('clineGsdPlugin.beforeTool returns undefined for allowed calls (SDK contract)', () => { + const { hooks } = clineGsdPlugin.setup({ agentId: 'test-agent' }); + const allowed = hooks.beforeTool({ + tool: { name: 'write_to_file' }, + input: { path: 'src/foo.ts' }, + }); + assert.equal(allowed, undefined, 'undefined = allow (Cline SDK beforeTool contract)'); +}); + +// -- upgrade 1: parity with the existing PreToolUse script semantics -------- + +test('the guard reason matches the PreToolUse script errorMessage contract', () => { + // The file-convention hook wrote { cancel:true, errorMessage:'GSD: ...' }. + // The SDK plugin maps cancel→skip and errorMessage→reason. The user-visible + // message text is preserved so the guard behaves identically to the user. + assert.ok(typeof PLANNING_GUARD_REASON === 'string' && PLANNING_GUARD_REASON.length > 0); + assert.ok(PLANNING_GUARD_REASON.includes('.planning/'), + 'reason must explain the .planning/ protection so the user can act on it'); +}); diff --git a/tests/cline-dispatch-degradation.test.cjs b/tests/cline-dispatch-degradation.test.cjs new file mode 100644 index 000000000..1b8b9707e --- /dev/null +++ b/tests/cline-dispatch-degradation.test.cjs @@ -0,0 +1,101 @@ +'use strict'; + +/** + * cline dispatch DEGRADATION — ADR-1239 / #2090. + * + * Proves cline's dispatch STAYS degraded/flat and is NEVER silently upgraded to + * the programmatic-cli profile baseline's full nested/background dispatch. + * Cline's own docs (docs/features/subagents.mdx) restrict subagents to a single + * level, read-only toolkit, no nested spawning — so claiming full dispatch would + * misrepresent a documented host restriction and violate the fail-closed + * negotiation contract. This is the cline counterpart to cursor's dispatch + * UPGRADE (#2089), asserting the OPPOSITE invariant: cline flattens. + * + * Cite: + * https://github.com/cline/cline/blob/main/docs/features/subagents.mdx + * — "subagents are restricted from editing files, using the browser, + * accessing MCP servers, or creating nested subagents." + * https://github.com/cline/cline/blob/main/docs/features/subagents.mdx + * — "They are explicitly prohibited from ... spawning other subagents." + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + shouldFlattenDispatch, + degradationFor, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CLN_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cline', 'capability.json'), 'utf8'), +); +const CLN_DISPATCH = CLN_CAP.runtime.hostIntegration.dispatch; + +// -- cline dispatch axes: documented restrictions ---------------------------- + +test('cline dispatch declares namedDispatch but NOT nested (single-level only)', () => { + assert.equal(CLN_DISPATCH.namedDispatch, true, + 'cite agents-squad example — start_subagent(preset:..., task:...) named dispatch'); + assert.equal(CLN_DISPATCH.nested, false, + 'cite subagents.mdx — subagents cannot create nested subagents'); +}); + +test('cline dispatch respects maxDepth: 1 (the documented hard limit)', () => { + assert.equal(CLN_DISPATCH.maxDepth, 1, + 'cite subagents.mdx — "explicitly prohibited from ... spawning other subagents"'); +}); + +test('cline subagentToolkit is read-only (no write/browser/mcp for subagents)', () => { + assert.equal(CLN_DISPATCH.subagentToolkit, 'read-only', + 'cite subagents.mdx — "strictly limited to read-only operations"'); +}); + +test('cline backgroundDispatch is false (background commands run, but no nested-dispatch)', () => { + assert.equal(CLN_DISPATCH.background, true, + 'cite subagents.mdx — "Commands executed by subagents run in the background"'); + assert.equal(CLN_DISPATCH.backgroundDispatch, false, + 'cite subagents.mdx — cannot spawn nested subagents from a background context'); +}); + +// -- shouldFlattenDispatch: cline MUST flatten (degraded) -------------------- + +test('shouldFlattenDispatch(cline) is true — waves run inline (the #853 rule)', () => { + assert.equal(shouldFlattenDispatch(CLN_DISPATCH), true, + 'cline has backgroundDispatch:false → GSD must force-flatten (run inline)'); +}); + +test('a hypothetical full-upgrade (backgroundDispatch:true) would NOT flatten — proving the discriminator', () => { + const hypothetical = { ...CLN_DISPATCH, backgroundDispatch: true, nested: true, maxDepth: 2, subagentToolkit: 'full' }; + assert.equal(shouldFlattenDispatch(hypothetical), false, + 'only background:true AND backgroundDispatch:true escapes flattening — cline lacks both'); +}); + +// -- degradationFor: cline dispatch is 'degraded' / flat --------------------- + +test('degradationFor("dispatch", cline) is degraded with the flat-dispatch fallback', () => { + const result = degradationFor('dispatch', CLN_CAP.runtime.hostIntegration); + assert.equal(result.level, 'degraded', + 'maxDepth:1 (flat) is a degraded dispatch surface, never full'); + assert.equal(result.fallback, 'flat dispatch — waves run inline'); + assert.notEqual(result.level, 'full', + 'cline dispatch must NEVER be classified as full — that would misrepresent the host'); +}); + +test('cline dispatch is never silently upgraded to the programmatic-cli baseline', () => { + // The full baseline requires nested + maxDepth>=2 + subagentToolkit 'full'. + // Cline violates ALL three (nested:false, maxDepth:1, read-only) — so it must + // stay degraded regardless of any negotiation defaults. + const result = degradationFor('dispatch', CLN_CAP.runtime.hostIntegration); + assert.notEqual(result.level, 'full'); +}); + +// -- boundary: maxDepth 1 vs 0 vs -1 ---------------------------------------- + +test('maxDepth 1 is flat (NOT absent, NOT unbounded)', () => { + assert.ok(CLN_DISPATCH.maxDepth > 0, 'maxDepth must be positive (not absent/single-agent)'); + assert.notEqual(CLN_DISPATCH.maxDepth, -1, 'cline is NOT unbounded — depth-1 is the documented limit'); + assert.notEqual(CLN_DISPATCH.maxDepth, 0, 'maxDepth:0 would mean no named dispatch — cline HAS named dispatch'); +}); diff --git a/tests/cline-imperative-reference.test.cjs b/tests/cline-imperative-reference.test.cjs new file mode 100644 index 000000000..aa475be80 --- /dev/null +++ b/tests/cline-imperative-reference.test.cjs @@ -0,0 +1,114 @@ +// allow-test-rule: AC2 requires asserting no `runtime === 'cline'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2090) +'use strict'; + +/** + * cline imperative reference host — ADR-1239 Phase D / #2090 (EoS/cline). + * + * Proves cline is driven through the PUBLIC Host-Integration Interface (the + * imperative adapter), that its negotiated axes classify + negotiate correctly, + * that negotiation fails CLOSED on a corrupted descriptor, that the + * Context7-sourced dispatch classification STAYS degraded/flat (cline subagents + * are documented as single-level read-only — maxDepth:1 — and must never be + * silently upgraded to full nested/background dispatch), and that the migration + * retired the hardcoded `runtime === 'cline'` / `isCline` branches (folded into + * descriptor-driven `runtime.hostBehaviors`). + * + * Contrast with cursor (#2089): cursor's dispatch got an UPGRADE (background + + * nested). cline's dispatch is a deliberate DEGRADATION that must be preserved — + * upgrading it would misrepresent a documented host restriction and violate the + * fail-closed negotiation contract (see dispatch-degradation test). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); +const { + profileOf, + negotiateHostCapabilities, + shouldFlattenDispatch, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CLN_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cline', 'capability.json'), 'utf8'), +); +const CLN_AXES = CLN_CAP.runtime.hostIntegration; + +// -- AC2: driven through the public interface (imperative adapter) ----------- + +test('createImperativeAdapter classifies cline as imperative + composes the registry', () => { + const adapter = createImperativeAdapter({ runtime: 'cline' }); + assert.equal(adapter.kind, 'imperative'); + assert.equal(adapter.runtime, 'cline'); + assert.ok(adapter.registry && typeof adapter.registry === 'object'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('cline axes classify as the programmatic-cli reference profile (imperative embedding)', () => { + assert.equal(profileOf(CLN_AXES), 'programmatic-cli'); +}); + +// -- AC3: all axes populated + validated ------------------------------------- + +test('cline descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => { + assert.equal(CLN_AXES.embeddingMode, 'imperative'); + assert.equal(CLN_AXES.commandSurface, 'slash-file'); + assert.equal(CLN_AXES.modelMode, 'active'); + assert.equal(CLN_AXES.hookBus, 'host'); + assert.equal(CLN_AXES.stateIO, 'filesystem'); + assert.equal(CLN_AXES.transport, 'mcp'); + assert.equal(CLN_AXES.runtime, 'node'); + const d = CLN_AXES.dispatch; + assert.equal(d.namedDispatch, true); + assert.equal(d.nested, false); + assert.equal(d.maxDepth, 1); + assert.equal(d.background, true); + assert.equal(d.subagentToolkit, 'read-only'); + assert.equal(d.backgroundDispatch, false); +}); + +// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------ + +test('negotiateHostCapabilities never throws for cline, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CLN_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CLN_AXES, embeddingMode: 'future-unknown' })); +}); + +test('a partial/empty cline descriptor degrades to the safe floor, not the programmatic-cli baseline', () => { + const result = negotiateHostCapabilities({}); + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed'); + assert.equal(result.effective.hookBus, 'none'); + assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']); + assert.ok(result.warnings.length > 0); +}); + +// -- AC2: the hardcoded branches are retired --------------------------------- + +test('cline descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => { + const hb = CLN_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object'); + assert.equal(hb.reapplyCommand, '/gsd-update --reapply'); + assert.equal(hb.frontmatterDialect, 'cline'); + assert.equal(hb.skipSharedHooksInstall, true); + assert.equal(hb.localTargetIsProjectRoot, true); + assert.equal(hb.clineRulesSurface, true); + assert.equal(hb.localCommandsViaRules, true); +}); + +test('no `runtime === "cline"` string-equality branch remains in the install source (AC2)', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts', 'src/runtime-hooks-surface.cts']) { + const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + const offenders = strip(src).match(/runtime\s*[!=]==\s*'cline'/g) || []; + assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cline' branch may remain in ${rel}; found: ${offenders.join(', ')}`); + } +}); diff --git a/tests/cline-model-override-upgrade.test.cjs b/tests/cline-model-override-upgrade.test.cjs new file mode 100644 index 000000000..458d9207a --- /dev/null +++ b/tests/cline-model-override-upgrade.test.cjs @@ -0,0 +1,161 @@ +'use strict'; + +/** + * cline createAgentModel UPGRADE — ADR-1239 / #2090 AC (upgrade 2). + * + * Proves GSD's per-subagent `model_overrides` / `model_profile_overrides` + * resolution (already used for OpenCode/Codex) now applies to cline subagents + * via `DefaultGateway.createAgentModel({ providerId, modelId })`, instead of + * leaving model selection untouched. Cline's `modelMode: active` (the host + * exposes provider registration via createLlmsRuntime) is what makes this + * wiring possible — passive hosts can only inject a per-agent model field. + * + * The binding resolves the createAgentModel call parameters from GSD config; + * the real gateway call is the host's responsibility (mocked here, same pattern + * as tests/fixtures/vscode-host-binding.cjs). + * + * Cite: + * https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx + * — createAgentModel({ providerId, modelId }) returns an AgentModel + * https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md + * — createLlmsRuntime(...) provider registry (modelMode: active) + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + resolveClineAgentModelParams, + inferProviderId, + DEFAULT_CLINE_PROVIDER_ID, +} = require('../gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs'); + +// -- upgrade 2: provider inference from a model id -------------------------- + +test('inferProviderId maps anthropic model ids to "anthropic"', () => { + assert.equal(inferProviderId('claude-sonnet-4-5'), 'anthropic'); + assert.equal(inferProviderId('claude-opus-4-1'), 'anthropic'); +}); + +test('inferProviderId maps openai model ids to "openai"', () => { + assert.equal(inferProviderId('gpt-4o'), 'openai'); + assert.equal(inferProviderId('o1-preview'), 'openai'); +}); + +test('inferProviderId falls back to DEFAULT_CLINE_PROVIDER_ID for unknown ids', () => { + assert.equal(inferProviderId('some-custom-model'), DEFAULT_CLINE_PROVIDER_ID); + assert.equal(inferProviderId(''), DEFAULT_CLINE_PROVIDER_ID); +}); + +// -- upgrade 2: model_overrides resolution ---------------------------------- + +test('a per-agent model_overrides entry resolves to createAgentModel params', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { planner: 'claude-sonnet-4-5' }, + modelProfileOverrides: null, + profile: 'balanced', + }); + assert.deepEqual(result, { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' }); +}); + +test('model_overrides takes precedence over model_profile_overrides', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { planner: 'claude-sonnet-4-5' }, + modelProfileOverrides: { balanced: { planner: 'claude-opus-4-1' } }, + profile: 'balanced', + }); + assert.equal(result.modelId, 'claude-sonnet-4-5', 'direct model_overrides wins'); +}); + +test('model_profile_overrides resolves when no direct model_overrides entry exists', () => { + const result = resolveClineAgentModelParams({ + agentType: 'executor', + modelOverrides: null, + modelProfileOverrides: { balanced: { executor: 'gpt-4o' } }, + profile: 'balanced', + }); + assert.deepEqual(result, { providerId: 'openai', modelId: 'gpt-4o' }); +}); + +// -- upgrade 2: no override → null (gateway default applies) ---------------- + +test('returns null when no override is configured (gateway picks the default model)', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: null, + modelProfileOverrides: null, + profile: 'balanced', + }); + assert.equal(result, null, 'null = no override; host gateway default applies'); +}); + +test('returns null when the agentType has no matching override', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { executor: 'claude-sonnet-4-5' }, + modelProfileOverrides: null, + profile: 'balanced', + }); + assert.equal(result, null); +}); + +// -- upgrade 2: the gateway binding (createAgentModel call shape) ----------- + +test('a resolved override drives DefaultGateway.createAgentModel with the right params', () => { + // Simulate the host gateway (mocked — the real @cline/sdk is not linked here). + const calls = []; + const fakeGateway = { + createAgentModel(selection) { calls.push(selection); return { providerId: selection.providerId, modelId: selection.modelId }; }, + }; + const params = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { planner: 'claude-sonnet-4-5' }, + modelProfileOverrides: null, + profile: 'balanced', + }); + assert.ok(params, 'override must resolve to non-null params'); + const model = fakeGateway.createAgentModel(params); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' }); + assert.equal(model.modelId, 'claude-sonnet-4-5'); +}); + +test('no override → createAgentModel is NOT called (gateway default, not GSD override)', () => { + const calls = []; + const fakeGateway = { + createAgentModel(selection) { calls.push(selection); return {}; }, + }; + const params = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: null, + modelProfileOverrides: null, + profile: 'balanced', + }); + if (params) fakeGateway.createAgentModel(params); + assert.equal(calls.length, 0, 'no override → gateway must use its own default, GSD does not call createAgentModel'); +}); + +// -- upgrade 2: fail-safe / malformed config -------------------------------- + +test('malformed override values (non-string) are ignored (fail-safe, not crash)', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { planner: 42, executor: 'claude-sonnet-4-5' }, + modelProfileOverrides: null, + profile: 'balanced', + }); + // planner's non-string override is ignored; falls through to null (no executor match for agentType planner) + assert.equal(result, null); +}); + +test('empty-string override is treated as absent', () => { + const result = resolveClineAgentModelParams({ + agentType: 'planner', + modelOverrides: { planner: '' }, + modelProfileOverrides: null, + profile: 'balanced', + }); + assert.equal(result, null); +});