diff --git a/src/cli/run.ts b/src/cli/run.ts index 62b2c9dc6a9..5bd33960a49 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -651,6 +651,8 @@ async function main(): Promise { workspaceService, workspaceGoalService, idleDispatcher, + streamManager, + turnRequestBuilderBindings, } = createCoreServices({ config, policyService, @@ -675,7 +677,7 @@ async function main(): Promise { // Codex OAuth explicitly to ensure Codex-routed OpenAI requests can load/refresh // OAuth tokens from providers.jsonc. const codexOauthService = new CodexOauthService(config, providerService); - aiService.setCodexOauthService(codexOauthService); + turnRequestBuilderBindings.codexOauthService = codexOauthService; // Same for Coder OAuth: coder:* models need per-request token loading/refresh. // Bind it to the REAL config (not the ephemeral tempDir copy): Coder rotates // the refresh token on every use, so persisting rotations only to tempDir @@ -690,7 +692,7 @@ async function main(): Promise { // token refreshes/issuer checks, and denied providers fail closed. policyService ); - aiService.setCoderOauthService(coderOauthService); + turnRequestBuilderBindings.coderOauthService = coderOauthService; // CLI-only exit code control: allows agent to set the process exit code // Useful for CI workflows where the agent should block merge on failure @@ -715,13 +717,14 @@ async function main(): Promise { return { success: true, exit_code }; }, }); - aiService.setExtraTools({ set_exit_code: setExitCodeTool }); + turnRequestBuilderBindings.extraTools = { set_exit_code: setExitCodeTool }; const session = new AgentSession({ workspaceId, config, historyService, aiService, + streamManager, initStateManager, backgroundProcessManager, workspaceGoalService, diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index 107996889a8..d95342fdb38 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -368,7 +368,7 @@ async function createWorkflowContext(options: { mcpConfig: realConfig, }); codexOauthService = new CodexOauthService(config, services.providerService); - services.aiService.setCodexOauthService(codexOauthService); + services.turnRequestBuilderBindings.codexOauthService = codexOauthService; // Bind Coder OAuth to the REAL config (not the ephemeral tempDir copy): // Coder rotates the refresh token on every use, so persisting rotations // only to tempDir would strand ~/.xum/providers.jsonc with a consumed @@ -382,7 +382,7 @@ async function createWorkflowContext(options: { // for token refreshes/issuer checks, and denied providers fail closed. policyService ); - services.aiService.setCoderOauthService(coderOauthService); + services.turnRequestBuilderBindings.coderOauthService = coderOauthService; // Const capture: `services` is a `let`, so the deferred sanitize closure // below would lose TypeScript's definite-assignment narrowing. @@ -392,6 +392,7 @@ async function createWorkflowContext(options: { config, historyService: services.historyService, aiService: services.aiService, + streamManager: services.streamManager, initStateManager: services.initStateManager, backgroundProcessManager: services.backgroundProcessManager, workspaceGoalService: services.workspaceGoalService, diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index f6a5df41eee..2f5a1271db9 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -3,6 +3,8 @@ import type { IncomingHttpHeaders } from "http"; import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; import type { HistoryService } from "@/node/services/historyService"; +import type { InitStateManager } from "@/node/services/initStateManager"; +import type { StreamManager } from "@/node/services/streamManager"; import type { ProjectService } from "@/node/services/projectService"; import type { WorkspaceService } from "@/node/services/workspaceService"; import type { MuxGatewayOauthService } from "@/node/services/muxGatewayOauthService"; @@ -56,6 +58,8 @@ export interface ORPCContext { config: Config; aiService: AIService; historyService: HistoryService; + streamManager: StreamManager; + initStateManager: InitStateManager; projectService: ProjectService; workspaceService: WorkspaceService; taskService: TaskService; diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index 553fe5d5626..d7fc3b410ff 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -55,8 +55,8 @@ describe("router agent skill routes", () => { const context = { config: new Config(tempDir), + initStateManager: { waitForInit: mock(async () => undefined) }, aiService: { - waitForInit: mock(async () => undefined), resolveXumToolScopeForWorkspace: mock(() => ({ type: "project", xumHome: tempDir, @@ -123,8 +123,8 @@ describe("router agent skill routes", () => { const context = { config: new Config(tempDir), + initStateManager: { waitForInit: mock(async () => undefined) }, aiService: { - waitForInit: mock(async () => undefined), resolveXumToolScopeForWorkspace: mock(() => ({ type: "project", xumHome: tempDir, diff --git a/src/node/services/agentDefinitions/agentDefinitionsService.ts b/src/node/services/agentDefinitions/agentDefinitionsService.ts index 4f003ef7ee6..00302f94310 100644 --- a/src/node/services/agentDefinitions/agentDefinitionsService.ts +++ b/src/node/services/agentDefinitions/agentDefinitionsService.ts @@ -942,7 +942,7 @@ export async function resolveAgentFrontmatter( export type AgentDefinitionsContext = Pick< ORPCContext, - "config" | "aiService" | "experimentsService" + "config" | "aiService" | "experimentsService" | "initStateManager" >; export async function resolveAgentDiscoveryContext( @@ -980,7 +980,7 @@ export async function listAgentDefinitions( includeDisabled?: boolean; } ) { - if (input.workspaceId) await context.aiService.waitForInit(input.workspaceId); + if (input.workspaceId) await context.initStateManager.waitForInit(input.workspaceId); const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input); const includeAgentPlugins = context.experimentsService.isExperimentEnabled( EXPERIMENT_IDS.AGENT_PLUGINS @@ -1041,7 +1041,7 @@ export async function getAgentDefinition( agentId: AgentId; } ) { - if (input.workspaceId) await context.aiService.waitForInit(input.workspaceId); + if (input.workspaceId) await context.initStateManager.waitForInit(input.workspaceId); const { runtime, discoveryPath } = await resolveAgentDiscoveryContext(context, input); return readAgentDefinition(runtime, discoveryPath, input.agentId, { includeAgentPlugins: context.experimentsService.isExperimentEnabled( diff --git a/src/node/services/agentPlugins/workspacePluginOperations.ts b/src/node/services/agentPlugins/workspacePluginOperations.ts index b8e41e82a7a..8583eca56f4 100644 --- a/src/node/services/agentPlugins/workspacePluginOperations.ts +++ b/src/node/services/agentPlugins/workspacePluginOperations.ts @@ -70,7 +70,7 @@ export async function listWorkspaceMcpPrompts( workspaceId: string, signal?: AbortSignal ) { - await context.aiService.waitForInit(workspaceId, signal); + await context.initStateManager.waitForInit(workspaceId, signal); const metadataResult = await context.aiService.getWorkspaceMetadata(workspaceId); if (!metadataResult.success) throw new Error(metadataResult.error); const metadata = metadataResult.data; @@ -152,7 +152,7 @@ export async function listWorkspacePluginSlashCommands( signal?: AbortSignal ) { if (!context.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.AGENT_PLUGINS)) return []; - await context.aiService.waitForInit(workspaceId, signal); + await context.initStateManager.waitForInit(workspaceId, signal); const metadataResult = await context.aiService.getWorkspaceMetadata(workspaceId); if (!metadataResult.success) throw new Error(metadataResult.error); const metadata = metadataResult.data; @@ -173,7 +173,7 @@ export async function getWorkspacePluginComposition( workspaceId: string, signal?: AbortSignal ) { - await context.aiService.waitForInit(workspaceId, signal); + await context.initStateManager.waitForInit(workspaceId, signal); const metadataResult = await context.aiService.getWorkspaceMetadata(workspaceId); if (!metadataResult.success) throw new Error(metadataResult.error); const metadata = metadataResult.data; diff --git a/src/node/services/agentSession.admissionGates.test.ts b/src/node/services/agentSession.admissionGates.test.ts index 37994c2ec59..91c7b7962b4 100644 --- a/src/node/services/agentSession.admissionGates.test.ts +++ b/src/node/services/agentSession.admissionGates.test.ts @@ -8,6 +8,7 @@ import type { SendMessageError } from "@/common/types/errors"; import { createMuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; import { AgentSession, CONTEXT_MUTATION_SEND_BLOCKED_MESSAGE } from "./agentSession"; +import { createStreamLifecycleMocks } from "./agentSession.testHarness"; import { createTestHistoryService } from "./testHistoryService"; const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; @@ -32,6 +33,7 @@ describe("AgentSession.sendMessage (admission gates)", () => { const streamMessage = mock(() => Promise.resolve(Ok(undefined))); const aiService = Object.assign(new EventEmitter(), { + ...createStreamLifecycleMocks(), isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), streamMessage: streamMessage as unknown as AIService["streamMessage"], diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index cf7546048a4..6e1e3679c6a 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -19,7 +19,11 @@ import type { BackgroundProcessManager } from "@/node/services/backgroundProcess import type { InitStateManager } from "@/node/services/initStateManager"; import { AgentSession } from "./agentSession"; import type { CompactionMonitor } from "./compactionMonitor"; -import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; +import { + createAgentSessionHarness, + createStartedTurnHandle, + createStreamLifecycleMocks, +} from "./agentSession.testHarness"; import { createTestHistoryService } from "./testHistoryService"; describe("AgentSession on-send auto-compaction snapshot deferral", () => { @@ -824,6 +828,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { }); const aiService = Object.assign(aiEmitter, { + ...createStreamLifecycleMocks(), isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), streamMessage: streamMessage as unknown as ( @@ -944,6 +949,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { Promise.resolve(Ok(createStartedTurnHandle())) ); const aiService = Object.assign(aiEmitter, { + ...createStreamLifecycleMocks(), isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), streamMessage: streamMessage as unknown as ( @@ -1053,6 +1059,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { }); const aiService = Object.assign(aiEmitter, { + ...createStreamLifecycleMocks(), isStreaming: mock((_workspaceId: string) => false), stopStream, streamMessage: streamMessage as unknown as ( @@ -1201,6 +1208,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { }); const aiService = Object.assign(aiEmitter, { + ...createStreamLifecycleMocks(), isStreaming: mock((_workspaceId: string) => false), stopStream, streamMessage: streamMessage as unknown as ( diff --git a/src/node/services/agentSession.continueMessageAgentId.test.ts b/src/node/services/agentSession.continueMessageAgentId.test.ts index 214166e3a00..69decddff07 100644 --- a/src/node/services/agentSession.continueMessageAgentId.test.ts +++ b/src/node/services/agentSession.continueMessageAgentId.test.ts @@ -4,6 +4,7 @@ import type { CompactionFollowUpRequest, MuxMessage } from "@/common/types/messa import type { FilePart, SendMessageOptions } from "@/common/orpc/types"; import type { Config } from "@/node/config"; import { AgentSession } from "./agentSession"; +import { createStreamLifecycleMocks } from "./agentSession.testHarness"; import type { AIService } from "./aiService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { InitStateManager } from "./initStateManager"; @@ -104,6 +105,7 @@ function createAiService(): AIService { off() { return this; }, + ...createStreamLifecycleMocks(), isStreaming: () => false, stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })), } as unknown as AIService; diff --git a/src/node/services/agentSession.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index 4aa91d58926..7fcace48927 100644 --- a/src/node/services/agentSession.disposeRace.test.ts +++ b/src/node/services/agentSession.disposeRace.test.ts @@ -17,7 +17,7 @@ import { startAbandonedBranchSummaryInBackground, type BranchSummaryAiService, } from "./branchSummary"; -import { createAgentSessionHarness } from "./agentSession.testHarness"; +import { createAgentSessionHarness, createStreamLifecycleMocks } from "./agentSession.testHarness"; import type { StreamMessageOptions } from "./aiService"; import type { TurnCompletion } from "./streamManager"; @@ -39,6 +39,7 @@ describe("AgentSession disposal race conditions", () => { const streamMessage = mock(() => Promise.resolve(Ok(undefined))); const aiService: AIService = { + ...createStreamLifecycleMocks(), on(eventName: string | symbol, listener: (...args: unknown[]) => void) { aiHandlers.set(String(eventName), listener); return this; @@ -136,6 +137,7 @@ describe("AgentSession disposal race conditions", () => { test("bails out of a send parked on the branch-summary await when removal disposes the session", async () => { const streamMessage = mock(() => Promise.resolve(Ok(undefined))); const aiService: AIService = { + ...createStreamLifecycleMocks(), on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { return this; }, @@ -254,6 +256,7 @@ describe("AgentSession disposal race conditions", () => { const aiHandlers = new Map void>(); const aiService: AIService = { + ...createStreamLifecycleMocks(), on(eventName: string | symbol, listener: (...args: unknown[]) => void) { aiHandlers.set(String(eventName), listener); return this; @@ -340,6 +343,7 @@ describe("AgentSession disposal race conditions", () => { const aiHandlers = new Map void>(); const aiService: AIService = { + ...createStreamLifecycleMocks(), on(eventName: string | symbol, listener: (...args: unknown[]) => void) { aiHandlers.set(String(eventName), listener); return this; @@ -434,6 +438,7 @@ describe("AgentSession disposal race conditions", () => { test("does not reset auto-retry intent for synthetic or rejected sends", async () => { const aiService: AIService = { + ...createStreamLifecycleMocks(), on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { return this; }, @@ -578,6 +583,7 @@ describe("AgentSession disposal race conditions", () => { test("preserves synthetic flag when flushing queued messages", () => { const aiService: AIService = { + ...createStreamLifecycleMocks(), on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { return this; }, diff --git a/src/node/services/agentSession.editMessageId.test.ts b/src/node/services/agentSession.editMessageId.test.ts index 09644eb2813..2872f286b07 100644 --- a/src/node/services/agentSession.editMessageId.test.ts +++ b/src/node/services/agentSession.editMessageId.test.ts @@ -8,7 +8,7 @@ import { createMuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; import { AgentSession } from "./agentSession"; import { createTestHistoryService } from "./testHistoryService"; -import { createStartedTurnHandle } from "./agentSession.testHarness"; +import { createStartedTurnHandle, createStreamLifecycleMocks } from "./agentSession.testHarness"; type StreamMessageHandler = AIService["streamMessage"]; @@ -42,6 +42,7 @@ describe("AgentSession.sendMessage (editMessageId)", () => { const streamMessage = mock(streamHandler); const aiService = Object.assign(new EventEmitter(), { + ...createStreamLifecycleMocks(), isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), streamMessage: streamMessage as unknown as AIService["streamMessage"], diff --git a/src/node/services/agentSession.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index eee36a733dd..f18428ce287 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -12,7 +12,7 @@ import type { AIService, StreamMessageOptions } from "./aiService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { InitStateManager } from "./initStateManager"; import { createTestHistoryService } from "./testHistoryService"; -import { createStartedTurnHandle } from "./agentSession.testHarness"; +import { createStartedTurnHandle, createStreamLifecycleMocks } from "./agentSession.testHarness"; /** * Log purity: externally-edited files must produce a durable @@ -51,6 +51,7 @@ describe("AgentSession file-change notification (turn start)", () => { return Promise.resolve(Ok(createStartedTurnHandle())); }); const aiService: AIService = { + ...createStreamLifecycleMocks(), on: mock(() => aiService), off: mock(() => aiService), stopStream: mock(() => Promise.resolve(Ok(undefined))), diff --git a/src/node/services/agentSession.memoryContext.test.ts b/src/node/services/agentSession.memoryContext.test.ts index 3e33d935732..16a145773b5 100644 --- a/src/node/services/agentSession.memoryContext.test.ts +++ b/src/node/services/agentSession.memoryContext.test.ts @@ -8,6 +8,7 @@ import type { Config } from "@/node/config"; import type { AIService } from "./aiService"; import type { MemorySessionContext } from "./memoryService"; import { AgentSession } from "./agentSession"; +import { createStreamLifecycleMocks } from "./agentSession.testHarness"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { HistoryService } from "./historyService"; import type { InitStateManager } from "./initStateManager"; @@ -28,6 +29,7 @@ function createSession(args: { }): AgentSession { const aiEmitter = new EventEmitter(); const aiService: AIService = { + ...createStreamLifecycleMocks(), on(eventName: string | symbol, listener: (...args: unknown[]) => void) { aiEmitter.on(String(eventName), listener); return this; diff --git a/src/node/services/agentSession.postCompactionAttachments.test.ts b/src/node/services/agentSession.postCompactionAttachments.test.ts index 45db40bed22..e153f66b6f1 100644 --- a/src/node/services/agentSession.postCompactionAttachments.test.ts +++ b/src/node/services/agentSession.postCompactionAttachments.test.ts @@ -14,6 +14,7 @@ import type { Config } from "@/node/config"; import type { AIService } from "./aiService"; import { AgentSession } from "./agentSession"; +import { createStreamLifecycleMocks } from "./agentSession.testHarness"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { HistoryService } from "./historyService"; import type { InitStateManager } from "./initStateManager"; @@ -104,6 +105,7 @@ function getAttachmentTypes( function createSessionForHistory(historyService: HistoryService, sessionDir: string): AgentSession { const aiEmitter = new EventEmitter(); const aiService: AIService = { + ...createStreamLifecycleMocks(), on(eventName: string | symbol, listener: (...args: unknown[]) => void) { aiEmitter.on(String(eventName), listener); return this; diff --git a/src/node/services/agentSession.postCompactionRefresh.test.ts b/src/node/services/agentSession.postCompactionRefresh.test.ts index ad7a0e71809..fa0781717ea 100644 --- a/src/node/services/agentSession.postCompactionRefresh.test.ts +++ b/src/node/services/agentSession.postCompactionRefresh.test.ts @@ -8,7 +8,7 @@ import { createTestHistoryService } from "./testHistoryService"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import { createMuxMessage } from "@/common/types/message"; import type { StreamEndEvent } from "@/common/types/stream"; -import { createAgentSessionHarness } from "./agentSession.testHarness"; +import { createAgentSessionHarness, createStreamLifecycleMocks } from "./agentSession.testHarness"; // NOTE: These tests focus on the event wiring (tool-call-end -> callback). // The actual post-compaction state computation is covered elsewhere. @@ -173,6 +173,7 @@ describe("AgentSession post-compaction refresh trigger", () => { const handlers = new Map void>(); const aiService: AIService = { + ...createStreamLifecycleMocks(), on(eventName: string | symbol, listener: (...args: unknown[]) => void) { handlers.set(String(eventName), listener); return this; diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index f861e345d33..4a63dc701a9 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -13,7 +13,11 @@ import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { MuxMessage } from "@/common/types/message"; import type { SendMessageOptions } from "@/common/orpc/types"; import { createTestHistoryService } from "./testHistoryService"; -import { createFailedTurnHandle, createStartedTurnHandle } from "./agentSession.testHarness"; +import { + createFailedTurnHandle, + createStartedTurnHandle, + createStreamLifecycleMocks, +} from "./agentSession.testHarness"; function contextExceededResult(messageId: string) { return { @@ -113,6 +117,7 @@ describe("AgentSession post-compaction context retry", () => { }); const aiService: AIService = { + ...createStreamLifecycleMocks(), on(eventName: string | symbol, listener: (...args: unknown[]) => void) { aiEmitter.on(String(eventName), listener); return this; @@ -264,6 +269,7 @@ describe("AgentSession post-compaction context retry", () => { }); const aiService: AIService = { + ...createStreamLifecycleMocks(), on(eventName: string | symbol, listener: (...args: unknown[]) => void) { aiEmitter.on(String(eventName), listener); return this; @@ -406,6 +412,7 @@ describe("AgentSession post-compaction context retry", () => { }); const aiService: AIService = { + ...createStreamLifecycleMocks(), on(eventName: string | symbol, listener: (...args: unknown[]) => void) { aiEmitter.on(String(eventName), listener); return this; diff --git a/src/node/services/agentSession.preStreamError.test.ts b/src/node/services/agentSession.preStreamError.test.ts index 621cfe7b1c5..7b0428ce614 100644 --- a/src/node/services/agentSession.preStreamError.test.ts +++ b/src/node/services/agentSession.preStreamError.test.ts @@ -40,7 +40,7 @@ async function createReplaySessionHarness( streamMessage: mock((_history: MuxMessage[]) => Promise.resolve(Err({ type: "unknown", raw: "unused" })) ) as unknown as AIService["streamMessage"], - getStreamInfo: mock((_workspaceId: string) => streamInfo) as AIService["getStreamInfo"], + getStreamInfo: mock((_workspaceId: string) => streamInfo), replayStream, }, initStateManagerOverrides: { replayInit }, @@ -226,7 +226,7 @@ describe("AgentSession pre-stream errors", () => { streamMessage: mock((_history: MuxMessage[]) => Promise.resolve(Err({ type: "api_key_not_found", provider: "anthropic" })) ) as unknown as AIService["streamMessage"], - getStreamInfo: mock((_workspaceId: string) => undefined) as AIService["getStreamInfo"], + getStreamInfo: mock((_workspaceId: string) => undefined), replayStream: mock((_workspaceId: string, _opts?: { afterTimestamp?: number }) => Promise.resolve() ), @@ -387,6 +387,8 @@ describe("AgentSession pre-stream errors", () => { const aiService = Object.assign(aiEmitter, { isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + getStreamInfo: mock((_workspaceId: string) => undefined), + replayStream: mock((_workspaceId: string) => Promise.resolve()), streamMessage: streamMessage as unknown as ( ...args: Parameters ) => Promise>, diff --git a/src/node/services/agentSession.preTurnMessages.test.ts b/src/node/services/agentSession.preTurnMessages.test.ts index b57038de012..75f3dbc4936 100644 --- a/src/node/services/agentSession.preTurnMessages.test.ts +++ b/src/node/services/agentSession.preTurnMessages.test.ts @@ -8,7 +8,7 @@ import { createMuxMessage } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import { AgentSession } from "./agentSession"; import { createTestHistoryService } from "./testHistoryService"; -import { createStartedTurnHandle } from "./agentSession.testHarness"; +import { createStartedTurnHandle, createStreamLifecycleMocks } from "./agentSession.testHarness"; const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; const config = { @@ -29,6 +29,7 @@ describe("AgentSession.sendMessage (preTurnMessages)", () => { const streamMessage = mock(() => Promise.resolve(Ok(createStartedTurnHandle()))); const aiService = Object.assign(new EventEmitter(), { + ...createStreamLifecycleMocks(), isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), streamMessage: streamMessage as unknown as AIService["streamMessage"], diff --git a/src/node/services/agentSession.replaySelfHealing.test.ts b/src/node/services/agentSession.replaySelfHealing.test.ts index bd67a829d79..55ba9c3263c 100644 --- a/src/node/services/agentSession.replaySelfHealing.test.ts +++ b/src/node/services/agentSession.replaySelfHealing.test.ts @@ -6,7 +6,6 @@ * bricked workspace fetch in server mode. */ import { describe, expect, it, mock, afterEach } from "bun:test"; -import type { AIService } from "@/node/services/aiService"; import type { MuxMessage } from "@/common/types/message"; import { WorkspaceChatMessageSchema } from "@/common/orpc/schemas"; import { isMuxMessage, type WorkspaceChatMessage } from "@/common/orpc/types"; @@ -16,7 +15,7 @@ async function createReplayHarness(workspaceId: string) { return await createAgentSessionHarness({ workspaceId, aiServiceOverrides: { - getStreamInfo: mock((_workspaceId: string) => undefined) as AIService["getStreamInfo"], + getStreamInfo: mock((_workspaceId: string) => undefined), replayStream: mock((_workspaceId: string, _opts?: { afterTimestamp?: number }) => Promise.resolve() ), diff --git a/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts b/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts index 84099bc93f0..4865eba44a7 100644 --- a/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts +++ b/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, mock, afterEach } from "bun:test"; import { AgentSession } from "./agentSession"; +import { createStreamLifecycleMocks } from "./agentSession.testHarness"; import type { Config } from "@/node/config"; import type { AIService } from "./aiService"; import type { InitStateManager } from "./initStateManager"; @@ -18,6 +19,7 @@ describe("AgentSession.resumeStream", () => { const streamMessage = mock(() => Promise.resolve(Ok(undefined))); const aiService: AIService = { + ...createStreamLifecycleMocks(), on: mock(() => aiService), off: mock(() => aiService), stopStream: mock(() => Promise.resolve(Ok(undefined))), diff --git a/src/node/services/agentSession.sinceReplayContract.test.ts b/src/node/services/agentSession.sinceReplayContract.test.ts index eec3e9392ac..0aca2e31499 100644 --- a/src/node/services/agentSession.sinceReplayContract.test.ts +++ b/src/node/services/agentSession.sinceReplayContract.test.ts @@ -13,7 +13,6 @@ * recomputation (silent since→full downgrade after ≥2 live-streamed turns). */ import { describe, expect, it, mock, afterEach } from "bun:test"; -import type { AIService } from "@/node/services/aiService"; import type { MuxMessage } from "@/common/types/message"; import { isMuxMessage, @@ -36,7 +35,7 @@ async function createContractHarness(workspaceId: string) { return await createAgentSessionHarness({ workspaceId, aiServiceOverrides: { - getStreamInfo: mock((_workspaceId: string) => undefined) as AIService["getStreamInfo"], + getStreamInfo: mock((_workspaceId: string) => undefined), replayStream, }, initStateManagerOverrides: { replayInit }, diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index a72fb1a03d8..424892c4b6a 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1,9 +1,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "events"; -import { AgentSession, clearProviderConfigFixableAbandonMarkers } from "./agentSession"; +import { + AgentSession, + clearProviderConfigFixableAbandonMarkers, + type AgentSessionAIService, +} from "./agentSession"; import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; import { createTestHistoryService } from "./testHistoryService"; -import type { AIService } from "./aiService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { HistoryService } from "./historyService"; import type { Config } from "@/node/config"; @@ -40,7 +43,7 @@ interface SessionBundle { session: AgentSession; config: Config; historyService: HistoryService; - aiService: AIService; + aiService: AgentSessionAIService; initStateManager: InitStateManager; backgroundProcessManager: BackgroundProcessManager; events: WorkspaceChatMessage[]; @@ -270,10 +273,12 @@ describe("AgentSession startup auto-retry recovery", () => { }) ); expect(appendResult.success).toBe(true); - const streamMessageMock = mock((_payload: Parameters[0]) => - Promise.resolve(Ok(createStartedTurnHandle())) + const streamMessageMock = mock( + (_payload: Parameters[0]) => + Promise.resolve(Ok(createStartedTurnHandle())) ); - aiService.streamMessage = streamMessageMock as unknown as AIService["streamMessage"]; + aiService.streamMessage = + streamMessageMock as unknown as AgentSessionAIService["streamMessage"]; const privateSession = session as unknown as { retryActiveStream: () => Promise; startupAutoRetryCheckPromise: Promise | null; @@ -314,10 +319,12 @@ describe("AgentSession startup auto-retry recovery", () => { }) ); expect(appendResult.success).toBe(true); - const streamMessageMock = mock((_payload: Parameters[0]) => - Promise.resolve(Ok(createStartedTurnHandle())) + const streamMessageMock = mock( + (_payload: Parameters[0]) => + Promise.resolve(Ok(createStartedTurnHandle())) ); - aiService.streamMessage = streamMessageMock as unknown as AIService["streamMessage"]; + aiService.streamMessage = + streamMessageMock as unknown as AgentSessionAIService["streamMessage"]; const privateSession = session as unknown as { retryActiveStream: () => Promise; startupAutoRetryCheckPromise: Promise | null; @@ -1376,7 +1383,8 @@ describe("AgentSession startup auto-retry recovery", () => { return Promise.resolve(Ok(createStartedTurnHandle())); }); - aiService.streamMessage = streamMessageMock as unknown as AIService["streamMessage"]; + aiService.streamMessage = + streamMessageMock as unknown as AgentSessionAIService["streamMessage"]; const privateSession = session as unknown as { retryActiveStream: () => Promise; @@ -1551,9 +1559,11 @@ describe("AgentSession startup auto-retry recovery", () => { const aiService = Object.assign(aiEmitter, { stopStream: mock(() => Promise.resolve(Ok(undefined))), isStreaming: mock(() => false), + getStreamInfo: mock(() => undefined), + replayStream: mock(() => Promise.resolve()), streamMessage: mock(() => Promise.resolve(Ok(createStartedTurnHandle()))), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(workspaceMetadata))), - }) as unknown as AIService; + }) as unknown as AgentSessionAIService; const initStateManager: InitStateManager = { on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { diff --git a/src/node/services/agentSession.testHarness.ts b/src/node/services/agentSession.testHarness.ts index 6bdff8ef9a7..5d53b30cf67 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -2,11 +2,10 @@ import { mock } from "bun:test"; import { EventEmitter } from "events"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import { Ok } from "@/common/types/result"; +import { Err, Ok } from "@/common/types/result"; import type { Config } from "@/node/config"; -import type { AIService } from "@/node/services/aiService"; import type { TurnStreamHandle } from "@/node/services/streamManager"; -import { AgentSession } from "@/node/services/agentSession"; +import { AgentSession, type AgentSessionAIService } from "@/node/services/agentSession"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; @@ -55,32 +54,54 @@ function createMockInitStateManager(overrides?: Partial): Init return Object.assign(new EventEmitter(), overrides) as unknown as InitStateManager; } -function createMockAiService(args?: { emitter?: EventEmitter; overrides?: Partial }): { +/** Stream-lifecycle surface AgentSession's constructor requires from its engine seam. */ +export function createStreamLifecycleMocks() { + return { + isStreaming: mock((_workspaceId: string) => false), + stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + getStreamInfo: mock((_workspaceId: string) => undefined), + replayStream: mock((_workspaceId: string, _options?: { afterTimestamp?: number }) => + Promise.resolve() + ), + }; +} + +function createMockAiService(args?: { + emitter?: EventEmitter; + overrides?: Partial; +}): { aiEmitter: EventEmitter; - aiService: AIService; + aiService: AgentSessionAIService; } { const aiEmitter = args?.emitter ?? new EventEmitter(); - return { - aiEmitter, - aiService: Object.assign(aiEmitter, { - isStreaming: mock((_workspaceId: string) => false), - stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), - getStreamInfo: mock((_workspaceId: string) => null), - streamMessage: mock(() => - Promise.resolve(Ok(createStartedTurnHandle("test-assistant-message"))) - ) as unknown as AIService["streamMessage"], - ...args?.overrides, - }) as unknown as AIService, - }; + const aiService: AgentSessionAIService = Object.assign(aiEmitter, { + // Real implementations report failures as Err results, never rejections. + createModelWithPinnedMetadata: mock(() => + Promise.resolve( + Err({ type: "unknown" as const, raw: "Test AI service cannot create models" }) + ) + ), + getWorkspaceMetadata: mock(() => + Promise.resolve(Err("Test AI service has no workspace metadata")) + ), + getProvidersConfig: mock(() => null), + isExperimentEnabled: mock((_experimentId) => false), + ...createStreamLifecycleMocks(), + streamMessage: mock(() => + Promise.resolve(Ok(createStartedTurnHandle("test-assistant-message"))) + ), + ...args?.overrides, + }); + return { aiEmitter, aiService }; } export interface AgentSessionHarnessOptions { workspaceId: string; config?: Config; historyService?: HistoryService; - aiService?: AIService; + aiService?: AgentSessionAIService; aiEmitter?: EventEmitter; - aiServiceOverrides?: Partial; + aiServiceOverrides?: Partial; initStateManager?: InitStateManager; initStateManagerOverrides?: Partial; backgroundProcessManager?: BackgroundProcessManager; @@ -97,7 +118,7 @@ export interface AgentSessionHarness { historyService: HistoryService; cleanup: () => Promise; aiEmitter: EventEmitter; - aiService: AIService; + aiService: AgentSessionAIService; initStateManager: InitStateManager; backgroundProcessManager: BackgroundProcessManager; events: WorkspaceChatMessage[]; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index df8c15c59f5..0262507f38b 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -8,8 +8,8 @@ import { PlatformPaths } from "@/common/utils/paths"; import { log } from "@/node/services/log"; import { eventSpine } from "@/node/services/events/eventSpine"; import type { Config } from "@/node/config"; -import type { AIService } from "@/node/services/aiService"; import type { TurnStreamHandle } from "@/node/services/streamManager"; +import type { StreamMessageOptions } from "@/node/services/turnRequestBuilder"; import type { HistoryService } from "@/node/services/historyService"; import type { SessionUsageService } from "@/node/services/sessionUsageService"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -183,8 +183,10 @@ import { awaitPendingBranchSummary, isRlmModeEnabled, runInlineAbandonedBranchSummary, + type BranchSummaryAiService, } from "@/node/services/branchSummary"; import type { Runtime } from "@/node/runtime/Runtime"; +import type { XumToolScope } from "@/common/types/toolScope"; import { execBuffered } from "@/node/utils/runtime/helpers"; import { renderAgentSkillSnapshotText } from "@/common/utils/agentSkills/skillSnapshot"; import type { MemorySessionContext } from "@/node/services/memoryService"; @@ -525,11 +527,57 @@ export interface AgentSessionMetadataEvent { metadata: FrontendWorkspaceMetadata | null; } +interface AgentSessionActiveStreamInfo { + messageId: string; + startTime?: number; + parts: Array<{ timestamp?: number; workflowRun?: { timestamp?: number } }>; + toolCompletionTimestamps: Map; +} + +export interface AgentSessionStreamManager { + stopStream( + workspaceId: string, + options?: { soft?: boolean; abandonPartial?: boolean; abortReason?: StreamAbortReason } + ): Promise>; + isStreaming(workspaceId: string): boolean; + getStreamInfo(workspaceId: string): AgentSessionActiveStreamInfo | undefined; + replayStream(workspaceId: string, options?: { afterTimestamp?: number }): Promise; +} + +/** Keeps AgentSession coupled only to the AI operations and events it consumes. */ +export interface AgentSessionAIService extends BranchSummaryAiService { + on(event: string, listener: (...args: unknown[]) => void): void; + off(event: string, listener: (...args: unknown[]) => void): void; + streamMessage(options: StreamMessageOptions): Promise>; + stopStream?( + workspaceId: string, + options?: { soft?: boolean; abandonPartial?: boolean; abortReason?: StreamAbortReason } + ): Promise>; + isStreaming?(workspaceId: string): boolean; + getStreamInfo?(workspaceId: string): AgentSessionActiveStreamInfo | undefined; + replayStream?(workspaceId: string, options?: { afterTimestamp?: number }): Promise; + getProvidersConfig(): ProvidersConfigMap | null; + isExperimentEnabled(experimentId: ExperimentId): boolean; + buildMemorySessionContext?( + workspaceId: string, + modelString: string, + options?: { includeHotMemories?: boolean } + ): Promise; + isClaudeSkillsCompatEnabled?(): boolean; + isAgentPluginsEnabled?(): boolean; + resolveXumToolScopeForWorkspace?( + metadata: WorkspaceMetadata, + runtime: Runtime, + workspacePath: string + ): XumToolScope; +} + interface AgentSessionOptions { workspaceId: string; config: Config; historyService: HistoryService; - aiService: AIService; + aiService: AgentSessionAIService; + streamManager?: AgentSessionStreamManager; mcpServerManager?: MCPServerManager; initStateManager: InitStateManager; telemetryService?: TelemetryService; @@ -596,7 +644,8 @@ export class AgentSession { private readonly workspaceId: string; private readonly config: Config; private readonly historyService: HistoryService; - private readonly aiService: AIService; + private readonly aiService: AgentSessionAIService; + private readonly streamManager: AgentSessionStreamManager; private readonly mcpServerManager?: MCPServerManager; private readonly initStateManager: InitStateManager; private readonly backgroundProcessManager: BackgroundProcessManager; @@ -832,6 +881,7 @@ export class AgentSession { config, historyService, aiService, + streamManager, mcpServerManager, initStateManager, telemetryService, @@ -854,6 +904,15 @@ export class AgentSession { this.config = config; this.historyService = historyService; this.aiService = aiService; + const streamManagerCandidate = streamManager ?? aiService; + assert( + typeof streamManagerCandidate.stopStream === "function" && + typeof streamManagerCandidate.isStreaming === "function" && + typeof streamManagerCandidate.getStreamInfo === "function" && + typeof streamManagerCandidate.replayStream === "function", + "AgentSession requires stream lifecycle access" + ); + this.streamManager = streamManagerCandidate as AgentSessionStreamManager; this.mcpServerManager = mcpServerManager; this.initStateManager = initStateManager; this.backgroundProcessManager = backgroundProcessManager; @@ -919,7 +978,7 @@ export class AgentSession { // flip the process exit code after the run already completed. // Promise.resolve guards test doubles that return non-promises. void Promise.resolve( - this.aiService.stopStream(this.workspaceId, { abandonPartial: true }) + this.streamManager.stopStream(this.workspaceId, { abandonPartial: true }) ).catch((error) => { log.debug(`dispose: stopStream failed: ${getErrorMessage(error)}`); }); @@ -933,7 +992,7 @@ export class AgentSession { } for (const { event, handler } of this.aiListeners) { - this.aiService.off(event, handler as never); + this.aiService.off(event, handler); } this.aiListeners.length = 0; for (const { event, handler } of this.initListeners) { @@ -1490,11 +1549,7 @@ export class AgentSession { } private isAiStreaming(): boolean { - const aiService = this.aiService as Partial>; - if (typeof aiService.isStreaming !== "function") { - return false; - } - return aiService.isStreaming(this.workspaceId); + return this.streamManager.isStreaming(this.workspaceId); } private normalizeStartupModel(model: unknown): string | undefined { @@ -1791,7 +1846,9 @@ export class AgentSession { } private async getWorkspaceMetadataForRetry(): Promise { - const aiService = this.aiService as Partial>; + const aiService = this.aiService as Partial< + Pick + >; if (typeof aiService.getWorkspaceMetadata !== "function") { return undefined; } @@ -2283,14 +2340,14 @@ export class AgentSession { }; const cleanup = () => { - this.aiService.off("stream-end", maybeResolve as never); - this.aiService.off("stream-abort", maybeResolve as never); - this.aiService.off("error", maybeResolve as never); + this.aiService.off("stream-end", maybeResolve); + this.aiService.off("stream-abort", maybeResolve); + this.aiService.off("error", maybeResolve); }; - this.aiService.on("stream-end", maybeResolve as never); - this.aiService.on("stream-abort", maybeResolve as never); - this.aiService.on("error", maybeResolve as never); + this.aiService.on("stream-end", maybeResolve); + this.aiService.on("stream-abort", maybeResolve); + this.aiService.on("error", maybeResolve); // Defensive: stream state may have changed between waitForIdle() and listener setup. maybeResolve({ workspaceId: this.workspaceId }); @@ -2401,7 +2458,7 @@ export class AgentSession { this.startupAutoRetryCheckScheduled = false; if ( this.isBusy() || - this.aiService.isStreaming(this.workspaceId) || + this.streamManager.isStreaming(this.workspaceId) || this.retryManager.isRetryPending ) { return; @@ -2428,7 +2485,7 @@ export class AgentSession { shouldRetainAfterStartupRecovery(): boolean { return ( this.isBusy() || - this.aiService.isStreaming(this.workspaceId) || + this.streamManager.isStreaming(this.workspaceId) || this.retryManager.isRetryPending ); } @@ -2546,15 +2603,15 @@ export class AgentSession { // Live mode still needs stream context when a response is currently active. // Replay only stream-start (no historical deltas/tool updates) so clients can // attach future live events to the correct message. - const liveStreamInfo = this.aiService.getStreamInfo(this.workspaceId); + const liveStreamInfo = this.streamManager.getStreamInfo(this.workspaceId); if (liveStreamInfo) { const streamLastTimestamp = this.getStreamLastTimestamp(liveStreamInfo); - await this.aiService.replayStream(this.workspaceId, { + await this.streamManager.replayStream(this.workspaceId, { afterTimestamp: streamLastTimestamp, }); // Stream can end while replayStream runs; only expose cursor when still active. - const liveStreamInfoAfterReplay = this.aiService.getStreamInfo?.(this.workspaceId); + const liveStreamInfoAfterReplay = this.streamManager.getStreamInfo(this.workspaceId); if (liveStreamInfoAfterReplay) { serverCursor = { ...serverCursor, @@ -2575,7 +2632,7 @@ export class AgentSession { // Read partial BEFORE iterating history so we can skip the corresponding // placeholder message (which has empty parts). The partial has the real content. - const streamInfo = this.aiService.getStreamInfo(this.workspaceId); + const streamInfo = this.streamManager.getStreamInfo(this.workspaceId); const partial = await this.historyService.readPartial(this.workspaceId); const partialHistorySequence = partial?.metadata?.historySequence; @@ -2747,13 +2804,13 @@ export class AgentSession { const attemptedStreamReplay = streamInfo !== undefined; if (streamInfo) { - await this.aiService.replayStream(this.workspaceId, { afterTimestamp }); + await this.streamManager.replayStream(this.workspaceId, { afterTimestamp }); } // Re-read stream state after replay. The stream can end while we are // replaying history, and caught-up cursor metadata must reflect that // latest backend state to avoid phantom active streams in the client. - const streamInfoAfterReplay = this.aiService.getStreamInfo?.(this.workspaceId); + const streamInfoAfterReplay = this.streamManager.getStreamInfo(this.workspaceId); if (streamInfoAfterReplay) { serverCursor = { ...serverCursor, @@ -4235,7 +4292,7 @@ export class AgentSession { // Prefer ProviderService's safe config view: it includes env/file API-key source // metadata plus the Codex OAuth presence bit, which context-limit resolution needs // to distinguish GPT-5.5 API-key requests from lower-cap OAuth-routed requests. - const maybeAIService = this.aiService as AIService & { + const maybeAIService = this.aiService as AgentSessionAIService & { getProvidersConfig?: () => ProvidersConfigMap | null; }; if (typeof maybeAIService.getProvidersConfig === "function") { @@ -4698,7 +4755,7 @@ export class AgentSession { this.midStreamCompactionPending = true; try { - const stopResult = await this.aiService.stopStream(this.workspaceId, { + const stopResult = await this.streamManager.stopStream(this.workspaceId, { abortReason: "system", }); if (!stopResult.success) { @@ -4820,7 +4877,7 @@ export class AgentSession { } } - const stopResult = await this.aiService.stopStream(this.workspaceId, { + const stopResult = await this.streamManager.stopStream(this.workspaceId, { ...options, abortReason: "user", }); @@ -5718,7 +5775,7 @@ export class AgentSession { void handler(payload as WorkspaceChatMessage); }; this.aiListeners.push({ event, handler: wrapped }); - this.aiService.on(event, wrapped as never); + this.aiService.on(event, wrapped); }; forward("stream-start", (payload) => { @@ -6165,7 +6222,7 @@ export class AgentSession { }; this.aiListeners.push({ event: "error", handler: errorHandler }); - this.aiService.on("error", errorHandler as never); + this.aiService.on("error", errorHandler); } private attachInitListeners(): void { @@ -6717,7 +6774,7 @@ export class AgentSession { } this.queuedProviderToolEndAbortInFlight = true; - const result = await this.aiService.stopStream(this.workspaceId, { + const result = await this.streamManager.stopStream(this.workspaceId, { soft: true, abortReason: "system", }); diff --git a/src/node/services/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts index 33b767b58d4..2b2a6164c42 100644 --- a/src/node/services/agentSkills/agentSkillsService.ts +++ b/src/node/services/agentSkills/agentSkillsService.ts @@ -1054,7 +1054,10 @@ export async function readAgentSkill( throw new Error(`Agent skill not found: ${name}`); } -export type AgentSkillsContext = Pick; +export type AgentSkillsContext = Pick< + ORPCContext, + "config" | "aiService" | "experimentsService" | "initStateManager" +>; async function resolveAgentSkillDiscoveryContext( context: AgentSkillsContext, @@ -1115,7 +1118,7 @@ async function getAgentSkillContext( context: AgentSkillsContext, input: { projectPath?: string; workspaceId?: string; disableWorkspaceAgents?: boolean } ) { - if (input.workspaceId) await context.aiService.waitForInit(input.workspaceId); + if (input.workspaceId) await context.initStateManager.waitForInit(input.workspaceId); return resolveAgentSkillDiscoveryContext(context, input); } diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 9a5ffe4cb04..15414817640 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -7,11 +7,7 @@ import * as path from "node:path"; import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { - AIService, - prepareProviderRequestMessages, - resolveMuxProjectRootForHostFs, -} from "./aiService"; +import { AIService, resolveMuxProjectRootForHostFs } from "./aiService"; import { discoverAvailableSubagentsForToolContext } from "./streamContextBuilder"; import { normalizeAnthropicBaseURL, @@ -21,7 +17,6 @@ import { import { HistoryService } from "./historyService"; import { InitStateManager } from "./initStateManager"; import { ProviderService } from "./providerService"; -import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { Config } from "@/node/config"; import * as runtimeFactory from "@/node/runtime/runtimeFactory"; @@ -34,18 +29,14 @@ import { XUM_APP_ATTRIBUTION_TITLE, XUM_APP_ATTRIBUTION_URL } from "@/constants/ import type { ProviderName } from "@/common/constants/providers"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; import type { CodexOauthService } from "@/node/services/codexOauthService"; -import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { CODEX_ENDPOINT } from "@/common/constants/codexOAuth"; -import { addInterruptedSentinel } from "@/browser/utils/messages/modelMessageTransform"; -import { buildWorkflowRunCardMessage } from "@/common/utils/workflowRunMessages"; import { jsonSchema, tool, type LanguageModel, type Tool } from "ai"; import { createMuxMessage } from "@/common/types/message"; -import type { ModelMessage, MuxMessage } from "@/common/types/message"; +import type { ModelMessage } from "@/common/types/message"; import type { XumToolScope } from "@/common/types/toolScope"; import type { WorkspaceMetadata } from "@/common/types/workspace"; -import { uniqueSuffix } from "@/common/utils/hasher"; import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; import type { ErrorEvent, @@ -62,10 +53,6 @@ import type { TurnExecutionOptions, TurnStreamHandle, } from "./streamManager"; -import type { - ActiveTurnThinkingOverride, - RebuildProviderOptionsForThinkingLevel, -} from "./thinkingOverride"; import { ExperimentsService } from "./experimentsService"; import type { DevToolsService } from "./devToolsService"; import { TelemetryService } from "@/node/services/telemetryService"; @@ -81,7 +68,6 @@ import type { ToolModelUsageEvent } from "@/common/utils/tools/tools"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { normalizeToCanonical } from "@/common/utils/ai/models"; import * as toolsModule from "@/common/utils/tools/tools"; -import * as providerOptionsModule from "@/common/utils/ai/providerOptions"; import * as systemMessageModule from "./systemMessage"; interface BasicAIServiceParts { @@ -206,9 +192,11 @@ function configureOpenAICodexOAuth( }); if (options?.setOauthService !== false) { - service.setCodexOauthService({ + const codexOauthStub = { getValidAuth: () => Promise.resolve({ success: true, data: TEST_CODEX_OAUTH }), - } as CodexOauthService); + }; + service.turnRequestBuilderBindings.codexOauthService = + codexOauthStub as unknown as CodexOauthService; } } @@ -421,134 +409,6 @@ function stubCommonStreamMessageDependencies(args: { return getToolsForModelSpy; } -describe("prepareProviderRequestMessages", () => { - it("slices at reset boundaries before filtering empty assistant messages", () => { - const oldMessage = createMuxMessage("old-user", "user", "old context", { - historySequence: 1, - }); - const resetBoundary = createMuxMessage("reset-boundary", "assistant", "", { - historySequence: 2, - contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, - }); - const newMessage = createMuxMessage("new-user", "user", "new context", { - historySequence: 3, - }); - - const result = prepareProviderRequestMessages( - [oldMessage, resetBoundary, newMessage], - "openai", - "off" - ); - - expect(result.activeContextMessages.map((message) => message.id)).toEqual(["new-user"]); - expect(result.providerRequestMessages.map((message) => message.id)).toEqual(["new-user"]); - }); - - it("filters workflow display rows while keeping provider-visible workflow results", () => { - const trigger = createMuxMessage("workflow-command", "user", "/shallow-review mux", { - historySequence: 1, - muxMetadata: { - type: "workflow-trigger-display", - rawCommand: "/shallow-review mux", - commandPrefix: "/shallow-review", - runId: "wfr_1", - }, - }); - const card = buildWorkflowRunCardMessage( - { name: "shallow-review", args: { input: "mux" } }, - { runId: "wfr_1", status: "running", result: null }, - 2 - ); - card.metadata = { - historySequence: 2, - synthetic: true, - uiVisible: true, - muxMetadata: { type: "workflow-run-card-display", runId: "wfr_1" }, - }; - const result = createMuxMessage( - "workflow-result", - "user", - "/shallow-review mux\n\n{}", - { - historySequence: 3, - muxMetadata: { - type: "workflow-result", - rawCommand: "/shallow-review mux", - commandPrefix: "/shallow-review", - runId: "wfr_1", - }, - } - ); - const nextUser = createMuxMessage("next-user", "user", "continue normal work", { - historySequence: 4, - }); - - const prepared = prepareProviderRequestMessages( - [trigger, card, result, nextUser], - "openai", - "off" - ); - - expect(prepared.activeContextMessages.map((message) => message.id)).toEqual([ - "workflow-result", - "next-user", - ]); - expect(prepared.providerRequestMessages.map((message) => message.id)).toEqual([ - "workflow-result", - "next-user", - ]); - }); - - it("excludes the stamped keep-recent tail from RLM compaction summarization requests", () => { - const head = createMuxMessage("head-user", "user", "old context", { historySequence: 1 }); - const headReply = createMuxMessage("head-assistant", "assistant", "old reply", { - historySequence: 2, - }); - const tail = createMuxMessage("tail-user", "user", "recent context", { historySequence: 3 }); - const tailReply = createMuxMessage("tail-assistant", "assistant", "recent reply", { - historySequence: 4, - }); - const stampedRequest = createMuxMessage("compact-req", "user", "/compact", { - historySequence: 5, - muxMetadata: { - type: "compaction-request", - rawCommand: "/compact", - parsed: {}, - keepRecentTail: { startHistorySequence: 3 }, - }, - }); - - const prepared = prepareProviderRequestMessages( - [head, headReply, tail, tailReply, stampedRequest], - "openai", - "off" - ); - - expect(prepared.providerRequestMessages.map((message) => message.id)).toEqual([ - "head-user", - "head-assistant", - "compact-req", - ]); - }); - - it("keeps whole-epoch summarization for unstamped compaction requests (RLM off)", () => { - const head = createMuxMessage("head-user", "user", "old context", { historySequence: 1 }); - const tail = createMuxMessage("tail-user", "user", "recent context", { historySequence: 2 }); - const request = createMuxMessage("compact-req", "user", "/compact", { - historySequence: 3, - muxMetadata: { type: "compaction-request", rawCommand: "/compact", parsed: {} }, - }); - - const prepared = prepareProviderRequestMessages([head, tail, request], "openai", "off"); - - expect(prepared.providerRequestMessages.map((message) => message.id)).toEqual([ - "head-user", - "tail-user", - "compact-req", - ]); - }); -}); - describe("AIService", () => { let service: AIService; @@ -1192,41 +1052,6 @@ describe("AIService.streamMessage compaction boundary slicing", () => { getToolsForModelSpy: ReturnType>; } - function messageIdsFromUnknownArray(messages: unknown): string[] { - if (!Array.isArray(messages)) { - throw new Error("Expected message array"); - } - - return messages.map((message) => { - if (!message || typeof message !== "object") { - throw new Error("Expected message object in array"); - } - - const id = (message as { id?: unknown }).id; - if (typeof id !== "string") { - throw new Error("Expected message.id to be a string"); - } - - return id; - }); - } - - function openAIOptionsFromStartStreamCall( - startStreamOptions: TurnExecutionOptions - ): Record { - const providerOptions = startStreamOptions.providerOptions; - if (!providerOptions || typeof providerOptions !== "object") { - throw new Error("Expected provider options object in startStream options"); - } - - const openai = (providerOptions as { openai?: unknown }).openai; - if (!openai || typeof openai !== "object") { - throw new Error("Expected OpenAI provider options in startStream providerOptions"); - } - - return openai as Record; - } - function initialMetadataFromStartStreamCall( startStreamOptions: TurnExecutionOptions ): Record { @@ -1505,74 +1330,6 @@ describe("AIService.streamMessage compaction boundary slicing", () => { }); }); - it("prepares fallback continuation from partial assistant output with one sentinel", async () => { - using xumHome = new DisposableTempDir("ai-service-fallback-continuation"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-fallback-continuation"; - const fallbackModel = KNOWN_MODELS.GPT.id; - await writeMainConfig(xumHome.path, { - modelFallbacks: { - [KNOWN_MODELS.SONNET.id]: { models: [fallbackModel] }, - }, - }); - - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { - effectiveModelString: KNOWN_MODELS.SONNET.id, - canonicalProviderName: "anthropic", - canonicalModelId: "claude-sonnet-4-5", - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "fix the issue")], - workspaceId, - modelString: KNOWN_MODELS.SONNET.id, - thinkingLevel: "off", - }); - expect(result.success).toBe(true); - expect(harness.startStreamCalls).toHaveLength(1); - - const modelFallback = harness.startStreamCalls[0]?.modelFallback; - expect(modelFallback).toBeDefined(); - if (!modelFallback) { - throw new Error("Expected modelFallback options on startStream"); - } - - const continuationAssistant: MuxMessage = { - id: "assistant-partial", - role: "assistant", - metadata: { partial: true, historySequence: 2 }, - parts: [ - { type: "text", text: "I checked the report." }, - { - type: "dynamic-tool", - toolCallId: "tool-1", - toolName: "bash", - state: "output-available", - input: { script: "printf ok" }, - output: { success: true, output: "ok" }, - }, - ], - }; - - const prepared = await modelFallback.prepare(fallbackModel, { - continuation: { assistantMessage: continuationAssistant }, - }); - expect(prepared.success).toBe(true); - - expect(harness.preparedPayloadMessageIds).toHaveLength(2); - expect(harness.preparedPayloadMessageIds[1]).toEqual([ - "latest-user", - "assistant-partial", - "interrupted-assistant-partial", - ]); - expect( - harness.preparedPayloadMessageIds[1]?.filter((id) => id === "interrupted-assistant-partial") - ).toHaveLength(1); - }); - it("prepares fallback system context with the fallback model's hot memories", async () => { using xumHome = new DisposableTempDir("ai-service-fallback-hot-memories"); const projectPath = path.join(xumHome.path, "project"); @@ -1603,8 +1360,9 @@ describe("AIService.streamMessage compaction boundary slicing", () => { useRequestedModelString: true, experimentsService, }); - harness.service.setMemoryService( - new MemoryService(harness.config, new MemoryMetaService(xumHome.path)) + harness.service.turnRequestBuilderBindings.memoryService = new MemoryService( + harness.config, + new MemoryMetaService(xumHome.path) ); const memoryCalls: Array<{ modelString: string; includeHotMemories: boolean }> = []; @@ -1647,786 +1405,133 @@ describe("AIService.streamMessage compaction boundary slicing", () => { ); }); - // GPT-5.6 Chat Completions explicit-caching seam: fallback provider options - // and route metadata must be rebuilt from the fallback model/route so cache - // fields cannot leak across routes in either direction. - function stubPerModelRouteResolution(service: AIService): void { - const providerModelFactory = Reflect.get(service, "providerModelFactory") as - | ProviderModelFactory - | undefined; - if (!providerModelFactory) { - throw new Error("Expected AIService.providerModelFactory in fallback route test"); - } - spyOn(providerModelFactory, "resolveAndCreateModel").mockImplementation( - (requestedModelString) => { - const isGateway = requestedModelString.startsWith("mux-gateway:"); - const canonicalModelString = isGateway - ? requestedModelString.replace("mux-gateway:openai/", "openai:") - : requestedModelString; - return Promise.resolve({ - success: true, - data: { - model: Object.create(null) as LanguageModel, - effectiveModelString: requestedModelString, - canonicalModelString, - canonicalProviderName: "openai" as ProviderName, - canonicalModelId: canonicalModelString.split(":")[1] ?? canonicalModelString, - wireProviderName: "openai", - routedThroughGateway: isGateway, - routeProvider: (isGateway ? "mux-gateway" : "openai") as ProviderName, - }, - }); - } - ); - - const providerService = Reflect.get(service, "providerService") as ProviderService | undefined; - if (!providerService) { - throw new Error("Expected AIService.providerService in fallback route test"); - } - spyOn(providerService, "getConfig").mockReturnValue({ - openai: { apiKeySet: true, isEnabled: true, isConfigured: true }, - }); - } - - async function runChatCompletionsFallback(options: { - tempDirName: string; - workspaceId: string; - sourceModel: string; - fallbackModel: string; - }): Promise<{ - primaryOpenAIOptions: Record; - primaryInitialMetadata: Record; - preparedOpenAIOptions: Record; - preparedMetadataPatch: Record; - }> { - using xumHome = new DisposableTempDir(options.tempDirName); + it("emits startup breadcrumbs as runtime-status events before stream start", async () => { + using xumHome = new DisposableTempDir("ai-service-startup-breadcrumbs"); const projectPath = path.join(xumHome.path, "project"); await fs.mkdir(projectPath, { recursive: true }); - await writeMainConfig(xumHome.path, { - modelFallbacks: { - [options.sourceModel]: { models: [options.fallbackModel] }, - }, - }); - const metadata = createLocalWorkspaceMetadata(options.workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { useRequestedModelString: true }); - stubPerModelRouteResolution(harness.service); + const workspaceId = "workspace-startup-breadcrumbs"; + const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); + const harness = createHarness(xumHome.path, metadata); + const runtimeStatusEvents: RuntimeStatusEvent[] = []; + + harness.service.on("runtime-status", (event) => { + runtimeStatusEvents.push(event as RuntimeStatusEvent); + }); const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "fix the issue")], - workspaceId: options.workspaceId, - modelString: options.sourceModel, + messages: [createMuxMessage("latest-user", "user", "hello")], + workspaceId, + modelString: "openai:gpt-5.2", thinkingLevel: "off", - muxProviderOptions: { openai: { wireFormat: "chatCompletions" } }, }); - expect(result.success).toBe(true); - expect(harness.startStreamCalls).toHaveLength(1); - - const startStreamArgs = harness.startStreamCalls[0]; - const modelFallback = startStreamArgs.modelFallback; - if (!modelFallback) { - throw new Error("Expected modelFallback options on startStream"); - } - - const prepared = await modelFallback.prepare(options.fallbackModel); - expect(prepared.success).toBe(true); - if (!prepared.success) { - throw new Error(prepared.error); - } - const preparedOpenAIOptions = (prepared.data.providerOptions as { openai?: unknown }) - ?.openai as Record; - expect(preparedOpenAIOptions).toBeDefined(); - - return { - primaryOpenAIOptions: openAIOptionsFromStartStreamCall(startStreamArgs), - primaryInitialMetadata: initialMetadataFromStartStreamCall(startStreamArgs), - preparedOpenAIOptions, - preparedMetadataPatch: (prepared.data.initialMetadataPatch ?? {}) as Record, - }; - } + expect(result.success).toBe(true); + expect( + runtimeStatusEvents.map((event) => ({ + phase: event.phase, + detail: event.detail, + runtimeType: event.runtimeType, + })) + ).toEqual([ + { + phase: "waiting", + detail: "Waiting for workspace initialization...", + runtimeType: "local", + }, + { + phase: "starting", + detail: "Checking workspace runtime...", + runtimeType: "local", + }, + { + phase: "checking", + detail: "Checking repository...", + runtimeType: "local", + }, + { + phase: "ready", + detail: undefined, + runtimeType: "local", + }, + { + phase: "starting", + detail: "Loading workspace context...", + runtimeType: "local", + }, + { + phase: "starting", + detail: "Loading tools...", + runtimeType: "local", + }, + { + phase: "starting", + detail: "Preparing model request...", + runtimeType: "local", + }, + { + phase: "starting", + detail: "Starting model stream...", + runtimeType: "local", + }, + ]); + }); - it("drops the Chat Completions cache key when an eligible source falls back to a gateway route", async () => { - const { - primaryOpenAIOptions, - primaryInitialMetadata, - preparedOpenAIOptions, - preparedMetadataPatch, - } = await runChatCompletionsFallback({ - tempDirName: "ai-service-fallback-cache-key-drop", - workspaceId: "workspace-fallback-cache-key-drop", - sourceModel: "openai:gpt-5.6-luna", - fallbackModel: "mux-gateway:openai/gpt-5.6-sol", - }); + it("reuses the pre-policy stream system context when advisor availability is unchanged", async () => { + using xumHome = new DisposableTempDir("ai-service-reuse-system-context"); + const projectPath = path.join(xumHome.path, "project"); + await fs.mkdir(projectPath, { recursive: true }); - // Source: direct official OpenAI GPT-5.6 Chat Completions gets the key. - expect(primaryOpenAIOptions.promptCacheKey).toStartWith("mux-v1-"); - expect(primaryInitialMetadata.routeProvider).toBe("openai"); - // Fallback: gateway route — the rebuilt options must not carry the key. - expect(preparedOpenAIOptions.promptCacheKey).toBeUndefined(); - expect(preparedMetadataPatch.routeProvider).toBe("mux-gateway"); - expect(preparedMetadataPatch.routedThroughGateway).toBe(true); - }); + const workspaceId = "workspace-reuse-system-context"; + const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); + const harness = createHarness(xumHome.path, metadata); - it("adds the Chat Completions cache key when a gateway source falls back to direct OpenAI", async () => { - const { - primaryOpenAIOptions, - primaryInitialMetadata, - preparedOpenAIOptions, - preparedMetadataPatch, - } = await runChatCompletionsFallback({ - tempDirName: "ai-service-fallback-cache-key-add", - workspaceId: "workspace-fallback-cache-key-add", - sourceModel: "mux-gateway:openai/gpt-5.6-luna", - fallbackModel: "openai:gpt-5.6-sol", + const result = await harness.service.streamMessage({ + messages: [createMuxMessage("latest-user", "user", "hello")], + workspaceId, + modelString: "openai:gpt-5.2", + thinkingLevel: "off", }); - // Source: gateway-routed GPT-5.6 Chat Completions gets no key. - expect(primaryOpenAIOptions.promptCacheKey).toBeUndefined(); - expect(primaryInitialMetadata.routeProvider).toBe("mux-gateway"); - // Fallback: direct official OpenAI — the rebuilt options carry the key. - expect(preparedOpenAIOptions.promptCacheKey).toStartWith("mux-v1-"); - expect(preparedMetadataPatch.routeProvider).toBe("openai"); - expect(preparedMetadataPatch.routedThroughGateway).toBe(false); + expect(result.success).toBe(true); + expect(harness.streamSystemContextAdvisorFlags).toEqual([false]); + expect(harness.startStreamCalls[0]?.providedRuntimeTempDir).toBe( + path.join(metadata.projectPath, ".tmp-stream") + ); }); - it("keeps the raw Coder identity when rebuilding fallback provider options", async () => { - // A cross-typed canonical-name instance ({name: "openai", type: - // "anthropic"}) canonicalizes coder:openai/x to openai:x by NAME while the - // wire is Anthropic. The fallback rebuild must hand the RAW coder string - // to option/header/cache builders (like the main path does) so they can - // recover the instance metadata — the canonical string would emit OpenAI - // options for an Anthropic-wire request. - using xumHome = new DisposableTempDir("ai-service-fallback-coder-raw-identity"); - const workspaceId = "workspace-fallback-coder-raw"; + it("rebuilds the stream system context when policy removes advisor guidance", async () => { + using xumHome = new DisposableTempDir("ai-service-rebuild-system-context-advisor"); const projectPath = path.join(xumHome.path, "project"); await fs.mkdir(projectPath, { recursive: true }); - const sourceModel = "openai:gpt-5.2"; - const fallbackModel = "coder:openai/claude-opus-4-5"; - await writeMainConfig(xumHome.path, { - modelFallbacks: { [sourceModel]: { models: [fallbackModel] } }, - }); + const workspaceId = "workspace-rebuild-system-context-advisor"; const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { useRequestedModelString: true }); - // Model parameter overrides must resolve from the instance TYPE - // (anthropic), not the name-canonicalized provider (openai): the OpenAI - // wildcard here must NOT leak into the Anthropic-wire request. - harness.config.saveProvidersConfig({ - anthropic: { modelParameters: { "claude-opus-4-5": { anthropicKnob: "yes" } } }, - openai: { modelParameters: { "*": { openaiKnob: "no" } } }, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- stub for advisor availability gating + const stubTool: Tool = {} as never; + const harness = createHarness(xumHome.path, metadata, { + allTools: { advisor: stubTool }, + postPolicyTools: {}, }); - - const providerModelFactory = Reflect.get(harness.service, "providerModelFactory") as - | ProviderModelFactory - | undefined; - if (!providerModelFactory) { - throw new Error("Expected AIService.providerModelFactory in fallback test"); - } - // Mirror the real factory: name-based canonicalization rewrites the coder - // string, while the wire comes from instance metadata. - spyOn(providerModelFactory, "resolveAndCreateModel").mockImplementation( - (requestedModelString) => { - const isCoder = requestedModelString.startsWith("coder:"); - return Promise.resolve({ - success: true, - data: { - model: Object.create(null) as LanguageModel, - effectiveModelString: requestedModelString, - canonicalModelString: isCoder ? "openai:claude-opus-4-5" : requestedModelString, - canonicalProviderName: "openai", - canonicalModelId: isCoder - ? "claude-opus-4-5" - : (requestedModelString.split(":")[1] ?? requestedModelString), - wireProviderName: isCoder ? "anthropic" : "openai", - ...(isCoder - ? { - coderWire: { - origin: "anthropic" as const, - modelId: "claude-opus-4-5", - providerType: "anthropic", - }, - } - : {}), - routedThroughGateway: false, - ...(isCoder ? { routeProvider: "coder" as ProviderName } : {}), - }, - }); - } - ); - const providerService = Reflect.get(harness.service, "providerService") as - | ProviderService - | undefined; - if (!providerService) { - throw new Error("Expected AIService.providerService in fallback test"); - } - spyOn(providerService, "getConfig").mockReturnValue({ - openai: { apiKeySet: true, isEnabled: true, isConfigured: true }, - coder: { - apiKeySet: false, - isEnabled: true, - isConfigured: true, - discoveredProviders: [{ name: "openai", type: "anthropic" }], - }, + await harness.config.editConfig((cfg) => { + cfg.advisorModelString = KNOWN_MODELS.SONNET.id; + return cfg; }); const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "fix the issue")], + messages: [createMuxMessage("latest-user", "user", "hello")], workspaceId, - modelString: sourceModel, - thinkingLevel: "high", + modelString: "openai:gpt-5.2", + thinkingLevel: "off", + experiments: { advisorTool: true }, }); - expect(result.success).toBe(true); - - const modelFallback = harness.startStreamCalls[0]?.modelFallback; - if (!modelFallback) { - throw new Error("Expected modelFallback options on startStream"); - } - const prepared = await modelFallback.prepare(fallbackModel); - expect(prepared.success).toBe(true); - if (!prepared.success) { - throw new Error(prepared.error); - } - - // Anthropic-wire options, not OpenAI: the builders saw the raw coder - // string and resolved the instance's type from providersConfig. - expect(prepared.data.providerOptions).toHaveProperty("anthropic"); - expect(prepared.data.providerOptions).not.toHaveProperty("openai"); - - // Override identity followed the instance type: the anthropic block's - // model entry applied; the OpenAI wildcard did not leak in. - const preparedAnthropicNamespace = ( - prepared.data.providerOptions as Record> - ).anthropic; - expect(preparedAnthropicNamespace.anthropicKnob).toBe("yes"); - expect(preparedAnthropicNamespace).not.toHaveProperty("openaiKnob"); - - // The returned request keeps the RAW identity: StreamManager keys - // system/tool cache-control and metadata resolution on this string, and - // the canonical openai:* form would drop Anthropic cache markers. - expect(prepared.data.modelString).toBe(fallbackModel); - - // Capability lookups saw the raw identity too: the fallback toolset was - // built for the instance's Claude upstream, not for canonical "openai:". - const fallbackToolConfig = harness.getToolsForModelSpy.mock.calls.at(-1)?.[1] as - | { capabilityModelString?: string } - | undefined; - expect(fallbackToolConfig?.capabilityModelString).toBe("anthropic:claude-opus-4-5"); - // Tool assembly keys on the WIRE identity (anthropic:): the raw - // coder string would parse as provider "coder" and skip the Anthropic - // tool branch, while canonical "openai:" selects the wrong family. - expect(harness.getToolsForModelSpy.mock.calls.at(-1)?.[0]).toBe("anthropic:claude-opus-4-5"); + expect(result.success).toBe(true); + expect(harness.streamSystemContextAdvisorFlags).toEqual([true, false]); }); - it("derives the main-path capability model from the raw Coder identity", async () => { - // aiService's capabilityModelString must resolve from the RAW selection: - // canonicalization rewrites coder:openai/x (type anthropic) to openai:x, - // which can no longer consult the instance metadata — tool/instruction - // decisions would treat a Claude model as OpenAI. - using xumHome = new DisposableTempDir("ai-service-main-coder-raw-capability"); - const workspaceId = "workspace-main-coder-raw"; - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - const modelString = "coder:openai/claude-opus-4-5"; - - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { useRequestedModelString: true }); - - const providerModelFactory = Reflect.get(harness.service, "providerModelFactory") as - | ProviderModelFactory - | undefined; - if (!providerModelFactory) { - throw new Error("Expected AIService.providerModelFactory in capability test"); - } - spyOn(providerModelFactory, "resolveAndCreateModel").mockResolvedValue({ - success: true, - data: { - model: Object.create(null) as LanguageModel, - effectiveModelString: modelString, - canonicalModelString: "openai:claude-opus-4-5", - canonicalProviderName: "openai", - canonicalModelId: "claude-opus-4-5", - wireProviderName: "anthropic", - coderWire: { - origin: "anthropic" as const, - modelId: "claude-opus-4-5", - providerType: "anthropic", - }, - routedThroughGateway: false, - routeProvider: "coder", - }, - }); - const providerService = Reflect.get(harness.service, "providerService") as - | ProviderService - | undefined; - if (!providerService) { - throw new Error("Expected AIService.providerService in capability test"); - } - spyOn(providerService, "getConfig").mockReturnValue({ - coder: { - apiKeySet: false, - isEnabled: true, - isConfigured: true, - discoveredProviders: [{ name: "openai", type: "anthropic" }], - }, - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "fix the issue")], - workspaceId, - modelString, - thinkingLevel: "off", - }); - expect(result.success).toBe(true); - - const toolConfig = harness.getToolsForModelSpy.mock.calls[0]?.[1] as - | { capabilityModelString?: string } - | undefined; - expect(toolConfig?.capabilityModelString).toBe("anthropic:claude-opus-4-5"); - // Tool assembly gets the WIRE identity so provider-specific branches - // (Anthropic native web tools) fire; raw "coder:" would skip them. - expect(harness.getToolsForModelSpy.mock.calls[0]?.[0]).toBe("anthropic:claude-opus-4-5"); - }); - - it("normalizes passthrough-gateway fallbacks to the canonical wire identity for tools", async () => { - // A coder: selection whose route fell back to mux-gateway (Coder - // disconnected / catalog rejection): the passthrough gateway forwards - // origin-shaped payloads, so tool assembly must key on the canonical - // wire identity — the mux-gateway:* prefix would skip the Anthropic - // tool branch entirely. - using xumHome = new DisposableTempDir("ai-service-main-coder-passthrough-fallback"); - const workspaceId = "workspace-main-coder-passthrough"; - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - const modelString = "coder:prod-anthropic/claude-opus-4-5"; - - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { useRequestedModelString: true }); - - const providerModelFactory = Reflect.get(harness.service, "providerModelFactory") as - | ProviderModelFactory - | undefined; - if (!providerModelFactory) { - throw new Error("Expected AIService.providerModelFactory in passthrough-fallback test"); - } - spyOn(providerModelFactory, "resolveAndCreateModel").mockResolvedValue({ - success: true, - data: { - model: Object.create(null) as LanguageModel, - effectiveModelString: "mux-gateway:anthropic/claude-opus-4-5", - canonicalModelString: "coder:prod-anthropic/claude-opus-4-5", - canonicalProviderName: "coder", - canonicalModelId: "prod-anthropic/claude-opus-4-5", - wireProviderName: "anthropic", - routedThroughGateway: true, - routeProvider: "mux-gateway", - }, - }); - const providerService = Reflect.get(harness.service, "providerService") as - | ProviderService - | undefined; - if (!providerService) { - throw new Error("Expected AIService.providerService in passthrough-fallback test"); - } - spyOn(providerService, "getConfig").mockReturnValue({ - coder: { - apiKeySet: false, - isEnabled: true, - isConfigured: true, - discoveredProviders: [{ name: "prod-anthropic", type: "anthropic" }], - }, - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "fix the issue")], - workspaceId, - modelString, - thinkingLevel: "off", - }); - expect(result.success).toBe(true); - - expect(harness.getToolsForModelSpy.mock.calls[0]?.[0]).toBe("anthropic:claude-opus-4-5"); - }); - - it("marks openai-chat Coder instances as Chat Completions for tools and options", async () => { - // coder:openrouter/... is created via provider.chat(...): tool assembly - // must not add Responses-only native web_search, and providerOptions - // must build for the Chat Completions wire (via the wireFormat knob). - using xumHome = new DisposableTempDir("ai-service-main-coder-chat-wire"); - const workspaceId = "workspace-main-coder-chat-wire"; - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - const modelString = "coder:openrouter/openai/gpt-5.2"; - - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { useRequestedModelString: true }); - - const providerModelFactory = Reflect.get(harness.service, "providerModelFactory") as - | ProviderModelFactory - | undefined; - if (!providerModelFactory) { - throw new Error("Expected AIService.providerModelFactory in chat-wire test"); - } - spyOn(providerModelFactory, "resolveAndCreateModel").mockResolvedValue({ - success: true, - data: { - model: Object.create(null) as LanguageModel, - effectiveModelString: modelString, - canonicalModelString: modelString, - canonicalProviderName: "coder", - canonicalModelId: "openrouter/openai/gpt-5.2", - wireProviderName: "openai", - coderWire: { - origin: "openai" as const, - modelId: "openai/gpt-5.2", - providerType: "openrouter", - }, - routedThroughGateway: false, - routeProvider: "coder", - }, - }); - const providerService = Reflect.get(harness.service, "providerService") as - | ProviderService - | undefined; - if (!providerService) { - throw new Error("Expected AIService.providerService in chat-wire test"); - } - spyOn(providerService, "getConfig").mockReturnValue({ - coder: { - apiKeySet: false, - isEnabled: true, - isConfigured: true, - discoveredProviders: [{ name: "openrouter", type: "openrouter" }], - }, - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "fix the issue")], - workspaceId, - modelString, - thinkingLevel: "off", - }); - expect(result.success).toBe(true); - - // Tools got the wire identity plus the Chat Completions marker. - expect(harness.getToolsForModelSpy.mock.calls[0]?.[0]).toBe("openai:openai/gpt-5.2"); - const toolConfig = harness.getToolsForModelSpy.mock.calls[0]?.[1] as - | { openaiWireFormat?: string } - | undefined; - expect(toolConfig?.openaiWireFormat).toBe("chatCompletions"); - }); - - it("forces the Responses format for openai-typed Coder instances", async () => { - // The factory always creates provider.responses(...) for type "openai", - // ignoring the wireFormat knob. A pre-existing chatCompletions setting - // (user option, or a refusal chain that started on direct OpenAI Chat - // Completions) must be overridden, or tools/options build Chat - // Completions payloads for a Responses request. - using xumHome = new DisposableTempDir("ai-service-main-coder-responses-wire"); - const workspaceId = "workspace-main-coder-responses-wire"; - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - const modelString = "coder:prod-openai/gpt-5.2"; - - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { useRequestedModelString: true }); - - const providerModelFactory = Reflect.get(harness.service, "providerModelFactory") as - | ProviderModelFactory - | undefined; - if (!providerModelFactory) { - throw new Error("Expected AIService.providerModelFactory in responses-wire test"); - } - spyOn(providerModelFactory, "resolveAndCreateModel").mockResolvedValue({ - success: true, - data: { - model: Object.create(null) as LanguageModel, - effectiveModelString: modelString, - canonicalModelString: modelString, - canonicalProviderName: "coder", - canonicalModelId: "prod-openai/gpt-5.2", - wireProviderName: "openai", - coderWire: { origin: "openai" as const, modelId: "gpt-5.2", providerType: "openai" }, - routedThroughGateway: false, - routeProvider: "coder", - }, - }); - const providerService = Reflect.get(harness.service, "providerService") as - | ProviderService - | undefined; - if (!providerService) { - throw new Error("Expected AIService.providerService in responses-wire test"); - } - spyOn(providerService, "getConfig").mockReturnValue({ - coder: { - apiKeySet: false, - isEnabled: true, - isConfigured: true, - discoveredProviders: [{ name: "prod-openai", type: "openai" }], - }, - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "fix the issue")], - workspaceId, - modelString, - thinkingLevel: "off", - muxProviderOptions: { openai: { wireFormat: "chatCompletions" } }, - }); - expect(result.success).toBe(true); - - expect(harness.getToolsForModelSpy.mock.calls[0]?.[0]).toBe("openai:gpt-5.2"); - const toolConfig = harness.getToolsForModelSpy.mock.calls[0]?.[1] as - | { openaiWireFormat?: string } - | undefined; - expect(toolConfig?.openaiWireFormat).toBe("responses"); - }); - - it("keeps unmappable cross-typed Coder overrides gateway-scoped", async () => { - // {name: "anthropic", type: "openai-compat"}: the instance is KNOWN but - // has no catalog identity (openai-compat fronts an arbitrary upstream). - // The override identity must stay coder-scoped — falling back to the - // name-canonical anthropic: would apply the anthropic block's - // wildcard/model settings to an OpenAI-chat request and merge - // Anthropic-shaped extras into the OpenAI SDK namespace. - using xumHome = new DisposableTempDir("ai-service-main-coder-unmappable-overrides"); - const workspaceId = "workspace-main-coder-unmappable-overrides"; - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - const modelString = "coder:anthropic/gpt-5"; - - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { useRequestedModelString: true }); - harness.config.saveProvidersConfig({ - anthropic: { modelParameters: { "*": { anthropicKnob: "yes" } } }, - coder: { modelParameters: { "*": { coderKnob: "yes" } } }, - }); - - const providerModelFactory = Reflect.get(harness.service, "providerModelFactory") as - | ProviderModelFactory - | undefined; - if (!providerModelFactory) { - throw new Error("Expected AIService.providerModelFactory in unmappable-overrides test"); - } - spyOn(providerModelFactory, "resolveAndCreateModel").mockResolvedValue({ - success: true, - data: { - model: Object.create(null) as LanguageModel, - effectiveModelString: modelString, - // Name-based canonicalization rewrites the canonical-route name even - // though the instance type is openai-compat. - canonicalModelString: "anthropic:gpt-5", - canonicalProviderName: "anthropic", - canonicalModelId: "gpt-5", - wireProviderName: "openai", - coderWire: { origin: "openai" as const, modelId: "gpt-5", providerType: "openai-compat" }, - routedThroughGateway: false, - routeProvider: "coder", - }, - }); - const providerService = Reflect.get(harness.service, "providerService") as - | ProviderService - | undefined; - if (!providerService) { - throw new Error("Expected AIService.providerService in unmappable-overrides test"); - } - spyOn(providerService, "getConfig").mockReturnValue({ - coder: { - apiKeySet: false, - isEnabled: true, - isConfigured: true, - discoveredProviders: [{ name: "anthropic", type: "openai-compat" }], - }, - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "fix the issue")], - workspaceId, - modelString, - thinkingLevel: "off", - }); - expect(result.success).toBe(true); - - // The anthropic block's extras never reach the OpenAI wire namespace; - // the coder block's own extras (explicit config for this gateway model) do. - const openaiOptions = openAIOptionsFromStartStreamCall(harness.startStreamCalls[0]); - expect(openaiOptions).not.toHaveProperty("anthropicKnob"); - expect(openaiOptions.coderKnob).toBe("yes"); - }); - - it("drops reasoning-only continuations before adding interrupted sentinels for non-Anthropic fallbacks", () => { - const continuationAssistant: MuxMessage = { - id: "assistant-reasoning-only", - role: "assistant", - metadata: { partial: true, historySequence: 2 }, - parts: [{ type: "reasoning", text: "internal scratchpad" }], - }; - - const { providerRequestMessages } = prepareProviderRequestMessages( - [createMuxMessage("latest-user", "user", "fix the issue"), continuationAssistant], - "openai", - "off" - ); - const messagesWithSentinel = addInterruptedSentinel(providerRequestMessages); - - expect(messagesWithSentinel.map((message) => message.id)).toEqual(["latest-user"]); - }); - - it("keeps reasoning-only continuations and sentinels for Anthropic thinking fallbacks", () => { - const continuationAssistant: MuxMessage = { - id: "assistant-reasoning-only", - role: "assistant", - metadata: { partial: true, historySequence: 2 }, - parts: [ - { - type: "reasoning", - text: "signed thinking", - }, - ], - }; - - const { providerRequestMessages } = prepareProviderRequestMessages( - [createMuxMessage("latest-user", "user", "fix the issue"), continuationAssistant], - "anthropic", - "medium" - ); - const messagesWithSentinel = addInterruptedSentinel(providerRequestMessages); - - expect(messagesWithSentinel.map((message) => message.id)).toEqual([ - "latest-user", - "assistant-reasoning-only", - "interrupted-assistant-reasoning-only", - ]); - }); - - it("emits startup breadcrumbs as runtime-status events before stream start", async () => { - using xumHome = new DisposableTempDir("ai-service-startup-breadcrumbs"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-startup-breadcrumbs"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - const runtimeStatusEvents: RuntimeStatusEvent[] = []; - - harness.service.on("runtime-status", (event) => { - runtimeStatusEvents.push(event as RuntimeStatusEvent); - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "hello")], - workspaceId, - modelString: "openai:gpt-5.2", - thinkingLevel: "off", - }); - - expect(result.success).toBe(true); - expect( - runtimeStatusEvents.map((event) => ({ - phase: event.phase, - detail: event.detail, - runtimeType: event.runtimeType, - })) - ).toEqual([ - { - phase: "waiting", - detail: "Waiting for workspace initialization...", - runtimeType: "local", - }, - { - phase: "starting", - detail: "Checking workspace runtime...", - runtimeType: "local", - }, - { - phase: "checking", - detail: "Checking repository...", - runtimeType: "local", - }, - { - phase: "ready", - detail: undefined, - runtimeType: "local", - }, - { - phase: "starting", - detail: "Loading workspace context...", - runtimeType: "local", - }, - { - phase: "starting", - detail: "Loading tools...", - runtimeType: "local", - }, - { - phase: "starting", - detail: "Preparing model request...", - runtimeType: "local", - }, - { - phase: "starting", - detail: "Starting model stream...", - runtimeType: "local", - }, - ]); - }); - - it("reuses the pre-policy stream system context when advisor availability is unchanged", async () => { - using xumHome = new DisposableTempDir("ai-service-reuse-system-context"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-reuse-system-context"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "hello")], - workspaceId, - modelString: "openai:gpt-5.2", - thinkingLevel: "off", - }); - - expect(result.success).toBe(true); - expect(harness.streamSystemContextAdvisorFlags).toEqual([false]); - expect(harness.startStreamCalls[0]?.providedRuntimeTempDir).toBe( - path.join(metadata.projectPath, ".tmp-stream") - ); - }); - - it("rebuilds the stream system context when policy removes advisor guidance", async () => { - using xumHome = new DisposableTempDir("ai-service-rebuild-system-context-advisor"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-rebuild-system-context-advisor"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- stub for advisor availability gating - const stubTool: Tool = {} as never; - const harness = createHarness(xumHome.path, metadata, { - allTools: { advisor: stubTool }, - postPolicyTools: {}, - }); - await harness.config.editConfig((cfg) => { - cfg.advisorModelString = KNOWN_MODELS.SONNET.id; - return cfg; - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "hello")], - workspaceId, - modelString: "openai:gpt-5.2", - thinkingLevel: "off", - experiments: { advisorTool: true }, - }); - - expect(result.success).toBe(true); - expect(harness.streamSystemContextAdvisorFlags).toEqual([true, false]); - }); - - it("rebuilds the stream system context without memory availability when policy strips the memory tool", async () => { - using xumHome = new DisposableTempDir("ai-service-rebuild-system-context-memory"); + it("rebuilds the stream system context without memory availability when policy strips the memory tool", async () => { + using xumHome = new DisposableTempDir("ai-service-rebuild-system-context-memory"); const projectPath = path.join(xumHome.path, "project"); await fs.mkdir(projectPath, { recursive: true }); @@ -2442,8 +1547,9 @@ describe("AIService.streamMessage compaction boundary slicing", () => { // description). postPolicyTools: {}, }); - harness.service.setMemoryService( - new MemoryService(harness.config, new MemoryMetaService(xumHome.path)) + harness.service.turnRequestBuilderBindings.memoryService = new MemoryService( + harness.config, + new MemoryMetaService(xumHome.path) ); const memoryCalls: Array<{ includeHotMemories: boolean }> = []; @@ -2476,8 +1582,9 @@ describe("AIService.streamMessage compaction boundary slicing", () => { const harness = createHarness(xumHome.path, metadata, { allTools: { memory: stubTool }, }); - harness.service.setMemoryService( - new MemoryService(harness.config, new MemoryMetaService(xumHome.path)) + harness.service.turnRequestBuilderBindings.memoryService = new MemoryService( + harness.config, + new MemoryMetaService(xumHome.path) ); const memoryCalls: Array<{ includeHotMemories: boolean }> = []; @@ -2528,7 +1635,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { ); const { config, service } = createBasicAIService(xumHome.path, { experimentsService }); const memoryService = new MemoryService(config, new MemoryMetaService(xumHome.path)); - service.setMemoryService(memoryService); + service.turnRequestBuilderBindings.memoryService = memoryService; const workspaceId = "workspace-memory-session-context"; // namedWorkspacePath is the persisted checkout root consumed by @@ -2587,7 +1694,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { ); const { config, service } = createBasicAIService(xumHome.path, { experimentsService }); const memoryService = new MemoryService(config, new MemoryMetaService(xumHome.path)); - service.setMemoryService(memoryService); + service.turnRequestBuilderBindings.memoryService = memoryService; const workspaceId = "workspace-memory-hot-failure"; const metadata: WorkspaceMetadata & { namedWorkspacePath: string } = { @@ -2660,128 +1767,6 @@ describe("AIService.streamMessage compaction boundary slicing", () => { }); }); - it("keeps legacy system workspaces on the global mux tool scope", async () => { - using xumHome = new DisposableTempDir("ai-service-system-tool-scope"); - const projectPath = path.join(xumHome.path, "legacy-system-project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-system-tool-scope"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - await harness.config.editConfig((cfg) => { - cfg.projects.set(projectPath, { workspaces: [], projectKind: "system" }); - return cfg; - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "hello")], - workspaceId, - modelString: "openai:gpt-5.2", - thinkingLevel: "off", - }); - - expect(result.success).toBe(true); - expect(harness.streamSystemContextMuxScopes.at(-1)).toEqual({ - type: "global", - xumHome: xumHome.path, - }); - }); - - it("keeps _multi workspaces on the project mux tool scope", async () => { - using xumHome = new DisposableTempDir("ai-service-multi-project-tool-scope"); - const workspaceId = "workspace-multi-project-tool-scope"; - const metadata = createLocalWorkspaceMetadata(workspaceId, MULTI_PROJECT_CONFIG_KEY); - const harness = createHarness(xumHome.path, metadata); - await harness.config.editConfig((cfg) => { - cfg.projects.set(MULTI_PROJECT_CONFIG_KEY, { workspaces: [], projectKind: "system" }); - return cfg; - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "hello")], - workspaceId, - modelString: "openai:gpt-5.2", - thinkingLevel: "off", - }); - - expect(result.success).toBe(true); - expect(harness.streamSystemContextMuxScopes.at(-1)).toEqual({ - type: "project", - xumHome: xumHome.path, - projectRoot: MULTI_PROJECT_CONFIG_KEY, - projectStorageAuthority: "host-local", - checkoutRoot: MULTI_PROJECT_CONFIG_KEY, - }); - }); - - it("uses the latest durable boundary slice for provider payload and OpenAI derivations", async () => { - using xumHome = new DisposableTempDir("ai-service-slice-latest-boundary"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-slice-latest"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - - const messages: MuxMessage[] = [ - createMuxMessage("boundary-1", "assistant", "compaction epoch 1", { - compacted: "user", - compactionBoundary: true, - compactionEpoch: 1, - model: "openai:gpt-5.2", - }), - createMuxMessage("assistant-old-response", "assistant", "older response", { - model: "openai:gpt-5.2", - providerMetadata: { openai: { responseId: "resp_epoch_1" } }, - }), - createMuxMessage( - "start-here-summary", - "assistant", - "# Start Here\n\n- Existing plan context\n\n*Plan file preserved at:* /tmp/plan.md", - { - compacted: "user", - agentId: "plan", - } - ), - createMuxMessage("mid-user", "user", "mid conversation"), - createMuxMessage("boundary-2", "assistant", "compaction epoch 2", { - compacted: "user", - compactionBoundary: true, - compactionEpoch: 2, - model: "openai:gpt-5.2", - }), - createMuxMessage("latest-user", "user", "continue", { historySequence: 42 }), - ]; - - const result = await harness.service.streamMessage({ - messages, - workspaceId, - modelString: "openai:gpt-5.2", - thinkingLevel: "medium", - }); - - expect(result.success).toBe(true); - expect(harness.planPayloadMessageIds).toEqual([["boundary-2", "latest-user"]]); - expect(harness.preparedPayloadMessageIds).toEqual([["boundary-2", "latest-user"]]); - expect(harness.startStreamCalls).toHaveLength(1); - - const startStreamCall = harness.startStreamCalls[0]; - expect(startStreamCall).toBeDefined(); - if (!startStreamCall) { - throw new Error("Expected streamManager.startStream call arguments"); - } - - const startStreamMessageIds = messageIdsFromUnknownArray(startStreamCall.messages); - expect(startStreamMessageIds).toEqual(["boundary-2", "latest-user"]); - expect(initialMetadataFromStartStreamCall(startStreamCall).requestHistorySequence).toBe(42); - - const openaiOptions = openAIOptionsFromStartStreamCall(startStreamCall); - expect(openaiOptions.previousResponseId).toBeUndefined(); - expect(openaiOptions.promptCacheKey).toBe( - `mux-v1-project-under-test-${uniqueSuffix([projectPath])}` - ); - }); - it("passes the resolved routeProvider into initial stream metadata", async () => { using xumHome = new DisposableTempDir("ai-service-route-provider-present"); const projectPath = path.join(xumHome.path, "project"); @@ -2880,104 +1865,6 @@ describe("AIService.streamMessage compaction boundary slicing", () => { expect(Object.prototype.hasOwnProperty.call(initialMetadata, "routeProvider")).toBe(false); }); - it("derives sentinel tool names from assembled post-policy tools", async () => { - using xumHome = new DisposableTempDir("ai-service-sentinel-tool-names"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-sentinel-tools"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- stub for tool-name extraction test - const stubTool: Tool = {} as never; - const finalTools: Record = { - bash: stubTool, - my_mcp_tool: stubTool, - }; - const allTools: Record = { - web_search: stubTool, - my_mcp_tool: stubTool, - bash: stubTool, - }; - const harness = createHarness(xumHome.path, metadata, { - allTools, - postPolicyTools: finalTools, - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "continue")], - workspaceId, - modelString: "openai:gpt-5.2", - thinkingLevel: "off", - muxProviderOptions: { - openai: { wireFormat: "chatCompletions" }, - }, - }); - - expect(result.success).toBe(true); - expect(harness.preparedToolNamesForSentinel).toEqual([["bash", "my_mcp_tool"]]); - expect(harness.preparedToolNamesForSentinel[0]).not.toContain("web_search"); - }); - - it("falls back safely when boundary metadata is malformed", async () => { - using xumHome = new DisposableTempDir("ai-service-slice-malformed-boundary"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-slice-malformed"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - - const messages: MuxMessage[] = [ - createMuxMessage("assistant-before-malformed", "assistant", "response before malformed", { - model: "openai:gpt-5.2", - providerMetadata: { openai: { responseId: "resp_before_malformed" } }, - }), - createMuxMessage("malformed-boundary", "assistant", "not a durable boundary", { - compacted: "user", - compactionBoundary: true, - // Invalid durable marker: must not truncate request payload. - compactionEpoch: 0, - model: "openai:gpt-5.2", - }), - createMuxMessage("latest-user", "user", "continue"), - ]; - - const result = await harness.service.streamMessage({ - messages, - workspaceId, - modelString: "openai:gpt-5.2", - thinkingLevel: "medium", - }); - - expect(result.success).toBe(true); - expect(harness.planPayloadMessageIds).toEqual([ - ["assistant-before-malformed", "malformed-boundary", "latest-user"], - ]); - expect(harness.preparedPayloadMessageIds).toEqual([ - ["assistant-before-malformed", "malformed-boundary", "latest-user"], - ]); - expect(harness.startStreamCalls).toHaveLength(1); - - const startStreamCall = harness.startStreamCalls[0]; - expect(startStreamCall).toBeDefined(); - if (!startStreamCall) { - throw new Error("Expected streamManager.startStream call arguments"); - } - - const startStreamMessageIds = messageIdsFromUnknownArray(startStreamCall.messages); - expect(startStreamMessageIds).toEqual([ - "assistant-before-malformed", - "malformed-boundary", - "latest-user", - ]); - - const openaiOptions = openAIOptionsFromStartStreamCall(startStreamCall); - expect(openaiOptions.previousResponseId).toBeUndefined(); - expect(openaiOptions.promptCacheKey).toBe( - `mux-v1-project-under-test-${uniqueSuffix([projectPath])}` - ); - }); - it("freezes advisor tool-call snapshots at the tool-call boundary", async () => { using xumHome = new DisposableTempDir("ai-service-advisor-step-snapshot-boundary"); const projectPath = path.join(xumHome.path, "project"); @@ -3442,161 +2329,9 @@ describe("AIService.streamMessage compaction boundary slicing", () => { error: recordUsageError, workspaceId, toolName: "advisor", - model: event.model, - }) - ); - }); - - describe("mid-turn thinking override rebuild closure", () => { - function getThinkingOverrideStartStreamArgs(harness: StreamMessageHarness): { - holder: unknown; - rebuild: RebuildProviderOptionsForThinkingLevel; - } { - expect(harness.startStreamCalls).toHaveLength(1); - const call = harness.startStreamCalls[0]; - if (!call) { - throw new Error("Expected streamManager.startStream call arguments"); - } - const holder = call.thinkingOverrideState; - const rebuild = call.rebuildProviderOptionsForThinkingLevel; - expect(typeof rebuild).toBe("function"); - return { holder, rebuild: rebuild! }; - } - - it("threads the session holder by reference and rebuilds options through the same pipeline", async () => { - using xumHome = new DisposableTempDir("ai-service-thinking-override"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-thinking-override"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { - useRequestedModelString: true, - canonicalProviderName: "anthropic", - }); - - const sessionHolder: ActiveTurnThinkingOverride = {}; - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "hello")], - workspaceId, - // Budget-token Anthropic model (no adaptive effort): level changes show - // up as thinking.budgetTokens differences. - modelString: "anthropic:claude-sonnet-4-5", - thinkingLevel: "low", - minThinkingLevel: "off", - activeTurnThinkingOverride: sessionHolder, - }); - expect(result.success).toBe(true); - - const { holder, rebuild } = getThinkingOverrideStartStreamArgs(harness); - // Same object: AgentSession's setter writes must be visible to prepareStep. - expect(holder).toBe(sessionHolder); - - // No-op: requested level equals the current effective level. - expect(rebuild("low")).toBeNull(); - - // Real transition: rebuilt provider options reflect the new level. - const rebuilt = rebuild("high"); - expect(rebuilt?.effectiveLevel).toBe("high"); - const anthropic = rebuilt?.providerOptions.anthropic as - | { thinking?: { type: string; budgetTokens?: number } } - | undefined; - expect(anthropic?.thinking).toEqual({ type: "enabled", budgetTokens: 20000 }); - - // The closure diffs against the LIVE level, not the send-time one: - // repeating the applied level is now a no-op. - expect(rebuild("high")).toBeNull(); - }); - - it("clamps mid-turn requests against the session-provided floor", async () => { - using xumHome = new DisposableTempDir("ai-service-thinking-floor"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-thinking-floor"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { - useRequestedModelString: true, - canonicalProviderName: "anthropic", - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "hello")], - workspaceId, - modelString: KNOWN_MODELS.SONNET.id, - thinkingLevel: "medium", - minThinkingLevel: "medium", - activeTurnThinkingOverride: {}, - }); - expect(result.success).toBe(true); - - const { rebuild } = getThinkingOverrideStartStreamArgs(harness); - // Below-floor requests clamp up to the floor, which equals the current - // level here — so they must be treated as no-ops, not as downgrades. - expect(rebuild("off")).toBeNull(); - expect(rebuild("low")).toBeNull(); - // Above-floor requests still apply. - expect(rebuild("high")?.effectiveLevel).toBe("high"); - }); - - it("applies Anthropic native-xhigh transitions as plain provider-option rebuilds", async () => { - using xumHome = new DisposableTempDir("ai-service-thinking-xhigh"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-thinking-xhigh"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { - useRequestedModelString: true, - canonicalProviderName: "anthropic", - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "hello")], - workspaceId, - modelString: "anthropic:claude-opus-4-7", - thinkingLevel: "high", - activeTurnThinkingOverride: {}, - }); - expect(result.success).toBe(true); - - const { rebuild } = getThinkingOverrideStartStreamArgs(harness); - const rebuilt = rebuild("xhigh"); - expect(rebuilt?.effectiveLevel).toBe("xhigh"); - const anthropic = rebuilt?.providerOptions.anthropic as - | { effort?: string; thinking?: unknown } - | undefined; - // Post-wire-hack: the native effort flows directly via provider options. - expect(anthropic?.effort).toBe("xhigh"); - expect(anthropic?.thinking).toEqual({ type: "adaptive", display: "summarized" }); - }); - - it("skips the grok-4-1-fast off<->on transition (model-instance swap)", async () => { - using xumHome = new DisposableTempDir("ai-service-thinking-grok"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-thinking-grok"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { - useRequestedModelString: true, - canonicalProviderName: "xai" as ProviderName, - }); - - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("latest-user", "user", "hello")], - workspaceId, - modelString: "xai:grok-4-1-fast", - thinkingLevel: "off", - activeTurnThinkingOverride: {}, - }); - expect(result.success).toBe(true); - - const { rebuild } = getThinkingOverrideStartStreamArgs(harness); - // off -> high selects a different model instance at creation time; the - // in-flight stream cannot express it via provider options. - expect(rebuild("high")).toBeNull(); - }); + model: event.model, + }) + ); }); }); @@ -3779,542 +2514,6 @@ describe("AIService.streamMessage multi-project trust gating", () => { }); }); -describe("AIService.streamMessage model parameter overrides", () => { - const ANTHROPIC_MODEL = "anthropic:claude-sonnet-4-5"; - - interface ModelParameterOverridesHarness { - service: AIService; - config: Config; - startStreamCalls: TurnExecutionOptions[]; - } - - function providerOptionsFromStartStreamCall( - startStreamArgs: TurnExecutionOptions - ): Record { - const providerOptions = startStreamArgs.providerOptions; - if (!providerOptions || typeof providerOptions !== "object" || Array.isArray(providerOptions)) { - throw new Error("Expected provider options object at startStream arg index 11"); - } - - return providerOptions; - } - - function callSettingsOverridesFromStartStreamCall( - startStreamArgs: TurnExecutionOptions - ): Record { - const callSettingsOverrides = startStreamArgs.callSettingsOverrides; - if ( - !callSettingsOverrides || - typeof callSettingsOverrides !== "object" || - Array.isArray(callSettingsOverrides) - ) { - throw new Error("Expected call settings overrides object at startStream arg index 21"); - } - - return callSettingsOverrides as Record; - } - - function createHarness( - xumHomePath: string, - metadata: WorkspaceMetadata, - options?: { routeProvider?: ProviderName } - ): ModelParameterOverridesHarness { - const { config, historyService, initStateManager, service } = createBasicAIService(xumHomePath); - const startStreamCalls: TurnExecutionOptions[] = []; - stubCommonStreamMessageDependencies({ - service, - config, - historyService, - initStateManager, - metadata, - startStreamCalls, - routeProvider: options?.routeProvider, - historySequence: 9, - effectiveModelString: ANTHROPIC_MODEL, - canonicalProviderName: "anthropic", - canonicalModelId: "claude-sonnet-4-5", - }); - return { service, config, startStreamCalls }; - } - - async function streamAndGetStartStreamArgs( - harness: ModelParameterOverridesHarness, - workspaceId: string, - modelString = ANTHROPIC_MODEL - ): Promise { - const result = await harness.service.streamMessage({ - messages: [createMuxMessage("user-message", "user", "hello")], - workspaceId, - modelString, - thinkingLevel: "off", - }); - - expect(result.success).toBe(true); - expect(harness.startStreamCalls).toHaveLength(1); - - const startStreamCall = harness.startStreamCalls[0]; - if (!startStreamCall) { - throw new Error("Expected streamManager.startStream call arguments"); - } - - return startStreamCall; - } - - afterEach(() => { - mock.restore(); - }); - - it("passes resolved call settings overrides as the final startStream argument", async () => { - using xumHome = new DisposableTempDir("ai-service-model-overrides-standard"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-model-overrides-standard"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - - spyOn(harness.config, "loadProvidersConfig").mockReturnValue({ - anthropic: { - modelParameters: { - "claude-sonnet-4-5": { - max_output_tokens: 16384, - temperature: 0.7, - }, - }, - }, - }); - - const startStreamArgs = await streamAndGetStartStreamArgs(harness, workspaceId); - expect(callSettingsOverridesFromStartStreamCall(startStreamArgs)).toEqual({ - maxOutputTokens: 16384, - temperature: 0.7, - }); - }); - - it("deep-merges provider extras under Xum-built provider options", async () => { - using xumHome = new DisposableTempDir("ai-service-model-overrides-provider-extras"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-model-overrides-provider-extras"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - - spyOn(harness.config, "loadProvidersConfig").mockReturnValue({ - anthropic: { - modelParameters: { - "*": { - custom_knob: 40, - }, - }, - }, - }); - - spyOn(providerOptionsModule, "buildProviderOptions").mockReturnValue({ - anthropic: { - thinking: { type: "enabled" }, - }, - }); - - const startStreamArgs = await streamAndGetStartStreamArgs(harness, workspaceId); - expect(providerOptionsFromStartStreamCall(startStreamArgs)).toEqual({ - anthropic: { - custom_knob: 40, - thinking: { type: "enabled" }, - }, - }); - }); - - it("merges routed OpenAI provider extras under the active route namespace", async () => { - using xumHome = new DisposableTempDir("ai-service-model-overrides-routed-openai"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-model-overrides-routed-openai"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { routeProvider: "openrouter" }); - - const providerModelFactory = Reflect.get( - harness.service, - "providerModelFactory" - ) as ProviderModelFactory; - const fakeModel = Object.create(null) as LanguageModel; - spyOn(providerModelFactory, "resolveAndCreateModel").mockResolvedValue({ - success: true, - data: { - model: fakeModel, - effectiveModelString: "openrouter:openai/gpt-5.2", - canonicalModelString: "openai:gpt-5.2", - canonicalProviderName: "openai", - canonicalModelId: "gpt-5.2", - wireProviderName: "openai", - routedThroughGateway: false, - routeProvider: "openrouter", - }, - }); - - spyOn(harness.config, "loadProvidersConfig").mockReturnValue({ - openai: { - modelParameters: { - "*": { - reasoning: { max_tokens: 4096 }, - }, - }, - }, - }); - - spyOn(providerOptionsModule, "buildProviderOptions").mockReturnValue({ - openrouter: { - reasoning: { - enabled: true, - effort: "medium", - exclude: false, - }, - }, - }); - - const startStreamArgs = await streamAndGetStartStreamArgs( - harness, - workspaceId, - "openai:gpt-5.2" - ); - expect(providerOptionsFromStartStreamCall(startStreamArgs)).toEqual({ - openrouter: { - reasoning: { - max_tokens: 4096, - enabled: true, - effort: "medium", - exclude: false, - }, - }, - }); - }); - - it("keeps type-derived standard settings but drops wire-mismatched extras for Coder instances", async () => { - // coder:google/ resolves overrides from the google block (instance - // TYPE), but the request speaks OpenAI-chat on the wire: standard call - // settings are SDK-agnostic and must apply, while Google-SDK-shaped - // extras must NOT merge into the OpenAI namespace. - using xumHome = new DisposableTempDir("ai-service-model-overrides-coder-wire-mismatch"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-model-overrides-coder-wire-mismatch"; - const modelString = "coder:google/gemini-3-pro"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata, { routeProvider: "coder" }); - - const providerModelFactory = Reflect.get( - harness.service, - "providerModelFactory" - ) as ProviderModelFactory; - const fakeModel = Object.create(null) as LanguageModel; - spyOn(providerModelFactory, "resolveAndCreateModel").mockResolvedValue({ - success: true, - data: { - model: fakeModel, - effectiveModelString: modelString, - canonicalModelString: modelString, - canonicalProviderName: "coder", - canonicalModelId: "google/gemini-3-pro", - wireProviderName: "openai", - routedThroughGateway: false, - routeProvider: "coder", - }, - }); - - const providersConfig = { - google: { - apiKeySet: true, - isEnabled: true, - isConfigured: true, - modelParameters: { - "*": { - max_output_tokens: 2048, - googleRoutingHint: { region: "us" }, - }, - }, - }, - coder: { - apiKeySet: false, - isEnabled: true, - isConfigured: true, - discoveredProviders: [{ name: "google", type: "google" }], - }, - }; - spyOn(harness.config, "loadProvidersConfig").mockReturnValue(providersConfig); - const providerService = Reflect.get(harness.service, "providerService") as ProviderService; - spyOn(providerService, "getConfig").mockReturnValue(providersConfig); - - spyOn(providerOptionsModule, "buildProviderOptions").mockReturnValue({ - openai: { reasoningEffort: "low" }, - }); - - const startStreamArgs = await streamAndGetStartStreamArgs(harness, workspaceId, modelString); - // Standard settings from the google block still apply (SDK-agnostic). - expect(callSettingsOverridesFromStartStreamCall(startStreamArgs)).toEqual({ - maxOutputTokens: 2048, - }); - // The Google-shaped extra did not leak into the OpenAI wire namespace. - expect(providerOptionsFromStartStreamCall(startStreamArgs)).toEqual({ - openai: { reasoningEffort: "low" }, - }); - }); - - it("passes empty call settings overrides when providers config is empty", async () => { - using xumHome = new DisposableTempDir("ai-service-model-overrides-empty"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-model-overrides-empty"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - - spyOn(harness.config, "loadProvidersConfig").mockReturnValue({}); - - const startStreamArgs = await streamAndGetStartStreamArgs(harness, workspaceId); - expect(startStreamArgs.callSettingsOverrides).toEqual({}); - }); - - it("preserves Xum-built provider options when provider extras conflict", async () => { - using xumHome = new DisposableTempDir("ai-service-model-overrides-conflict"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-model-overrides-conflict"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - - spyOn(harness.config, "loadProvidersConfig").mockReturnValue({ - anthropic: { - modelParameters: { - "*": { - thinking: { type: "disabled" }, - custom_knob: 10, - }, - }, - }, - }); - - spyOn(providerOptionsModule, "buildProviderOptions").mockReturnValue({ - anthropic: { - thinking: { type: "enabled" }, - sendReasoning: true, - }, - }); - - const startStreamArgs = await streamAndGetStartStreamArgs(harness, workspaceId); - expect(providerOptionsFromStartStreamCall(startStreamArgs)).toEqual({ - anthropic: { - custom_knob: 10, - thinking: { type: "enabled" }, - sendReasoning: true, - }, - }); - }); - - it("deep-merges nested provider extras with Xum-built options", async () => { - using xumHome = new DisposableTempDir("ai-service-model-overrides-nested"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-model-overrides-nested"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - - // Override to OpenRouter provider - const providerModelFactory = Reflect.get( - harness.service, - "providerModelFactory" - ) as ProviderModelFactory; - const fakeModel = Object.create(null) as LanguageModel; - spyOn(providerModelFactory, "resolveAndCreateModel").mockResolvedValue({ - success: true, - data: { - model: fakeModel, - effectiveModelString: "openrouter:deepseek/deepseek-r1", - canonicalModelString: "openrouter:deepseek/deepseek-r1", - canonicalProviderName: "openrouter", - canonicalModelId: "deepseek/deepseek-r1", - wireProviderName: "openrouter", - routedThroughGateway: false, - }, - }); - - spyOn(harness.config, "loadProvidersConfig").mockReturnValue({ - openrouter: { - modelParameters: { - "*": { - reasoning: { max_tokens: 4096 }, - }, - }, - }, - }); - - spyOn(providerOptionsModule, "buildProviderOptions").mockReturnValue({ - openrouter: { - reasoning: { - enabled: true, - effort: "high", - exclude: false, - }, - }, - }); - - const startStreamArgs = await streamAndGetStartStreamArgs( - harness, - workspaceId, - "openrouter:deepseek/deepseek-r1" - ); - expect(providerOptionsFromStartStreamCall(startStreamArgs)).toEqual({ - openrouter: { - reasoning: { - max_tokens: 4096, - enabled: true, - effort: "high", - exclude: false, - }, - }, - }); - }); - - it("Xum values win on nested leaf conflicts during deep merge", async () => { - using xumHome = new DisposableTempDir("ai-service-model-overrides-nested-conflict"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-model-overrides-nested-conflict"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const harness = createHarness(xumHome.path, metadata); - - // Override to OpenRouter provider - const providerModelFactory = Reflect.get( - harness.service, - "providerModelFactory" - ) as ProviderModelFactory; - const fakeModel = Object.create(null) as LanguageModel; - spyOn(providerModelFactory, "resolveAndCreateModel").mockResolvedValue({ - success: true, - data: { - model: fakeModel, - effectiveModelString: "openrouter:deepseek/deepseek-r1", - canonicalModelString: "openrouter:deepseek/deepseek-r1", - canonicalProviderName: "openrouter", - canonicalModelId: "deepseek/deepseek-r1", - wireProviderName: "openrouter", - routedThroughGateway: false, - }, - }); - - spyOn(harness.config, "loadProvidersConfig").mockReturnValue({ - openrouter: { - modelParameters: { - "*": { - reasoning: { enabled: false, max_tokens: 4096 }, - }, - }, - }, - }); - - spyOn(providerOptionsModule, "buildProviderOptions").mockReturnValue({ - openrouter: { - reasoning: { - enabled: true, - effort: "high", - exclude: false, - }, - }, - }); - - const startStreamArgs = await streamAndGetStartStreamArgs( - harness, - workspaceId, - "openrouter:deepseek/deepseek-r1" - ); - expect(providerOptionsFromStartStreamCall(startStreamArgs)).toEqual({ - openrouter: { - reasoning: { - max_tokens: 4096, - enabled: true, - effort: "high", - exclude: false, - }, - }, - }); - }); - - it("builds options for the effective route when a Coder selection falls away to a passthrough gateway", async () => { - using xumHome = new DisposableTempDir("ai-service-coder-fallback-options"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-coder-fallback-options"; - const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); - const { config, historyService, initStateManager, service } = createBasicAIService( - xumHome.path - ); - const startStreamCalls: TurnExecutionOptions[] = []; - stubCommonStreamMessageDependencies({ - service, - config, - historyService, - initStateManager, - metadata, - startStreamCalls, - }); - // The google-typed instance metadata is present: resolving the RAW - // coder: selection against it yields the gateway's OpenAI-chat wire — - // but this request FELL AWAY to the passthrough mux-gateway, which - // forwards native Google bytes, so the options must use the google - // namespace (thinkingConfig), not OpenAI reasoning options. - spyOn(config, "loadProvidersConfig").mockReturnValue({ - coder: { - deploymentUrl: "https://coder.example.com", - discoveredProviders: [{ name: "google", type: "google" }], - }, - } as ReturnType); - const providerModelFactory = Reflect.get(service, "providerModelFactory") as - | ProviderModelFactory - | undefined; - if (!providerModelFactory) { - throw new Error("Expected AIService.providerModelFactory in test harness"); - } - spyOn(providerModelFactory, "resolveAndCreateModel").mockImplementation(() => - Promise.resolve({ - success: true, - data: { - model: Object.create(null) as LanguageModel, - effectiveModelString: "mux-gateway:google/gemini-2.5-pro", - canonicalModelString: "coder:google/gemini-2.5-pro", - canonicalProviderName: "coder" as ProviderName, - canonicalModelId: "google/gemini-2.5-pro", - wireProviderName: "google" as ProviderName, - coderSelectedInstance: { name: "google", type: "google" }, - routedThroughGateway: true, - routeProvider: "mux-gateway" as ProviderName, - }, - }) - ); - - const result = await service.streamMessage({ - messages: [createMuxMessage("user-message", "user", "hello")], - workspaceId, - modelString: "coder:google/gemini-2.5-pro", - thinkingLevel: "medium", - }); - expect(result.success).toBe(true); - expect(startStreamCalls).toHaveLength(1); - const startStreamCall = startStreamCalls[0]; - if (!startStreamCall) { - throw new Error("Expected streamManager.startStream call arguments"); - } - const providerOptions = providerOptionsFromStartStreamCall(startStreamCall); - expect(providerOptions.google).toBeDefined(); - expect(providerOptions.google).toHaveProperty("thinkingConfig"); - expect(providerOptions.openai).toBeUndefined(); - }); -}); - describe("AIService.streamMessage turn envelope", () => { interface TurnEnvelopeHarness { service: AIService; diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 085560ba66c..8e4081ac412 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1,59 +1,38 @@ -import * as fs from "fs/promises"; import { EventEmitter } from "events"; +import * as fs from "fs/promises"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import assert from "@/common/utils/assert"; import { type LanguageModel, type Tool } from "ai"; -import { projectAutomationDisabled } from "@/node/utils/projectAutomation"; -import { linkAbortSignal } from "@/node/utils/abort"; -import { ensurePrivateDir } from "@/node/utils/fs"; +import type { ProvidersConfigMap } from "@/common/orpc/types"; import type { Result } from "@/common/types/result"; -import { Ok, Err } from "@/common/types/result"; +import { Err, Ok } from "@/common/types/result"; import type { WorkspaceMetadata } from "@/common/types/workspace"; -import type { SendMessageOptions, ProvidersConfigMap } from "@/common/orpc/types"; - -import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; +import { ensurePrivateDir } from "@/node/utils/fs"; import { - ADVISOR_DEFAULT_MAX_USES_PER_TURN, - resolveAdvisorEnabledForAgent, -} from "@/common/constants/advisor"; + TurnRequestBuilder, + type TurnRequestBuilderBindings, + resolveMuxProjectRootForHostFs, + resolveXumToolScope, + type StreamMessageOptions, +} from "./turnRequestBuilder"; +export { prepareProviderRequestMessages, replaceOrAppendMessageById } from "./turnRequestBuilder"; +export type { StreamMessageOptions } from "./turnRequestBuilder"; + import { EXPERIMENT_IDS, type ExperimentId } from "@/common/constants/experiments"; +import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; -import type { GoalRecordV1 } from "@/common/types/goal"; -import type { ModelMessage, MuxMessage, MuxMessageMetadata } from "@/common/types/message"; -import { createMuxMessage } from "@/common/types/message"; -import type { Config } from "@/node/config"; -import { - StreamManager, - type ModelFallbackOptions, - type StreamTextOnChunk, - type TurnCompletion, - type TurnEngineEvent, - type TurnExecutionOptions, - type TurnStreamHandle, -} from "./streamManager"; -import { emitTurnEnvelope } from "./turnEnvelope"; -import { - sharedDurableEventJournal, - type DurableEventJournal, -} from "@/node/utils/journal/durableEventJournal"; -import { runLanguageModelCleanup } from "./languageModelCleanup"; -import type { InitStateManager } from "./initStateManager"; import type { SendMessageError } from "@/common/types/errors"; -import { - deriveToolHookConfig, - getForcedXaiSearchToolNames, - getToolsForModel, - type AdvisorStepCaptureRef, - type MCPPromptRuntime, - type ToolConfiguration, -} from "@/common/utils/tools/tools"; -import { getGoalToolAvailability } from "@/common/utils/tools/toolAvailability"; +import type { MuxProviderOptions } from "@/common/types/providerOptions"; +import { getSrcBaseDir, isSSHRuntime } from "@/common/types/runtime"; +import type { XumToolScope } from "@/common/types/toolScope"; import { cloneToolPreservingDescriptors } from "@/common/utils/tools/cloneToolPreservingDescriptors"; +import type { Config } from "@/node/config"; +import { ContainerManager } from "@/node/multiProject/containerManager"; +import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; +import type { Runtime } from "@/node/runtime/Runtime"; import { createRuntime } from "@/node/runtime/runtimeFactory"; -import { agentPluginHookService } from "@/node/services/agentPlugins/hookService"; -import { resolveAgentPluginsMcpContext } from "@/node/services/agentPlugins/mcpConfig"; import { createRuntimeContextForWorkspace, createRuntimeForWorkspace, @@ -61,337 +40,50 @@ import { resolveWorkspaceRootPath, type WorkspaceRuntimeContext, } from "@/node/runtime/runtimeHelpers"; -import type { Runtime } from "@/node/runtime/Runtime"; -import { getWorkspacePathHintForProject } from "@/node/services/workspaceProjectRepos"; -import { isRlmModeEnabled } from "@/node/services/branchSummary"; -import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; -import { getXumEnv, getRuntimeType } from "@/node/runtime/initHook"; -import { getSrcBaseDir, isSSHRuntime } from "@/common/types/runtime"; -import { ContainerManager } from "@/node/multiProject/containerManager"; -import { secretsToRecord } from "@/common/types/secrets"; -import { mergeMultiProjectSecrets } from "@/node/services/utils/multiProjectSecrets"; -import type { MuxProviderOptions } from "@/common/types/providerOptions"; -import type { XumToolScope } from "@/common/types/toolScope"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { PolicyService } from "@/node/services/policyService"; import type { ProviderService } from "@/node/services/providerService"; -import type { CodexOauthService } from "@/node/services/codexOauthService"; -import type { CoderOauthService } from "@/node/services/coderOauthService"; -import type { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; -import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; -import type { FileState } from "@/node/services/agentSession"; +import { getWorkspacePathHintForProject } from "@/node/services/workspaceProjectRepos"; +import { + sharedDurableEventJournal, + type DurableEventJournal, +} from "@/node/utils/journal/durableEventJournal"; +import type { InitStateManager } from "./initStateManager"; import { log } from "./log"; import { - addInterruptedSentinel, - filterEmptyAssistantMessages, -} from "@/browser/utils/messages/modelMessageTransform"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; + StreamManager, + type TurnCompletion, + type TurnEngineEvent, + type TurnStreamHandle, +} from "./streamManager"; -import type { HistoryService } from "./historyService"; -import { delegatedToolCallManager } from "./delegatedToolCallManager"; -import { createErrorEvent, formatSendMessageError } from "./utils/sendMessageError"; -import { findWorkspaceEntry, resolveWorkspaceModelFallbackChain } from "@/node/services/taskUtils"; -import { createAssistantMessageId } from "./utils/messageIds"; -import type { SessionUsageService } from "./sessionUsageService"; -import { sumUsageHistory, getTotalCost } from "@/common/utils/tokens/usageAggregator"; -import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { normalizeToCanonical } from "@/common/utils/ai/models"; -import { extractChunkDeltaText } from "@/common/utils/ai/streamChunks"; -import { readToolInstructions } from "./systemMessage"; -import { - effectiveAdditionalSystemContext, - mergeAdditionalSystemInstructions, - readAdditionalSystemContext, -} from "./additionalSystemContext"; -import type { TelemetryService } from "@/node/services/telemetryService"; import type { DevToolsService } from "@/node/services/devToolsService"; import type { ExperimentsService } from "@/node/services/experimentsService"; -import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; +import type { TelemetryService } from "@/node/services/telemetryService"; +import { delegatedToolCallManager } from "./delegatedToolCallManager"; +import type { HistoryService } from "./historyService"; +import type { SessionUsageService } from "./sessionUsageService"; -import type { WorkspaceMCPOverrides } from "@/common/types/mcp"; -import type { MCPServerManager, MCPWorkspaceStats } from "@/node/services/mcpServerManager"; -import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; -import type { TaskService } from "@/node/services/taskService"; +import type { ProvidersConfig } from "@/common/config/schemas/providersConfig"; +import { getProjects, isMultiProject } from "@/common/utils/multiProject"; import { resolveMemoryProjectIdentity, - type MemoryService, type MemorySessionContext, } from "@/node/services/memoryService"; import { formatHotMemoriesBlock } from "@/node/services/memoryHotSet"; -import { resolveMemoryAccessPolicy } from "@/node/services/tools/memory"; -import { isExecLikeEditingCapableInResolvedChain } from "@/common/utils/agentTools"; -import { - buildProviderOptions, - buildRequestHeaders, - resolveProviderOptionsNamespaceKey, -} from "@/common/utils/ai/providerOptions"; -import { resolveModelParameterOverrides } from "@/common/utils/ai/modelParameterOverrides"; -import type { ProvidersConfig } from "@/common/config/schemas/providersConfig"; -import { resolveCoderGatewayMetadataModel } from "@/common/utils/providers/coderGatewayMetadata"; -import { - coderGatewayWireProtocol, - resolveCoderWireCanonicalModel, -} from "@/common/constants/coderOAuth"; -import { PROVIDER_DEFINITIONS, type ProviderName } from "@/common/constants/providers"; -import { - customProviderWireOrigin, - isCustomProviderConfig, -} from "@/common/utils/providers/customProviders"; -import { isPlainObject } from "@/common/utils/isPlainObject"; -import { sliceMessagesForProviderFromLatestContextBoundary } from "@/common/utils/messages/compactionBoundary"; -import { excludeKeepRecentTailForCompactionRequest } from "@/common/utils/messages/keepRecentTail"; -import { getProjects, isMultiProject } from "@/common/utils/multiProject"; -import { uniqueSuffix } from "@/common/utils/hasher"; -import { isWorkspaceTrustedForSharedExecution } from "@/node/services/utils/workspaceTrust"; - -import { DEFAULT_GOAL_DEFAULTS, normalizeGoalDefaults } from "@/constants/goals"; -import { mergeGoalDefaults } from "@/common/utils/goals/resolveGoalSetIntent"; -import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; -import { - THINKING_LEVEL_OFF, - type OpenAIReasoningMode, - type ThinkingLevel, -} from "@/common/types/thinking"; -import { - enforceThinkingPolicy, - isXaiGrokFastVariantSwap, - lookupMinThinkingLevelOverride, - resolveEffectiveThinkingLevel, - resolveMinimumThinkingLevel, -} from "@/common/utils/thinking/policy"; -import type { - ActiveTurnThinkingOverride, - RebuildFirstStepForThinkingLevel, - RebuildProviderOptionsForThinkingLevel, -} from "@/node/services/thinkingOverride"; +import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; -import type { ErrorEvent, StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; -import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; -import { - computeActiveToolNames, - prepareToolSearch, - rebuildToolSearchState, - seedToolSearchActivationsFromMessages, - TOOL_SEARCH_TOOL_NAME, - type ToolSearchRuntime, -} from "@/common/utils/tools/toolCatalog"; -import type { PTCEventWithParent } from "@/node/services/tools/code_execution"; -import { MockAiStreamPlayer } from "./mock/mockAiStreamPlayer"; -import { DEVTOOLS_RUN_METADATA_ID_HEADER } from "./devToolsHeaderCapture"; -import { ProviderModelFactory, modelCostsIncluded } from "./providerModelFactory"; -import { prepareMessagesForProvider } from "./messagePipeline"; -import { getLegacyModeForAgentMetadata, resolveAgentForStream } from "./agentResolution"; -import { buildPlanInstructions, buildStreamSystemContext } from "./streamContextBuilder"; -import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; -import { - normalizeUsageModelKey, - resolveModelForMetadata, -} from "@/common/utils/providers/modelEntries"; -import { - simulateContextLimitError, - simulateToolPolicyNoop, - type SimulationContext, -} from "./streamSimulation"; -import { - applyToolPolicyAndExperiments, - captureMcpToolTelemetry, - resolveBackendGatedPtcExperiments, -} from "./toolAssembly"; -import { createKernelFileLoader } from "@/node/services/tools/kernelFileLoad"; -import { eventSpine, type RequestAssembleContext } from "@/node/services/events/eventSpine"; +import type { StreamAbortReason } from "@/common/types/stream"; import { getErrorMessage } from "@/common/utils/errors"; import { validateJsonSchemaSubsetSchema } from "@/common/utils/jsonSchemaSubset"; -import { isTerminalWorkflowRunStatus } from "@/common/types/workflow"; -import { - WORKFLOW_RESULT_METADATA_TYPE, - buildWorkflowResultContextMessage, - filterWorkflowDisplayOnlyMessages, -} from "@/common/utils/workflowRunMessages"; -import { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime"; +import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries"; import { WorkflowRunStore } from "@/node/services/workflows/WorkflowRunStore"; -import { - WorkflowService, - type WorkflowRunStatusChangedEvent, -} from "@/node/services/workflows/WorkflowService"; -import { - DEFAULT_WORKFLOW_AGENT_ID, - WorkflowTaskServiceAdapter, -} from "@/node/services/workflows/WorkflowTaskServiceAdapter"; -import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext"; -import { resolveWorkflowScript } from "@/node/services/workflows/workflowScriptResolver"; -import { isWorkspaceProjectTrusted } from "@/node/utils/projectTrust"; - -const STREAM_STARTUP_DIAGNOSTIC_THRESHOLD_MS = 1_000; - -export function prepareProviderRequestMessages( - messages: MuxMessage[], - canonicalProviderName: string, - effectiveThinkingLevel: ThinkingLevel -): { - activeContextMessages: MuxMessage[]; - providerRequestMessages: MuxMessage[]; - contextBoundarySlicedCount: number; -} { - // Workflow display rows are durable UI history, not main-agent context. - const messagesWithoutWorkflowDisplay = filterWorkflowDisplayOnlyMessages(messages); - // RLM keep-recent floor: a stamped compaction request summarizes only the - // older head; the stamped tail is preserved verbatim after the boundary. - // No-op (same reference) unless the trailing user row carries the durable - // stamp, so RLM-off requests and replay stay byte-identical. - const activeContextMessages = excludeKeepRecentTailForCompactionRequest( - sliceMessagesForProviderFromLatestContextBoundary(messagesWithoutWorkflowDisplay) - ); - const contextBoundarySlicedCount = - messagesWithoutWorkflowDisplay.length - activeContextMessages.length; - const preserveReasoningOnly = - canonicalProviderName === "anthropic" && effectiveThinkingLevel !== "off"; - return { - activeContextMessages, - providerRequestMessages: filterEmptyAssistantMessages( - activeContextMessages, - preserveReasoningOnly - ), - contextBoundarySlicedCount, - }; -} - -// Exported for the replay builder: fallback requests append the refusal's -// partial continuation the same way production does. -export function replaceOrAppendMessageById( - messages: MuxMessage[], - replacement: MuxMessage -): MuxMessage[] { - const index = messages.findIndex((message) => message.id === replacement.id); - if (index === -1) { - return [...messages, replacement]; - } - - const next = [...messages]; - next[index] = replacement; - return next; -} - -// --------------------------------------------------------------------------- -// streamMessage options -// --------------------------------------------------------------------------- - -/** Options bag for {@link AIService.streamMessage}. */ -export interface StreamMessageOptions { - messages: MuxMessage[]; - workspaceId: string; - modelString: string; - thinkingLevel?: ThinkingLevel; - /** OpenAI pro reasoning mode; delivered via provider options (inert for unsupported models). */ - reasoningMode?: OpenAIReasoningMode; - toolPolicy?: ToolPolicy; - abortSignal?: AbortSignal; - /** Live workspace scratchpad snapshot from the renderer; when present it wins over disk. */ - additionalSystemContext?: string; - additionalSystemInstructions?: string; - maxOutputTokens?: number; - muxProviderOptions?: MuxProviderOptions; - /** Internal-only flag for Copilot billing attribution; never sourced from IPC schemas. */ - agentInitiated?: boolean; - agentId?: string; - /** See SendMessageOptionsSchema.strictAgentResolution: explicit-agent sends fail loudly instead of falling back to exec. */ - strictAgentResolution?: SendMessageOptions["strictAgentResolution"]; - /** ACP prompt correlation id used to match stream events to a specific request. */ - acpPromptId?: string; - /** Invoked with each fatal pre-start error event this call emits before returning Err. */ - onPreStartError?: (event: ErrorEvent) => void; - /** Tool names that should be delegated back to ACP clients for this request. */ - delegatedToolNames?: string[]; - recordFileState?: (filePath: string, state: FileState) => Promise; - postCompactionAttachments?: PostCompactionAttachment[] | null; - /** - * Resolver for the session-segment memory context (memory experiment): - * index snapshot for the memory tool description + hot-memories block. - * AgentSession caches the result per model/session segment because hot-memory - * selection is token-budgeted with the active model tokenizer. A callback - * (not a pre-resolved value) because it must be computed after - * runtime.ensureReady(): project-scope listing on a - * stopped Docker/remote workspace would otherwise cache an empty/partial - * context for the whole segment. - */ - resolveMemoryContext?: ( - modelString: string, - options?: { includeHotMemories?: boolean } - ) => Promise; - experiments?: SendMessageOptions["experiments"]; - allowAgentSetGoal?: boolean; - workspaceGoalService?: WorkspaceGoalService; - disableWorkspaceAgents?: boolean; - hasQueuedMessages?: (dispatchMode?: "tool-end" | "turn-end") => boolean; - muxMetadata?: MuxMessageMetadata; - openaiTruncationModeOverride?: "auto" | "disabled"; - /** - * Model floor already resolved by AgentSession (config.json - * minThinkingLevelByModel → resolveMinimumThinkingLevel). Passed down so - * mid-turn overrides clamp against the same floor as the send-time level; - * internal callers may omit it (re-resolved from defaults). - */ - minThinkingLevel?: ThinkingLevel; - /** - * Session-owned per-turn holder for mid-turn thinking-level overrides. - * When absent (compaction, sub-agent paths), the feature is inert for the - * stream. See src/node/services/thinkingOverride.ts. - */ - activeTurnThinkingOverride?: ActiveTurnThinkingOverride; -} - -/** - * Recursively merge user-provided provider extras under Xum-built provider options. - * Xum values win on leaf conflicts; both sides' non-conflicting nested fields are preserved. - */ -function mergeProviderExtrasUnderMux( - providerExtras: Record, - muxProviderNamespace: Record -): Record { - const merged: Record = { ...providerExtras }; - - for (const [key, muxValue] of Object.entries(muxProviderNamespace)) { - const extraValue = merged[key]; - merged[key] = - isPlainObject(extraValue) && isPlainObject(muxValue) - ? mergeProviderExtrasUnderMux(extraValue, muxValue) - : muxValue; - } - - return merged; -} - -function markProviderMetadataCostsIncluded( - providerMetadata: Record | undefined, - costsIncluded: boolean | undefined -): Record | undefined { - if (!costsIncluded) { - return providerMetadata; - } - - const muxMetadata = providerMetadata?.mux; - const existingMux = - muxMetadata && typeof muxMetadata === "object" - ? (muxMetadata as Record) - : undefined; - - return { - ...(providerMetadata ?? {}), - mux: { - ...(existingMux ?? {}), - costsIncluded: true, - }, - }; -} - -const WORKFLOW_CONTINUATION_RETRY_DELAY_MS = 1_000; -const WORKSPACE_BUSY_IDLE_ONLY_SEND_MESSAGE = "Workspace is busy; idle-only send was skipped."; - -function isWorkspaceBusyIdleOnlySend(error: SendMessageError): boolean { - return error.type === "unknown" && error.raw.includes(WORKSPACE_BUSY_IDLE_ONLY_SEND_MESSAGE); -} +import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; +import { MockAiStreamPlayer } from "./mock/mockAiStreamPlayer"; +import { ProviderModelFactory } from "./providerModelFactory"; -function waitForWorkflowContinuationRetry(): Promise { - return new Promise((resolve) => setTimeout(resolve, WORKFLOW_CONTINUATION_RETRY_DELAY_MS)); -} +export { resolveMuxProjectRootForHostFs }; interface ToolExecutionContext { toolCallId?: string; @@ -402,158 +94,24 @@ function isToolExecutionContext(value: unknown): value is ToolExecutionContext { if (typeof value !== "object" || value == null || Array.isArray(value)) { return false; } - const record = value as Record; - const toolCallId = record.toolCallId; - const abortSignal = record.abortSignal; - - const validToolCallId = toolCallId == null || typeof toolCallId === "string"; - const validAbortSignal = abortSignal == null || abortSignal instanceof AbortSignal; - - return validToolCallId && validAbortSignal; -} - -/** - * Derive the host-local project root for mux managed-file tools (fs/promises). - * Remote runtimes (ssh, docker) have a workspacePath that is a remote/container - * path — unusable by host fs. Fall back to metadata.projectPath which is always - * host-local. - */ -export function resolveMuxProjectRootForHostFs( - metadata: WorkspaceMetadata, - workspacePath: string -): string { - const runtimeType = metadata.runtimeConfig.type; - return runtimeType === "ssh" || runtimeType === "docker" ? metadata.projectPath : workspacePath; -} - -function resolveXumToolScope( - config: Config, - metadata: WorkspaceMetadata, - workspacePath: string, - /** Checkout root in the project storage authority's filesystem. */ - checkoutRoot?: string | null -): XumToolScope { - const projectConfig = config.loadConfigOrDefault().projects.get(metadata.projectPath); - if ( - projectConfig?.projectKind === "system" && - metadata.projectPath !== MULTI_PROJECT_CONFIG_KEY - ) { - // Preserve ~/.xum-backed tool behavior for legacy system workspaces after removing - // Chat with Xum. Multi-project workspaces still point at a real checkout under _multi, - // so they stay project-scoped. - return { - type: "global", - xumHome: config.rootDir, - }; - } - - const runtimeType = metadata.runtimeConfig.type; - return { - type: "project", - xumHome: config.rootDir, - projectRoot: resolveMuxProjectRootForHostFs(metadata, workspacePath), - projectStorageAuthority: - runtimeType === "ssh" || runtimeType === "docker" ? "runtime" : "host-local", - ...(checkoutRoot != null ? { checkoutRoot } : {}), - }; -} - -/** - * Pin the factory-resolved Coder instance type into a providers-config view. - * - * Every request builder (message prep, options, headers, overrides, - * capability lookups, mid-turn rebuild closures) consumes ONE snapshot per - * request instead of re-reading ProviderService. Pinning closes the residual - * race between the factory's own config read and this capture: a concurrent - * authoritative catalog refresh that rewrites the selected instance's type - * would otherwise make the builders resolve a different wire than the - * already-created SDK model. additionalProviders is the highest-precedence - * metadata source (resolveCoderGatewayProvider consults it first), so the - * pinned entry wins over any concurrently rewritten discovered metadata. - * Pinning keys on the RAW selection's instance (coderSelectedInstance), not - * on the effective route: a coder: selection that FELL BACK to a direct - * provider still has builders resolving the raw model string (capability - * lookups, override identity, option/header rebuilds), and a concurrent - * retag between the factory's read and this capture would otherwise hand - * the already-created fallback model another type's options. Non-coder - * selections, shadowed prefixes, and unknown instances have no snapshot and - * keep the view untouched. - */ -function pinCoderInstanceProvidersConfig( - view: ProvidersConfigMap, - rawModelString: string, - instance: { name: string; type: string } | undefined -): ProvidersConfigMap { - if (!instance || !rawModelString.startsWith("coder:")) { - return view; - } - return { - ...view, - coder: { - ...(view.coder ?? { apiKeySet: false, isEnabled: true, isConfigured: true }), - additionalProviders: [{ name: instance.name, type: instance.type }], - }, - }; -} - -/** - * Raw providers.jsonc counterpart of pinCoderInstanceProvidersConfig for - * consumers that need file-shaped config (modelParameters lookups). Same - * rationale: additionalProviders is the highest-precedence metadata source, - * so pinning the factory-resolved instance there keeps metadata-dependent - * decisions (mappedToModel aliases, sampling gates) on the type the SDK - * model was created for. - */ -function pinCoderInstanceRawProvidersConfig( - view: ProvidersConfig | null, - rawModelString: string, - instance: { name: string; type: string } | undefined -): ProvidersConfig | null { - if (!view || !instance || !rawModelString.startsWith("coder:")) { - return view; - } - return { - ...view, - coder: { - ...(view.coder ?? {}), - additionalProviders: [{ name: instance.name, type: instance.type }], - }, - }; -} - -function derivePromptCacheScope(metadata: WorkspaceMetadata): string { - return `${metadata.projectName}-${uniqueSuffix([metadata.projectPath])}`; -} - -interface WorkflowResultContinuationSender { - isWorkflowInvocationCurrent(workspaceId: string, runId: string): Promise; - sendMessage( - workspaceId: string, - message: string, - options: SendMessageOptions, - internal?: { - skipAutoResumeReset?: boolean; - synthetic?: boolean; - agentInitiated?: boolean; - /** When true, reject instead of queueing if the workspace is busy. */ - requireIdle?: boolean; - startStreamInBackground?: boolean; - } - ): Promise>; + return ( + (record.toolCallId == null || typeof record.toolCallId === "string") && + (record.abortSignal == null || record.abortSignal instanceof AbortSignal) + ); } export class AIService extends EventEmitter { private readonly streamManager: StreamManager; + private readonly turnRequestBuilder: TurnRequestBuilder; private readonly historyService: HistoryService; private readonly config: Config; private readonly workspaceMcpOverridesService: WorkspaceMcpOverridesService; - private mcpServerManager?: MCPServerManager; private readonly policyService?: PolicyService; private readonly telemetryService?: TelemetryService; private readonly initStateManager: InitStateManager; private mockModeEnabled: boolean; - private mockAiStreamPlayer?: MockAiStreamPlayer; + public mockAiStreamPlayer?: MockAiStreamPlayer; private readonly backgroundProcessManager?: BackgroundProcessManager; private readonly sessionUsageService?: SessionUsageService; private readonly providerService: ProviderService; @@ -561,18 +119,6 @@ export class AIService extends EventEmitter { private readonly devToolsService?: DevToolsService; private readonly experimentsService?: ExperimentsService; - // Tracks in-flight stream startup (before StreamManager emits stream-start). - // This enables user interrupts (Esc/Ctrl+C) during the UI "starting..." phase. - private readonly pendingStreamStarts = new Map< - string, - { - abortController: AbortController; - startTime: number; - syntheticMessageId: string; - acpPromptId?: string; - } - >(); - /** * Tracks queued DevTools run metadata by assistant message id so stream-end/abort * can clear orphaned entries when a stream starts but never reaches middleware run creation. @@ -584,17 +130,6 @@ export class AIService extends EventEmitter { // Debug: captured LLM request payloads for last send per workspace private lastLlmRequestByWorkspace = new Map(); - private taskService?: TaskService; - private memoryService?: MemoryService; - private timelineService?: ToolConfiguration["timelineService"]; - private extraTools?: Record; - private onWorkflowRunStatusChanged?: ( - event: WorkflowRunStatusChangedEvent - ) => Promise | void; - private workflowResultContinuationSender?: WorkflowResultContinuationSender; - private workspaceHeartbeatService?: ToolConfiguration["workspaceHeartbeatService"]; - private analyticsService?: { executeRawQuery(sql: string): Promise }; - private desktopSessionManager?: DesktopSessionManager; constructor( config: Config, @@ -607,7 +142,9 @@ export class AIService extends EventEmitter { policyService?: PolicyService, telemetryService?: TelemetryService, devToolsService?: DevToolsService, - experimentsService?: ExperimentsService + experimentsService?: ExperimentsService, + streamManager?: StreamManager, + public readonly turnRequestBuilderBindings: TurnRequestBuilderBindings = {} ) { super(); // Increase max listeners to accommodate multiple concurrent workspace listeners @@ -625,20 +162,56 @@ export class AIService extends EventEmitter { this.experimentsService = experimentsService; this.providerService = providerService; this.providerService.onConfigChanged(() => this.emit("providers-config-changed")); - this.streamManager = new StreamManager( - historyService, - sessionUsageService, - () => this.providerService.getConfig(), - (event) => this.emitEngineEvent(event) - ); + this.streamManager = + streamManager ?? + new StreamManager(historyService, sessionUsageService, () => + this.providerService.getConfig() + ); + this.streamManager.setEventSink((event) => this.emitEngineEvent(event)); this.devToolsService = devToolsService; this.providerModelFactory = new ProviderModelFactory( config, providerService, policyService, - undefined, + turnRequestBuilderBindings, devToolsService ); + this.turnRequestBuilder = new TurnRequestBuilder({ + config: this.config, + historyService: this.historyService, + initStateManager: this.initStateManager, + providerService: this.providerService, + providerModelFactory: this.providerModelFactory, + streamManager: this.streamManager, + workspaceMcpOverridesService: this.workspaceMcpOverridesService, + policyService: this.policyService, + telemetryService: this.telemetryService, + backgroundProcessManager: this.backgroundProcessManager, + sessionUsageService: this.sessionUsageService, + devToolsService: this.devToolsService, + experimentsService: this.experimentsService, + lastLlmRequestByWorkspace: this.lastLlmRequestByWorkspace, + bindings: this.turnRequestBuilderBindings, + emit: (event, ...args) => this.emit(event, ...args), + createAbortedTurnHandle: (messageId) => this.createAbortedTurnHandle(messageId), + createSettledTurnHandle: (messageId, completion) => + this.createSettledTurnHandle(messageId, completion), + getWorkspaceMetadata: (workspaceId) => this.getWorkspaceMetadata(workspaceId), + createWorkspaceRuntimeContext: (workspaceId, metadata) => + this.createWorkspaceRuntimeContext(workspaceId, metadata), + isClaudeSkillsCompatEnabled: () => this.isClaudeSkillsCompatEnabled(), + isAgentPluginsEnabled: () => this.isAgentPluginsEnabled(), + wrapToolsForDelegation: (workspaceId, tools, delegatedToolNames) => + this.wrapToolsForDelegation(workspaceId, tools, delegatedToolNames), + durableEventJournalFor: (workspaceId) => this.durableEventJournalFor(workspaceId), + shouldAllowLegacyInvalidWorkflowAgentOutputSchema: (metadata) => + this.shouldAllowLegacyInvalidWorkflowAgentOutputSchema(metadata), + createModel: (modelString, providerOptions, options) => + this.createModel(modelString, providerOptions, options), + isStreaming: (workspaceId) => this.streamManager.isStreaming(workspaceId), + trackPendingDevToolsRunMetadata: (messageId, workspaceId, metadataId) => + this.trackPendingDevToolsRunMetadata(messageId, workspaceId, metadataId), + }); void this.ensureSessionsDir(); this.mockModeEnabled = false; @@ -648,35 +221,6 @@ export class AIService extends EventEmitter { } } - setCodexOauthService(service: CodexOauthService): void { - this.providerModelFactory.codexOauthService = service; - } - setCoderOauthService(service: CoderOauthService): void { - this.providerModelFactory.coderOauthService = service; - } - setMCPServerManager(manager: MCPServerManager): void { - this.mcpServerManager = manager; - this.streamManager.setMCPServerManager(manager); - } - - setTaskService(taskService: TaskService): void { - this.taskService = taskService; - } - - setWorkspaceHeartbeatService( - service: NonNullable - ): void { - this.workspaceHeartbeatService = service; - } - - setMemoryService(memoryService: MemoryService): void { - this.memoryService = memoryService; - } - - setTimelineService(timelineService: NonNullable): void { - this.timelineService = timelineService; - } - /** * Whether a global experiment is enabled. False when no ExperimentsService was * provided (lightweight test setups). Exposed so collaborators constructed with @@ -705,7 +249,7 @@ export class AIService extends EventEmitter { modelString: string, options?: { includeHotMemories?: boolean } ): Promise { - if (!this.memoryService) return null; + if (!this.turnRequestBuilderBindings.memoryService) return null; if (this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY) !== true) { return null; } @@ -722,7 +266,8 @@ export class AIService extends EventEmitter { // disables project memory when no single project identity exists. projectPath: resolveMemoryProjectIdentity(metadata), }; - const indexEntries = await this.memoryService.listIndexEntries(ctx); + const indexEntries = + await this.turnRequestBuilderBindings.memoryService.listIndexEntries(ctx); // Hot preloading is a sub-experiment: without it, memories stay // pull-based like skills (index only, contents fetched on demand). let hotMemoriesBlock: string | null = null; @@ -736,7 +281,7 @@ export class AIService extends EventEmitter { this.providerService.getConfig() ); const tokenizer = await getTokenizerForModel(modelString, metadataModel); - const items = await this.memoryService.listHotMemories(ctx, { + const items = await this.turnRequestBuilderBindings.memoryService.listHotMemories(ctx, { countTokens: (text) => tokenizer.countTokens(text), }); hotMemoriesBlock = items.length === 0 ? null : formatHotMemoriesBlock(items); @@ -757,36 +302,10 @@ export class AIService extends EventEmitter { } } - setWorkflowRunStatusChangedHandler( - handler: (event: WorkflowRunStatusChangedEvent) => Promise | void - ): void { - this.onWorkflowRunStatusChanged = handler; - } - - setWorkflowResultContinuationSender(sender: WorkflowResultContinuationSender): void { - this.workflowResultContinuationSender = sender; - } - - setAnalyticsService(service: { executeRawQuery(sql: string): Promise }): void { - this.analyticsService = service; - } - - setDesktopSessionManager(desktopSessionManager: DesktopSessionManager): void { - this.desktopSessionManager = desktopSessionManager; - } - getProvidersConfig(): ProvidersConfigMap | null { return this.providerService.getConfig(); } - /** - * Set extra tools to include in every tool call. - * Used by CLI to inject tools like set_exit_code without modifying core tool definitions. - */ - setExtraTools(tools: Record): void { - this.extraTools = tools; - } - private emitEngineEvent(event: TurnEngineEvent): void | Promise { if (event.type === "error") { this.clearTrackedPendingDevToolsRunMetadata(event.messageId); @@ -977,6 +496,7 @@ export class AIService extends EventEmitter { aiService: this, historyService: this.historyService, }); + this.streamManager.setMockStreamLifecycle(this.mockAiStreamPlayer); } async getWorkspaceMetadata(workspaceId: string): Promise> { @@ -1303,72 +823,25 @@ export class AIService extends EventEmitter { async streamMessage( opts: StreamMessageOptions ): Promise> { - const { - messages, + const { messages, workspaceId, modelString, thinkingLevel, abortSignal, agentId, muxMetadata } = + opts; + // Register before the first await so interrupts can cancel slow preparation. + const pendingStart = this.streamManager.beginStreamStart({ workspaceId, - modelString, - thinkingLevel, - reasoningMode, - toolPolicy, abortSignal, - additionalSystemContext, - additionalSystemInstructions, - maxOutputTokens, - muxProviderOptions, - agentInitiated, - agentId, - strictAgentResolution, - acpPromptId, - onPreStartError, - delegatedToolNames, - recordFileState, - postCompactionAttachments, - resolveMemoryContext, - experiments: experimentsFromOptions, - allowAgentSetGoal, - workspaceGoalService, - disableWorkspaceAgents, - hasQueuedMessages, - openaiTruncationModeOverride, - muxMetadata, - minThinkingLevel: providedMinThinkingLevel, - activeTurnThinkingOverride, - } = opts; - // Backfill the PTC/RLM trio from the backend's persisted experiment - // overrides (same `?? isExperimentEnabled` pattern as the other - // backend-gated experiments below). A renderer with no origin-local - // override sends `undefined` for these flags, and the effective UI and - // /refine gate already resolve against the backend override — tool - // assembly must agree or a persisted-RLM workspace silently streams with - // the non-persistent flat/PTC toolset. Explicit false stays false. - const experiments: StreamMessageOptions["experiments"] = resolveBackendGatedPtcExperiments( - experimentsFromOptions, - (experimentId) => this.experimentsService?.isExperimentEnabled(experimentId) === true - ); - // Support interrupts during startup (before StreamManager emits stream-start). - // We register an AbortController up-front and let stopStream() abort it. - const pendingAbortController = new AbortController(); - const startTime = Date.now(); - const syntheticMessageId = `starting-${startTime}-${Math.random().toString(36).substring(2, 11)}`; - - // Link external abort signal (if provided). - const unlinkAbortSignal = linkAbortSignal(abortSignal, pendingAbortController); - - this.pendingStreamStarts.set(workspaceId, { - abortController: pendingAbortController, - startTime, - syntheticMessageId, - acpPromptId, + acpPromptId: opts.acpPromptId, }); - - const combinedAbortSignal = pendingAbortController.signal; - - let pendingRunMetadataId: string | null = null; + const startTime = Date.now(); + const syntheticMessageId = pendingStart.syntheticMessageId; + const combinedAbortSignal = pendingStart.abortSignal; const startupPhaseTimingsMs: Record = {}; const recordStartupPhaseTiming = (phase: string, phaseStartedAt: number): void => { startupPhaseTimingsMs[phase] = Date.now() - phaseStartedAt; }; - let logSlowStreamStartup: ((details: Record) => void) | undefined; + const startupState = { + pendingRunMetadataId: null as string | null, + logSlowStreamStartup: undefined as ((details: Record) => void) | undefined, + }; try { if (this.mockModeEnabled && this.mockAiStreamPlayer) { @@ -1376,9 +849,6 @@ export class AIService extends EventEmitter { if (combinedAbortSignal.aborted) { return Ok(this.createAbortedTurnHandle(syntheticMessageId)); } - // play() resolves at stream-start; the scripted turn settles its own - // completion from the terminal scripted event, which arrives later. - // Pre-start aborts never schedule a turn and settle aborted here. const result = await this.mockAiStreamPlayer.play(messages, workspaceId, { model: modelString, agentId, @@ -1392,2943 +862,86 @@ export class AIService extends EventEmitter { return Ok(result.data ?? this.createAbortedTurnHandle(syntheticMessageId)); } - // DEBUG: Log streamMessage call const lastMessage = messages[messages.length - 1]; log.debug( - `[STREAM MESSAGE] workspaceId=${workspaceId} messageCount=${messages.length} lastRole=${lastMessage?.role}` + "[STREAM MESSAGE] workspaceId=" + + workspaceId + + " messageCount=" + + messages.length + + " lastRole=" + + lastMessage?.role ); - // Before starting a new stream, commit any existing partial to history - // This is idempotent - won't double-commit if already in chat.jsonl const commitPartialStartedAt = Date.now(); await this.historyService.commitPartial(workspaceId); recordStartupPhaseTiming("commitPartialMs", commitPartialStartedAt); - // Helper: clean up an assistant placeholder that was appended to history but never - // streamed (due to abort during setup). Used in two abort-check sites below. - const deleteAbortedPlaceholder = async (messageId: string): Promise => { - const deleteResult = await this.historyService.deleteMessage(workspaceId, messageId); - if (!deleteResult.success) { - log.error( - `Failed to delete aborted assistant placeholder (${messageId}): ${deleteResult.error}` - ); - } - }; - - // Mode (plan|exec|compact) is derived from the selected agent definition. - const effectiveMuxProviderOptions: MuxProviderOptions = muxProviderOptions ?? {}; - // Preliminary clamp for the factory call only: the factory reads the - // thinking level solely for the xAI Grok variant swap, which never - // depends on Coder instance metadata, so a pre-snapshot resolution is - // safe there. The FINAL effectiveThinkingLevel is re-resolved below - // from the pinned request snapshot — resolving it from this earlier - // read would race a concurrent instance retag and disagree with the - // wire the factory created the SDK model for. - const preliminaryThinkingLevel: ThinkingLevel = resolveEffectiveThinkingLevel( - modelString, - thinkingLevel, - this.providerService.getConfig() - ); - - // Resolve model string (xAI variant mapping + gateway routing) and create the model. - const resolveAndCreateModelStartedAt = Date.now(); - const modelResult = await this.providerModelFactory.resolveAndCreateModel( - modelString, - preliminaryThinkingLevel, - effectiveMuxProviderOptions, - { agentInitiated, workspaceId } - ); - recordStartupPhaseTiming("resolveAndCreateModelMs", resolveAndCreateModelStartedAt); - if (!modelResult.success) { - return Err(modelResult.error); - } - const { - effectiveModelString, - canonicalModelString, - canonicalProviderName, - wireProviderName, - routedThroughGateway, - routeProvider, - } = modelResult.data; - // ONE providers-config snapshot for every request builder (messages, - // options, headers, overrides, capability lookups, mid-turn rebuild - // closures). Re-reading ProviderService per builder races concurrent - // catalog refreshes: an instance-type change mid-request would hand the - // already-created SDK model another wire's options/headers. The - // factory-resolved instance type is PINNED into the snapshot so every - // coder-wire resolution matches the created model even when the change - // lands between the factory's read and this capture. - const requestProvidersConfig = pinCoderInstanceProvidersConfig( - this.providerService.getConfig(), - modelString, - modelResult.data.coderSelectedInstance - ); - // FINAL thinking clamp from the pinned snapshot. Models that cannot disable - // thinking, including aliases mapped to them, get the same treatment. - // Resolved here — not from the pre-factory read — so a - // concurrent instance retag cannot leave the level derived from one - // type while options/messages are built for the other's wire. - const effectiveThinkingLevel: ThinkingLevel = resolveEffectiveThinkingLevel( - modelString, - thinkingLevel, - requestProvidersConfig - ); - // Capability lookups must see the RAW coder identity: name-based - // canonicalization can rewrite a cross-typed instance (coder:openai/x - // with type anthropic) to openai:x, hiding the instance metadata that - // resolveModelForMetadata needs to derive the real capability model. - // Non-coder strings keep the canonical form (raw gateway strings like - // mux-gateway:origin/x would otherwise leak through unresolved). - const capabilityModelString = resolveModelForMetadata( - modelString.startsWith("coder:") ? modelString : canonicalModelString, - requestProvidersConfig - ); - // Provider-specific tool assembly keys on the WIRE identity of the - // EFFECTIVE route: raw coder:/ strings parse as - // provider "coder" inside getToolsForModel, which skips the Anthropic - // branch (native web tools) and the OpenAI branch (MCP schema - // sanitization). The wire variant matters too: openai-chat instance - // types (openrouter/google/azure/openai-compat/vercel) are created via - // provider.chat(...), so Responses-only assembly (native web_search) - // and Responses-only providerOptions must be suppressed via the - // existing wireFormat knob. When routing fell away from Coder, the - // effective route IS the identity (a coder:openrouter selection that - // fell back to direct OpenRouter must not be treated as OpenAI-wire). - // The capability identity above stays raw-derived. A custom provider - // shadowing the "coder" prefix keeps its raw identity; unknown - // instances fall back to the name-canonical form. - const resolveToolsIdentity = ( - raw: string, - effective: string, - canonical: string, - // The factory's wire snapshot — resolved from the SAME config read - // that created the SDK model. Re-reading the providers config here - // instead would race authoritative catalog refreshes: a mid-request - // type change would assemble another wire's tools/options for the - // already-created model. Shadowed prefixes and unknown instances have - // no snapshot, and their canonical form is the raw string. - coderWire: - | { origin: "anthropic" | "openai"; modelId: string; providerType: string } - | undefined, - // Snapshot the identity is resolved against; the refusal-fallback - // path passes ITS pinned snapshot, not the primary request's. - providersConfigSnapshot: ProvidersConfigMap - ): { modelString: string; openaiWireFormat?: "chatCompletions" | "responses" } => { - // Custom providers own their raw prefix (including shadowed built-in - // ids) and speak the wire their providerType selects: tool assembly - // must key on that wire so Responses-bound MCP schemas are sanitized - // and provider-native tools are offered. Chat-completions custom - // providers keep their generic identity. - const rawSeparator = raw.indexOf(":"); - const rawPrefix = rawSeparator > 0 ? raw.slice(0, rawSeparator) : ""; - const rawCustomEntry = rawPrefix ? providersConfigSnapshot[rawPrefix] : undefined; - if (isCustomProviderConfig(rawCustomEntry)) { - const wireOrigin = customProviderWireOrigin(rawCustomEntry.providerType); - if (wireOrigin === "openai") { - // The factory always creates provider.responses() for this type. - return { - modelString: `openai:${raw.slice(rawSeparator + 1)}`, - openaiWireFormat: "responses", - }; - } - if (wireOrigin === "anthropic") { - return { modelString: `anthropic:${raw.slice(rawSeparator + 1)}` }; - } - return { modelString: raw }; - } - if (!raw.startsWith("coder:")) { - return { modelString: raw }; - } - if (!effective.startsWith("coder:")) { - // Fallback away from Coder. A PASSTHROUGH gateway fallback - // (mux-gateway:anthropic/x) must normalize to the canonical wire - // identity: getToolsForModel only runs Anthropic/OpenAI-specific - // assembly (native web tools, MCP schema sanitization) for direct - // provider prefixes, and passthrough gateways forward origin-shaped - // payloads. Transforming gateways (openrouter) keep their own - // identity, same as a direct selection of that gateway. - const separator = effective.indexOf(":"); - const effectiveProvider = separator > 0 ? effective.slice(0, separator) : ""; - const definition = Object.hasOwn(PROVIDER_DEFINITIONS, effectiveProvider) - ? PROVIDER_DEFINITIONS[effectiveProvider as ProviderName] - : undefined; - const passthroughGateway = - definition?.kind === "gateway" && - "passthrough" in definition && - definition.passthrough === true; - return { - modelString: passthroughGateway ? normalizeToCanonical(effective) : effective, - }; - } - if (!coderWire) { - return { modelString: canonical }; - } - // The factory creates Coder instances from the wire alone (openai - // type → provider.responses, openai-chat types → provider.chat), so - // BOTH OpenAI wire kinds must override any pre-existing wireFormat: - // a refusal chain that starts on direct OpenAI Chat Completions and - // falls back to an openai-typed Coder instance would otherwise build - // Chat Completions tools/options for a Responses request. - const wireProtocol = coderGatewayWireProtocol(coderWire.providerType); - return { - modelString: `${coderWire.origin}:${coderWire.modelId}`, - ...(wireProtocol === "openai-chat" - ? { openaiWireFormat: "chatCompletions" as const } - : wireProtocol === "openai-responses" - ? { openaiWireFormat: "responses" as const } - : {}), - }; - }; - const toolsIdentity = resolveToolsIdentity( - modelString, - effectiveModelString, - canonicalModelString, - modelResult.data.coderWire, - requestProvidersConfig - ); - const toolsModelString = toolsIdentity.modelString; - // Option/header builder identity: raw selections resolve via the - // pinned instance config (coder-routed requests need the wire), but a - // Coder selection whose routing FELL AWAY from the gateway must build - // options for the EFFECTIVE route. Example: coder:google/gemini-* with - // Coder unavailable routes through the passthrough mux-gateway and - // sends native Google bytes — resolving the raw string against the - // pinned instance would emit the gateway wire's OpenAI options and - // drop Google settings such as thinkingConfig. Tool assembly - // (toolsModelString) already follows the effective route; reuse it. - const optionsModelString = - modelString.startsWith("coder:") && !effectiveModelString.startsWith("coder:") - ? toolsModelString - : modelString; - // The user's own wireFormat, captured BEFORE wire injection: the - // refusal-fallback prepare() must reset to it when swapping to a model - // whose route is not an OpenAI-wire Coder instance. - const userOpenAIWireFormat = effectiveMuxProviderOptions.openai?.wireFormat; - if (toolsIdentity.openaiWireFormat != null) { - // Deliberate in-place update: every downstream consumer - // (buildProviderOptions, toolsForModelConfig.openaiWireFormat, header - // building, mid-turn thinking rebuilds) reads this object, and the - // actual request bytes go over Chat Completions. - effectiveMuxProviderOptions.openai = { - ...(effectiveMuxProviderOptions.openai ?? {}), - wireFormat: toolsIdentity.openaiWireFormat, - }; - } - - // Dump original messages for debugging - log.debug_obj(`${workspaceId}/1_original_messages.json`, messages); - - // Context Boundary request slicing happens before empty-assistant filtering so - // provider-invisible reset rows can still bound the active context window. - // Message preparation keys on the WIRE provider (wireProviderName), not - // the config identity: a gateway-scoped coder:/ request - // sends Anthropic/OpenAI-shaped bytes, so wire-specific transforms must - // still run for it. - const { activeContextMessages, providerRequestMessages, contextBoundarySlicedCount } = - prepareProviderRequestMessages(messages, wireProviderName, effectiveThinkingLevel); - if (contextBoundarySlicedCount > 0) { - log.debug("Prepared provider history window", { - workspaceId, - originalCount: messages.length, - contextBoundarySlicedCount, - activeContextCount: activeContextMessages.length, - }); + const buildOutcome = await this.turnRequestBuilder.build(opts, { + abortSignal: combinedAbortSignal, + syntheticMessageId, + startTime, + startupPhaseTimingsMs, + startupState, + recordStartupPhaseTiming, + }); + if (buildOutcome.type === "finished") { + return buildOutcome.result; } - log.debug_obj(`${workspaceId}/1a_active_context_messages.json`, activeContextMessages); - log.debug( - `Filtered ${activeContextMessages.length - providerRequestMessages.length} empty assistant messages` - ); - log.debug_obj(`${workspaceId}/1b_provider_request_messages.json`, providerRequestMessages); - // OpenAI-specific: Keep reasoning parts in history so each request can - // carry forward reasoning context without relying on previous_response_id. - if (wireProviderName === "openai") { - log.debug("Keeping reasoning parts for OpenAI (managed via explicit history)"); - } - // Add [CONTINUE] sentinel to partial messages (for model context) - const messagesWithSentinel = addInterruptedSentinel(providerRequestMessages); + const startStreamStartedAt = Date.now(); + const streamResult = await this.streamManager.startStream(buildOutcome.turnExecutionOptions); + recordStartupPhaseTiming("startStreamMs", startStreamStartedAt); - // Get workspace metadata to retrieve workspace path - const getWorkspaceMetadataStartedAt = Date.now(); - const metadataResult = await this.getWorkspaceMetadata(workspaceId); - recordStartupPhaseTiming("getWorkspaceMetadataMs", getWorkspaceMetadataStartedAt); - if (!metadataResult.success) { - return Err({ type: "unknown", raw: metadataResult.error }); + if (!streamResult.success) { + if (startupState.pendingRunMetadataId != null) { + this.clearTrackedPendingDevToolsRunMetadata(buildOutcome.assistantMessageId); + startupState.pendingRunMetadataId = null; + } + buildOutcome.logStartOutcome("stream_start_failed", streamResult.error.type); + return Err(streamResult.error); } - const metadata = metadataResult.data; - - if (this.policyService?.isEnforced()) { - if (!this.policyService.isRuntimeAllowed(metadata.runtimeConfig)) { - return Err({ - type: "policy_denied", - message: "Workspace runtime is not allowed by policy", - }); + if (combinedAbortSignal.aborted && !this.streamManager.isStreaming(workspaceId)) { + if (startupState.pendingRunMetadataId != null) { + this.clearTrackedPendingDevToolsRunMetadata(buildOutcome.assistantMessageId); + startupState.pendingRunMetadataId = null; } + await buildOutcome.deleteAbortedPlaceholder(buildOutcome.assistantMessageId); } - const workspaceLog = log.withFields({ workspaceId, workspaceName: metadata.name }); - logSlowStreamStartup = (details: Record) => { - const totalMs = Date.now() - startTime; - if (totalMs < STREAM_STARTUP_DIAGNOSTIC_THRESHOLD_MS) { - return; - } - - workspaceLog.info("[stream-startup] Slow pre-stream preparation", { - workspaceId, - modelString, - totalMs, - startupPhaseTimingsMs, - ...details, - }); - }; - const emitStartupBreadcrumb = ( - startupStage: - | "waiting_for_init" - | "checking_runtime" - | "loading_workspace_context" - | "loading_tools" - | "preparing_request" - | "starting_stream" - ): void => { - const breadcrumb = - startupStage === "waiting_for_init" - ? { - phase: "waiting" as const, - detail: "Waiting for workspace initialization...", - } - : startupStage === "checking_runtime" - ? { - phase: "starting" as const, - detail: "Checking workspace runtime...", - } - : startupStage === "loading_workspace_context" - ? { - phase: "starting" as const, - detail: "Loading workspace context...", - } - : startupStage === "loading_tools" - ? { - phase: "starting" as const, - detail: "Loading tools...", - } - : startupStage === "preparing_request" - ? { - phase: "starting" as const, - detail: "Preparing model request...", - } - : { - phase: "starting" as const, - detail: "Starting model stream...", - }; - - workspaceLog.info("[stream-startup] Breadcrumb", { - startupStage, - phase: breadcrumb.phase, - detail: breadcrumb.detail, - elapsedMs: Date.now() - startTime, - }); - this.emit("runtime-status", { - type: "runtime-status", + buildOutcome.logStartOutcome("started"); + return Ok(streamResult.data); + } catch (error) { + if (startupState.pendingRunMetadataId != null) { + this.clearTrackedPendingDevToolsRunMetadataById( workspaceId, - phase: breadcrumb.phase, - runtimeType: metadata.runtimeConfig.type, - source: "startup", - detail: breadcrumb.detail, - }); - }; - - const runtimeContextResult = this.createWorkspaceRuntimeContext(workspaceId, metadata); - if (!runtimeContextResult.success) { - return runtimeContextResult; + startupState.pendingRunMetadataId + ); + startupState.pendingRunMetadataId = null; } - const { runtime, workspacePath, hostCheckoutRoot, projectCheckoutRoot } = - runtimeContextResult.data; + const errorMessage = getErrorMessage(error); + startupState.logSlowStreamStartup?.({ outcome: "error", errorMessage }); + log.error("Stream message error:", error); + return Err({ type: "unknown", raw: "Failed to stream message: " + errorMessage }); + } finally { + pendingStart.finish(); + } + } - // Wait for init to complete before any runtime I/O operations - // (SSH/devcontainer may not be ready until init finishes pulling the container) - emitStartupBreadcrumb("waiting_for_init"); - const waitForInitStartedAt = Date.now(); - await this.initStateManager.waitForInit(workspaceId, combinedAbortSignal); - recordStartupPhaseTiming("waitForInitMs", waitForInitStartedAt); - if (combinedAbortSignal.aborted) { - return Ok(this.createAbortedTurnHandle(syntheticMessageId)); - } - - // Verify runtime is actually reachable after init completes. - // For Docker workspaces, this checks the container exists and starts it if stopped. - // For Coder workspaces, this may start a stopped workspace and wait for it. - // If init failed during container creation, ensureReady() will return an error. - emitStartupBreadcrumb("checking_runtime"); - const ensureReadyStartedAt = Date.now(); - const readyResult = await runtime.ensureReady({ - signal: combinedAbortSignal, - statusSink: (status) => { - // Emit runtime-status events for frontend UX (StreamingBarrier) - this.emit("runtime-status", { - type: "runtime-status", - workspaceId, - phase: status.phase, - runtimeType: status.runtimeType, - source: "runtime", - detail: status.detail, - }); - }, - }); - recordStartupPhaseTiming("ensureReadyMs", ensureReadyStartedAt); - if (!readyResult.ready) { - // Generate message ID for the error event (frontend needs this for synthetic message) - const errorMessageId = createAssistantMessageId(); - const runtimeType = metadata.runtimeConfig?.type ?? "local"; - const runtimeLabel = runtimeType === "docker" ? "Container" : "Runtime"; - const errorMessage = readyResult.error || `${runtimeLabel} unavailable.`; - - // Use the errorType from ensureReady result (runtime_not_ready vs runtime_start_failed) - const errorType = readyResult.errorType; - - // Emit error event so frontend receives it via stream subscription. - // This mirrors the context_exceeded pattern - the fire-and-forget sendMessage - // call in useCreationWorkspace.ts won't see the returned Err, but will receive - // this event through the workspace chat subscription. - const errorEvent = createErrorEvent(workspaceId, { - messageId: errorMessageId, - error: errorMessage, - errorType, - acpPromptId, - }); - this.emit("error", errorEvent); - onPreStartError?.(errorEvent); - - logSlowStreamStartup?.({ - outcome: "runtime_not_ready", - runtimeType, - errorType, - errorMessage, - }); - - return Err({ - type: errorType, - message: errorMessage, - }); - } - - // Memory context (memory experiment): resolved only after ensureReady so - // project-scope listing sees a running runtime (a stopped Docker/remote - // workspace would yield an empty/partial context, and AgentSession caches - // the result per model/session segment). - const memoryContext = resolveMemoryContext - ? await resolveMemoryContext(modelString, { includeHotMemories: false }) - : undefined; - - // Resolve agent definition, compute effective mode & tool policy. - const cfg = this.config.loadConfigOrDefault(); - const advisorExperimentEnabled = - experiments?.advisorTool ?? - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.ADVISOR_TOOL) === true; - const dynamicWorkflowsExperimentEnabled = - experiments?.dynamicWorkflows ?? - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS) === true; - const memoryExperimentEnabled = - experiments?.memory ?? - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY) === true; - const timelineExperimentEnabled = - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.TIMELINE) === true; - const workspaceHeartbeatsExperimentEnabled = - experiments?.workspaceHeartbeats ?? - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS) === true; - const toolSearchExperimentEnabled = - experiments?.toolSearch ?? - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.TOOL_SEARCH) === true; - const memoryHotSetExperimentEnabled = - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY_HOT_SET) === true; - // claude-skills-compat is host-evaluated (like memory-hot-set): sub-agents share the - // host ExperimentsService, so it is not inherited through SendMessageOptions.experiments. - const claudeSkillsCompatExperimentEnabled = this.isClaudeSkillsCompatEnabled(); - const agentPluginsExperimentEnabled = this.isAgentPluginsEnabled(); - // Once final tool policy keeps the memory tool, upgrade the index-only - // memory context (resolved pre-policy with includeHotMemories: false) to - // the token-budgeted hot block for the model that will actually stream. - // Returns the unchanged pre-policy `memoryContext` reference when hot - // preloading is off or the memory tool was stripped, so callers can use - // identity comparison to decide whether the system prompt must be rebuilt. - const upgradeMemoryContextForModel = async ( - memoryToolAvailableForModel: boolean, - modelStringForContext: string - ): Promise => - memoryToolAvailableForModel && - memoryHotSetExperimentEnabled && - resolveMemoryContext !== undefined - ? await resolveMemoryContext(modelStringForContext, { includeHotMemories: true }) - : memoryContext; - emitStartupBreadcrumb("loading_workspace_context"); - const resolveAgentForStreamStartedAt = Date.now(); - const agentResult = await resolveAgentForStream({ - workspaceId, - metadata, - runtime, - workspacePath, - requestedAgentId: agentId, - strictAgentResolution, - disableWorkspaceAgents: disableWorkspaceAgents ?? false, - callerToolPolicy: toolPolicy, - cfg, - emitError: (event) => { - this.emit("error", event); - onPreStartError?.(event); - }, - isAdvisorExperimentEnabled: advisorExperimentEnabled, - includeAgentPlugins: agentPluginsExperimentEnabled, - }); - recordStartupPhaseTiming("resolveAgentForStreamMs", resolveAgentForStreamStartedAt); - if (!agentResult.success) { - return agentResult; - } - const { - effectiveAgentId, - agentDefinition, - agentDiscoveryRuntime, - agentDiscoveryPath, - isSubagentWorkspace, - agentInheritanceChain, - agentIsPlanLike, - effectiveMode, - taskSettings, - taskDepth, - shouldDisableTaskToolsForDepth, - effectiveToolPolicy, - } = agentResult.data; - const legacyModeForMetadata = getLegacyModeForAgentMetadata(effectiveAgentId, effectiveMode); - const projectTrusted = isWorkspaceProjectTrusted(this.config, metadata); - // projectAutomationDisabled: benchmark harnesses opt out of automatic - // repo hook execution (tool_env/tool_pre/tool_post) while keeping - // config trust for sub-agent delegation. - const sharedExecutionTrusted = - isWorkspaceTrustedForSharedExecution(metadata, cfg.projects) && - !projectAutomationDisabled(); - const agentAdvisorEnabled = resolveAdvisorEnabledForAgent( - effectiveAgentId, - cfg.agentAiDefaults?.[effectiveAgentId]?.advisorEnabled - ); - const advisorModelString = cfg.advisorModelString?.trim() ?? ""; - const advisorToolEligible = - advisorExperimentEnabled && agentAdvisorEnabled && advisorModelString.length > 0; - - // Goals graduated to GA: tools are gated solely on the workspace's - // current goal status + agent capability, not on an experiment flag. - let currentGoalForTools: GoalRecordV1 | null = null; - if (workspaceGoalService) { - currentGoalForTools = await workspaceGoalService.getGoal(workspaceId); - } - const effectiveGoalDefaults = mergeGoalDefaults( - normalizeGoalDefaults(cfg.goalDefaults ?? DEFAULT_GOAL_DEFAULTS), - metadata.goalDefaults ?? null - ); - const goalToolAvailability = getGoalToolAvailability({ - goalStatus: currentGoalForTools?.status ?? null, - parentWorkspaceId: metadata.parentWorkspaceId, - allowAgentSetGoal, - agentInheritanceChain, - }); - - // Fetch workspace MCP overrides (for filtering servers and tools) - // NOTE: Stored in /.xum/mcp.local.jsonc (not ~/.xum/config.json). - let mcpOverrides: WorkspaceMCPOverrides | undefined; - const loadWorkspaceMcpOverridesStartedAt = Date.now(); - try { - mcpOverrides = ( - await this.workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId) - ).overrides; - } catch (error) { - log.warn("[MCP] Failed to load workspace MCP overrides; continuing without overrides", { - workspaceId, - error, - }); - mcpOverrides = undefined; - } - recordStartupPhaseTiming("loadWorkspaceMcpOverridesMs", loadWorkspaceMcpOverridesStartedAt); - - // Agent Plugins: discovery follows the active checkout and is disabled - // for workspaces that exec off-host (SSH/Docker/devcontainer). - const agentPluginsMcpContext = hostCheckoutRoot - ? resolveAgentPluginsMcpContext(metadata, hostCheckoutRoot) - : null; - - // Tier-1 plugin hooks (agent-plugins experiment): reconcile discovered - // hooks.js modules with the event spine BEFORE request assembly so both - // request.assemble and tool.execute middleware are in place for this - // turn. Failure posture: a broken plugin never blocks a send. - try { - await agentPluginHookService.ensureWorkspaceHooks({ - workspaceId, - sessionDir: this.config.getSessionDir(workspaceId), - journal: this.durableEventJournalFor(workspaceId), - enabled: this.isAgentPluginsEnabled(), - xumHome: this.config.rootDir, - // Project containers follow the same off-host gating as plugin MCP. - projectRoot: agentPluginsMcpContext?.projectRoot, - projectTrusted, - }); - } catch (error) { - log.warn("Agent plugin hooks: ensure failed; continuing without plugin hooks", { error }); - } - - // Fetch MCP server config for system prompt (before building message). - const listMcpServersStartedAt = Date.now(); - const mcpServers = this.mcpServerManager - ? await this.mcpServerManager.listServers( - metadata.projectPath, - mcpOverrides, - projectTrusted, - agentPluginsMcpContext - ) - : undefined; - recordStartupPhaseTiming("listMcpServersMs", listMcpServersStartedAt); - - const loadAdditionalSystemContextStartedAt = Date.now(); - let workspaceAdditionalSystemContext = additionalSystemContext; - if (workspaceAdditionalSystemContext == null) { - try { - // Fall back to disk only when the renderer did not send a live snapshot. - // `effectiveAdditionalSystemContext` honors the `enabled` toggle: when - // the user has disabled the scratchpad, the persisted content is - // intentionally not injected. - const record = await readAdditionalSystemContext(this.config, workspaceId); - workspaceAdditionalSystemContext = effectiveAdditionalSystemContext(record); - } catch (error) { - // The scratchpad is user-editable state, so a transient read failure should not block a send. - log.warn("Failed to load workspace additional system context; continuing without it", { - workspaceId, - error, - }); - workspaceAdditionalSystemContext = ""; - } - } - const scratchpadAdditionalSystemInstructions = mergeAdditionalSystemInstructions( - workspaceAdditionalSystemContext, - additionalSystemInstructions - ); - recordStartupPhaseTiming( - "loadAdditionalSystemContextMs", - loadAdditionalSystemContextStartedAt - ); - - // Build plan-aware instructions and determine plan→exec transition content. - // IMPORTANT: Derive this from the same boundary-sliced message payload that is sent to - // the model so plan hints/handoffs cannot be suppressed by pre-boundary history. - const buildPlanInstructionsStartedAt = Date.now(); - const { effectiveAdditionalInstructions, planFilePath, planContentForTransition } = - await buildPlanInstructions({ - runtime, - metadata, - workspaceId, - workspacePath, - effectiveMode, - effectiveAgentId, - agentIsPlanLike, - agentDiscoveryRuntime, - agentDiscoveryPath, - additionalSystemInstructions: scratchpadAdditionalSystemInstructions, - shouldDisableTaskToolsForDepth, - taskDepth, - taskSettings, - requestPayloadMessages: providerRequestMessages, - }); - recordStartupPhaseTiming("buildPlanInstructionsMs", buildPlanInstructionsStartedAt); - - const xumScope = resolveXumToolScope( - this.config, - metadata, - workspacePath, - projectCheckoutRoot - ); - - const workflowSkillStorageContext = resolveSkillStorageContext({ - runtime, - workspacePath, - xumScope, - includeAgentPlugins: this.isAgentPluginsEnabled(), - }); - - const desktopSessionManager = this.desktopSessionManager; - let desktopCapabilityPromise: ReturnType | undefined; - const loadDesktopCapability = - desktopSessionManager == null - ? undefined - : () => { - // Reuse the same capability probe for every desktop-gated agent discovered during - // this request so discovery cannot trigger one desktop startup attempt per agent. - desktopCapabilityPromise ??= desktopSessionManager.getCapability(workspaceId); - return desktopCapabilityPromise; - }; - - // modelStringForSystem lets the refusal-fallback prepare() rebuild the - // system prompt for the fallback model (model-keyed instruction sections). - // Memory index eligibility mirrors memory tool registration (experiment + - // service); tool policy may still strip the tool, which forces a rebuild - // below so the prompt never advertises an absent tool. - const memoryToolEligible = memoryExperimentEnabled && this.memoryService !== undefined; - const buildStreamSystemContextForToolset = ( - toolset: { advisorToolAvailable: boolean; memoryToolAvailable: boolean }, - modelStringForSystem: string = modelString, - contextForModel: MemorySessionContext | undefined = memoryContext - ) => - buildStreamSystemContext({ - runtime, - metadata, - workspacePath, - workspaceId, - agentDefinition, - effectiveMode, - agentDiscoveryRuntime, - agentDiscoveryPath, - isSubagentWorkspace, - effectiveAdditionalInstructions, - planFilePath, - modelString: modelStringForSystem, - cfg, - providersConfig: this.providerService.getConfig(), - mcpServers, - xumScope, - loadDesktopCapability, - advisorToolAvailable: toolset.advisorToolAvailable, - memoryToolAvailable: toolset.memoryToolAvailable, - hotMemoriesBlock: contextForModel?.hotMemoriesBlock ?? undefined, - claudeSkillsCompatEnabled: claudeSkillsCompatExperimentEnabled, - agentPluginsEnabled: agentPluginsExperimentEnabled, - }); - - // Build provisional agent context before tool policy finalizes the toolset. - // The final system prompt is rebuilt after policy application so advisor guidance cannot - // survive when the resolved toolset strips the advisor tool. - const buildStreamSystemContextStartedAt = Date.now(); - const prePolicyStreamSystemContext = await buildStreamSystemContextForToolset({ - advisorToolAvailable: advisorToolEligible, - memoryToolAvailable: memoryToolEligible, - }); - recordStartupPhaseTiming("buildStreamSystemContextMs", buildStreamSystemContextStartedAt); - const { - agentSystemPromptSections, - agentDefinitions, - availableSkills, - ancestorPlanFilePaths, - } = prePolicyStreamSystemContext; - let systemMessageTokens = prePolicyStreamSystemContext.systemMessageTokens; - let systemMessage = prePolicyStreamSystemContext.systemMessage; - - // Load project secrets for local tool execution and MCP server startup. - const projectSecrets = isMultiProject(metadata) - ? mergeMultiProjectSecrets(metadata, this.config) - : this.config.getEffectiveSecrets(metadata.projectPath); - - // Generate stream token and create temp directory for tools - const streamToken = this.streamManager.generateStreamToken(); - - let mcpTools: Record | undefined; - let mcpToolServerNames: Record | undefined; - let mcpStats: MCPWorkspaceStats | undefined; - let mcpPromptRuntime: MCPPromptRuntime | undefined; - let mcpSetupDurationMs = 0; - - if (this.mcpServerManager) { - const mcpServerManager = this.mcpServerManager; - const mcpToolSetupStartedAt = Date.now(); - try { - const result = await mcpServerManager.getToolsForWorkspace({ - workspaceId, - projectPath: metadata.projectPath, - runtime, - workspacePath, - trusted: projectTrusted, - overrides: mcpOverrides, - projectSecrets: await secretsToRecord(projectSecrets), - agentPlugins: agentPluginsMcpContext, - }); - - mcpTools = result.tools; - mcpToolServerNames = result.toolServerNames; - mcpStats = result.stats; - // Omit the tool when no prompts exist to avoid adding unused schema context. - if (result.promptDescriptors.length > 0) { - mcpPromptRuntime = { - prompts: result.promptDescriptors, - getPrompt: (serverName, promptName, args, options) => - mcpServerManager.getPrompt(workspaceId, serverName, promptName, args, options), - }; - } - } catch (error) { - workspaceLog.error("Failed to start MCP servers", { error }); - } finally { - mcpSetupDurationMs = Date.now() - mcpToolSetupStartedAt; - startupPhaseTimingsMs.mcpToolSetupMs = mcpSetupDurationMs; - } - } - - // Tool search (tool-search experiment): assembly-time gate. The runtime - // holder makes getToolsForModel create the tool_catalog_search tool; its `state` - // is assigned only after policy filtering builds the deferred catalog - // (see prepareToolSearch below). Without MCP tools there is nothing to - // defer, so the feature stays fully inactive. - const toolSearchRuntime: ToolSearchRuntime | undefined = - toolSearchExperimentEnabled && Object.keys(mcpTools ?? {}).length > 0 ? {} : undefined; - - const createTempDirForStreamStartedAt = Date.now(); - const runtimeTempDir = await this.streamManager.createTempDirForStream(streamToken, runtime); - recordStartupPhaseTiming("createTempDirForStreamMs", createTempDirForStreamStartedAt); - - // Extract tool-specific instructions from AGENTS.md files and agent definition - const readToolInstructionsStartedAt = Date.now(); - const toolInstructions = await readToolInstructions( - metadata, - runtime, - workspacePath, - capabilityModelString, - agentSystemPromptSections, - cfg.projects, - claudeSkillsCompatExperimentEnabled - ); - recordStartupPhaseTiming("readToolInstructionsMs", readToolInstructionsStartedAt); - - // Calculate cumulative session costs for MUX_COSTS_USD env var - let sessionCostsUsd: number | undefined; - const loadSessionUsageStartedAt = Date.now(); - if (this.sessionUsageService) { - const sessionUsage = await this.sessionUsageService.getSessionUsage(workspaceId); - if (sessionUsage) { - const allUsage = sumUsageHistory(Object.values(sessionUsage.byModel)); - sessionCostsUsd = getTotalCost(allUsage); - } - } - recordStartupPhaseTiming("loadSessionUsageMs", loadSessionUsageStartedAt); - - // Get model-specific tools with workspace path (correct for local or remote) - emitStartupBreadcrumb("loading_tools"); - const getToolsForModelStartedAt = Date.now(); - assert( - workspaceId.trim().length > 0, - "AIService.streamMessage requires a non-empty workspaceId" - ); - if (advisorExperimentEnabled && agentAdvisorEnabled && advisorModelString.length === 0) { - workspaceLog.warn( - "Advisor tool enabled for agent without advisorModelString; suppressing", - { - effectiveAgentId, - } - ); - } - if (advisorToolEligible) { - assert( - advisorModelString.length > 0, - "AIService advisorModelString must be non-empty when advisor is eligible" - ); - } - // Mutable ref updated by StreamManager.prepareStep so the advisor tool reads the live - // transcript lazily at execute time instead of capturing a stale snapshot here. - const advisorTranscriptRef: { messages?: ModelMessage[] } = {}; - const advisorStepCaptureRef: AdvisorStepCaptureRef = { - currentStepText: "", - currentStepReasoning: "", - frozenSnapshotsByToolCallId: new Map(), - }; - const onAdvisorChunk: StreamTextOnChunk = ({ chunk }) => { - switch (chunk.type) { - case "text-delta": { - // Providers/SDKs can stream advisor text deltas under different field names. - const chunkText = extractChunkDeltaText(chunk as Record, [ - "textDelta", - "delta", - "text", - ]); - if (chunkText.length > 0) { - advisorStepCaptureRef.currentStepText += chunkText; - } - return; - } - case "reasoning-delta": { - // Anthropic signature updates can arrive as reasoning deltas without text. - const chunkText = extractChunkDeltaText(chunk as Record, [ - "text", - "textDelta", - "delta", - ]); - if (chunkText.length > 0) { - advisorStepCaptureRef.currentStepReasoning += chunkText; - } - return; - } - case "tool-call": { - if (chunk.toolName !== "advisor") { - return; - } - const toolCallId = chunk.toolCallId?.trim?.() ?? ""; - // Skip malformed tool calls defensively — the normal tool-error - // path will handle bad input; crashing the stream callback would - // be worse than missing the snapshot. - if ( - toolCallId.length === 0 || - !isPlainObject(chunk.input) || - advisorStepCaptureRef.frozenSnapshotsByToolCallId.has(toolCallId) - ) { - return; - } - advisorStepCaptureRef.frozenSnapshotsByToolCallId.set(toolCallId, { - toolCallId, - toolName: "advisor", - input: { ...chunk.input }, - stepText: advisorStepCaptureRef.currentStepText, - stepReasoning: advisorStepCaptureRef.currentStepReasoning, - }); - return; - } - default: - return; - } - }; - // Tool-side generateText() results do not consistently echo mux.costsIncluded in - // providerMetadata, so remember the resolved billing mode from model creation and - // re-stamp it before converting usage into display/session costs. - const toolModelCostsIncludedByModelString = new Map(); - // Creation-time pricing identity for tool-created models (advisor): a - // Coder catalog refresh can remove/retag the instance while the tool - // request runs, and resolving the identity from live config at - // completion would price/persist the usage under a different provider. - const toolModelMetadataModelByModelString = new Map(); - // Normalize: undefined -> default, null -> unlimited, positive int -> exact cap. - const advisorMaxUses = - cfg.advisorMaxUsesPerTurn === null - ? null - : (cfg.advisorMaxUsesPerTurn ?? ADVISOR_DEFAULT_MAX_USES_PER_TURN); - assert( - cfg.advisorMaxOutputTokens == null || - (Number.isInteger(cfg.advisorMaxOutputTokens) && cfg.advisorMaxOutputTokens > 0), - "AIService advisorMaxOutputTokens must be null, undefined, or a positive integer" - ); - const advisorMaxOutputTokens = - cfg.advisorMaxOutputTokens != null && cfg.advisorMaxOutputTokens > 0 - ? cfg.advisorMaxOutputTokens - : undefined; - // Clamp the persisted advisor thinking level so the tool metadata matches the - // providerOptions actually sent to generateText(). - const advisorReasoningLevel = enforceThinkingPolicy( - advisorModelString, - cfg.advisorThinkingLevel ?? THINKING_LEVEL_OFF, - undefined, - this.providerService.getConfig() - ); - const runtimeType = getRuntimeType(metadata.runtimeConfig); - const xumEnv = getXumEnv(metadata.projectPath, runtimeType, metadata.name, { - workspaceId, - modelString, - thinkingLevel: thinkingLevel ?? "off", - costsUsd: sessionCostsUsd, - }); - const getWorkflowProjectTrusted = () => isWorkspaceProjectTrusted(this.config, metadata); - - const workflowService = - dynamicWorkflowsExperimentEnabled && this.taskService != null - ? new WorkflowService({ - runStore: new WorkflowRunStore({ - sessionDir: this.config.getSessionDir(workspaceId), - }), - onRunStatusChanged: async (event) => { - if (!isTerminalWorkflowRunStatus(event.status)) { - await this.taskService?.resetWorkflowRunTerminalAttention({ - ownerWorkspaceId: event.workspaceId, - runId: event.runId, - }); - } - await this.onWorkflowRunStatusChanged?.(event); - }, - runtimeFactory: new QuickJSRuntimeFactory(), - taskAdapterFactory: (runId, workflowName) => - new WorkflowTaskServiceAdapter({ - taskService: this.taskService!, - parentWorkspaceId: workspaceId, - workflowRunId: runId, - workflowName, - defaultAgentId: DEFAULT_WORKFLOW_AGENT_ID, - patchToolConfig: { - workspaceId, - cwd: workspacePath, - runtime, - runtimeTempDir, - workspaceSessionDir: this.config.getSessionDir(workspaceId), - trusted: getWorkflowProjectTrusted(), - }, - getProjectTrusted: getWorkflowProjectTrusted, - experiments: { - ...experiments, - dynamicWorkflows: dynamicWorkflowsExperimentEnabled, - workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, - }, - }), - resolveWorkflowScript: (scriptPath) => - resolveWorkflowScript({ - scriptPath, - runtime, - workspacePath, - projectSearchRoot: projectCheckoutRoot ?? workspacePath, - projectTrusted: getWorkflowProjectTrusted(), - includeAgentPlugins: this.isAgentPluginsEnabled(), - skillStorageContext: workflowSkillStorageContext, - }), - // Background workflow tools outlive the model turn that started them. Feed the - // terminal result back as a hidden user turn so the parent agent continues - // instead of leaving the user staring at the workflow report payload. - onBackgroundRunTerminal: async ({ runId, status, result, run }) => { - if (run.parentWorkflow != null) { - return; - } - if (this.taskService != null) { - await this.taskService.enqueueWorkflowRunTerminalAttention({ - ownerWorkspaceId: workspaceId, - runId, - status, - }); - return; - } - - const continuationSender = this.workflowResultContinuationSender; - if (continuationSender == null) { - log.warn("Workflow completed but no continuation sender is configured", { - workspaceId, - runId, - }); - return; - } - - const scriptPath = run.workflow.sourcePath ?? run.workflow.name; - const rawCommand = `workflow_run ${scriptPath}`; - const workflowResultMessage = buildWorkflowResultContextMessage({ - rawCommand, - name: scriptPath, - runId, - status, - result, - run, - }); - for (;;) { - const invocationCurrent = await continuationSender.isWorkflowInvocationCurrent( - workspaceId, - runId - ); - if (!invocationCurrent) { - if (this.isStreaming(workspaceId)) { - await waitForWorkflowContinuationRetry(); - continue; - } - log.debug("Skipping superseded workflow continuation", { workspaceId, runId }); - return; - } - - const sendResult = await continuationSender.sendMessage( - workspaceId, - workflowResultMessage, - { - model: modelString, - thinkingLevel: effectiveThinkingLevel, - // Carry the turn's pro mode so the workflow-result - // continuation does not silently drop back to standard. - reasoningMode, - agentId: effectiveAgentId, - toolPolicy: effectiveToolPolicy, - additionalSystemInstructions: scratchpadAdditionalSystemInstructions, - maxOutputTokens, - providerOptions: effectiveMuxProviderOptions, - experiments: { - ...experiments, - dynamicWorkflows: dynamicWorkflowsExperimentEnabled, - workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, - }, - skipAiSettingsPersistence: true, - muxMetadata: { - type: WORKFLOW_RESULT_METADATA_TYPE, - rawCommand, - commandPrefix: "workflow_run", - runId, - requestedModel: modelString, - }, - }, - { - skipAutoResumeReset: true, - synthetic: true, - agentInitiated: true, - requireIdle: true, - startStreamInBackground: true, - } - ); - if (sendResult.success) { - return; - } - if (!isWorkspaceBusyIdleOnlySend(sendResult.error)) { - log.warn("Failed to continue agent after workflow completion", { - workspaceId, - runId, - error: sendResult.error, - }); - return; - } - await waitForWorkflowContinuationRetry(); - } - }, - getCurrentProjectTrusted: () => isWorkspaceProjectTrusted(this.config, metadata), - runnerId: `workflow-runner:${workspaceId}`, - }) - : undefined; - - // Create assistant message ID early so tool-side usage reporting and nested tool events - // stay scoped to this specific assistant turn. The placeholder is appended to history below - // (after the abort check). - const assistantMessageId = createAssistantMessageId(); - const allowLegacyInvalidWorkflowAgentOutputSchema = - await this.shouldAllowLegacyInvalidWorkflowAgentOutputSchema(metadata); - // Hoisted so the refusal-fallback prepare() can rebuild the toolset for a - // different model with identical context (only the model string varies). - const toolsForModelConfig: ToolConfiguration = { - cwd: workspacePath, - runtime, - projects: getProjects(metadata), - secrets: await secretsToRecord(projectSecrets), - xumEnv, - runtimeTempDir, - ...(advisorToolEligible - ? { - advisorRuntime: { - advisorModelString, - reasoningLevel: advisorReasoningLevel, - maxUsesPerTurn: advisorMaxUses, - maxOutputTokens: advisorMaxOutputTokens, - getTranscriptSnapshot: () => { - const messages = advisorTranscriptRef.messages; - assert( - messages != null, - "AIService advisor transcript ref must be populated before advisor execution" - ); - return messages; - }, - takeToolCallSnapshot: (toolCallId) => { - const normalizedToolCallId = toolCallId.trim(); - assert(normalizedToolCallId.length > 0, "advisor toolCallId must be non-empty"); - const snapshot = - advisorStepCaptureRef.frozenSnapshotsByToolCallId.get(normalizedToolCallId); - if (snapshot == null) { - return undefined; - } - const didDelete = - advisorStepCaptureRef.frozenSnapshotsByToolCallId.delete(normalizedToolCallId); - assert(didDelete, "advisor tool-call snapshot must be deleted when consumed"); - assert( - snapshot.toolName === "advisor", - "advisor snapshot must belong to advisor" - ); - return snapshot; - }, - createModel: async (ms: string) => { - const advisorModelString = ms.trim(); - assert( - advisorModelString.length > 0, - "advisor model string must be non-empty when creating an advisor model" - ); - // ONE config snapshot for both SDK model creation and the - // pinned pricing identity: two independent reads would let - // a catalog refresh land between them, running the request - // on one wire while recording usage under another type. - const advisorProvidersConfig = this.config.loadProvidersConfig() ?? {}; - // View snapshot captured at creation time for option - // building (buildProviderOptions takes the oRPC view, not - // the raw config shape). - const advisorOptionsProvidersConfig = this.providerService.getConfig(); - const advisorModel = await this.createModel(advisorModelString, undefined, { - workspaceId, - providersConfig: advisorProvidersConfig, - }); - if (!advisorModel.success) { - throw new Error( - `Failed to create advisor model: ${getErrorMessage(advisorModel.error)}` - ); - } - toolModelCostsIncludedByModelString.set( - advisorModelString, - modelCostsIncluded(advisorModel.data) - ); - // Same effective-route rule as createModelWithPinnedMetadata: - // a coder: selection whose gateway is unavailable falls away - // to a direct provider inside createModel, and identity or - // options derived from the raw selection (instance type) - // would diverge from the model actually created. - const advisorEffectiveModelString = - this.providerModelFactory.resolveEffectiveModelString( - advisorModelString, - undefined, - advisorProvidersConfig - ); - const advisorOnCoderRoute = advisorEffectiveModelString.startsWith("coder:"); - // Creation-time identity from the SAME snapshot the model - // was created from (see map declaration). - toolModelMetadataModelByModelString.set( - advisorModelString, - resolveModelForMetadata( - advisorOnCoderRoute - ? advisorModelString - : normalizeToCanonical(advisorEffectiveModelString), - advisorProvidersConfig - ) - ); - // Wire-resolved identity for option construction, same - // snapshot: a raw coder: string carries no wire info, so - // buildProviderOptions would emit the wrong (or no) - // namespace for custom-named/cross-typed instances. Mirrors - // resolveOptionsCanonicalModel's shadow + wire rules. - const advisorOptionsModelString = (() => { - // Custom providers keep their RAW identity: with the - // pinned snapshot below, buildProviderOptions remaps the - // wire namespace itself while still resolving - // mappedToModel alias metadata from the custom entry. - if (!advisorModelString.startsWith("coder:")) { - return advisorModelString; - } - const coderSection = advisorProvidersConfig.coder; - if (isCustomProviderConfig(coderSection)) { - return advisorModelString; - } - if (!advisorOnCoderRoute) { - // Fallback-away: options must target the route that - // actually serves the request, not the instance's wire. - return normalizeToCanonical(advisorEffectiveModelString); - } - const wire = resolveCoderWireCanonicalModel( - advisorModelString.slice("coder:".length), - coderSection as - | { discoveredProviders?: unknown; additionalProviders?: unknown } - | undefined - ); - return wire ? `${wire.origin}:${wire.modelId}` : advisorModelString; - })(); - return { - model: advisorModel.data, - optionsModelString: advisorOptionsModelString, - optionsProvidersConfig: advisorOptionsProvidersConfig, - }; - }, - abortSignal: combinedAbortSignal, - }, - } - : {}), - ...(toolSearchRuntime ? { toolSearchRuntime } : {}), - capabilityModelString, - openaiWireFormat: effectiveMuxProviderOptions?.openai?.wireFormat, - xaiNativeToolsEnabled: routeProvider === "xai", - xaiSearchParameters: effectiveMuxProviderOptions.xai?.searchParameters, - backgroundProcessManager: this.backgroundProcessManager, - // Plan agent configuration for plan file access. - // - read: plan file is readable in all agents (useful context) - // - write: allowed in all agents; plan agents still lock other edits to the exact plan path - planFileOnly: agentIsPlanLike, - emitChatEvent: (event) => { - // Defensive: tools should only emit events for the workspace they belong to. - if ("workspaceId" in event && event.workspaceId !== workspaceId) { - return; - } - if (event.type === "workflow-run-attached") { - return this.streamManager.attachWorkflowRunToToolCall(event).then(() => { - this.emit(event.type, event as never); - }); - } - this.emit(event.type, event as never); - }, - workspaceProjectPath: metadata.projectPath, - workspaceExecutionRootPath: metadata.subProjectPath ?? metadata.projectPath, - workspaceSessionDir: this.config.getSessionDir(workspaceId), - planFilePath, - ancestorPlanFilePaths, - workspaceId, - xumScope, - timelineService: timelineExperimentEnabled ? this.timelineService : undefined, - workspaceHeartbeatService: this.workspaceHeartbeatService, - workflowService, - goalService: workspaceGoalService, - goalDefaults: effectiveGoalDefaults, - enableGoalTools: goalToolAvailability, - // Only child workspaces (tasks) can report to a parent. - enableAgentReport: Boolean(metadata.parentWorkspaceId), - // RLM family messaging: gate on the flags persisted on the task record at - // spawn — NOT the live send-options experiments — so a child spawned under RLM - // keeps task_message_parent/task_message_sibling across app restarts and - // frontend experiment toggles. Uses the full RLM predicate (rlm AND a PTC - // parent) rather than the bare rlm bit: the hidden sub-flag can stay true - // after its parent is disabled, and such children run outside RLM. Workflow- - // owned workers are excluded: they hand results to WorkflowRunner through the - // journal path. - enableFamilyMessaging: - Boolean(metadata.parentWorkspaceId) && - metadata.workflowTask == null && - isRlmModeEnabled( - findWorkspaceEntry(cfg, workspaceId)?.workspace.taskExperiments, - undefined - ), - workflowAgentOutputSchema: metadata.workflowTask?.outputSchema, - allowLegacyInvalidWorkflowAgentOutputSchema, - // External edit detection callback - recordFileState, - reportModelUsage: (event) => { - try { - const eventModel = event.model.trim(); - assert(eventModel.length > 0, "tool model usage event model must be non-empty"); - // Persist tool-side model usage under its own model bucket so session costs keep - // advisor/system-side pricing separate from the parent chat model. - const providerMetadata = markProviderMetadataCostsIncluded( - event.providerMetadata, - toolModelCostsIncludedByModelString.get(eventModel) - ); - // Prefer the creation-time identity captured when the tool model - // was created; models not created through the tool runtime fall - // back to live resolution (their identity is not coder-scoped). - const pinnedMetadataModel = toolModelMetadataModelByModelString.get(eventModel); - const metadataModel = - pinnedMetadataModel ?? - resolveModelForMetadata(eventModel, this.providerService.getConfig()); - this.streamManager.recordToolModelUsage(workspaceId, assistantMessageId, { - toolName: event.toolName, - toolCallId: event.toolCallId, - timestamp: event.timestamp, - model: eventModel, - metadataModel, - usage: event.usage, - ...(providerMetadata != null ? { providerMetadata } : {}), - }); - void (async () => { - try { - if (!this.sessionUsageService) { - return; - } - const displayUsage = createDisplayUsage( - event.usage, - eventModel, - providerMetadata, - metadataModel - ); - if (!displayUsage) { - return; - } - // Ledger keys resolve Coder identities to their record-time - // metadata identity — the CREATION-TIME pin when available, - // mirroring StreamManager.recordSessionUsage. Non-coder - // models keep the canonical key (their metadata identity can - // be a mappedToModel pricing alias, not the ledger bucket). - const canonicalModel = - eventModel.startsWith("coder:") && pinnedMetadataModel - ? pinnedMetadataModel - : normalizeUsageModelKey(eventModel, this.providerService.getConfig()); - await this.sessionUsageService.recordUsage( - workspaceId, - canonicalModel, - displayUsage - ); - this.emit("session-usage-delta", { - type: "session-usage-delta" as const, - workspaceId, - sourceWorkspaceId: workspaceId, - byModelDelta: { [canonicalModel]: displayUsage }, - timestamp: Date.now(), - }); - } catch (error) { - log.warn("Failed to record tool model usage", { - error, - workspaceId, - toolName: event.toolName, - model: event.model, - }); - } - })(); - } catch (error) { - log.warn("Failed to record tool model usage", { - error, - workspaceId, - toolName: event.toolName, - model: event.model, - }); - } - }, - onConfigChanged: () => this.providerService.notifyConfigChanged(), - taskService: this.taskService, - analyticsService: this.analyticsService, - desktopSessionManager: this.desktopSessionManager, - // Agent memory (memory experiment): per-scope write policy derived from - // the agent class (exec-like / plan-like / read-only). Project memory is - // host-local under xumHome, keyed by the stable project identity. - memoryService: this.memoryService, - memoryAccess: resolveMemoryAccessPolicy({ - planLike: agentIsPlanLike, - editingCapable: isExecLikeEditingCapableInResolvedChain(agentInheritanceChain), - }), - // Experiments for inheritance to subagents and workflow tool gating. - experiments: { - ...experiments, - dynamicWorkflows: dynamicWorkflowsExperimentEnabled, - memory: memoryExperimentEnabled, - timeline: timelineExperimentEnabled, - workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, - toolSearch: toolSearchExperimentEnabled, - claudeSkillsCompat: claudeSkillsCompatExperimentEnabled, - agentPlugins: agentPluginsExperimentEnabled, - }, - // Dynamic context for tool descriptions (moved from system prompt for better model attention) - availableSubagents: agentDefinitions, - availableSkills, - mcpPromptRuntime, - // Session-segment memory index advertised in the memory tool - // description (same disclosure mechanic as skills). - memoryIndexEntries: memoryContext?.indexEntries, - // Trust gating: only run hooks/scripts when the full shared workspace runtime is trusted. - trusted: sharedExecutionTrusted, - }; - const allTools = await getToolsForModel( - toolsModelString, - toolsForModelConfig, - workspaceId, - this.initStateManager, - toolInstructions, - mcpTools - ); - recordStartupPhaseTiming("getToolsForModelMs", getToolsForModelStartedAt); - const toolsWithDelegation = this.wrapToolsForDelegation( - workspaceId, - allTools, - delegatedToolNames - ); - - // Forward nested PTC tool events to the stream (tool-call-start/end only, - // not console events which appear in final result only). Shared with the - // refusal-fallback prepare() tool rebuild. - const emitNestedPtcToolEvent = (event: PTCEventWithParent) => { - if (event.type === "tool-call-start" || event.type === "tool-call-end") { - this.streamManager.emitNestedToolEvent(workspaceId, assistantMessageId, event); - } - }; - - // Host file loader backing mux.load (r12 bulk kernel ingestion). Built - // from the same cwd/runtime pair the file tools use so path resolution - // matches mux.file_read. Only honored by kernel-mode code_execution. - // SECURITY: the loader shares the tool hook trust gate — its bulk read - // runs through the same tool.execute pipeline as a hook-wrapped - // file_read call, so a trusted tool_pre denying sensitive paths gates - // mux.load too (it must not be a hook bypass for file_read). - const kernelFileLoader = createKernelFileLoader({ - cwd: toolsForModelConfig.cwd, - runtime: toolsForModelConfig.runtime, - hooks: deriveToolHookConfig(toolsForModelConfig) ?? undefined, - }); - - // Apply tool policy and PTC experiments (lazy-loads PTC dependencies only when needed). - const applyToolPolicyAndExperimentsStartedAt = Date.now(); - let tools = await applyToolPolicyAndExperiments({ - allTools: toolsWithDelegation, - extraTools: this.extraTools, - effectiveToolPolicy, - experiments, - emitNestedToolEvent: emitNestedPtcToolEvent, - sandbox: { - workspaceId, - sessionDir: this.config.getSessionDir(workspaceId), - kernelFileLoader, - }, - }); - recordStartupPhaseTiming( - "applyToolPolicyAndExperimentsMs", - applyToolPolicyAndExperimentsStartedAt - ); - - // Tool search (tool-search experiment): post-policy gate. Classification - // must consume the policy-filtered record so policy-disabled tools never - // enter the deferred catalog. This runs before every downstream consumer - // of `tools` (system-prompt rebuild, sentinel tool names, telemetry, - // streaming) so a dropped tool_catalog_search cannot leak anywhere. - // PTC gate uses the same condition toolAssembly uses to add code_execution: - // presence-sniffing the record would misfire on an MCP tool named - // code_execution (see prepareToolSearch). - const ptcEnabled = experiments?.programmaticToolCalling === true; - if (toolSearchRuntime) { - const toolSearchPrep = prepareToolSearch({ - tools, - mcpToolNames: Object.keys(mcpTools ?? {}), - mcpToolServers: mcpToolServerNames, - toolPolicy: effectiveToolPolicy, - ptcEnabled, - }); - tools = toolSearchPrep.tools; - if (toolSearchPrep.state) { - toolSearchRuntime.state = toolSearchPrep.state; - } - } - - const advisorToolAvailable = tools.advisor !== undefined; - const memoryToolAvailable = tools.memory !== undefined; - const finalMemoryContext = await upgradeMemoryContextForModel( - memoryToolAvailable, - modelString - ); - const finalStreamSystemContext = - advisorToolAvailable === advisorToolEligible && - memoryToolAvailable === memoryToolEligible && - finalMemoryContext === memoryContext - ? prePolicyStreamSystemContext - : await (async () => { - // Rebuild when policy/experiments changed advisor or memory tool - // availability (stale advisor guidance / memory index must not advertise - // absent tools), or when the post-policy memory tool enables the - // token-budgeted hot block. On SSH this context build scans agents, - // skills, and instruction files over many small remote ops. - const rebuildStreamSystemContextStartedAt = Date.now(); - const rebuiltContext = await buildStreamSystemContextForToolset( - { - advisorToolAvailable, - memoryToolAvailable, - }, - modelString, - finalMemoryContext - ); - recordStartupPhaseTiming( - "rebuildStreamSystemContextMs", - rebuildStreamSystemContextStartedAt - ); - return rebuiltContext; - })(); - systemMessageTokens = finalStreamSystemContext.systemMessageTokens; - systemMessage = finalStreamSystemContext.systemMessage; - - // Kept as a standalone prefix so the refusal-fallback prepare() can reapply - // it to a system prompt rebuilt for the fallback model. - let mcpWarningPrefix: string | undefined; - if (mcpStats && mcpStats.failedServerCount > 0) { - const failedNames = mcpStats.failedServerNames.join(", "); - workspaceLog.warn("MCP servers failed to start", { failedNames }); - // Reapply the MCP startup warning after rebuilding the final system prompt. - mcpWarningPrefix = `[Warning: ${mcpStats.failedServerCount} MCP server(s) failed to start: ${failedNames}. Tools from these servers are unavailable. Check MCP server configuration in Settings.]\n\n`; - systemMessage = `${mcpWarningPrefix}${systemMessage}`; - // Keep context-size estimation accurate after mutating the system prompt. - const metadataModel = resolveModelForMetadata(modelString, requestProvidersConfig); - const tokenizer = await getTokenizerForModel(modelString, metadataModel); - systemMessageTokens = await tokenizer.countTokens(systemMessage); - } - - // Waterfall hook point: registered middleware may rewrite the final system - // prompt or filter the toolset. Contract for future consumers: any content - // middleware adds to a request must exist as a durable event first - // (append-time materialization) — see eventSpine module docs. Gated on - // hasMiddleware so the empty-pipeline hot path skips ctx construction. - if (eventSpine.hasMiddleware("request.assemble")) { - const assembleCtx: RequestAssembleContext = { - workspaceId, - modelString, - systemMessage, - tools, - }; - await eventSpine.run("request.assemble", assembleCtx); - tools = assembleCtx.tools; - // PTC needs no post-hook bridge reconcile: bridgeable tools are not - // in the hook-visible record, so middleware cannot invalidate the - // ToolBridge code_execution closes over. Tools promoted to the - // model-visible set (policy-required tools, mcp_prompt_get) are - // excluded from the bridge at assembly time (see toolAssembly), so - // a hook that filters or wraps them affects the only dispatch path. - // Tool-search state was classified from the pre-hook record; a hook - // that added/removed tools would leave allToolNames/deferred/active - // sets stale (prepareStep scoping + sentinel names both read them). - // Rebuild in place so the state describes the post-hook toolset. - if (toolSearchRuntime?.state) { - tools = rebuildToolSearchState(toolSearchRuntime.state, { - tools, - mcpToolNames: Object.keys(mcpTools ?? {}), - mcpToolServers: mcpToolServerNames, - toolPolicy: effectiveToolPolicy, - ptcEnabled, - }).tools; - } - if (assembleCtx.systemMessage !== systemMessage) { - systemMessage = assembleCtx.systemMessage; - // Keep context-size estimation accurate after middleware mutation. - const metadataModel = resolveModelForMetadata(modelString, requestProvidersConfig); - const tokenizer = await getTokenizerForModel(modelString, metadataModel); - systemMessageTokens = await tokenizer.countTokens(systemMessage); - } - } - - // Re-activate deferred tools discovered by tool_catalog_search in earlier turns - // without requiring a new search. Must run before the sentinel list is - // computed so pre-activated tools are advertised in agent transitions. - if (toolSearchRuntime?.state) { - seedToolSearchActivationsFromMessages(toolSearchRuntime.state, messagesWithSentinel); - } - - // Agent-transition sentinels must list only tools the model can actually - // see on the first step: deferred, not-yet-activated MCP tools are - // hidden by activeTools scoping, so advertising them would steer the - // model toward unavailable tool calls. - const toolNamesForSentinel = ( - computeActiveToolNames(toolSearchRuntime?.state) ?? Object.keys(tools) - ).sort(); - - // Run the full message preparation pipeline (inject context, transform, validate). - // This is a purely functional pipeline with no service dependencies. - emitStartupBreadcrumb("preparing_request"); - const prepareMessagesForProviderStartedAt = Date.now(); - const finalMessages = await prepareMessagesForProvider({ - messagesWithSentinel, - effectiveAgentId, - toolNamesForSentinel, - planContentForTransition, - planFilePath, - postCompactionAttachments, - providerForMessages: wireProviderName, - effectiveThinkingLevel, - modelString, - providersConfig: requestProvidersConfig, - anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl, - workspaceId, - }); - recordStartupPhaseTiming("prepareMessagesForProviderMs", prepareMessagesForProviderStartedAt); - - captureMcpToolTelemetry({ - telemetryService: this.telemetryService, - mcpStats, - mcpTools, - tools, - mcpSetupDurationMs, - workspaceId, - modelString, - effectiveAgentId, - metadata, - effectiveToolPolicy, - }); - - if (combinedAbortSignal.aborted) { - return Ok(this.createAbortedTurnHandle(assistantMessageId)); - } - - const requestHistorySequence = providerRequestMessages.reduce( - (latest, message) => Math.max(latest, message.metadata?.historySequence ?? -1), - -1 - ); - const assistantMessage = createMuxMessage(assistantMessageId, "assistant", "", { - ...(requestHistorySequence >= 0 ? { requestHistorySequence } : {}), - timestamp: Date.now(), - model: canonicalModelString, - routedThroughGateway, - systemMessageTokens, - agentId: effectiveAgentId, - }); - - // Append to history to get historySequence assigned - const appendResult = await this.historyService.appendToHistory(workspaceId, assistantMessage); - if (!appendResult.success) { - return Err({ type: "unknown", raw: appendResult.error }); - } - - // Get the assigned historySequence - const historySequence = assistantMessage.metadata?.historySequence ?? 0; - - // Handle simulated stream scenarios (OpenAI SDK testing features). - // These emit synthetic stream events without calling an AI provider. - const forceContextLimitError = - modelString.startsWith("openai:") && - effectiveMuxProviderOptions.openai?.forceContextLimitError === true; - const simulateToolPolicyNoopFlag = - modelString.startsWith("openai:") && - effectiveMuxProviderOptions.openai?.simulateToolPolicyNoop === true; - - if (forceContextLimitError || simulateToolPolicyNoopFlag) { - const simulationCtx: SimulationContext = { - workspaceId, - assistantMessageId, - canonicalModelString, - routedThroughGateway, - ...(routeProvider != null ? { routeProvider } : {}), - historySequence, - systemMessageTokens, - effectiveAgentId, - effectiveMode, - metadataMode: legacyModeForMetadata, - effectiveThinkingLevel, - emit: (event, data) => this.emit(event, data), - }; - - // Simulations emit their synthetic events before returning, so the - // handle settles immediately with the matching terminal outcome. - if (forceContextLimitError) { - const streamError = await simulateContextLimitError(simulationCtx, this.historyService); - return Ok( - this.createSettledTurnHandle(assistantMessageId, { status: "failed", streamError }) - ); - } - await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); - return Ok(this.createSettledTurnHandle(assistantMessageId, { status: "completed" })); - } - - // Build provider options based on thinking level and request-sliced message history. - const truncationMode = openaiTruncationModeOverride; - // Use the same boundary-sliced payload history that we send to the provider. - // This keeps OpenAI request state aligned with the explicit history Xum sends. - // Pass workspaceId to derive stable promptCacheKey for OpenAI caching. - const buildProviderOptionsStartedAt = Date.now(); - const promptCacheScope = derivePromptCacheScope(metadata); - const providerOptions = buildProviderOptions( - optionsModelString, - effectiveThinkingLevel, - providerRequestMessages, - (id) => this.streamManager.isResponseIdLost(id), - effectiveMuxProviderOptions, - workspaceId, - truncationMode, - requestProvidersConfig, - routeProvider, - promptCacheScope, - reasoningMode - ); - recordStartupPhaseTiming("buildProviderOptionsMs", buildProviderOptionsStartedAt); - - // Build per-request HTTP headers (e.g., workspace correlation and - // anthropic-beta for 1M context). This is the single injection site for - // provider-specific headers, handling both direct and gateway-routed models - // identically. - const buildRequestConfigStartedAt = Date.now(); - let requestHeaders = buildRequestHeaders( - optionsModelString, - effectiveMuxProviderOptions, - workspaceId, - requestProvidersConfig, - routeProvider - ); - - // --- Model parameter overrides from providers.jsonc --- - // Raw file view pinned to the SAME factory-resolved instance identity - // as requestProvidersConfig: resolveModelParameterOverrides resolves - // mappedToModel aliases and sampling gates via resolveModelForMetadata - // internally, so a live raw load would let a concurrent instance retag - // derive those decisions from another type than the created SDK model. - const providersConfig = pinCoderInstanceRawProvidersConfig( - this.config.loadProvidersConfig(), - modelString, - modelResult.data.coderSelectedInstance - ); - // Override config identity follows the Coder instance TYPE, not the - // name-canonicalized provider: a cross-typed instance ({name: "openai", - // type: "anthropic"}) canonicalizes to openai:, which would apply - // the OpenAI block's wildcard/model settings to an Anthropic-wire - // request and ignore the intended anthropic block. KNOWN instances with - // no catalog identity ({name: "anthropic", type: "openai-compat"}, - // vendor-less vercel) keep the RAW gateway-scoped identity — falling - // back to the name-canonical form would apply the name-alike provider's - // block (and merge its SDK-shaped extras) onto a different wire. - // Unknown instances and shadowed prefixes keep the canonical identity. - const resolveOverridesIdentity = ( - rawModelString: string, - canonical: string, - canonicalProvider: string, - // The request's pinned snapshot (main or fallback) — never a fresh - // read, so the override identity matches the created model's wire. - currentProvidersConfig: ReturnType - ): { providerName: string; modelString: string; coderDerived: boolean } => { - if (rawModelString.startsWith("coder:")) { - const metadataCanonical = resolveCoderGatewayMetadataModel( - rawModelString, - currentProvidersConfig - ); - if (metadataCanonical != null) { - const separator = metadataCanonical.indexOf(":"); - return { - providerName: - separator > 0 ? metadataCanonical.slice(0, separator) : canonicalProvider, - modelString: metadataCanonical, - coderDerived: true, - }; - } - const coderSection = currentProvidersConfig?.coder; - if (!isCustomProviderConfig(coderSection)) { - const wire = resolveCoderWireCanonicalModel( - rawModelString.slice("coder:".length), - coderSection as - | { discoveredProviders?: unknown; additionalProviders?: unknown } - | undefined - ); - if (wire) { - // Known but unmappable: same coder-scoped identity as - // non-canonical unmappable instances (coder:llm-proxy/x), whose - // canonical form is already the raw string. coderDerived stays - // false: coder-block extras are the user's explicit config for - // this exact gateway model and merge into the wire namespace. - return { providerName: "coder", modelString: rawModelString, coderDerived: false }; - } - } - } - const separator = canonical.indexOf(":"); - return { - providerName: separator > 0 ? canonical.slice(0, separator) : canonicalProvider, - modelString: canonical, - coderDerived: false, - }; - }; - const overridesIdentity = resolveOverridesIdentity( - modelString, - canonicalModelString, - canonicalProviderName, - requestProvidersConfig - ); - const resolvedOverrides = resolveModelParameterOverrides( - providersConfig, - overridesIdentity.providerName, - overridesIdentity.modelString, - effectiveModelString - ); - - // Merge provider extras (user knobs) UNDER Xum-built options (safety-critical). - // Recursive merge within the provider namespace preserves non-conflicting nested - // subfields (e.g., user reasoning.max_tokens alongside Xum reasoning.enabled). - // Xum-built values win on leaf conflicts for safety of thinking/reasoning/cache. - // Shared by the initial build and mid-turn thinking-level rebuilds so both - // produce identically-shaped options. - // Namespace key must match what buildProviderOptions computes internally - // (wire origin for gateway-scoped Coder models), or extras merge under a - // namespace the SDK never reads. - const providerOptionsNamespaceKey = resolveProviderOptionsNamespaceKey( - wireProviderName, - routeProvider - ); - // Wire-compat gate for provider extras: standard call settings - // (temperature, maxOutputTokens, ...) are SDK-agnostic, but extras are - // shaped for the override block's own SDK namespace. A type-derived - // Coder identity whose native provider differs from the wire SDK - // (coder:openrouter/... or coder:google/... speak OpenAI-chat on the - // wire) must not merge OpenRouter/Google-shaped extras into the OpenAI - // namespace, where the SDK would reject or silently drop them. - // Anthropic/openai-typed instances match their wire and keep extras; - // non-Coder identities keep existing behavior. - const extrasWireCompatible = - !overridesIdentity.coderDerived || - overridesIdentity.providerName === providerOptionsNamespaceKey; - const mergeModelParameterExtras = ( - builtOptions: Record - ): Record => { - if (!resolvedOverrides.providerExtras || !extrasWireCompatible) { - return builtOptions; - } - const muxProviderNamespace = builtOptions[providerOptionsNamespaceKey]; - return { - ...builtOptions, - [providerOptionsNamespaceKey]: isPlainObject(muxProviderNamespace) - ? mergeProviderExtrasUnderMux(resolvedOverrides.providerExtras, muxProviderNamespace) - : resolvedOverrides.providerExtras, - }; - }; - const mergedProviderOptions = mergeModelParameterExtras( - providerOptions as Record - ); - - recordStartupPhaseTiming("buildRequestConfigMs", buildRequestConfigStartedAt); - - if (Object.keys(resolvedOverrides.standard).length > 0 || resolvedOverrides.providerExtras) { - log.debug( - `Resolved model parameter overrides for ${canonicalModelString}`, - resolvedOverrides - ); - } - - // --- Mid-turn thinking-level override support --- - // Floor resolved by AgentSession when present; internal callers fall back - // to the model default so clamping never loosens below policy. - const minThinkingLevel = - providedMinThinkingLevel ?? - resolveMinimumThinkingLevel(modelString, undefined, requestProvidersConfig); - // Rebuilds provider options for a new level using the exact same pipeline - // as the initial build (policy clamp → resolveEffectiveThinkingLevel → - // buildProviderOptions → providers.jsonc extras merge). Consumed by - // StreamManager's prepareStep; `null` ⇒ skip (no-op or model-swap level). - const currentEffectiveLevelRef = { current: effectiveThinkingLevel }; - // Pure recompute shared by the mid-turn rebuild closure and the turn - // envelope's pending-override fold: no ref mutation, so envelope - // emission can preview the step-0 result without making prepareStep - // think the level was already applied. - const computeRebuiltProviderOptions = ( - level: ThinkingLevel, - currentLevel: ThinkingLevel - ): { effectiveLevel: ThinkingLevel; providerOptions: Record } | null => { - const clamped = enforceThinkingPolicy( - modelString, - level, - minThinkingLevel, - requestProvidersConfig - ); - const effective = resolveEffectiveThinkingLevel( - modelString, - clamped, - requestProvidersConfig - ); - if (effective === currentLevel) { - return null; - } - // off ↔ non-off on grok-4-1-fast selects a different model instance — - // not expressible via provider options on the in-flight stream. - if (isXaiGrokFastVariantSwap(canonicalModelString, currentLevel, effective)) { - return null; - } - const rebuilt = buildProviderOptions( - optionsModelString, - effective, - providerRequestMessages, - (id) => this.streamManager.isResponseIdLost(id), - effectiveMuxProviderOptions, - workspaceId, - truncationMode, - requestProvidersConfig, - routeProvider, - promptCacheScope, - reasoningMode - ); - const merged = mergeModelParameterExtras(rebuilt as Record); - return { effectiveLevel: effective, providerOptions: merged }; - }; - const rebuildProviderOptionsForThinkingLevel: RebuildProviderOptionsForThinkingLevel = ( - level - ) => { - const result = computeRebuiltProviderOptions(level, currentEffectiveLevelRef.current); - if (result != null) { - currentEffectiveLevelRef.current = result.effectiveLevel; - } - return result; - }; - - // Debug dump: Log the complete LLM request when MUX_DEBUG_LLM_REQUEST is set - if (resolveXumEnvironmentValue("DEBUG_LLM_REQUEST", process.env) === "1") { - log.info( - `[MUX_DEBUG_LLM_REQUEST] Full LLM request:\n${JSON.stringify( - { - workspaceId, - model: modelString, - systemMessage, - messages: finalMessages, - tools: Object.fromEntries( - Object.entries(tools).map(([n, t]) => [ - n, - { description: t.description, inputSchema: t.inputSchema }, - ]) - ), - providerOptions: mergedProviderOptions, - thinkingLevel: effectiveThinkingLevel, - maxOutputTokens, - mode: legacyModeForMetadata, - agentId: effectiveAgentId, - toolPolicy: effectiveToolPolicy, - }, - null, - 2 - )}` - ); - - if (resolvedOverrides.standard && Object.keys(resolvedOverrides.standard).length > 0) { - log.debug("Model parameter overrides (standard):", resolvedOverrides.standard); - } - if (resolvedOverrides.providerExtras) { - log.debug( - "Model parameter overrides (provider extras):", - resolvedOverrides.providerExtras - ); - } - } - - if (combinedAbortSignal.aborted) { - await deleteAbortedPlaceholder(assistantMessageId); - return Ok(this.createAbortedTurnHandle(assistantMessageId)); - } - - // Capture request payload for the debug modal, then delegate to StreamManager. - const snapshot: DebugLlmRequestSnapshot = { - capturedAt: Date.now(), - workspaceId, - messageId: assistantMessageId, - model: modelString, - providerName: canonicalProviderName, - thinkingLevel: effectiveThinkingLevel, - mode: legacyModeForMetadata, - agentId: effectiveAgentId, - maxOutputTokens, - systemMessage, - messages: finalMessages, - }; - - try { - this.lastLlmRequestByWorkspace.set(workspaceId, structuredClone(snapshot)); - } catch (error) { - const errMsg = getErrorMessage(error); - workspaceLog.warn("Failed to capture debug LLM request snapshot", { error: errMsg }); - } - const toolsForStream = tools; - - const canQueueDevToolsRunMetadata = - this.devToolsService?.enabled === true && - typeof modelResult.data.model !== "string" && - modelResult.data.model.specificationVersion === "v4"; - - if (canQueueDevToolsRunMetadata) { - // Correlate pending run metadata with the specific request that reaches - // DevTools middleware to avoid cross-request policy leakage. Queue only - // when middleware is guaranteed to run (LanguageModelV3). - pendingRunMetadataId = String(streamToken); - this.devToolsService.setPendingRunMetadata(workspaceId, pendingRunMetadataId, { - toolPolicy: - effectiveToolPolicy != null && effectiveToolPolicy.length > 0 - ? effectiveToolPolicy - : undefined, - // Join key for the replay verifier: re-anchors this recorded run to - // its turn-envelope row and assistant message (see DevToolsRun). - ...(requestHistorySequence >= 0 ? { requestHistorySequence } : {}), - }); - this.trackPendingDevToolsRunMetadata(assistantMessageId, workspaceId, pendingRunMetadataId); - requestHeaders = { - ...requestHeaders, - [DEVTOOLS_RUN_METADATA_ID_HEADER]: pendingRunMetadataId, - }; - } - - // --- Refusal fallback chain --- - // Resolved from app config by the RAW selection (metadata-aware inside): - // a cross-typed Coder instance (coder:openai/x, type anthropic) must use - // its own gateway-scoped chain, never the direct provider's. Task - // children can opt out via taskOnRefusal: "fail" (see - // resolveWorkspaceModelFallbackChain). - const modelFallbackChain = resolveWorkspaceModelFallbackChain( - this.config.loadConfigOrDefault(), - workspaceId, - modelString, - this.providerService.getConfig() - ); - - // Lazily rebuilds the per-model slice of this pipeline (model creation, - // provider-specific message prep, provider options, headers, parameter - // overrides) when StreamManager swaps to a fallback model after a - // refusal. Reusing the original request verbatim would leak - // provider-specific options/messages across providers. - const modelFallback: ModelFallbackOptions | undefined = - modelFallbackChain.length > 0 - ? { - chain: modelFallbackChain, - prepare: async (nextModelString, prepareOptions) => { - const fallbackSourceMessages = prepareOptions?.continuation - ? replaceOrAppendMessageById( - messages, - prepareOptions.continuation.assistantMessage - ) - : messages; - - // Preliminary thinking clamp for the factory call only (xAI - // variant swap; never Coder-metadata-dependent — same split - // as the main path). The FINAL level is recomputed below from - // the pinned nextProvidersConfig so a concurrent instance - // retag cannot leave the level derived from older metadata - // than the created SDK model. - const requestedNextThinkingLevel = - prepareOptions?.thinkingLevelOverride ?? effectiveThinkingLevel; - const preliminaryNextThinkingLevel = enforceThinkingPolicy( - nextModelString, - requestedNextThinkingLevel, - resolveMinimumThinkingLevel( - nextModelString, - lookupMinThinkingLevelOverride( - this.config.loadConfigOrDefault().minThinkingLevelByModel, - nextModelString - ), - this.providerService.getConfig() - ), - this.providerService.getConfig() - ); - - // Reset the primary model's injected chat-wire format before - // resolving the fallback: the fallback's wire is decided by - // ITS effective route, and the factory's direct-OpenAI branch - // reads this knob for model selection. - if (effectiveMuxProviderOptions.openai?.wireFormat !== userOpenAIWireFormat) { - effectiveMuxProviderOptions.openai = { - ...(effectiveMuxProviderOptions.openai ?? {}), - wireFormat: userOpenAIWireFormat, - }; - } - - const nextModelResult = await this.providerModelFactory.resolveAndCreateModel( - nextModelString, - preliminaryNextThinkingLevel, - effectiveMuxProviderOptions, - { agentInitiated, workspaceId } - ); - if (!nextModelResult.success) { - return Err(formatSendMessageError(nextModelResult.error).message); - } - const next = nextModelResult.data; - // Same single-snapshot rule as the main path, pinned to the - // fallback selection's factory-resolved instance. - const nextProvidersConfig = pinCoderInstanceProvidersConfig( - this.providerService.getConfig(), - nextModelString, - next.coderSelectedInstance - ); - // FINAL thinking clamp from the pinned snapshot: the message - // and option builders below must agree with the wire the - // factory created the fallback SDK model for. Re-clamps the - // source level against the fallback model's policy/floor (a - // mid-turn thinking override folded in by StreamManager wins - // over the send-time level). - const nextMinThinkingLevel = resolveMinimumThinkingLevel( - nextModelString, - lookupMinThinkingLevelOverride( - this.config.loadConfigOrDefault().minThinkingLevelByModel, - nextModelString - ), - nextProvidersConfig - ); - const nextThinkingLevel = enforceThinkingPolicy( - nextModelString, - requestedNextThinkingLevel, - nextMinThinkingLevel, - nextProvidersConfig - ); - const nextToolsIdentity = resolveToolsIdentity( - nextModelString, - next.effectiveModelString, - next.canonicalModelString, - next.coderWire, - nextProvidersConfig - ); - // Same effective-route rule as the main path's - // optionsModelString: a Coder fallback selection that itself - // fell away from the gateway must build options/headers for - // its effective route, not the pinned instance's wire. - const nextOptionsModelString = - nextModelString.startsWith("coder:") && - !next.effectiveModelString.startsWith("coder:") - ? nextToolsIdentity.modelString - : nextModelString; - if (nextToolsIdentity.openaiWireFormat != null) { - // Same in-place injection as the main path: the primary - // stream is dead once a refusal fallback runs, so every - // consumer (option/header rebuilds, mid-turn thinking - // rebuild closures) must see the fallback's wire. - effectiveMuxProviderOptions.openai = { - ...(effectiveMuxProviderOptions.openai ?? {}), - wireFormat: nextToolsIdentity.openaiWireFormat, - }; - } - - try { - // Rebuild the toolset for the fallback model: provider-native - // web tools and MCP schema sanitization are provider-specific - // (reusing Anthropic-shaped tools on OpenAI 400s, and vice - // versa silently drops web tooling). - // Same raw-identity rule as the main path's capability - // lookup: cross-typed Coder instances need the raw string. - const nextCapabilityModelString = resolveModelForMetadata( - nextModelString.startsWith("coder:") - ? nextModelString - : next.canonicalModelString, - nextProvidersConfig - ); - const nextAllTools = await getToolsForModel( - // Wire identity, mirroring the main path: provider-specific - // tool branches (Anthropic native web tools, OpenAI MCP - // schema sanitization) must key on the wire, not on the - // "coder" prefix or the name-canonical form. - nextToolsIdentity.modelString, - { - ...toolsForModelConfig, - capabilityModelString: nextCapabilityModelString, - // Snapshot from the main path is stale here: the - // fallback's wire decides Responses-only tool assembly. - openaiWireFormat: effectiveMuxProviderOptions.openai?.wireFormat, - xaiNativeToolsEnabled: next.routeProvider === "xai", - }, - workspaceId, - this.initStateManager, - toolInstructions, - mcpTools - ); - let nextTools = await applyToolPolicyAndExperiments({ - allTools: this.wrapToolsForDelegation( - workspaceId, - nextAllTools, - delegatedToolNames - ), - extraTools: this.extraTools, - effectiveToolPolicy, - experiments, - emitNestedToolEvent: emitNestedPtcToolEvent, - sandbox: { - workspaceId, - sessionDir: this.config.getSessionDir(workspaceId), - kernelFileLoader, - }, - }); - // Tool search: keep the per-stream state consistent with the - // fallback model's re-assembled toolset. rebuildToolSearchState - // mutates the state object in place — StreamManager's request - // holds a reference to it, so prepareStep reads current state. - if (toolSearchRuntime) { - if (toolSearchRuntime.state) { - nextTools = rebuildToolSearchState(toolSearchRuntime.state, { - tools: nextTools, - mcpToolNames: Object.keys(mcpTools ?? {}), - mcpToolServers: mcpToolServerNames, - toolPolicy: effectiveToolPolicy, - ptcEnabled, - }).tools; - } else if (!(mcpTools && TOOL_SEARCH_TOOL_NAME in mcpTools)) { - // The primary-path gate deactivated deferral (e.g. every - // MCP tool was policy-disabled). StreamManager was never - // handed scoping state, so tool_catalog_search must not appear in - // the fallback toolset either. Skipped when an MCP tool - // collides with the name: that record entry is a - // legitimate MCP tool, not our search tool. - const { [TOOL_SEARCH_TOOL_NAME]: _removed, ...rest } = nextTools; - nextTools = rest; - } - } - const nextMemoryToolAvailable = nextTools.memory !== undefined; - // Raw identity for prompt rebuilding too (the main path - // passes its raw modelString): "Model:"-scoped instructions - // and tokenizer-dependent memory budgeting must see the - // instance-typed identity, not the name-canonicalized one. - const nextMemoryContext = await upgradeMemoryContextForModel( - nextMemoryToolAvailable, - nextModelString - ); - - // Rebuild the system prompt for the fallback model (tool - // instructions and "Model:" sections are model-keyed), keeping - // the MCP failure warning if one was applied. - const nextSystemContext = await buildStreamSystemContextForToolset( - { - advisorToolAvailable: nextTools.advisor !== undefined, - memoryToolAvailable: nextMemoryToolAvailable, - }, - nextModelString, - nextMemoryContext - ); - let nextSystem = nextSystemContext.systemMessage; - let nextSystemTokens = nextSystemContext.systemMessageTokens; - if (mcpWarningPrefix != null) { - nextSystem = `${mcpWarningPrefix}${nextSystem}`; - // nextCapabilityModelString already resolved the raw - // coder identity; reuse it as the metadata model. - const nextTokenizer = await getTokenizerForModel( - nextModelString, - nextCapabilityModelString - ); - nextSystemTokens = await nextTokenizer.countTokens(nextSystem); - } - - // Waterfall hook point: the fallback request is rebuilt from - // scratch, so middleware-applied tool restrictions / prompt - // context from the primary run would otherwise be lost — run - // request.assemble over the rebuilt request too (see the - // primary-path run above). - if (eventSpine.hasMiddleware("request.assemble")) { - const nextAssembleCtx: RequestAssembleContext = { - workspaceId, - modelString: nextModelString, - systemMessage: nextSystem, - tools: nextTools, - }; - await eventSpine.run("request.assemble", nextAssembleCtx); - nextTools = nextAssembleCtx.tools; - // Same reconcile as the primary path: tool-search state - // must describe the post-hook toolset. - if (toolSearchRuntime?.state) { - nextTools = rebuildToolSearchState(toolSearchRuntime.state, { - tools: nextTools, - mcpToolNames: Object.keys(mcpTools ?? {}), - mcpToolServers: mcpToolServerNames, - toolPolicy: effectiveToolPolicy, - ptcEnabled, - }).tools; - } - if (nextAssembleCtx.systemMessage !== nextSystem) { - nextSystem = nextAssembleCtx.systemMessage; - const nextTokenizer = await getTokenizerForModel( - nextModelString, - nextCapabilityModelString - ); - nextSystemTokens = await nextTokenizer.countTokens(nextSystem); - } - } - - // Same active-set scoping as the primary sentinel: never - // advertise deferred, not-yet-activated MCP tools. Computed - // AFTER the request.assemble hook (like the primary path) so - // transition guidance never advertises middleware-removed - // tools. - const nextToolNamesForSentinel = ( - computeActiveToolNames(toolSearchRuntime?.state) ?? Object.keys(nextTools) - ).sort(); - - const { providerRequestMessages: nextProviderRequestMessages } = - prepareProviderRequestMessages( - fallbackSourceMessages, - next.wireProviderName, - nextThinkingLevel - ); - const nextFinalMessages = await prepareMessagesForProvider({ - messagesWithSentinel: addInterruptedSentinel(nextProviderRequestMessages), - effectiveAgentId, - toolNamesForSentinel: nextToolNamesForSentinel, - planContentForTransition, - planFilePath, - postCompactionAttachments, - providerForMessages: next.wireProviderName, - effectiveThinkingLevel: nextThinkingLevel, - // RAW fallback identity, matching the main path's raw - // modelString: canonicalization can rewrite cross-typed - // Coder instances (coder:openai/x, type anthropic) to a - // direct-provider string, hiding the instance metadata - // from cache/option/header builders. - modelString: nextModelString, - providersConfig: nextProvidersConfig, - anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl, - workspaceId, - }); - - const nextProviderOptions = buildProviderOptions( - nextOptionsModelString, - nextThinkingLevel, - nextProviderRequestMessages, - (id) => this.streamManager.isResponseIdLost(id), - effectiveMuxProviderOptions, - workspaceId, - truncationMode, - nextProvidersConfig, - next.routeProvider, - promptCacheScope, - reasoningMode - ); - - // buildProviderOptions re-gates pro mode for each fallback model, - // so the native option never leaks onto unsupported fallbacks. - let nextHeaders = buildRequestHeaders( - nextOptionsModelString, - effectiveMuxProviderOptions, - workspaceId, - nextProvidersConfig, - next.routeProvider - ); - if (pendingRunMetadataId != null) { - // Keep DevTools run correlation on fallback requests too. - nextHeaders = { - ...nextHeaders, - [DEVTOOLS_RUN_METADATA_ID_HEADER]: pendingRunMetadataId, - }; - } - - // Same type-derived override identity as the main path: - // cross-typed Coder fallbacks must not read the name-alike - // provider's override block. - const nextOverridesIdentity = resolveOverridesIdentity( - nextModelString, - next.canonicalModelString, - next.canonicalProviderName, - nextProvidersConfig - ); - const nextOverrides = resolveModelParameterOverrides( - // Same pinned-raw-view rule as the main path, keyed to - // the fallback selection's own instance. - pinCoderInstanceRawProvidersConfig( - this.config.loadProvidersConfig(), - nextModelString, - next.coderSelectedInstance - ), - nextOverridesIdentity.providerName, - nextOverridesIdentity.modelString, - next.effectiveModelString - ); - const nextNamespaceKey = resolveProviderOptionsNamespaceKey( - next.wireProviderName, - next.routeProvider - ); - // Same wire-compat gate as the main path: type-derived - // extras only merge when the override block's SDK matches - // the wire namespace. - const nextExtrasWireCompatible = - !nextOverridesIdentity.coderDerived || - nextOverridesIdentity.providerName === nextNamespaceKey; - // Mirrors mergeModelParameterExtras for the fallback model; - // shared by this baseline build and mid-turn rebuilds below. - const mergeNextModelParameterExtras = ( - builtOptions: Record - ): Record => { - if (!nextOverrides.providerExtras || !nextExtrasWireCompatible) { - return builtOptions; - } - const nextMuxNamespace = builtOptions[nextNamespaceKey]; - return { - ...builtOptions, - [nextNamespaceKey]: isPlainObject(nextMuxNamespace) - ? mergeProviderExtrasUnderMux( - nextOverrides.providerExtras, - nextMuxNamespace - ) - : nextOverrides.providerExtras, - }; - }; - const nextMergedProviderOptions = mergeNextModelParameterExtras( - nextProviderOptions as Record - ); - - // Rebuild closure bound to the FALLBACK model so mid-turn - // thinking changes keep working after the hop. - const nextCurrentEffectiveLevelRef = { current: nextThinkingLevel }; - const rebuildNextProviderOptionsForThinkingLevel: RebuildProviderOptionsForThinkingLevel = - (level) => { - const clamped = enforceThinkingPolicy( - nextModelString, - level, - nextMinThinkingLevel, - nextProvidersConfig - ); - const effective = resolveEffectiveThinkingLevel( - nextModelString, - clamped, - nextProvidersConfig - ); - if (effective === nextCurrentEffectiveLevelRef.current) { - return null; - } - if ( - isXaiGrokFastVariantSwap( - next.canonicalModelString, - nextCurrentEffectiveLevelRef.current, - effective - ) - ) { - return null; - } - const rebuilt = buildProviderOptions( - nextOptionsModelString, - effective, - nextProviderRequestMessages, - (id) => this.streamManager.isResponseIdLost(id), - effectiveMuxProviderOptions, - workspaceId, - truncationMode, - nextProvidersConfig, - next.routeProvider, - promptCacheScope, - reasoningMode - ); - const merged = mergeNextModelParameterExtras( - rebuilt as Record - ); - nextCurrentEffectiveLevelRef.current = effective; - return { effectiveLevel: effective, providerOptions: merged }; - }; - - // Shared with the return payload below: the fallback stream - // restarts at step 0, where StreamManager scopes to these - // forced tools when present. - const nextForcedFirstStepToolNames = - next.routeProvider === "xai" - ? getForcedXaiSearchToolNames( - nextCapabilityModelString, - effectiveMuxProviderOptions.xai?.searchParameters - )?.filter((toolName) => toolName in nextTools) - : undefined; - - // The fallback request is a different request identity - // (model, system prompt, toolset, provider options), so it - // needs its own envelope: pairSessionTurns compares the LAST - // envelope per requestHistorySequence, so this row supersedes - // the primary one and replay-verify/cache-audit see the - // request that actually streamed. Deferred to - // onStreamConstructed: a prepare whose stream construction - // later fails must not supersede the primary envelope. - // Same step-0 scoping as the primary envelope: fingerprint - // only the tools the first fallback step actually sends. - const nextFirstStepToolNames = new Set( - nextForcedFirstStepToolNames?.length - ? nextForcedFirstStepToolNames - : nextToolNamesForSentinel - ); - const emitFallbackEnvelopeWith = async ( - thinkingLevelForEnvelope: string, - providerOptionsForEnvelope: unknown - ): Promise => { - await emitTurnEnvelope({ - journal: this.durableEventJournalFor(workspaceId), - workspaceId, - systemMessage: nextSystem, - tools: Object.fromEntries( - Object.entries(nextTools).filter(([name]) => - nextFirstStepToolNames.has(name) - ) - ), - modelString: nextModelString, - thinkingLevel: thinkingLevelForEnvelope, - providerOptions: providerOptionsForEnvelope, - requestHistorySequence, - sentinelToolNames: nextToolNamesForSentinel, - wireProviderName: next.wireProviderName, - anthropicCacheTtl: - effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, - planContentForTransition, - planFilePath, - postCompactionAttachments, - // The continuation never reaches chat.jsonl at this - // sequence (the assistant row lands later), so replay - // needs the envelope's durable copy to rebuild the - // fallback request. - partialContinuationMessage: prepareOptions?.continuation?.assistantMessage, - }); - }; - const emitFallbackEnvelope = (): Promise => - emitFallbackEnvelopeWith(nextThinkingLevel, nextMergedProviderOptions); - // Same step-0 race closure as the primary path, bound to the - // fallback request's own build inputs. - const rebuildNextFirstStepForThinkingLevel: RebuildFirstStepForThinkingLevel = - async (effectiveLevel, providerOptionsForEnvelope) => { - const { providerRequestMessages: racedNextMessages } = - prepareProviderRequestMessages( - fallbackSourceMessages, - next.wireProviderName, - effectiveLevel - ); - const rebuiltFinal = await prepareMessagesForProvider({ - messagesWithSentinel: addInterruptedSentinel(racedNextMessages), - effectiveAgentId, - toolNamesForSentinel: nextToolNamesForSentinel, - planContentForTransition, - planFilePath, - postCompactionAttachments, - providerForMessages: next.wireProviderName, - effectiveThinkingLevel: effectiveLevel, - modelString: nextModelString, - providersConfig: nextProvidersConfig, - anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl, - workspaceId, - }); - await emitFallbackEnvelopeWith(effectiveLevel, providerOptionsForEnvelope); - return rebuiltFinal; - }; - - return Ok({ - onStreamConstructed: emitFallbackEnvelope, - rebuildFirstStepForThinkingLevel: rebuildNextFirstStepForThinkingLevel, - model: next.model, - // RAW identity (matching the main path's raw modelString): - // StreamManager keys createCachedSystemMessage / - // applyCacheControlToTools / metadata resolution on this, - // and the canonical string hides cross-typed Coder - // instance metadata from those lookups. - modelString: nextModelString, - messages: nextFinalMessages, - system: nextSystem, - tools: nextTools, - providerOptions: nextMergedProviderOptions, - headers: nextHeaders, - callSettingsOverrides: nextOverrides.standard, - anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, - thinkingLevel: nextThinkingLevel, - forcedFirstStepToolNames: nextForcedFirstStepToolNames, - rebuildProviderOptionsForThinkingLevel: - rebuildNextProviderOptionsForThinkingLevel, - // Pinned snapshot for the swap's request-config rebuild - // and metadata resolution (see PreparedModelFallback). - providersConfig: nextProvidersConfig, - initialMetadataPatch: { - routedThroughGateway: next.routedThroughGateway, - ...(next.routeProvider != null ? { routeProvider: next.routeProvider } : {}), - // Explicit undefined clears a stale costsIncluded when falling - // back from a subscription-routed model to an API model. - costsIncluded: modelCostsIncluded(next.model) ? true : undefined, - systemMessageTokens: nextSystemTokens, - }, - }); - } catch (error) { - // Release the created fallback model's transport resources when - // a later prepare step throws (it never reaches StreamManager, - // whose cleanup only covers models it took ownership of). - runLanguageModelCleanup(next.model); - throw error; - } - }, - } - : undefined; - - const forcedFirstStepToolNames = - routeProvider === "xai" - ? getForcedXaiSearchToolNames( - capabilityModelString, - effectiveMuxProviderOptions.xai?.searchParameters - )?.filter((toolName) => toolName in toolsForStream) - : undefined; - - // Durable turn envelope: fingerprint the FINAL request identity (post - // request.assemble middleware, post tool-policy rebuild). Deferred to - // StreamManager's construction boundary (like the fallback envelope): - // aborts or setup errors before a stream exists must not persist a - // phantom request row. Emission never fails the turn. - // Step-0 wire truth: StreamManager sends only the first step's active - // tools (forced xAI search set, else the tool-search active subset), so - // the envelope fingerprints that subset — deferred tools never reach - // this request and would otherwise show as false replay divergences. - const firstStepToolNames = new Set( - forcedFirstStepToolNames?.length - ? forcedFirstStepToolNames - : (computeActiveToolNames(toolSearchRuntime?.state) ?? Object.keys(toolsForStream)) - ); - - // Fold PREPARING-window pending thinking overrides into the ACTUAL - // request build, not just the envelope: message preparation is - // thinking-level-dependent (Anthropic signed-reasoning transforms), so - // recording the new level while streaming old-level messages would make - // wire and replay diverge — or send an invalid extended-thinking - // request. Consuming pending here (applied set below) is safe: - // createStreamAtomically seeds streamInfo.thinkingLevel from `applied`, - // and prepareStep simply sees no pending to re-apply. - // Loop until pending is quiescent: setActiveTurnThinkingLevel can write - // a NEW pending while the awaited message rebuild runs, and stamping the - // first level after the await would leave step 0 rebuilding only - // provider options while the messages stay at the stale level. - let streamThinkingLevel = effectiveThinkingLevel; - let streamProviderOptions = mergedProviderOptions; - let streamFinalMessages = finalMessages; - while (activeTurnThinkingOverride?.pending != null) { - const pendingPreparingLevel = activeTurnThinkingOverride.pending; - activeTurnThinkingOverride.pending = undefined; - const folded = computeRebuiltProviderOptions(pendingPreparingLevel, streamThinkingLevel); - if (folded == null) { - // No-op fold (same effective level / non-foldable variant swap): - // re-check pending — a change may have raced the previous rebuild. - continue; - } - const { providerRequestMessages: foldedRequestMessages } = prepareProviderRequestMessages( - messages, - wireProviderName, - folded.effectiveLevel - ); - streamFinalMessages = await prepareMessagesForProvider({ - messagesWithSentinel: addInterruptedSentinel(foldedRequestMessages), - effectiveAgentId, - toolNamesForSentinel, - planContentForTransition, - planFilePath, - postCompactionAttachments, - providerForMessages: wireProviderName, - effectiveThinkingLevel: folded.effectiveLevel, - modelString, - providersConfig: requestProvidersConfig, - anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl, - workspaceId, - }); - streamProviderOptions = folded.providerOptions; - streamThinkingLevel = folded.effectiveLevel; - activeTurnThinkingOverride.applied = folded.effectiveLevel; - // Keep the mid-turn rebuild baseline in sync so a later identical - // request is correctly treated as a no-op. - currentEffectiveLevelRef.current = folded.effectiveLevel; - // Loop re-checks pending: a change during the awaits above re-folds - // against the level just applied. - } - - const emitPrimaryEnvelopeWith = async ( - thinkingLevel: string, - providerOptions: unknown - ): Promise => { - await emitTurnEnvelope({ - journal: this.durableEventJournalFor(workspaceId), - workspaceId, - systemMessage, - tools: Object.fromEntries( - Object.entries(toolsForStream).filter(([name]) => firstStepToolNames.has(name)) - ), - modelString, - thinkingLevel, - providerOptions, - // Replay pairing key + request-time inputs that are model-visible but - // not derivable from chat.jsonl: the resolved wire provider (instance- - // typed gateways need live metadata), the per-send Anthropic cache TTL, - // and the injected plan-transition / post-compaction content. - requestHistorySequence, - // Sentinel names are recorded separately: forced first-step scoping - // narrows the wire manifest while the sentinel lists the full active - // set, so replay cannot derive one from the other. - sentinelToolNames: toolNamesForSentinel, - wireProviderName, - anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, - planContentForTransition, - planFilePath, - postCompactionAttachments, - }); - }; - const emitPrimaryEnvelope = (): Promise => - emitPrimaryEnvelopeWith(streamThinkingLevel, streamProviderOptions); - // Step-0 rebuild for a thinking override that raced stream setup - // (written during startStream's awaits, after the quiescence loop): - // rebuild the wire messages under the consumed level and supersede the - // envelope so replay pairing (last row per sequence) sees the request - // that actually streamed. - const rebuildFirstStepForThinkingLevel: RebuildFirstStepForThinkingLevel = async ( - effectiveLevel, - providerOptions - ) => { - const { providerRequestMessages: racedRequestMessages } = prepareProviderRequestMessages( - messages, - wireProviderName, - effectiveLevel - ); - const rebuiltFinal = await prepareMessagesForProvider({ - messagesWithSentinel: addInterruptedSentinel(racedRequestMessages), - effectiveAgentId, - toolNamesForSentinel, - planContentForTransition, - planFilePath, - postCompactionAttachments, - providerForMessages: wireProviderName, - effectiveThinkingLevel: effectiveLevel, - modelString, - providersConfig: requestProvidersConfig, - anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl, - workspaceId, - }); - await emitPrimaryEnvelopeWith(effectiveLevel, providerOptions); - return rebuiltFinal; - }; - - emitStartupBreadcrumb("starting_stream"); - const startStreamStartedAt = Date.now(); - const turnExecutionOptions: TurnExecutionOptions = { - workspaceId, - messages: streamFinalMessages, - model: modelResult.data.model, - modelString, - historySequence, - system: systemMessage, - runtime, - messageId: assistantMessageId, - abortSignal: combinedAbortSignal, - tools: toolsForStream, - initialMetadata: { - ...(requestHistorySequence >= 0 ? { requestHistorySequence } : {}), - systemMessageTokens, - timestamp: Date.now(), - agentId: effectiveAgentId, - ...(legacyModeForMetadata != null ? { mode: legacyModeForMetadata } : {}), - routedThroughGateway, - ...(routeProvider != null ? { routeProvider } : {}), - ...(muxMetadata !== undefined ? { muxMetadata } : {}), - ...(acpPromptId != null ? { acpPromptId } : {}), - ...(modelCostsIncluded(modelResult.data.model) ? { costsIncluded: true } : {}), - }, - providerOptions: streamProviderOptions, - maxOutputTokens, - toolPolicy: effectiveToolPolicy, - providedStreamToken: streamToken, - hasQueuedMessages, - workspaceName: metadata.name, - thinkingLevel: streamThinkingLevel, - headers: requestHeaders, - anthropicCacheTtlOverride: effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, - callSettingsOverrides: resolvedOverrides.standard, - onChunk: advisorToolEligible ? onAdvisorChunk : undefined, - onStepMessages: advisorToolEligible - ? (stepMessages) => { - advisorTranscriptRef.messages = stepMessages; - advisorStepCaptureRef.currentStepText = ""; - advisorStepCaptureRef.currentStepReasoning = ""; - advisorStepCaptureRef.frozenSnapshotsByToolCallId.clear(); - } - : undefined, - providedRuntimeTempDir: runtimeTempDir, - modelFallback, - toolSearchState: toolSearchRuntime?.state, - thinkingOverrideState: activeTurnThinkingOverride, - rebuildProviderOptionsForThinkingLevel, - forcedFirstStepToolNames, - providersConfigSnapshot: requestProvidersConfig, - onStreamConstructed: emitPrimaryEnvelope, - rebuildFirstStepForThinkingLevel, - }; - const streamResult = await this.streamManager.startStream(turnExecutionOptions); - recordStartupPhaseTiming("startStreamMs", startStreamStartedAt); - - if (!streamResult.success) { - // StreamManager failed before registering a stream. Clear queued run - // metadata so it cannot attach to a later unrelated request. - if (pendingRunMetadataId != null) { - this.clearTrackedPendingDevToolsRunMetadata(assistantMessageId); - pendingRunMetadataId = null; - } - - logSlowStreamStartup?.({ - outcome: "stream_start_failed", - providerName: canonicalProviderName, - routeProvider, - agentId: effectiveAgentId, - mode: legacyModeForMetadata, - runtimeType: metadata.runtimeConfig.type, - errorType: streamResult.error.type, - toolCount: Object.keys(toolsForStream).length, - mcpToolCount: Object.keys(mcpTools ?? {}).length, - mcpFailedServerCount: mcpStats?.failedServerCount ?? 0, - providerRequestMessageCount: providerRequestMessages.length, - finalMessageCount: finalMessages.length, - }); - - // StreamManager already returns SendMessageError - return Err(streamResult.error); - } - - // If we were interrupted during StreamManager startup before the stream was registered, - // make sure we don't leave an empty assistant placeholder behind. - if (combinedAbortSignal.aborted && !this.streamManager.isStreaming(workspaceId)) { - if (pendingRunMetadataId != null) { - this.clearTrackedPendingDevToolsRunMetadata(assistantMessageId); - pendingRunMetadataId = null; - } - await deleteAbortedPlaceholder(assistantMessageId); - } - - logSlowStreamStartup?.({ - outcome: "started", - providerName: canonicalProviderName, - routeProvider, - agentId: effectiveAgentId, - mode: legacyModeForMetadata, - runtimeType: metadata.runtimeConfig.type, - toolCount: Object.keys(toolsForStream).length, - mcpToolCount: Object.keys(mcpTools ?? {}).length, - mcpFailedServerCount: mcpStats?.failedServerCount ?? 0, - providerRequestMessageCount: providerRequestMessages.length, - finalMessageCount: finalMessages.length, - }); - - // StreamManager now handles history updates directly on stream-end. - return Ok(streamResult.data); - } catch (error) { - if (pendingRunMetadataId != null) { - this.clearTrackedPendingDevToolsRunMetadataById(workspaceId, pendingRunMetadataId); - pendingRunMetadataId = null; - } - - const errorMessage = getErrorMessage(error); - logSlowStreamStartup?.({ - outcome: "error", - errorMessage, - }); - log.error("Stream message error:", error); - // Return as unknown error type - return Err({ type: "unknown", raw: `Failed to stream message: ${errorMessage}` }); - } finally { - unlinkAbortSignal(); - const pending = this.pendingStreamStarts.get(workspaceId); - if (pending?.abortController === pendingAbortController) { - this.pendingStreamStarts.delete(workspaceId); - } - } - } - - async stopStream( - workspaceId: string, - options?: { soft?: boolean; abandonPartial?: boolean; abortReason?: StreamAbortReason } - ): Promise> { - const pending = this.pendingStreamStarts.get(workspaceId); - const isActuallyStreaming = - this.mockModeEnabled && this.mockAiStreamPlayer - ? this.mockAiStreamPlayer.isStreaming(workspaceId) - : this.streamManager.isStreaming(workspaceId); - - if (pending) { - pending.abortController.abort(); - - // If we're still in pre-stream startup (no StreamManager stream yet), emit a synthetic - // stream-abort so the renderer can exit the "starting..." UI immediately. - const abortReason = options?.abortReason ?? "startup"; - if (!isActuallyStreaming) { - this.emit("stream-abort", { - type: "stream-abort", - workspaceId, - abortReason, - messageId: pending.syntheticMessageId, - metadata: { duration: Date.now() - pending.startTime }, - abandonPartial: options?.abandonPartial, - acpPromptId: pending.acpPromptId, - } satisfies StreamAbortEvent); - } - } - - if (this.mockModeEnabled && this.mockAiStreamPlayer) { - await this.mockAiStreamPlayer.stop(workspaceId); - return Ok(undefined); - } - return this.streamManager.stopStream(workspaceId, options); - } + async stopStream( + workspaceId: string, + options?: { soft?: boolean; abandonPartial?: boolean; abortReason?: StreamAbortReason } + ): Promise> { + return this.streamManager.stopStream(workspaceId, options); + } /** * Check if a workspace is currently streaming */ isStreaming(workspaceId: string): boolean { - if (this.mockModeEnabled && this.mockAiStreamPlayer) { - return this.mockAiStreamPlayer.isStreaming(workspaceId); - } return this.streamManager.isStreaming(workspaceId); } - /** - * Get the current stream state for a workspace - */ - getStreamState(workspaceId: string): string { - if (this.mockModeEnabled && this.mockAiStreamPlayer) { - return this.mockAiStreamPlayer.isStreaming(workspaceId) ? "streaming" : "idle"; - } - return this.streamManager.getStreamState(workspaceId); - } - - /** - * Get the current stream info for a workspace if actively streaming - * Used to re-establish streaming context on frontend reconnection - */ - getStreamInfo(workspaceId: string): ReturnType { - if (this.mockModeEnabled && this.mockAiStreamPlayer) { - return undefined; - } - return this.streamManager.getStreamInfo(workspaceId); - } - - /** - * Replay stream events - * Emits the same events that would be emitted during live streaming - */ - async replayStream(workspaceId: string, opts?: { afterTimestamp?: number }): Promise { - if (this.mockModeEnabled && this.mockAiStreamPlayer) { - await this.mockAiStreamPlayer.replayStream(workspaceId); - return; - } - await this.streamManager.replayStream(workspaceId, opts); - } - - debugGetLastMockPrompt(workspaceId: string): Result { - if (typeof workspaceId !== "string" || workspaceId.trim().length === 0) { - return Err("debugGetLastMockPrompt: workspaceId is required"); - } - - if (!this.mockModeEnabled || !this.mockAiStreamPlayer) { - return Ok(null); - } - - return Ok(this.mockAiStreamPlayer.debugGetLastPrompt(workspaceId)); - } - debugGetLastMockModel(workspaceId: string): Result { - if (typeof workspaceId !== "string" || workspaceId.trim().length === 0) { - return Err("debugGetLastMockModel: workspaceId is required"); - } - - if (!this.mockModeEnabled || !this.mockAiStreamPlayer) { - return Ok(null); - } - - return Ok(this.mockAiStreamPlayer.debugGetLastModel(workspaceId)); - } - debugGetLastLlmRequest(workspaceId: string): Result { if (typeof workspaceId !== "string" || workspaceId.trim().length === 0) { return Err("debugGetLastLlmRequest: workspaceId is required"); @@ -4337,26 +950,6 @@ export class AIService extends EventEmitter { return Ok(this.lastLlmRequestByWorkspace.get(workspaceId) ?? null); } - /** - * DEBUG ONLY: Trigger an artificial stream error for testing. - * This is used by integration tests to simulate network errors mid-stream. - * @returns true if an active stream was found and error was triggered - */ - debugTriggerStreamError( - workspaceId: string, - errorMessage = "Test-triggered stream error" - ): Promise { - return this.streamManager.debugTriggerStreamError(workspaceId, errorMessage); - } - - /** - * Wait for workspace initialization to complete (if running). - * Public wrapper for agent discovery and other callers. - */ - async waitForInit(workspaceId: string, abortSignal?: AbortSignal): Promise { - return this.initStateManager.waitForInit(workspaceId, abortSignal); - } - async deleteWorkspace(workspaceId: string): Promise> { try { const workspaceDir = this.config.getSessionDir(workspaceId); diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index d4c45d71979..39a2beab655 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -10,6 +10,8 @@ import { IdleDispatcher } from "@/node/services/idleDispatcher"; import { InitStateManager } from "@/node/services/initStateManager"; import { ProviderService } from "@/node/services/providerService"; import { AIService } from "@/node/services/aiService"; +import type { TurnRequestBuilderBindings } from "@/node/services/turnRequestBuilder"; +import { StreamManager } from "@/node/services/streamManager"; import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import { SessionUsageService } from "@/node/services/sessionUsageService"; import { log } from "@/node/services/log"; @@ -73,6 +75,7 @@ export interface CoreServices { */ idleDispatcher: IdleDispatcher; aiService: AIService; + streamManager: StreamManager; mcpConfigService: MCPConfigService; mcpServerManager: MCPServerManager; extensionMetadata: ExtensionMetadataService; @@ -81,6 +84,7 @@ export interface CoreServices { memoryService: MemoryService; memoryMetaService: MemoryMetaService; memoryConsolidationService: MemoryConsolidationService; + turnRequestBuilderBindings: TurnRequestBuilderBindings; } export function createCoreServices(opts: CoreServicesOptions): CoreServices { @@ -151,6 +155,11 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { const workspaceMcpOverridesService = opts.workspaceMcpOverridesService ?? new WorkspaceMcpOverridesService(config); + const turnRequestBuilderBindings: TurnRequestBuilderBindings = {}; + const streamManager = new StreamManager(historyService, sessionUsageService, () => + providerService.getConfig() + ); + const aiService = new AIService( config, historyService, @@ -162,7 +171,9 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.policyService, opts.telemetryService, opts.devToolsService, - opts.experimentsService + opts.experimentsService, + streamManager, + turnRequestBuilderBindings ); // Agent memory (memory experiment): scope roots derive from Config (xum home @@ -170,7 +181,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { // Host-local sidecar for user-owned memory metadata (pins + usage stats). const memoryMetaService = new MemoryMetaService(config.rootDir); const memoryService = new MemoryService(config, memoryMetaService); - aiService.setMemoryService(memoryService); + turnRequestBuilderBindings.memoryService = memoryService; // Background dream consolidation (memory-consolidation experiment). Without // an ExperimentsService (CLI/test contexts) the service stays inert. @@ -218,7 +229,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { }, opts.policyService ); - aiService.setMCPServerManager(mcpServerManager); + turnRequestBuilderBindings.mcpServerManager = mcpServerManager; + streamManager.setMCPServerManager(mcpServerManager); // Recorded prompt options can hold stale secret snapshots, so prompt refreshes // resolve credentials from current configuration. mcpServerManager.setSecretsResolver(async (workspaceId, projectPath) => { @@ -242,15 +254,15 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.policyService, opts.telemetryService, opts.experimentsService, - opts.sessionTimingService + opts.sessionTimingService, + streamManager ); - aiService.setWorkspaceHeartbeatService(workspaceService); + turnRequestBuilderBindings.workspaceHeartbeatService = workspaceService; // Tool-started workflows share the same sidebar activity cache as ORPC-started workflows, // so terminal updates must prune active run counts regardless of launch path. - aiService.setWorkflowRunStatusChangedHandler((event) => - workspaceService.emitWorkflowRunActivity(event) - ); - aiService.setWorkflowResultContinuationSender(workspaceService); + turnRequestBuilderBindings.onWorkflowRunStatusChanged = (event) => + workspaceService.emitWorkflowRunActivity(event); + turnRequestBuilderBindings.workflowResultContinuationSender = workspaceService; workspaceService.setMemoryConsolidationService(memoryConsolidationService); if (opts.devToolsService) { // DevTools debug-log cleanup when workspaces are archived/removed. @@ -291,9 +303,10 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { workspaceService, initStateManager, sessionUsageService, - workspaceGoalService + workspaceGoalService, + streamManager ); - aiService.setTaskService(taskService); + turnRequestBuilderBindings.taskService = taskService; workspaceService.setAgentTaskIntegration(taskService); // Goal continuation bridge lives at the core scope so every codepath that @@ -320,6 +333,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { workspaceGoalService, idleDispatcher, aiService, + streamManager, mcpConfigService, mcpServerManager, extensionMetadata, @@ -328,5 +342,6 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { memoryService, memoryMetaService, memoryConsolidationService, + turnRequestBuilderBindings, }; } diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index b464cf94216..f5e317cb43e 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -27,6 +27,7 @@ import { resolveOpenAIWebSocketResponsesUrl, wrapFetchWithAnthropicCacheControl, wrapFetchWithXAIServiceTier, + type OauthServiceBindings, } from "./providerModelFactory"; import { hasLanguageModelCleanup } from "./languageModelCleanup"; import type { DevToolsService } from "./devToolsService"; @@ -95,15 +96,20 @@ function expectSuccessfulRouteResult( } async function withTempConfig( - run: (config: Config, factory: ProviderModelFactory) => Promise | void + run: ( + config: Config, + factory: ProviderModelFactory, + oauth: OauthServiceBindings + ) => Promise | void ): Promise { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-provider-model-factory-")); try { const config = new Config(tmpDir); const providerService = new ProviderService(config); - const factory = new ProviderModelFactory(config, providerService); - await run(config, factory); + const oauth: OauthServiceBindings = {}; + const factory = new ProviderModelFactory(config, providerService, undefined, oauth); + await run(config, factory, oauth); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -135,7 +141,8 @@ async function withTempPolicyProviderFactory( run: ( config: Config, factory: ProviderModelFactory, - policyService: PolicyService + policyService: PolicyService, + oauth: OauthServiceBindings ) => Promise | void ): Promise { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-provider-model-factory-")); @@ -151,8 +158,9 @@ async function withTempPolicyProviderFactory( policyService = new PolicyService(config); await policyService.initialize(); const providerService = new ProviderService(config, policyService); - const factory = new ProviderModelFactory(config, providerService, policyService); - await run(config, factory, policyService); + const oauth: OauthServiceBindings = {}; + const factory = new ProviderModelFactory(config, providerService, policyService, oauth); + await run(config, factory, policyService, oauth); } finally { policyService?.dispose(); if (prevPolicyFileEnv === undefined) { @@ -1036,7 +1044,7 @@ describe("ProviderModelFactory GitHub Copilot", () => { }); it("normalizes Request bodies for the Codex OAuth responses endpoint", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { const originalOpenAIRegistry = PROVIDER_REGISTRY.openai; const requests: Array<{ input: Parameters[0]; @@ -1094,7 +1102,7 @@ describe("ProviderModelFactory GitHub Copilot", () => { const codexOauthService = Object.create(CodexOauthService.prototype) as CodexOauthService; codexOauthService.getValidAuth = () => Promise.resolve(Ok(auth)); - factory.codexOauthService = codexOauthService; + oauth.codexOauthService = codexOauthService; PROVIDER_REGISTRY.openai = async () => { const module = await originalOpenAIRegistry(); @@ -2316,12 +2324,12 @@ describe("ProviderModelFactory Coder", () => { } it("creates Anthropic-origin models against the deployment's AI Bridge", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { const originalAnthropicRegistry = PROVIDER_REGISTRY.anthropic; let capturedBaseURL: string | undefined; saveCoderConfig(config); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); PROVIDER_REGISTRY.anthropic = async () => { const module = await originalAnthropicRegistry(); @@ -2351,12 +2359,12 @@ describe("ProviderModelFactory Coder", () => { }); it("creates OpenAI-origin models via the bridge's Responses endpoint", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { const originalOpenAIRegistry = PROVIDER_REGISTRY.openai; let capturedBaseURL: string | undefined; saveCoderConfig(config); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); PROVIDER_REGISTRY.openai = async () => { const module = await originalOpenAIRegistry(); @@ -2386,7 +2394,7 @@ describe("ProviderModelFactory Coder", () => { }); it("routes custom-named provider instances using the discovered type", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { const originalOpenAIRegistry = PROVIDER_REGISTRY.openai; let capturedBaseURL: string | undefined; @@ -2395,7 +2403,7 @@ describe("ProviderModelFactory Coder", () => { saveCoderConfig(config, { discoveredProviders: [{ name: "prod-openai", type: "openai" }], }); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); PROVIDER_REGISTRY.openai = async () => { const module = await originalOpenAIRegistry(); @@ -2425,14 +2433,14 @@ describe("ProviderModelFactory Coder", () => { }); it("speaks chat completions to OpenAI-compatible provider types and honors additionalProviders", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // additionalProviders is the user-managed escape hatch for deployments // where the member cannot list providers; openai-compat upstreams only // guarantee /chat/completions, not the Responses API. saveCoderConfig(config, { additionalProviders: [{ name: "llm-proxy", type: "openai-compat" }], }); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.createModel("coder:llm-proxy/llama-3.3-70b"); expect(result.success).toBe(true); @@ -2445,10 +2453,10 @@ describe("ProviderModelFactory Coder", () => { }); it("speaks the Anthropic wire protocol to bedrock-type provider instances", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // The gateway serves Bedrock through its Anthropic client (/v1/messages). saveCoderConfig(config); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.createModel("coder:bedrock/claude-sonnet-4-5"); expect(result.success).toBe(true); @@ -2460,7 +2468,7 @@ describe("ProviderModelFactory Coder", () => { }); it("keeps instances named after other direct providers routed through Coder", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // A default-named google instance: canonicalization must NOT rewrite // coder:google/x to google:x (which would route to the direct Google // provider, bypassing the gateway the user selected — or fail without @@ -2476,7 +2484,7 @@ describe("ProviderModelFactory Coder", () => { // Direct Google credentials exist: they must NOT capture the request. google: { apiKey: "g-key" }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel("coder:google/gemini-3-pro", "off"); expect(result.success).toBe(true); @@ -2494,7 +2502,7 @@ describe("ProviderModelFactory Coder", () => { }); it("reports the wire provider from instance metadata, not the instance name", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // {name: "openai", type: "anthropic"}: the request speaks Anthropic on // the wire, so message preparation (reasoning transforms, PDF-filename // sanitization) must key on anthropic even though the name says openai. @@ -2503,7 +2511,7 @@ describe("ProviderModelFactory Coder", () => { models: ["openai/claude-opus-4-5"], discoveredModels: ["openai/claude-opus-4-5"], }); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel("coder:openai/claude-opus-4-5", "off"); expect(result.success).toBe(true); @@ -2521,7 +2529,7 @@ describe("ProviderModelFactory Coder", () => { }); it("merges backend disableBetaFeatures for custom-named Anthropic-wire instances", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // The wire (instance type), not the route name, classifies the request // as Anthropic: without wire-based classification the authoritative // providers.anthropic.disableBetaFeatures never merges and cache_control @@ -2535,7 +2543,7 @@ describe("ProviderModelFactory Coder", () => { ...config.loadProvidersConfig(), anthropic: { disableBetaFeatures: true }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const muxOptions: MuxProviderOptions = {}; const result = await factory.createModel("coder:prod-anthropic/claude-opus-4-5", muxOptions); @@ -2545,7 +2553,7 @@ describe("ProviderModelFactory Coder", () => { }); it("does not merge Anthropic beta config for cross-typed anthropic-named instances", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // {name: "anthropic", type: "openai-compat"}: the model ID starts with // "anthropic/" but the wire is NOT Anthropic — name-based classification // would wrongly merge Anthropic-only config into the request options. @@ -2558,7 +2566,7 @@ describe("ProviderModelFactory Coder", () => { ...config.loadProvidersConfig(), anthropic: { disableBetaFeatures: true }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const muxOptions: MuxProviderOptions = {}; const result = await factory.createModel("coder:anthropic/gpt-5", muxOptions); @@ -2568,7 +2576,7 @@ describe("ProviderModelFactory Coder", () => { }); it("merges the OpenAI ZDR store setting for custom-named openai-typed instances", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // Type "openai" = the real OpenAI Responses upstream, where the ZDR // store flag applies. Name-based classification (modelId startsWith // "openai/") misses custom names entirely. @@ -2581,7 +2589,7 @@ describe("ProviderModelFactory Coder", () => { ...config.loadProvidersConfig(), openai: { store: false }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const muxOptions: MuxProviderOptions = {}; const result = await factory.createModel("coder:prod-openai/gpt-5.2", muxOptions); @@ -2591,7 +2599,7 @@ describe("ProviderModelFactory Coder", () => { }); it("does not merge the OpenAI store setting for cross-typed openai-named instances", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // {name: "openai", type: "openai-compat"}: the model ID starts with // "openai/" but the upstream is NOT the real OpenAI — the ZDR store // flag must not leak onto arbitrary compat upstreams. @@ -2604,7 +2612,7 @@ describe("ProviderModelFactory Coder", () => { ...config.loadProvidersConfig(), openai: { store: false }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const muxOptions: MuxProviderOptions = {}; const result = await factory.createModel("coder:openai/gpt-5", muxOptions); @@ -2614,7 +2622,7 @@ describe("ProviderModelFactory Coder", () => { }); it("falls back to the type-derived provider when the catalog excludes the model", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // Cross-typed instance {name: "openai", type: "anthropic"} whose // catalog does NOT contain the requested model: the explicit coder // route cannot be restored, and the fallback identity comes from the @@ -2632,7 +2640,7 @@ describe("ProviderModelFactory Coder", () => { openai: { apiKey: "sk-openai" }, anthropic: { apiKey: "sk-anthropic" }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel("coder:openai/claude-sonnet-4-5", "off"); expect(result.success).toBe(true); @@ -2649,7 +2657,7 @@ describe("ProviderModelFactory Coder", () => { }); it("creates the SDK model from the same config snapshot as the wire report", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // Another Xum process rewrites providers.jsonc between route/wire // resolution and model creation (an authoritative refresh changing the // instance's type). The created SDK model must follow the SAME @@ -2661,7 +2669,7 @@ describe("ProviderModelFactory Coder", () => { models: ["prod/claude-opus-4-5"], discoveredModels: ["prod/claude-opus-4-5"], }); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const realLoad = config.loadProvidersConfig.bind(config); let loads = 0; @@ -2695,7 +2703,7 @@ describe("ProviderModelFactory Coder", () => { }); it("canonicalizes gateway-scoped type-derived fallback seeds for the wire identity", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // A bedrock-typed instance whose catalog excludes the requested model // seeds its fallback from the instance type: bedrock:anthropic.. // The seed is itself gateway-scoped — the wire identity must be its @@ -2711,7 +2719,7 @@ describe("ProviderModelFactory Coder", () => { ...config.loadProvidersConfig(), bedrock: { region: "us-east-1" }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel( "coder:bedrock/anthropic.claude-sonnet-4-5", @@ -2728,7 +2736,7 @@ describe("ProviderModelFactory Coder", () => { }); it("rejects catalog-excluded models on instances without a canonical fallback", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // openai-compat fronts an arbitrary upstream, so a catalog-excluded // model has NO distinct canonical identity to fall back to. Feeding the // rejected coder: string back into routing would resolve the last-resort @@ -2738,7 +2746,7 @@ describe("ProviderModelFactory Coder", () => { models: ["llm-proxy/allowed-model"], discoveredModels: ["llm-proxy/allowed-model"], }); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel("coder:llm-proxy/excluded-model", "off"); expect(result.success).toBe(false); @@ -2754,7 +2762,7 @@ describe("ProviderModelFactory Coder", () => { }); it("rejects catalog-excluded models on canonical-named instances without a canonical fallback", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // {name: "anthropic", type: "openai-compat"}: metadata resolution is // null (arbitrary upstream), so the fallback must not adopt the // name-derived anthropic: identity — the rejection applies @@ -2770,7 +2778,7 @@ describe("ProviderModelFactory Coder", () => { ...config.loadProvidersConfig(), anthropic: { apiKey: "sk-ant-test" }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel("coder:anthropic/excluded-model", "off"); expect(result.success).toBe(false); @@ -2786,7 +2794,7 @@ describe("ProviderModelFactory Coder", () => { }); it("rejects disconnected unmappable canonical-named instances instead of name-canonicalizing", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // Coder disconnected (no coderOauth) + {name: "anthropic", // type: "openai-compat"} + direct Anthropic credentials: the seed has // no canonical fallback identity, so the request must fail on the @@ -2802,7 +2810,7 @@ describe("ProviderModelFactory Coder", () => { ...config.loadProvidersConfig(), anthropic: { apiKey: "sk-ant-test" }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel("coder:anthropic/some-model", "off"); expect(result.success).toBe(false); @@ -2814,9 +2822,9 @@ describe("ProviderModelFactory Coder", () => { }); it("rejects unknown provider names with an actionable error", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { saveCoderConfig(config); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.createModel("coder:mystery-provider/some-model"); expect(result.success).toBe(false); @@ -2831,13 +2839,13 @@ describe("ProviderModelFactory Coder", () => { }); it("rejects copilot-type provider instances as unsupported", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // Copilot gateway routes need request-time tokens only an official // Copilot client can mint; Xum's Coder OAuth token is not enough. saveCoderConfig(config, { discoveredProviders: [{ name: "copilot", type: "copilot" }], }); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.createModel("coder:copilot/gpt-5.2"); expect(result.success).toBe(false); @@ -2852,14 +2860,14 @@ describe("ProviderModelFactory Coder", () => { }); it("injects a fresh Bearer token per request and strips the placeholder x-api-key", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { const originalAnthropicRegistry = PROVIDER_REGISTRY.anthropic; const originalFetch = globalThis.fetch; let capturedFetch: typeof fetch | undefined; let forwardedHeaders: Headers | undefined; saveCoderConfig(config); - factory.coderOauthService = stubCoderOauthService("at_fresh"); + oauth.coderOauthService = stubCoderOauthService("at_fresh"); PROVIDER_REGISTRY.anthropic = async () => { const module = await originalAnthropicRegistry(); @@ -2913,14 +2921,14 @@ describe("ProviderModelFactory Coder", () => { policy_format_version: "0.1", provider_access: [{ id: "coder" }], }, - async (config, factory, policyService) => { + async (config, factory, policyService, oauth) => { const originalAnthropicRegistry = PROVIDER_REGISTRY.anthropic; const originalFetch = globalThis.fetch; let capturedFetch: typeof fetch | undefined; let upstreamCalls = 0; saveCoderConfig(config); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); PROVIDER_REGISTRY.anthropic = async () => { const module = await originalAnthropicRegistry(); @@ -3019,7 +3027,7 @@ describe("ProviderModelFactory Coder", () => { policy_format_version: "0.1", provider_access: [{ id: "coder" }], }, - async (config, factory, policyService) => { + async (config, factory, policyService, oauth) => { const originalAnthropicRegistry = PROVIDER_REGISTRY.anthropic; const originalFetch = globalThis.fetch; let capturedFetch: typeof fetch | undefined; @@ -3043,7 +3051,7 @@ describe("ProviderModelFactory Coder", () => { expect(refresh.success).toBe(true); return stubbedGetValidAuth(); }; - factory.coderOauthService = stub; + oauth.coderOauthService = stub; PROVIDER_REGISTRY.anthropic = async () => { const module = await originalAnthropicRegistry(); @@ -3096,7 +3104,7 @@ describe("ProviderModelFactory Coder", () => { policy_format_version: "0.1", provider_access: [{ id: "coder", base_url: LOCKED_URL }], }, - async (config, factory) => { + async (config, factory, _policyService, oauth) => { const originalAnthropicRegistry = PROVIDER_REGISTRY.anthropic; let capturedBaseURL: string | undefined; @@ -3117,7 +3125,7 @@ describe("ProviderModelFactory Coder", () => { }, }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService("at_factory", LOCKED_URL); + oauth.coderOauthService = stubCoderOauthService("at_factory", LOCKED_URL); PROVIDER_REGISTRY.anthropic = async () => { const module = await originalAnthropicRegistry(); @@ -3142,7 +3150,7 @@ describe("ProviderModelFactory Coder", () => { }); it("only routes models from the discovered bridge catalog through Coder", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // Coder is logged in and preferred over direct, but its discovered // catalog only contains one anthropic model. The AI Bridge cannot serve // models outside its catalog, so any other model must fall back to the @@ -3156,7 +3164,7 @@ describe("ProviderModelFactory Coder", () => { ...providersConfig, anthropic: { apiKey: "sk-ant-test" }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); await saveRoutePriority(config, ["coder", "direct"]); @@ -3176,7 +3184,7 @@ describe("ProviderModelFactory Coder", () => { }); it("keeps routing through Coder while the catalog is unknown", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // No models key: the catalog is unknown (discovery pending or failed // transiently after login). Routing stays permissive — blocking would // strand Coder routing until the next login even after the bridge @@ -3187,7 +3195,7 @@ describe("ProviderModelFactory Coder", () => { ...providersConfig, anthropic: { apiKey: "sk-ant-test" }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); await saveRoutePriority(config, ["coder", "direct"]); @@ -3201,7 +3209,7 @@ describe("ProviderModelFactory Coder", () => { }); it("does not restore an explicit coder: prefix for models absent from the catalog", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // The user explicitly selected coder:anthropic/claude-opus-4-1, but the // discovered catalog does not contain it. The explicit-gateway restore // must apply the same catalog gate as resolveRoute — otherwise the @@ -3218,7 +3226,7 @@ describe("ProviderModelFactory Coder", () => { ...providersConfig, anthropic: { apiKey: "sk-ant-test" }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); await saveRoutePriority(config, ["direct"]); @@ -3252,7 +3260,7 @@ describe("ProviderModelFactory Coder", () => { }); it("routes nothing through Coder when the discovered catalog is empty", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // Discovery always overwrites the catalog — empty means the bridge // exposed no models (e.g. AI Bridge not entitled). Auto-routing must // skip Coder entirely rather than send every model to a bridge that @@ -3263,7 +3271,7 @@ describe("ProviderModelFactory Coder", () => { ...providersConfig, anthropic: { apiKey: "sk-ant-test" }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); await saveRoutePriority(config, ["coder", "direct"]); @@ -3285,7 +3293,7 @@ describe("ProviderModelFactory Coder", () => { { id: "anthropic" }, ], }, - async (config, factory) => { + async (config, factory, _policyService, oauth) => { // The persisted catalog is deliberately policy-unfiltered (both // models present); the CURRENT policy must gate routing so the // disallowed model falls back to direct instead of being rewritten @@ -3299,7 +3307,7 @@ describe("ProviderModelFactory Coder", () => { ...providersConfig, anthropic: { apiKey: "sk-ant-test" }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); await saveRoutePriority(config, ["coder", "direct"]); @@ -3329,7 +3337,7 @@ describe("ProviderModelFactory Coder", () => { policy_format_version: "0.1", provider_access: [{ id: "coder", base_url: LOCKED_URL }], }, - async (config, factory) => { + async (config, factory, _policyService, oauth) => { const originalAnthropicRegistry = PROVIDER_REGISTRY.anthropic; let capturedBaseURL: string | undefined; @@ -3352,7 +3360,7 @@ describe("ProviderModelFactory Coder", () => { }, }, } as Parameters[0]); - factory.coderOauthService = stubCoderOauthService("at_factory", LOCKED_URL); + oauth.coderOauthService = stubCoderOauthService("at_factory", LOCKED_URL); PROVIDER_REGISTRY.anthropic = async () => { const module = await originalAnthropicRegistry(); @@ -3382,14 +3390,14 @@ describe("ProviderModelFactory Coder", () => { policy_format_version: "0.1", provider_access: [{ id: "coder", base_url: "https://locked.coder.example.com" }], }, - async (config, factory) => { + async (config, factory, _policyService, oauth) => { // Logged in to a different (user-chosen) deployment: those tokens must // not be used for the policy-locked endpoint, nor may traffic flow to // the user-chosen deployment while policy is enforced. The coder route // is unavailable (issuer mismatch with the forced URL), so the model // falls back to the direct origin — which the policy also denies. saveCoderConfig(config); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.createModel("coder:anthropic/claude-sonnet-4-5"); expect(result.success).toBe(false); @@ -3401,7 +3409,7 @@ describe("ProviderModelFactory Coder", () => { }); it("refuses to attach credentials minted by a different deployment than the model's", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { const originalAnthropicRegistry = PROVIDER_REGISTRY.anthropic; const originalFetch = globalThis.fetch; let capturedFetch: typeof fetch | undefined; @@ -3412,7 +3420,7 @@ describe("ProviderModelFactory Coder", () => { // by request time the user has re-logged into a different deployment: // the wrapper must fail instead of sending that bearer token to the // model's (old) base URL. - factory.coderOauthService = stubCoderOauthService("at_other", "https://other.example.com"); + oauth.coderOauthService = stubCoderOauthService("at_other", "https://other.example.com"); PROVIDER_REGISTRY.anthropic = async () => { const module = await originalAnthropicRegistry(); @@ -3459,9 +3467,9 @@ describe("ProviderModelFactory Coder", () => { }); it("rejects model ids without a supported bridge origin", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { saveCoderConfig(config); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); // Known direct origins (e.g. coder:google/...) canonicalize away from the // gateway before reaching the coder branch, so only coder-scoped ids @@ -3477,12 +3485,12 @@ describe("ProviderModelFactory Coder", () => { }); it("fails with api_key_not_found when Coder OAuth is not connected", async () => { - await withTempConfig(async (config, factory) => { + await withTempConfig(async (config, factory, oauth) => { // Deployment URL alone is not enough - login is required. Use a // coder-scoped id so canonicalization cannot reroute to a direct // provider configured via workstation env keys. saveCoderConfig(config, { coderOauth: undefined }); - factory.coderOauthService = stubCoderOauthService(); + oauth.coderOauthService = stubCoderOauthService(); const result = await factory.createModel("coder:meta-llama/llama-3"); expect(result.success).toBe(false); diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 8700aad8af9..ca84cd7d7b4 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -1064,6 +1064,16 @@ function formatCustomProviderRequirementError( // ProviderModelFactory // --------------------------------------------------------------------------- +/** + * OAuth services are wired after construction (they depend on services built + * later), so the factory reads them from this shared bindings object at use + * time instead of capturing instances up front. + */ +export interface OauthServiceBindings { + codexOauthService?: CodexOauthService; + coderOauthService?: CoderOauthService; +} + /** * Factory responsible for creating AI SDK LanguageModel instances from model strings. * @@ -1075,20 +1085,19 @@ export class ProviderModelFactory { private readonly providerService: ProviderService; private readonly policyService?: PolicyService; private readonly devToolsService?: DevToolsService; - codexOauthService?: CodexOauthService; - coderOauthService?: CoderOauthService; + private readonly oauthServices?: OauthServiceBindings; constructor( config: Config, providerService: ProviderService, policyService?: PolicyService, - codexOauthService?: CodexOauthService, + oauthServices?: OauthServiceBindings, devToolsService?: DevToolsService ) { this.config = config; this.providerService = providerService; this.policyService = policyService; - this.codexOauthService = codexOauthService; + this.oauthServices = oauthServices; this.devToolsService = devToolsService; } @@ -1580,7 +1589,7 @@ export class ProviderModelFactory { const effectiveWireFormat = muxProviderOptions?.openai?.wireFormat ?? "responses"; const baseFetch = getProviderFetch(providerConfig); - const codexOauthService = this.codexOauthService; + const codexOauthService = this.oauthServices?.codexOauthService; const webSocketTransportEnabled = (providerConfig as { webSocketTransportEnabled?: unknown }).webSocketTransportEnabled === true; @@ -2207,7 +2216,7 @@ export class ProviderModelFactory { } const deploymentUrl = creds.deploymentUrl; - const coderOauthService = this.coderOauthService; + const coderOauthService = this.oauthServices?.coderOauthService; if (!coderOauthService) { return Err({ type: "invalid_model_string", diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 6de3a579aee..2c91917c182 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -92,6 +92,8 @@ export class ServiceContainer { // Core services — instantiated by createCoreServices (shared with `xum run` CLI) private readonly historyService: CoreServices["historyService"]; public readonly aiService: CoreServices["aiService"]; + public readonly streamManager: CoreServices["streamManager"]; + public readonly initStateManager: CoreServices["initStateManager"]; public readonly workspaceService: CoreServices["workspaceService"]; public readonly taskService: CoreServices["taskService"]; public readonly providerService: CoreServices["providerService"]; @@ -190,7 +192,9 @@ export class ServiceContainer { // Spread core services into class fields this.historyService = core.historyService; this.aiService = core.aiService; - this.aiService.setAnalyticsService(this.analyticsService); + this.streamManager = core.streamManager; + this.initStateManager = core.initStateManager; + core.turnRequestBuilderBindings.analyticsService = this.analyticsService; this.browserSessionDiscoveryService = new AgentBrowserSessionDiscoveryService({ resolveWorkspaceCandidatePathsFn: async (workspaceId: string) => { const allWorkspaceMetadata = await config.getAllWorkspaceMetadata(); @@ -251,7 +255,7 @@ export class ServiceContainer { experimentsService: this.experimentsService, workspaceService: this.workspaceService, }); - this.aiService.setDesktopSessionManager(this.desktopSessionManager); + core.turnRequestBuilderBindings.desktopSessionManager = this.desktopSessionManager; this.desktopTokenManager = new DesktopTokenManager(); this.desktopBridgeServer = new DesktopBridgeServer({ desktopSessionManager: this.desktopSessionManager, @@ -319,7 +323,7 @@ export class ServiceContainer { this.taskService.setTimelineRecorder(this.timelineService); this.heartbeatService.setTimelineRecorder(this.timelineService); this.workspaceGoalService.setTimelineRecorder(this.timelineService); - this.aiService.setTimelineService(this.timelineService); + core.turnRequestBuilderBindings.timelineService = this.timelineService; this.timelineService.subscribeToWorkspace(this.workspaceService); this.windowService = new WindowService(); this.mcpOauthService = new McpOauthService( @@ -345,7 +349,7 @@ export class ServiceContainer { this.providerService, this.windowService ); - this.aiService.setCodexOauthService(this.codexOauthService); + core.turnRequestBuilderBindings.codexOauthService = this.codexOauthService; this.coderOauthService = new CoderOauthService( config, this.providerService, @@ -354,7 +358,7 @@ export class ServiceContainer { // for logins, refreshes, and issuer checks. this.policyService ); - this.aiService.setCoderOauthService(this.coderOauthService); + core.turnRequestBuilderBindings.coderOauthService = this.coderOauthService; this.copilotOauthService = new CopilotOauthService(this.providerService, this.windowService); // Terminal services - PTYService is cross-platform this.ptyService = new PTYService(); @@ -629,6 +633,8 @@ export class ServiceContainer { config: this.config, aiService: this.aiService, historyService: this.historyService, + streamManager: this.streamManager, + initStateManager: this.initStateManager, projectService: this.projectService, workspaceService: this.workspaceService, taskService: this.taskService, diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 485348b7bd2..44b359b61d0 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -5636,6 +5636,53 @@ describe("StreamManager - categorizeError", () => { } }); describe("StreamManager - stopStream", () => { + test("aborts a pending startup with its reserved identity", async () => { + const events: TurnEngineEvent[] = []; + const streamManager = new StreamManager(historyService, undefined, undefined, (event) => { + events.push(event); + }); + const startup = streamManager.beginStreamStart({ + workspaceId: "pending-workspace", + acpPromptId: "prompt-1", + }); + + const result = await streamManager.stopStream("pending-workspace", { + abandonPartial: true, + abortReason: "user", + }); + startup.finish(); + + expect(result.success).toBe(true); + expect(startup.abortSignal.aborted).toBe(true); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "stream-abort", + workspaceId: "pending-workspace", + messageId: startup.syntheticMessageId, + abortReason: "user", + abandonPartial: true, + acpPromptId: "prompt-1", + }); + }); + + test("routes mock lifecycle operations through the engine", async () => { + const streamManager = new StreamManager(historyService); + const stop = mock(() => Promise.resolve()); + const replayStream = mock(() => Promise.resolve()); + streamManager.setMockStreamLifecycle({ + isStreaming: (workspaceId) => workspaceId === "mock-workspace", + stop, + replayStream, + }); + + expect(streamManager.isStreaming("mock-workspace")).toBe(true); + expect((await streamManager.stopStream("mock-workspace")).success).toBe(true); + await streamManager.replayStream("mock-workspace", { afterTimestamp: 10 }); + + expect(stop).toHaveBeenCalledWith("mock-workspace"); + expect(replayStream).toHaveBeenCalledWith("mock-workspace"); + }); + test("emits stream-abort when stopping non-existent stream", async () => { const streamManager = new StreamManager(historyService); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 6b5df209fb6..0d202a5d2a9 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -215,7 +215,7 @@ export interface TurnStreamHandle { completion: Promise; } -export interface TurnCompletionController { +interface TurnCompletionController { promise: Promise; settle: (completion: TurnCompletion) => void; } @@ -323,7 +323,7 @@ interface StreamRequestConfig { */ thinkingOverrideState?: ActiveTurnThinkingOverride; /** - * Closure built by AIService that re-runs the effective-level pipeline + * Closure built by TurnRequestBuilder that re-runs the effective-level pipeline * (policy clamp + resolveEffectiveThinkingLevel) and rebuilds provider * options for the stream's model. `null` ⇒ not applicable / no-op. */ @@ -338,12 +338,12 @@ interface StreamRequestConfig { } /** - * Per-model request pieces for a refusal-fallback swap, rebuilt by AIService so + * Per-model request pieces for a refusal-fallback swap, rebuilt by TurnRequestBuilder so * provider-specific message preparation, provider options, and headers match a * first-class send of the fallback model (reusing the source model's request * verbatim would leak provider-specific options/messages across providers). */ -export interface PreparedModelFallback { +interface PreparedModelFallback { model: LanguageModel; /** Canonical model string of the fallback attempt (drives metadata + tokenizer). */ modelString: string; @@ -385,7 +385,7 @@ export interface PreparedModelFallback { onStreamConstructed?: () => Promise; /** * Pinned providers-config snapshot the fallback request was built from - * (see AIService's pinCoderWireProvidersConfig). The swap's request-config + * (see TurnRequestBuilder's pinCoderInstanceProvidersConfig). The swap's request-config * rebuild and metadata resolution must read THIS snapshot, not the live * config: a catalog refresh between prepare() and the swap could retag the * instance and hand the prepared SDK model another wire's cache wrappers, @@ -566,7 +566,7 @@ function summarizeToolResultForLog(output: unknown): Record { }; } -function markProviderMetadataCostsIncluded( +export function markProviderMetadataCostsIncluded( providerMetadata: Record | undefined, costsIncluded: boolean | undefined ): Record | undefined { @@ -700,7 +700,7 @@ interface WorkspaceStreamInfo { // attempt's totalUsage only. didRetryAfterEmptyOutput?: boolean; // Refusal-fallback chain state. `original` keeps the pre-wrap request inputs - // (as passed by AIService) that prepare() does not rebuild, so the request can + // (as passed by TurnRequestBuilder) that prepare() does not rebuild, so the request can // be rebuilt for a different model — buildStreamRequestConfig re-applies // provider-specific wrapping (e.g. Anthropic cached system message / tool // cache_control), which must not be applied twice or leak across providers. @@ -782,15 +782,37 @@ function nextPartTimestamp(streamInfo: WorkspaceStreamInfo): number { * - Atomic stream creation/cancellation operations * - Guaranteed resource cleanup in all code paths */ +interface PendingStreamStartHandle { + readonly abortSignal: AbortSignal; + readonly syntheticMessageId: string; + finish(): void; +} + +interface MockStreamLifecycle { + isStreaming(workspaceId: string): boolean; + stop(workspaceId: string): Promise; + replayStream(workspaceId: string): Promise; +} + export class StreamManager { private workspaceStreams = new Map(); + private readonly pendingStreamStarts = new Map< + string, + { + abortController: AbortController; + startTime: number; + syntheticMessageId: string; + acpPromptId?: string; + } + >(); + private mockStreamLifecycle?: MockStreamLifecycle; private streamLocks = new Map(); private readonly PARTIAL_WRITE_THROTTLE_MS = 500; private readonly historyService: HistoryService; private mcpServerManager?: MCPServerManager; private readonly sessionUsageService?: SessionUsageService; private readonly getProvidersConfig: () => ProvidersConfigMap | null; - private readonly eventSink: TurnEngineEventSink; + private eventSink: TurnEngineEventSink; // Token tracker for live streaming statistics private tokenTracker = new StreamingTokenTracker(); // Track OpenAI previousResponseIds that have been invalidated @@ -809,6 +831,10 @@ export class StreamManager { this.eventSink = eventSink; } + setEventSink(eventSink: TurnEngineEventSink): void { + this.eventSink = eventSink; + } + private emitTurnEvent(event: TurnEngineEvent): void { // TurnEngineEventSink may return a promise; non-abort delivery stays // fire-and-forget, so contain rejections here or a failing async sink @@ -851,6 +877,44 @@ export class StreamManager { this.mcpServerManager = manager; } + setMockStreamLifecycle(lifecycle: MockStreamLifecycle | undefined): void { + this.mockStreamLifecycle = lifecycle; + } + + beginStreamStart(input: { + workspaceId: string; + abortSignal?: AbortSignal; + acpPromptId?: string; + }): PendingStreamStartHandle { + const abortController = new AbortController(); + const startTime = Date.now(); + const syntheticMessageId = + "starting-" + startTime + "-" + Math.random().toString(36).substring(2, 11); + const unlinkAbortSignal = linkAbortSignal(input.abortSignal, abortController); + + this.pendingStreamStarts.set(input.workspaceId, { + abortController, + startTime, + syntheticMessageId, + acpPromptId: input.acpPromptId, + }); + + let finished = false; + return { + abortSignal: abortController.signal, + syntheticMessageId, + finish: () => { + if (finished) return; + finished = true; + unlinkAbortSignal(); + const pending = this.pendingStreamStarts.get(input.workspaceId); + if (pending?.abortController === abortController) { + this.pendingStreamStarts.delete(input.workspaceId); + } + }, + }; + } + recordToolModelUsage( workspaceId: string, messageId: string, @@ -2253,7 +2317,7 @@ export class StreamManager { // this step's provider request is built. const thinkingOverride = this.applyPendingThinkingOverride(request); // Step 0: an override consumed here raced stream setup (written during - // startStream's awaits, after AIService's pre-construction quiescence + // startStream's awaits, after TurnRequestBuilder's pre-construction quiescence // fold). Message preparation is thinking-level-dependent (Anthropic // signed-reasoning transforms), so rebuild the first-step messages // under the applied level too; the closure also emits a superseding @@ -3914,7 +3978,7 @@ export class StreamManager { ? { acpPromptId: streamInfo.initialMetadata.acpPromptId } : {}), metadata: { - ...streamInfo.initialMetadata, // AIService-provided metadata (systemMessageTokens, etc) + ...streamInfo.initialMetadata, // TurnRequestBuilder-provided metadata (systemMessageTokens, etc) model: canonicalModel, metadataModel: streamInfo.metadataModel, routedThroughGateway, @@ -4767,7 +4831,7 @@ export class StreamManager { } // Step 3: Create temp directory for this stream using runtime. - // AIService pre-creates this dir so tool configuration can reference the same stable path; + // TurnRequestBuilder pre-creates this dir so tool configuration can reference the same stable path; // once startStream receives it, StreamManager owns cleanup for both success and abort paths. runtimeTempDir = providedRuntimeTempDir ?? (await this.createTempDirForStream(streamToken, runtime)); @@ -4992,23 +5056,44 @@ export class StreamManager { options?: { soft?: boolean; abandonPartial?: boolean; abortReason?: StreamAbortReason } ): Promise> { const typedWorkspaceId = workspaceId as WorkspaceId; + const pending = this.pendingStreamStarts.get(workspaceId); + const isActuallyStreaming = this.mockStreamLifecycle + ? this.mockStreamLifecycle.isStreaming(workspaceId) + : this.isStreaming(workspaceId); + + if (pending) { + pending.abortController.abort(); + if (!isActuallyStreaming) { + await this.emitStreamAbort( + typedWorkspaceId, + pending.syntheticMessageId, + { duration: Date.now() - pending.startTime }, + options?.abortReason ?? "startup", + options?.abandonPartial, + pending.acpPromptId + ); + } + } + + if (this.mockStreamLifecycle) { + await this.mockStreamLifecycle.stop(workspaceId); + return Ok(undefined); + } try { const streamInfo = this.workspaceStreams.get(typedWorkspaceId); if (!streamInfo) { - const abortReason = options?.abortReason ?? "startup"; - // Emit abort event so frontend clears pending stream state. - // This handles the case where user interrupts before stream-start arrives. - // Use empty messageId - frontend handles gracefully (just clears pendingStreamStartTime). - void this.emitStreamAbort( - typedWorkspaceId, - "", - {}, - abortReason, - options?.abandonPartial - ).catch((error) => { - log.error("Stream-abort delivery failed", { error: getErrorMessage(error) }); - }); + if (!pending) { + void this.emitStreamAbort( + typedWorkspaceId, + "", + {}, + options?.abortReason ?? "startup", + options?.abandonPartial + ).catch((error) => { + log.error("Stream-abort delivery failed", { error: getErrorMessage(error) }); + }); + } return Ok(undefined); } @@ -5016,14 +5101,12 @@ export class StreamManager { const soft = options?.soft ?? false; if (soft) { - // Soft interrupt: set flag, will cancel at next block boundary streamInfo.softInterrupt = { pending: true, abandonPartial: options?.abandonPartial ?? false, abortReason, }; } else { - // Hard interrupt: cancel immediately await this.cancelStreamSafely( typedWorkspaceId, streamInfo, @@ -5034,7 +5117,7 @@ export class StreamManager { return Ok(undefined); } catch (error) { const message = getErrorMessage(error); - return Err(`Failed to stop stream: ${message}`); + return Err("Failed to stop stream: " + message); } } @@ -5051,6 +5134,9 @@ export class StreamManager { * Checks if a workspace currently has an active stream */ isStreaming(workspaceId: string): boolean { + if (this.mockStreamLifecycle) { + return this.mockStreamLifecycle.isStreaming(workspaceId); + } const state = this.getStreamState(workspaceId); return state === StreamState.STARTING || state === StreamState.STREAMING; } @@ -5109,6 +5195,11 @@ export class StreamManager { * This allows replay to flow through the same event path as live streaming (no duplication) */ async replayStream(workspaceId: string, opts?: { afterTimestamp?: number }): Promise { + if (this.mockStreamLifecycle) { + await this.mockStreamLifecycle.replayStream(workspaceId); + return; + } + const typedWorkspaceId = workspaceId as WorkspaceId; const streamInfo = this.workspaceStreams.get(typedWorkspaceId); diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index f59325fa49e..e4346b1ed3e 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -447,6 +447,7 @@ function createAIServiceMocks( mock((): Promise> => Promise.resolve(Err("createModel not mocked"))); const getStreamInfo = overrides?.getStreamInfo ?? mock(() => undefined); const getProvidersConfig = overrides?.getProvidersConfig ?? mock(() => null); + const replayStream = mock(() => Promise.resolve()); const on = overrides?.on ?? mock(() => undefined); const off = overrides?.off ?? mock(() => undefined); @@ -459,6 +460,7 @@ function createAIServiceMocks( createModel, getStreamInfo, getProvidersConfig, + replayStream, on, off, } as unknown as AIService, @@ -640,7 +642,9 @@ function createTaskServiceHarness( workspaceService, initStateManager, overrides?.sessionUsageService, - overrides?.workspaceGoalService + overrides?.workspaceGoalService, + // The engine reads only getStreamInfo here; the AI mock carries the same mock the tests override. + aiService as unknown as ConstructorParameters[7] ); return { diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index ad0ad4028c2..6b5efd2cf92 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -17,6 +17,7 @@ import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; import type { Config, ProjectsConfig, Workspace as WorkspaceConfigEntry } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; +import type { StreamManager } from "@/node/services/streamManager"; import type { QueueCutCutter } from "@/node/services/messageQueue"; import { areArchiveUntrackedPathListsEqual, @@ -2203,7 +2204,8 @@ export class TaskService implements AgentTaskIntegration { private readonly workspaceService: WorkspaceHost, private readonly initStateManager: InitStateManager, private readonly sessionUsageService?: SessionUsageService, - private readonly workspaceGoalService?: WorkspaceGoalService + private readonly workspaceGoalService?: WorkspaceGoalService, + private readonly streamManager?: StreamManager ) { this.agentPeerMessageBroker = new AgentPeerMessageBroker(workspaceService); this.taskHandleStore = new TaskHandleStore(config); @@ -14069,7 +14071,7 @@ export class TaskService implements AgentTaskIntegration { if (this.workspaceService.hasPendingBashMonitorWakeContinuation(event.workspaceId)) { return true; } - const activeStream = this.aiService.getStreamInfo(event.workspaceId); + const activeStream = this.streamManager?.getStreamInfo(event.workspaceId); if (activeStream == null || activeStream.messageId === event.messageId) { return false; } @@ -14101,7 +14103,7 @@ export class TaskService implements AgentTaskIntegration { * handleStreamEnd's awaits cannot steal attribution from the real cutter. */ private captureQueueCutAttributionSnapshot(workspaceId: string): QueueCutAttributionSnapshot { - const activeStream = this.aiService.getStreamInfo(workspaceId); + const activeStream = this.streamManager?.getStreamInfo(workspaceId); return { activeStream: activeStream != null diff --git a/src/node/services/turnRequestBuilder.test.ts b/src/node/services/turnRequestBuilder.test.ts new file mode 100644 index 00000000000..cc961d50b50 --- /dev/null +++ b/src/node/services/turnRequestBuilder.test.ts @@ -0,0 +1,412 @@ +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; +import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; +import type { ProvidersConfigMap } from "@/common/orpc/types"; +import { createMuxMessage, type MuxMessage } from "@/common/types/message"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; +import { addInterruptedSentinel } from "@/browser/utils/messages/modelMessageTransform"; +import { buildWorkflowRunCardMessage } from "@/common/utils/workflowRunMessages"; +import * as providerOptionsModule from "@/common/utils/ai/providerOptions"; +import type { ProvidersConfig } from "@/node/config"; +import { InitStateManager } from "./initStateManager"; +import { ProviderModelFactory } from "./providerModelFactory"; +import { ProviderService } from "./providerService"; +import { StreamManager } from "./streamManager"; +import { createTestHistoryService } from "./testHistoryService"; +import { + TurnRequestBuilder, + prepareProviderRequestMessages, + resolveXumToolScope, + type PrepareModelAttemptOptions, +} from "./turnRequestBuilder"; +import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; + +async function createPreparationHarness() { + const testHistory = await createTestHistoryService(); + const { config, historyService } = testHistory; + const providerService = new ProviderService(config); + const streamManager = new StreamManager( + historyService, + undefined, + () => providerService.getConfig(), + () => undefined + ); + const builder = new TurnRequestBuilder({ + config, + historyService, + initStateManager: new InitStateManager(config), + providerService, + providerModelFactory: new ProviderModelFactory(config, providerService), + streamManager, + workspaceMcpOverridesService: new WorkspaceMcpOverridesService(config), + lastLlmRequestByWorkspace: new Map(), + bindings: {}, + emit: () => false, + createAbortedTurnHandle: (messageId) => ({ + messageId, + completion: Promise.resolve({ status: "aborted", abortReason: "startup" }), + }), + createSettledTurnHandle: (messageId, completion) => ({ + messageId, + completion: Promise.resolve(completion), + }), + getWorkspaceMetadata: () => Promise.reject(new Error("not used by request preparation tests")), + createWorkspaceRuntimeContext: () => { + throw new Error("not used by request preparation tests"); + }, + isClaudeSkillsCompatEnabled: () => false, + isAgentPluginsEnabled: () => false, + wrapToolsForDelegation: (_workspaceId, tools) => tools, + durableEventJournalFor: () => { + throw new Error("not used by request preparation tests"); + }, + shouldAllowLegacyInvalidWorkflowAgentOutputSchema: () => Promise.resolve(false), + createModel: () => Promise.reject(new Error("not used by request preparation tests")), + isStreaming: () => false, + trackPendingDevToolsRunMetadata: () => undefined, + }); + return { ...testHistory, builder, providerService }; +} + +function preparationOptions( + providersConfigSnapshot: ProvidersConfigMap, + overrides: Partial = {} +): PrepareModelAttemptOptions { + const modelString = "anthropic:claude-sonnet-4-5"; + return { + rawModelString: modelString, + canonicalModelString: modelString, + canonicalProviderName: "anthropic", + effectiveModelString: modelString, + optionsModelString: modelString, + wireProviderName: "anthropic", + effectiveThinkingLevel: "medium", + minThinkingLevel: "off", + providerRequestMessages: [createMuxMessage("user", "user", "continue")], + muxProviderOptions: {}, + workspaceId: "workspace", + truncationMode: undefined, + providersConfigSnapshot, + promptCacheScope: "project-scope", + reasoningMode: undefined, + ...overrides, + }; +} + +afterEach(() => mock.restore()); + +describe("TurnRequestBuilder message preparation", () => { + it.each([ + { + name: "uses the latest valid reset boundary", + messages: [ + createMuxMessage("old", "user", "old", { historySequence: 1 }), + createMuxMessage("reset", "assistant", "", { + historySequence: 2, + contextBoundaryKind: CONTEXT_BOUNDARY_KINDS.RESET, + }), + createMuxMessage("latest", "user", "latest", { historySequence: 3 }), + ], + expected: ["latest"], + }, + { + name: "ignores malformed boundary metadata", + messages: [ + createMuxMessage("before", "assistant", "before", { historySequence: 1 }), + createMuxMessage("malformed", "assistant", "not a durable boundary", { + historySequence: 2, + compacted: "user", + compactionBoundary: true, + compactionEpoch: 0, + }), + createMuxMessage("latest", "user", "latest", { historySequence: 3 }), + ], + expected: ["before", "malformed", "latest"], + }, + ])("$name", ({ messages, expected }) => { + const prepared = prepareProviderRequestMessages([...messages], "openai", "off"); + expect(prepared.providerRequestMessages.map((message) => message.id)).toEqual([...expected]); + }); + + it.each([ + { provider: "openai" as const, level: "off" as const, expected: ["latest"] }, + { + provider: "anthropic" as const, + level: "high" as const, + expected: ["latest", "partial", "interrupted-partial"], + }, + ])("prepares $provider fallback continuations", ({ provider, level, expected }) => { + const partial: MuxMessage = { + id: "partial", + role: "assistant", + metadata: { partial: true, historySequence: 2 }, + parts: [{ type: "reasoning", text: "unfinished" }], + }; + const prepared = prepareProviderRequestMessages( + [createMuxMessage("latest", "user", "continue"), partial], + provider, + level + ); + expect( + addInterruptedSentinel(prepared.providerRequestMessages).map((message) => message.id) + ).toEqual([...expected]); + }); + + it("filters workflow display rows while preserving the provider-visible result", () => { + const trigger = createMuxMessage("workflow-command", "user", "/review", { + historySequence: 1, + muxMetadata: { + type: "workflow-trigger-display", + rawCommand: "/review", + commandPrefix: "/review", + runId: "wfr_1", + }, + }); + const card = buildWorkflowRunCardMessage( + { name: "review", args: {} }, + { runId: "wfr_1", status: "running", result: null }, + 2 + ); + card.metadata = { + historySequence: 2, + synthetic: true, + uiVisible: true, + muxMetadata: { type: "workflow-run-card-display", runId: "wfr_1" }, + }; + const result = createMuxMessage("workflow-result", "user", "result", { + historySequence: 3, + muxMetadata: { + type: "workflow-result", + rawCommand: "/review", + commandPrefix: "/review", + runId: "wfr_1", + }, + }); + + const prepared = prepareProviderRequestMessages( + [trigger, card, result, createMuxMessage("next", "user", "continue")], + "openai", + "off" + ); + expect(prepared.providerRequestMessages.map((message) => message.id)).toEqual([ + "workflow-result", + "next", + ]); + }); + + it.each([ + { keepRecentTail: true, expected: ["head", "compact"] }, + { keepRecentTail: false, expected: ["head", "tail", "compact"] }, + ])("prepares compaction requests with keepRecentTail=$keepRecentTail", (testCase) => { + const request = createMuxMessage("compact", "user", "/compact", { + historySequence: 3, + muxMetadata: { + type: "compaction-request", + rawCommand: "/compact", + parsed: {}, + ...(testCase.keepRecentTail ? { keepRecentTail: { startHistorySequence: 2 } } : {}), + }, + }); + const prepared = prepareProviderRequestMessages( + [ + createMuxMessage("head", "user", "old", { historySequence: 1 }), + createMuxMessage("tail", "user", "recent", { historySequence: 2 }), + request, + ], + "openai", + "off" + ); + expect(prepared.providerRequestMessages.map((message) => message.id)).toEqual([ + ...testCase.expected, + ]); + }); +}); + +describe("TurnRequestBuilder tool scope", () => { + it.each([ + { projectPath: "/system", projectKind: "system" as const, expected: "global" }, + { projectPath: MULTI_PROJECT_CONFIG_KEY, projectKind: "system" as const, expected: "project" }, + ])("uses $expected scope for $projectPath", async ({ projectPath, projectKind, expected }) => { + const { config, cleanup } = await createTestHistoryService(); + try { + await config.editConfig((current) => { + current.projects.set(projectPath, { workspaces: [], projectKind }); + return current; + }); + const metadata: WorkspaceMetadata = { + id: "workspace", + name: "workspace", + projectName: "project", + projectPath, + runtimeConfig: { type: "local" }, + }; + expect(resolveXumToolScope(config, metadata, projectPath).type).toBe(expected); + } finally { + await cleanup(); + } + }); +}); + +describe("TurnRequestBuilder model attempt preparation", () => { + it("merges call settings and provider extras at the resolved namespace", async () => { + const harness = await createPreparationHarness(); + try { + harness.config.saveProvidersConfig({ + openai: { + modelParameters: { + "*": { temperature: 0.7, reasoning: { max_tokens: 4096 } }, + }, + }, + }); + spyOn(providerOptionsModule, "buildProviderOptions").mockReturnValue({ + openrouter: { reasoning: { enabled: true, effort: "high", exclude: false } }, + }); + const prepared = harness.builder.prepareModelAttempt( + preparationOptions(harness.providerService.getConfig() ?? {}, { + rawModelString: "openai:gpt-5.2", + canonicalModelString: "openai:gpt-5.2", + canonicalProviderName: "openai", + effectiveModelString: "openrouter:openai/gpt-5.2", + optionsModelString: "openai:gpt-5.2", + wireProviderName: "openai", + routeProvider: "openrouter", + }) + ); + + expect(prepared.resolvedOverrides.standard).toEqual({ temperature: 0.7 }); + expect(prepared.providerOptions).toEqual({ + openrouter: { + reasoning: { enabled: true, effort: "high", exclude: false, max_tokens: 4096 }, + }, + }); + } finally { + await harness.cleanup(); + } + }); + + it("clamps thinking rebuilds and tracks the applied level", async () => { + const harness = await createPreparationHarness(); + try { + const prepared = harness.builder.prepareModelAttempt( + preparationOptions(harness.providerService.getConfig() ?? {}, { + effectiveThinkingLevel: "medium", + minThinkingLevel: "medium", + }) + ); + + expect(prepared.rebuildProviderOptionsForThinkingLevel("off")).toBeNull(); + const rebuilt = prepared.rebuildProviderOptionsForThinkingLevel("high"); + expect(rebuilt?.effectiveLevel).toBe("high"); + expect(rebuilt?.providerOptions.anthropic).toMatchObject({ + thinking: { type: "enabled", budgetTokens: 20000 }, + }); + expect(prepared.rebuildProviderOptionsForThinkingLevel("high")).toBeNull(); + } finally { + await harness.cleanup(); + } + }); + + it.each([ + { routeProvider: "openai" as const, hasCacheKey: true }, + { routeProvider: "mux-gateway" as const, hasCacheKey: false }, + ])("sets Chat Completions cache keys for $routeProvider routes", async (testCase) => { + const harness = await createPreparationHarness(); + try { + const prepared = harness.builder.prepareModelAttempt( + preparationOptions( + { openai: { apiKeySet: true, isEnabled: true, isConfigured: true } }, + { + rawModelString: "openai:gpt-5.6-luna", + canonicalModelString: "openai:gpt-5.6-luna", + canonicalProviderName: "openai", + effectiveModelString: "openai:gpt-5.6-luna", + optionsModelString: "openai:gpt-5.6-luna", + wireProviderName: "openai", + routeProvider: testCase.routeProvider, + effectiveThinkingLevel: "off", + muxProviderOptions: { openai: { wireFormat: "chatCompletions" } }, + } + ) + ); + const openai = prepared.providerOptions.openai as Record; + expect(typeof openai.promptCacheKey === "string").toBe(testCase.hasCacheKey); + } finally { + await harness.cleanup(); + } + }); + + it.each([ + { + name: "maps a cross-typed Coder instance to its Anthropic wire", + rawConfig: { + anthropic: { modelParameters: { "*": { anthropicKnob: "yes" } } }, + openai: { modelParameters: { "*": { openaiKnob: "no" } } }, + }, + snapshot: { + coder: { + apiKeySet: false, + isEnabled: true, + isConfigured: true, + additionalProviders: [{ name: "openai", type: "anthropic" }], + }, + }, + options: { + rawModelString: "coder:openai/claude-opus-4-5", + canonicalModelString: "openai:claude-opus-4-5", + canonicalProviderName: "openai" as const, + effectiveModelString: "coder:openai/claude-opus-4-5", + optionsModelString: "coder:openai/claude-opus-4-5", + wireProviderName: "anthropic", + routeProvider: "coder" as const, + coderSelectedInstance: { name: "openai", type: "anthropic" }, + }, + namespace: "anthropic", + included: "anthropicKnob", + excluded: "openaiKnob", + }, + { + name: "keeps unmappable Coder overrides gateway-scoped", + rawConfig: { + anthropic: { modelParameters: { "*": { anthropicKnob: "no" } } }, + coder: { modelParameters: { "*": { coderKnob: "yes" } } }, + }, + snapshot: { + coder: { + apiKeySet: false, + isEnabled: true, + isConfigured: true, + discoveredProviders: [{ name: "anthropic", type: "openai-compat" }], + }, + }, + options: { + rawModelString: "coder:anthropic/gpt-5", + canonicalModelString: "anthropic:gpt-5", + canonicalProviderName: "anthropic" as const, + effectiveModelString: "coder:anthropic/gpt-5", + optionsModelString: "coder:anthropic/gpt-5", + wireProviderName: "openai", + routeProvider: "coder" as const, + coderSelectedInstance: { name: "anthropic", type: "openai-compat" }, + }, + namespace: "openai", + included: "coderKnob", + excluded: "anthropicKnob", + }, + ])("$name", async (testCase) => { + const harness = await createPreparationHarness(); + try { + harness.config.saveProvidersConfig(testCase.rawConfig as unknown as ProvidersConfig); + const prepared = harness.builder.prepareModelAttempt( + preparationOptions( + testCase.snapshot as unknown as ProvidersConfigMap, + testCase.options as Partial + ) + ); + const namespace = prepared.providerOptions[testCase.namespace] as Record; + expect(namespace[testCase.included]).toBe("yes"); + expect(namespace).not.toHaveProperty(testCase.excluded); + } finally { + await harness.cleanup(); + } + }); +}); diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts new file mode 100644 index 00000000000..c9e67eca543 --- /dev/null +++ b/src/node/services/turnRequestBuilder.ts @@ -0,0 +1,2930 @@ +import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; +import assert from "@/common/utils/assert"; +import { type LanguageModel, type Tool } from "ai"; + +import type { ProvidersConfigMap, SendMessageOptions } from "@/common/orpc/types"; +import type { Result } from "@/common/types/result"; +import { Err, Ok } from "@/common/types/result"; +import type { WorkspaceMetadata } from "@/common/types/workspace"; +import { projectAutomationDisabled } from "@/node/utils/projectAutomation"; + +import { + ADVISOR_DEFAULT_MAX_USES_PER_TURN, + resolveAdvisorEnabledForAgent, +} from "@/common/constants/advisor"; +import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; + +import { + addInterruptedSentinel, + filterEmptyAssistantMessages, +} from "@/browser/utils/messages/modelMessageTransform"; +import type { SendMessageError } from "@/common/types/errors"; +import type { GoalRecordV1 } from "@/common/types/goal"; +import type { ModelMessage, MuxMessage, MuxMessageMetadata } from "@/common/types/message"; +import { createMuxMessage } from "@/common/types/message"; +import type { MuxProviderOptions } from "@/common/types/providerOptions"; +import { secretsToRecord } from "@/common/types/secrets"; +import type { XumToolScope } from "@/common/types/toolScope"; +import { getGoalToolAvailability } from "@/common/utils/tools/toolAvailability"; +import { + deriveToolHookConfig, + getForcedXaiSearchToolNames, + getToolsForModel, + type AdvisorStepCaptureRef, + type MCPPromptRuntime, + type ToolConfiguration, +} from "@/common/utils/tools/tools"; +import type { Config } from "@/node/config"; +import { getRuntimeType, getXumEnv } from "@/node/runtime/initHook"; +import { type WorkspaceRuntimeContext } from "@/node/runtime/runtimeHelpers"; +import { agentPluginHookService } from "@/node/services/agentPlugins/hookService"; +import { resolveAgentPluginsMcpContext } from "@/node/services/agentPlugins/mcpConfig"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import { isRlmModeEnabled } from "@/node/services/branchSummary"; +import type { PolicyService } from "@/node/services/policyService"; +import type { ProviderService } from "@/node/services/providerService"; +import { mergeMultiProjectSecrets } from "@/node/services/utils/multiProjectSecrets"; +import { type DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; +import type { InitStateManager } from "./initStateManager"; +import { runLanguageModelCleanup } from "./languageModelCleanup"; +import { log } from "./log"; +import type { StreamManager } from "./streamManager"; +import { + markProviderMetadataCostsIncluded, + type ModelFallbackOptions, + type StreamTextOnChunk, + type TurnCompletion, + type TurnExecutionOptions, + type TurnStreamHandle, +} from "./streamManager"; +import { emitTurnEnvelope } from "./turnEnvelope"; + +import { normalizeToCanonical } from "@/common/utils/ai/models"; +import { extractChunkDeltaText } from "@/common/utils/ai/streamChunks"; +import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; +import { getTotalCost, sumUsageHistory } from "@/common/utils/tokens/usageAggregator"; +import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; +import type { DevToolsService } from "@/node/services/devToolsService"; +import type { ExperimentsService } from "@/node/services/experimentsService"; +import { findWorkspaceEntry, resolveWorkspaceModelFallbackChain } from "@/node/services/taskUtils"; +import type { TelemetryService } from "@/node/services/telemetryService"; +import { + effectiveAdditionalSystemContext, + mergeAdditionalSystemInstructions, + readAdditionalSystemContext, +} from "./additionalSystemContext"; +import type { HistoryService } from "./historyService"; +import type { SessionUsageService } from "./sessionUsageService"; +import { readToolInstructions } from "./systemMessage"; +import { createAssistantMessageId } from "./utils/messageIds"; +import { createErrorEvent, formatSendMessageError } from "./utils/sendMessageError"; + +import type { ProvidersConfig } from "@/common/config/schemas/providersConfig"; +import { + coderGatewayWireProtocol, + resolveCoderWireCanonicalModel, +} from "@/common/constants/coderOAuth"; +import { PROVIDER_DEFINITIONS, type ProviderName } from "@/common/constants/providers"; +import type { WorkspaceMCPOverrides } from "@/common/types/mcp"; +import { isExecLikeEditingCapableInResolvedChain } from "@/common/utils/agentTools"; +import { resolveModelParameterOverrides } from "@/common/utils/ai/modelParameterOverrides"; +import { + buildProviderOptions, + buildRequestHeaders, + resolveProviderOptionsNamespaceKey, +} from "@/common/utils/ai/providerOptions"; +import { uniqueSuffix } from "@/common/utils/hasher"; +import { isPlainObject } from "@/common/utils/isPlainObject"; +import { sliceMessagesForProviderFromLatestContextBoundary } from "@/common/utils/messages/compactionBoundary"; +import { excludeKeepRecentTailForCompactionRequest } from "@/common/utils/messages/keepRecentTail"; +import { getProjects, isMultiProject } from "@/common/utils/multiProject"; +import { resolveCoderGatewayMetadataModel } from "@/common/utils/providers/coderGatewayMetadata"; +import { + customProviderWireOrigin, + isCustomProviderConfig, +} from "@/common/utils/providers/customProviders"; +import type { MCPServerManager, MCPWorkspaceStats } from "@/node/services/mcpServerManager"; +import { type MemoryService, type MemorySessionContext } from "@/node/services/memoryService"; +import type { TaskService } from "@/node/services/taskService"; +import { resolveMemoryAccessPolicy } from "@/node/services/tools/memory"; +import { isWorkspaceTrustedForSharedExecution } from "@/node/services/utils/workspaceTrust"; +import type { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; + +import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; +import { + THINKING_LEVEL_OFF, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; +import { mergeGoalDefaults } from "@/common/utils/goals/resolveGoalSetIntent"; +import { + enforceThinkingPolicy, + isXaiGrokFastVariantSwap, + lookupMinThinkingLevelOverride, + resolveEffectiveThinkingLevel, + resolveMinimumThinkingLevel, +} from "@/common/utils/thinking/policy"; +import { DEFAULT_GOAL_DEFAULTS, normalizeGoalDefaults } from "@/constants/goals"; +import type { + RebuildFirstStepForThinkingLevel, + RebuildProviderOptionsForThinkingLevel, +} from "@/node/services/thinkingOverride"; + +import { isTerminalWorkflowRunStatus } from "@/common/types/workflow"; +import { getErrorMessage } from "@/common/utils/errors"; +import { + normalizeUsageModelKey, + resolveModelForMetadata, +} from "@/common/utils/providers/modelEntries"; +import { + computeActiveToolNames, + prepareToolSearch, + rebuildToolSearchState, + seedToolSearchActivationsFromMessages, + TOOL_SEARCH_TOOL_NAME, + type ToolSearchRuntime, +} from "@/common/utils/tools/toolCatalog"; +import { + buildWorkflowResultContextMessage, + filterWorkflowDisplayOnlyMessages, + WORKFLOW_RESULT_METADATA_TYPE, +} from "@/common/utils/workflowRunMessages"; +import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext"; +import { eventSpine, type RequestAssembleContext } from "@/node/services/events/eventSpine"; +import { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime"; +import type { PTCEventWithParent } from "@/node/services/tools/code_execution"; +import { createKernelFileLoader } from "@/node/services/tools/kernelFileLoad"; +import { WorkflowRunStore } from "@/node/services/workflows/WorkflowRunStore"; +import { resolveWorkflowScript } from "@/node/services/workflows/workflowScriptResolver"; +import { + WorkflowService, + type WorkflowRunStatusChangedEvent, +} from "@/node/services/workflows/WorkflowService"; +import { + DEFAULT_WORKFLOW_AGENT_ID, + WorkflowTaskServiceAdapter, +} from "@/node/services/workflows/WorkflowTaskServiceAdapter"; +import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; +import { isWorkspaceProjectTrusted } from "@/node/utils/projectTrust"; +import { getLegacyModeForAgentMetadata, resolveAgentForStream } from "./agentResolution"; +import { DEVTOOLS_RUN_METADATA_ID_HEADER } from "./devToolsHeaderCapture"; +import { prepareMessagesForProvider } from "./messagePipeline"; +import type { OauthServiceBindings, ProviderModelFactory } from "./providerModelFactory"; +import { modelCostsIncluded } from "./providerModelFactory"; +import { buildPlanInstructions, buildStreamSystemContext } from "./streamContextBuilder"; +import { + simulateContextLimitError, + simulateToolPolicyNoop, + type SimulationContext, +} from "./streamSimulation"; +import { + applyToolPolicyAndExperiments, + captureMcpToolTelemetry, + resolveBackendGatedPtcExperiments, +} from "./toolAssembly"; + +const STREAM_STARTUP_DIAGNOSTIC_THRESHOLD_MS = 1_000; + +export function resolveMuxProjectRootForHostFs( + metadata: WorkspaceMetadata, + workspacePath: string +): string { + const runtimeType = metadata.runtimeConfig.type; + return runtimeType === "ssh" || runtimeType === "docker" ? metadata.projectPath : workspacePath; +} + +export function resolveXumToolScope( + config: Config, + metadata: WorkspaceMetadata, + workspacePath: string, + checkoutRoot?: string | null +): XumToolScope { + const projectConfig = config.loadConfigOrDefault().projects.get(metadata.projectPath); + if ( + projectConfig?.projectKind === "system" && + metadata.projectPath !== MULTI_PROJECT_CONFIG_KEY + ) { + return { type: "global", xumHome: config.rootDir }; + } + const runtimeType = metadata.runtimeConfig.type; + return { + type: "project", + xumHome: config.rootDir, + projectRoot: resolveMuxProjectRootForHostFs(metadata, workspacePath), + projectStorageAuthority: + runtimeType === "ssh" || runtimeType === "docker" ? "runtime" : "host-local", + ...(checkoutRoot != null ? { checkoutRoot } : {}), + }; +} + +import type { PostCompactionAttachment } from "@/common/types/attachment"; +import type { ErrorEvent } from "@/common/types/stream"; +import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; +import type { FileState } from "@/node/services/agentSession"; +import type { ActiveTurnThinkingOverride } from "@/node/services/thinkingOverride"; +import type { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; + +/** Options used to prepare and execute a turn. */ +export interface StreamMessageOptions { + messages: MuxMessage[]; + workspaceId: string; + modelString: string; + thinkingLevel?: ThinkingLevel; + /** OpenAI pro reasoning mode; delivered via provider options (inert for unsupported models). */ + reasoningMode?: OpenAIReasoningMode; + toolPolicy?: ToolPolicy; + abortSignal?: AbortSignal; + /** Live workspace scratchpad snapshot from the renderer; when present it wins over disk. */ + additionalSystemContext?: string; + additionalSystemInstructions?: string; + maxOutputTokens?: number; + muxProviderOptions?: MuxProviderOptions; + /** Internal-only flag for Copilot billing attribution; never sourced from IPC schemas. */ + agentInitiated?: boolean; + agentId?: string; + /** See SendMessageOptionsSchema.strictAgentResolution: explicit-agent sends fail loudly instead of falling back to exec. */ + strictAgentResolution?: SendMessageOptions["strictAgentResolution"]; + /** ACP prompt correlation id used to match stream events to a specific request. */ + acpPromptId?: string; + /** Invoked with each fatal pre-start error event this call emits before returning Err. */ + onPreStartError?: (event: ErrorEvent) => void; + /** Tool names that should be delegated back to ACP clients for this request. */ + delegatedToolNames?: string[]; + recordFileState?: (filePath: string, state: FileState) => Promise; + postCompactionAttachments?: PostCompactionAttachment[] | null; + /** + * Resolver for the session-segment memory context (memory experiment): + * index snapshot for the memory tool description + hot-memories block. + * AgentSession caches the result per model/session segment because hot-memory + * selection is token-budgeted with the active model tokenizer. A callback + * (not a pre-resolved value) because it must be computed after + * runtime.ensureReady(): project-scope listing on a + * stopped Docker/remote workspace would otherwise cache an empty/partial + * context for the whole segment. + */ + resolveMemoryContext?: ( + modelString: string, + options?: { includeHotMemories?: boolean } + ) => Promise; + experiments?: SendMessageOptions["experiments"]; + allowAgentSetGoal?: boolean; + workspaceGoalService?: WorkspaceGoalService; + disableWorkspaceAgents?: boolean; + hasQueuedMessages?: (dispatchMode?: "tool-end" | "turn-end") => boolean; + muxMetadata?: MuxMessageMetadata; + openaiTruncationModeOverride?: "auto" | "disabled"; + /** + * Model floor already resolved by AgentSession (config.json + * minThinkingLevelByModel → resolveMinimumThinkingLevel). Passed down so + * mid-turn overrides clamp against the same floor as the send-time level; + * internal callers may omit it (re-resolved from defaults). + */ + minThinkingLevel?: ThinkingLevel; + /** + * Session-owned per-turn holder for mid-turn thinking-level overrides. + * When absent (compaction, sub-agent paths), the feature is inert for the + * stream. See src/node/services/thinkingOverride.ts. + */ + activeTurnThinkingOverride?: ActiveTurnThinkingOverride; +} + +export function prepareProviderRequestMessages( + messages: MuxMessage[], + canonicalProviderName: string, + effectiveThinkingLevel: ThinkingLevel +): { + activeContextMessages: MuxMessage[]; + providerRequestMessages: MuxMessage[]; + contextBoundarySlicedCount: number; +} { + // Workflow display rows are durable UI history, not main-agent context. + const messagesWithoutWorkflowDisplay = filterWorkflowDisplayOnlyMessages(messages); + // RLM keep-recent floor: a stamped compaction request summarizes only the + // older head; the stamped tail is preserved verbatim after the boundary. + // No-op (same reference) unless the trailing user row carries the durable + // stamp, so RLM-off requests and replay stay byte-identical. + const activeContextMessages = excludeKeepRecentTailForCompactionRequest( + sliceMessagesForProviderFromLatestContextBoundary(messagesWithoutWorkflowDisplay) + ); + const contextBoundarySlicedCount = + messagesWithoutWorkflowDisplay.length - activeContextMessages.length; + const preserveReasoningOnly = + canonicalProviderName === "anthropic" && effectiveThinkingLevel !== "off"; + return { + activeContextMessages, + providerRequestMessages: filterEmptyAssistantMessages( + activeContextMessages, + preserveReasoningOnly + ), + contextBoundarySlicedCount, + }; +} + +// Exported for the replay builder: fallback requests append the refusal's +// partial continuation the same way production does. +export function replaceOrAppendMessageById( + messages: MuxMessage[], + replacement: MuxMessage +): MuxMessage[] { + const index = messages.findIndex((message) => message.id === replacement.id); + if (index === -1) { + return [...messages, replacement]; + } + + const next = [...messages]; + next[index] = replacement; + return next; +} + +// --------------------------------------------------------------------------- +// streamMessage options +// --------------------------------------------------------------------------- + +/** + * Recursively merge user-provided provider extras under Xum-built provider options. + * Xum values win on leaf conflicts; both sides' non-conflicting nested fields are preserved. + */ +function mergeProviderExtrasUnderMux( + providerExtras: Record, + muxProviderNamespace: Record +): Record { + const merged: Record = { ...providerExtras }; + + for (const [key, muxValue] of Object.entries(muxProviderNamespace)) { + const extraValue = merged[key]; + merged[key] = + isPlainObject(extraValue) && isPlainObject(muxValue) + ? mergeProviderExtrasUnderMux(extraValue, muxValue) + : muxValue; + } + + return merged; +} + +const WORKFLOW_CONTINUATION_RETRY_DELAY_MS = 1_000; +const WORKSPACE_BUSY_IDLE_ONLY_SEND_MESSAGE = "Workspace is busy; idle-only send was skipped."; + +function isWorkspaceBusyIdleOnlySend(error: SendMessageError): boolean { + return error.type === "unknown" && error.raw.includes(WORKSPACE_BUSY_IDLE_ONLY_SEND_MESSAGE); +} + +function waitForWorkflowContinuationRetry(): Promise { + return new Promise((resolve) => setTimeout(resolve, WORKFLOW_CONTINUATION_RETRY_DELAY_MS)); +} + +/** + * Pin the factory-resolved Coder instance type into a providers-config view. + * + * Every request builder (message prep, options, headers, overrides, + * capability lookups, mid-turn rebuild closures) consumes ONE snapshot per + * request instead of re-reading ProviderService. Pinning closes the residual + * race between the factory's own config read and this capture: a concurrent + * authoritative catalog refresh that rewrites the selected instance's type + * would otherwise make the builders resolve a different wire than the + * already-created SDK model. additionalProviders is the highest-precedence + * metadata source (resolveCoderGatewayProvider consults it first), so the + * pinned entry wins over any concurrently rewritten discovered metadata. + * Pinning keys on the RAW selection's instance (coderSelectedInstance), not + * on the effective route: a coder: selection that FELL BACK to a direct + * provider still has builders resolving the raw model string (capability + * lookups, override identity, option/header rebuilds), and a concurrent + * retag between the factory's read and this capture would otherwise hand + * the already-created fallback model another type's options. Non-coder + * selections, shadowed prefixes, and unknown instances have no snapshot and + * keep the view untouched. + */ +function pinCoderInstanceProvidersConfig( + view: ProvidersConfigMap, + rawModelString: string, + instance: { name: string; type: string } | undefined +): ProvidersConfigMap { + if (!instance || !rawModelString.startsWith("coder:")) { + return view; + } + return { + ...view, + coder: { + ...(view.coder ?? { apiKeySet: false, isEnabled: true, isConfigured: true }), + additionalProviders: [{ name: instance.name, type: instance.type }], + }, + }; +} + +/** + * Raw providers.jsonc counterpart of pinCoderInstanceProvidersConfig for + * consumers that need file-shaped config (modelParameters lookups). Same + * rationale: additionalProviders is the highest-precedence metadata source, + * so pinning the factory-resolved instance there keeps metadata-dependent + * decisions (mappedToModel aliases, sampling gates) on the type the SDK + * model was created for. + */ +function pinCoderInstanceRawProvidersConfig( + view: ProvidersConfig | null, + rawModelString: string, + instance: { name: string; type: string } | undefined +): ProvidersConfig | null { + if (!view || !instance || !rawModelString.startsWith("coder:")) { + return view; + } + return { + ...view, + coder: { + ...(view.coder ?? {}), + additionalProviders: [{ name: instance.name, type: instance.type }], + }, + }; +} + +function derivePromptCacheScope(metadata: WorkspaceMetadata): string { + return `${metadata.projectName}-${uniqueSuffix([metadata.projectPath])}`; +} + +interface WorkflowResultContinuationSender { + isWorkflowInvocationCurrent(workspaceId: string, runId: string): Promise; + sendMessage( + workspaceId: string, + message: string, + options: SendMessageOptions, + internal?: { + skipAutoResumeReset?: boolean; + synthetic?: boolean; + agentInitiated?: boolean; + /** When true, reject instead of queueing if the workspace is busy. */ + requireIdle?: boolean; + startStreamInBackground?: boolean; + } + ): Promise>; +} + +interface TurnRequestBuildStartupState { + pendingRunMetadataId: string | null; + logSlowStreamStartup?: (details: Record) => void; +} + +interface TurnRequestBuildContext { + abortSignal: AbortSignal; + syntheticMessageId: string; + startTime: number; + startupPhaseTimingsMs: Record; + startupState: TurnRequestBuildStartupState; + recordStartupPhaseTiming: (phase: string, phaseStartedAt: number) => void; +} + +type TurnRequestBuildOutcome = + | { type: "finished"; result: Result } + | { + type: "ready"; + turnExecutionOptions: TurnExecutionOptions; + assistantMessageId: string; + deleteAbortedPlaceholder: (messageId: string) => Promise; + logStartOutcome: (outcome: "started" | "stream_start_failed", errorType?: string) => void; + }; + +export interface TurnRequestBuilderBindings extends OauthServiceBindings { + mcpServerManager?: MCPServerManager; + taskService?: TaskService; + memoryService?: MemoryService; + timelineService?: ToolConfiguration["timelineService"]; + extraTools?: Record; + onWorkflowRunStatusChanged?: (event: WorkflowRunStatusChangedEvent) => Promise | void; + workflowResultContinuationSender?: WorkflowResultContinuationSender; + workspaceHeartbeatService?: ToolConfiguration["workspaceHeartbeatService"]; + analyticsService?: { executeRawQuery(sql: string): Promise }; + desktopSessionManager?: DesktopSessionManager; +} + +interface TurnRequestBuilderDependencies { + config: Config; + historyService: HistoryService; + initStateManager: InitStateManager; + providerService: ProviderService; + providerModelFactory: ProviderModelFactory; + streamManager: StreamManager; + workspaceMcpOverridesService: WorkspaceMcpOverridesService; + policyService?: PolicyService; + telemetryService?: TelemetryService; + backgroundProcessManager?: BackgroundProcessManager; + sessionUsageService?: SessionUsageService; + devToolsService?: DevToolsService; + experimentsService?: ExperimentsService; + lastLlmRequestByWorkspace: Map; + bindings: TurnRequestBuilderBindings; + emit: (event: string, ...args: unknown[]) => boolean; + createAbortedTurnHandle: (messageId: string) => TurnStreamHandle; + createSettledTurnHandle: (messageId: string, completion: TurnCompletion) => TurnStreamHandle; + getWorkspaceMetadata: (workspaceId: string) => Promise>; + createWorkspaceRuntimeContext: ( + workspaceId: string, + metadata: WorkspaceMetadata + ) => Result< + WorkspaceRuntimeContext & { + hostCheckoutRoot: string | null; + projectCheckoutRoot: string | null; + }, + SendMessageError + >; + isClaudeSkillsCompatEnabled: () => boolean; + isAgentPluginsEnabled: () => boolean; + wrapToolsForDelegation: ( + workspaceId: string, + tools: Record, + delegatedToolNames?: string[] + ) => Record; + durableEventJournalFor: (workspaceId: string) => DurableEventJournal; + shouldAllowLegacyInvalidWorkflowAgentOutputSchema: ( + metadata: WorkspaceMetadata + ) => Promise; + createModel: ( + modelString: string, + muxProviderOptions?: MuxProviderOptions, + opts?: { agentInitiated?: boolean; workspaceId?: string; providersConfig?: ProvidersConfig } + ) => Promise>; + isStreaming: (workspaceId: string) => boolean; + trackPendingDevToolsRunMetadata: ( + messageId: string, + workspaceId: string, + metadataId: string + ) => void; +} + +export interface PrepareModelAttemptOptions { + rawModelString: string; + canonicalModelString: string; + canonicalProviderName: string; + effectiveModelString: string; + optionsModelString: string; + wireProviderName: string; + routeProvider?: ProviderName; + effectiveThinkingLevel: ThinkingLevel; + minThinkingLevel: ThinkingLevel; + providerRequestMessages: MuxMessage[]; + muxProviderOptions: MuxProviderOptions; + workspaceId: string; + truncationMode: Parameters[6]; + providersConfigSnapshot: ProvidersConfigMap; + coderSelectedInstance?: { name: string; type: string }; + promptCacheScope: string; + reasoningMode: Parameters[10]; + recordStartupPhaseTiming?: (phase: string, phaseStartedAt: number) => void; +} + +interface PreparedModelAttempt { + providerOptions: Record; + requestHeaders: Record | undefined; + resolvedOverrides: ReturnType; + currentEffectiveLevelRef: { current: ThinkingLevel }; + computeRebuiltProviderOptions: ( + level: ThinkingLevel, + currentLevel: ThinkingLevel + ) => { effectiveLevel: ThinkingLevel; providerOptions: Record } | null; + rebuildProviderOptionsForThinkingLevel: RebuildProviderOptionsForThinkingLevel; +} + +export class TurnRequestBuilder { + constructor(private readonly dependencies: TurnRequestBuilderDependencies) {} + + private resolveOverridesIdentity( + rawModelString: string, + canonical: string, + canonicalProvider: string, + providersConfig: ProvidersConfigMap + ): { providerName: string; modelString: string; coderDerived: boolean } { + if (rawModelString.startsWith("coder:")) { + const metadataCanonical = resolveCoderGatewayMetadataModel(rawModelString, providersConfig); + if (metadataCanonical != null) { + const separator = metadataCanonical.indexOf(":"); + return { + providerName: separator > 0 ? metadataCanonical.slice(0, separator) : canonicalProvider, + modelString: metadataCanonical, + coderDerived: true, + }; + } + const coderSection = providersConfig.coder; + if (!isCustomProviderConfig(coderSection)) { + const wire = resolveCoderWireCanonicalModel( + rawModelString.slice("coder:".length), + coderSection as + | { discoveredProviders?: unknown; additionalProviders?: unknown } + | undefined + ); + if (wire) { + return { providerName: "coder", modelString: rawModelString, coderDerived: false }; + } + } + } + const separator = canonical.indexOf(":"); + return { + providerName: separator > 0 ? canonical.slice(0, separator) : canonicalProvider, + modelString: canonical, + coderDerived: false, + }; + } + + prepareModelAttempt(options: PrepareModelAttemptOptions): PreparedModelAttempt { + const buildProviderOptionsStartedAt = Date.now(); + const providerOptions = buildProviderOptions( + options.optionsModelString, + options.effectiveThinkingLevel, + options.providerRequestMessages, + (id) => this.dependencies.streamManager.isResponseIdLost(id), + options.muxProviderOptions, + options.workspaceId, + options.truncationMode, + options.providersConfigSnapshot, + options.routeProvider, + options.promptCacheScope, + options.reasoningMode + ) as Record; + options.recordStartupPhaseTiming?.("buildProviderOptionsMs", buildProviderOptionsStartedAt); + const buildRequestConfigStartedAt = Date.now(); + const requestHeaders = buildRequestHeaders( + options.optionsModelString, + options.muxProviderOptions, + options.workspaceId, + options.providersConfigSnapshot, + options.routeProvider + ); + const overridesIdentity = this.resolveOverridesIdentity( + options.rawModelString, + options.canonicalModelString, + options.canonicalProviderName, + options.providersConfigSnapshot + ); + const resolvedOverrides = resolveModelParameterOverrides( + pinCoderInstanceRawProvidersConfig( + this.dependencies.config.loadProvidersConfig(), + options.rawModelString, + options.coderSelectedInstance + ), + overridesIdentity.providerName, + overridesIdentity.modelString, + options.effectiveModelString + ); + const namespaceKey = resolveProviderOptionsNamespaceKey( + options.wireProviderName, + options.routeProvider + ); + const extrasWireCompatible = + !overridesIdentity.coderDerived || overridesIdentity.providerName === namespaceKey; + const mergeExtras = (builtOptions: Record): Record => { + if (!resolvedOverrides.providerExtras || !extrasWireCompatible) { + return builtOptions; + } + const muxProviderNamespace = builtOptions[namespaceKey]; + return { + ...builtOptions, + [namespaceKey]: isPlainObject(muxProviderNamespace) + ? mergeProviderExtrasUnderMux(resolvedOverrides.providerExtras, muxProviderNamespace) + : resolvedOverrides.providerExtras, + }; + }; + const currentEffectiveLevelRef = { current: options.effectiveThinkingLevel }; + const computeRebuiltProviderOptions = ( + level: ThinkingLevel, + currentLevel: ThinkingLevel + ): { effectiveLevel: ThinkingLevel; providerOptions: Record } | null => { + const clamped = enforceThinkingPolicy( + options.rawModelString, + level, + options.minThinkingLevel, + options.providersConfigSnapshot + ); + const effective = resolveEffectiveThinkingLevel( + options.rawModelString, + clamped, + options.providersConfigSnapshot + ); + if ( + effective === currentLevel || + isXaiGrokFastVariantSwap(options.canonicalModelString, currentLevel, effective) + ) { + return null; + } + const rebuilt = buildProviderOptions( + options.optionsModelString, + effective, + options.providerRequestMessages, + (id) => this.dependencies.streamManager.isResponseIdLost(id), + options.muxProviderOptions, + options.workspaceId, + options.truncationMode, + options.providersConfigSnapshot, + options.routeProvider, + options.promptCacheScope, + options.reasoningMode + ) as Record; + return { effectiveLevel: effective, providerOptions: mergeExtras(rebuilt) }; + }; + const rebuildProviderOptionsForThinkingLevel: RebuildProviderOptionsForThinkingLevel = ( + level + ) => { + const result = computeRebuiltProviderOptions(level, currentEffectiveLevelRef.current); + if (result != null) { + currentEffectiveLevelRef.current = result.effectiveLevel; + } + return result; + }; + options.recordStartupPhaseTiming?.("buildRequestConfigMs", buildRequestConfigStartedAt); + return { + providerOptions: mergeExtras(providerOptions), + requestHeaders, + resolvedOverrides, + currentEffectiveLevelRef, + computeRebuiltProviderOptions, + rebuildProviderOptionsForThinkingLevel, + }; + } + + async build( + opts: StreamMessageOptions, + context: TurnRequestBuildContext + ): Promise { + const { + messages, + workspaceId, + modelString, + thinkingLevel, + reasoningMode, + toolPolicy, + additionalSystemContext, + additionalSystemInstructions, + maxOutputTokens, + muxProviderOptions, + agentInitiated, + agentId, + strictAgentResolution, + acpPromptId, + onPreStartError, + delegatedToolNames, + recordFileState, + postCompactionAttachments, + resolveMemoryContext, + experiments: experimentsFromOptions, + allowAgentSetGoal, + workspaceGoalService, + disableWorkspaceAgents, + hasQueuedMessages, + openaiTruncationModeOverride, + muxMetadata, + minThinkingLevel: providedMinThinkingLevel, + activeTurnThinkingOverride, + } = opts; + const experiments: StreamMessageOptions["experiments"] = resolveBackendGatedPtcExperiments( + experimentsFromOptions, + (experimentId) => + this.dependencies.experimentsService?.isExperimentEnabled(experimentId) === true + ); + const combinedAbortSignal = context.abortSignal; + const syntheticMessageId = context.syntheticMessageId; + const startTime = context.startTime; + const startupPhaseTimingsMs = context.startupPhaseTimingsMs; + const recordStartupPhaseTiming = context.recordStartupPhaseTiming; + let pendingRunMetadataId: string | null = context.startupState.pendingRunMetadataId; + + const deleteAbortedPlaceholder = async (messageId: string): Promise => { + const deleteResult = await this.dependencies.historyService.deleteMessage( + workspaceId, + messageId + ); + if (!deleteResult.success) { + log.error( + "Failed to delete aborted assistant placeholder (" + + messageId + + "): " + + deleteResult.error + ); + } + }; + // Mode (plan|exec|compact) is derived from the selected agent definition. + const effectiveMuxProviderOptions: MuxProviderOptions = muxProviderOptions ?? {}; + const userOpenAIWireFormat = effectiveMuxProviderOptions.openai?.wireFormat; + + const resolveToolsIdentity = ( + raw: string, + effective: string, + canonical: string, + // The factory's wire snapshot — resolved from the SAME config read + // that created the SDK model. Re-reading the providers config here + // instead would race authoritative catalog refreshes: a mid-request + // type change would assemble another wire's tools/options for the + // already-created model. Shadowed prefixes and unknown instances have + // no snapshot, and their canonical form is the raw string. + coderWire: + | { origin: "anthropic" | "openai"; modelId: string; providerType: string } + | undefined, + // Snapshot the identity is resolved against; the refusal-fallback + // path passes ITS pinned snapshot, not the primary request's. + providersConfigSnapshot: ProvidersConfigMap + ): { modelString: string; openaiWireFormat?: "chatCompletions" | "responses" } => { + // Custom providers own their raw prefix (including shadowed built-in + // ids) and speak the wire their providerType selects: tool assembly + // must key on that wire so Responses-bound MCP schemas are sanitized + // and provider-native tools are offered. Chat-completions custom + // providers keep their generic identity. + const rawSeparator = raw.indexOf(":"); + const rawPrefix = rawSeparator > 0 ? raw.slice(0, rawSeparator) : ""; + const rawCustomEntry = rawPrefix ? providersConfigSnapshot[rawPrefix] : undefined; + if (isCustomProviderConfig(rawCustomEntry)) { + const wireOrigin = customProviderWireOrigin(rawCustomEntry.providerType); + if (wireOrigin === "openai") { + // The factory always creates provider.responses() for this type. + return { + modelString: `openai:${raw.slice(rawSeparator + 1)}`, + openaiWireFormat: "responses", + }; + } + if (wireOrigin === "anthropic") { + return { modelString: `anthropic:${raw.slice(rawSeparator + 1)}` }; + } + return { modelString: raw }; + } + if (!raw.startsWith("coder:")) { + return { modelString: raw }; + } + if (!effective.startsWith("coder:")) { + // Fallback away from Coder. A PASSTHROUGH gateway fallback + // (mux-gateway:anthropic/x) must normalize to the canonical wire + // identity: getToolsForModel only runs Anthropic/OpenAI-specific + // assembly (native web tools, MCP schema sanitization) for direct + // provider prefixes, and passthrough gateways forward origin-shaped + // payloads. Transforming gateways (openrouter) keep their own + // identity, same as a direct selection of that gateway. + const separator = effective.indexOf(":"); + const effectiveProvider = separator > 0 ? effective.slice(0, separator) : ""; + const definition = Object.hasOwn(PROVIDER_DEFINITIONS, effectiveProvider) + ? PROVIDER_DEFINITIONS[effectiveProvider as ProviderName] + : undefined; + const passthroughGateway = + definition?.kind === "gateway" && + "passthrough" in definition && + definition.passthrough === true; + return { + modelString: passthroughGateway ? normalizeToCanonical(effective) : effective, + }; + } + if (!coderWire) { + return { modelString: canonical }; + } + // The factory creates Coder instances from the wire alone (openai + // type → provider.responses, openai-chat types → provider.chat), so + // BOTH OpenAI wire kinds must override any pre-existing wireFormat: + // a refusal chain that starts on direct OpenAI Chat Completions and + // falls back to an openai-typed Coder instance would otherwise build + // Chat Completions tools/options for a Responses request. + const wireProtocol = coderGatewayWireProtocol(coderWire.providerType); + return { + modelString: `${coderWire.origin}:${coderWire.modelId}`, + ...(wireProtocol === "openai-chat" + ? { openaiWireFormat: "chatCompletions" as const } + : wireProtocol === "openai-responses" + ? { openaiWireFormat: "responses" as const } + : {}), + }; + }; + const prepareModelSeed = async (options: { + rawModelString: string; + requestedThinkingLevel: ThinkingLevel | undefined; + minimumThinkingLevelOverride: ThinkingLevel | undefined; + enforceMinimum: boolean; + recordTiming?: boolean; + }) => { + if ( + options.enforceMinimum && + effectiveMuxProviderOptions.openai?.wireFormat !== userOpenAIWireFormat + ) { + effectiveMuxProviderOptions.openai = { + ...(effectiveMuxProviderOptions.openai ?? {}), + wireFormat: userOpenAIWireFormat, + }; + } + + const requestedThinkingLevel = options.requestedThinkingLevel ?? THINKING_LEVEL_OFF; + const preliminaryProvidersConfig = this.dependencies.providerService.getConfig(); + const preliminaryMinThinkingLevel = resolveMinimumThinkingLevel( + options.rawModelString, + options.minimumThinkingLevelOverride, + preliminaryProvidersConfig + ); + const preliminaryThinkingLevel = options.enforceMinimum + ? enforceThinkingPolicy( + options.rawModelString, + requestedThinkingLevel, + preliminaryMinThinkingLevel, + preliminaryProvidersConfig + ) + : resolveEffectiveThinkingLevel( + options.rawModelString, + requestedThinkingLevel, + preliminaryProvidersConfig + ); + + const resolveAndCreateModelStartedAt = Date.now(); + const resolved = await this.dependencies.providerModelFactory.resolveAndCreateModel( + options.rawModelString, + preliminaryThinkingLevel, + effectiveMuxProviderOptions, + { agentInitiated, workspaceId } + ); + if (options.recordTiming) { + recordStartupPhaseTiming("resolveAndCreateModelMs", resolveAndCreateModelStartedAt); + } + if (!resolved.success) { + return resolved; + } + + const providersConfig = pinCoderInstanceProvidersConfig( + this.dependencies.providerService.getConfig(), + options.rawModelString, + resolved.data.coderSelectedInstance + ); + const minThinkingLevel = resolveMinimumThinkingLevel( + options.rawModelString, + options.minimumThinkingLevelOverride, + providersConfig + ); + const effectiveThinkingLevel = options.enforceMinimum + ? enforceThinkingPolicy( + options.rawModelString, + requestedThinkingLevel, + minThinkingLevel, + providersConfig + ) + : resolveEffectiveThinkingLevel( + options.rawModelString, + requestedThinkingLevel, + providersConfig + ); + const toolsIdentity = resolveToolsIdentity( + options.rawModelString, + resolved.data.effectiveModelString, + resolved.data.canonicalModelString, + resolved.data.coderWire, + providersConfig + ); + const optionsModelString = + options.rawModelString.startsWith("coder:") && + !resolved.data.effectiveModelString.startsWith("coder:") + ? toolsIdentity.modelString + : options.rawModelString; + if (toolsIdentity.openaiWireFormat != null) { + effectiveMuxProviderOptions.openai = { + ...(effectiveMuxProviderOptions.openai ?? {}), + wireFormat: toolsIdentity.openaiWireFormat, + }; + } + + return Ok({ + ...resolved.data, + rawModelString: options.rawModelString, + providersConfig, + minThinkingLevel, + effectiveThinkingLevel, + capabilityModelString: resolveModelForMetadata( + options.rawModelString.startsWith("coder:") + ? options.rawModelString + : resolved.data.canonicalModelString, + providersConfig + ), + toolsModelString: toolsIdentity.modelString, + optionsModelString, + }); + }; + + const modelResult = await prepareModelSeed({ + rawModelString: modelString, + requestedThinkingLevel: thinkingLevel, + minimumThinkingLevelOverride: providedMinThinkingLevel, + enforceMinimum: false, + recordTiming: true, + }); + if (!modelResult.success) { + return { type: "finished", result: Err(modelResult.error) }; + } + const { + canonicalModelString, + canonicalProviderName, + wireProviderName, + routedThroughGateway, + routeProvider, + providersConfig: requestProvidersConfig, + effectiveThinkingLevel, + capabilityModelString, + } = modelResult.data; + + log.debug_obj(`${workspaceId}/1_original_messages.json`, messages); + + // Context Boundary request slicing happens before empty-assistant filtering so + // provider-invisible reset rows can still bound the active context window. + // Message preparation keys on the WIRE provider (wireProviderName), not + // the config identity: a gateway-scoped coder:/ request + // sends Anthropic/OpenAI-shaped bytes, so wire-specific transforms must + // still run for it. + const { activeContextMessages, providerRequestMessages, contextBoundarySlicedCount } = + prepareProviderRequestMessages(messages, wireProviderName, effectiveThinkingLevel); + if (contextBoundarySlicedCount > 0) { + log.debug("Prepared provider history window", { + workspaceId, + originalCount: messages.length, + contextBoundarySlicedCount, + activeContextCount: activeContextMessages.length, + }); + } + log.debug_obj(`${workspaceId}/1a_active_context_messages.json`, activeContextMessages); + log.debug( + `Filtered ${activeContextMessages.length - providerRequestMessages.length} empty assistant messages` + ); + log.debug_obj(`${workspaceId}/1b_provider_request_messages.json`, providerRequestMessages); + + // OpenAI-specific: Keep reasoning parts in history so each request can + // carry forward reasoning context without relying on previous_response_id. + if (wireProviderName === "openai") { + log.debug("Keeping reasoning parts for OpenAI (managed via explicit history)"); + } + const messagesWithSentinel = addInterruptedSentinel(providerRequestMessages); + + const getWorkspaceMetadataStartedAt = Date.now(); + const metadataResult = await this.dependencies.getWorkspaceMetadata(workspaceId); + recordStartupPhaseTiming("getWorkspaceMetadataMs", getWorkspaceMetadataStartedAt); + if (!metadataResult.success) { + return { type: "finished", result: Err({ type: "unknown", raw: metadataResult.error }) }; + } + + const metadata = metadataResult.data; + + if (this.dependencies.policyService?.isEnforced()) { + if (!this.dependencies.policyService.isRuntimeAllowed(metadata.runtimeConfig)) { + return { + type: "finished", + result: Err({ + type: "policy_denied", + message: "Workspace runtime is not allowed by policy", + }), + }; + } + } + const workspaceLog = log.withFields({ workspaceId, workspaceName: metadata.name }); + const logSlowStreamStartup = (details: Record): void => { + const totalMs = Date.now() - startTime; + if (totalMs < STREAM_STARTUP_DIAGNOSTIC_THRESHOLD_MS) { + return; + } + + workspaceLog.info("[stream-startup] Slow pre-stream preparation", { + workspaceId, + modelString, + totalMs, + startupPhaseTimingsMs, + ...details, + }); + }; + // Exposed so the caller's catch path can log slow startups that fail after build(). + context.startupState.logSlowStreamStartup = logSlowStreamStartup; + + const emitStartupBreadcrumb = ( + startupStage: + | "waiting_for_init" + | "checking_runtime" + | "loading_workspace_context" + | "loading_tools" + | "preparing_request" + | "starting_stream" + ): void => { + const breadcrumb = + startupStage === "waiting_for_init" + ? { + phase: "waiting" as const, + detail: "Waiting for workspace initialization...", + } + : startupStage === "checking_runtime" + ? { + phase: "starting" as const, + detail: "Checking workspace runtime...", + } + : startupStage === "loading_workspace_context" + ? { + phase: "starting" as const, + detail: "Loading workspace context...", + } + : startupStage === "loading_tools" + ? { + phase: "starting" as const, + detail: "Loading tools...", + } + : startupStage === "preparing_request" + ? { + phase: "starting" as const, + detail: "Preparing model request...", + } + : { + phase: "starting" as const, + detail: "Starting model stream...", + }; + + workspaceLog.info("[stream-startup] Breadcrumb", { + startupStage, + phase: breadcrumb.phase, + detail: breadcrumb.detail, + elapsedMs: Date.now() - startTime, + }); + this.dependencies.emit("runtime-status", { + type: "runtime-status", + workspaceId, + phase: breadcrumb.phase, + runtimeType: metadata.runtimeConfig.type, + source: "startup", + detail: breadcrumb.detail, + }); + }; + + const runtimeContextResult = this.dependencies.createWorkspaceRuntimeContext( + workspaceId, + metadata + ); + if (!runtimeContextResult.success) { + return { type: "finished", result: Err(runtimeContextResult.error) }; + } + const { runtime, workspacePath, hostCheckoutRoot, projectCheckoutRoot } = + runtimeContextResult.data; + + // Wait for init to complete before any runtime I/O operations + // (SSH/devcontainer may not be ready until init finishes pulling the container) + emitStartupBreadcrumb("waiting_for_init"); + const waitForInitStartedAt = Date.now(); + await this.dependencies.initStateManager.waitForInit(workspaceId, combinedAbortSignal); + recordStartupPhaseTiming("waitForInitMs", waitForInitStartedAt); + if (combinedAbortSignal.aborted) { + return { + type: "finished", + result: Ok(this.dependencies.createAbortedTurnHandle(syntheticMessageId)), + }; + } + + // Verify runtime is actually reachable after init completes. + // For Docker workspaces, this checks the container exists and starts it if stopped. + // For Coder workspaces, this may start a stopped workspace and wait for it. + // If init failed during container creation, ensureReady() will return an error. + emitStartupBreadcrumb("checking_runtime"); + const ensureReadyStartedAt = Date.now(); + const readyResult = await runtime.ensureReady({ + signal: combinedAbortSignal, + statusSink: (status) => { + // Emit runtime-status events for frontend UX (StreamingBarrier) + this.dependencies.emit("runtime-status", { + type: "runtime-status", + workspaceId, + phase: status.phase, + runtimeType: status.runtimeType, + source: "runtime", + detail: status.detail, + }); + }, + }); + recordStartupPhaseTiming("ensureReadyMs", ensureReadyStartedAt); + if (!readyResult.ready) { + // Generate message ID for the error event (frontend needs this for synthetic message) + const errorMessageId = createAssistantMessageId(); + const runtimeType = metadata.runtimeConfig?.type ?? "local"; + const runtimeLabel = runtimeType === "docker" ? "Container" : "Runtime"; + const errorMessage = readyResult.error || `${runtimeLabel} unavailable.`; + + const errorType = readyResult.errorType; + + // Emit error event so frontend receives it via stream subscription. + // This mirrors the context_exceeded pattern - the fire-and-forget sendMessage + // call in useCreationWorkspace.ts won't see the returned Err, but will receive + // this event through the workspace chat subscription. + const errorEvent = createErrorEvent(workspaceId, { + messageId: errorMessageId, + error: errorMessage, + errorType, + acpPromptId, + }); + this.dependencies.emit("error", errorEvent); + onPreStartError?.(errorEvent); + + logSlowStreamStartup({ + outcome: "runtime_not_ready", + runtimeType, + errorType, + errorMessage, + }); + + return { + type: "finished", + result: Err({ + type: errorType, + message: errorMessage, + }), + }; + } + + // Memory context (memory experiment): resolved only after ensureReady so + // project-scope listing sees a running runtime (a stopped Docker/remote + // workspace would yield an empty/partial context, and AgentSession caches + // the result per model/session segment). + const memoryContext = resolveMemoryContext + ? await resolveMemoryContext(modelString, { includeHotMemories: false }) + : undefined; + + const cfg = this.dependencies.config.loadConfigOrDefault(); + const advisorExperimentEnabled = + experiments?.advisorTool ?? + this.dependencies.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.ADVISOR_TOOL) === + true; + const dynamicWorkflowsExperimentEnabled = + experiments?.dynamicWorkflows ?? + this.dependencies.experimentsService?.isExperimentEnabled( + EXPERIMENT_IDS.DYNAMIC_WORKFLOWS + ) === true; + const memoryExperimentEnabled = + experiments?.memory ?? + this.dependencies.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY) === true; + const timelineExperimentEnabled = + this.dependencies.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.TIMELINE) === true; + const workspaceHeartbeatsExperimentEnabled = + experiments?.workspaceHeartbeats ?? + this.dependencies.experimentsService?.isExperimentEnabled( + EXPERIMENT_IDS.WORKSPACE_HEARTBEATS + ) === true; + const toolSearchExperimentEnabled = + experiments?.toolSearch ?? + this.dependencies.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.TOOL_SEARCH) === + true; + const memoryHotSetExperimentEnabled = + this.dependencies.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY_HOT_SET) === + true; + // claude-skills-compat is host-evaluated (like memory-hot-set): sub-agents share the + // host ExperimentsService, so it is not inherited through SendMessageOptions.experiments. + const claudeSkillsCompatExperimentEnabled = this.dependencies.isClaudeSkillsCompatEnabled(); + const agentPluginsExperimentEnabled = this.dependencies.isAgentPluginsEnabled(); + // Once final tool policy keeps the memory tool, upgrade the index-only + // memory context (resolved pre-policy with includeHotMemories: false) to + // the token-budgeted hot block for the model that will actually stream. + // Returns the unchanged pre-policy `memoryContext` reference when hot + // preloading is off or the memory tool was stripped, so callers can use + // identity comparison to decide whether the system prompt must be rebuilt. + const upgradeMemoryContextForModel = async ( + memoryToolAvailableForModel: boolean, + modelStringForContext: string + ): Promise => + memoryToolAvailableForModel && + memoryHotSetExperimentEnabled && + resolveMemoryContext !== undefined + ? await resolveMemoryContext(modelStringForContext, { includeHotMemories: true }) + : memoryContext; + emitStartupBreadcrumb("loading_workspace_context"); + const resolveAgentForStreamStartedAt = Date.now(); + const agentResult = await resolveAgentForStream({ + workspaceId, + metadata, + runtime, + workspacePath, + requestedAgentId: agentId, + strictAgentResolution, + disableWorkspaceAgents: disableWorkspaceAgents ?? false, + callerToolPolicy: toolPolicy, + cfg, + emitError: (event) => { + this.dependencies.emit("error", event); + onPreStartError?.(event); + }, + isAdvisorExperimentEnabled: advisorExperimentEnabled, + includeAgentPlugins: agentPluginsExperimentEnabled, + }); + recordStartupPhaseTiming("resolveAgentForStreamMs", resolveAgentForStreamStartedAt); + if (!agentResult.success) { + return { type: "finished", result: Err(agentResult.error) }; + } + const { + effectiveAgentId, + agentDefinition, + agentDiscoveryRuntime, + agentDiscoveryPath, + isSubagentWorkspace, + agentInheritanceChain, + agentIsPlanLike, + effectiveMode, + taskSettings, + taskDepth, + shouldDisableTaskToolsForDepth, + effectiveToolPolicy, + } = agentResult.data; + const legacyModeForMetadata = getLegacyModeForAgentMetadata(effectiveAgentId, effectiveMode); + const projectTrusted = isWorkspaceProjectTrusted(this.dependencies.config, metadata); + // projectAutomationDisabled: benchmark harnesses opt out of automatic + // repo hook execution (tool_env/tool_pre/tool_post) while keeping + // config trust for sub-agent delegation. + const sharedExecutionTrusted = + isWorkspaceTrustedForSharedExecution(metadata, cfg.projects) && !projectAutomationDisabled(); + const agentAdvisorEnabled = resolveAdvisorEnabledForAgent( + effectiveAgentId, + cfg.agentAiDefaults?.[effectiveAgentId]?.advisorEnabled + ); + const advisorModelString = cfg.advisorModelString?.trim() ?? ""; + const advisorToolEligible = + advisorExperimentEnabled && agentAdvisorEnabled && advisorModelString.length > 0; + + // Goals graduated to GA: tools are gated solely on the workspace's + // current goal status + agent capability, not on an experiment flag. + let currentGoalForTools: GoalRecordV1 | null = null; + if (workspaceGoalService) { + currentGoalForTools = await workspaceGoalService.getGoal(workspaceId); + } + const effectiveGoalDefaults = mergeGoalDefaults( + normalizeGoalDefaults(cfg.goalDefaults ?? DEFAULT_GOAL_DEFAULTS), + metadata.goalDefaults ?? null + ); + const goalToolAvailability = getGoalToolAvailability({ + goalStatus: currentGoalForTools?.status ?? null, + parentWorkspaceId: metadata.parentWorkspaceId, + allowAgentSetGoal, + agentInheritanceChain, + }); + + // Fetch workspace MCP overrides (for filtering servers and tools) + // NOTE: Stored in /.xum/mcp.local.jsonc (not ~/.xum/config.json). + let mcpOverrides: WorkspaceMCPOverrides | undefined; + const loadWorkspaceMcpOverridesStartedAt = Date.now(); + try { + mcpOverrides = ( + await this.dependencies.workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId) + ).overrides; + } catch (error) { + log.warn("[MCP] Failed to load workspace MCP overrides; continuing without overrides", { + workspaceId, + error, + }); + mcpOverrides = undefined; + } + recordStartupPhaseTiming("loadWorkspaceMcpOverridesMs", loadWorkspaceMcpOverridesStartedAt); + + // Agent Plugins: discovery follows the active checkout and is disabled + // for workspaces that exec off-host (SSH/Docker/devcontainer). + const agentPluginsMcpContext = hostCheckoutRoot + ? resolveAgentPluginsMcpContext(metadata, hostCheckoutRoot) + : null; + + // Tier-1 plugin hooks (agent-plugins experiment): reconcile discovered + // hooks.js modules with the event spine BEFORE request assembly so both + // request.assemble and tool.execute middleware are in place for this + // turn. Failure posture: a broken plugin never blocks a send. + try { + await agentPluginHookService.ensureWorkspaceHooks({ + workspaceId, + sessionDir: this.dependencies.config.getSessionDir(workspaceId), + journal: this.dependencies.durableEventJournalFor(workspaceId), + enabled: this.dependencies.isAgentPluginsEnabled(), + xumHome: this.dependencies.config.rootDir, + // Project containers follow the same off-host gating as plugin MCP. + projectRoot: agentPluginsMcpContext?.projectRoot, + projectTrusted, + }); + } catch (error) { + log.warn("Agent plugin hooks: ensure failed; continuing without plugin hooks", { error }); + } + + const listMcpServersStartedAt = Date.now(); + const mcpServers = this.dependencies.bindings.mcpServerManager + ? await this.dependencies.bindings.mcpServerManager.listServers( + metadata.projectPath, + mcpOverrides, + projectTrusted, + agentPluginsMcpContext + ) + : undefined; + recordStartupPhaseTiming("listMcpServersMs", listMcpServersStartedAt); + + const loadAdditionalSystemContextStartedAt = Date.now(); + let workspaceAdditionalSystemContext = additionalSystemContext; + if (workspaceAdditionalSystemContext == null) { + try { + // Fall back to disk only when the renderer did not send a live snapshot. + // `effectiveAdditionalSystemContext` honors the `enabled` toggle: when + // the user has disabled the scratchpad, the persisted content is + // intentionally not injected. + const record = await readAdditionalSystemContext(this.dependencies.config, workspaceId); + workspaceAdditionalSystemContext = effectiveAdditionalSystemContext(record); + } catch (error) { + // The scratchpad is user-editable state, so a transient read failure should not block a send. + log.warn("Failed to load workspace additional system context; continuing without it", { + workspaceId, + error, + }); + workspaceAdditionalSystemContext = ""; + } + } + const scratchpadAdditionalSystemInstructions = mergeAdditionalSystemInstructions( + workspaceAdditionalSystemContext, + additionalSystemInstructions + ); + recordStartupPhaseTiming("loadAdditionalSystemContextMs", loadAdditionalSystemContextStartedAt); + + // Build plan-aware instructions and determine plan→exec transition content. + // IMPORTANT: Derive this from the same boundary-sliced message payload that is sent to + // the model so plan hints/handoffs cannot be suppressed by pre-boundary history. + const buildPlanInstructionsStartedAt = Date.now(); + const { effectiveAdditionalInstructions, planFilePath, planContentForTransition } = + await buildPlanInstructions({ + runtime, + metadata, + workspaceId, + workspacePath, + effectiveMode, + effectiveAgentId, + agentIsPlanLike, + agentDiscoveryRuntime, + agentDiscoveryPath, + additionalSystemInstructions: scratchpadAdditionalSystemInstructions, + shouldDisableTaskToolsForDepth, + taskDepth, + taskSettings, + requestPayloadMessages: providerRequestMessages, + }); + recordStartupPhaseTiming("buildPlanInstructionsMs", buildPlanInstructionsStartedAt); + + const xumScope = resolveXumToolScope( + this.dependencies.config, + metadata, + workspacePath, + projectCheckoutRoot + ); + + const workflowSkillStorageContext = resolveSkillStorageContext({ + runtime, + workspacePath, + xumScope, + includeAgentPlugins: this.dependencies.isAgentPluginsEnabled(), + }); + + const desktopSessionManager = this.dependencies.bindings.desktopSessionManager; + let desktopCapabilityPromise: ReturnType | undefined; + const loadDesktopCapability = + desktopSessionManager == null + ? undefined + : () => { + // Reuse the same capability probe for every desktop-gated agent discovered during + // this request so discovery cannot trigger one desktop startup attempt per agent. + desktopCapabilityPromise ??= desktopSessionManager.getCapability(workspaceId); + return desktopCapabilityPromise; + }; + + // modelStringForSystem lets the refusal-fallback prepare() rebuild the + // system prompt for the fallback model (model-keyed instruction sections). + // Memory index eligibility mirrors memory tool registration (experiment + + // service); tool policy may still strip the tool, which forces a rebuild + // below so the prompt never advertises an absent tool. + const memoryToolEligible = + memoryExperimentEnabled && this.dependencies.bindings.memoryService !== undefined; + const buildStreamSystemContextForToolset = ( + toolset: { advisorToolAvailable: boolean; memoryToolAvailable: boolean }, + modelStringForSystem: string = modelString, + contextForModel: MemorySessionContext | undefined = memoryContext + ) => + buildStreamSystemContext({ + runtime, + metadata, + workspacePath, + workspaceId, + agentDefinition, + effectiveMode, + agentDiscoveryRuntime, + agentDiscoveryPath, + isSubagentWorkspace, + effectiveAdditionalInstructions, + planFilePath, + modelString: modelStringForSystem, + cfg, + providersConfig: this.dependencies.providerService.getConfig(), + mcpServers, + xumScope, + loadDesktopCapability, + advisorToolAvailable: toolset.advisorToolAvailable, + memoryToolAvailable: toolset.memoryToolAvailable, + hotMemoriesBlock: contextForModel?.hotMemoriesBlock ?? undefined, + claudeSkillsCompatEnabled: claudeSkillsCompatExperimentEnabled, + agentPluginsEnabled: agentPluginsExperimentEnabled, + }); + + // Build provisional agent context before tool policy finalizes the toolset. + // The final system prompt is rebuilt after policy application so advisor guidance cannot + // survive when the resolved toolset strips the advisor tool. + const buildStreamSystemContextStartedAt = Date.now(); + const prePolicyStreamSystemContext = await buildStreamSystemContextForToolset({ + advisorToolAvailable: advisorToolEligible, + memoryToolAvailable: memoryToolEligible, + }); + recordStartupPhaseTiming("buildStreamSystemContextMs", buildStreamSystemContextStartedAt); + const { agentSystemPromptSections, agentDefinitions, availableSkills, ancestorPlanFilePaths } = + prePolicyStreamSystemContext; + let systemMessageTokens = prePolicyStreamSystemContext.systemMessageTokens; + let systemMessage = prePolicyStreamSystemContext.systemMessage; + + const projectSecrets = isMultiProject(metadata) + ? mergeMultiProjectSecrets(metadata, this.dependencies.config) + : this.dependencies.config.getEffectiveSecrets(metadata.projectPath); + + const streamToken = this.dependencies.streamManager.generateStreamToken(); + + let mcpTools: Record | undefined; + let mcpToolServerNames: Record | undefined; + let mcpStats: MCPWorkspaceStats | undefined; + let mcpPromptRuntime: MCPPromptRuntime | undefined; + let mcpSetupDurationMs = 0; + + if (this.dependencies.bindings.mcpServerManager) { + const mcpServerManager = this.dependencies.bindings.mcpServerManager; + const mcpToolSetupStartedAt = Date.now(); + try { + const result = await mcpServerManager.getToolsForWorkspace({ + workspaceId, + projectPath: metadata.projectPath, + runtime, + workspacePath, + trusted: projectTrusted, + overrides: mcpOverrides, + projectSecrets: await secretsToRecord(projectSecrets), + agentPlugins: agentPluginsMcpContext, + }); + + mcpTools = result.tools; + mcpToolServerNames = result.toolServerNames; + mcpStats = result.stats; + // Omit the tool when no prompts exist to avoid adding unused schema context. + if (result.promptDescriptors.length > 0) { + mcpPromptRuntime = { + prompts: result.promptDescriptors, + getPrompt: (serverName, promptName, args, options) => + mcpServerManager.getPrompt(workspaceId, serverName, promptName, args, options), + }; + } + } catch (error) { + workspaceLog.error("Failed to start MCP servers", { error }); + } finally { + mcpSetupDurationMs = Date.now() - mcpToolSetupStartedAt; + startupPhaseTimingsMs.mcpToolSetupMs = mcpSetupDurationMs; + } + } + + // Tool search (tool-search experiment): assembly-time gate. The runtime + // holder makes getToolsForModel create the tool_catalog_search tool; its `state` + // is assigned only after policy filtering builds the deferred catalog + // (see prepareToolSearch below). Without MCP tools there is nothing to + // defer, so the feature stays fully inactive. + const toolSearchRuntime: ToolSearchRuntime | undefined = + toolSearchExperimentEnabled && Object.keys(mcpTools ?? {}).length > 0 ? {} : undefined; + + const createTempDirForStreamStartedAt = Date.now(); + const runtimeTempDir = await this.dependencies.streamManager.createTempDirForStream( + streamToken, + runtime + ); + recordStartupPhaseTiming("createTempDirForStreamMs", createTempDirForStreamStartedAt); + + const readToolInstructionsStartedAt = Date.now(); + const toolInstructions = await readToolInstructions( + metadata, + runtime, + workspacePath, + capabilityModelString, + agentSystemPromptSections, + cfg.projects, + claudeSkillsCompatExperimentEnabled + ); + recordStartupPhaseTiming("readToolInstructionsMs", readToolInstructionsStartedAt); + + // Calculate cumulative session costs for MUX_COSTS_USD env var + let sessionCostsUsd: number | undefined; + const loadSessionUsageStartedAt = Date.now(); + if (this.dependencies.sessionUsageService) { + const sessionUsage = await this.dependencies.sessionUsageService.getSessionUsage(workspaceId); + if (sessionUsage) { + const allUsage = sumUsageHistory(Object.values(sessionUsage.byModel)); + sessionCostsUsd = getTotalCost(allUsage); + } + } + recordStartupPhaseTiming("loadSessionUsageMs", loadSessionUsageStartedAt); + + emitStartupBreadcrumb("loading_tools"); + assert(workspaceId.trim().length > 0, "streamMessage requires a non-empty workspaceId"); + if (advisorExperimentEnabled && agentAdvisorEnabled && advisorModelString.length === 0) { + workspaceLog.warn("Advisor tool enabled for agent without advisorModelString; suppressing", { + effectiveAgentId, + }); + } + if (advisorToolEligible) { + assert( + advisorModelString.length > 0, + "advisorModelString must be non-empty when advisor is eligible" + ); + } + // Mutable ref updated by StreamManager.prepareStep so the advisor tool reads the live + // transcript lazily at execute time instead of capturing a stale snapshot here. + const advisorTranscriptRef: { messages?: ModelMessage[] } = {}; + const advisorStepCaptureRef: AdvisorStepCaptureRef = { + currentStepText: "", + currentStepReasoning: "", + frozenSnapshotsByToolCallId: new Map(), + }; + const onAdvisorChunk: StreamTextOnChunk = ({ chunk }) => { + switch (chunk.type) { + case "text-delta": { + // Providers/SDKs can stream advisor text deltas under different field names. + const chunkText = extractChunkDeltaText(chunk as Record, [ + "textDelta", + "delta", + "text", + ]); + if (chunkText.length > 0) { + advisorStepCaptureRef.currentStepText += chunkText; + } + return; + } + case "reasoning-delta": { + // Anthropic signature updates can arrive as reasoning deltas without text. + const chunkText = extractChunkDeltaText(chunk as Record, [ + "text", + "textDelta", + "delta", + ]); + if (chunkText.length > 0) { + advisorStepCaptureRef.currentStepReasoning += chunkText; + } + return; + } + case "tool-call": { + if (chunk.toolName !== "advisor") { + return; + } + const toolCallId = chunk.toolCallId?.trim?.() ?? ""; + // Skip malformed tool calls defensively — the normal tool-error + // path will handle bad input; crashing the stream callback would + // be worse than missing the snapshot. + if ( + toolCallId.length === 0 || + !isPlainObject(chunk.input) || + advisorStepCaptureRef.frozenSnapshotsByToolCallId.has(toolCallId) + ) { + return; + } + advisorStepCaptureRef.frozenSnapshotsByToolCallId.set(toolCallId, { + toolCallId, + toolName: "advisor", + input: { ...chunk.input }, + stepText: advisorStepCaptureRef.currentStepText, + stepReasoning: advisorStepCaptureRef.currentStepReasoning, + }); + return; + } + default: + return; + } + }; + // Tool-side generateText() results do not consistently echo mux.costsIncluded in + // providerMetadata, so remember the resolved billing mode from model creation and + // re-stamp it before converting usage into display/session costs. + const toolModelCostsIncludedByModelString = new Map(); + // Creation-time pricing identity for tool-created models (advisor): a + // Coder catalog refresh can remove/retag the instance while the tool + // request runs, and resolving the identity from live config at + // completion would price/persist the usage under a different provider. + const toolModelMetadataModelByModelString = new Map(); + // Normalize: undefined -> default, null -> unlimited, positive int -> exact cap. + const advisorMaxUses = + cfg.advisorMaxUsesPerTurn === null + ? null + : (cfg.advisorMaxUsesPerTurn ?? ADVISOR_DEFAULT_MAX_USES_PER_TURN); + assert( + cfg.advisorMaxOutputTokens == null || + (Number.isInteger(cfg.advisorMaxOutputTokens) && cfg.advisorMaxOutputTokens > 0), + "advisorMaxOutputTokens must be null, undefined, or a positive integer" + ); + const advisorMaxOutputTokens = + cfg.advisorMaxOutputTokens != null && cfg.advisorMaxOutputTokens > 0 + ? cfg.advisorMaxOutputTokens + : undefined; + // Clamp the persisted advisor thinking level so the tool metadata matches the + // providerOptions actually sent to generateText(). + const advisorReasoningLevel = enforceThinkingPolicy( + advisorModelString, + cfg.advisorThinkingLevel ?? THINKING_LEVEL_OFF, + undefined, + this.dependencies.providerService.getConfig() + ); + const runtimeType = getRuntimeType(metadata.runtimeConfig); + const xumEnv = getXumEnv(metadata.projectPath, runtimeType, metadata.name, { + workspaceId, + modelString, + thinkingLevel: thinkingLevel ?? "off", + costsUsd: sessionCostsUsd, + }); + const getWorkflowProjectTrusted = () => + isWorkspaceProjectTrusted(this.dependencies.config, metadata); + + const workflowService = + dynamicWorkflowsExperimentEnabled && this.dependencies.bindings.taskService != null + ? new WorkflowService({ + runStore: new WorkflowRunStore({ + sessionDir: this.dependencies.config.getSessionDir(workspaceId), + }), + onRunStatusChanged: async (event) => { + if (!isTerminalWorkflowRunStatus(event.status)) { + await this.dependencies.bindings.taskService?.resetWorkflowRunTerminalAttention({ + ownerWorkspaceId: event.workspaceId, + runId: event.runId, + }); + } + await this.dependencies.bindings.onWorkflowRunStatusChanged?.(event); + }, + runtimeFactory: new QuickJSRuntimeFactory(), + taskAdapterFactory: (runId, workflowName) => + new WorkflowTaskServiceAdapter({ + taskService: this.dependencies.bindings.taskService!, + parentWorkspaceId: workspaceId, + workflowRunId: runId, + workflowName, + defaultAgentId: DEFAULT_WORKFLOW_AGENT_ID, + patchToolConfig: { + workspaceId, + cwd: workspacePath, + runtime, + runtimeTempDir, + workspaceSessionDir: this.dependencies.config.getSessionDir(workspaceId), + trusted: getWorkflowProjectTrusted(), + }, + getProjectTrusted: getWorkflowProjectTrusted, + experiments: { + ...experiments, + dynamicWorkflows: dynamicWorkflowsExperimentEnabled, + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, + }, + }), + resolveWorkflowScript: (scriptPath) => + resolveWorkflowScript({ + scriptPath, + runtime, + workspacePath, + projectSearchRoot: projectCheckoutRoot ?? workspacePath, + projectTrusted: getWorkflowProjectTrusted(), + includeAgentPlugins: this.dependencies.isAgentPluginsEnabled(), + skillStorageContext: workflowSkillStorageContext, + }), + // Background workflow tools outlive the model turn that started them. Feed the + // terminal result back as a hidden user turn so the parent agent continues + // instead of leaving the user staring at the workflow report payload. + onBackgroundRunTerminal: async ({ runId, status, result, run }) => { + if (run.parentWorkflow != null) { + return; + } + if (this.dependencies.bindings.taskService != null) { + await this.dependencies.bindings.taskService.enqueueWorkflowRunTerminalAttention({ + ownerWorkspaceId: workspaceId, + runId, + status, + }); + return; + } + + const continuationSender = + this.dependencies.bindings.workflowResultContinuationSender; + if (continuationSender == null) { + log.warn("Workflow completed but no continuation sender is configured", { + workspaceId, + runId, + }); + return; + } + + const scriptPath = run.workflow.sourcePath ?? run.workflow.name; + const rawCommand = `workflow_run ${scriptPath}`; + const workflowResultMessage = buildWorkflowResultContextMessage({ + rawCommand, + name: scriptPath, + runId, + status, + result, + run, + }); + for (;;) { + const invocationCurrent = await continuationSender.isWorkflowInvocationCurrent( + workspaceId, + runId + ); + if (!invocationCurrent) { + if (this.dependencies.isStreaming(workspaceId)) { + await waitForWorkflowContinuationRetry(); + continue; + } + log.debug("Skipping superseded workflow continuation", { workspaceId, runId }); + return; + } + + const sendResult = await continuationSender.sendMessage( + workspaceId, + workflowResultMessage, + { + model: modelString, + thinkingLevel: effectiveThinkingLevel, + // Carry the turn's pro mode so the workflow-result + // continuation does not silently drop back to standard. + reasoningMode, + agentId: effectiveAgentId, + toolPolicy: effectiveToolPolicy, + additionalSystemInstructions: scratchpadAdditionalSystemInstructions, + maxOutputTokens, + providerOptions: effectiveMuxProviderOptions, + experiments: { + ...experiments, + dynamicWorkflows: dynamicWorkflowsExperimentEnabled, + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, + }, + skipAiSettingsPersistence: true, + muxMetadata: { + type: WORKFLOW_RESULT_METADATA_TYPE, + rawCommand, + commandPrefix: "workflow_run", + runId, + requestedModel: modelString, + }, + }, + { + skipAutoResumeReset: true, + synthetic: true, + agentInitiated: true, + requireIdle: true, + startStreamInBackground: true, + } + ); + if (sendResult.success) { + return; + } + if (!isWorkspaceBusyIdleOnlySend(sendResult.error)) { + log.warn("Failed to continue agent after workflow completion", { + workspaceId, + runId, + error: sendResult.error, + }); + return; + } + await waitForWorkflowContinuationRetry(); + } + }, + getCurrentProjectTrusted: () => + isWorkspaceProjectTrusted(this.dependencies.config, metadata), + runnerId: `workflow-runner:${workspaceId}`, + }) + : undefined; + + // Create assistant message ID early so tool-side usage reporting and nested tool events + // stay scoped to this specific assistant turn. The placeholder is appended to history below + // (after the abort check). + const assistantMessageId = createAssistantMessageId(); + const allowLegacyInvalidWorkflowAgentOutputSchema = + await this.dependencies.shouldAllowLegacyInvalidWorkflowAgentOutputSchema(metadata); + // Hoisted so the refusal-fallback prepare() can rebuild the toolset for a + // different model with identical context (only the model string varies). + const toolsForModelConfig: ToolConfiguration = { + cwd: workspacePath, + runtime, + projects: getProjects(metadata), + secrets: await secretsToRecord(projectSecrets), + xumEnv, + runtimeTempDir, + ...(advisorToolEligible + ? { + advisorRuntime: { + advisorModelString, + reasoningLevel: advisorReasoningLevel, + maxUsesPerTurn: advisorMaxUses, + maxOutputTokens: advisorMaxOutputTokens, + getTranscriptSnapshot: () => { + const messages = advisorTranscriptRef.messages; + assert( + messages != null, + "advisor transcript ref must be populated before advisor execution" + ); + return messages; + }, + takeToolCallSnapshot: (toolCallId) => { + const normalizedToolCallId = toolCallId.trim(); + assert(normalizedToolCallId.length > 0, "advisor toolCallId must be non-empty"); + const snapshot = + advisorStepCaptureRef.frozenSnapshotsByToolCallId.get(normalizedToolCallId); + if (snapshot == null) { + return undefined; + } + const didDelete = + advisorStepCaptureRef.frozenSnapshotsByToolCallId.delete(normalizedToolCallId); + assert(didDelete, "advisor tool-call snapshot must be deleted when consumed"); + assert(snapshot.toolName === "advisor", "advisor snapshot must belong to advisor"); + return snapshot; + }, + createModel: async (ms: string) => { + const advisorModelString = ms.trim(); + assert( + advisorModelString.length > 0, + "advisor model string must be non-empty when creating an advisor model" + ); + // ONE config snapshot for both SDK model creation and the + // pinned pricing identity: two independent reads would let + // a catalog refresh land between them, running the request + // on one wire while recording usage under another type. + const advisorProvidersConfig = this.dependencies.config.loadProvidersConfig() ?? {}; + // View snapshot captured at creation time for option + // building (buildProviderOptions takes the oRPC view, not + // the raw config shape). + const advisorOptionsProvidersConfig = this.dependencies.providerService.getConfig(); + const advisorModel = await this.dependencies.createModel( + advisorModelString, + undefined, + { + workspaceId, + providersConfig: advisorProvidersConfig, + } + ); + if (!advisorModel.success) { + throw new Error( + `Failed to create advisor model: ${getErrorMessage(advisorModel.error)}` + ); + } + toolModelCostsIncludedByModelString.set( + advisorModelString, + modelCostsIncluded(advisorModel.data) + ); + // Same effective-route rule as createModelWithPinnedMetadata: + // a coder: selection whose gateway is unavailable falls away + // to a direct provider inside createModel, and identity or + // options derived from the raw selection (instance type) + // would diverge from the model actually created. + const advisorEffectiveModelString = + this.dependencies.providerModelFactory.resolveEffectiveModelString( + advisorModelString, + undefined, + advisorProvidersConfig + ); + const advisorOnCoderRoute = advisorEffectiveModelString.startsWith("coder:"); + // Creation-time identity from the SAME snapshot the model + // was created from (see map declaration). + toolModelMetadataModelByModelString.set( + advisorModelString, + resolveModelForMetadata( + advisorOnCoderRoute + ? advisorModelString + : normalizeToCanonical(advisorEffectiveModelString), + advisorProvidersConfig + ) + ); + // Wire-resolved identity for option construction, same + // snapshot: a raw coder: string carries no wire info, so + // buildProviderOptions would emit the wrong (or no) + // namespace for custom-named/cross-typed instances. Mirrors + // resolveOptionsCanonicalModel's shadow + wire rules. + const advisorOptionsModelString = (() => { + // Custom providers keep their RAW identity: with the + // pinned snapshot below, buildProviderOptions remaps the + // wire namespace itself while still resolving + // mappedToModel alias metadata from the custom entry. + if (!advisorModelString.startsWith("coder:")) { + return advisorModelString; + } + const coderSection = advisorProvidersConfig.coder; + if (isCustomProviderConfig(coderSection)) { + return advisorModelString; + } + if (!advisorOnCoderRoute) { + // Fallback-away: options must target the route that + // actually serves the request, not the instance's wire. + return normalizeToCanonical(advisorEffectiveModelString); + } + const wire = resolveCoderWireCanonicalModel( + advisorModelString.slice("coder:".length), + coderSection as + | { discoveredProviders?: unknown; additionalProviders?: unknown } + | undefined + ); + return wire ? `${wire.origin}:${wire.modelId}` : advisorModelString; + })(); + return { + model: advisorModel.data, + optionsModelString: advisorOptionsModelString, + optionsProvidersConfig: advisorOptionsProvidersConfig, + }; + }, + abortSignal: combinedAbortSignal, + }, + } + : {}), + ...(toolSearchRuntime ? { toolSearchRuntime } : {}), + capabilityModelString, + openaiWireFormat: effectiveMuxProviderOptions?.openai?.wireFormat, + xaiNativeToolsEnabled: routeProvider === "xai", + xaiSearchParameters: effectiveMuxProviderOptions.xai?.searchParameters, + backgroundProcessManager: this.dependencies.backgroundProcessManager, + // Plan agent configuration for plan file access. + // - read: plan file is readable in all agents (useful context) + // - write: allowed in all agents; plan agents still lock other edits to the exact plan path + planFileOnly: agentIsPlanLike, + emitChatEvent: (event) => { + // Defensive: tools should only emit events for the workspace they belong to. + if ("workspaceId" in event && event.workspaceId !== workspaceId) { + return; + } + if (event.type === "workflow-run-attached") { + return this.dependencies.streamManager.attachWorkflowRunToToolCall(event).then(() => { + this.dependencies.emit(event.type, event as never); + }); + } + this.dependencies.emit(event.type, event as never); + }, + workspaceProjectPath: metadata.projectPath, + workspaceExecutionRootPath: metadata.subProjectPath ?? metadata.projectPath, + workspaceSessionDir: this.dependencies.config.getSessionDir(workspaceId), + planFilePath, + ancestorPlanFilePaths, + workspaceId, + xumScope, + timelineService: timelineExperimentEnabled + ? this.dependencies.bindings.timelineService + : undefined, + workspaceHeartbeatService: this.dependencies.bindings.workspaceHeartbeatService, + workflowService, + goalService: workspaceGoalService, + goalDefaults: effectiveGoalDefaults, + enableGoalTools: goalToolAvailability, + // Only child workspaces (tasks) can report to a parent. + enableAgentReport: Boolean(metadata.parentWorkspaceId), + // RLM family messaging: gate on the flags persisted on the task record at + // spawn — NOT the live send-options experiments — so a child spawned under RLM + // keeps task_message_parent/task_message_sibling across app restarts and + // frontend experiment toggles. Uses the full RLM predicate (rlm AND a PTC + // parent) rather than the bare rlm bit: the hidden sub-flag can stay true + // after its parent is disabled, and such children run outside RLM. Workflow- + // owned workers are excluded: they hand results to WorkflowRunner through the + // journal path. + enableFamilyMessaging: + Boolean(metadata.parentWorkspaceId) && + metadata.workflowTask == null && + isRlmModeEnabled( + findWorkspaceEntry(cfg, workspaceId)?.workspace.taskExperiments, + undefined + ), + workflowAgentOutputSchema: metadata.workflowTask?.outputSchema, + allowLegacyInvalidWorkflowAgentOutputSchema, + recordFileState, + reportModelUsage: (event) => { + try { + const eventModel = event.model.trim(); + assert(eventModel.length > 0, "tool model usage event model must be non-empty"); + // Persist tool-side model usage under its own model bucket so session costs keep + // advisor/system-side pricing separate from the parent chat model. + const providerMetadata = markProviderMetadataCostsIncluded( + event.providerMetadata, + toolModelCostsIncludedByModelString.get(eventModel) + ); + // Prefer the creation-time identity captured when the tool model + // was created; models not created through the tool runtime fall + // back to live resolution (their identity is not coder-scoped). + const pinnedMetadataModel = toolModelMetadataModelByModelString.get(eventModel); + const metadataModel = + pinnedMetadataModel ?? + resolveModelForMetadata(eventModel, this.dependencies.providerService.getConfig()); + this.dependencies.streamManager.recordToolModelUsage(workspaceId, assistantMessageId, { + toolName: event.toolName, + toolCallId: event.toolCallId, + timestamp: event.timestamp, + model: eventModel, + metadataModel, + usage: event.usage, + ...(providerMetadata != null ? { providerMetadata } : {}), + }); + void (async () => { + try { + if (!this.dependencies.sessionUsageService) { + return; + } + const displayUsage = createDisplayUsage( + event.usage, + eventModel, + providerMetadata, + metadataModel + ); + if (!displayUsage) { + return; + } + // Ledger keys resolve Coder identities to their record-time + // metadata identity — the CREATION-TIME pin when available, + // mirroring StreamManager.recordSessionUsage. Non-coder + // models keep the canonical key (their metadata identity can + // be a mappedToModel pricing alias, not the ledger bucket). + const canonicalModel = + eventModel.startsWith("coder:") && pinnedMetadataModel + ? pinnedMetadataModel + : normalizeUsageModelKey( + eventModel, + this.dependencies.providerService.getConfig() + ); + await this.dependencies.sessionUsageService.recordUsage( + workspaceId, + canonicalModel, + displayUsage + ); + this.dependencies.emit("session-usage-delta", { + type: "session-usage-delta" as const, + workspaceId, + sourceWorkspaceId: workspaceId, + byModelDelta: { [canonicalModel]: displayUsage }, + timestamp: Date.now(), + }); + } catch (error) { + log.warn("Failed to record tool model usage", { + error, + workspaceId, + toolName: event.toolName, + model: event.model, + }); + } + })(); + } catch (error) { + log.warn("Failed to record tool model usage", { + error, + workspaceId, + toolName: event.toolName, + model: event.model, + }); + } + }, + onConfigChanged: () => this.dependencies.providerService.notifyConfigChanged(), + taskService: this.dependencies.bindings.taskService, + analyticsService: this.dependencies.bindings.analyticsService, + desktopSessionManager: this.dependencies.bindings.desktopSessionManager, + // Agent memory (memory experiment): per-scope write policy derived from + // the agent class (exec-like / plan-like / read-only). Project memory is + // host-local under xumHome, keyed by the stable project identity. + memoryService: this.dependencies.bindings.memoryService, + memoryAccess: resolveMemoryAccessPolicy({ + planLike: agentIsPlanLike, + editingCapable: isExecLikeEditingCapableInResolvedChain(agentInheritanceChain), + }), + // Experiments for inheritance to subagents and workflow tool gating. + experiments: { + ...experiments, + dynamicWorkflows: dynamicWorkflowsExperimentEnabled, + memory: memoryExperimentEnabled, + timeline: timelineExperimentEnabled, + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, + toolSearch: toolSearchExperimentEnabled, + claudeSkillsCompat: claudeSkillsCompatExperimentEnabled, + agentPlugins: agentPluginsExperimentEnabled, + }, + // Dynamic context for tool descriptions (moved from system prompt for better model attention) + availableSubagents: agentDefinitions, + availableSkills, + mcpPromptRuntime, + // Session-segment memory index advertised in the memory tool + // description (same disclosure mechanic as skills). + memoryIndexEntries: memoryContext?.indexEntries, + // Trust gating: only run hooks/scripts when the full shared workspace runtime is trusted. + trusted: sharedExecutionTrusted, + }; + const emitNestedPtcToolEvent = (event: PTCEventWithParent) => { + if (event.type === "tool-call-start" || event.type === "tool-call-end") { + this.dependencies.streamManager.emitNestedToolEvent(workspaceId, assistantMessageId, event); + } + }; + const kernelFileLoader = createKernelFileLoader({ + cwd: toolsForModelConfig.cwd, + runtime: toolsForModelConfig.runtime, + hooks: deriveToolHookConfig(toolsForModelConfig) ?? undefined, + }); + const ptcEnabled = experiments?.programmaticToolCalling === true; + let mcpWarningPrefix: string | undefined; + if (mcpStats && mcpStats.failedServerCount > 0) { + const failedNames = mcpStats.failedServerNames.join(", "); + workspaceLog.warn("MCP servers failed to start", { failedNames }); + mcpWarningPrefix = + "[Warning: " + + mcpStats.failedServerCount + + " MCP server(s) failed to start: " + + failedNames + + ". Tools from these servers are unavailable. Check MCP server configuration in Settings.]\n\n"; + } + + type ModelSeed = typeof modelResult.data; + const prepareModelRequest = async (options: { + seed: ModelSeed; + sourceMessages: MuxMessage[]; + providerRequestMessages?: MuxMessage[]; + initializeToolSearch: boolean; + reusePrePolicySystemContext: boolean; + requestHistorySequence: number; + partialContinuationMessage?: MuxMessage; + recordTimings?: boolean; + cleanupModelOnError?: boolean; + }) => { + const { seed } = options; + try { + const attemptProviderRequestMessages = + options.providerRequestMessages ?? + prepareProviderRequestMessages( + options.sourceMessages, + seed.wireProviderName, + seed.effectiveThinkingLevel + ).providerRequestMessages; + + const getToolsStartedAt = Date.now(); + const allTools = await getToolsForModel( + seed.toolsModelString, + { + ...toolsForModelConfig, + capabilityModelString: seed.capabilityModelString, + openaiWireFormat: effectiveMuxProviderOptions.openai?.wireFormat, + xaiNativeToolsEnabled: seed.routeProvider === "xai", + }, + workspaceId, + this.dependencies.initStateManager, + toolInstructions, + mcpTools + ); + if (options.recordTimings) { + recordStartupPhaseTiming("getToolsForModelMs", getToolsStartedAt); + } + + const applyPolicyStartedAt = Date.now(); + let attemptTools = await applyToolPolicyAndExperiments({ + allTools: this.dependencies.wrapToolsForDelegation( + workspaceId, + allTools, + delegatedToolNames + ), + extraTools: this.dependencies.bindings.extraTools, + effectiveToolPolicy, + experiments, + emitNestedToolEvent: emitNestedPtcToolEvent, + sandbox: { + workspaceId, + sessionDir: this.dependencies.config.getSessionDir(workspaceId), + kernelFileLoader, + }, + }); + if (options.recordTimings) { + recordStartupPhaseTiming("applyToolPolicyAndExperimentsMs", applyPolicyStartedAt); + } + + if (toolSearchRuntime) { + if (options.initializeToolSearch) { + const preparedSearch = prepareToolSearch({ + tools: attemptTools, + mcpToolNames: Object.keys(mcpTools ?? {}), + mcpToolServers: mcpToolServerNames, + toolPolicy: effectiveToolPolicy, + ptcEnabled, + }); + attemptTools = preparedSearch.tools; + if (preparedSearch.state) { + toolSearchRuntime.state = preparedSearch.state; + } + } else if (toolSearchRuntime.state) { + attemptTools = rebuildToolSearchState(toolSearchRuntime.state, { + tools: attemptTools, + mcpToolNames: Object.keys(mcpTools ?? {}), + mcpToolServers: mcpToolServerNames, + toolPolicy: effectiveToolPolicy, + ptcEnabled, + }).tools; + } else if (!(mcpTools && TOOL_SEARCH_TOOL_NAME in mcpTools)) { + const { [TOOL_SEARCH_TOOL_NAME]: _removed, ...rest } = attemptTools; + attemptTools = rest; + } + } + + const advisorToolAvailable = attemptTools.advisor !== undefined; + const memoryToolAvailable = attemptTools.memory !== undefined; + const memoryContextForModel = await upgradeMemoryContextForModel( + memoryToolAvailable, + seed.rawModelString + ); + const canReuseSystemContext = + options.reusePrePolicySystemContext && + advisorToolAvailable === advisorToolEligible && + memoryToolAvailable === memoryToolEligible && + memoryContextForModel === memoryContext; + const rebuildSystemStartedAt = Date.now(); + const systemContext = canReuseSystemContext + ? prePolicyStreamSystemContext + : await buildStreamSystemContextForToolset( + { advisorToolAvailable, memoryToolAvailable }, + seed.rawModelString, + memoryContextForModel + ); + if (options.recordTimings && !canReuseSystemContext) { + recordStartupPhaseTiming("rebuildStreamSystemContextMs", rebuildSystemStartedAt); + } + let attemptSystem = systemContext.systemMessage; + let attemptSystemTokens = systemContext.systemMessageTokens; + if (mcpWarningPrefix != null) { + attemptSystem = mcpWarningPrefix + attemptSystem; + const tokenizer = await getTokenizerForModel( + seed.rawModelString, + seed.capabilityModelString + ); + attemptSystemTokens = await tokenizer.countTokens(attemptSystem); + } + + if (eventSpine.hasMiddleware("request.assemble")) { + const assembleCtx: RequestAssembleContext = { + workspaceId, + modelString: seed.rawModelString, + systemMessage: attemptSystem, + tools: attemptTools, + }; + await eventSpine.run("request.assemble", assembleCtx); + attemptTools = assembleCtx.tools; + if (toolSearchRuntime?.state) { + attemptTools = rebuildToolSearchState(toolSearchRuntime.state, { + tools: attemptTools, + mcpToolNames: Object.keys(mcpTools ?? {}), + mcpToolServers: mcpToolServerNames, + toolPolicy: effectiveToolPolicy, + ptcEnabled, + }).tools; + } + if (assembleCtx.systemMessage !== attemptSystem) { + attemptSystem = assembleCtx.systemMessage; + const tokenizer = await getTokenizerForModel( + seed.rawModelString, + seed.capabilityModelString + ); + attemptSystemTokens = await tokenizer.countTokens(attemptSystem); + } + } + + if (options.initializeToolSearch && toolSearchRuntime?.state) { + seedToolSearchActivationsFromMessages(toolSearchRuntime.state, messagesWithSentinel); + } + const toolNamesForSentinel = ( + computeActiveToolNames(toolSearchRuntime?.state) ?? Object.keys(attemptTools) + ).sort(); + const prepareMessagesForProviderStartedAt = Date.now(); + const finalMessages = await prepareMessagesForProvider({ + messagesWithSentinel: addInterruptedSentinel(attemptProviderRequestMessages), + effectiveAgentId, + toolNamesForSentinel, + planContentForTransition, + planFilePath, + postCompactionAttachments, + providerForMessages: seed.wireProviderName, + effectiveThinkingLevel: seed.effectiveThinkingLevel, + modelString: seed.rawModelString, + providersConfig: seed.providersConfig, + anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl, + workspaceId, + }); + if (options.recordTimings) { + recordStartupPhaseTiming( + "prepareMessagesForProviderMs", + prepareMessagesForProviderStartedAt + ); + } + const preparedAttempt = this.prepareModelAttempt({ + rawModelString: seed.rawModelString, + canonicalModelString: seed.canonicalModelString, + canonicalProviderName: seed.canonicalProviderName, + effectiveModelString: seed.effectiveModelString, + optionsModelString: seed.optionsModelString, + wireProviderName: seed.wireProviderName, + routeProvider: seed.routeProvider, + effectiveThinkingLevel: seed.effectiveThinkingLevel, + minThinkingLevel: seed.minThinkingLevel, + providerRequestMessages: attemptProviderRequestMessages, + muxProviderOptions: effectiveMuxProviderOptions, + workspaceId, + truncationMode: openaiTruncationModeOverride, + providersConfigSnapshot: seed.providersConfig, + coderSelectedInstance: seed.coderSelectedInstance, + promptCacheScope: derivePromptCacheScope(metadata), + reasoningMode, + ...(options.recordTimings ? { recordStartupPhaseTiming } : {}), + }); + const forcedFirstStepToolNames = + seed.routeProvider === "xai" + ? getForcedXaiSearchToolNames( + seed.capabilityModelString, + effectiveMuxProviderOptions.xai?.searchParameters + )?.filter((toolName) => toolName in attemptTools) + : undefined; + const firstStepToolNames = new Set( + forcedFirstStepToolNames?.length ? forcedFirstStepToolNames : toolNamesForSentinel + ); + const emitEnvelopeWith = async ( + level: string, + providerOptionsForEnvelope: unknown + ): Promise => { + await emitTurnEnvelope({ + journal: this.dependencies.durableEventJournalFor(workspaceId), + workspaceId, + systemMessage: attemptSystem, + tools: Object.fromEntries( + Object.entries(attemptTools).filter(([name]) => firstStepToolNames.has(name)) + ), + modelString: seed.rawModelString, + thinkingLevel: level, + providerOptions: providerOptionsForEnvelope, + requestHistorySequence: options.requestHistorySequence, + sentinelToolNames: toolNamesForSentinel, + wireProviderName: seed.wireProviderName, + anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, + planContentForTransition, + planFilePath, + postCompactionAttachments, + partialContinuationMessage: options.partialContinuationMessage, + }); + }; + const rebuildMessagesForThinkingLevel = async (level: ThinkingLevel) => { + const rebuiltMessages = prepareProviderRequestMessages( + options.sourceMessages, + seed.wireProviderName, + level + ).providerRequestMessages; + return prepareMessagesForProvider({ + messagesWithSentinel: addInterruptedSentinel(rebuiltMessages), + effectiveAgentId, + toolNamesForSentinel, + planContentForTransition, + planFilePath, + postCompactionAttachments, + providerForMessages: seed.wireProviderName, + effectiveThinkingLevel: level, + modelString: seed.rawModelString, + providersConfig: seed.providersConfig, + anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl, + workspaceId, + }); + }; + const rebuildFirstStepForThinkingLevel: RebuildFirstStepForThinkingLevel = async ( + level, + providerOptionsForEnvelope + ) => { + const rebuiltMessages = await rebuildMessagesForThinkingLevel(level); + await emitEnvelopeWith(level, providerOptionsForEnvelope); + return rebuiltMessages; + }; + + return { + ...seed, + providerRequestMessages: attemptProviderRequestMessages, + messages: finalMessages, + system: attemptSystem, + systemMessageTokens: attemptSystemTokens, + tools: attemptTools, + toolNamesForSentinel, + forcedFirstStepToolNames, + providerOptions: preparedAttempt.providerOptions, + headers: preparedAttempt.requestHeaders, + resolvedOverrides: preparedAttempt.resolvedOverrides, + currentEffectiveLevelRef: preparedAttempt.currentEffectiveLevelRef, + computeRebuiltProviderOptions: preparedAttempt.computeRebuiltProviderOptions, + rebuildProviderOptionsForThinkingLevel: + preparedAttempt.rebuildProviderOptionsForThinkingLevel, + rebuildMessagesForThinkingLevel, + emitEnvelopeWith, + onStreamConstructed: () => + emitEnvelopeWith(seed.effectiveThinkingLevel, preparedAttempt.providerOptions), + rebuildFirstStepForThinkingLevel, + }; + } catch (error) { + if (options.cleanupModelOnError) { + runLanguageModelCleanup(options.seed.model); + } + throw error; + } + }; + + const requestHistorySequence = providerRequestMessages.reduce( + (latest, message) => Math.max(latest, message.metadata?.historySequence ?? -1), + -1 + ); + emitStartupBreadcrumb("preparing_request"); + const primaryRequest = await prepareModelRequest({ + seed: modelResult.data, + sourceMessages: messages, + providerRequestMessages, + initializeToolSearch: true, + reusePrePolicySystemContext: true, + requestHistorySequence, + recordTimings: true, + }); + const tools = primaryRequest.tools; + systemMessage = primaryRequest.system; + systemMessageTokens = primaryRequest.systemMessageTokens; + const finalMessages = primaryRequest.messages; + + captureMcpToolTelemetry({ + telemetryService: this.dependencies.telemetryService, + mcpStats, + mcpTools, + tools, + mcpSetupDurationMs, + workspaceId, + modelString, + effectiveAgentId, + metadata, + effectiveToolPolicy, + }); + + if (combinedAbortSignal.aborted) { + return { + type: "finished", + result: Ok(this.dependencies.createAbortedTurnHandle(assistantMessageId)), + }; + } + + const assistantMessage = createMuxMessage(assistantMessageId, "assistant", "", { + ...(requestHistorySequence >= 0 ? { requestHistorySequence } : {}), + timestamp: Date.now(), + model: canonicalModelString, + routedThroughGateway, + systemMessageTokens, + agentId: effectiveAgentId, + }); + + const appendResult = await this.dependencies.historyService.appendToHistory( + workspaceId, + assistantMessage + ); + if (!appendResult.success) { + return { type: "finished", result: Err({ type: "unknown", raw: appendResult.error }) }; + } + + const historySequence = assistantMessage.metadata?.historySequence ?? 0; + + // Handle simulated stream scenarios (OpenAI SDK testing features). + // These emit synthetic stream events without calling an AI provider. + const forceContextLimitError = + modelString.startsWith("openai:") && + effectiveMuxProviderOptions.openai?.forceContextLimitError === true; + const simulateToolPolicyNoopFlag = + modelString.startsWith("openai:") && + effectiveMuxProviderOptions.openai?.simulateToolPolicyNoop === true; + + if (forceContextLimitError || simulateToolPolicyNoopFlag) { + const simulationCtx: SimulationContext = { + workspaceId, + assistantMessageId, + canonicalModelString, + routedThroughGateway, + ...(routeProvider != null ? { routeProvider } : {}), + historySequence, + systemMessageTokens, + effectiveAgentId, + effectiveMode, + metadataMode: legacyModeForMetadata, + effectiveThinkingLevel, + emit: (event, data) => this.dependencies.emit(event, data), + }; + + // Simulations emit their synthetic events before returning, so the + // handle settles immediately with the matching terminal outcome. + if (forceContextLimitError) { + const streamError = await simulateContextLimitError( + simulationCtx, + this.dependencies.historyService + ); + return { + type: "finished", + result: Ok( + this.dependencies.createSettledTurnHandle(assistantMessageId, { + status: "failed", + streamError, + }) + ), + }; + } + await simulateToolPolicyNoop( + simulationCtx, + effectiveToolPolicy, + this.dependencies.historyService + ); + return { + type: "finished", + result: Ok( + this.dependencies.createSettledTurnHandle(assistantMessageId, { status: "completed" }) + ), + }; + } + + let requestHeaders = primaryRequest.headers; + const mergedProviderOptions = primaryRequest.providerOptions; + const resolvedOverrides = primaryRequest.resolvedOverrides; + const currentEffectiveLevelRef = primaryRequest.currentEffectiveLevelRef; + const computeRebuiltProviderOptions = primaryRequest.computeRebuiltProviderOptions; + const rebuildProviderOptionsForThinkingLevel = + primaryRequest.rebuildProviderOptionsForThinkingLevel; + // Debug dump: Log the complete LLM request when MUX_DEBUG_LLM_REQUEST is set + if (resolveXumEnvironmentValue("DEBUG_LLM_REQUEST", process.env) === "1") { + log.info( + `[MUX_DEBUG_LLM_REQUEST] Full LLM request:\n${JSON.stringify( + { + workspaceId, + model: modelString, + systemMessage, + messages: finalMessages, + tools: Object.fromEntries( + Object.entries(tools).map(([n, t]) => [ + n, + { description: t.description, inputSchema: t.inputSchema }, + ]) + ), + providerOptions: mergedProviderOptions, + thinkingLevel: effectiveThinkingLevel, + maxOutputTokens, + mode: legacyModeForMetadata, + agentId: effectiveAgentId, + toolPolicy: effectiveToolPolicy, + }, + null, + 2 + )}` + ); + + if (resolvedOverrides.standard && Object.keys(resolvedOverrides.standard).length > 0) { + log.debug("Model parameter overrides (standard):", resolvedOverrides.standard); + } + if (resolvedOverrides.providerExtras) { + log.debug("Model parameter overrides (provider extras):", resolvedOverrides.providerExtras); + } + } + + if (combinedAbortSignal.aborted) { + await deleteAbortedPlaceholder(assistantMessageId); + return { + type: "finished", + result: Ok(this.dependencies.createAbortedTurnHandle(assistantMessageId)), + }; + } + + const snapshot: DebugLlmRequestSnapshot = { + capturedAt: Date.now(), + workspaceId, + messageId: assistantMessageId, + model: modelString, + providerName: canonicalProviderName, + thinkingLevel: effectiveThinkingLevel, + mode: legacyModeForMetadata, + agentId: effectiveAgentId, + maxOutputTokens, + systemMessage, + messages: finalMessages, + }; + + try { + this.dependencies.lastLlmRequestByWorkspace.set(workspaceId, structuredClone(snapshot)); + } catch (error) { + const errMsg = getErrorMessage(error); + workspaceLog.warn("Failed to capture debug LLM request snapshot", { error: errMsg }); + } + const toolsForStream = tools; + + const devToolsService = this.dependencies.devToolsService; + const canQueueDevToolsRunMetadata = + devToolsService?.enabled === true && + typeof modelResult.data.model !== "string" && + modelResult.data.model.specificationVersion === "v4"; + + if (canQueueDevToolsRunMetadata) { + // Correlate pending run metadata with the specific request that reaches + // DevTools middleware to avoid cross-request policy leakage. Queue only + // when middleware is guaranteed to run (LanguageModelV3). + pendingRunMetadataId = String(streamToken); + context.startupState.pendingRunMetadataId = pendingRunMetadataId; + devToolsService.setPendingRunMetadata(workspaceId, pendingRunMetadataId, { + toolPolicy: + effectiveToolPolicy != null && effectiveToolPolicy.length > 0 + ? effectiveToolPolicy + : undefined, + // Join key for the replay verifier: re-anchors this recorded run to + // its turn-envelope row and assistant message (see DevToolsRun). + ...(requestHistorySequence >= 0 ? { requestHistorySequence } : {}), + }); + this.dependencies.trackPendingDevToolsRunMetadata( + assistantMessageId, + workspaceId, + pendingRunMetadataId + ); + requestHeaders = { + ...requestHeaders, + [DEVTOOLS_RUN_METADATA_ID_HEADER]: pendingRunMetadataId, + }; + } + + // --- Refusal fallback chain --- + // Resolved from app config by the RAW selection (metadata-aware inside): + // a cross-typed Coder instance (coder:openai/x, type anthropic) must use + // its own gateway-scoped chain, never the direct provider's. Task + // children can opt out via taskOnRefusal: "fail" (see + // resolveWorkspaceModelFallbackChain). + const modelFallbackChain = resolveWorkspaceModelFallbackChain( + this.dependencies.config.loadConfigOrDefault(), + workspaceId, + modelString, + this.dependencies.providerService.getConfig() + ); + + // Lazily rebuilds the per-model slice of this pipeline (model creation, + // provider-specific message prep, provider options, headers, parameter + // overrides) when StreamManager swaps to a fallback model after a + // refusal. Reusing the original request verbatim would leak + // provider-specific options/messages across providers. + const modelFallback: ModelFallbackOptions | undefined = + modelFallbackChain.length > 0 + ? { + chain: modelFallbackChain, + prepare: async (nextModelString, prepareOptions) => { + const sourceMessages = prepareOptions?.continuation + ? replaceOrAppendMessageById(messages, prepareOptions.continuation.assistantMessage) + : messages; + const requestedThinkingLevel = + prepareOptions?.thinkingLevelOverride ?? effectiveThinkingLevel; + const nextSeedResult = await prepareModelSeed({ + rawModelString: nextModelString, + requestedThinkingLevel, + minimumThinkingLevelOverride: lookupMinThinkingLevelOverride( + this.dependencies.config.loadConfigOrDefault().minThinkingLevelByModel, + nextModelString + ), + enforceMinimum: true, + }); + if (!nextSeedResult.success) { + return Err(formatSendMessageError(nextSeedResult.error).message); + } + + const nextRequest = await prepareModelRequest({ + seed: nextSeedResult.data, + sourceMessages, + initializeToolSearch: false, + reusePrePolicySystemContext: false, + requestHistorySequence, + partialContinuationMessage: prepareOptions?.continuation?.assistantMessage, + cleanupModelOnError: true, + }); + let nextHeaders = nextRequest.headers; + if (pendingRunMetadataId != null) { + nextHeaders = { + ...nextHeaders, + [DEVTOOLS_RUN_METADATA_ID_HEADER]: pendingRunMetadataId, + }; + } + + return Ok({ + onStreamConstructed: nextRequest.onStreamConstructed, + rebuildFirstStepForThinkingLevel: nextRequest.rebuildFirstStepForThinkingLevel, + model: nextRequest.model, + modelString: nextModelString, + messages: nextRequest.messages, + system: nextRequest.system, + tools: nextRequest.tools, + providerOptions: nextRequest.providerOptions, + headers: nextHeaders, + callSettingsOverrides: nextRequest.resolvedOverrides.standard, + anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, + thinkingLevel: nextRequest.effectiveThinkingLevel, + forcedFirstStepToolNames: nextRequest.forcedFirstStepToolNames, + rebuildProviderOptionsForThinkingLevel: + nextRequest.rebuildProviderOptionsForThinkingLevel, + providersConfig: nextRequest.providersConfig, + initialMetadataPatch: { + routedThroughGateway: nextRequest.routedThroughGateway, + ...(nextRequest.routeProvider != null + ? { routeProvider: nextRequest.routeProvider } + : {}), + costsIncluded: modelCostsIncluded(nextRequest.model) ? true : undefined, + systemMessageTokens: nextRequest.systemMessageTokens, + }, + }); + }, + } + : undefined; + + const forcedFirstStepToolNames = primaryRequest.forcedFirstStepToolNames; + + // Fold PREPARING-window pending thinking overrides into the ACTUAL + // request build, not just the envelope: message preparation is + // thinking-level-dependent (Anthropic signed-reasoning transforms), so + // recording the new level while streaming old-level messages would make + // wire and replay diverge — or send an invalid extended-thinking + // request. Consuming pending here (applied set below) is safe: + // createStreamAtomically seeds streamInfo.thinkingLevel from `applied`, + // and prepareStep simply sees no pending to re-apply. + // Loop until pending is quiescent: setActiveTurnThinkingLevel can write + // a NEW pending while the awaited message rebuild runs, and stamping the + // first level after the await would leave step 0 rebuilding only + // provider options while the messages stay at the stale level. + let streamThinkingLevel = effectiveThinkingLevel; + let streamProviderOptions = mergedProviderOptions; + let streamFinalMessages = finalMessages; + while (activeTurnThinkingOverride?.pending != null) { + const pendingPreparingLevel = activeTurnThinkingOverride.pending; + activeTurnThinkingOverride.pending = undefined; + const folded = computeRebuiltProviderOptions(pendingPreparingLevel, streamThinkingLevel); + if (folded == null) { + // No-op fold (same effective level / non-foldable variant swap): + // re-check pending — a change may have raced the previous rebuild. + continue; + } + streamFinalMessages = await primaryRequest.rebuildMessagesForThinkingLevel( + folded.effectiveLevel + ); + streamProviderOptions = folded.providerOptions; + streamThinkingLevel = folded.effectiveLevel; + activeTurnThinkingOverride.applied = folded.effectiveLevel; + // Keep the mid-turn rebuild baseline in sync so a later identical + // request is correctly treated as a no-op. + currentEffectiveLevelRef.current = folded.effectiveLevel; + // Loop re-checks pending: a change during the awaits above re-folds + // against the level just applied. + } + + const emitPrimaryEnvelope = (): Promise => + primaryRequest.emitEnvelopeWith(streamThinkingLevel, streamProviderOptions); + emitStartupBreadcrumb("starting_stream"); + const turnExecutionOptions: TurnExecutionOptions = { + workspaceId, + messages: streamFinalMessages, + model: modelResult.data.model, + modelString, + historySequence, + system: systemMessage, + runtime, + messageId: assistantMessageId, + abortSignal: combinedAbortSignal, + tools: toolsForStream, + initialMetadata: { + ...(requestHistorySequence >= 0 ? { requestHistorySequence } : {}), + systemMessageTokens, + timestamp: Date.now(), + agentId: effectiveAgentId, + ...(legacyModeForMetadata != null ? { mode: legacyModeForMetadata } : {}), + routedThroughGateway, + ...(routeProvider != null ? { routeProvider } : {}), + ...(muxMetadata !== undefined ? { muxMetadata } : {}), + ...(acpPromptId != null ? { acpPromptId } : {}), + ...(modelCostsIncluded(modelResult.data.model) ? { costsIncluded: true } : {}), + }, + providerOptions: streamProviderOptions, + maxOutputTokens, + toolPolicy: effectiveToolPolicy, + providedStreamToken: streamToken, + hasQueuedMessages, + workspaceName: metadata.name, + thinkingLevel: streamThinkingLevel, + headers: requestHeaders, + anthropicCacheTtlOverride: effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, + callSettingsOverrides: resolvedOverrides.standard, + onChunk: advisorToolEligible ? onAdvisorChunk : undefined, + onStepMessages: advisorToolEligible + ? (stepMessages) => { + advisorTranscriptRef.messages = stepMessages; + advisorStepCaptureRef.currentStepText = ""; + advisorStepCaptureRef.currentStepReasoning = ""; + advisorStepCaptureRef.frozenSnapshotsByToolCallId.clear(); + } + : undefined, + providedRuntimeTempDir: runtimeTempDir, + modelFallback, + toolSearchState: toolSearchRuntime?.state, + thinkingOverrideState: activeTurnThinkingOverride, + rebuildProviderOptionsForThinkingLevel, + forcedFirstStepToolNames, + providersConfigSnapshot: requestProvidersConfig, + onStreamConstructed: emitPrimaryEnvelope, + rebuildFirstStepForThinkingLevel: primaryRequest.rebuildFirstStepForThinkingLevel, + }; + + const logStartOutcome = ( + outcome: "started" | "stream_start_failed", + errorType?: string + ): void => { + logSlowStreamStartup({ + outcome, + providerName: canonicalProviderName, + routeProvider, + agentId: effectiveAgentId, + mode: legacyModeForMetadata, + runtimeType: metadata.runtimeConfig.type, + ...(errorType != null ? { errorType } : {}), + toolCount: Object.keys(toolsForStream).length, + mcpToolCount: Object.keys(mcpTools ?? {}).length, + mcpFailedServerCount: mcpStats?.failedServerCount ?? 0, + providerRequestMessageCount: providerRequestMessages.length, + finalMessageCount: finalMessages.length, + }); + }; + + return { + type: "ready", + turnExecutionOptions, + assistantMessageId, + deleteAbortedPlaceholder, + logStartOutcome, + }; + } +} diff --git a/src/node/services/workflows/WorkflowService.context.test.ts b/src/node/services/workflows/WorkflowService.context.test.ts index f7acf4f86fb..4111decf4e8 100644 --- a/src/node/services/workflows/WorkflowService.context.test.ts +++ b/src/node/services/workflows/WorkflowService.context.test.ts @@ -72,8 +72,8 @@ describe("WorkflowService request orchestration", () => { const context = { workflowRuntimeFactory: new QuickJSRuntimeFactory(), config, + initStateManager: { waitForInit }, aiService: { - waitForInit, resolveXumToolScopeForWorkspace: mock((_metadata, _runtime, executionPath) => ({ type: "project", xumHome: temp.path, diff --git a/src/node/services/workflows/WorkflowService.ts b/src/node/services/workflows/WorkflowService.ts index 0cfafe2b485..2430127843f 100644 --- a/src/node/services/workflows/WorkflowService.ts +++ b/src/node/services/workflows/WorkflowService.ts @@ -18,6 +18,7 @@ import { } from "@/node/runtime/runtimeHelpers"; import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; +import type { InitStateManager } from "@/node/services/initStateManager"; import type { ExperimentsService } from "@/node/services/experimentsService"; import { resolveSkillStorageContext } from "@/node/services/agentSkills/skillStorageContext"; import type { TaskService } from "@/node/services/taskService"; @@ -970,6 +971,7 @@ export const DYNAMIC_WORKFLOWS_DISABLED_ERROR_MESSAGE = "Dynamic workflows are d export interface WorkflowServiceContext { config: Config; aiService: AIService; + initStateManager: InitStateManager; workspaceService: WorkspaceService; taskService: TaskService; experimentsService: ExperimentsService; @@ -998,7 +1000,7 @@ export async function resolveWorkflowContext( if (!context.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS)) { throw new Error(DYNAMIC_WORKFLOWS_DISABLED_ERROR_MESSAGE); } - await context.aiService.waitForInit(workspaceId); + await context.initStateManager.waitForInit(workspaceId); const metadataResult = await context.aiService.getWorkspaceMetadata(workspaceId); if (!metadataResult.success) { throw new Error(metadataResult.error); diff --git a/src/node/services/workspaceService.multiProject.test.ts b/src/node/services/workspaceService.multiProject.test.ts index 3b19c4306aa..78a7bb37647 100644 --- a/src/node/services/workspaceService.multiProject.test.ts +++ b/src/node/services/workspaceService.multiProject.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { Config } from "@/node/config"; import { ContainerManager } from "@/node/multiProject/containerManager"; +import { createStreamLifecycleMocks } from "@/node/services/agentSession.testHarness"; import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; import * as runtimeFactory from "@/node/runtime/runtimeFactory"; import * as gitModule from "@/node/git"; @@ -74,7 +75,7 @@ interface WorkspaceServiceTestOptions { } function createMockAIService(metadata?: WorkspaceMetadata): AIService { return { - isStreaming: mock(() => false), + ...createStreamLifecycleMocks(), getWorkspaceMetadata: mock(() => Promise.resolve(metadata ? Ok(metadata) : Ok(undefined))), on: mock(() => undefined), off: mock(() => undefined), @@ -715,7 +716,7 @@ describe("WorkspaceService multi-project lifecycle", () => { findWorkspace: mock(() => null), }; const mockAIService = { - isStreaming: mock(() => false), + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; @@ -882,7 +883,7 @@ describe("WorkspaceService multi-project lifecycle", () => { findWorkspace: mock(() => null), }; const mockAIService = { - isStreaming: mock(() => false), + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; @@ -1033,7 +1034,7 @@ describe("WorkspaceService multi-project lifecycle", () => { findWorkspace: mock(() => null), }; const mockAIService = { - isStreaming: mock(() => false), + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; @@ -1179,7 +1180,7 @@ describe("WorkspaceService multi-project lifecycle", () => { findWorkspace: mock(() => null), }; const mockAIService = { - isStreaming: mock(() => false), + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; @@ -1293,7 +1294,7 @@ describe("WorkspaceService multi-project lifecycle", () => { findWorkspace: mock(() => null), }; const mockAIService = { - isStreaming: mock(() => false), + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; @@ -1336,7 +1337,7 @@ describe("WorkspaceService multi-project lifecycle", () => { getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), }; const mockAIService = { - isStreaming: mock(() => false), + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 292987bd199..5a34ba588d7 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -3,7 +3,11 @@ import { WorkspaceService, generateForkBranchName, generateForkTitle } from "./w import { registerInProcessWorkflowRun } from "@/node/services/workflows/workflowArchiveAdmission"; import type { IdleCompactionOutcome } from "./idleCompactionService"; import type { AgentSession } from "./agentSession"; -import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; +import { + createAgentSessionHarness, + createStartedTurnHandle, + createStreamLifecycleMocks, +} from "./agentSession.testHarness"; import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { askUserQuestionManager } from "./askUserQuestionManager"; @@ -176,7 +180,7 @@ function createMockAIService(overrides: Partial = {}): AIService { return { on: mock(() => undefined), off: mock(() => undefined), - isStreaming: mock(() => false), + ...createStreamLifecycleMocks(), ...overrides, } as unknown as AIService; } @@ -193,6 +197,7 @@ function createWorkspaceServiceForTest(options: { telemetryService?: WorkspaceServiceArgs[8]; experimentsService?: WorkspaceServiceArgs[9]; sessionTimingService?: WorkspaceServiceArgs[10]; + streamManager?: WorkspaceServiceArgs[11]; }): WorkspaceService { // Test helpers often don't exercise HistoryService; use a narrow stub for those cases. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -208,7 +213,8 @@ function createWorkspaceServiceForTest(options: { options.policyService, options.telemetryService, options.experimentsService, - options.sessionTimingService + options.sessionTimingService, + options.streamManager ); } @@ -4032,6 +4038,7 @@ describe("WorkspaceService bash monitor wakes", () => { cleanup: mock(() => Promise.resolve()), }) as unknown as BackgroundProcessManager & EventEmitter; const aiService = Object.assign(new EventEmitter(), { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), }) as unknown as AIService & EventEmitter; const workspaceService = createWorkspaceServiceForTest({ @@ -4440,6 +4447,7 @@ describe("WorkspaceService bash monitor wakes", () => { cleanup: mock(() => Promise.resolve()), }) as unknown as BackgroundProcessManager & EventEmitter; const aiService = Object.assign(new EventEmitter(), { + ...createStreamLifecycleMocks(), isStreaming: mock(() => true), }) as unknown as AIService & EventEmitter; const workspaceService = createWorkspaceServiceForTest({ @@ -4507,6 +4515,7 @@ describe("WorkspaceService bash monitor wakes", () => { cleanup: mock(() => Promise.resolve()), }) as unknown as BackgroundProcessManager & EventEmitter; const aiService = Object.assign(new EventEmitter(), { + ...createStreamLifecycleMocks(), isStreaming: mock(() => true), }) as unknown as AIService & EventEmitter; const workspaceService = createWorkspaceServiceForTest({ @@ -5010,6 +5019,7 @@ describe("WorkspaceService bash monitor wakes", () => { }) as unknown as BackgroundProcessManager & EventEmitter; let streaming = true; const aiService = Object.assign(new EventEmitter(), { + ...createStreamLifecycleMocks(), isStreaming: mock(() => streaming), }) as unknown as AIService & EventEmitter; const workspaceService = createWorkspaceServiceForTest({ @@ -9230,6 +9240,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { const aiService = aiServiceOverride ?? ({ + ...createStreamLifecycleMocks(), on: mock(() => undefined), isStreaming: mock(() => false), } as unknown as AIService); @@ -10185,6 +10196,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { // lock must fail the mutation instead of truncating under a live stream. let streaming = false; const aiService = { + ...createStreamLifecycleMocks(), on: mock(() => undefined), isStreaming: mock(() => streaming), } as unknown as AIService; @@ -10240,6 +10252,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { // published row cannot land inside a PREPARING snapshot window. let streaming = true; const aiService = { + ...createStreamLifecycleMocks(), on: mock(() => undefined), isStreaming: mock(() => streaming), } as unknown as AIService; @@ -10720,6 +10733,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { test("context reset rejects active streams", async () => { const aiService = { + ...createStreamLifecycleMocks(), on: mock(() => undefined), isStreaming: mock(() => true), } as unknown as AIService; @@ -10984,6 +10998,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { test("user-aborted streams do NOT replay queued goal mutations", async () => { const aiEmitter = new EventEmitter(); const aiService = Object.assign(aiEmitter, { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), }) as unknown as AIService; const { config, workspaceService, goalService, cleanup } = await createServices(aiService); @@ -11059,6 +11074,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { test("mid-stream activity emits surface the optimistic goal, then revert on user abort", async () => { const aiEmitter = new EventEmitter(); const aiService = Object.assign(aiEmitter, { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), }) as unknown as AIService; const { config, workspaceService, goalService, cleanup } = await createServices(aiService); @@ -11148,6 +11164,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { // in-flight stream can be charged to the replacement goal. const aiEmitter = new EventEmitter(); const aiService = Object.assign(aiEmitter, { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), }) as unknown as AIService; const { config, workspaceService, goalService, cleanup } = await createServices(aiService); @@ -11214,6 +11231,7 @@ describe("WorkspaceService initialize", () => { } as unknown as Config; const aiService = { + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; @@ -11280,6 +11298,7 @@ describe("WorkspaceService initialize", () => { await fsPromises.writeFile(path.join(realConfig.rootDir, "config.json"), "{invalid-json"); const aiService = { + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; @@ -11330,6 +11349,7 @@ describe("WorkspaceService initialize", () => { } const aiService = { + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; @@ -11371,6 +11391,7 @@ describe("WorkspaceService initialize", () => { await fsPromises.writeFile(path.join(realConfig.rootDir, "config.json"), "{invalid-json"); const aiService = { + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; @@ -11507,6 +11528,7 @@ describe("WorkspaceService rename lock", () => { beforeEach(async () => { // Create minimal mocks for the services mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve({ success: false, error: "not found" })), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -11612,6 +11634,7 @@ describe("WorkspaceService sendMessage status clearing", () => { beforeEach(async () => { const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve({ success: false as const, error: "not found" }) @@ -12458,6 +12481,7 @@ describe("WorkspaceService pending auto-title", () => { namedWorkspacePath: workspacePath, }; const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(metadata))), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -12714,6 +12738,7 @@ describe("WorkspaceService idle compaction dispatch", () => { beforeEach(async () => { const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve({ success: false as const, error: "not found" }) @@ -13247,6 +13272,7 @@ describe("WorkspaceService streaming generation guard", () => { beforeEach(async () => { const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve({ success: false as const, error: "not found" }) @@ -13567,6 +13593,7 @@ describe("WorkspaceService executeBash archive guards", () => { ); const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: getWorkspaceMetadataMock, // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -13793,6 +13820,7 @@ describe("WorkspaceService executeBash workspace path resolution", () => { ); const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: getWorkspaceMetadataMock, on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { @@ -13924,6 +13952,7 @@ describe("WorkspaceService getFileCompletions", () => { beforeEach(async () => { const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve({ success: false as const, error: "not found" }) @@ -14212,6 +14241,7 @@ describe("WorkspaceService getProjectGitStatuses", () => { } { const getWorkspaceMetadataMock = mock(() => Promise.resolve(Ok(params.metadata))); const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: getWorkspaceMetadataMock, on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { @@ -14459,6 +14489,7 @@ describe("WorkspaceService post-compaction metadata refresh", () => { beforeEach(async () => { const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve({ success: false as const, error: "not found" }) @@ -14595,6 +14626,7 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { beforeEach(async () => { const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve({ success: false as const, error: "nope" })), on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { @@ -14836,6 +14868,7 @@ describe("WorkspaceService assertPricedModelForBudgetedGoal", () => { async function makeService(): Promise { const aiService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), on: mock(() => undefined), off: mock(() => undefined), @@ -15618,6 +15651,7 @@ describe("WorkspaceService remove desktop session cleanup", () => { removeWorkspaceMock = mock(() => Promise.resolve()); const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), stopStream: mock(() => Promise.resolve(Ok(undefined))), getWorkspaceMetadata: mock(() => Promise.resolve(Err("not found"))), @@ -16255,6 +16289,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { let workspaceService: WorkspaceService; let mockAIService: AIService; + let mockStreamManager: { getStreamInfo: ReturnType }; let configState: ProjectsConfig; let editConfigSpy: ReturnType; let historyService: HistoryService; @@ -16308,6 +16343,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { loadConfigOrDefault: mock(() => configState), }; mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(workspaceMetadata))), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -16316,11 +16352,13 @@ describe("WorkspaceService archive lifecycle hooks", () => { off: mock(() => {}), } as unknown as AIService; + mockStreamManager = { ...createStreamLifecycleMocks(), getStreamInfo: mock(() => undefined) }; workspaceService = createWorkspaceServiceForTest({ config: mockConfig, historyService, aiService: mockAIService, initStateManager: mockInitStateManager as InitStateManager, + streamManager: mockStreamManager as unknown as WorkspaceServiceArgs[11], }); }); @@ -16661,10 +16699,9 @@ describe("WorkspaceService archive lifecycle hooks", () => { test("acquirePreInterruptionArchiveHold binds the stream exemption to the delegated turns", () => { const delegated = { taskHandleId: "wt-1", ownerWorkspaceId: "owner-1", turnId: "turn-1" }; const streamMeta: Record = { type: "workspace-turn-task", ...delegated }; - Object.assign(mockAIService, { - isStreaming: mock(() => true), - getStreamInfo: mock(() => ({ muxMetadata: streamMeta })), - }); + Object.assign(mockAIService, { isStreaming: mock(() => true) }); + // The delegated-turn correlation is read from the engine, not the AI facade. + mockStreamManager.getStreamInfo = mock(() => ({ muxMetadata: streamMeta })); // The active stream carries the collected turn's exact correlation: interruptible // delegated work, so the hold is granted. @@ -17183,6 +17220,7 @@ describe("WorkspaceService archive init cancellation", () => { }; const mockAIService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(workspaceMetadata))), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -17303,6 +17341,7 @@ describe("WorkspaceService unarchive lifecycle hooks", () => { getAllWorkspaceMetadata: mock(() => Promise.resolve([workspaceMetadata])), }; const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(workspaceMetadata))), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -17443,6 +17482,7 @@ describe("WorkspaceService archive snapshots", () => { loadConfigOrDefault: mock(() => configState), }; const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(workspaceMetadata))), on: mock(() => undefined), @@ -17632,6 +17672,7 @@ describe("WorkspaceService preflightArchive and acknowledged archive", () => { loadConfigOrDefault: mock(() => configState), }; const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(workspaceMetadata))), on: mock(() => undefined), @@ -17917,6 +17958,7 @@ describe("WorkspaceService unarchive snapshot restore", () => { loadConfigOrDefault: mock(() => configState), }; const aiService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(workspaceMetadata))), on: mock(() => undefined), @@ -18069,6 +18111,7 @@ describe("WorkspaceService deleteWorktree", () => { }; const aiService = { + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), } as unknown as AIService; @@ -18281,6 +18324,7 @@ describe("WorkspaceService archiveMergedInProject", () => { }; const aiService: AIService = { + ...createStreamLifecycleMocks(), on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { return this; }, @@ -18517,6 +18561,7 @@ describe("WorkspaceService init cancellation", () => { configWithStableId.generateStableId = () => parentId; const aiService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), stopStream: mock(() => Promise.resolve(Ok(undefined))), getWorkspaceMetadata: mock(async (workspaceId: string) => { @@ -18586,6 +18631,7 @@ describe("WorkspaceService init cancellation", () => { configWithStableId.generateStableId = () => victimId; const aiService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), stopStream: mock(() => Promise.resolve(Ok(undefined))), getWorkspaceMetadata: mock(async (workspaceId: string) => { @@ -18673,6 +18719,7 @@ describe("WorkspaceService init cancellation", () => { const generateStableIdMock = mock(() => "ws-untrusted"); const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), // eslint-disable-next-line @typescript-eslint/no-empty-function on: mock(() => {}), @@ -18769,6 +18816,7 @@ describe("WorkspaceService init cancellation", () => { const clearInMemoryStateMock = mock((_workspaceId: string) => undefined); const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), // eslint-disable-next-line @typescript-eslint/no-empty-function on: mock(() => {}), @@ -18825,6 +18873,7 @@ describe("WorkspaceService init cancellation", () => { const editConfigMock = mock(() => Promise.resolve()); const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), // eslint-disable-next-line @typescript-eslint/no-empty-function on: mock(() => {}), @@ -18877,6 +18926,7 @@ describe("WorkspaceService init cancellation", () => { const workspaceId = "ws-list-initializing"; const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), // eslint-disable-next-line @typescript-eslint/no-empty-function on: mock(() => {}), @@ -18996,6 +19046,7 @@ describe("WorkspaceService init cancellation", () => { }; const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), // eslint-disable-next-line @typescript-eslint/no-empty-function on: mock(() => {}), @@ -19148,6 +19199,7 @@ describe("WorkspaceService init cancellation", () => { }; const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), // eslint-disable-next-line @typescript-eslint/no-empty-function on: mock(() => {}), @@ -19227,6 +19279,7 @@ describe("WorkspaceService init cancellation", () => { } as unknown as InitStateManager; const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })), getWorkspaceMetadata: mock(() => Promise.resolve({ success: false as const, error: "na" })), @@ -19293,6 +19346,7 @@ describe("WorkspaceService init cancellation", () => { const tempRoot = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-ws-remove-fail-")); try { const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })), getWorkspaceMetadata: mock(() => @@ -19361,6 +19415,7 @@ describe("WorkspaceService init cancellation", () => { const tempRoot = await fsPromises.mkdtemp(path.join(tmpdir(), "mux-ws-remove-runtime-")); try { const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), stopStream: mock(() => Promise.resolve({ success: true as const, data: undefined })), getWorkspaceMetadata: mock(() => @@ -19415,6 +19470,7 @@ describe("WorkspaceService regenerateTitle", () => { beforeEach(async () => { const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve({ success: false as const, error: "workspace metadata unavailable" }) @@ -19617,6 +19673,7 @@ describe("WorkspaceService fork", () => { const sourceProjectPath = "/tmp/project"; const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve( @@ -19737,6 +19794,7 @@ describe("WorkspaceService fork", () => { }); const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(sourceMetadata))), on: mock(() => undefined), @@ -19864,6 +19922,7 @@ describe("WorkspaceService fork", () => { expect(sourceUsage?.byModel["claude-sonnet-4-20250514"]?.input.tokens).toBe(100); const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(sourceMetadata))), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -19984,6 +20043,7 @@ describe("WorkspaceService fork", () => { expect(writePartialResult.success).toBe(true); const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(sourceMetadata))), on: mock(() => undefined), @@ -20094,6 +20154,7 @@ describe("WorkspaceService fork", () => { }); const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(sourceMetadata))), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -20204,6 +20265,7 @@ describe("WorkspaceService fork", () => { }); const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(sourceMetadata))), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -20313,6 +20375,7 @@ describe("WorkspaceService fork", () => { }); const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(sourceMetadata))), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -20414,6 +20477,7 @@ describe("WorkspaceService interruptStream", () => { }; const mockAIService: AIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), getWorkspaceMetadata: mock(() => Promise.resolve({ success: false, error: "not found" })), // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -20560,6 +20624,7 @@ describe("generateForkTitle", () => { describe("WorkspaceService.getGoalContinuationRuntimeState", () => { async function makeService(initState: InitStatus | undefined): Promise { const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), on: mock(() => undefined), off: mock(() => undefined), @@ -20697,6 +20762,7 @@ describe("WorkspaceService.getGoalContinuationRuntimeState", () => { historyService: HistoryService; }> { const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), on: mock(() => undefined), off: mock(() => undefined), @@ -20927,6 +20993,7 @@ describe("WorkspaceService.getGoalContinuationRuntimeState", () => { configOverrides: Partial ): Promise { const mockAIService = { + ...createStreamLifecycleMocks(), isStreaming: mock(() => false), on: mock(() => undefined), off: mock(() => undefined), @@ -21933,6 +22000,7 @@ describe("WorkspaceService.fork branch-summary rollback ordering", () => { }, ]; const aiService = { + ...createStreamLifecycleMocks(), on: mock(() => undefined), off: mock(() => undefined), isStreaming: mock(() => false), diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index fb0860e8fa7..5dbcbf7f22e 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -39,6 +39,7 @@ import { import type { QueueCutCutter } from "@/node/services/messageQueue"; import type { HistoryService } from "@/node/services/historyService"; import type { AIService } from "@/node/services/aiService"; +import type { StreamManager } from "@/node/services/streamManager"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { ExtensionMetadataService, @@ -2373,7 +2374,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { policyService?: PolicyService, telemetryService?: TelemetryService, experimentsService?: ExperimentsService, - sessionTimingService?: SessionTimingService + sessionTimingService?: SessionTimingService, + private readonly streamManager?: StreamManager ) { super(); this.bashMonitorWakeStore = new BashMonitorWakeStore(config); @@ -4001,8 +4003,14 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { * This is used by integration tests to simulate network errors mid-stream. * @returns true if an active stream was found and error was triggered */ - debugTriggerStreamError(workspaceId: string, errorMessage?: string): Promise { - return this.aiService.debugTriggerStreamError(workspaceId, errorMessage); + debugTriggerStreamError( + workspaceId: string, + errorMessage = "Test-triggered stream error" + ): Promise { + return ( + this.streamManager?.debugTriggerStreamError(workspaceId, errorMessage) ?? + Promise.resolve(false) + ); } /** @@ -4713,6 +4721,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { config: this.config, historyService: this.historyService, aiService: this.aiService, + streamManager: this.streamManager, mcpServerManager: this.mcpServerManager, telemetryService: this.telemetryService, initStateManager: this.initStateManager, @@ -8906,7 +8915,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // different turn) is treated as user work and refuses. if (this.aiService.isStreaming(workspaceId)) { const streamCorrelation = parseWorkspaceTurnTaskCorrelation( - this.aiService.getStreamInfo(workspaceId)?.muxMetadata + this.streamManager?.getStreamInfo(workspaceId)?.muxMetadata ); const streamIsExpectedDelegatedTurn = streamCorrelation != null && diff --git a/tests/ipc/streaming/queuedMessages.completing.test.ts b/tests/ipc/streaming/queuedMessages.completing.test.ts index 69c1c5e8924..c322f02bde7 100644 --- a/tests/ipc/streaming/queuedMessages.completing.test.ts +++ b/tests/ipc/streaming/queuedMessages.completing.test.ts @@ -330,11 +330,9 @@ describe("Queued messages during stream completion", () => { } // Verify the queued message made it into the second stream prompt. - const promptResult = aiService.debugGetLastMockPrompt(workspaceId); - if (!promptResult.success || !promptResult.data) { - throw new Error("Mock prompt snapshot missing after queued stream start"); - } - const promptUserMessages = promptResult.data + const prompt = aiService.mockAiStreamPlayer?.debugGetLastPrompt(workspaceId); + if (!prompt) throw new Error("Mock prompt snapshot missing after queued stream start"); + const promptUserMessages = prompt .filter((message) => message.role === "user") .map((message) => message.parts diff --git a/tests/ipc/streaming/queuedMessages.starting.test.ts b/tests/ipc/streaming/queuedMessages.starting.test.ts index 4715b42bf96..63ce1e0ec96 100644 --- a/tests/ipc/streaming/queuedMessages.starting.test.ts +++ b/tests/ipc/streaming/queuedMessages.starting.test.ts @@ -133,11 +133,9 @@ describe("Queued messages during stream start", () => { throw new Error("Second stream never started after queued message release"); } - const promptResult = aiService.debugGetLastMockPrompt(workspaceId); - if (!promptResult.success || !promptResult.data) { - throw new Error("Mock prompt snapshot missing after queued stream start"); - } - const promptUserMessages = promptResult.data + const prompt = aiService.mockAiStreamPlayer?.debugGetLastPrompt(workspaceId); + if (!prompt) throw new Error("Mock prompt snapshot missing after queued stream start"); + const promptUserMessages = prompt .filter((message) => message.role === "user") .map((message) => message.parts diff --git a/tests/ipc/tasks/persistentSubagentCompaction.test.ts b/tests/ipc/tasks/persistentSubagentCompaction.test.ts index bdf2aa505cd..1226b85a28f 100644 --- a/tests/ipc/tasks/persistentSubagentCompaction.test.ts +++ b/tests/ipc/tasks/persistentSubagentCompaction.test.ts @@ -198,14 +198,10 @@ describe("Persistent sub-agent compaction", () => { expect(fullHistoryResult.success).toBe(true); expect(fullHistory.some((message) => extractText(message).includes(seedText))).toBe(true); - const lastPromptResult = env.services.aiService.debugGetLastMockPrompt(childWorkspaceId); - expect(lastPromptResult.success).toBe(true); - if (!lastPromptResult.success || lastPromptResult.data == null) { - throw new Error("Expected a captured mock prompt"); - } - expect(lastPromptResult.data[0]?.metadata?.compactionBoundary).toBe(true); - expect(lastPromptResult.data.some((message) => extractText(message).includes(seedText))).toBe( - false - ); + const lastPrompt = + env.services.aiService.mockAiStreamPlayer?.debugGetLastPrompt(childWorkspaceId); + if (lastPrompt == null) throw new Error("Expected a captured mock prompt"); + expect(lastPrompt[0]?.metadata?.compactionBoundary).toBe(true); + expect(lastPrompt.some((message) => extractText(message).includes(seedText))).toBe(false); }, 30_000); }); diff --git a/tests/ui/config/modelOneshot.test.ts b/tests/ui/config/modelOneshot.test.ts index 7779911cda1..9beb511eb44 100644 --- a/tests/ui/config/modelOneshot.test.ts +++ b/tests/ui/config/modelOneshot.test.ts @@ -37,11 +37,9 @@ describe("Model one-shot (/ message)", () => { await app.chat.expectInputValue(""); // Verify the mock AI router received the correct model - const modelResult = app.env.services.aiService.debugGetLastMockModel(app.workspaceId); - expect(modelResult.success).toBe(true); - if (modelResult.success) { - expect(modelResult.data).toBe(expectedModelId); - } + expect( + app.env.services.aiService.mockAiStreamPlayer?.debugGetLastModel(app.workspaceId) + ).toBe(expectedModelId); // Verify the ModelSelector UI didn't change (preference not persisted) const modelSelectorAfter = await app.chat.getModelSelectorText();