From ec39d807c0acc747eb22121ca087313f3fd45fee Mon Sep 17 00:00:00 2001 From: pluginslab <57633278+pluginslab@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:24:01 +0000 Subject: [PATCH] fix(smart-routing): stop overriding sub-agent models chosen by agentRouting (#2310) * fix(smart-routing): stop overriding sub-agent models chosen by agentRouting Smart routing classified the first pass of every query loop, including the loop each sub-agent runs. A sub-agent's prompt is not a user turn, but it was classified as one and the pinned model overwrote the model agentRouting (or the agent definition) had just selected. With both features on, every routed sub-agent silently ran on the strong model. decideTurnModel now returns routed:false when the query loop belongs to a sub-agent, so the parent turn's decision holds for its Agent tool call as documented, and sub-agent runs no longer count toward the routing tally or the per-session allowlist disable. Co-Authored-By: Claude Opus 5.5 * test(smart-routing): cover sub-agent query model preservation * fix(smart-routing): preserve inherited fork models * fix(smart-routing): preserve parent request selection in forks * fix(smart-routing): preserve selection across context boundaries * fix(smart-routing): keep inherited runtime and request selections paired * fix: preserve fork selections across tool and provider changes * fix: preserve model selections across asynchronous context lifetimes * fix: preserve child model policy and recovery origin * fix: validate skill admission before effects and retain recovery ownership * fix: establish ambient fork models against the current provider * fix: preserve assigned child models in plan mode --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: jatmn --- docs/smart-routing.md | 3 + src/Tool.ts | 19 + src/commands/btw/btw.tsx | 23 +- src/query.ts | 97 +- src/query/agentStepLimit.test.ts | 1118 ++++++++++++++++++- src/services/api/smartRouting/index.test.ts | 27 + src/services/api/smartRouting/index.ts | 9 + src/tools/AgentTool/runAgent.output.test.ts | 2 +- src/tools/AgentTool/runAgent.ts | 15 + src/tools/SkillTool/SkillTool.ts | 10 + src/utils/forkedAgent.ts | 99 +- src/utils/hooks/execAgentHook.ts | 6 + 12 files changed, 1409 insertions(+), 19 deletions(-) diff --git a/docs/smart-routing.md b/docs/smart-routing.md index 021cce5ea..d44eb73d3 100644 --- a/docs/smart-routing.md +++ b/docs/smart-routing.md @@ -51,7 +51,10 @@ These set a startup default. An explicit `smartRouting` block in settings always ## Behavior notes - **One decision per turn.** The model is chosen once when your message arrives and held for the whole turn (including its tool calls), so it does not flap mid-turn. +- **Sub-agents are not routed.** A sub-agent's prompt is not a user turn, so smart routing leaves its model alone: it uses its [`agentRouting`](agent-routing.md) route or agent-definition model, and otherwise inherits the turn's model. Default internal forks (such as `/btw` and SDK side questions) inherit the parent's accepted provider request selection, including explicit reasoning settings, across tool-list refreshes. The implicit Agent-tool fork supplies its own model options and does not inherit this request selection. After an active provider switch, including one during compaction or following automatic recovery, ambient forks use the new provider's configured model; explicit provider overrides remain fixed. Side questions without a saved parent request use the configured model selection; allowed explicit agent, hook, and skill model choices take precedence. These explicit choices still obey `availableModels`; inherited/default choices retain their existing policy. Sub-agent runs are not counted in the `/cost` routing summary. +- **Upgrade note.** Explicit agent-definition, inline-skill, or hook models excluded by `availableModels` are rejected. If a configuration previously relied on such a choice, select an allowed model or have the administrator update the allowlist. - **Fallback.** If a simple-routed turn's model call errors (transport or server error), it retries once on the strong model. Aborts and auth/permission/bad-request errors are not retried. +- **Model limits still apply.** Inherited request aliases and reasoning settings do not replace the concrete model used for context limits, session context-window overrides, or compaction. - **Allowlist.** Any model smart routing selects is checked against your org model allowlist (`availableModels`). A disallowed model is coerced to strong; if strong is also disallowed, routing disables itself for the session and the default model is used. Running `/smartroute on` re-enables routing and clears that session disable. - **Same-provider only.** Roles must be model-only `agentModels` entries (or bare model ids). If a role resolves to a cross-provider entry (one with `base_url`/`api_key`), routing silently disables — cross-provider routing is not supported yet. - **Auditing.** `/cost` shows a routing summary: how many turns went simple vs strong, how many escalated to strong via fallback, and an estimated savings line when both models appear in the first-party reference pricing table. That estimate is reference pricing only and may not reflect what your provider/gateway/account actually bills. diff --git a/src/Tool.ts b/src/Tool.ts index 86c10f132..aeb8d1060 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -201,6 +201,25 @@ export type ToolUseContext = { /** Per-agent provider override from agentRouting config */ providerOverride?: { model: string; baseURL: string; apiKey: string } } + /** Shared by copies with the same options; internal options clones retain separate snapshots. */ + queryModelSelection?: { + agentId: ToolUseContext['agentId'] + /** Inherited selection; accepted requests replace it within this options snapshot. */ + current?: { + /** Identity and value invalidate provenance after an explicit model override. */ + options: ToolUseContext['options'] + model: string + /** Selection before routing/resolution, to detect later configuration changes. */ + selectedModel: string + /** Concrete model used for the request, independent of source options. */ + runtimeModel: string + requestModel: string + /** Provider category and optional saved profile; fixed overrides are independent. */ + providerKey: string + /** Default ambient inheritance may follow a provider switch; explicit model choices may not. */ + followsProvider: boolean + } + } abortController: AbortController readFileState: FileStateCache getAppState(): AppState diff --git a/src/commands/btw/btw.tsx b/src/commands/btw/btw.tsx index a54e179e6..381f09073 100644 --- a/src/commands/btw/btw.tsx +++ b/src/commands/btw/btw.tsx @@ -18,8 +18,9 @@ import type { Message } from '../../types/message.js'; import { createAbortController } from '../../utils/abortController.js'; import { saveGlobalConfig } from '../../utils/config.js'; import { errorMessage } from '../../utils/errors.js'; -import { type CacheSafeParams, getLastCacheSafeParams } from '../../utils/forkedAgent.js'; +import { type CacheSafeParams, getLastCacheSafeParams, getQueryModelSelection } from '../../utils/forkedAgent.js'; import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; +import { getDefaultMainLoopModelSetting, parseUserSpecifiedModel } from '../../utils/model/model.js'; import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'; import { runSideQuestion } from '../../utils/sideQuestion.js'; import { asSystemPrompt } from '../../utils/systemPromptType.js'; @@ -209,11 +210,29 @@ async function buildCacheSafeParams(context: ProcessUserInputContext): Promise { const consumedCommandUuids: string[] = [] + // Query clones its context; retained parent references must see the request + // selection too (for post-turn cache-sharing forks). + // Hooks can shallow-copy a context without using createSubagentContext. + // Preserve sharing within this agent, never across an agent boundary. + if ( + !params.toolUseContext.queryModelSelection || + params.toolUseContext.queryModelSelection.agentId !== params.toolUseContext.agentId + ) { + params.toolUseContext.queryModelSelection = { agentId: params.toolUseContext.agentId } + } const terminal = yield* queryLoop(params, consumedCommandUuids) // Only reached if queryLoop returned normally. Skipped on throw (error // propagates through yield*) and on .return() (Return completion closes @@ -842,6 +853,9 @@ async function* queryLoop( // is reassigned within an iteration (queryTracking, messages updates); // the rest are read-only between continue sites. let { toolUseContext } = state + // Existing forks can outlive a provider switch. Refresh before compaction, + // capability checks, or request selection consume their concrete model. + toolUseContext = refreshAmbientQueryModelSelection(toolUseContext) const { messages, autoCompactTracking, @@ -1233,6 +1247,12 @@ async function* queryLoop( } } + // Compaction can await while the active provider changes. Refresh before + // request selection and the tool executor capture this context. + toolUseContext = refreshAmbientQueryModelSelection(toolUseContext) + const followsProvider = !toolUseContext.agentId || + getQueryModelSelection(toolUseContext)?.followsProvider === true + //TODO: no need to set toolUseContext.messages during set-up since it is updated here toolUseContext = { ...toolUseContext, @@ -1265,19 +1285,26 @@ async function* queryLoop( appState.mainLoopModelForSession ?? appState.mainLoopModel ?? getDefaultMainLoopModelSetting() - let currentModel = getRuntimeMainLoopModel({ - permissionMode, - mainLoopModel: parseUserSpecifiedModel(appStateMainLoopModel), - exceeds200kTokens: - permissionMode === 'plan' && - doesMostRecentAssistantMessageExceed200k(messagesForQuery), - }) + const selectedModel = toolUseContext.agentId + ? getQueryModelSelection(toolUseContext)?.requestModel ?? toolUseContext.options.mainLoopModel + : appStateMainLoopModel + // Children already have an assigned model. Root plan aliases must not + // promote that assignment again, even when smart routing is disabled. + let currentModel = toolUseContext.agentId + ? parseUserSpecifiedModel(toolUseContext.options.mainLoopModel) + : getRuntimeMainLoopModel({ + permissionMode, + mainLoopModel: parseUserSpecifiedModel(selectedModel), + exceeds200kTokens: + permissionMode === 'plan' && + doesMostRecentAssistantMessageExceed200k(messagesForQuery), + }) // Smart routing (opt-in): classify once per user turn (transition===undefined) // and pin the decision; reuse the pin on every continuation pass. Applied // BEFORE the blocking-limit math below so the token-budget guard and the // model call agree on the model. Disabled/misconfigured → pin is `routed:false` - // and currentModel keeps today's resolution (byte-for-byte unchanged). + // and currentModel keeps the root's resolution or child's assigned model. if (state.transition === undefined) { pinnedTurnRoute = decideTurnModel({ settings: appState.settings as unknown as Parameters[0]['settings'], @@ -1289,6 +1316,7 @@ async function* queryLoop( turnNumber: deriveUserTurnNumber(messagesForQuery), }, sessionId: getSessionId(), + agentId: toolUseContext.agentId, }) if (pinnedTurnRoute.routed === false && pinnedTurnRoute.justDisabledForSession) { yield createSystemMessage( @@ -1532,6 +1560,23 @@ async function* queryLoop( // callModel performs async provider preparation. params.onModelRequestStart?.() modelRequestLifecycleStarted = true + const requestModel = pinnedTurnRoute?.routed + ? currentModel + : getProviderRequestModel(selectedModel, currentModel) + const queryModelSelection = toolUseContext.queryModelSelection ??= { + agentId: toolUseContext.agentId, + } + const requestSelection = { + options: toolUseContext.options, + model: toolUseContext.options.mainLoopModel, + selectedModel, + runtimeModel: currentModel, + requestModel, + providerKey: getAmbientQueryProviderKey(), + // Automatic model retries may mutate options; that is not an + // explicit user choice and must not change the selection origin. + followsProvider, + } for await (const message of deps.callModel({ messages: prependUserContext( injectRequestOnlyMessages( @@ -1550,9 +1595,7 @@ async function* queryLoop( return appState.toolPermissionContext }, model: currentModel, - requestModel: pinnedTurnRoute?.routed - ? currentModel - : getProviderRequestModel(appStateMainLoopModel, currentModel), + requestModel, ...(config.gates.fastModeEnabled && { fastMode: appState.fastMode, }), @@ -1604,6 +1647,7 @@ async function* queryLoop( reservedTurnCount = turnCount } providerDispatchAccepted = true + queryModelSelection.current = requestSelection params.onProviderDispatchAccepted?.() return true }, @@ -2420,6 +2464,9 @@ async function* queryLoop( if (isWithheldRateLimit) { const fallback = resolveNextFallbackProviderFromState() if (fallback !== null) { + // Recovery needs the origin even if the active provider changed + // while the failed request was in flight; it replaces the pair below. + const selection = getOwnedQueryModelSelection(toolUseContext) const activated = setActiveProviderProfile(fallback.nextProfileId) if (activated) { const fromLabel = fallback.fromProfileId ?? 'previous provider' @@ -2441,7 +2488,23 @@ async function* queryLoop( mainLoopModel: activatedModel, mainLoopModelForSession: null, })) - toolUseContext.options.mainLoopModel = activatedModel + const runtimeModel = parseUserSpecifiedModel(activatedModel) + const options = { ...toolUseContext.options, mainLoopModel: runtimeModel } + // Recovery replaces the pair, not its origin. Clone so retained + // contexts still own their earlier snapshot, even if setAppState + // is a no-op in this child. + toolUseContext = { + ...toolUseContext, options, + queryModelSelection: { + agentId: toolUseContext.agentId, + ...(selection && { current: { + ...selection, options, model: runtimeModel, + selectedModel: activatedModel, runtimeModel, + requestModel: getProviderRequestModel(activatedModel, runtimeModel), + providerKey: getAmbientQueryProviderKey(), + } }), + }, + } } // System informational, NOT an assistant API error. The original // 429 is still withheld upstream, so SDK hosts that terminate on @@ -3101,6 +3164,10 @@ async function* queryLoop( if (updatedToolUseContext.options.refreshTools) { const refreshedTools = updatedToolUseContext.options.refreshTools() if (refreshedTools !== updatedToolUseContext.options.tools) { + // A provider can also switch during the tool. Preserve that transition + // before cloning options, while the old ownership proof is still valid. + updatedToolUseContext = refreshAmbientQueryModelSelection(updatedToolUseContext) + const selection = getQueryModelSelection(updatedToolUseContext) updatedToolUseContext = { ...updatedToolUseContext, options: { @@ -3108,6 +3175,12 @@ async function* queryLoop( tools: refreshedTools, }, } + // Keep earlier sampled/retained contexts valid too. Moving a shared + // holder's options pointer would invalidate their accepted selections. + updatedToolUseContext.queryModelSelection = { + agentId: updatedToolUseContext.agentId, + ...(selection && { current: { ...selection, options: updatedToolUseContext.options } }), + } } } diff --git a/src/query/agentStepLimit.test.ts b/src/query/agentStepLimit.test.ts index 6501d936a..3780fe687 100644 --- a/src/query/agentStepLimit.test.ts +++ b/src/query/agentStepLimit.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, test } from 'bun:test' +import { describe, expect, spyOn, test } from 'bun:test' +import { PassThrough } from 'node:stream' +import { snapshotEnv } from '../test/envSnapshot.js' +import { REAL_PROVIDER_TEST_CHILD_ENV, REAL_PROVIDER_TEST_TIMEOUT_MS, runTestFileWithRealProviders } from '../test/providerModuleIsolation.js' +import { createElement } from 'react' import { z } from 'zod/v4' import { @@ -8,9 +12,11 @@ import { } from '../query.js' import { buildTool, type Tools } from '../Tool.js' import type { QueryDeps } from './deps.js' +import { resolveProviderRequest } from '../services/api/providerConfig.js' import { FallbackTriggeredError } from '../services/api/withRetry.js' import { createAssistantMessage, + createAssistantAPIErrorMessage, createUserMessage, normalizeMessagesForAPI, } from '../utils/messages.js' @@ -20,6 +26,19 @@ import { AGENT_STEP_LIMIT_TOOL_RESULT_PREFIX } from './agentStepLimit.js' import type { Terminal } from './transitions.js' import type { Message } from '../types/message.js' import { dequeueAll, enqueue } from '../utils/messageQueueManager.js' +import * as modelAllowlist from '../utils/model/modelAllowlist.js' +import * as providerProfiles from '../utils/providerProfiles.js' +import { getSessionId, getMainLoopModelOverride, setMainLoopModelOverride } from '../bootstrap/state.js' +import { createCacheSafeParams, createSubagentContext, getLastCacheSafeParams, runForkedAgent, saveCacheSafeParams } from '../utils/forkedAgent.js' +import { clearPostSamplingHooks, registerPostSamplingHook } from '../utils/hooks/postSamplingHooks.js' +import { createFileStateCacheWithSizeLimit } from '../utils/fileStateCache.js' +import { clearSessionContextWindowOverride, getSessionContextWindowOverride, setSessionContextWindowOverride } from '../utils/context.js' +import { isAutoCompactEnabled, shouldAutoCompact } from '../services/compact/autoCompact.js' +import { + clearSmartRoutingSessionDisable, + getRoutingTally, + resetRoutingTally, +} from '../services/api/smartRouting/index.js' const echoCalls: string[] = [] @@ -33,7 +52,7 @@ const echoTool = buildTool({ async prompt() { return '' }, - async call(input) { + async call(input, _context: QueryParams['toolUseContext']) { echoCalls.push(input.text) return { data: `echo:${input.text}` } }, @@ -143,7 +162,1102 @@ async function drain(params: QueryParams): Promise<{ } } +function makeAliasParent(callModel: QueryDeps['callModel']): QueryParams { + const params = makeParams(callModel) + delete params.toolUseContext.agentId + const appState = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ ...appState, mainLoopModel: 'codexplan' }) + params.toolUseContext.options.mainLoopModel = 'gpt-5.6-sol' + params.toolUseContext.readFileState = createFileStateCacheWithSizeLimit(10) + params.toolUseContext.setAppState = () => {} + return params +} + +function gatewayEffort(requestModel: string): string | undefined { + return resolveProviderRequest({ + model: requestModel, + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://gateway.example/v1', + OPENAI_API_KEY: 'test-key', + }, + }).reasoning?.effort +} + +describe('fork request selection at context boundaries', () => { + test.each((['sdk', 'btw'] as const).flatMap(entry => + (['provider', 'model'] as const).map(change => ({ entry, change })), + ))('fresh $entry side question establishes its pair after a $change change during preparation', async ({ entry, change }) => { + const queryModule = await import('../query.js') + const contexts = await import('../context.js') + const prompts = await import('../constants/prompts.js') + const config = await import('../utils/config.js') + const sideQuestion = await import('../utils/sideQuestion.js') + const { buildSideQuestionFallbackParams } = await import('../utils/queryContext.js') + const { call } = await import('../commands/btw/btw.js') + const { createRoot } = await import('../ink.js') + const { AppStateProvider } = await import('../state/AppState.js') + const originalQuery = queryModule.query + const originalSideQuestion = sideQuestion.runSideQuestion + const savedBefore = getLastCacheSafeParams() + const overrideBefore = getMainLoopModelOverride() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const completed = Promise.withResolvers() + const userContext = spyOn(contexts, 'getUserContext').mockImplementation(async () => { + entered.resolve() + await release.promise + return {} + }) + const systemContext = spyOn(contexts, 'getSystemContext').mockResolvedValue({}) + const systemPrompt = spyOn(prompts, 'getSystemPrompt').mockResolvedValue(['test system']) + const saveConfig = spyOn(config, 'saveGlobalConfig').mockImplementation(() => {}) + let providerId = 'provider-a' + let configured = 'provider-a-only' + const profile = spyOn(providerProfiles, 'getActiveProviderProfile').mockImplementation( + () => ({ id: providerId }) as ReturnType, + ) + const requests: string[] = [] + const budgets: string[] = [] + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: 'done' }) + }) + const state = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ ...state, mainLoopModel: configured }) + params.toolUseContext.options.mainLoopModel = configured + params.deps = { ...params.deps!, autocompact: async (_messages, context) => { + budgets.push(context.options.mainLoopModel) + return { wasCompacted: false } + } } + const injectedQuery = spyOn(queryModule, 'query').mockImplementation(input => originalQuery({ ...input, deps: params.deps })) + const runSideQuestion = spyOn(sideQuestion, 'runSideQuestion').mockImplementation(async input => { + try { + // Builders still carry their original options; inheritance must resolve + // a current pair at the fork boundary after this preparation wait. + expect(input.cacheSafeParams.toolUseContext.options.mainLoopModel).toBe('provider-a-only') + const result = await originalSideQuestion(input) + completed.resolve() + return result + } catch (error) { completed.reject(error); throw error } + }) + const stdout = new PassThrough() + stdout.resume() + let root: Awaited> | undefined + const deadline = setTimeout(() => { entered.reject(new Error('preparation was not reached')); completed.reject(new Error('side question did not finish')) }, 3000) + try { + saveCacheSafeParams(null) + setMainLoopModelOverride(configured) + const pending = entry === 'sdk' ? buildSideQuestionFallbackParams({ + tools: [], commands: [], mcpClients: [], messages: params.messages, + readFileState: params.toolUseContext.readFileState, + getAppState: params.toolUseContext.getAppState, setAppState: () => {}, + customSystemPrompt: 'test system', appendSystemPrompt: undefined, + thinkingConfig: { type: 'disabled' }, agents: [], + }) : undefined + if (entry === 'btw') { + root = await createRoot({ stdout: stdout as unknown as NodeJS.WriteStream, patchConsole: false }) + root.render(createElement(AppStateProvider, null, await call( + () => {}, params.toolUseContext as Parameters[1], 'what happened?', + ))) + } + await entered.promise + if (change === 'provider') providerId = 'provider-b' + configured = 'codexplan' + release.resolve() + if (pending) await sideQuestion.runSideQuestion({ question: 'what happened?', cacheSafeParams: await pending }) + await completed.promise + expect(requests).toEqual(['codexplan']) + expect(budgets).toEqual(['gpt-5.6-sol']) + expect(gatewayEffort(requests[0]!)).toBe('high') + } finally { + clearTimeout(deadline) + release.resolve() + root?.unmount() + stdout.end() + runSideQuestion.mockRestore() + injectedQuery.mockRestore() + profile.mockRestore() + saveConfig.mockRestore() + systemPrompt.mockRestore() + systemContext.mockRestore() + userContext.mockRestore() + setMainLoopModelOverride(overrideBefore) + saveCacheSafeParams(savedBefore) + } + }) + + // Allow the isolated child its full deadline plus teardown/reporting time. + test('profileless provider switches preserve ambient, explicit and fixed choices before and after child creation', async () => { + // Other files replace provider modules globally. This boundary must exercise + // the real identity and clearing helpers, not a mocked profile ID. + if (process.env[REAL_PROVIDER_TEST_CHILD_ENV] !== '1') { + await runTestFileWithRealProviders(import.meta.path) + return + } + const config = await import('../utils/config.js') + const { getAPIProvider } = await import('../utils/model/providers.js') + const { mkdtemp, rm } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const scratch = await mkdtemp(join(tmpdir(), 'query-provider-switch-')) + const environment = { CLAUDE_CODE_USE_OPENAI: '1', OPENAI_BASE_URL: 'https://gateway.example/v1', OPENAI_MODEL: 'gateway-only', OPENAI_API_KEY: 'test-key' } + // Snapshot exactly the keys the real clear helper removes, plus our inputs. + const cleared = { ...process.env } + providerProfiles.clearProviderProfileEnvFromProcessEnv(cleared) + const changedKeys = [...new Set([...Object.keys(process.env).filter(key => !(key in cleared)), ...Object.keys(environment)])] + const restoreEnv = snapshotEnv(changedKeys) + let providerConfig = { ...config.getGlobalConfig(), providerProfiles: [], activeProviderProfileId: undefined } as ReturnType + const readConfig = spyOn(config, 'getGlobalConfig').mockImplementation(() => providerConfig) + const writeConfig = spyOn(config, 'saveGlobalConfig').mockImplementation(update => { providerConfig = update(providerConfig) }) + const observed: Array<{ creation: string; choice: string; requests: string[]; budgets: string[] }> = [] + try { + for (const creation of ['before', 'after'] as const) { + for (const choice of ['ambient', 'explicit', 'fixed'] as const) { + providerProfiles.clearProviderProfileEnvFromProcessEnv() + Object.assign(process.env, environment) + providerConfig = { ...providerConfig, activeProviderProfileId: undefined } + expect(providerProfiles.getActiveProviderProfile()).toBeUndefined() + expect(getAPIProvider()).toBe('openai') + let configured = 'gateway-only' + const requests: string[] = [] + const budgets: string[] = [] + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: 'done' }) + }) + const state = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ ...state, mainLoopModel: configured }) + params.toolUseContext.options.mainLoopModel = configured + if (choice === 'fixed') params.toolUseContext.options.providerOverride = { + model: configured, baseURL: 'https://fixed.example/v1', apiKey: 'test-key', + } + await drain(params) + const fork = () => createSubagentContext(params.toolUseContext, choice === 'explicit' + ? { options: { ...params.toolUseContext.options } } : undefined) + let child = creation === 'before' ? fork() : undefined + providerProfiles.clearActiveProviderProfile({ configDir: scratch, cwd: scratch }) + configured = 'claude-sonnet-4-6' + expect(providerProfiles.getActiveProviderProfile()).toBeUndefined() + expect(getAPIProvider()).toBe('firstParty') + child ??= fork() + params.deps = { ...params.deps!, autocompact: async (_messages, context) => { + budgets.push(context.options.mainLoopModel) + return { wasCompacted: false } + } } + await drain({ ...params, toolUseContext: child }) + observed.push({ creation, choice, requests, budgets }) + } + } + expect(observed).toEqual(['before', 'after'].flatMap(creation => + ['ambient', 'explicit', 'fixed'].map(choice => ({ + creation, choice, + requests: ['gateway-only', choice === 'ambient' ? 'claude-sonnet-4-6' : 'gateway-only'], + budgets: [choice === 'ambient' ? 'claude-sonnet-4-6' : 'gateway-only'], + })), + )) + } finally { + writeConfig.mockRestore() + readConfig.mockRestore() + restoreEnv() + await rm(scratch, { recursive: true, force: true }) + } + }, REAL_PROVIDER_TEST_TIMEOUT_MS + 5_000) + + test.each((['microcompact', 'autocompact'] as const).flatMap(interval => + (['ambient', 'explicit', 'fixed'] as const).map(choice => ({ interval, choice })), + ))('refreshes $choice selection after awaiting $interval', async ({ interval, choice }) => { + let providerId = 'provider-a' + const active = spyOn(providerProfiles, 'getActiveProviderProfile').mockImplementation( + () => ({ id: providerId }) as ReturnType, + ) + const requests: string[] = [] + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: 'done' }) + }) + const state = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ ...state, mainLoopModel: `${providerId}-only` }) + params.toolUseContext.options.mainLoopModel = 'provider-a-only' + if (choice === 'fixed') params.toolUseContext.options.providerOverride = { + model: 'provider-a-only', baseURL: 'https://fixed.example/v1', apiKey: 'test-key', + } + const child = createSubagentContext(params.toolUseContext, choice === 'explicit' + ? { options: { ...params.toolUseContext.options } } : undefined) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const waitForSwitch = async () => { entered.resolve(); await release.promise } + params.deps = { ...params.deps!, + microcompact: async messages => { + if (interval === 'microcompact') await waitForSwitch() + return { messages } + }, + autocompact: async () => { + if (interval === 'autocompact') await waitForSwitch() + return { wasCompacted: false } + }, + } + const running = drain({ ...params, toolUseContext: child }) + try { + await entered.promise + providerId = 'provider-b' + release.resolve() + await running + expect(requests).toEqual([choice === 'ambient' ? 'provider-b-only' : 'provider-a-only']) + } finally { + release.resolve() + await running + active.mockRestore() + } + }) + + test.each([ + ...(['provider', 'model'] as const).flatMap(recovery => + (['ambient', 'explicit', 'fixed'] as const).map(choice => ({ recovery, choice, duringResponse: false }))), + { recovery: 'provider' as const, choice: 'ambient' as const, duringResponse: true }, + ])('preserves $choice origin through automatic $recovery fallback and a later tool switch; response switch=$duringResponse', async ({ recovery, choice, duringResponse }) => { + const settingsModule = await import('../utils/settings/settings.js') + let providerId = 'provider-a' + const profiles = ['provider-a', 'provider-x', 'provider-b', 'provider-c'].map(id => ({ + id, name: id, provider: 'openai' as const, baseUrl: 'https://gateway.example/v1', model: `${id}-only`, + })) + const active = spyOn(providerProfiles, 'getActiveProviderProfile').mockImplementation( + () => profiles.find(p => p.id === providerId) as ReturnType, + ) + const allProfiles = spyOn(providerProfiles, 'getProviderProfiles').mockReturnValue(profiles) + const activate = spyOn(providerProfiles, 'setActiveProviderProfile').mockImplementation(id => { + providerId = id + return profiles.find(p => p.id === id) ?? null + }) + const settings = spyOn(settingsModule, 'getSettings_DEPRECATED').mockReturnValue({ + providerFallbackChain: recovery === 'provider' ? ['provider-a', 'provider-x', 'provider-b'].filter(id => duringResponse || id !== 'provider-x') : [], + }) + const requests: string[] = [] + let nestedContext: QueryParams['toolUseContext'] | undefined + const echo = spyOn(echoTool, 'call').mockImplementation(async (_input, context) => { + providerId = 'provider-c' + nestedContext = createSubagentContext(context) + return { data: 'switched' } + }) + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + if (requests.length === 1) { + if (recovery === 'model') throw new FallbackTriggeredError('provider-a-only', 'provider-b-only') + if (duringResponse) providerId = 'provider-x' + yield createAssistantAPIErrorMessage({ content: 'rate limited', error: 'rate_limit' }) + } else { + yield createAssistantMessage({ content: requests.length === 2 + ? [{ type: 'tool_use', id: 'toolu_fallback_switch', name: 'Echo', input: { text: 'switch' } }] + : 'done' }) + } + }) + const state = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ ...state, mainLoopModel: `${providerId}-only` }) + params.toolUseContext.options.mainLoopModel = 'provider-a-only' + params.toolUseContext.options.tools = [echoTool] + if (choice === 'fixed') params.toolUseContext.options.providerOverride = { + model: 'provider-a-only', baseURL: 'https://fixed.example/v1', apiKey: 'test-key', + } + const child = createSubagentContext(params.toolUseContext, choice === 'explicit' + ? { options: { ...params.toolUseContext.options } } : undefined) + try { + await drain({ ...params, toolUseContext: child, fallbackModel: 'provider-b-only' }) + expect(nestedContext).toBeDefined() + await drain({ ...params, toolUseContext: nestedContext! }) + expect(requests).toEqual(['provider-a-only', 'provider-b-only', + choice === 'ambient' ? 'provider-c-only' : 'provider-b-only', + choice === 'ambient' ? 'provider-c-only' : 'provider-b-only']) + } finally { + echo.mockRestore() + settings.mockRestore() + activate.mockRestore() + allProfiles.mockRestore() + active.mockRestore() + } + }) + + test.each((['hook', 'skill', 'definition'] as const).flatMap(source => [ + ...(['denied', 'allowed', 'default'] as const).map(policy => ({ source, policy, rootAlias: undefined })), + ...(['haiku', 'opusplan'] as const).map(rootAlias => ({ source, policy: 'allowed' as const, rootAlias })), + ]))('$source model selection obeys the real allowlist: $policy, root=$rootAlias', async ({ source, policy, rootAlias }) => { + const settingsModule = await import('../utils/settings/settings.js') + const queryModule = await import('../query.js') + const commands = await import('../commands.js') + const models = await import('../utils/model/model.js') + const transcripts = await import('../utils/sessionStorage.js') + const { execAgentHook } = await import('../utils/hooks/execAgentHook.js') + const { SkillTool } = await import('../tools/SkillTool/SkillTool.js') + const { runAgent } = await import('../tools/AgentTool/runAgent.js') + const originalQuery = queryModule.query + const overrideBefore = getMainLoopModelOverride() + const explicitModel = rootAlias ? 'claude-haiku-4-5-20251001' : 'gpt-4o' + const parentModel = rootAlias ? explicitModel : 'gpt-5' + const requested = policy === 'default' ? undefined : explicitModel + const configured = { + availableModels: rootAlias === 'opusplan' ? undefined : rootAlias ? [explicitModel] + : policy === 'default' ? [] : policy === 'allowed' ? ['gpt-5', 'gpt-4o'] : ['gpt-5'], + smartRouting: { enabled: true, simpleModel: parentModel, strongModel: parentModel }, + } + const settings = spyOn(settingsModule, 'getSettings_DEPRECATED').mockReturnValue(configured) + const initialSettings = spyOn(settingsModule, 'getInitialSettings').mockReturnValue(configured) + const smallModel = spyOn(models, 'getSmallFastModel').mockReturnValue(parentModel) + const transcript = spyOn(transcripts, 'recordSidechainTranscript').mockResolvedValue(undefined) + const metadata = spyOn(transcripts, 'writeAgentMetadata').mockResolvedValue(undefined) + const commandList = spyOn(commands, 'getCommands').mockResolvedValue([{ + type: 'prompt', name: 'selection-test', description: 'test', progressMessage: 'test', + contentLength: 0, source: 'builtin', model: requested, + hooks: { PreToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'echo selection-test' }] }] }, + getPromptForCommand: async () => [{ type: 'text', text: 'inspect' }], + }]) + const requests: string[] = [] + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: source === 'hook' + ? [{ type: 'tool_use', id: 'toolu_policy_hook', name: 'StructuredOutput', input: { ok: true } }] + : rootAlias && requests.length === 1 + ? [{ type: 'tool_use', id: 'toolu_policy_plan', name: 'Echo', input: { text: 'inspect' } }] + : 'done' }) + }) + let state = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ ...state, mainLoopModel: rootAlias ?? parentModel, settings: configured, + toolPermissionContext: { ...state.toolPermissionContext, mode: rootAlias ? 'plan' : 'default' }, + mcp: { ...state.mcp, commands: [] } }) + if (source === 'skill') params.toolUseContext.setAppState = update => { state = update(state) } + params.toolUseContext.options.mainLoopModel = parentModel + if (rootAlias) params.toolUseContext.options.tools = [echoTool] + const childQuery = spyOn(queryModule, 'query').mockImplementation(input => originalQuery({ ...input, deps: params.deps })) + if (rootAlias) setMainLoopModelOverride(rootAlias) + try { + expect(modelAllowlist.isModelAllowed(explicitModel)).toBe(policy === 'allowed') + if (rootAlias) expect(models.getUserSpecifiedModelSetting()).toBe(rootAlias) + let failure: unknown + if (source === 'hook') { + const result = await execAgentHook( + { type: 'agent', prompt: 'verify', model: requested, timeout: 2 }, + 'Stop:0', 'Stop', '{}', params.toolUseContext.abortController.signal, params.toolUseContext, undefined, params.messages, + ) + failure = result.message?.type === 'attachment' && result.message.attachment.type !== 'hook_success' ? result : undefined + } else { + try { + if (source === 'skill') { + const child = createSubagentContext(params.toolUseContext, { shareSetAppState: true }) + const result = await SkillTool.call({ skill: 'selection-test' }, child, params.canUseTool, + createAssistantMessage({ content: [{ type: 'tool_use', id: 'toolu_skill_policy', name: 'Skill', input: { skill: 'selection-test' } }] })) + await drain({ ...params, toolUseContext: result.contextModifier?.(child) ?? child }) + } else { + for await (const _message of runAgent({ + agentDefinition: { agentType: 'selection-test', whenToUse: 'test', source: 'userSettings', tools: rootAlias ? ['Echo'] : [], model: requested, getSystemPrompt: () => 'test' }, + promptMessages: params.messages, toolUseContext: params.toolUseContext, canUseTool: params.canUseTool, + isAsync: false, querySource: 'agent:custom', availableTools: rootAlias ? [echoTool] : [], + override: { userContext: {}, systemContext: {}, systemPrompt: asSystemPrompt([]) }, + })) { /* Exhaust the actual agent query. */ } + } + } catch (error) { failure = error } + } + if (source === 'skill') expect(state.sessionHooks.size).toBe(policy === 'denied' ? 0 : 1) + if (policy === 'denied') { + expect(requests).toEqual([]) + expect(failure).toBeDefined() + } else { + expect(failure).toBeUndefined() + expect(requests).toEqual(Array(rootAlias && source !== 'hook' ? 2 : 1).fill(requested ?? parentModel)) + } + } finally { + childQuery.mockRestore() + commandList.mockRestore() + metadata.mockRestore() + transcript.mockRestore() + smallModel.mockRestore() + initialSettings.mockRestore() + settings.mockRestore() + setMainLoopModelOverride(overrideBefore) + echoCalls.length = 0 + } + }) + + test.each((['transcript', 'tool'] as const).flatMap(interval => + (['ambient', 'explicit', 'fixed'] as const).map(choice => ({ interval, choice })), + ))('existing $choice fork follows a provider switch during $interval', async ({ interval, choice }) => { + const queryModule = await import('../query.js') + const transcripts = await import('../utils/sessionStorage.js') + const originalQuery = queryModule.query + const savedBefore = getLastCacheSafeParams() + let providerId = 'provider-a' + const activeProvider = spyOn(providerProfiles, 'getActiveProviderProfile').mockImplementation( + () => ({ id: providerId }) as ReturnType, + ) + const allowlist = spyOn(modelAllowlist, 'isModelAllowed').mockReturnValue(true) + const transcriptEntered = Promise.withResolvers() + const releaseTranscript = Promise.withResolvers() + let holdTranscript = interval === 'transcript' + const transcript = spyOn(transcripts, 'recordSidechainTranscript').mockImplementation(async () => { + if (holdTranscript) { + holdTranscript = false + transcriptEntered.resolve() + await releaseTranscript.promise + } + }) + const echo = spyOn(echoTool, 'call').mockImplementation(async () => { + providerId = 'provider-b' + return { data: 'switched' } + }) + const requests: string[] = [] + const budgets: string[] = [] + let child = false + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: child && interval === 'tool' && requests.length === 1 + ? [{ type: 'tool_use', id: 'toolu_provider_switch', name: 'Echo', input: { text: 'switch' } }] + : 'done' }) + }) + const state = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ + ...state, settings: providerId === 'provider-a' + ? { smartRouting: { enabled: true, simpleModel: 'gpt-4o', strongModel: 'gpt-4o' } } : {}, + }) + params.toolUseContext.options.tools = [echoTool] + params.toolUseContext.options.refreshTools = () => [echoTool] + params.deps = { ...params.deps!, autocompact: async (_messages, context) => { + budgets.push(context.options.mainLoopModel) + return { wasCompacted: false } + } } + const forkQuery = spyOn(queryModule, 'query').mockImplementation(input => originalQuery({ ...input, deps: params.deps })) + clearSmartRoutingSessionDisable(getSessionId()) + resetRoutingTally() + try { + await drain(params) + child = true + // Explicit choices must stay explicit after their first accepted request. + providerId = 'provider-a' + requests.length = 0 + budgets.length = 0 + holdTranscript = interval === 'transcript' + const pending = runForkedAgent({ + promptMessages: [createUserMessage({ content: 'continue' })], + cacheSafeParams: { ...params, forkContextMessages: [] }, + canUseTool: params.canUseTool, querySource: 'session_memory', forkLabel: 'selection-test', + ...(choice !== 'ambient' && { overrides: { options: { + ...params.toolUseContext.options, + ...(choice === 'fixed' && { providerOverride: { + model: 'gpt-4o', baseURL: 'https://provider-a.example/v1', apiKey: 'test-key', + } }), + } } }), + }) + if (interval === 'transcript') { + await transcriptEntered.promise + providerId = 'provider-b' + releaseTranscript.resolve() + } + await pending + const expected = choice === 'ambient' ? 'codexplan' : 'gpt-4o' + expect(requests).toEqual(interval === 'tool' ? ['gpt-4o', expected] : [expected]) + expect(budgets).toEqual(interval === 'tool' + ? ['gpt-4o', choice === 'ambient' ? 'gpt-5.6-sol' : 'gpt-4o'] + : [choice === 'ambient' ? 'gpt-5.6-sol' : 'gpt-4o']) + expect(gatewayEffort(requests.at(-1)!)).toBe(choice === 'ambient' ? 'high' : undefined) + } finally { + releaseTranscript.resolve() + transcript.mockRestore() + echo.mockRestore() + forkQuery.mockRestore() + activeProvider.mockRestore() + allowlist.mockRestore() + saveCacheSafeParams(savedBefore) + resetRoutingTally() + clearSmartRoutingSessionDisable(getSessionId()) + } + }) + + test.each([false, true])('retained parent and delayed sampled contexts survive tool refresh=%s', async refresh => { + const queryModule = await import('../query.js') + const originalQuery = queryModule.query + const savedBefore = getLastCacheSafeParams() + const releaseHook = Promise.withResolvers() + let hookWork: Promise | undefined + const requests: string[] = [] + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: requests.length === 1 + ? [{ type: 'tool_use', id: 'toolu_retained', name: 'Echo', input: { text: 'refresh' } }] + : 'done' }) + }) + params.querySource = 'repl_main_thread' + params.toolUseContext.options.tools = [echoTool] + params.toolUseContext.options.refreshTools = () => refresh ? [echoTool] : params.toolUseContext.options.tools + const forkQuery = spyOn(queryModule, 'query').mockImplementation(input => originalQuery({ ...input, deps: params.deps })) + registerPostSamplingHook(context => { + if (context.toolUseContext.agentId || hookWork) return + hookWork = (async () => { + await releaseHook.promise + return runForkedAgent({ + promptMessages: [createUserMessage({ content: 'summarize' })], + cacheSafeParams: createCacheSafeParams(context), canUseTool: params.canUseTool, + querySource: 'session_memory', forkLabel: 'retained-sample', skipTranscript: true, + }) + })() + return hookWork.then(() => {}) + }) + try { + await drain(params) + expect(hookWork).toBeDefined() + releaseHook.resolve() + await hookWork + await drain({ ...params, toolUseContext: createSubagentContext(params.toolUseContext) }) + expect(requests).toEqual(['codexplan', 'codexplan', 'codexplan', 'codexplan']) + expect(requests.map(gatewayEffort)).toEqual(['high', 'high', 'high', 'high']) + params.toolUseContext.options = { ...params.toolUseContext.options } + await drain({ ...params, toolUseContext: createSubagentContext(params.toolUseContext) }) + expect(requests.at(-1)).toBe('gpt-5.6-sol') + } finally { + releaseHook.resolve() + await hookWork + clearPostSamplingHooks() + forkQuery.mockRestore() + saveCacheSafeParams(savedBefore) + } + }) + + test.each([ + { route: undefined, fixed: false }, + { route: 'gpt-5.6-sol?reasoning=low', fixed: false }, + { route: 'gpt-5.6-sol?reasoning=low', fixed: true }, + ])('tool refresh then provider switch in nested forks: $route / fixed=$fixed', async ({ route, fixed }) => { + let providerId = 'provider-a' + const activeProvider = spyOn(providerProfiles, 'getActiveProviderProfile').mockImplementation( + () => ({ id: providerId }) as ReturnType, + ) + const allowlist = spyOn(modelAllowlist, 'isModelAllowed').mockReturnValue(true) + const savedBefore = getLastCacheSafeParams() + const requests: string[] = [] + let useTool = false + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + const content = useTool + ? [{ type: 'tool_use' as const, id: 'toolu_selection_refresh', name: 'Echo', input: { text: 'refresh' } }] + : 'done' + useTool = false + yield createAssistantMessage({ content }) + }) + const state = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ + ...state, settings: providerId === 'provider-a' && route + ? { smartRouting: { enabled: true, simpleModel: route, strongModel: route } } : {}, + }) + params.toolUseContext.options.tools = [echoTool] + params.toolUseContext.options.refreshTools = () => [echoTool] + if (fixed) params.toolUseContext.options.providerOverride = { + model: route!, baseURL: 'https://provider-a.example/v1', apiKey: 'test-key', + } + let latestContext = params.toolUseContext + params.deps = { ...params.deps!, autocompact: async (_messages, context) => { + latestContext = context + return { wasCompacted: false } + } } + clearSmartRoutingSessionDisable(getSessionId()) + resetRoutingTally() + try { + await drain(params) + const inherited = route ?? 'codexplan' + const child = createSubagentContext(params.toolUseContext) + requests.length = 0 + useTool = true + await drain({ ...params, toolUseContext: child }) + expect(requests).toEqual([inherited, inherited]) + expect(requests.map(gatewayEffort)).toEqual(route ? ['low', 'low'] : ['high', 'high']) + + // The next default fork uses the last accepted, internally refreshed context. + // Both profiles configure codexplan; only A had a smart-routing selection. + providerId = 'provider-b' + const nested = createSubagentContext(latestContext) + const expected = fixed ? inherited : 'codexplan' + expect(nested.options.mainLoopModel).toBe(fixed ? route! : 'gpt-5.6-sol') + requests.length = 0 + useTool = true + await drain({ ...params, toolUseContext: nested }) + expect(requests).toEqual([expected, expected]) + expect(requests.map(gatewayEffort)).toEqual(fixed ? ['low', 'low'] : ['high', 'high']) + + // An explicit same-concrete-model choice still wins after both transitions. + const explicit = createSubagentContext(latestContext, { + options: { ...latestContext.options, mainLoopModel: 'gpt-5.6-sol', providerOverride: undefined }, + }) + requests.length = 0 + useTool = true + await drain({ ...params, toolUseContext: explicit }) + expect(requests).toEqual(['gpt-5.6-sol', 'gpt-5.6-sol']) + } finally { + activeProvider.mockRestore() + allowlist.mockRestore() + saveCacheSafeParams(savedBefore) + resetRoutingTally() + clearSmartRoutingSessionDisable(getSessionId()) + } + }) + + test.each(['accepted', 'fresh', 'nested'] as const)('fork model budgets retain concrete identity: %s', async source => { + const environment = { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://gateway.example/v1', + OPENAI_API_KEY: 'test-key', + DISABLE_AUTO_COMPACT: '1', + DISABLE_COMPACT: '1', + } + const restoreEnv = snapshotEnv(Object.keys(environment)) + const previousCap = getSessionContextWindowOverride('gpt-5.6-sol') + Object.assign(process.env, environment) + clearSessionContextWindowOverride('gpt-5.6-sol') + try { + const requests: string[] = [] + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: 'done' }) + }) + if (source !== 'fresh') await drain(params) + let child = createSubagentContext(params.toolUseContext) + if (source === 'nested') child = createSubagentContext(child) + const prior = createAssistantMessage({ content: 'earlier provider reply' }) + prior.message.model = 'gpt-5.6-sol' + prior.message.usage = { ...prior.message.usage, input_tokens: 150000, + output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } + const fork = { ...params, toolUseContext: child, querySource: 'side_question' as const, + messages: [createUserMessage({ content: 'earlier question' }), prior, + createUserMessage({ content: 'side question' })] } + requests.length = 0 + expect((await drain(fork)).returned.reason).toBe('completed') + expect(requests).toEqual(['codexplan']) + expect(gatewayEffort(requests[0]!)).toBe('high') + + setSessionContextWindowOverride('gpt-5.6-sol', 60000) + prior.message.usage.input_tokens = 90000 + requests.length = 0 + expect((await drain(fork)).returned.reason).toBe('blocking_limit') + expect(requests).toEqual([]) + + clearSessionContextWindowOverride('gpt-5.6-sol') + delete process.env.DISABLE_AUTO_COMPACT + delete process.env.DISABLE_COMPACT + expect(isAutoCompactEnabled()).toBe(true) + prior.message.usage.input_tokens = 100000 + // Run the real compaction eligibility policy at query's production boundary; + // no summarizer/provider IO is needed when this history fits the model. + let compact: boolean | undefined + fork.deps = { ...params.deps!, autocompact: async (messages, context, _cache, querySource) => { + compact = await shouldAutoCompact(messages, context.options.mainLoopModel, querySource) + return { wasCompacted: false } + } } + await drain(fork) + expect(compact).toBe(false) + } finally { + clearSessionContextWindowOverride('gpt-5.6-sol') + if (previousCap !== undefined) setSessionContextWindowOverride('gpt-5.6-sol', previousCap) + restoreEnv() + } + }) + + test('fresh default forks retain the configured alias without overriding explicit children', async () => { + const requests: string[] = [] + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: 'done' }) + }) + // Retain the earlier regression: fork before any parent dispatch, so there + // is no saved request. Fresh REPL and SDK contexts have this same shape. + await drain({ ...params, toolUseContext: createSubagentContext(params.toolUseContext) }) + expect(requests).toEqual(['codexplan']) + expect(gatewayEffort(requests[0]!)).toBe('high') + + const explicit = createSubagentContext(params.toolUseContext, { + options: { ...params.toolUseContext.options }, + }) + // An explicit child can itself fork before dispatch. Its choice must not + // be reconstructed from the root's codexplan setting. + await drain({ ...params, toolUseContext: createSubagentContext(explicit) }) + await drain({ ...params, toolUseContext: explicit }) + expect(requests).toEqual(['codexplan', 'gpt-5.6-sol', 'gpt-5.6-sol']) + expect(gatewayEffort(requests[1]!)).toBeUndefined() + + // A skill can replace options after the fork has inherited a selection. + // The query must invalidate that request even for the same concrete model. + const skill = createSubagentContext(params.toolUseContext) + skill.options = { ...skill.options, mainLoopModel: 'gpt-5.6-sol' } + await drain({ ...params, toolUseContext: skill }) + expect(requests.at(-1)).toBe('gpt-5.6-sol') + + // A fresh fixed-provider root must not adopt the ambient codexplan setting. + const fixed = { ...params.toolUseContext, options: { + ...params.toolUseContext.options, mainLoopModel: 'gpt-4o', + providerOverride: { model: 'gpt-4o', baseURL: 'https://fixed.example/v1', apiKey: 'test-key' }, + } } + await drain({ ...params, toolUseContext: createSubagentContext(fixed) }) + expect(requests.at(-1)).toBe('gpt-4o') + }) + + test('agent hook cannot overwrite the saved parent selection', async () => { + const queryModule = await import('../query.js') + const { execAgentHook } = await import('../utils/hooks/execAgentHook.js') + const originalQuery = queryModule.query + const savedBefore = getLastCacheSafeParams() + const requests: string[] = [] + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: options.querySource === 'hook_agent' + ? [{ type: 'tool_use', id: 'toolu_hook_selection', name: 'StructuredOutput', input: { ok: true } }] + : 'done' }) + }) + params.querySource = 'sdk' + const hookQuery = spyOn(queryModule, 'query').mockImplementation(input => originalQuery({ ...input, deps: params.deps })) + try { + await drain(params) + const parent = getLastCacheSafeParams()!.toolUseContext + const hookResult = await execAgentHook( + { type: 'agent', prompt: 'verify', model: 'gpt-4o', timeout: 2 }, + 'Stop:0', 'Stop', '{}', parent.abortController.signal, parent, undefined, params.messages, + ) + expect(hookResult.message?.type).toBe('attachment') + if (hookResult.message?.type === 'attachment') { + expect(hookResult.message.attachment.type).toBe('hook_success') + } + await drain({ ...params, toolUseContext: createSubagentContext(parent), querySource: 'side_question' }) + expect(requests).toEqual(['codexplan', 'gpt-4o', 'codexplan']) + expect(gatewayEffort(requests[2]!)).toBe('high') + } finally { + hookQuery.mockRestore() + saveCacheSafeParams(savedBefore) + } + }) + + test('SDK fallback side question retains the configured request before a snapshot exists', async () => { + const queryModule = await import('../query.js') + const originalQuery = queryModule.query + const { buildSideQuestionFallbackParams } = await import('../utils/queryContext.js') + const { runSideQuestion } = await import('../utils/sideQuestion.js') + const originalOverride = getMainLoopModelOverride() + const allowlist = spyOn(modelAllowlist, 'isModelAllowed').mockReturnValue(true) + const requests: string[] = [] + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: 'done' }) + }) + const sideQuery = spyOn(queryModule, 'query').mockImplementation(input => originalQuery({ ...input, deps: params.deps })) + try { + setMainLoopModelOverride('codexplan') + const cacheSafeParams = await buildSideQuestionFallbackParams({ + tools: [], commands: [], mcpClients: [], messages: params.messages, + readFileState: params.toolUseContext.readFileState, + getAppState: params.toolUseContext.getAppState, setAppState: () => {}, + customSystemPrompt: 'test system', appendSystemPrompt: undefined, + thinkingConfig: { type: 'disabled' }, agents: [], + }) + await runSideQuestion({ question: 'what happened?', cacheSafeParams }) + expect(requests).toEqual(['codexplan']) + expect(gatewayEffort(requests[0]!)).toBe('high') + } finally { + sideQuery.mockRestore() + allowlist.mockRestore() + setMainLoopModelOverride(originalOverride) + } + }) + + test.each([ + { selected: 'codexplan', route: undefined, current: 'codexplan', request: 'codexplan', effort: 'high' }, + { selected: 'codexplan', route: 'gpt-5.6-sol', current: 'codexplan', request: 'gpt-5.6-sol', effort: undefined }, + { selected: 'codexplan', route: 'gpt-5.6-sol?reasoning=low', current: 'codexplan', request: 'gpt-5.6-sol?reasoning=low', effort: 'low' }, + { selected: 'codexplan', route: 'gpt-4o', current: 'codexplan', request: 'gpt-4o', effort: undefined }, + { selected: 'codexplan', route: undefined, current: 'gpt-5.6-sol', request: 'gpt-5.6-sol', effort: undefined }, + { selected: 'gpt-5.6-sol', route: undefined, current: 'codexplan', request: 'codexplan', effort: 'high' }, + { selected: 'codexplan', route: 'provider-a-only', current: 'codexplan', request: 'codexplan', effort: 'high', switchProvider: true }, + { selected: 'codexplan', route: 'provider-a-only', current: 'codexplan', request: 'codexplan', effort: 'high', switchProvider: true, sdk: true }, + { selected: 'codexplan', route: 'gpt-5.6-sol?reasoning=low', current: 'codexplan', request: 'gpt-5.6-sol?reasoning=low', effort: 'low', sdk: true }, + ].map(entry => ({ switchProvider: false, sdk: false, ...entry })))('side question selection: $selected / $route -> $current; switch=$switchProvider SDK=$sdk', async ({ selected, route, current, request, effort, switchProvider, sdk }) => { + let providerId = 'provider-a' + const activeProvider = spyOn(providerProfiles, 'getActiveProviderProfile').mockImplementation( + () => ({ id: providerId }) as ReturnType, + ) + const queryModule = await import('../query.js') + const sideQuestion = await import('../utils/sideQuestion.js') + const config = await import('../utils/config.js') + const { call } = await import('../commands/btw/btw.js') + const { createRoot } = await import('../ink.js') + const { AppStateProvider } = await import('../state/AppState.js') + const originalQuery = queryModule.query + const originalSideQuestion = sideQuestion.runSideQuestion + const savedBefore = getLastCacheSafeParams() + const requests: string[] = [] + const params = makeAliasParent(async function* ({ options }) { + requests.push(options.requestModel ?? options.model) + yield createAssistantMessage({ content: 'done' }) + }) + const state = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ + ...state, mainLoopModel: selected, + settings: providerId === 'provider-a' && route ? { smartRouting: { enabled: true, simpleModel: route, strongModel: route } } : {}, + }) + const allowlist = spyOn(modelAllowlist, 'isModelAllowed').mockReturnValue(true) + clearSmartRoutingSessionDisable(getSessionId()) + resetRoutingTally() + const completion = Promise.withResolvers() + const runtimeModels: string[] = [] + const sideQuery = spyOn(queryModule, 'query').mockImplementation(input => { + if (input.querySource === 'side_question') runtimeModels.push(input.toolUseContext.options.mainLoopModel) + return originalQuery({ ...input, deps: params.deps }) + }) + const runSideQuestion = spyOn(sideQuestion, 'runSideQuestion').mockImplementation(async input => { + try { + const result = await originalSideQuestion(input) + completion.resolve() + return result + } catch (error) { + completion.reject(error) + throw error + } + }) + const saveConfig = spyOn(config, 'saveGlobalConfig').mockImplementation(() => {}) + const stdout = new PassThrough() + stdout.resume() + let root: Awaited> | undefined + const deadline = setTimeout(() => completion.reject(new Error('/btw did not finish')), 2000) + try { + root = await createRoot({ stdout: stdout as unknown as NodeJS.WriteStream, patchConsole: false }) + params.querySource = 'repl_main_thread' + await drain(params) + if (switchProvider) providerId = 'provider-b' + // Commands get a fresh root context rather than the saved query object. + const commandContext = makeAliasParent(async function* () {}).toolUseContext + commandContext.getAppState = () => ({ ...state, mainLoopModel: current }) + if (sdk) { + const saved = getLastCacheSafeParams()! + await originalSideQuestion({ question: 'what happened?', cacheSafeParams: { + ...saved, toolUseContext: { ...saved.toolUseContext, abortController: new AbortController() }, + } }) + } else { + root.render(createElement(AppStateProvider, null, await call( + () => {}, commandContext as Parameters[1], 'what happened?', + ))) + await completion.promise + } + expect(requests).toEqual([route ?? selected, request]) + expect(gatewayEffort(requests[1]!)).toBe(effort) + expect(runtimeModels).toEqual([request === 'codexplan' ? 'gpt-5.6-sol' : request]) + } finally { + activeProvider.mockRestore() + clearTimeout(deadline) + root?.unmount() + stdout.end() + saveConfig.mockRestore() + runSideQuestion.mockRestore() + sideQuery.mockRestore() + saveCacheSafeParams(savedBefore) + allowlist.mockRestore() + resetRoutingTally() + clearSmartRoutingSessionDisable(getSessionId()) + } + }) +}) + describe('agent step limits', () => { + test.each([ + ...(['haiku', 'opusplan'] as const).flatMap(rootAlias => + [true, false].map(routing => ({ rootAlias, routing, mode: 'plan' as const }))), + { rootAlias: 'sonnet', routing: true, mode: 'plan' as const }, + { rootAlias: 'haiku', routing: true, mode: 'default' as const }, + ])('preserves assigned child models under $rootAlias/$mode, routing=$routing', async ({ rootAlias, routing, mode }) => { + const models = await import('../utils/model/model.js') + const settingsModule = await import('../utils/settings/settings.js') + const overrideBefore = getMainLoopModelOverride() + const assignedModel = 'claude-haiku-4-5-20251001' + const configured = { + availableModels: rootAlias === 'haiku' ? [assignedModel] : undefined, + smartRouting: { enabled: routing, simpleModel: assignedModel, strongModel: assignedModel }, + } + const settings = spyOn(settingsModule, 'getSettings_DEPRECATED').mockReturnValue(configured) + const requests: Array<{ model: string; requestModel?: string }> = [] + const params = makeParams(async function* ({ options }) { + requests.push({ model: options.model, requestModel: options.requestModel }) + yield createAssistantMessage({ content: requests.length === 1 + ? [{ type: 'tool_use', id: 'toolu_plan_selection', name: 'Echo', input: { text: 'inspect' } }] + : 'done' }) + }, [echoTool]) + delete params.toolUseContext.agentId + const appState = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ ...appState, mainLoopModel: rootAlias, + settings: configured, toolPermissionContext: { ...appState.toolPermissionContext, mode } }) + params.toolUseContext.options.mainLoopModel = assignedModel + params.toolUseContext.readFileState = createFileStateCacheWithSizeLimit(10) + setMainLoopModelOverride(rootAlias) + clearSmartRoutingSessionDisable(getSessionId()) + resetRoutingTally() + try { + expect(models.getUserSpecifiedModelSetting()).toBe(rootAlias) + expect(modelAllowlist.isModelAllowed(assignedModel)).toBe(true) + if (rootAlias === 'haiku') expect(modelAllowlist.isModelAllowed('claude-sonnet-4-6')).toBe(false) + // Without routing, root plan promotion must still occur. Default forks + // inherit that accepted result; an explicit child keeps its own model. + const rootModel = routing ? assignedModel : rootAlias === 'opusplan' + ? models.getDefaultOpusModel() : models.getDefaultSonnetModel() + const pair = (model: string) => ({ model, requestModel: model }) + await drain(params) + expect(requests).toEqual([pair(rootModel), pair(rootModel)]) + const child = createSubagentContext(params.toolUseContext) + const nested = createSubagentContext(child) + const explicit = createSubagentContext(params.toolUseContext, { + options: { ...params.toolUseContext.options, mainLoopModel: assignedModel }, + }) + const observed: Array = [] + for (const context of [child, nested, explicit]) { + requests.length = 0 + await drain({ ...params, toolUseContext: context }) + observed.push([...requests]) + } + expect(observed).toEqual([rootModel, rootModel, assignedModel].map(model => [pair(model), pair(model)])) + expect(getRoutingTally().simple + getRoutingTally().strong).toBe(routing ? 1 : 0) + } finally { + settings.mockRestore() + setMainLoopModelOverride(overrideBefore) + resetRoutingTally() + clearSmartRoutingSessionDisable(getSessionId()) + echoCalls.length = 0 + } + }) + + test.each([ + { route: 'gpt-5.6-sol', requestModel: 'gpt-5.6-sol', effort: undefined }, + { route: 'gpt-5.6-sol?reasoning=low', requestModel: 'gpt-5.6-sol?reasoning=low', effort: 'low' }, + { route: undefined, requestModel: 'codexplan', effort: 'high' }, + ])('cache-sharing forks preserve parent request selection: $requestModel', async ({ route, requestModel, effort }) => { + const allowlist = spyOn(modelAllowlist, 'isModelAllowed').mockReturnValue(true) + clearSmartRoutingSessionDisable(getSessionId()) + resetRoutingTally() + try { + const requests: Array<{ model: string; requestModel?: string; effort?: string }> = [] + const params = makeParams(async function* ({ options }) { + const resolved = resolveProviderRequest({ + model: options.requestModel ?? options.model, + processEnv: { + CLAUDE_CODE_USE_OPENAI: '1', + OPENAI_BASE_URL: 'https://gateway.example/v1', + OPENAI_API_KEY: 'test-key', + }, + }) + requests.push({ model: options.model, requestModel: options.requestModel, effort: resolved.reasoning?.effort }) + yield createAssistantMessage({ + content: requests.length === 1 + ? [{ type: 'tool_use', id: 'toolu_selection', name: 'Echo', input: { text: 'inspect' } }] + : 'done', + }) + }, [echoTool]) + delete params.toolUseContext.agentId + const appState = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ + ...appState, + mainLoopModel: 'codexplan', + settings: route ? { + smartRouting: { enabled: true, simpleModel: route, strongModel: route }, + } : {}, + }) + params.toolUseContext.options.mainLoopModel = 'gpt-5.6-sol' + params.toolUseContext.readFileState = createFileStateCacheWithSizeLimit(10) + const expected = { model: route ?? 'gpt-5.6-sol', requestModel, effort } + + // Execute the parent: a resolved model name alone cannot prove which + // request selection it used. Retain the original context across query's clones. + await drain(params) + expect(requests).toEqual([expected, expected]) + const parentOptions = params.toolUseContext.options + let fork = params.toolUseContext + for (let depth = 0; depth < 2; depth++) { + fork = createSubagentContext(fork) + requests.length = 0 + await drain({ ...params, toolUseContext: fork }) + expect(requests).toEqual([expected, expected]) + } + expect(parentOptions.mainLoopModel).toBe(route ?? 'gpt-5.6-sol') + expect(getRoutingTally().simple + getRoutingTally().strong).toBe(route ? 1 : 0) + + // Explicit agent options and a skill's spread-context override must win, + // even when the requested runtime model equals the parent's resolved alias. + for (const kind of ['agent', 'skill', 'in-place'] as const) { + const options = { ...parentOptions, mainLoopModel: 'gpt-5.6-sol' } + const source = kind === 'skill' + ? { ...params.toolUseContext, options } + : params.toolUseContext + if (kind === 'in-place') source.options.mainLoopModel = 'gpt-4o' + const overridden = createSubagentContext(source, kind === 'agent' ? { options } : undefined) + requests.length = 0 + await drain({ ...params, toolUseContext: overridden }) + const model = kind === 'in-place' ? 'gpt-4o' : 'gpt-5.6-sol' + expect(requests).toEqual([ + { model, requestModel: model, effort: undefined }, + { model, requestModel: model, effort: undefined }, + ]) + } + } finally { + allowlist.mockRestore() + resetRoutingTally() + clearSmartRoutingSessionDisable(getSessionId()) + echoCalls.length = 0 + } + }) + + test('smart routing preserves the sub-agent model through query and tool continuations', async () => { + const allowlist = spyOn(modelAllowlist, 'isModelAllowed').mockReturnValue(true) + clearSmartRoutingSessionDisable(getSessionId()) + resetRoutingTally() + try { + // The main-thread control proves routing is enabled in this fixture. + for (const isSubagent of [false, true]) { + const requests: Array<{ model: string; requestModel?: string }> = [] + const params = makeParams(async function* ({ options }) { + requests.push({ model: options.model, requestModel: options.requestModel }) + yield createAssistantMessage({ + content: requests.length === 1 + ? [{ type: 'tool_use', id: 'toolu_routing', name: 'Echo', input: { text: 'inspect' } }] + : 'done', + }) + }, [echoTool]) + if (!isSubagent) delete params.toolUseContext.agentId + const appState = params.toolUseContext.getAppState() + params.toolUseContext.getAppState = () => ({ + ...appState, + settings: { + smartRouting: { enabled: true, simpleModel: 'gpt-5-mini', strongModel: 'gpt-5' }, + }, + }) + + await drain(params) + + const expectedModel = isSubagent ? 'gpt-4o' : 'gpt-5' + expect(requests).toEqual([ + { model: expectedModel, requestModel: expectedModel }, + { model: expectedModel, requestModel: expectedModel }, + ]) + expect(params.toolUseContext.options.mainLoopModel).toBe(expectedModel) + + if (!isSubagent) { + // Cache-sharing forks inherit the routed turn model in options, + // while the parent's app state still holds the session default. + params.toolUseContext.readFileState = createFileStateCacheWithSizeLimit(10) + const forkContext = createSubagentContext(params.toolUseContext) + expect(forkContext.agentId).toBeTruthy() + expect(forkContext.getAppState().mainLoopModel).toBe('gpt-4o') + expect(forkContext.options.mainLoopModel).toBe('gpt-5') + requests.length = 0 + await drain({ ...params, toolUseContext: forkContext }) + expect(requests).toEqual([ + { model: 'gpt-5', requestModel: 'gpt-5' }, + { model: 'gpt-5', requestModel: 'gpt-5' }, + ]) + } + } + // Only the main-thread turn contributes a routing decision. + expect(getRoutingTally()).toMatchObject({ simple: 0, strong: 1 }) + } finally { + allowlist.mockRestore() + resetRoutingTally() + clearSmartRoutingSessionDisable(getSessionId()) + echoCalls.length = 0 + } + }) + test('shares one turn budget across query calls for the same prompt', async () => { let modelCalls = 0 const turnBudget = createQueryTurnBudget(1) diff --git a/src/services/api/smartRouting/index.test.ts b/src/services/api/smartRouting/index.test.ts index 7bbf6e8df..85788cb25 100644 --- a/src/services/api/smartRouting/index.test.ts +++ b/src/services/api/smartRouting/index.test.ts @@ -177,6 +177,33 @@ describe('decideTurnModel', () => { expect(d).toMatchObject({ routed: true, complexity: 'strong', model: 'gpt-5' }) }) + test('sub-agent loops are never routed, so an agentRouting model is not overwritten', () => { + mockGlobalAllowlist(undefined) + for (const userText of ['ok thanks', 'refactor the auth module please']) { + const d = decideTurnModel({ + settings: enabledSettings(), + parentModel: 'agent-routed-model', + input: { userText, turnNumber: 3 }, + sessionId: 'sess-1', + agentId: 'agent-1', + }) + expect(d).toEqual({ routed: false }) + } + }) + + test('a sub-agent loop does not disable routing for the session', () => { + const spy = mockGlobalAllowlist(['some-other-model']) + decideTurnModel({ + settings: enabledSettings(), + parentModel: PARENT, + input: { userText: 'ok thanks', turnNumber: 3 }, + sessionId: 'sess-1', + agentId: 'agent-1', + }) + expect(isSmartRoutingDisabledForSession('sess-1')).toBe(false) + spy.mockRestore() + }) + test('disallowed simple model coerces to strong', () => { // Distinct, non-prefix-colliding model ids so the allowlist genuinely blocks // simple while permitting strong. diff --git a/src/services/api/smartRouting/index.ts b/src/services/api/smartRouting/index.ts index 71dc57fae..8c87af729 100644 --- a/src/services/api/smartRouting/index.ts +++ b/src/services/api/smartRouting/index.ts @@ -91,6 +91,8 @@ export interface DecideTurnModelInput { permissionMode?: PermissionMode input: RoutingInput sessionId?: string + /** Set when the query loop belongs to a sub-agent rather than the main thread. */ + agentId?: string } /** @@ -100,6 +102,11 @@ export interface DecideTurnModelInput { * short-circuits when the routed model equals the session model). A disallowed * routed model is coerced to strong; if strong is also disallowed, routing is * disabled for the session and the caller falls back to today's resolution. + * + * Sub-agents are never routed. Their prompt is not a user turn: the parent + * turn's decision already holds for its Agent tool call, and the sub-agent's + * model comes from agentRouting or its definition. Pinning here would + * overwrite that model with the classifier's pick. */ export function decideTurnModel({ settings, @@ -107,7 +114,9 @@ export function decideTurnModel({ permissionMode, input, sessionId, + agentId, }: DecideTurnModelInput): TurnRoutingDecision { + if (agentId) return { routed: false } if (isSmartRoutingDisabledForSession(sessionId)) return { routed: false } const config = resolveSmartRoutingConfig({ settings, parentModel, permissionMode }) diff --git a/src/tools/AgentTool/runAgent.output.test.ts b/src/tools/AgentTool/runAgent.output.test.ts index b5f9d782d..85bf304e6 100644 --- a/src/tools/AgentTool/runAgent.output.test.ts +++ b/src/tools/AgentTool/runAgent.output.test.ts @@ -806,7 +806,7 @@ for (const phase of ['initial', 'incremental'] as const) { yield createAssistantMessage({ content: 'fork answer' }) } })) const controller = new AbortController() - const context = { abortController: controller, readFileState: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), getAppState: getDefaultAppState, options: {}, messages: [] } as unknown as ToolUseContext + const context = { abortController: controller, readFileState: createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), getAppState: getDefaultAppState, options: { mainLoopModel: 'test-model' }, messages: [] } as unknown as ToolUseContext const clear = spyOn(FileStateCache.prototype, 'clear') const fork = runForkedAgent({ promptMessages: [createUserMessage({ content: 'fork initial' })], cacheSafeParams: { toolUseContext: context, forkContextMessages: [], systemPrompt: [] as never, userContext: {}, systemContext: {} }, canUseTool: async () => ({ behavior: 'allow' }), querySource: 'compact', forkLabel: 'compact' }) const outcome = fork.then(() => 'finished', error => error) diff --git a/src/tools/AgentTool/runAgent.ts b/src/tools/AgentTool/runAgent.ts index 24831ad1c..5d7093539 100644 --- a/src/tools/AgentTool/runAgent.ts +++ b/src/tools/AgentTool/runAgent.ts @@ -417,6 +417,21 @@ export async function* runAgent({ ) } + // Definitions that resolve to the inherited/default model need no new + // selection permission. A route has its own effective-model check above. + if ( + effectiveModel === resolvedAgentModel && + !providerOverride && + agentDefinition.model && + resolvedAgentModel !== getAgentModel(undefined, toolUseContext.options.mainLoopModel, model, permissionMode) && + !isModelAllowed(agentDefinition.model) && + !isModelAllowed(resolvedAgentModel) + ) { + throw new Error( + `Model '${resolvedAgentModel}' is not available. Your organization restricts model selection.`, + ) + } + const agentId = override?.agentId ? override.agentId : createAgentId() // Route this agent's transcript into a grouping subdirectory if requested diff --git a/src/tools/SkillTool/SkillTool.ts b/src/tools/SkillTool/SkillTool.ts index af0fa4a95..0fc514231 100644 --- a/src/tools/SkillTool/SkillTool.ts +++ b/src/tools/SkillTool/SkillTool.ts @@ -58,6 +58,7 @@ import { lazySchema } from '../../utils/lazySchema.js' import { createUserMessage, normalizeMessages } from '../../utils/messages.js' import type { ModelAlias } from '../../utils/model/aliases.js' import { resolveSkillModelOverride } from '../../utils/model/model.js' +import { isModelAllowed } from '../../utils/model/modelAllowlist.js' import { recordSkillUsage } from '../../utils/suggestions/skillUsageTracking.js' import { createAgentId } from '../../utils/uuid.js' import { runAgent } from '../AgentTool/runAgent.js' @@ -643,6 +644,15 @@ export const SkillTool: Tool = buildTool({ ) } + // Reject before command expansion can register persistent skill hooks. + const requestedModel = command?.type === 'prompt' ? command.model : undefined + if (requestedModel && !isModelAllowed(requestedModel) && + !isModelAllowed(resolveSkillModelOverride(requestedModel, context.options.mainLoopModel))) { + throw new Error( + `Model '${requestedModel}' is not available. Your organization restricts model selection.`, + ) + } + // Process the skill with optional args const { processPromptSlashCommand } = await import( 'src/utils/processUserInput/processSlashCommand.js' diff --git a/src/utils/forkedAgent.ts b/src/utils/forkedAgent.ts index f0ca2a722..d1c068da0 100644 --- a/src/utils/forkedAgent.ts +++ b/src/utils/forkedAgent.ts @@ -35,6 +35,9 @@ import { extractTextContent, getLastAssistantMessage, } from './messages.js' +import { getDefaultMainLoopModelSetting, getProviderRequestModel, parseUserSpecifiedModel } from './model/model.js' +import { getActiveProviderProfile } from './providerProfiles.js' +import { getAPIProvider } from './model/providers.js' import { createDenialTrackingState } from './permissions/denialTracking.js' import { parseToolListFromCLI } from './permissions/permissionSetup.js' import { recordSidechainTranscript } from './sessionStorage.js' @@ -324,6 +327,55 @@ export type SubagentContextOverrides = { contentReplacementState?: ContentReplacementState } +/** Include profileless transport changes, not just named-profile changes. */ +export function getAmbientQueryProviderKey(): string { + return `${getAPIProvider()}:${getActiveProviderProfile()?.id ?? ''}` +} + +/** Return provenance only while its owner and model options are unchanged. */ +export function getOwnedQueryModelSelection(context: ToolUseContext) { + const selection = context.queryModelSelection?.current + return context.queryModelSelection?.agentId === context.agentId && + selection?.options === context.options && + selection.model === context.options.mainLoopModel + ? selection + : undefined +} + +export function getQueryModelSelection(context: ToolUseContext) { + const selection = getOwnedQueryModelSelection(context) + return !selection?.followsProvider || context.options.providerOverride || + selection?.providerKey === getAmbientQueryProviderKey() + ? selection + : undefined +} + +/** Re-select an inherited ambient pair before consuming a context under a new provider. */ +export function refreshAmbientQueryModelSelection(context: ToolUseContext): ToolUseContext { + const selection = getOwnedQueryModelSelection(context) + const providerKey = getAmbientQueryProviderKey() + if (!selection?.followsProvider || context.options.providerOverride || + selection.providerKey === providerKey) return context + + const state = context.getAppState() + const selectedModel = state.mainLoopModelForSession ?? state.mainLoopModel ?? getDefaultMainLoopModelSetting() + const runtimeModel = parseUserSpecifiedModel(selectedModel) + const options = { ...context.options, mainLoopModel: runtimeModel } + // A retained reader still owns its earlier options/selection snapshot. + return { + ...context, + options, + queryModelSelection: { + agentId: context.agentId, + current: { + ...selection, options, model: runtimeModel, selectedModel, runtimeModel, + requestModel: getProviderRequestModel(selectedModel, runtimeModel), + providerKey, + }, + }, + } +} + /** * Creates an isolated ToolUseContext for subagents. * @@ -367,6 +419,31 @@ export function createSubagentContext( parentContext: ToolUseContext, overrides?: SubagentContextOverrides, ): ToolUseContext { + if (!overrides?.options) parentContext = refreshAmbientQueryModelSelection(parentContext) + const selection = getQueryModelSelection(parentContext) + const providerKey = getAmbientQueryProviderKey() + let runtimeModel = selection?.runtimeModel ?? parentContext.options.mainLoopModel + let requestModel = selection?.requestModel ?? parentContext.options.mainLoopModel + // Fresh root options can predate async /btw or SDK preparation. Establish + // the concrete/request pair together from the current configured selection. + // Saved selections, explicit children and fixed providers keep their choices. + if (!overrides?.options && !parentContext.options.providerOverride && + !parentContext.queryModelSelection?.current && !parentContext.agentId) { + const state = parentContext.getAppState() + const selectedModel = state.mainLoopModelForSession ?? state.mainLoopModel ?? getDefaultMainLoopModelSetting() + runtimeModel = parseUserSpecifiedModel(selectedModel) + requestModel = getProviderRequestModel( + selectedModel, + runtimeModel, + ) + } + // Explicit options (including same-runtime model overrides) take precedence. + const options = overrides?.options ?? { + ...parentContext.options, + mainLoopModel: runtimeModel, + } + const agentId = overrides?.agentId ?? createAgentId() + // Determine abortController: explicit override > share parent's > new child const abortController = overrides?.abortController ?? @@ -472,10 +549,28 @@ export function createSubagentContext( openMessageSelector: undefined, // Fields that can be overridden or copied from parent - options: overrides?.options ?? parentContext.options, + options, messages: overrides?.messages ?? parentContext.messages, // Generate new agentId for subagents (each subagent should have its own ID) - agentId: overrides?.agentId ?? createAgentId(), + agentId, + // Runtime options retain the concrete model for budgets and capabilities. + // Request aliases/reasoning travel separately, owned by this child. Explicit + // options start a new selection even when the concrete model is unchanged. + queryModelSelection: { + agentId, + ...(!overrides?.options && { + current: { + options, + model: options.mainLoopModel, + selectedModel: requestModel, + runtimeModel: options.mainLoopModel, + requestModel, + providerKey, + followsProvider: selection?.followsProvider ?? + (!parentContext.agentId && !parentContext.queryModelSelection?.current), + }, + }), + }, agentType: overrides?.agentType, ...(overrides?.queryLifecycle ? { queryLifecycle: overrides.queryLifecycle } diff --git a/src/utils/hooks/execAgentHook.ts b/src/utils/hooks/execAgentHook.ts index 28c082deb..fe4189c96 100644 --- a/src/utils/hooks/execAgentHook.ts +++ b/src/utils/hooks/execAgentHook.ts @@ -17,6 +17,7 @@ import { errorMessage } from '../errors.js' import type { HookResult } from '../hooks.js' import { createUserMessage, handleMessageFromStream } from '../messages.js' import { getSmallFastModel } from '../model/model.js' +import { isModelAllowed } from '../model/modelAllowlist.js' import { hasPermissionsToUseTool } from '../permissions/permissions.js' import { getAgentTranscriptPath, getTranscriptPath } from '../sessionStorage.js' import type { AgentHook } from '../settings/types.js' @@ -116,6 +117,11 @@ When done, return your result using the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool with: ]) const model = hook.model ?? getSmallFastModel() + if (hook.model && !isModelAllowed(hook.model)) { + throw new Error( + `Model '${hook.model}' is not available. Your organization restricts model selection.`, + ) + } const MAX_AGENT_TURNS = 50 // Create unique agentId for this hook agent