diff --git a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts index 98c1b8a87e..8d3d7d56ae 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/runtimeSetModel.test.ts @@ -90,6 +90,52 @@ describe('applyRuntimeSetModelChange', () => { expect(getSessionProvider(sessionId)).toBe('xd'); }); + it('serializes route writes so an older failure cannot overwrite a newer switch', async () => { + const sessionId = rememberSession('runtime-set-model-concurrent-rollback'); + setSessionProvider(sessionId, 'openai'); + let rejectFirst!: (error: Error) => void; + const firstGate = new Promise((_resolve, reject) => { + rejectFirst = reject; + }); + let callCount = 0; + const setModel = vi.fn(() => { + callCount += 1; + return callCount === 1 ? firstGate : Promise.resolve(); + }); + const writes: Array = []; + sessionProviderWriteObserver.current = (writtenSessionId, providerId) => { + if (writtenSessionId === sessionId) writes.push(providerId); + }; + const maker: RuntimeSetModelMaker = { + getSession: () => ({ + agentKind: 'codex', + remoteHostId: 'remote-1', + model: 'model-a', + setModel, + }), + listActiveSessions: () => [], + closeSession: vi.fn(async () => {}), + }; + + const firstSwitch = applyRuntimeSetModelChange({ maker, sessionId, model: 'model-b', providerId: 'xd' }); + const firstFailure = expect(firstSwitch).rejects.toThrow('first switch failed'); + await vi.waitFor(() => expect(setModel).toHaveBeenCalledTimes(1)); + const secondSwitch = applyRuntimeSetModelChange({ maker, sessionId, model: 'model-c', providerId: 'xai' }); + await Promise.resolve(); + expect(setModel).toHaveBeenCalledTimes(1); + expect(getSessionProvider(sessionId)).toBe('xd'); + + rejectFirst(new Error('first switch failed')); + await firstFailure; + await secondSwitch; + expect(setModel.mock.calls).toEqual([ + ['model-b', { providerId: 'xd' }], + ['model-c', { providerId: 'xai' }], + ]); + expect(writes).toEqual(['xd', 'openai', 'xai']); + expect(getSessionProvider(sessionId)).toBe('xai'); + }); + it('keeps a successful provider route change after live setModel succeeds', async () => { const sessionId = rememberSession('runtime-set-model-success'); const setModel = vi.fn(async () => {}); diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 414692b8cd..ec29d04b65 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -108,6 +108,29 @@ export function isRemoteModelSwitchRouteChangeError(error: unknown): boolean { ); } +const runtimeSetModelChangeLocks = new Map>(); + +async function withRuntimeSetModelChangeLock( + sessionId: string, + run: () => Promise, +): Promise { + const previous = runtimeSetModelChangeLocks.get(sessionId) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + runtimeSetModelChangeLocks.set(sessionId, current); + await previous; + try { + return await run(); + } finally { + release(); + if (runtimeSetModelChangeLocks.get(sessionId) === current) { + runtimeSetModelChangeLocks.delete(sessionId); + } + } +} + /** * 应用本地运行时 model/provider 切换。 * @@ -126,6 +149,13 @@ export function isRemoteModelSwitchRouteChangeError(error: unknown): boolean { */ export async function applyRuntimeSetModelChange( input: ApplyRuntimeSetModelChangeInput, +): Promise { + return withRuntimeSetModelChangeLock(input.sessionId, () => + applyRuntimeSetModelChangeUnlocked(input)); +} + +async function applyRuntimeSetModelChangeUnlocked( + input: ApplyRuntimeSetModelChangeInput, ): Promise { const { maker, sessionId, model, providerId, effort, isSessionInTurn, logger } = input; const normalizedProviderId = normalizeSessionProviderId(providerId); diff --git a/packages/maker-core/src/agents/codex/index.test.ts b/packages/maker-core/src/agents/codex/index.test.ts index 05eb1e04dd..11dd4e1e0f 100644 --- a/packages/maker-core/src/agents/codex/index.test.ts +++ b/packages/maker-core/src/agents/codex/index.test.ts @@ -2908,7 +2908,6 @@ describe('CodexAgent reference directories', () => { ); expect(turnCall?.[1]).toMatchObject({ threadId: 'start-thread-2', - serviceTier: 'fast', }); expect(bindingLeases.has('local')).toBe(false); await handle.close(); @@ -3234,7 +3233,6 @@ describe('CodexAgent reference directories', () => { ); expect(turnCalls[1]?.[1]).toMatchObject({ threadId: 'start-thread-id', - serviceTier: 'fast', }); await handle.close(); }); @@ -3529,7 +3527,7 @@ describe('CodexAgent reference directories', () => { await handle.close(); }); - it('retries a stale reference turn with its frozen permission profile', async () => { + it('retries a stale reference turn with its frozen send route', async () => { const agent = new CodexAgent(createDeps()); const firstTurnGate = deferred(); let turnStartCount = 0; @@ -3577,8 +3575,7 @@ describe('CodexAgent reference directories', () => { runtimeWorkspaceRoots: ['/repo', '/shared-frozen-turn'], permissions: profileName, approvalPolicy: 'on-request', - model: 'gpt-5.5', - serviceTier: 'fast', + model: 'gpt-5.4', }); expect('sandbox' in resumeParams).toBe(false); @@ -3591,8 +3588,7 @@ describe('CodexAgent reference directories', () => { expect('sandboxPolicy' in params).toBe(false); } expect(turnCalls[1]?.[1]).toMatchObject({ - model: 'gpt-5.5', - serviceTier: 'fast', + model: 'gpt-5.4', }); await handle.close(); }); @@ -25330,10 +25326,10 @@ describe('CodexAgent plan mode', () => { collaborationMode?: { mode: string; settings: { model: string; reasoning_effort: string } }; }]; expect(retryParams.collaborationMode?.settings).toMatchObject({ - model: 'gpt-5.4', + model: 'gpt-5', reasoning_effort: 'high', }); - expect(handle.model).toBe('gpt-5.4'); + expect(handle.model).toBe('gpt-5'); await handle.close(); }); @@ -27542,6 +27538,7 @@ describe('CodexAgent context window reporting', () => { let turnSeq = 0; return installFakeHost(agent, (method) => { if (method === Method.TurnStart) return { turn: { id: `turn-${++turnSeq}` } }; + if (method === Method.ThreadResume) return { thread: { id: 'start-thread-id' } }; if (method === Method.ThreadSettingsUpdate) return {}; if (method === Method.TurnInterrupt) return {}; return undefined; @@ -27568,6 +27565,215 @@ describe('CodexAgent context window reporting', () => { } as never); } + it('passes a verified provider/model window to new and resumed thread config', async () => { + const resolveVerifiedContextWindow = vi.fn( + (providerId: string | null | undefined, modelId: string) => + providerId === 'custom-provider' && modelId === 'custom-model' ? 1_000_001 : null, + ); + const agent = new CodexAgent(createDeps({}, { resolveVerifiedContextWindow })); + const host = installFakeHost(agent); + const started = await agent.startSession({ + sessionId: 'session-context-window-start', + model: 'custom-model', + providerId: 'custom-provider', + workingDir: '/repo', + }); + const [, startParams] = host.request.mock.calls.find( + ([method]) => method === Method.ThreadStart, + ) as [string, Record]; + expect(startParams.config).toMatchObject({ model_context_window: 1_000_001 }); + await started.close(); + + const resumed = await agent.startSession({ + sessionId: 'session-context-window-resume', + model: 'custom-model', + providerId: 'custom-provider', + workingDir: '/repo', + resumeSessionId: '123e4567-e89b-12d3-a456-426614174000', + }); + const [, resumeParams] = host.request.mock.calls.find( + ([method]) => method === Method.ThreadResume, + ) as [string, Record]; + expect(resumeParams.config).toMatchObject({ model_context_window: 1_000_001 }); + expect(resolveVerifiedContextWindow).toHaveBeenCalledWith('custom-provider', 'custom-model'); + await resumed.close(); + }); + + it('reapplies a changed verified window on an idle model switch', async () => { + const agent = agentWithVerified((_providerId, modelId) => + modelId === 'new-model' ? 900_000 : 300_000, + ); + const host = installTurnCapableHost(agent); + const handle = await agent.startSession({ + sessionId: 'session-context-window-idle-switch', + model: 'old-model', + workingDir: '/repo', + }); + const handlers = host.getThreadHandlers(); + if (!handlers) throw new Error('expected thread handlers'); + await handle.send({ type: 'user', content: 'create a rollout' }); + handlers.turnStarted?.({ threadId: 'start-thread-id', turn: { id: 'turn-1' } } as never); + handlers.turnCompleted?.({ + threadId: 'start-thread-id', + turn: { id: 'turn-1', status: 'completed' }, + } as never); + if (!handle.setModel) throw new Error('expected setModel support'); + await handle.setModel('new-model'); + expect(host.unsubscribeThread).toHaveBeenCalledWith('start-thread-id'); + const resumeCalls = host.request.mock.calls.filter( + ([method]) => method === Method.ThreadResume, + ); + expect(resumeCalls).toHaveLength(1); + expect(resumeCalls[0]?.[1]).toMatchObject({ + model: 'new-model', + config: { model_context_window: 900_000 }, + }); + expect(host.request.mock.calls.some(([method]) => method === Method.ThreadSettingsUpdate)).toBe(false); + await handle.close(); + }); + + it('records the profile sent by an unused-thread replacement', async () => { + const replacementGate = deferred<{ + thread: { id: string }; + model: string; + modelProvider: string; + cwd: string; + }>(); + let threadStartCount = 0; + const agent = agentWithVerified((_providerId, modelId) => + modelId === 'new-model' ? 900_000 : 300_000, + ); + const host = installFakeHost(agent, (method) => { + if (method === Method.ThreadStart) { + threadStartCount += 1; + if (threadStartCount === 2) return replacementGate.promise; + return { + thread: { id: `start-thread-${threadStartCount}` }, + model: 'new-model', + modelProvider: 'openai', + cwd: '/repo', + }; + } + if (method === Method.TurnStart) return { turn: { id: 'turn-1' } }; + return undefined; + }); + const handle = await agent.startSession({ + sessionId: 'session-context-window-replacement-profile', + model: 'old-model', + workingDir: '/repo', + }); + + if (!handle.setModel) throw new Error('expected setModel support'); + const switchPromise = handle.setModel('new-model'); + await vi.waitFor(() => expect(threadStartCount).toBe(2)); + await handle.setExtraDirs?.(['/shared-during-replacement']); + replacementGate.resolve({ + thread: { id: 'start-thread-2' }, + model: 'new-model', + modelProvider: 'openai', + cwd: '/repo', + }); + await switchPromise; + + await handle.send({ type: 'user', content: 'use the reference' }); + const startCalls = host.request.mock.calls.filter(([method]) => method === Method.ThreadStart); + expect(startCalls).toHaveLength(3); + expect(startCalls[2]?.[1]).toMatchObject({ + permissions: 'cindy-readonly-references', + runtimeWorkspaceRoots: ['/repo', '/shared-during-replacement'], + }); + await handle.close(); + }); + + it('clears a previously applied window when the live catalog no longer verifies it', async () => { + let catalogWindow: number | undefined = 300_000; + const agent = agentWithVerified((_providerId, modelId) => + modelId === 'old-model' || modelId === 'new-model' ? catalogWindow ?? null : null, + ); + const host = installTurnCapableHost(agent); + const handle = await agent.startSession({ + sessionId: 'session-context-window-clear', + model: 'old-model', + workingDir: '/repo', + }); + const handlers = host.getThreadHandlers(); + if (!handlers) throw new Error('expected thread handlers'); + await handle.send({ type: 'user', content: 'create a rollout' }); + handlers.turnStarted?.({ threadId: 'start-thread-id', turn: { id: 'turn-1' } } as never); + handlers.turnCompleted?.({ + threadId: 'start-thread-id', + turn: { id: 'turn-1', status: 'completed' }, + } as never); + if (!handle.setModel) throw new Error('expected setModel support'); + catalogWindow = undefined; + await handle.setModel('new-model'); + const resumeCalls = host.request.mock.calls.filter( + ([method]) => method === Method.ThreadResume, + ); + expect(resumeCalls).toHaveLength(1); + expect(resumeCalls[0]?.[1]).not.toHaveProperty('config.model_context_window'); + await handle.close(); + }); + + it('freezes the send route while asynchronous input preparation is pending', async () => { + const skillsEntered = deferred(); + const skillsGate = deferred<{ skills: []; errors: [] }>(); + const agent = agentWithVerified((_providerId, modelId) => + modelId === 'new-model' ? 900_000 : 300_000, + ); + vi.spyOn(agent as unknown as { + listSkillsForCwd: ( + workingDir: string, + forceReload: boolean, + ) => Promise<{ skills: []; errors: [] }>; + }, 'listSkillsForCwd').mockImplementation(() => { + skillsEntered.resolve(); + return skillsGate.promise; + }); + const host = installTurnCapableHost(agent); + const handle = await agent.startSession({ + sessionId: 'session-context-window-input-snapshot', + model: 'old-model', + workingDir: '/repo', + }); + const handlers = host.getThreadHandlers(); + if (!handlers) throw new Error('expected thread handlers'); + const initialStart = host.request.mock.calls.find( + ([method]) => method === Method.ThreadStart, + ); + expect(initialStart?.[1]).toMatchObject({ config: { model_context_window: 300_000 } }); + await handle.send({ type: 'user', content: 'first' }); + handlers.turnStarted?.({ threadId: 'start-thread-id', turn: { id: 'turn-1' } } as never); + handlers.turnCompleted?.({ + threadId: 'start-thread-id', + turn: { id: 'turn-1', status: 'completed' }, + } as never); + const pendingSend = handle.send({ type: 'user', content: '/slow-skill' }); + await skillsEntered.promise; + if (!handle.setModel) throw new Error('expected setModel support'); + await handle.setModel('new-model'); + skillsGate.resolve({ skills: [], errors: [] }); + await pendingSend; + const turnCalls = host.request.mock.calls.filter(([method]) => method === Method.TurnStart); + expect(turnCalls[1]?.[1]).toMatchObject({ model: 'old-model' }); + handlers.turnStarted?.({ threadId: 'start-thread-id', turn: { id: 'turn-2' } } as never); + handlers.turnCompleted?.({ + threadId: 'start-thread-id', + turn: { id: 'turn-2', status: 'completed' }, + } as never); + await vi.waitFor(() => { + const resumeCalls = host.request.mock.calls.filter(([method]) => method === Method.ThreadResume); + expect(resumeCalls[0]?.[1]).toMatchObject({ + model: 'new-model', + config: { model_context_window: 900_000 }, + }); + }); + await handle.send({ type: 'user', content: 'next' }); + const nextTurnCall = host.request.mock.calls.filter(([method]) => method === Method.TurnStart).at(-1); + expect(nextTurnCall?.[1]).toMatchObject({ model: 'new-model' }); + await handle.close(); + }); + async function reportedContextWindow( agent: CodexAgent, sessionId: string, @@ -27726,10 +27932,8 @@ describe('CodexAgent context window reporting', () => { await handle.close(); }); - // daemon 重启后 turn/start 报 "thread not found" 会走 thread/resume + 重投; 若会话是用 - // 'gpt-5' 哨兵启动的, resume 会把它解析成具体路由模型并重写 turnParams.model。快照必须 - // 跟着改写走 —— 否则重投 turn 的用量按 'gpt-5' 去问 host(拿不到上限), 保留 1M。 - it('daemon 恢复重写模型后刷新 turn 模型快照', async () => { + // 未验证的 sentinel 路由不做 model/list 探测;恢复时保持 Codex 默认窗口。 + it('daemon 恢复时对未验证 sentinel 保持 Codex 默认窗口', async () => { const agent = agentWithWindows({ [GATEWAY_MODEL]: 372_000 }); let turnStartCount = 0; const host = installFakeHost(agent, (method) => { @@ -27764,7 +27968,7 @@ describe('CodexAgent context window reporting', () => { handlers.turnStarted?.({ threadId: 'start-thread-id', turn: { id: 'turn-2' } } as never); pushUsage(handlers, 'turn-2', 1_000_000); - expect(handle.getUsageSnapshot().contextWindow).toBe(372_000); + expect(handle.getUsageSnapshot().contextWindow).toBe(1_000_000); await handle.close(); }); diff --git a/packages/maker-core/src/agents/codex/index.ts b/packages/maker-core/src/agents/codex/index.ts index 9015c04cc7..892c9605f9 100644 --- a/packages/maker-core/src/agents/codex/index.ts +++ b/packages/maker-core/src/agents/codex/index.ts @@ -4110,6 +4110,28 @@ export class CodexAgent extends BaseAgent { // was attempted, prefer resume before replacing the thread; "no rollout" // is the only safe proof that the thread is still unused. let threadMayHaveRollout = false; + // model_context_window is thread-scoped and cannot be changed through + // thread/settings/update. Keep the last successfully applied value so a + // live catalog refresh cannot make the old route look newer than the + // loaded thread actually is. + let appliedThreadModelContextWindow: number | undefined; + let hasAppliedThreadModelContextWindow = false; + let pendingThreadModelContextWindowReapply = false; + // Model/provider changes and turn route admission share one FIFO. A send + // snapshots every change queued before it; changes queued while that + // snapshot is being prepared wait for the admission gate and affect the + // next turn only. + let modelChangeChain: Promise = Promise.resolve(); + let turnRouteAdmissionGate: Promise | null = null; + const enqueueModelChange = (task: () => Promise): Promise => { + const admissionGate = turnRouteAdmissionGate; + const run = modelChangeChain.then(async () => { + if (admissionGate) await admissionGate; + return task(); + }); + modelChangeChain = run.then(() => undefined, () => undefined); + return run; + }; // 计划模式(与 permissionMode 正交, **一次性选择**): mutablePlanMode 是 UI 勾选的 // "武装"态 —— send 消耗它并立即 emit plan_mode_changed(false) 让勾选熄灭; // 本轮「计划 → 审阅 → 修订/批准」循环由 planCycleActive 承载: @@ -5169,7 +5191,47 @@ export class CodexAgent extends BaseAgent { return JSON.stringify(mutableWritableDirs); } - function currentThreadWorkspaceConfig(): Pick< + type CodexRouteSnapshot = { + model: string; + catalogModel: string | undefined; + providerId: string | null | undefined; + effort: Effort; + serviceTier: ServiceTier | null | undefined; + }; + + const currentRouteSnapshot = (): CodexRouteSnapshot => ({ + model: mutableModel, + catalogModel: mutableCatalogModel, + providerId: mutableProviderId, + effort: mutableEffort, + serviceTier: mutableServiceTier, + }); + + const resolveVerifiedContextWindow = this.deps.resolveVerifiedContextWindow; + const verifiedModelContextWindow = ( + providerId: string | null | undefined, + modelId: string | undefined, + ): number | undefined => { + if (!modelId) return undefined; + const value = resolveVerifiedContextWindow?.(providerId, modelId); + return typeof value === 'number' && Number.isInteger(value) && value > 0 + ? value + : undefined; + }; + const markThreadModelContextWindowApplied = ( + workspaceConfig: Pick, + ): void => { + const value = workspaceConfig.config?.model_context_window; + appliedThreadModelContextWindow = + typeof value === 'number' && Number.isInteger(value) && value > 0 + ? value + : undefined; + hasAppliedThreadModelContextWindow = true; + }; + + function currentThreadWorkspaceConfig( + route: CodexRouteSnapshot = currentRouteSnapshot(), + ): Pick< ThreadStartParams, | 'approvalPolicy' | 'approvalsReviewer' @@ -5179,6 +5241,10 @@ export class CodexAgent extends BaseAgent { | 'config' > { const { approvalPolicy, approvalsReviewer, sandbox } = currentApprovalConfig(); + const modelContextWindow = verifiedModelContextWindow( + route.providerId, + route.catalogModel ?? route.model, + ); const config = { ...capabilityRoutingConfig, ...(readonlyReferenceDirsSupported ? readonlyReferencesConfig() : {}), @@ -5194,6 +5260,9 @@ export class CodexAgent extends BaseAgent { } : {}), ...(reviewMode ? {} : host.getSessionMcpConfig(opts.sessionInstanceId)), + ...(modelContextWindow !== undefined + ? { model_context_window: modelContextWindow } + : {}), }; const shared = { approvalPolicy, @@ -5216,7 +5285,9 @@ export class CodexAgent extends BaseAgent { return { ...shared, sandbox }; } - function currentTurnWorkspaceConfig(): Pick< + function currentTurnWorkspaceConfig( + options?: { allowInactiveReadonlyProfile?: boolean }, + ): Pick< TurnStartParams, | 'approvalPolicy' | 'approvalsReviewer' @@ -5244,7 +5315,8 @@ export class CodexAgent extends BaseAgent { }; if ( shouldUseReadonlyReferencesProfile() && - !activeTurnPermissionPolicy + !activeTurnPermissionPolicy && + options?.allowInactiveReadonlyProfile !== true ) { // The profile was selected on thread/start or thread/resume. Repeating // the selector here makes Codex 0.145.0 reload its base config (which @@ -5280,6 +5352,7 @@ export class CodexAgent extends BaseAgent { function collaborationModeForTurn( planThisTurn: boolean, continuePlanCycleThisTurn = planCycleActive, + route: Pick = currentRouteSnapshot(), ): CollaborationModeParam | undefined { // 只由「本 turn 意图(per-send 快照/消耗武装态的结果) + 进行中的循环」驱动, // **不**读武装态 mutablePlanMode —— 排队普通消息(快照 false)派发时武装态 @@ -5288,8 +5361,8 @@ export class CodexAgent extends BaseAgent { return { mode: 'plan', settings: { - model: mutableModel, - reasoning_effort: clampEffortForCodex(mutableModel, mutableEffort), + model: route.model, + reasoning_effort: clampEffortForCodex(route.model, route.effort), developer_instructions: null, }, }; @@ -5299,8 +5372,8 @@ export class CodexAgent extends BaseAgent { return { mode: 'default', settings: { - model: mutableModel, - reasoning_effort: clampEffortForCodex(mutableModel, mutableEffort), + model: route.model, + reasoning_effort: clampEffortForCodex(route.model, route.effort), developer_instructions: developerInstructions, }, }; @@ -5559,6 +5632,7 @@ export class CodexAgent extends BaseAgent { timeoutMs: CRITICAL_THREAD_RPC_TIMEOUT_MS, }); assertCurrentHost('thread/resume'); + markThreadModelContextWindowApplied(params); if (Object.hasOwn(resp, 'serviceTier')) { mutableServiceTier = normalizeServiceTier(resp.serviceTier) ?? null; } @@ -5640,6 +5714,7 @@ export class CodexAgent extends BaseAgent { timeoutMs: CRITICAL_THREAD_RPC_TIMEOUT_MS, }); assertCurrentHost('thread/start'); + markThreadModelContextWindowApplied(params); if (Object.hasOwn(resp, 'serviceTier')) { mutableServiceTier = normalizeServiceTier(resp.serviceTier) ?? null; } @@ -5759,10 +5834,12 @@ export class CodexAgent extends BaseAgent { */ const replaceUnusedThreadWithCurrentProfile = async ( signal?: AbortSignal, + route: CodexRouteSnapshot = currentRouteSnapshot(), ): Promise => { const previousThreadId = threadId; const replacementProfileFingerprint = currentReadonlyReferencesProfileFingerprint(); const replacementServiceTierGeneration = serviceTierMutationGeneration; + const replacementThreadWorkspaceConfig = currentThreadWorkspaceConfig(route); const inheritedHostBindingLease = releaseHostBindingLease !== null; acquireHostBindingLeaseIfNeeded(); try { @@ -5772,11 +5849,11 @@ export class CodexAgent extends BaseAgent { signal, request: () => host.request(Method.ThreadStart, { cwd: opts.workingDir, - ...currentThreadWorkspaceConfig(), + ...replacementThreadWorkspaceConfig, ...(sessionDynamicTools.length > 0 ? { dynamicTools: sessionDynamicTools } : {}), ...(threadModelProvider ? { modelProvider: threadModelProvider } : {}), - ...(mutableModel && mutableModel !== 'gpt-5' ? { model: mutableModel } : {}), - ...(mutableServiceTier !== undefined ? { serviceTier: mutableServiceTier } : {}), + ...(route.model && route.model !== 'gpt-5' ? { model: route.model } : {}), + ...(route.serviceTier !== undefined ? { serviceTier: route.serviceTier } : {}), ...(developerInstructions && !useProxyChannel ? { developerInstructions } : {}), }), onLateResolve: async (lateResp) => { @@ -5794,6 +5871,7 @@ export class CodexAgent extends BaseAgent { }, }); assertCurrentHost('read-only reference profile replacement'); + markThreadModelContextWindowApplied(replacementThreadWorkspaceConfig); const nextThreadId = resp.thread.id; if (closed) { await unsubscribeDetachedThread( @@ -5847,7 +5925,10 @@ export class CodexAgent extends BaseAgent { ? { threadId, historyHasProductPrompt: true } : undefined; } - readonlyReferencesProfileActive = replacementProfileFingerprint !== null; + // Record the profile that was actually sent with this replacement, not + // a newer mutable reference-directory selection made while thread/start + // was in flight. + readonlyReferencesProfileActive = 'permissions' in replacementThreadWorkspaceConfig; readonlyReferencesProfileFingerprint = readonlyReferencesProfileActive ? replacementProfileFingerprint : null; @@ -5871,6 +5952,7 @@ export class CodexAgent extends BaseAgent { */ const ensureReadonlyReferencesProfileForNextTurn = ( signal?: AbortSignal, + route: CodexRouteSnapshot = currentRouteSnapshot(), ): Promise | null => { const desiredFingerprint = currentReadonlyReferencesProfileFingerprint(); if ( @@ -5885,10 +5967,10 @@ export class CodexAgent extends BaseAgent { readonlyReferencesProfileFingerprint === targetFingerprint ) return; if (!threadMayHaveRollout) { - await replaceUnusedThreadWithCurrentProfile(signal); + await replaceUnusedThreadWithCurrentProfile(signal, route); continue; } - const resumeThreadWorkspaceConfig = currentThreadWorkspaceConfig(); + const resumeThreadWorkspaceConfig = currentThreadWorkspaceConfig(route); const resumeProfileFingerprint = targetFingerprint; const resumeServiceTierGeneration = serviceTierMutationGeneration; assertCurrentHost('read-only reference profile refresh'); @@ -5903,15 +5985,15 @@ export class CodexAgent extends BaseAgent { cwd: opts.workingDir, ...resumeThreadWorkspaceConfig, ...(threadModelProvider ? { modelProvider: threadModelProvider } : {}), - ...(mutableModel && mutableModel !== 'gpt-5' ? { model: mutableModel } : {}), - ...(mutableServiceTier !== undefined ? { serviceTier: mutableServiceTier } : {}), + ...(route.model && route.model !== 'gpt-5' ? { model: route.model } : {}), + ...(route.serviceTier !== undefined ? { serviceTier: route.serviceTier } : {}), }), }); } catch (e) { if (!/no rollout found/i.test(String(e))) throw e; if (signal?.aborted) throw new Error('Codex send cancelled before acceptance'); threadMayHaveRollout = false; - await replaceUnusedThreadWithCurrentProfile(signal); + await replaceUnusedThreadWithCurrentProfile(signal, route); continue; } assertCurrentHost('read-only reference profile refresh'); @@ -5924,6 +6006,7 @@ export class CodexAgent extends BaseAgent { void pushThreadSettings({ serviceTier: mutableServiceTier ?? null }); } codexThreadModelProviderId = resp.modelProvider?.trim() || undefined; + markThreadModelContextWindowApplied(resumeThreadWorkspaceConfig); readonlyReferencesProfileActive = 'permissions' in resumeThreadWorkspaceConfig; readonlyReferencesProfileFingerprint = readonlyReferencesProfileActive ? resumeProfileFingerprint @@ -5937,6 +6020,138 @@ export class CodexAgent extends BaseAgent { })(); }; + const reapplyThreadModelContextWindow = async ( + route: CodexRouteSnapshot = currentRouteSnapshot(), + ): Promise => { + if (!threadId || closed) return; + const workspaceConfig = currentThreadWorkspaceConfig(route); + const targetWindow = workspaceConfig.config?.model_context_window; + if (hasAppliedThreadModelContextWindow && appliedThreadModelContextWindow === targetWindow) { + pendingThreadModelContextWindowReapply = false; + return; + } + if (!threadMayHaveRollout) { + await replaceUnusedThreadWithCurrentProfile(undefined, route); + pendingThreadModelContextWindowReapply = false; + return; + } + + const reapplyThreadId = threadId; + const serviceTierGeneration = serviceTierMutationGeneration; + assertCurrentHost('thread config reapply after model switch'); + await host.unsubscribeThread(reapplyThreadId); + if (closed) return; + try { + const resp = await requestProfileLifecycle({ + action: 'refresh', + request: () => host.request(Method.ThreadResume, { + threadId: reapplyThreadId, + ...(resumeExcludeTurnsSupported ? { excludeTurns: true } : {}), + cwd: opts.workingDir, + ...workspaceConfig, + ...(threadModelProvider ? { modelProvider: threadModelProvider } : {}), + ...(route.model && route.model !== 'gpt-5' ? { model: route.model } : {}), + ...(route.serviceTier !== undefined ? { serviceTier: route.serviceTier } : {}), + }), + }); + assertCurrentHost('thread config reapply after model switch'); + if (resp.thread.id !== reapplyThreadId) { + throw new Error( + `Codex thread/resume returned ${resp.thread.id} while reapplying ${reapplyThreadId}`, + ); + } + markThreadModelContextWindowApplied(workspaceConfig); + if ( + Object.hasOwn(resp, 'serviceTier') + && serviceTierGeneration === serviceTierMutationGeneration + ) { + mutableServiceTier = normalizeServiceTier(resp.serviceTier) ?? null; + } else if (serviceTierGeneration !== serviceTierMutationGeneration) { + void pushThreadSettings({ serviceTier: mutableServiceTier ?? null }); + } + codexThreadModelProviderId = resp.modelProvider?.trim() || undefined; + readonlyReferencesProfileActive = 'permissions' in workspaceConfig; + threadMayHaveRollout = true; + pendingThreadModelContextWindowReapply = false; + } catch (error) { + if (/no rollout found/i.test(String(error))) { + threadMayHaveRollout = false; + await replaceUnusedThreadWithCurrentProfile(undefined, route); + pendingThreadModelContextWindowReapply = false; + return; + } + throw error; + } + }; + + const schedulePendingThreadModelContextWindowReapply = (): void => { + if ( + !pendingThreadModelContextWindowReapply + || closed + || isTurnInFlight + || isTurnStartPending + ) return; + void enqueueModelChange(() => reapplyThreadModelContextWindow()).catch((error) => { + if (!closed) pendingThreadModelContextWindowReapply = true; + log.warn('deferred Codex thread context window reapply failed', { + threadId, + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + + const captureTurnRoute = async ( + signal?: AbortSignal, + ): Promise<{ + route: CodexRouteSnapshot; + turnWorkspaceConfig: ReturnType; + threadWorkspaceConfig: ReturnType; + threadProfileFingerprint: string | null; + }> => { + while (turnRouteAdmissionGate) await turnRouteAdmissionGate; + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + turnRouteAdmissionGate = gate; + const admissionTurnWorkspaceConfig = currentTurnWorkspaceConfig({ + allowInactiveReadonlyProfile: true, + }); + const admissionUsesReadonlyProfile = + shouldUseReadonlyReferencesProfile() && !activeTurnPermissionPolicy; + try { + const queuedChanges = modelChangeChain; + await queuedChanges; + if (pendingThreadModelContextWindowReapply) { + await reapplyThreadModelContextWindow(); + } + const route = currentRouteSnapshot(); + const profileRefresh = ensureReadonlyReferencesProfileForNextTurn(signal, route); + if (profileRefresh) await profileRefresh; + const threadWorkspaceConfig = currentThreadWorkspaceConfig(route); + const threadProfileFingerprint = currentReadonlyReferencesProfileFingerprint(); + const preparedTurnWorkspaceConfig = currentTurnWorkspaceConfig(); + const turnWorkspaceConfig = { + ...preparedTurnWorkspaceConfig, + approvalPolicy: admissionTurnWorkspaceConfig.approvalPolicy, + ...(admissionTurnWorkspaceConfig.approvalsReviewer + ? { approvalsReviewer: admissionTurnWorkspaceConfig.approvalsReviewer } + : {}), + ...(admissionTurnWorkspaceConfig.runtimeWorkspaceRoots + ? { runtimeWorkspaceRoots: admissionTurnWorkspaceConfig.runtimeWorkspaceRoots } + : {}), + }; + if (!admissionTurnWorkspaceConfig.approvalsReviewer) { + delete (turnWorkspaceConfig as { approvalsReviewer?: unknown }).approvalsReviewer; + } + if (admissionUsesReadonlyProfile) { + delete (turnWorkspaceConfig as { sandboxPolicy?: unknown }).sandboxPolicy; + } + return { route, turnWorkspaceConfig, threadWorkspaceConfig, threadProfileFingerprint }; + } finally { + if (turnRouteAdmissionGate === gate) turnRouteAdmissionGate = null; + release(); + } + }; + // ── dispatchInteraction + pendingApprovals (Claude 同款 dismissAllPending 模式) ── // // 为什么需要 pendingApprovals Map: server 发来 ServerRequest, 我们 await @@ -10461,6 +10676,7 @@ export class CodexAgent extends BaseAgent { return; } handleTurnCompleted(params); + schedulePendingThreadModelContextWindowReapply(); }, itemStarted: (params) => { // 血缘不能跟着 turn 对账队列一起迟到:AppServerHost 只为未知 child 缓冲 5s。 @@ -11284,24 +11500,17 @@ export class CodexAgent extends BaseAgent { source: 'codex', }); - // Phase 3: 把 mutable 配置每 turn 透传 — server 接受 per-turn 覆盖。 - // **关键**: 无引用目录时用 sandboxPolicy: SandboxPolicy;有引用目录时继承 - // thread/start / thread/resume 已激活的 named permissions profile。profile - // selector 不能在 turn/start 重复发送,见 ensureReadonlyReferencesProfileForNextTurn。 - // effort 同理: 协议层只能在 turn/start 透传 (v2.rs:5800), thread/start 不接; - // 用户在 session 创建时选的 effort 也是靠 first turn/start 这里传过去才生效。 - let turnWorkspaceConfig: ReturnType; - let turnThreadWorkspaceConfig: ReturnType; - let turnThreadProfileFingerprint: string | null; + // Permission tightening may arrive while route/profile admission is still + // preparing the send. Seed the unattended marker from the admission-time + // mode so a pending Full-access turn is still interrupted when its id arrives; + // the authoritative approval policy is recalculated below before turn/start. + turnLaunchedUnattended = mutablePermissionMode === 'bypassPermissions'; + + // Admit the route before asynchronous Skill/input preparation. Model + // changes queued afterwards are deliberately for the next turn. + let turnRouteSnapshot: Awaited>; try { - const profileRefresh = ensureReadonlyReferencesProfileForNextTurn(sendOpts?.signal); - if (profileRefresh) await profileRefresh; - turnWorkspaceConfig = currentTurnWorkspaceConfig(); - // A stale-daemon retry must hydrate the exact thread-level profile - // that matches this turn, not mutable settings changed while the - // original turn/start RPC was pending. - turnThreadWorkspaceConfig = currentThreadWorkspaceConfig(); - turnThreadProfileFingerprint = currentReadonlyReferencesProfileFingerprint(); + turnRouteSnapshot = await captureTurnRoute(sendOpts?.signal); } catch (e) { isTurnStartPending = false; endPlanCycleAfterPreStartFailure('read-only reference profile refresh failed'); @@ -11326,6 +11535,12 @@ export class CodexAgent extends BaseAgent { if (sendOpts?.throwOnStartFailure) throw new Error(message); return; } + const { + route: turnRoute, + turnWorkspaceConfig, + threadWorkspaceConfig: turnThreadWorkspaceConfig, + threadProfileFingerprint: turnThreadProfileFingerprint, + } = turnRouteSnapshot; const { approvalPolicy, approvalsReviewer } = turnWorkspaceConfig; // 记录本 turn 是否由无人值守策略发射。普通降级路由显式使用 user reviewer, // 与 Ask 权限等价;policy turn 则以 untrusted + read-only 发射,并由 host @@ -11372,25 +11587,29 @@ export class CodexAgent extends BaseAgent { } } // sticky 语义要求进过 plan 的线程后续持续复位, 见 collaborationModeForTurn。 - const collaborationMode = collaborationModeForTurn(requestedPlanTurn, continuePlanCycleThisTurn); + const collaborationMode = collaborationModeForTurn( + requestedPlanTurn, + continuePlanCycleThisTurn, + turnRoute, + ); const turnStartsInPlanMode = collaborationMode?.mode === 'plan'; const turnParams: TurnStartParams = { threadId, input: turnInput, ...turnWorkspaceConfig, - effort: clampEffortForCodex(mutableModel, mutableEffort), + effort: clampEffortForCodex(turnRoute.model, turnRoute.effort), // 强制 reasoning summary='auto' — 不依赖用户 ~/.codex/config.toml 写没写 // model_reasoning_summary, 让 thinking 文本在所有用户机器上一致流式出。 // (v2.rs:5801-5803 turn/start 的 summary 会 override server config) summary: 'auto', - ...(mutableModel && mutableModel !== 'gpt-5' ? { model: mutableModel } : {}), - ...(mutableServiceTier !== undefined ? { serviceTier: mutableServiceTier } : {}), + ...(turnRoute.model && turnRoute.model !== 'gpt-5' ? { model: turnRoute.model } : {}), + ...(turnRoute.serviceTier !== undefined ? { serviceTier: turnRoute.serviceTier } : {}), ...(collaborationMode ? { collaborationMode } : {}), }; // 这一 turn 的用量按这里发出去的 (provider, model) 归属上下文窗口 —— 之后 setModel // 立即改这两个值也不会串到还在产出的本 turn (见 activeTurnModel / capContextWindow)。 - activeTurnModel = mutableCatalogModel; - activeTurnProviderId = mutableProviderId; + activeTurnModel = turnRoute.catalogModel; + activeTurnProviderId = turnRoute.providerId; const markTurnConfigAccepted = (): void => { threadMayHaveRollout = true; if (turnParams.collaborationMode?.mode === 'plan') { @@ -11778,7 +11997,6 @@ export class CodexAgent extends BaseAgent { // 无需在这里管理新返回的 subscription 句柄。 assertCurrentHost('thread/resume retry subscribe'); host.subscribeThread(threadId, handlers); - const resumeModel = mutableModel; const resumeServiceTierGeneration = serviceTierMutationGeneration; const resumeParams: ThreadResumeParams = { threadId, @@ -11786,48 +12004,24 @@ export class CodexAgent extends BaseAgent { cwd: opts.workingDir, ...turnThreadWorkspaceConfig, ...(threadModelProvider ? { modelProvider: threadModelProvider } : {}), - ...(resumeModel && resumeModel !== 'gpt-5' ? { model: resumeModel } : {}), - ...(mutableServiceTier !== undefined ? { serviceTier: mutableServiceTier } : {}), + ...(turnRoute.model && turnRoute.model !== 'gpt-5' ? { model: turnRoute.model } : {}), + ...(turnRoute.serviceTier !== undefined ? { serviceTier: turnRoute.serviceTier } : {}), ...(developerInstructions && !useProxyChannel ? { developerInstructions } : {}), }; const resumeResp = await host.request(Method.ThreadResume, resumeParams, { timeoutMs: CRITICAL_THREAD_RPC_TIMEOUT_MS, }); - if (mutableModel === resumeModel && resumeModel === 'gpt-5' && resumeResp.model) { - mutableModel = resumeResp.model; - // 与 thread/start 的哨兵解析同理:'gpt-5' 是占位、不是目录条目, 解析出的真实 - // id 才能用来查窗口上限。漏掉这行会让恢复后的 turn 一直按 'gpt-5' 去查(查不到 - // → 不收敛), 这也是本文件第三处哨兵解析, 三处必须一致。 - mutableCatalogModel = resumeResp.model; - } + markThreadModelContextWindowApplied(turnThreadWorkspaceConfig); if ( Object.hasOwn(resumeResp, 'serviceTier') && resumeServiceTierGeneration === serviceTierMutationGeneration ) { - mutableServiceTier = normalizeServiceTier(resumeResp.serviceTier) ?? null; + const acceptedServiceTier = normalizeServiceTier(resumeResp.serviceTier) ?? null; + mutableServiceTier = acceptedServiceTier; + turnParams.serviceTier = acceptedServiceTier; } else if (resumeServiceTierGeneration !== serviceTierMutationGeneration) { void pushThreadSettings({ serviceTier: mutableServiceTier ?? null }); } - turnParams.effort = clampEffortForCodex(mutableModel, mutableEffort); - if (mutableModel && mutableModel !== 'gpt-5') { - turnParams.model = mutableModel; - } else { - delete turnParams.model; - } - // 恢复路径可能把 'gpt-5' 哨兵解析成具体路由模型 —— 重投的 turn 用的是新值, - // 窗口归属必须跟着改写走, 否则查不到目录条目、沿用 app-server 的基础模型窗口。 - activeTurnModel = mutableCatalogModel; - activeTurnProviderId = mutableProviderId; - if (mutableServiceTier !== undefined) { - turnParams.serviceTier = mutableServiceTier ?? null; - } else { - delete turnParams.serviceTier; - } - if (turnParams.collaborationMode) { - turnParams.collaborationMode.settings.model = mutableModel; - turnParams.collaborationMode.settings.reasoning_effort = - clampEffortForCodex(mutableModel, mutableEffort); - } readonlyReferencesProfileActive = 'permissions' in turnThreadWorkspaceConfig; readonlyReferencesProfileFingerprint = readonlyReferencesProfileActive ? turnThreadProfileFingerprint @@ -12290,16 +12484,33 @@ export class CodexAgent extends BaseAgent { // ── Phase 3: 运行时切换 (下一 turn 才生效, 内部已是 mutable 闭包) ── async setModel(newModel: string, setOpts?: { providerId?: string | null }) { if (reviewMode) return; + const deferReapplyForCurrentTurn = isTurnInFlight || isTurnStartPending; + return enqueueModelChange(async () => { // provider 可能在 model 不变时单独切换(同一 id 换路由), 所以先记 provider 再做 model 去重。 // 窗口上限按 (provider, model) 解析, 漏掉这一步会让后续 turn 拿新模型去问旧路由。 const prevProviderId = mutableProviderId; if (setOpts && Object.hasOwn(setOpts, 'providerId')) mutableProviderId = setOpts.providerId; if (newModel === mutableModel) { if (mutableProviderId !== prevProviderId) { - autoReviewDecisionCache.clear(); - autoReviewUnavailableNotice.reset(); - autoReviewConfirmUndeliveredNotice.reset(); - refreshCodexAutoReviewerRoute(threadId); + try { + autoReviewDecisionCache.clear(); + autoReviewUnavailableNotice.reset(); + autoReviewConfirmUndeliveredNotice.reset(); + refreshCodexAutoReviewerRoute(threadId); + const targetWindow = currentThreadWorkspaceConfig().config?.model_context_window; + if (!hasAppliedThreadModelContextWindow || targetWindow !== appliedThreadModelContextWindow) { + if (deferReapplyForCurrentTurn || isTurnInFlight || isTurnStartPending) { + pendingThreadModelContextWindowReapply = true; + } else { + await reapplyThreadModelContextWindow(); + } + } + } catch (error) { + mutableProviderId = prevProviderId; + pendingThreadModelContextWindowReapply = true; + refreshCodexAutoReviewerRoute(threadId); + throw error; + } } return; } @@ -12327,10 +12538,18 @@ export class CodexAgent extends BaseAgent { mutableCatalogModel = newModel; try { refreshCodexAutoReviewerRoute(threadId); - // thread 已启动 → 立即经 thread/settings/update 推给 server (sticky); 未启动则由 - // 首个 thread/start 携带。沿用 turn/start 的 'gpt-5'=server 默认哨兵约定 (省略), - // 避免把占位 model id 发给 server。失败时 turn/start 透传仍是兜底。 - if (newModel && newModel !== 'gpt-5') await pushThreadSettings({ model: newModel }); + const targetWindow = currentThreadWorkspaceConfig().config?.model_context_window; + const needsReapply = !hasAppliedThreadModelContextWindow + || targetWindow !== appliedThreadModelContextWindow; + if (needsReapply) { + if (deferReapplyForCurrentTurn || isTurnInFlight || isTurnStartPending) { + pendingThreadModelContextWindowReapply = true; + } else { + await reapplyThreadModelContextWindow(); + } + } else if (newModel && newModel !== 'gpt-5') { + await pushThreadSettings({ model: newModel }); + } } catch (e) { // 抛回调用方前把三个快照恢复原值。host 的 applyRuntimeSetModelChange 在异常分支 // 会把 session 的 provider route 恢复成旧值; 我们这边若留着新值, 下一 turn 就会 @@ -12344,8 +12563,11 @@ export class CodexAgent extends BaseAgent { mutableModel = prevModel; mutableCatalogModel = prevCatalogModel; modelSwitchRecord = prevSwitchRecord; + pendingThreadModelContextWindowReapply = true; + refreshCodexAutoReviewerRoute(threadId); throw e; } + }); }, async setEffort(newEffort: Effort) {