From f0749edbf12379d15b6f380fc81bb0f5793f0843 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:45:14 +0000 Subject: [PATCH 01/22] refactor(ai): introduce turn request builder module --- src/node/services/aiService.ts | 81 ++----------------------- src/node/services/turnRequestBuilder.ts | 75 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 76 deletions(-) create mode 100644 src/node/services/turnRequestBuilder.ts diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 085560ba66..dc84862846 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -12,6 +12,8 @@ import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import type { SendMessageOptions, ProvidersConfigMap } from "@/common/orpc/types"; +import type { StreamMessageOptions } from "./turnRequestBuilder"; +export type { StreamMessageOptions } from "./turnRequestBuilder"; import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; import { @@ -21,7 +23,7 @@ import { import { EXPERIMENT_IDS, type ExperimentId } from "@/common/constants/experiments"; import type { GoalRecordV1 } from "@/common/types/goal"; -import type { ModelMessage, MuxMessage, MuxMessageMetadata } from "@/common/types/message"; +import type { ModelMessage, MuxMessage } from "@/common/types/message"; import { createMuxMessage } from "@/common/types/message"; import type { Config } from "@/node/config"; import { @@ -76,15 +78,12 @@ 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 { log } from "./log"; import { addInterruptedSentinel, filterEmptyAssistantMessages, } from "@/browser/utils/messages/modelMessageTransform"; -import type { PostCompactionAttachment } from "@/common/types/attachment"; import type { HistoryService } from "./historyService"; import { delegatedToolCallManager } from "./delegatedToolCallManager"; @@ -146,11 +145,7 @@ import { isWorkspaceTrustedForSharedExecution } from "@/node/services/utils/work 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 { THINKING_LEVEL_OFF, type ThinkingLevel } from "@/common/types/thinking"; import { enforceThinkingPolicy, isXaiGrokFastVariantSwap, @@ -159,13 +154,11 @@ import { resolveMinimumThinkingLevel, } from "@/common/utils/thinking/policy"; import type { - ActiveTurnThinkingOverride, RebuildFirstStepForThinkingLevel, RebuildProviderOptionsForThinkingLevel, } from "@/node/services/thinkingOverride"; -import type { ErrorEvent, StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; -import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; +import type { StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; import { computeActiveToolNames, prepareToolSearch, @@ -274,70 +267,6 @@ export function replaceOrAppendMessageById( // 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. diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts new file mode 100644 index 0000000000..127e02a15b --- /dev/null +++ b/src/node/services/turnRequestBuilder.ts @@ -0,0 +1,75 @@ +import type { SendMessageOptions } from "@/common/orpc/types"; +import type { PostCompactionAttachment } from "@/common/types/attachment"; +import type { MuxMessage, MuxMessageMetadata } from "@/common/types/message"; +import type { MuxProviderOptions } from "@/common/types/providerOptions"; +import type { ErrorEvent } from "@/common/types/stream"; +import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; +import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; +import type { FileState } from "@/node/services/agentSession"; +import type { MemorySessionContext } from "@/node/services/memoryService"; +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; +} From 28ed2cea9aa031983d9bf61fec95b0975a03e976 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:53:59 +0000 Subject: [PATCH 02/22] refactor(init): inject initialization manager directly --- src/node/orpc/context.ts | 2 ++ src/node/services/serviceContainer.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index f6a5df41ee..9375846ee7 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -3,6 +3,7 @@ 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 { ProjectService } from "@/node/services/projectService"; import type { WorkspaceService } from "@/node/services/workspaceService"; import type { MuxGatewayOauthService } from "@/node/services/muxGatewayOauthService"; @@ -56,6 +57,7 @@ export interface ORPCContext { config: Config; aiService: AIService; historyService: HistoryService; + initStateManager: InitStateManager; projectService: ProjectService; workspaceService: WorkspaceService; taskService: TaskService; diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 6de3a579ae..ee39a36f6c 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -92,6 +92,7 @@ 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 initStateManager: CoreServices["initStateManager"]; public readonly workspaceService: CoreServices["workspaceService"]; public readonly taskService: CoreServices["taskService"]; public readonly providerService: CoreServices["providerService"]; @@ -190,6 +191,7 @@ export class ServiceContainer { // Spread core services into class fields this.historyService = core.historyService; this.aiService = core.aiService; + this.initStateManager = core.initStateManager; this.aiService.setAnalyticsService(this.analyticsService); this.browserSessionDiscoveryService = new AgentBrowserSessionDiscoveryService({ resolveWorkspaceCandidatePathsFn: async (workspaceId: string) => { @@ -629,6 +631,7 @@ export class ServiceContainer { config: this.config, aiService: this.aiService, historyService: this.historyService, + initStateManager: this.initStateManager, projectService: this.projectService, workspaceService: this.workspaceService, taskService: this.taskService, From 2d387dad1fd609023d58eac6fa62d3ac77f2b30a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:59:09 +0000 Subject: [PATCH 03/22] refactor(stream): own pre-start lifecycle in stream manager --- src/node/services/streamManager.ts | 119 +++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 16 deletions(-) diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 6b5df209fb..bce5e6f66b 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -782,8 +782,30 @@ function nextPartTimestamp(streamInfo: WorkspaceStreamInfo): number { * - Atomic stream creation/cancellation operations * - Guaranteed resource cleanup in all code paths */ +export interface PendingStreamStartHandle { + readonly abortSignal: AbortSignal; + readonly syntheticMessageId: string; + finish(): void; +} + +export 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; @@ -851,6 +873,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, @@ -4992,23 +5052,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 +5097,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 +5113,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 +5130,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 +5191,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); From 00b9ee073268204aca950922c4936b32923c7a52 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:59:48 +0000 Subject: [PATCH 04/22] test(stream): cover pre-start lifecycle ownership --- src/node/services/streamManager.test.ts | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 485348b7bd..5fbe8ed6af 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -5636,6 +5636,54 @@ 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).toEqual([ + expect.objectContaining({ + 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); From d2814796a990f7719e880c555efb0090f1e3a88d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:06:27 +0000 Subject: [PATCH 05/22] refactor(ai): extract turn request builder orchestration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with xum • Model: openai:gpt-5.6-sol • Thinking: high • Cost: .41 --- src/node/services/aiService.ts | 3154 +-------------------- src/node/services/turnRequestBuilder.ts | 3421 +++++++++++++++++++++++ 2 files changed, 3511 insertions(+), 3064 deletions(-) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index dc84862846..e4ff96af1f 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -12,7 +12,8 @@ import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import type { SendMessageOptions, ProvidersConfigMap } from "@/common/orpc/types"; -import type { StreamMessageOptions } from "./turnRequestBuilder"; +import { TurnRequestBuilder, type StreamMessageOptions } from "./turnRequestBuilder"; +export { prepareProviderRequestMessages, replaceOrAppendMessageById } from "./turnRequestBuilder"; export type { StreamMessageOptions } from "./turnRequestBuilder"; import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; @@ -215,133 +216,6 @@ 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 -// --------------------------------------------------------------------------- - -/** - * 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); -} - -function waitForWorkflowContinuationRetry(): Promise { - return new Promise((resolve) => setTimeout(resolve, WORKFLOW_CONTINUATION_RETRY_DELAY_MS)); -} - -interface ToolExecutionContext { - toolCallId?: string; - abortSignal?: AbortSignal; -} - -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 @@ -388,92 +262,9 @@ function resolveXumToolScope( }; } -/** - * 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>; -} - 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; @@ -568,6 +359,53 @@ export class AIService extends EventEmitter { undefined, 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, + lateBound: { + mcpServerManager: () => this.mcpServerManager, + taskService: () => this.taskService, + memoryService: () => this.memoryService, + timelineService: () => this.timelineService, + extraTools: () => this.extraTools, + onWorkflowRunStatusChanged: () => this.onWorkflowRunStatusChanged, + workflowResultContinuationSender: () => this.workflowResultContinuationSender, + workspaceHeartbeatService: () => this.workspaceHeartbeatService, + analyticsService: () => this.analyticsService, + desktopSessionManager: () => this.desktopSessionManager, + }, + 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.isStreaming(workspaceId), + trackPendingDevToolsRunMetadata: (messageId, workspaceId, metadataId) => + this.trackPendingDevToolsRunMetadata(messageId, workspaceId, metadataId), + }); void this.ensureSessionsDir(); this.mockModeEnabled = false; @@ -1232,72 +1070,30 @@ export class AIService extends EventEmitter { async streamMessage( opts: StreamMessageOptions ): Promise> { - const { - messages, - 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 { messages, workspaceId, modelString, thinkingLevel, abortSignal, agentId, muxMetadata } = + opts; 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 syntheticMessageId = + "starting-" + startTime + "-" + Math.random().toString(36).substring(2, 11); 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 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) { @@ -1305,9 +1101,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, @@ -1321,2832 +1114,65 @@ 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, - }); - } - 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); - - // 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 }); - } - - 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", - }); - } - } - 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", - workspaceId, - phase: breadcrumb.phase, - runtimeType: metadata.runtimeConfig.type, - source: "startup", - detail: breadcrumb.detail, - }); - }; - - const runtimeContextResult = this.createWorkspaceRuntimeContext(workspaceId, metadata); - if (!runtimeContextResult.success) { - return runtimeContextResult; - } - 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.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, + const buildOutcome = await this.turnRequestBuilder.build(opts, { + abortSignal: combinedAbortSignal, + syntheticMessageId, + startupState, + recordStartupPhaseTiming, }); - - // 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 }); + if (buildOutcome.type === "finished") { + return buildOutcome.result; } - // 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); + const streamResult = await this.streamManager.startStream(buildOutcome.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; + if (startupState.pendingRunMetadataId != null) { + this.clearTrackedPendingDevToolsRunMetadata(buildOutcome.assistantMessageId); + startupState.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 + buildOutcome.logStartOutcome("stream_start_failed", streamResult.error.type); 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; + if (startupState.pendingRunMetadataId != null) { + this.clearTrackedPendingDevToolsRunMetadata(buildOutcome.assistantMessageId); + startupState.pendingRunMetadataId = null; } - await deleteAbortedPlaceholder(assistantMessageId); + await buildOutcome.deleteAbortedPlaceholder(buildOutcome.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. + buildOutcome.logStartOutcome("started"); return Ok(streamResult.data); } catch (error) { - if (pendingRunMetadataId != null) { - this.clearTrackedPendingDevToolsRunMetadataById(workspaceId, pendingRunMetadataId); - pendingRunMetadataId = null; + if (startupState.pendingRunMetadataId != null) { + this.clearTrackedPendingDevToolsRunMetadataById( + workspaceId, + startupState.pendingRunMetadataId + ); + startupState.pendingRunMetadataId = null; } - const errorMessage = getErrorMessage(error); - logSlowStreamStartup?.({ - outcome: "error", - errorMessage, - }); + startupState.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}` }); + return Err({ type: "unknown", raw: "Failed to stream message: " + errorMessage }); } finally { unlinkAbortSignal(); const pending = this.pendingStreamStarts.get(workspaceId); diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 127e02a15b..3009790a79 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -1,3 +1,212 @@ +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 type { Result } from "@/common/types/result"; +import { Ok, Err } 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 { + ADVISOR_DEFAULT_MAX_USES_PER_TURN, + resolveAdvisorEnabledForAgent, +} from "@/common/constants/advisor"; +import { EXPERIMENT_IDS, type ExperimentId } from "@/common/constants/experiments"; + +import type { GoalRecordV1 } from "@/common/types/goal"; +import type { ModelMessage, MuxMessage } 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 { cloneToolPreservingDescriptors } from "@/common/utils/tools/cloneToolPreservingDescriptors"; +import { createRuntime } from "@/node/runtime/runtimeFactory"; +import { agentPluginHookService } from "@/node/services/agentPlugins/hookService"; +import { resolveAgentPluginsMcpContext } from "@/node/services/agentPlugins/mcpConfig"; +import { + createRuntimeContextForWorkspace, + createRuntimeForWorkspace, + resolveWorkspaceExecutionPath, + 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 { PolicyService } from "@/node/services/policyService"; +import type { ProviderService } from "@/node/services/providerService"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import { log } from "./log"; +import { + addInterruptedSentinel, + filterEmptyAssistantMessages, +} from "@/browser/utils/messages/modelMessageTransform"; + +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 { 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 { + 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 ThinkingLevel } from "@/common/types/thinking"; +import { + enforceThinkingPolicy, + isXaiGrokFastVariantSwap, + lookupMinThinkingLevelOverride, + resolveEffectiveThinkingLevel, + resolveMinimumThinkingLevel, +} from "@/common/utils/thinking/policy"; +import type { + RebuildFirstStepForThinkingLevel, + RebuildProviderOptionsForThinkingLevel, +} from "@/node/services/thinkingOverride"; + +import type { StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; +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 { 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 { 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 { 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; + import type { SendMessageOptions } from "@/common/orpc/types"; import type { PostCompactionAttachment } from "@/common/types/attachment"; import type { MuxMessage, MuxMessageMetadata } from "@/common/types/message"; @@ -73,3 +282,3215 @@ export interface StreamMessageOptions { */ 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; +} + +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); +} + +function waitForWorkflowContinuationRetry(): Promise { + return new Promise((resolve) => setTimeout(resolve, WORKFLOW_CONTINUATION_RETRY_DELAY_MS)); +} + +interface ToolExecutionContext { + toolCallId?: string; + abortSignal?: AbortSignal; +} + +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; +} + +/** + +/** + * 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>; +} + +export interface TurnRequestBuildStartupState { + pendingRunMetadataId: string | null; + logSlowStreamStartup?: (details: Record) => void; +} + +export interface TurnRequestBuildContext { + abortSignal: AbortSignal; + syntheticMessageId: string; + startupState: TurnRequestBuildStartupState; + recordStartupPhaseTiming: (phase: string, phaseStartedAt: number) => void; +} + +export 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; + }; + +interface TurnRequestBuilderLateBoundDependencies { + mcpServerManager: () => MCPServerManager | undefined; + taskService: () => TaskService | undefined; + memoryService: () => MemoryService | undefined; + timelineService: () => ToolConfiguration["timelineService"]; + extraTools: () => Record | undefined; + onWorkflowRunStatusChanged: () => + | ((event: WorkflowRunStatusChangedEvent) => Promise | void) + | undefined; + workflowResultContinuationSender: () => WorkflowResultContinuationSender | undefined; + workspaceHeartbeatService: () => ToolConfiguration["workspaceHeartbeatService"]; + analyticsService: () => { executeRawQuery(sql: string): Promise } | undefined; + desktopSessionManager: () => DesktopSessionManager | undefined; +} + +export 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; + lateBound: TurnRequestBuilderLateBoundDependencies; + 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 class TurnRequestBuilder { + constructor(private readonly dependencies: TurnRequestBuilderDependencies) {} + + private get config(): Config { + return this.dependencies.config; + } + private get historyService(): HistoryService { + return this.dependencies.historyService; + } + private get initStateManager(): InitStateManager { + return this.dependencies.initStateManager; + } + private get providerService(): ProviderService { + return this.dependencies.providerService; + } + private get providerModelFactory(): ProviderModelFactory { + return this.dependencies.providerModelFactory; + } + private get streamManager(): StreamManager { + return this.dependencies.streamManager; + } + private get workspaceMcpOverridesService(): WorkspaceMcpOverridesService { + return this.dependencies.workspaceMcpOverridesService; + } + private get policyService(): PolicyService | undefined { + return this.dependencies.policyService; + } + private get telemetryService(): TelemetryService | undefined { + return this.dependencies.telemetryService; + } + private get backgroundProcessManager(): BackgroundProcessManager | undefined { + return this.dependencies.backgroundProcessManager; + } + private get sessionUsageService(): SessionUsageService | undefined { + return this.dependencies.sessionUsageService; + } + private get devToolsService(): DevToolsService | undefined { + return this.dependencies.devToolsService; + } + private get experimentsService(): ExperimentsService | undefined { + return this.dependencies.experimentsService; + } + private get lastLlmRequestByWorkspace(): Map { + return this.dependencies.lastLlmRequestByWorkspace; + } + private get mcpServerManager(): MCPServerManager | undefined { + return this.dependencies.lateBound.mcpServerManager(); + } + private get taskService(): TaskService | undefined { + return this.dependencies.lateBound.taskService(); + } + private get memoryService(): MemoryService | undefined { + return this.dependencies.lateBound.memoryService(); + } + private get timelineService(): ToolConfiguration["timelineService"] { + return this.dependencies.lateBound.timelineService(); + } + private get extraTools(): Record | undefined { + return this.dependencies.lateBound.extraTools(); + } + private get onWorkflowRunStatusChanged(): + | ((event: WorkflowRunStatusChangedEvent) => Promise | void) + | undefined { + return this.dependencies.lateBound.onWorkflowRunStatusChanged(); + } + private get workflowResultContinuationSender(): WorkflowResultContinuationSender | undefined { + return this.dependencies.lateBound.workflowResultContinuationSender(); + } + private get workspaceHeartbeatService(): ToolConfiguration["workspaceHeartbeatService"] { + return this.dependencies.lateBound.workspaceHeartbeatService(); + } + private get analyticsService(): { executeRawQuery(sql: string): Promise } | undefined { + return this.dependencies.lateBound.analyticsService(); + } + private get desktopSessionManager(): DesktopSessionManager | undefined { + return this.dependencies.lateBound.desktopSessionManager(); + } + + private emit(event: string, ...args: unknown[]): boolean { + return this.dependencies.emit(event, ...args); + } + private createAbortedTurnHandle(messageId: string): TurnStreamHandle { + return this.dependencies.createAbortedTurnHandle(messageId); + } + private createSettledTurnHandle(messageId: string, completion: TurnCompletion): TurnStreamHandle { + return this.dependencies.createSettledTurnHandle(messageId, completion); + } + private getWorkspaceMetadata(workspaceId: string): Promise> { + return this.dependencies.getWorkspaceMetadata(workspaceId); + } + private createWorkspaceRuntimeContext(workspaceId: string, metadata: WorkspaceMetadata) { + return this.dependencies.createWorkspaceRuntimeContext(workspaceId, metadata); + } + private isClaudeSkillsCompatEnabled(): boolean { + return this.dependencies.isClaudeSkillsCompatEnabled(); + } + private isAgentPluginsEnabled(): boolean { + return this.dependencies.isAgentPluginsEnabled(); + } + private wrapToolsForDelegation( + workspaceId: string, + tools: Record, + delegatedToolNames?: string[] + ): Record { + return this.dependencies.wrapToolsForDelegation(workspaceId, tools, delegatedToolNames); + } + private durableEventJournalFor(workspaceId: string): DurableEventJournal { + return this.dependencies.durableEventJournalFor(workspaceId); + } + private shouldAllowLegacyInvalidWorkflowAgentOutputSchema( + metadata: WorkspaceMetadata + ): Promise { + return this.dependencies.shouldAllowLegacyInvalidWorkflowAgentOutputSchema(metadata); + } + private createModel( + modelString: string, + muxProviderOptions?: MuxProviderOptions, + opts?: { agentInitiated?: boolean; workspaceId?: string; providersConfig?: ProvidersConfig } + ) { + return this.dependencies.createModel(modelString, muxProviderOptions, opts); + } + private isStreaming(workspaceId: string): boolean { + return this.dependencies.isStreaming(workspaceId); + } + private trackPendingDevToolsRunMetadata( + messageId: string, + workspaceId: string, + metadataId: string + ): void { + this.dependencies.trackPendingDevToolsRunMetadata(messageId, workspaceId, metadataId); + } + + 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.experimentsService?.isExperimentEnabled(experimentId) === true + ); + const combinedAbortSignal = context.abortSignal; + const syntheticMessageId = context.syntheticMessageId; + const recordStartupPhaseTiming = context.recordStartupPhaseTiming; + let pendingRunMetadataId: string | null = context.startupState.pendingRunMetadataId; + let logSlowStreamStartup: ((details: Record) => void) | undefined; + + 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 { type: "finished", result: 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, + }); + } + 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); + + // Get workspace metadata to retrieve workspace path + const getWorkspaceMetadataStartedAt = Date.now(); + const metadataResult = await this.getWorkspaceMetadata(workspaceId); + recordStartupPhaseTiming("getWorkspaceMetadataMs", getWorkspaceMetadataStartedAt); + if (!metadataResult.success) { + return { type: "finished", result: Err({ type: "unknown", raw: metadataResult.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", + }); + } + } + 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", + workspaceId, + phase: breadcrumb.phase, + runtimeType: metadata.runtimeConfig.type, + source: "startup", + detail: breadcrumb.detail, + }); + }; + + const runtimeContextResult = this.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.initStateManager.waitForInit(workspaceId, combinedAbortSignal); + recordStartupPhaseTiming("waitForInitMs", waitForInitStartedAt); + if (combinedAbortSignal.aborted) { + return { type: "finished", result: 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 { 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.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 { type: "finished", result: 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 { type: "finished", result: 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 { + type: "finished", + result: 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 { type: "finished", result: 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); + context.startupState.pendingRunMetadataId = pendingRunMetadataId; + 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; + }; + 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 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, + }; + } +} From 1fabe8f28a16b9959f673f41169b316359bde866 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:12:19 +0000 Subject: [PATCH 06/22] refactor(ai): share model attempt preparation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with xum • Model: openai:gpt-5.6-sol • Thinking: high • Cost: .41 --- src/node/services/turnRequestBuilder.ts | 570 ++++++++++-------------- 1 file changed, 230 insertions(+), 340 deletions(-) diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 3009790a79..6b74d6dc9d 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -587,6 +587,39 @@ export interface TurnRequestBuilderDependencies { ) => void; } +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) {} @@ -719,6 +752,158 @@ export class TurnRequestBuilder { this.dependencies.trackPendingDevToolsRunMetadata(messageId, workspaceId, metadataId); } + 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, + }; + } + + private prepareModelAttempt(options: PrepareModelAttemptOptions): PreparedModelAttempt { + const buildProviderOptionsStartedAt = Date.now(); + const providerOptions = buildProviderOptions( + options.optionsModelString, + options.effectiveThinkingLevel, + options.providerRequestMessages, + (id) => this.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.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.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 @@ -2398,233 +2583,38 @@ export class TurnRequestBuilder { }; } - // 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( + const minThinkingLevel = + providedMinThinkingLevel ?? + resolveMinimumThinkingLevel(modelString, undefined, requestProvidersConfig); + const preparedModelAttempt = this.prepareModelAttempt({ + rawModelString: modelString, + canonicalModelString, + canonicalProviderName, + effectiveModelString, optionsModelString, + wireProviderName, + routeProvider, effectiveThinkingLevel, + minThinkingLevel, providerRequestMessages, - (id) => this.streamManager.isResponseIdLost(id), - effectiveMuxProviderOptions, + muxProviderOptions: effectiveMuxProviderOptions, workspaceId, truncationMode, - requestProvidersConfig, - routeProvider, + providersConfigSnapshot: requestProvidersConfig, + coderSelectedInstance: modelResult.data.coderSelectedInstance, 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; - }; - + reasoningMode, + recordStartupPhaseTiming, + }); + let requestHeaders = preparedModelAttempt.requestHeaders; + const mergedProviderOptions = preparedModelAttempt.providerOptions; + const resolvedOverrides = preparedModelAttempt.resolvedOverrides; + const currentEffectiveLevelRef = preparedModelAttempt.currentEffectiveLevelRef; + const computeRebuiltProviderOptions = preparedModelAttempt.computeRebuiltProviderOptions; + const rebuildProviderOptionsForThinkingLevel = + preparedModelAttempt.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( @@ -3015,136 +3005,36 @@ export class TurnRequestBuilder { workspaceId, }); - const nextProviderOptions = buildProviderOptions( - nextOptionsModelString, - nextThinkingLevel, - nextProviderRequestMessages, - (id) => this.streamManager.isResponseIdLost(id), - effectiveMuxProviderOptions, + const preparedFallbackAttempt = this.prepareModelAttempt({ + rawModelString: nextModelString, + canonicalModelString: next.canonicalModelString, + canonicalProviderName: next.canonicalProviderName, + effectiveModelString: next.effectiveModelString, + optionsModelString: nextOptionsModelString, + wireProviderName: next.wireProviderName, + routeProvider: next.routeProvider, + effectiveThinkingLevel: nextThinkingLevel, + minThinkingLevel: nextMinThinkingLevel, + providerRequestMessages: nextProviderRequestMessages, + muxProviderOptions: effectiveMuxProviderOptions, workspaceId, truncationMode, - nextProvidersConfig, - next.routeProvider, + providersConfigSnapshot: nextProvidersConfig, + coderSelectedInstance: next.coderSelectedInstance, 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 - ); + reasoningMode, + }); + let nextHeaders = preparedFallbackAttempt.requestHeaders; 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 }; - }; - + const nextMergedProviderOptions = preparedFallbackAttempt.providerOptions; + const nextOverrides = preparedFallbackAttempt.resolvedOverrides; + const rebuildNextProviderOptionsForThinkingLevel = + preparedFallbackAttempt.rebuildProviderOptionsForThinkingLevel; // Shared with the return payload below: the fallback stream // restarts at step 0, where StreamManager scopes to these // forced tools when present. From a5bdc647818f3df9286a52d853f5f93b0dcf480b Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:15:24 +0000 Subject: [PATCH 07/22] fix(ai): preserve builder early outcomes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with xum • Model: openai:gpt-5.6-sol • Thinking: high • Cost: .41 --- src/node/services/turnRequestBuilder.ts | 31 ++++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 6b74d6dc9d..4c8834a214 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -1204,10 +1204,13 @@ export class TurnRequestBuilder { if (this.policyService?.isEnforced()) { if (!this.policyService.isRuntimeAllowed(metadata.runtimeConfig)) { - return Err({ - type: "policy_denied", - message: "Workspace runtime is not allowed by policy", - }); + return { + type: "finished", + result: Err({ + type: "policy_denied", + message: "Workspace runtime is not allowed by policy", + }), + }; } } const workspaceLog = log.withFields({ workspaceId, workspaceName: metadata.name }); @@ -1350,10 +1353,13 @@ export class TurnRequestBuilder { errorMessage, }); - return Err({ - type: errorType, - message: errorMessage, - }); + return { + type: "finished", + result: Err({ + type: errorType, + message: errorMessage, + }), + }; } // Memory context (memory experiment): resolved only after ensureReady so @@ -2572,9 +2578,12 @@ export class TurnRequestBuilder { // 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 }) - ); + return { + type: "finished", + result: Ok( + this.createSettledTurnHandle(assistantMessageId, { status: "failed", streamError }) + ), + }; } await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); return { From ad84e0b67abd65dc87022e6d060baa34477e1b3f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:18:16 +0000 Subject: [PATCH 08/22] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20narrow=20Agent?= =?UTF-8?q?Session=20AI=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Generated with `xum` • Model: `openai:gpt-5.6-sol` • Thinking: `high`_ --- .../agentSession.startupAutoRetry.test.ts | 30 +++++--- src/node/services/agentSession.testHarness.ts | 49 ++++++++----- src/node/services/agentSession.ts | 69 +++++++++++++++---- 3 files changed, 103 insertions(+), 45 deletions(-) diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index a72fb1a03d..60a78e1496 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; @@ -1553,7 +1561,7 @@ describe("AgentSession startup auto-retry recovery", () => { isStreaming: mock(() => false), 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 6bdff8ef9a..f4f8ffd76b 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -4,9 +4,8 @@ import { EventEmitter } from "events"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; import { 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,44 @@ function createMockInitStateManager(overrides?: Partial): Init return Object.assign(new EventEmitter(), overrides) as unknown as InitStateManager; } -function createMockAiService(args?: { emitter?: EventEmitter; overrides?: Partial }): { +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, { + createModelWithPinnedMetadata: mock(() => + Promise.reject(new Error("Test AI service cannot create models")) + ), + getWorkspaceMetadata: mock(() => + Promise.reject(new Error("Test AI service has no workspace metadata")) + ), + getProvidersConfig: mock(() => null), + isExperimentEnabled: mock((_experimentId) => false), + 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() + ), + 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 +108,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 df8c15c59f..462b87b376 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,46 @@ export interface AgentSessionMetadataEvent { metadata: FrontendWorkspaceMetadata | null; } +interface AgentSessionActiveStreamInfo { + messageId: string; + startTime?: number; + parts: Array<{ timestamp?: number; workflowRun?: { timestamp?: number } }>; + toolCompletionTimestamps: Map; +} + +/** 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; mcpServerManager?: MCPServerManager; initStateManager: InitStateManager; telemetryService?: TelemetryService; @@ -596,7 +633,7 @@ 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 mcpServerManager?: MCPServerManager; private readonly initStateManager: InitStateManager; private readonly backgroundProcessManager: BackgroundProcessManager; @@ -933,7 +970,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,7 +1527,7 @@ export class AgentSession { } private isAiStreaming(): boolean { - const aiService = this.aiService as Partial>; + const aiService = this.aiService as Partial>; if (typeof aiService.isStreaming !== "function") { return false; } @@ -1791,7 +1828,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 +2322,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 }); @@ -4235,7 +4274,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") { @@ -5718,7 +5757,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 +6204,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 { From 0c4a00d04595027e8c4a685776898cc953ee9131 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:44:28 +0000 Subject: [PATCH 09/22] refactor(ai): share per-model request preparation --- src/node/services/aiService.ts | 285 +--- src/node/services/turnRequestBuilder.ts | 1616 +++++++++-------------- 2 files changed, 692 insertions(+), 1209 deletions(-) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index e4ff96af1f..3486f26bc2 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1,62 +1,41 @@ -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 { TurnRequestBuilder, type StreamMessageOptions } from "./turnRequestBuilder"; +import { linkAbortSignal } from "@/node/utils/abort"; +import { ensurePrivateDir } from "@/node/utils/fs"; +import { + TurnRequestBuilder, + resolveMuxProjectRootForHostFs, + resolveXumToolScope, + type StreamMessageOptions, + type WorkflowResultContinuationSender, +} from "./turnRequestBuilder"; export { prepareProviderRequestMessages, replaceOrAppendMessageById } from "./turnRequestBuilder"; export type { StreamMessageOptions } from "./turnRequestBuilder"; -import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; -import { - ADVISOR_DEFAULT_MAX_USES_PER_TURN, - resolveAdvisorEnabledForAgent, -} from "@/common/constants/advisor"; 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 } 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 { MuxMessage } from "@/common/types/message"; +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 ToolConfiguration } from "@/common/utils/tools/tools"; +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, @@ -64,202 +43,72 @@ 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 { CoderOauthService } from "@/node/services/coderOauthService"; +import type { CodexOauthService } from "@/node/services/codexOauthService"; 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 { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +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"; + 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 { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; 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 type { MCPServerManager } from "@/node/services/mcpServerManager"; +import { formatHotMemoriesBlock } from "@/node/services/memoryHotSet"; 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 ThinkingLevel } from "@/common/types/thinking"; -import { - enforceThinkingPolicy, - isXaiGrokFastVariantSwap, - lookupMinThinkingLevelOverride, - resolveEffectiveThinkingLevel, - resolveMinimumThinkingLevel, -} from "@/common/utils/thinking/policy"; -import type { - RebuildFirstStepForThinkingLevel, - RebuildProviderOptionsForThinkingLevel, -} from "@/node/services/thinkingOverride"; +import type { TaskService } from "@/node/services/taskService"; +import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; import type { StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; -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 { 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; - -/** - * 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; +import { type WorkflowRunStatusChangedEvent } from "@/node/services/workflows/WorkflowService"; +import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; +import { MockAiStreamPlayer } from "./mock/mockAiStreamPlayer"; +import { ProviderModelFactory } from "./providerModelFactory"; + +export { resolveMuxProjectRootForHostFs }; + +interface ToolExecutionContext { + toolCallId?: string; + abortSignal?: AbortSignal; } -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, - }; +function isToolExecutionContext(value: unknown): value is ToolExecutionContext { + if (typeof value !== "object" || value == null || Array.isArray(value)) { + return false; } - - 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 } : {}), - }; + const record = value as Record; + return ( + (record.toolCallId == null || typeof record.toolCallId === "string") && + (record.abortSignal == null || record.abortSignal instanceof AbortSignal) + ); } export class AIService extends EventEmitter { @@ -1131,6 +980,8 @@ export class AIService extends EventEmitter { const buildOutcome = await this.turnRequestBuilder.build(opts, { abortSignal: combinedAbortSignal, syntheticMessageId, + startTime, + startupPhaseTimingsMs, startupState, recordStartupPhaseTiming, }); diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 4c8834a214..68525f2f59 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -1,43 +1,32 @@ -import * as fs from "fs/promises"; - 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 { projectAutomationDisabled } from "@/node/utils/projectAutomation"; +import type { ProvidersConfigMap, SendMessageOptions } 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 { projectAutomationDisabled } from "@/node/utils/projectAutomation"; -import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; import { ADVISOR_DEFAULT_MAX_USES_PER_TURN, resolveAdvisorEnabledForAgent, } from "@/common/constants/advisor"; -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 } 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"; + 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, @@ -46,99 +35,88 @@ import { type MCPPromptRuntime, type ToolConfiguration, } from "@/common/utils/tools/tools"; -import { getGoalToolAvailability } from "@/common/utils/tools/toolAvailability"; -import { cloneToolPreservingDescriptors } from "@/common/utils/tools/cloneToolPreservingDescriptors"; -import { createRuntime } from "@/node/runtime/runtimeFactory"; +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 { - createRuntimeContextForWorkspace, - createRuntimeForWorkspace, - resolveWorkspaceExecutionPath, - resolveWorkspaceRootPath, - type WorkspaceRuntimeContext, -} from "@/node/runtime/runtimeHelpers"; -import type { Runtime } from "@/node/runtime/Runtime"; -import { getWorkspacePathHintForProject } from "@/node/services/workspaceProjectRepos"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; 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 { PolicyService } from "@/node/services/policyService"; import type { ProviderService } from "@/node/services/providerService"; -import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +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 { - addInterruptedSentinel, - filterEmptyAssistantMessages, -} from "@/browser/utils/messages/modelMessageTransform"; + type ModelFallbackOptions, + type StreamTextOnChunk, + type TurnCompletion, + type TurnExecutionOptions, + type TurnStreamHandle, +} from "./streamManager"; +import { emitTurnEnvelope } from "./turnEnvelope"; -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 { 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 { 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 { 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 { 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 { - resolveMemoryProjectIdentity, - type MemoryService, - type MemorySessionContext, -} from "@/node/services/memoryService"; -import { formatHotMemoriesBlock } from "@/node/services/memoryHotSet"; -import { resolveMemoryAccessPolicy } from "@/node/services/tools/memory"; + 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 { resolveModelParameterOverrides } from "@/common/utils/ai/modelParameterOverrides"; -import type { ProvidersConfig } from "@/common/config/schemas/providersConfig"; +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 { - 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 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 { 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 ThinkingLevel } from "@/common/types/thinking"; +import { + THINKING_LEVEL_OFF, + type OpenAIReasoningMode, + type ThinkingLevel, +} from "@/common/types/thinking"; +import { mergeGoalDefaults } from "@/common/utils/goals/resolveGoalSetIntent"; import { enforceThinkingPolicy, isXaiGrokFastVariantSwap, @@ -146,12 +124,18 @@ import { resolveEffectiveThinkingLevel, resolveMinimumThinkingLevel, } from "@/common/utils/thinking/policy"; +import { DEFAULT_GOAL_DEFAULTS, normalizeGoalDefaults } from "@/constants/goals"; import type { RebuildFirstStepForThinkingLevel, RebuildProviderOptionsForThinkingLevel, } from "@/node/services/thinkingOverride"; -import type { StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; +import { isTerminalWorkflowRunStatus } from "@/common/types/workflow"; +import { getErrorMessage } from "@/common/utils/errors"; +import { + normalizeUsageModelKey, + resolveModelForMetadata, +} from "@/common/utils/providers/modelEntries"; import { computeActiveToolNames, prepareToolSearch, @@ -160,39 +144,18 @@ import { TOOL_SEARCH_TOOL_NAME, type ToolSearchRuntime, } from "@/common/utils/tools/toolCatalog"; -import type { PTCEventWithParent } from "@/node/services/tools/code_execution"; -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 { getErrorMessage } from "@/common/utils/errors"; -import { validateJsonSchemaSubsetSchema } from "@/common/utils/jsonSchemaSubset"; -import { isTerminalWorkflowRunStatus } from "@/common/types/workflow"; import { - WORKFLOW_RESULT_METADATA_TYPE, 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, @@ -201,21 +164,63 @@ 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 { 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 { 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; -import type { SendMessageOptions } from "@/common/orpc/types"; +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 { MuxMessage, MuxMessageMetadata } from "@/common/types/message"; -import type { MuxProviderOptions } from "@/common/types/providerOptions"; import type { ErrorEvent } from "@/common/types/stream"; -import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { FileState } from "@/node/services/agentSession"; -import type { MemorySessionContext } from "@/node/services/memoryService"; import type { ActiveTurnThinkingOverride } from "@/node/services/thinkingOverride"; import type { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; @@ -390,28 +395,6 @@ function waitForWorkflowContinuationRetry(): Promise { return new Promise((resolve) => setTimeout(resolve, WORKFLOW_CONTINUATION_RETRY_DELAY_MS)); } -interface ToolExecutionContext { - toolCallId?: string; - abortSignal?: AbortSignal; -} - -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; -} - -/** - /** * Pin the factory-resolved Coder instance type into a providers-config view. * @@ -479,7 +462,7 @@ function derivePromptCacheScope(metadata: WorkspaceMetadata): string { return `${metadata.projectName}-${uniqueSuffix([metadata.projectPath])}`; } -interface WorkflowResultContinuationSender { +export interface WorkflowResultContinuationSender { isWorkflowInvocationCurrent(workspaceId: string, runId: string): Promise; sendMessage( workspaceId: string, @@ -504,6 +487,8 @@ export interface TurnRequestBuildStartupState { export interface TurnRequestBuildContext { abortSignal: AbortSignal; syntheticMessageId: string; + startTime: number; + startupPhaseTimingsMs: Record; startupState: TurnRequestBuildStartupState; recordStartupPhaseTiming: (phase: string, phaseStartedAt: number) => void; } @@ -944,6 +929,8 @@ export class TurnRequestBuilder { ); 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; let logSlowStreamStartup: ((details: Record) => void) | undefined; @@ -961,86 +948,8 @@ export class TurnRequestBuilder { }; // 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() - ); + const userOpenAIWireFormat = effectiveMuxProviderOptions.openai?.wireFormat; - // 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 { type: "finished", result: 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, @@ -1123,41 +1032,135 @@ export class TurnRequestBuilder { : {}), }; }; - 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, - }; + 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.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.providerModelFactory.resolveAndCreateModel( + options.rawModelString, + preliminaryThinkingLevel, + effectiveMuxProviderOptions, + { agentInitiated, workspaceId } + ); + if (options.recordTiming) { + recordStartupPhaseTiming("resolveAndCreateModelMs", resolveAndCreateModelStartedAt); + } + if (!resolved.success) { + return resolved; + } + + const providersConfig = pinCoderInstanceProvidersConfig( + this.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; // Dump original messages for debugging log.debug_obj(`${workspaceId}/1_original_messages.json`, messages); @@ -1738,7 +1741,6 @@ export class TurnRequestBuilder { // 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" @@ -2307,208 +2309,327 @@ export class TurnRequestBuilder { // 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); + 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"; } - // 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); - } - } + 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.initStateManager, + toolInstructions, + mcpTools + ); + if (options.recordTimings) { + recordStartupPhaseTiming("getToolsForModelMs", getToolsStartedAt); + } - // 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); - } + const applyPolicyStartedAt = Date.now(); + let attemptTools = await applyToolPolicyAndExperiments({ + allTools: this.wrapToolsForDelegation(workspaceId, allTools, delegatedToolNames), + extraTools: this.extraTools, + effectiveToolPolicy, + experiments, + emitNestedToolEvent: emitNestedPtcToolEvent, + sandbox: { + workspaceId, + sessionDir: this.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 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, + }); + 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.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; + } + }; - // 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 requestHistorySequence = providerRequestMessages.reduce( + (latest, message) => Math.max(latest, message.metadata?.historySequence ?? -1), + -1 + ); 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, + const primaryRequest = await prepareModelRequest({ + seed: modelResult.data, + sourceMessages: messages, + providerRequestMessages, + initializeToolSearch: true, + reusePrePolicySystemContext: true, + requestHistorySequence, + recordTimings: true, }); recordStartupPhaseTiming("prepareMessagesForProviderMs", prepareMessagesForProviderStartedAt); + const tools = primaryRequest.tools; + systemMessage = primaryRequest.system; + systemMessageTokens = primaryRequest.systemMessageTokens; + const finalMessages = primaryRequest.messages; captureMcpToolTelemetry({ telemetryService: this.telemetryService, @@ -2527,10 +2648,6 @@ export class TurnRequestBuilder { return { type: "finished", result: 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(), @@ -2592,38 +2709,13 @@ export class TurnRequestBuilder { }; } - const truncationMode = openaiTruncationModeOverride; - const promptCacheScope = derivePromptCacheScope(metadata); - const minThinkingLevel = - providedMinThinkingLevel ?? - resolveMinimumThinkingLevel(modelString, undefined, requestProvidersConfig); - const preparedModelAttempt = this.prepareModelAttempt({ - rawModelString: modelString, - canonicalModelString, - canonicalProviderName, - effectiveModelString, - optionsModelString, - wireProviderName, - routeProvider, - effectiveThinkingLevel, - minThinkingLevel, - providerRequestMessages, - muxProviderOptions: effectiveMuxProviderOptions, - workspaceId, - truncationMode, - providersConfigSnapshot: requestProvidersConfig, - coderSelectedInstance: modelResult.data.coderSelectedInstance, - promptCacheScope, - reasoningMode, - recordStartupPhaseTiming, - }); - let requestHeaders = preparedModelAttempt.requestHeaders; - const mergedProviderOptions = preparedModelAttempt.providerOptions; - const resolvedOverrides = preparedModelAttempt.resolvedOverrides; - const currentEffectiveLevelRef = preparedModelAttempt.currentEffectiveLevelRef; - const computeRebuiltProviderOptions = preparedModelAttempt.computeRebuiltProviderOptions; + let requestHeaders = primaryRequest.headers; + const mergedProviderOptions = primaryRequest.providerOptions; + const resolvedOverrides = primaryRequest.resolvedOverrides; + const currentEffectiveLevelRef = primaryRequest.currentEffectiveLevelRef; + const computeRebuiltProviderOptions = primaryRequest.computeRebuiltProviderOptions; const rebuildProviderOptionsForThinkingLevel = - preparedModelAttempt.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( @@ -2737,463 +2829,72 @@ export class TurnRequestBuilder { ? { chain: modelFallbackChain, prepare: async (nextModelString, prepareOptions) => { - const fallbackSourceMessages = prepareOptions?.continuation + const sourceMessages = 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 = + const requestedThinkingLevel = 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( + const nextSeedResult = await prepareModelSeed({ + rawModelString: nextModelString, + requestedThinkingLevel, + minimumThinkingLevelOverride: 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, - }; + enforceMinimum: true, + }); + if (!nextSeedResult.success) { + return Err(formatSendMessageError(nextSeedResult.error).message); } - 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 preparedFallbackAttempt = this.prepareModelAttempt({ - rawModelString: nextModelString, - canonicalModelString: next.canonicalModelString, - canonicalProviderName: next.canonicalProviderName, - effectiveModelString: next.effectiveModelString, - optionsModelString: nextOptionsModelString, - wireProviderName: next.wireProviderName, - routeProvider: next.routeProvider, - effectiveThinkingLevel: nextThinkingLevel, - minThinkingLevel: nextMinThinkingLevel, - providerRequestMessages: nextProviderRequestMessages, - muxProviderOptions: effectiveMuxProviderOptions, - workspaceId, - truncationMode, - providersConfigSnapshot: nextProvidersConfig, - coderSelectedInstance: next.coderSelectedInstance, - promptCacheScope, - reasoningMode, - }); - let nextHeaders = preparedFallbackAttempt.requestHeaders; - if (pendingRunMetadataId != null) { - nextHeaders = { - ...nextHeaders, - [DEVTOOLS_RUN_METADATA_ID_HEADER]: pendingRunMetadataId, - }; - } - const nextMergedProviderOptions = preparedFallbackAttempt.providerOptions; - const nextOverrides = preparedFallbackAttempt.resolvedOverrides; - const rebuildNextProviderOptionsForThinkingLevel = - preparedFallbackAttempt.rebuildProviderOptionsForThinkingLevel; - // 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 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, }; - 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; } + + 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 = - 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)) - ); + const forcedFirstStepToolNames = primaryRequest.forcedFirstStepToolNames; // Fold PREPARING-window pending thinking overrides into the ACTUAL // request build, not just the envelope: message preparation is @@ -3219,25 +2920,9 @@ export class TurnRequestBuilder { // re-check pending — a change may have raced the previous rebuild. continue; } - const { providerRequestMessages: foldedRequestMessages } = prepareProviderRequestMessages( - messages, - wireProviderName, + streamFinalMessages = await primaryRequest.rebuildMessagesForThinkingLevel( 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; @@ -3248,68 +2933,15 @@ export class TurnRequestBuilder { // 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. + primaryRequest.emitEnvelopeWith(streamThinkingLevel, streamProviderOptions); 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; + const rebuiltMessages = await primaryRequest.rebuildMessagesForThinkingLevel(effectiveLevel); + await primaryRequest.emitEnvelopeWith(effectiveLevel, providerOptions); + return rebuiltMessages; }; const turnExecutionOptions: TurnExecutionOptions = { workspaceId, From 2b50ba6738de7f999f34c413d2803676f6c85d04 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:49:00 +0000 Subject: [PATCH 10/22] fix(ai): preserve startup breadcrumbs --- src/node/services/turnRequestBuilder.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 68525f2f59..6c86ebddb0 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -2615,6 +2615,7 @@ export class TurnRequestBuilder { (latest, message) => Math.max(latest, message.metadata?.historySequence ?? -1), -1 ); + emitStartupBreadcrumb("preparing_request"); const prepareMessagesForProviderStartedAt = Date.now(); const primaryRequest = await prepareModelRequest({ seed: modelResult.data, @@ -2943,6 +2944,7 @@ export class TurnRequestBuilder { await primaryRequest.emitEnvelopeWith(effectiveLevel, providerOptions); return rebuiltMessages; }; + emitStartupBreadcrumb("starting_stream"); const turnExecutionOptions: TurnExecutionOptions = { workspaceId, messages: streamFinalMessages, From 1e81ef8ba16f74b319eb795225825379b25284d5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:51:35 +0000 Subject: [PATCH 11/22] refactor(stream): centralize startup lifecycle ownership --- src/node/services/aiService.ts | 90 +++++++----------------------- src/node/services/streamManager.ts | 6 +- 2 files changed, 24 insertions(+), 72 deletions(-) diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 3486f26bc2..85bc995ef5 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -9,7 +9,6 @@ import type { ProvidersConfigMap } 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 { linkAbortSignal } from "@/node/utils/abort"; import { ensurePrivateDir } from "@/node/utils/fs"; import { TurnRequestBuilder, @@ -83,7 +82,7 @@ import { import type { TaskService } from "@/node/services/taskService"; import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; -import type { StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; +import type { StreamAbortReason } from "@/common/types/stream"; import { getErrorMessage } from "@/common/utils/errors"; import { validateJsonSchemaSubsetSchema } from "@/common/utils/jsonSchemaSubset"; import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries"; @@ -130,18 +129,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. @@ -176,7 +163,8 @@ export class AIService extends EventEmitter { policyService?: PolicyService, telemetryService?: TelemetryService, devToolsService?: DevToolsService, - experimentsService?: ExperimentsService + experimentsService?: ExperimentsService, + streamManager?: StreamManager ) { super(); // Increase max listeners to accommodate multiple concurrent workspace listeners @@ -194,12 +182,12 @@ 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, @@ -251,7 +239,7 @@ export class AIService extends EventEmitter { this.shouldAllowLegacyInvalidWorkflowAgentOutputSchema(metadata), createModel: (modelString, providerOptions, options) => this.createModel(modelString, providerOptions, options), - isStreaming: (workspaceId) => this.isStreaming(workspaceId), + isStreaming: (workspaceId) => this.streamManager.isStreaming(workspaceId), trackPendingDevToolsRunMetadata: (messageId, workspaceId, metadataId) => this.trackPendingDevToolsRunMetadata(messageId, workspaceId, metadataId), }); @@ -593,6 +581,7 @@ export class AIService extends EventEmitter { aiService: this, historyService: this.historyService, }); + this.streamManager.setMockStreamLifecycle(this.mockAiStreamPlayer); } async getWorkspaceMetadata(workspaceId: string): Promise> { @@ -921,20 +910,15 @@ export class AIService extends EventEmitter { ): Promise> { const { messages, workspaceId, modelString, thinkingLevel, abortSignal, agentId, muxMetadata } = opts; - const pendingAbortController = new AbortController(); - const startTime = Date.now(); - const syntheticMessageId = - "starting-" + startTime + "-" + Math.random().toString(36).substring(2, 11); - const unlinkAbortSignal = linkAbortSignal(abortSignal, pendingAbortController); - - this.pendingStreamStarts.set(workspaceId, { - abortController: pendingAbortController, - startTime, - syntheticMessageId, + // Register before the first await so interrupts can cancel slow preparation. + const pendingStart = this.streamManager.beginStreamStart({ + workspaceId, + abortSignal, acpPromptId: opts.acpPromptId, }); - - const combinedAbortSignal = pendingAbortController.signal; + 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; @@ -1025,11 +1009,7 @@ export class AIService extends EventEmitter { log.error("Stream message error:", error); 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); - } + pendingStart.finish(); } } @@ -1037,35 +1017,6 @@ export class AIService extends EventEmitter { 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); } @@ -1073,9 +1024,6 @@ export class AIService extends EventEmitter { * 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); } diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index bce5e6f66b..6d3e5b108e 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -812,7 +812,7 @@ export class StreamManager { 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 @@ -831,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 From 574fbd539a97707078225e2b871ac09c2c61d977 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:26:45 +0000 Subject: [PATCH 12/22] refactor(stream): migrate lifecycle consumers to engine --- src/node/orpc/context.ts | 2 + .../agentSession.preStreamError.test.ts | 4 +- .../agentSession.replaySelfHealing.test.ts | 3 +- .../agentSession.sinceReplayContract.test.ts | 3 +- src/node/services/agentSession.ts | 31 ++++++-- src/node/services/aiService.ts | 79 +------------------ src/node/services/coreServices.ts | 16 +++- src/node/services/serviceContainer.ts | 3 + src/node/services/taskService.ts | 8 +- src/node/services/workspaceService.ts | 17 +++- .../queuedMessages.completing.test.ts | 8 +- .../streaming/queuedMessages.starting.test.ts | 8 +- .../persistentSubagentCompaction.test.ts | 14 ++-- tests/ui/config/modelOneshot.test.ts | 8 +- 14 files changed, 78 insertions(+), 126 deletions(-) diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 9375846ee7..2f5a1271db 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -4,6 +4,7 @@ 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"; @@ -57,6 +58,7 @@ export interface ORPCContext { config: Config; aiService: AIService; historyService: HistoryService; + streamManager: StreamManager; initStateManager: InitStateManager; projectService: ProjectService; workspaceService: WorkspaceService; diff --git a/src/node/services/agentSession.preStreamError.test.ts b/src/node/services/agentSession.preStreamError.test.ts index 621cfe7b1c..b666a96da0 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() ), diff --git a/src/node/services/agentSession.replaySelfHealing.test.ts b/src/node/services/agentSession.replaySelfHealing.test.ts index bd67a829d7..55ba9c3263 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.sinceReplayContract.test.ts b/src/node/services/agentSession.sinceReplayContract.test.ts index eec3e9392a..0aca2e3149 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.ts b/src/node/services/agentSession.ts index 462b87b376..680f4e5a99 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -534,6 +534,11 @@ interface AgentSessionActiveStreamInfo { toolCompletionTimestamps: Map; } +export interface AgentSessionStreamManager { + 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; @@ -544,8 +549,8 @@ export interface AgentSessionAIService extends BranchSummaryAiService { options?: { soft?: boolean; abandonPartial?: boolean; abortReason?: StreamAbortReason } ): Promise>; isStreaming(workspaceId: string): boolean; - getStreamInfo(workspaceId: string): AgentSessionActiveStreamInfo | undefined; - replayStream(workspaceId: string, options?: { afterTimestamp?: number }): Promise; + getStreamInfo?(workspaceId: string): AgentSessionActiveStreamInfo | undefined; + replayStream?(workspaceId: string, options?: { afterTimestamp?: number }): Promise; getProvidersConfig(): ProvidersConfigMap | null; isExperimentEnabled(experimentId: ExperimentId): boolean; buildMemorySessionContext?( @@ -567,6 +572,7 @@ interface AgentSessionOptions { config: Config; historyService: HistoryService; aiService: AgentSessionAIService; + streamManager?: AgentSessionStreamManager; mcpServerManager?: MCPServerManager; initStateManager: InitStateManager; telemetryService?: TelemetryService; @@ -634,6 +640,7 @@ export class AgentSession { private readonly config: Config; private readonly historyService: HistoryService; private readonly aiService: AgentSessionAIService; + private readonly streamManager: AgentSessionStreamManager; private readonly mcpServerManager?: MCPServerManager; private readonly initStateManager: InitStateManager; private readonly backgroundProcessManager: BackgroundProcessManager; @@ -869,6 +876,7 @@ export class AgentSession { config, historyService, aiService, + streamManager, mcpServerManager, initStateManager, telemetryService, @@ -891,6 +899,13 @@ export class AgentSession { this.config = config; this.historyService = historyService; this.aiService = aiService; + const streamManagerCandidate = streamManager ?? aiService; + assert( + 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; @@ -2585,15 +2600,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, @@ -2614,7 +2629,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; @@ -2786,13 +2801,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, diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 85bc995ef5..41220447a5 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -24,7 +24,6 @@ import { EXPERIMENT_IDS, type ExperimentId } from "@/common/constants/experiment import type { DebugLlmRequestSnapshot } from "@/common/types/debugLlmRequest"; import type { SendMessageError } from "@/common/types/errors"; -import type { MuxMessage } from "@/common/types/message"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import { getSrcBaseDir, isSSHRuntime } from "@/common/types/runtime"; import type { XumToolScope } from "@/common/types/toolScope"; @@ -121,7 +120,7 @@ export class AIService extends EventEmitter { 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; @@ -1027,62 +1026,6 @@ export class AIService extends EventEmitter { 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"); @@ -1091,26 +1034,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 d4c45d7197..636e46c6a7 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -10,6 +10,7 @@ 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 { 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 +74,7 @@ export interface CoreServices { */ idleDispatcher: IdleDispatcher; aiService: AIService; + streamManager: StreamManager; mcpConfigService: MCPConfigService; mcpServerManager: MCPServerManager; extensionMetadata: ExtensionMetadataService; @@ -151,6 +153,10 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { const workspaceMcpOverridesService = opts.workspaceMcpOverridesService ?? new WorkspaceMcpOverridesService(config); + const streamManager = new StreamManager(historyService, sessionUsageService, () => + providerService.getConfig() + ); + const aiService = new AIService( config, historyService, @@ -162,7 +168,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.policyService, opts.telemetryService, opts.devToolsService, - opts.experimentsService + opts.experimentsService, + streamManager ); // Agent memory (memory experiment): scope roots derive from Config (xum home @@ -242,7 +249,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.policyService, opts.telemetryService, opts.experimentsService, - opts.sessionTimingService + opts.sessionTimingService, + streamManager ); aiService.setWorkspaceHeartbeatService(workspaceService); // Tool-started workflows share the same sidebar activity cache as ORPC-started workflows, @@ -291,7 +299,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { workspaceService, initStateManager, sessionUsageService, - workspaceGoalService + workspaceGoalService, + streamManager ); aiService.setTaskService(taskService); workspaceService.setAgentTaskIntegration(taskService); @@ -320,6 +329,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { workspaceGoalService, idleDispatcher, aiService, + streamManager, mcpConfigService, mcpServerManager, extensionMetadata, diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index ee39a36f6c..0dd1613cdd 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -92,6 +92,7 @@ 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"]; @@ -191,6 +192,7 @@ export class ServiceContainer { // Spread core services into class fields this.historyService = core.historyService; this.aiService = core.aiService; + this.streamManager = core.streamManager; this.initStateManager = core.initStateManager; this.aiService.setAnalyticsService(this.analyticsService); this.browserSessionDiscoveryService = new AgentBrowserSessionDiscoveryService({ @@ -631,6 +633,7 @@ export class ServiceContainer { config: this.config, aiService: this.aiService, historyService: this.historyService, + streamManager: this.streamManager, initStateManager: this.initStateManager, projectService: this.projectService, workspaceService: this.workspaceService, diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index ad0ad4028c..6b5efd2cf9 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/workspaceService.ts b/src/node/services/workspaceService.ts index fb0860e8fa..5dbcbf7f22 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 69c1c5e892..c322f02bde 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 4715b42bf9..63ce1e0ec9 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 bdf2aa505c..1226b85a28 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 7779911cda..9beb511eb4 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(); From 94a6ef9308634b86269bcb432fd04042adbf8587 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:36:32 +0000 Subject: [PATCH 13/22] refactor(ai): consolidate turn preparation dependencies --- src/cli/run.ts | 7 +- src/cli/workflow.ts | 4 +- src/node/services/aiService.test.ts | 23 +++--- src/node/services/aiService.ts | 102 +++--------------------- src/node/services/coreServices.ts | 23 +++--- src/node/services/serviceContainer.ts | 10 +-- src/node/services/turnRequestBuilder.ts | 52 ++++++------ 7 files changed, 75 insertions(+), 146 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index 62b2c9dc6a..24f7f83096 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -651,6 +651,7 @@ async function main(): Promise { workspaceService, workspaceGoalService, idleDispatcher, + turnRequestBuilderBindings, } = createCoreServices({ config, policyService, @@ -675,7 +676,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 +691,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,7 +716,7 @@ 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, diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index 107996889a..b96462089d 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. diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 9a5ffe4cb0..0416125c2b 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -206,9 +206,9 @@ function configureOpenAICodexOAuth( }); if (options?.setOauthService !== false) { - service.setCodexOauthService({ + service.turnRequestBuilderBindings.codexOauthService = { getValidAuth: () => Promise.resolve({ success: true, data: TEST_CODEX_OAUTH }), - } as CodexOauthService); + } as CodexOauthService; } } @@ -1603,8 +1603,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 }> = []; @@ -2442,8 +2443,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 +2478,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 +2531,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 +2590,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 } = { diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 41220447a5..0dfde7470c 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -12,10 +12,10 @@ import type { WorkspaceMetadata } from "@/common/types/workspace"; import { ensurePrivateDir } from "@/node/utils/fs"; import { TurnRequestBuilder, + type TurnRequestBuilderBindings, resolveMuxProjectRootForHostFs, resolveXumToolScope, type StreamMessageOptions, - type WorkflowResultContinuationSender, } from "./turnRequestBuilder"; export { prepareProviderRequestMessages, replaceOrAppendMessageById } from "./turnRequestBuilder"; export type { StreamMessageOptions } from "./turnRequestBuilder"; @@ -28,7 +28,6 @@ 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 ToolConfiguration } from "@/common/utils/tools/tools"; import type { Config } from "@/node/config"; import { ContainerManager } from "@/node/multiProject/containerManager"; import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; @@ -42,8 +41,6 @@ import { type WorkspaceRuntimeContext, } from "@/node/runtime/runtimeHelpers"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; -import type { CoderOauthService } from "@/node/services/coderOauthService"; -import type { CodexOauthService } from "@/node/services/codexOauthService"; import type { PolicyService } from "@/node/services/policyService"; import type { ProviderService } from "@/node/services/providerService"; import { getWorkspacePathHintForProject } from "@/node/services/workspaceProjectRepos"; @@ -61,7 +58,6 @@ import { } from "./streamManager"; import { normalizeToCanonical } from "@/common/utils/ai/models"; -import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; import type { DevToolsService } from "@/node/services/devToolsService"; import type { ExperimentsService } from "@/node/services/experimentsService"; import type { TelemetryService } from "@/node/services/telemetryService"; @@ -71,14 +67,11 @@ import type { SessionUsageService } from "./sessionUsageService"; import type { ProvidersConfig } from "@/common/config/schemas/providersConfig"; import { getProjects, isMultiProject } from "@/common/utils/multiProject"; -import type { MCPServerManager } from "@/node/services/mcpServerManager"; -import { formatHotMemoriesBlock } from "@/node/services/memoryHotSet"; import { resolveMemoryProjectIdentity, - type MemoryService, type MemorySessionContext, } from "@/node/services/memoryService"; -import type { TaskService } from "@/node/services/taskService"; +import { formatHotMemoriesBlock } from "@/node/services/memoryHotSet"; import { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; import type { StreamAbortReason } from "@/common/types/stream"; @@ -86,7 +79,6 @@ import { getErrorMessage } from "@/common/utils/errors"; import { validateJsonSchemaSubsetSchema } from "@/common/utils/jsonSchemaSubset"; import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries"; import { WorkflowRunStore } from "@/node/services/workflows/WorkflowRunStore"; -import { type WorkflowRunStatusChangedEvent } from "@/node/services/workflows/WorkflowService"; import { getTokenizerForModel } from "@/node/utils/main/tokenizer"; import { MockAiStreamPlayer } from "./mock/mockAiStreamPlayer"; import { ProviderModelFactory } from "./providerModelFactory"; @@ -115,7 +107,6 @@ export class AIService extends EventEmitter { 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; @@ -139,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, @@ -163,7 +143,8 @@ export class AIService extends EventEmitter { telemetryService?: TelemetryService, devToolsService?: DevToolsService, experimentsService?: ExperimentsService, - streamManager?: StreamManager + streamManager?: StreamManager, + public readonly turnRequestBuilderBindings: TurnRequestBuilderBindings = {} ) { super(); // Increase max listeners to accommodate multiple concurrent workspace listeners @@ -210,18 +191,7 @@ export class AIService extends EventEmitter { devToolsService: this.devToolsService, experimentsService: this.experimentsService, lastLlmRequestByWorkspace: this.lastLlmRequestByWorkspace, - lateBound: { - mcpServerManager: () => this.mcpServerManager, - taskService: () => this.taskService, - memoryService: () => this.memoryService, - timelineService: () => this.timelineService, - extraTools: () => this.extraTools, - onWorkflowRunStatusChanged: () => this.onWorkflowRunStatusChanged, - workflowResultContinuationSender: () => this.workflowResultContinuationSender, - workspaceHeartbeatService: () => this.workspaceHeartbeatService, - analyticsService: () => this.analyticsService, - desktopSessionManager: () => this.desktopSessionManager, - }, + bindings: this.turnRequestBuilderBindings, emit: (event, ...args) => this.emit(event, ...args), createAbortedTurnHandle: (messageId) => this.createAbortedTurnHandle(messageId), createSettledTurnHandle: (messageId, completion) => @@ -251,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 @@ -308,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; } @@ -325,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; @@ -339,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); @@ -360,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); diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 636e46c6a7..39a2beab65 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -10,6 +10,7 @@ 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"; @@ -83,6 +84,7 @@ export interface CoreServices { memoryService: MemoryService; memoryMetaService: MemoryMetaService; memoryConsolidationService: MemoryConsolidationService; + turnRequestBuilderBindings: TurnRequestBuilderBindings; } export function createCoreServices(opts: CoreServicesOptions): CoreServices { @@ -153,6 +155,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { const workspaceMcpOverridesService = opts.workspaceMcpOverridesService ?? new WorkspaceMcpOverridesService(config); + const turnRequestBuilderBindings: TurnRequestBuilderBindings = {}; const streamManager = new StreamManager(historyService, sessionUsageService, () => providerService.getConfig() ); @@ -169,7 +172,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.telemetryService, opts.devToolsService, opts.experimentsService, - streamManager + streamManager, + turnRequestBuilderBindings ); // Agent memory (memory experiment): scope roots derive from Config (xum home @@ -177,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. @@ -225,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) => { @@ -252,13 +257,12 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { 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. @@ -302,7 +306,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { workspaceGoalService, streamManager ); - aiService.setTaskService(taskService); + turnRequestBuilderBindings.taskService = taskService; workspaceService.setAgentTaskIntegration(taskService); // Goal continuation bridge lives at the core scope so every codepath that @@ -338,5 +342,6 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { memoryService, memoryMetaService, memoryConsolidationService, + turnRequestBuilderBindings, }; } diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 0dd1613cdd..2c91917c18 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -194,7 +194,7 @@ export class ServiceContainer { this.aiService = core.aiService; this.streamManager = core.streamManager; this.initStateManager = core.initStateManager; - this.aiService.setAnalyticsService(this.analyticsService); + core.turnRequestBuilderBindings.analyticsService = this.analyticsService; this.browserSessionDiscoveryService = new AgentBrowserSessionDiscoveryService({ resolveWorkspaceCandidatePathsFn: async (workspaceId: string) => { const allWorkspaceMetadata = await config.getAllWorkspaceMetadata(); @@ -255,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, @@ -323,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( @@ -349,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, @@ -358,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(); diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 6c86ebddb0..0fe8dfe14b 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -64,6 +64,8 @@ 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 { CodexOauthService } from "@/node/services/codexOauthService"; +import type { CoderOauthService } from "@/node/services/coderOauthService"; import type { DevToolsService } from "@/node/services/devToolsService"; import type { ExperimentsService } from "@/node/services/experimentsService"; import { findWorkspaceEntry, resolveWorkspaceModelFallbackChain } from "@/node/services/taskUtils"; @@ -503,19 +505,19 @@ export type TurnRequestBuildOutcome = logStartOutcome: (outcome: "started" | "stream_start_failed", errorType?: string) => void; }; -interface TurnRequestBuilderLateBoundDependencies { - mcpServerManager: () => MCPServerManager | undefined; - taskService: () => TaskService | undefined; - memoryService: () => MemoryService | undefined; - timelineService: () => ToolConfiguration["timelineService"]; - extraTools: () => Record | undefined; - onWorkflowRunStatusChanged: () => - | ((event: WorkflowRunStatusChangedEvent) => Promise | void) - | undefined; - workflowResultContinuationSender: () => WorkflowResultContinuationSender | undefined; - workspaceHeartbeatService: () => ToolConfiguration["workspaceHeartbeatService"]; - analyticsService: () => { executeRawQuery(sql: string): Promise } | undefined; - desktopSessionManager: () => DesktopSessionManager | undefined; +export interface TurnRequestBuilderBindings { + codexOauthService?: CodexOauthService; + coderOauthService?: CoderOauthService; + 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; } export interface TurnRequestBuilderDependencies { @@ -533,7 +535,7 @@ export interface TurnRequestBuilderDependencies { devToolsService?: DevToolsService; experimentsService?: ExperimentsService; lastLlmRequestByWorkspace: Map; - lateBound: TurnRequestBuilderLateBoundDependencies; + bindings: TurnRequestBuilderBindings; emit: (event: string, ...args: unknown[]) => boolean; createAbortedTurnHandle: (messageId: string) => TurnStreamHandle; createSettledTurnHandle: (messageId: string, completion: TurnCompletion) => TurnStreamHandle; @@ -651,36 +653,36 @@ export class TurnRequestBuilder { return this.dependencies.lastLlmRequestByWorkspace; } private get mcpServerManager(): MCPServerManager | undefined { - return this.dependencies.lateBound.mcpServerManager(); + return this.dependencies.bindings.mcpServerManager; } private get taskService(): TaskService | undefined { - return this.dependencies.lateBound.taskService(); + return this.dependencies.bindings.taskService; } private get memoryService(): MemoryService | undefined { - return this.dependencies.lateBound.memoryService(); + return this.dependencies.bindings.memoryService; } private get timelineService(): ToolConfiguration["timelineService"] { - return this.dependencies.lateBound.timelineService(); + return this.dependencies.bindings.timelineService; } private get extraTools(): Record | undefined { - return this.dependencies.lateBound.extraTools(); + return this.dependencies.bindings.extraTools; } private get onWorkflowRunStatusChanged(): | ((event: WorkflowRunStatusChangedEvent) => Promise | void) | undefined { - return this.dependencies.lateBound.onWorkflowRunStatusChanged(); + return this.dependencies.bindings.onWorkflowRunStatusChanged; } private get workflowResultContinuationSender(): WorkflowResultContinuationSender | undefined { - return this.dependencies.lateBound.workflowResultContinuationSender(); + return this.dependencies.bindings.workflowResultContinuationSender; } private get workspaceHeartbeatService(): ToolConfiguration["workspaceHeartbeatService"] { - return this.dependencies.lateBound.workspaceHeartbeatService(); + return this.dependencies.bindings.workspaceHeartbeatService; } private get analyticsService(): { executeRawQuery(sql: string): Promise } | undefined { - return this.dependencies.lateBound.analyticsService(); + return this.dependencies.bindings.analyticsService; } private get desktopSessionManager(): DesktopSessionManager | undefined { - return this.dependencies.lateBound.desktopSessionManager(); + return this.dependencies.bindings.desktopSessionManager; } private emit(event: string, ...args: unknown[]): boolean { @@ -893,6 +895,8 @@ export class TurnRequestBuilder { opts: StreamMessageOptions, context: TurnRequestBuildContext ): Promise { + this.providerModelFactory.codexOauthService = this.dependencies.bindings.codexOauthService; + this.providerModelFactory.coderOauthService = this.dependencies.bindings.coderOauthService; const { messages, workspaceId, From bafc4b8838760f752593c118b75da543a6873261 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:06:14 +0000 Subject: [PATCH 14/22] =?UTF-8?q?=F0=9F=A4=96=20tests:=20move=20preparatio?= =?UTF-8?q?n=20coverage=20to=20request=20builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _Generated with 0xum0 • Model: 0openai:gpt-5.6-sol0 • Thinking: 0high0 • Cost: 040.000_ --- src/node/services/aiService.test.ts | 2016 +----------------- src/node/services/turnRequestBuilder.test.ts | 428 ++++ src/node/services/turnRequestBuilder.ts | 6 +- 3 files changed, 536 insertions(+), 1914 deletions(-) create mode 100644 src/node/services/turnRequestBuilder.test.ts diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 0416125c2b..f2df4b2a7e 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 { @@ -421,134 +407,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 +1050,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 +1328,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"); @@ -1648,790 +1403,137 @@ 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"; + 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 }); - 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"); - const projectPath = path.join(xumHome.path, "project"); - await fs.mkdir(projectPath, { recursive: true }); - - const workspaceId = "workspace-rebuild-system-context-memory"; + const workspaceId = "workspace-rebuild-system-context-memory"; const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- stub for memory availability gating const stubTool: Tool = {} as never; @@ -2663,128 +1765,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"); @@ -2883,104 +1863,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"); @@ -3445,161 +2327,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, + }) + ); }); }); @@ -3782,542 +2512,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/turnRequestBuilder.test.ts b/src/node/services/turnRequestBuilder.test.ts new file mode 100644 index 0000000000..917b82a7fc --- /dev/null +++ b/src/node/services/turnRequestBuilder.test.ts @@ -0,0 +1,428 @@ +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 { 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(), + lateBound: { + mcpServerManager: () => undefined, + taskService: () => undefined, + memoryService: () => undefined, + timelineService: () => undefined, + extraTools: () => undefined, + onWorkflowRunStatusChanged: () => undefined, + workflowResultContinuationSender: () => undefined, + workspaceHeartbeatService: () => undefined, + analyticsService: () => undefined, + desktopSessionManager: () => undefined, + }, + emit: () => false, + createAbortedTurnHandle: (messageId) => ({ + messageId, + completion: Promise.resolve({ status: "aborted", abortReason: "startup" }), + }), + createSettledTurnHandle: (messageId, completion) => ({ + messageId, + completion: Promise.resolve(completion), + }), + getWorkspaceMetadata: async () => { + throw 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: async () => false, + createModel: async () => { + throw 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 import("@/node/config").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 index 0fe8dfe14b..7c0b37c705 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -574,7 +574,7 @@ export interface TurnRequestBuilderDependencies { ) => void; } -interface PrepareModelAttemptOptions { +export interface PrepareModelAttemptOptions { rawModelString: string; canonicalModelString: string; canonicalProviderName: string; @@ -595,7 +595,7 @@ interface PrepareModelAttemptOptions { recordStartupPhaseTiming?: (phase: string, phaseStartedAt: number) => void; } -interface PreparedModelAttempt { +export interface PreparedModelAttempt { providerOptions: Record; requestHeaders: Record | undefined; resolvedOverrides: ReturnType; @@ -776,7 +776,7 @@ export class TurnRequestBuilder { }; } - private prepareModelAttempt(options: PrepareModelAttemptOptions): PreparedModelAttempt { + prepareModelAttempt(options: PrepareModelAttemptOptions): PreparedModelAttempt { const buildProviderOptionsStartedAt = Date.now(); const providerOptions = buildProviderOptions( options.optionsModelString, From 728976053eaecafa05f91ed9bfbe5fd4dc9ea846 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:39:46 +0000 Subject: [PATCH 15/22] refactor(session): route lifecycle through turn engine --- .../agentSession.startupAutoRetry.test.ts | 2 ++ src/node/services/agentSession.ts | 25 ++++++++++++------- src/node/services/turnRequestBuilder.test.ts | 13 +--------- 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 60a78e1496..424892c4b6 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1559,6 +1559,8 @@ 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 AgentSessionAIService; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 680f4e5a99..526443709a 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -535,6 +535,11 @@ interface AgentSessionActiveStreamInfo { } 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; } @@ -544,11 +549,11 @@ 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( + stopStream?( workspaceId: string, options?: { soft?: boolean; abandonPartial?: boolean; abortReason?: StreamAbortReason } ): Promise>; - isStreaming(workspaceId: string): boolean; + isStreaming?(workspaceId: string): boolean; getStreamInfo?(workspaceId: string): AgentSessionActiveStreamInfo | undefined; replayStream?(workspaceId: string, options?: { afterTimestamp?: number }): Promise; getProvidersConfig(): ProvidersConfigMap | null; @@ -901,7 +906,9 @@ export class AgentSession { this.aiService = aiService; const streamManagerCandidate = streamManager ?? aiService; assert( - typeof streamManagerCandidate.getStreamInfo === "function" && + typeof streamManagerCandidate.stopStream === "function" && + typeof streamManagerCandidate.isStreaming === "function" && + typeof streamManagerCandidate.getStreamInfo === "function" && typeof streamManagerCandidate.replayStream === "function", "AgentSession requires stream lifecycle access" ); @@ -971,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)}`); }); @@ -2455,7 +2462,7 @@ export class AgentSession { this.startupAutoRetryCheckScheduled = false; if ( this.isBusy() || - this.aiService.isStreaming(this.workspaceId) || + this.streamManager.isStreaming(this.workspaceId) || this.retryManager.isRetryPending ) { return; @@ -2482,7 +2489,7 @@ export class AgentSession { shouldRetainAfterStartupRecovery(): boolean { return ( this.isBusy() || - this.aiService.isStreaming(this.workspaceId) || + this.streamManager.isStreaming(this.workspaceId) || this.retryManager.isRetryPending ); } @@ -4752,7 +4759,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) { @@ -4874,7 +4881,7 @@ export class AgentSession { } } - const stopResult = await this.aiService.stopStream(this.workspaceId, { + const stopResult = await this.streamManager.stopStream(this.workspaceId, { ...options, abortReason: "user", }); @@ -6771,7 +6778,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/turnRequestBuilder.test.ts b/src/node/services/turnRequestBuilder.test.ts index 917b82a7fc..9f8d69428f 100644 --- a/src/node/services/turnRequestBuilder.test.ts +++ b/src/node/services/turnRequestBuilder.test.ts @@ -39,18 +39,7 @@ async function createPreparationHarness() { streamManager, workspaceMcpOverridesService: new WorkspaceMcpOverridesService(config), lastLlmRequestByWorkspace: new Map(), - lateBound: { - mcpServerManager: () => undefined, - taskService: () => undefined, - memoryService: () => undefined, - timelineService: () => undefined, - extraTools: () => undefined, - onWorkflowRunStatusChanged: () => undefined, - workflowResultContinuationSender: () => undefined, - workspaceHeartbeatService: () => undefined, - analyticsService: () => undefined, - desktopSessionManager: () => undefined, - }, + bindings: {}, emit: () => false, createAbortedTurnHandle: (messageId) => ({ messageId, From b5c0d3e3889f31a32c1305f6adebcbfc4ec9aca0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:04:54 +0000 Subject: [PATCH 16/22] fix(ai): read OAuth services live from turn bindings The builder only copied codexOauthService/coderOauthService into ProviderModelFactory at build() time, so AIService.createModel callers (branch summaries, refinement, status generation) hit 'Codex OAuth service not initialized' before any turn ran. The factory now reads the shared bindings object at use time. Also restores the slow-startup diagnostic on the caller's error path by writing logSlowStreamStartup back into startupState, and fixes lint in the migrated tests. --- src/node/services/aiService.test.ts | 6 +- src/node/services/aiService.ts | 2 +- .../services/providerModelFactory.test.ts | 156 +++++++++--------- src/node/services/providerModelFactory.ts | 21 ++- src/node/services/streamManager.test.ts | 19 +-- src/node/services/turnRequestBuilder.test.ts | 15 +- src/node/services/turnRequestBuilder.ts | 19 +-- 7 files changed, 123 insertions(+), 115 deletions(-) diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index f2df4b2a7e..1541481764 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -192,9 +192,11 @@ function configureOpenAICodexOAuth( }); if (options?.setOauthService !== false) { - service.turnRequestBuilderBindings.codexOauthService = { + const codexOauthStub = { getValidAuth: () => Promise.resolve({ success: true, data: TEST_CODEX_OAUTH }), - } as CodexOauthService; + }; + service.turnRequestBuilderBindings.codexOauthService = + codexOauthStub as unknown as CodexOauthService; } } diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 0dfde7470c..8e4081ac41 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -173,7 +173,7 @@ export class AIService extends EventEmitter { config, providerService, policyService, - undefined, + turnRequestBuilderBindings, devToolsService ); this.turnRequestBuilder = new TurnRequestBuilder({ diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index b464cf9421..f5e317cb43 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 8700aad8af..ca84cd7d7b 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/streamManager.test.ts b/src/node/services/streamManager.test.ts index 5fbe8ed6af..44b359b61d 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -5654,16 +5654,15 @@ describe("StreamManager - stopStream", () => { expect(result.success).toBe(true); expect(startup.abortSignal.aborted).toBe(true); - expect(events).toEqual([ - expect.objectContaining({ - type: "stream-abort", - workspaceId: "pending-workspace", - messageId: startup.syntheticMessageId, - abortReason: "user", - abandonPartial: true, - acpPromptId: "prompt-1", - }), - ]); + 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 () => { diff --git a/src/node/services/turnRequestBuilder.test.ts b/src/node/services/turnRequestBuilder.test.ts index 9f8d69428f..cc961d50b5 100644 --- a/src/node/services/turnRequestBuilder.test.ts +++ b/src/node/services/turnRequestBuilder.test.ts @@ -7,6 +7,7 @@ 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"; @@ -49,9 +50,7 @@ async function createPreparationHarness() { messageId, completion: Promise.resolve(completion), }), - getWorkspaceMetadata: async () => { - throw new Error("not used by request preparation tests"); - }, + getWorkspaceMetadata: () => Promise.reject(new Error("not used by request preparation tests")), createWorkspaceRuntimeContext: () => { throw new Error("not used by request preparation tests"); }, @@ -61,10 +60,8 @@ async function createPreparationHarness() { durableEventJournalFor: () => { throw new Error("not used by request preparation tests"); }, - shouldAllowLegacyInvalidWorkflowAgentOutputSchema: async () => false, - createModel: async () => { - 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, }); @@ -398,9 +395,7 @@ describe("TurnRequestBuilder model attempt preparation", () => { ])("$name", async (testCase) => { const harness = await createPreparationHarness(); try { - harness.config.saveProvidersConfig( - testCase.rawConfig as unknown as import("@/node/config").ProvidersConfig - ); + harness.config.saveProvidersConfig(testCase.rawConfig as unknown as ProvidersConfig); const prepared = harness.builder.prepareModelAttempt( preparationOptions( testCase.snapshot as unknown as ProvidersConfigMap, diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 7c0b37c705..d5d66ce5f4 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -64,8 +64,6 @@ 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 { CodexOauthService } from "@/node/services/codexOauthService"; -import type { CoderOauthService } from "@/node/services/coderOauthService"; import type { DevToolsService } from "@/node/services/devToolsService"; import type { ExperimentsService } from "@/node/services/experimentsService"; import { findWorkspaceEntry, resolveWorkspaceModelFallbackChain } from "@/node/services/taskUtils"; @@ -171,7 +169,7 @@ 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 { ProviderModelFactory } from "./providerModelFactory"; +import type { OauthServiceBindings, ProviderModelFactory } from "./providerModelFactory"; import { modelCostsIncluded } from "./providerModelFactory"; import { buildPlanInstructions, buildStreamSystemContext } from "./streamContextBuilder"; import { @@ -505,9 +503,7 @@ export type TurnRequestBuildOutcome = logStartOutcome: (outcome: "started" | "stream_start_failed", errorType?: string) => void; }; -export interface TurnRequestBuilderBindings { - codexOauthService?: CodexOauthService; - coderOauthService?: CoderOauthService; +export interface TurnRequestBuilderBindings extends OauthServiceBindings { mcpServerManager?: MCPServerManager; taskService?: TaskService; memoryService?: MemoryService; @@ -895,8 +891,6 @@ export class TurnRequestBuilder { opts: StreamMessageOptions, context: TurnRequestBuildContext ): Promise { - this.providerModelFactory.codexOauthService = this.dependencies.bindings.codexOauthService; - this.providerModelFactory.coderOauthService = this.dependencies.bindings.coderOauthService; const { messages, workspaceId, @@ -937,7 +931,6 @@ export class TurnRequestBuilder { const startupPhaseTimingsMs = context.startupPhaseTimingsMs; const recordStartupPhaseTiming = context.recordStartupPhaseTiming; let pendingRunMetadataId: string | null = context.startupState.pendingRunMetadataId; - let logSlowStreamStartup: ((details: Record) => void) | undefined; const deleteAbortedPlaceholder = async (messageId: string): Promise => { const deleteResult = await this.historyService.deleteMessage(workspaceId, messageId); @@ -1221,7 +1214,7 @@ export class TurnRequestBuilder { } } const workspaceLog = log.withFields({ workspaceId, workspaceName: metadata.name }); - logSlowStreamStartup = (details: Record) => { + const logSlowStreamStartup = (details: Record): void => { const totalMs = Date.now() - startTime; if (totalMs < STREAM_STARTUP_DIAGNOSTIC_THRESHOLD_MS) { return; @@ -1235,6 +1228,8 @@ export class TurnRequestBuilder { ...details, }); }; + // Exposed so the caller's catch path can log slow startups that fail after build(). + context.startupState.logSlowStreamStartup = logSlowStreamStartup; const emitStartupBreadcrumb = ( startupStage: @@ -1353,7 +1348,7 @@ export class TurnRequestBuilder { this.emit("error", errorEvent); onPreStartError?.(errorEvent); - logSlowStreamStartup?.({ + logSlowStreamStartup({ outcome: "runtime_not_ready", runtimeType, errorType, @@ -3006,7 +3001,7 @@ export class TurnRequestBuilder { outcome: "started" | "stream_start_failed", errorType?: string ): void => { - logSlowStreamStartup?.({ + logSlowStreamStartup({ outcome, providerName: canonicalProviderName, routeProvider, From bc963b6c9af179c59ec704892d927469321e2337 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:05:06 +0000 Subject: [PATCH 17/22] tests: satisfy the engine lifecycle seam in hand-rolled AI mocks AgentSession now asserts stream-lifecycle access on its engine seam, and WorkspaceService reads delegated-turn stream info from the engine. Share one createStreamLifecycleMocks() harness helper across the hand-rolled AIService mocks (file-specific overrides still win), make the harness report failures as Err results like the real implementations, and give the archive-hooks suite an engine stub for the stream exemption. --- .../agentSession.admissionGates.test.ts | 2 + .../agentSession.autoCompaction.test.ts | 10 ++- ...gentSession.continueMessageAgentId.test.ts | 2 + .../services/agentSession.disposeRace.test.ts | 8 +- .../agentSession.editMessageId.test.ts | 3 +- ...gentSession.fileChangeNotification.test.ts | 3 +- .../agentSession.memoryContext.test.ts | 2 + ...tSession.postCompactionAttachments.test.ts | 2 + ...agentSession.postCompactionRefresh.test.ts | 3 +- .../agentSession.postCompactionRetry.test.ts | 9 +- .../agentSession.preStreamError.test.ts | 2 + .../agentSession.preTurnMessages.test.ts | 3 +- ...ntSession.resumeStreamEmptyHistory.test.ts | 2 + src/node/services/agentSession.testHarness.ts | 28 +++++-- src/node/services/workspaceService.test.ts | 82 +++++++++++++++++-- 15 files changed, 138 insertions(+), 23 deletions(-) diff --git a/src/node/services/agentSession.admissionGates.test.ts b/src/node/services/agentSession.admissionGates.test.ts index 37994c2ec5..91c7b7962b 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 cf7546048a..6e1e3679c6 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 214166e3a0..69decddff0 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 4aa91d5892..7fcace4892 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 09644eb281..2872f286b0 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 eee36a733d..f18428ce28 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 3e33d93573..16a145773b 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 45db40bed2..e153f66b6f 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 ad7a0e7180..fa0781717e 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 f861e345d3..4a63dc701a 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 b666a96da0..7b0428ce61 100644 --- a/src/node/services/agentSession.preStreamError.test.ts +++ b/src/node/services/agentSession.preStreamError.test.ts @@ -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 b57038de01..75f3dbc493 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.resumeStreamEmptyHistory.test.ts b/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts index 84099bc93f..4865eba44a 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.testHarness.ts b/src/node/services/agentSession.testHarness.ts index f4f8ffd76b..5d53b30cf6 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -2,7 +2,7 @@ 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 { TurnStreamHandle } from "@/node/services/streamManager"; import { AgentSession, type AgentSessionAIService } from "@/node/services/agentSession"; @@ -54,6 +54,18 @@ function createMockInitStateManager(overrides?: Partial): Init return Object.assign(new EventEmitter(), overrides) as unknown as InitStateManager; } +/** 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; @@ -63,20 +75,18 @@ function createMockAiService(args?: { } { const aiEmitter = args?.emitter ?? new EventEmitter(); const aiService: AgentSessionAIService = Object.assign(aiEmitter, { + // Real implementations report failures as Err results, never rejections. createModelWithPinnedMetadata: mock(() => - Promise.reject(new Error("Test AI service cannot create models")) + Promise.resolve( + Err({ type: "unknown" as const, raw: "Test AI service cannot create models" }) + ) ), getWorkspaceMetadata: mock(() => - Promise.reject(new Error("Test AI service has no workspace metadata")) + Promise.resolve(Err("Test AI service has no workspace metadata")) ), getProvidersConfig: mock(() => null), isExperimentEnabled: mock((_experimentId) => false), - 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() - ), + ...createStreamLifecycleMocks(), streamMessage: mock(() => Promise.resolve(Ok(createStartedTurnHandle("test-assistant-message"))) ), diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 292987bd19..5a34ba588d 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), From 876d4f05a9f5fca0f5548a6021cdbefee0264eac Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:26:17 +0000 Subject: [PATCH 18/22] refactor: flatten builder access paths and prune slop from the extraction Delete the 37 single-line getters/forwarders TurnRequestBuilder grew during the extraction (direct this.dependencies access instead), reuse the prepared request's first-step rebuild closure rather than re-wrapping it, share markProviderMetadataCostsIncluded from streamManager, and collapse AgentSession.isAiStreaming to the asserted engine reference. Pass streamManager into the CLI AgentSession constructions ('xum run' and 'xum workflow' would fail the new lifecycle assert without it, since AIService no longer carries getStreamInfo/replayStream). Unexport ten module-private types, fix assert messages and comments still naming AIService as the request builder, and drop narrative comments. --- src/cli/run.ts | 2 + src/cli/workflow.ts | 1 + src/node/services/agentSession.ts | 6 +- src/node/services/streamManager.ts | 24 +- src/node/services/turnRequestBuilder.ts | 499 ++++++++++-------------- 5 files changed, 214 insertions(+), 318 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index 24f7f83096..5bd33960a4 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -651,6 +651,7 @@ async function main(): Promise { workspaceService, workspaceGoalService, idleDispatcher, + streamManager, turnRequestBuilderBindings, } = createCoreServices({ config, @@ -723,6 +724,7 @@ async function main(): Promise { config, historyService, aiService, + streamManager, initStateManager, backgroundProcessManager, workspaceGoalService, diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index b96462089d..d95342fdb3 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -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/services/agentSession.ts b/src/node/services/agentSession.ts index 526443709a..0262507f38 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1549,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 { diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 6d3e5b108e..0d202a5d2a 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,13 +782,13 @@ function nextPartTimestamp(streamInfo: WorkspaceStreamInfo): number { * - Atomic stream creation/cancellation operations * - Guaranteed resource cleanup in all code paths */ -export interface PendingStreamStartHandle { +interface PendingStreamStartHandle { readonly abortSignal: AbortSignal; readonly syntheticMessageId: string; finish(): void; } -export interface MockStreamLifecycle { +interface MockStreamLifecycle { isStreaming(workspaceId: string): boolean; stop(workspaceId: string): Promise; replayStream(workspaceId: string): Promise; @@ -2317,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 @@ -3978,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, @@ -4831,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)); diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index d5d66ce5f4..4b4c2739f7 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -51,6 +51,7 @@ import { runLanguageModelCleanup } from "./languageModelCleanup"; import { log } from "./log"; import type { StreamManager } from "./streamManager"; import { + markProviderMetadataCostsIncluded, type ModelFallbackOptions, type StreamTextOnChunk, type TurnCompletion, @@ -361,29 +362,6 @@ function mergeProviderExtrasUnderMux( 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."; @@ -462,7 +440,7 @@ function derivePromptCacheScope(metadata: WorkspaceMetadata): string { return `${metadata.projectName}-${uniqueSuffix([metadata.projectPath])}`; } -export interface WorkflowResultContinuationSender { +interface WorkflowResultContinuationSender { isWorkflowInvocationCurrent(workspaceId: string, runId: string): Promise; sendMessage( workspaceId: string, @@ -479,12 +457,12 @@ export interface WorkflowResultContinuationSender { ): Promise>; } -export interface TurnRequestBuildStartupState { +interface TurnRequestBuildStartupState { pendingRunMetadataId: string | null; logSlowStreamStartup?: (details: Record) => void; } -export interface TurnRequestBuildContext { +interface TurnRequestBuildContext { abortSignal: AbortSignal; syntheticMessageId: string; startTime: number; @@ -493,7 +471,7 @@ export interface TurnRequestBuildContext { recordStartupPhaseTiming: (phase: string, phaseStartedAt: number) => void; } -export type TurnRequestBuildOutcome = +type TurnRequestBuildOutcome = | { type: "finished"; result: Result } | { type: "ready"; @@ -516,7 +494,7 @@ export interface TurnRequestBuilderBindings extends OauthServiceBindings { desktopSessionManager?: DesktopSessionManager; } -export interface TurnRequestBuilderDependencies { +interface TurnRequestBuilderDependencies { config: Config; historyService: HistoryService; initStateManager: InitStateManager; @@ -591,7 +569,7 @@ export interface PrepareModelAttemptOptions { recordStartupPhaseTiming?: (phase: string, phaseStartedAt: number) => void; } -export interface PreparedModelAttempt { +interface PreparedModelAttempt { providerOptions: Record; requestHeaders: Record | undefined; resolvedOverrides: ReturnType; @@ -606,135 +584,6 @@ export interface PreparedModelAttempt { export class TurnRequestBuilder { constructor(private readonly dependencies: TurnRequestBuilderDependencies) {} - private get config(): Config { - return this.dependencies.config; - } - private get historyService(): HistoryService { - return this.dependencies.historyService; - } - private get initStateManager(): InitStateManager { - return this.dependencies.initStateManager; - } - private get providerService(): ProviderService { - return this.dependencies.providerService; - } - private get providerModelFactory(): ProviderModelFactory { - return this.dependencies.providerModelFactory; - } - private get streamManager(): StreamManager { - return this.dependencies.streamManager; - } - private get workspaceMcpOverridesService(): WorkspaceMcpOverridesService { - return this.dependencies.workspaceMcpOverridesService; - } - private get policyService(): PolicyService | undefined { - return this.dependencies.policyService; - } - private get telemetryService(): TelemetryService | undefined { - return this.dependencies.telemetryService; - } - private get backgroundProcessManager(): BackgroundProcessManager | undefined { - return this.dependencies.backgroundProcessManager; - } - private get sessionUsageService(): SessionUsageService | undefined { - return this.dependencies.sessionUsageService; - } - private get devToolsService(): DevToolsService | undefined { - return this.dependencies.devToolsService; - } - private get experimentsService(): ExperimentsService | undefined { - return this.dependencies.experimentsService; - } - private get lastLlmRequestByWorkspace(): Map { - return this.dependencies.lastLlmRequestByWorkspace; - } - private get mcpServerManager(): MCPServerManager | undefined { - return this.dependencies.bindings.mcpServerManager; - } - private get taskService(): TaskService | undefined { - return this.dependencies.bindings.taskService; - } - private get memoryService(): MemoryService | undefined { - return this.dependencies.bindings.memoryService; - } - private get timelineService(): ToolConfiguration["timelineService"] { - return this.dependencies.bindings.timelineService; - } - private get extraTools(): Record | undefined { - return this.dependencies.bindings.extraTools; - } - private get onWorkflowRunStatusChanged(): - | ((event: WorkflowRunStatusChangedEvent) => Promise | void) - | undefined { - return this.dependencies.bindings.onWorkflowRunStatusChanged; - } - private get workflowResultContinuationSender(): WorkflowResultContinuationSender | undefined { - return this.dependencies.bindings.workflowResultContinuationSender; - } - private get workspaceHeartbeatService(): ToolConfiguration["workspaceHeartbeatService"] { - return this.dependencies.bindings.workspaceHeartbeatService; - } - private get analyticsService(): { executeRawQuery(sql: string): Promise } | undefined { - return this.dependencies.bindings.analyticsService; - } - private get desktopSessionManager(): DesktopSessionManager | undefined { - return this.dependencies.bindings.desktopSessionManager; - } - - private emit(event: string, ...args: unknown[]): boolean { - return this.dependencies.emit(event, ...args); - } - private createAbortedTurnHandle(messageId: string): TurnStreamHandle { - return this.dependencies.createAbortedTurnHandle(messageId); - } - private createSettledTurnHandle(messageId: string, completion: TurnCompletion): TurnStreamHandle { - return this.dependencies.createSettledTurnHandle(messageId, completion); - } - private getWorkspaceMetadata(workspaceId: string): Promise> { - return this.dependencies.getWorkspaceMetadata(workspaceId); - } - private createWorkspaceRuntimeContext(workspaceId: string, metadata: WorkspaceMetadata) { - return this.dependencies.createWorkspaceRuntimeContext(workspaceId, metadata); - } - private isClaudeSkillsCompatEnabled(): boolean { - return this.dependencies.isClaudeSkillsCompatEnabled(); - } - private isAgentPluginsEnabled(): boolean { - return this.dependencies.isAgentPluginsEnabled(); - } - private wrapToolsForDelegation( - workspaceId: string, - tools: Record, - delegatedToolNames?: string[] - ): Record { - return this.dependencies.wrapToolsForDelegation(workspaceId, tools, delegatedToolNames); - } - private durableEventJournalFor(workspaceId: string): DurableEventJournal { - return this.dependencies.durableEventJournalFor(workspaceId); - } - private shouldAllowLegacyInvalidWorkflowAgentOutputSchema( - metadata: WorkspaceMetadata - ): Promise { - return this.dependencies.shouldAllowLegacyInvalidWorkflowAgentOutputSchema(metadata); - } - private createModel( - modelString: string, - muxProviderOptions?: MuxProviderOptions, - opts?: { agentInitiated?: boolean; workspaceId?: string; providersConfig?: ProvidersConfig } - ) { - return this.dependencies.createModel(modelString, muxProviderOptions, opts); - } - private isStreaming(workspaceId: string): boolean { - return this.dependencies.isStreaming(workspaceId); - } - private trackPendingDevToolsRunMetadata( - messageId: string, - workspaceId: string, - metadataId: string - ): void { - this.dependencies.trackPendingDevToolsRunMetadata(messageId, workspaceId, metadataId); - } - private resolveOverridesIdentity( rawModelString: string, canonical: string, @@ -778,7 +627,7 @@ export class TurnRequestBuilder { options.optionsModelString, options.effectiveThinkingLevel, options.providerRequestMessages, - (id) => this.streamManager.isResponseIdLost(id), + (id) => this.dependencies.streamManager.isResponseIdLost(id), options.muxProviderOptions, options.workspaceId, options.truncationMode, @@ -804,7 +653,7 @@ export class TurnRequestBuilder { ); const resolvedOverrides = resolveModelParameterOverrides( pinCoderInstanceRawProvidersConfig( - this.config.loadProvidersConfig(), + this.dependencies.config.loadProvidersConfig(), options.rawModelString, options.coderSelectedInstance ), @@ -856,7 +705,7 @@ export class TurnRequestBuilder { options.optionsModelString, effective, options.providerRequestMessages, - (id) => this.streamManager.isResponseIdLost(id), + (id) => this.dependencies.streamManager.isResponseIdLost(id), options.muxProviderOptions, options.workspaceId, options.truncationMode, @@ -923,7 +772,8 @@ export class TurnRequestBuilder { } = opts; const experiments: StreamMessageOptions["experiments"] = resolveBackendGatedPtcExperiments( experimentsFromOptions, - (experimentId) => this.experimentsService?.isExperimentEnabled(experimentId) === true + (experimentId) => + this.dependencies.experimentsService?.isExperimentEnabled(experimentId) === true ); const combinedAbortSignal = context.abortSignal; const syntheticMessageId = context.syntheticMessageId; @@ -933,7 +783,10 @@ export class TurnRequestBuilder { let pendingRunMetadataId: string | null = context.startupState.pendingRunMetadataId; const deleteAbortedPlaceholder = async (messageId: string): Promise => { - const deleteResult = await this.historyService.deleteMessage(workspaceId, messageId); + const deleteResult = await this.dependencies.historyService.deleteMessage( + workspaceId, + messageId + ); if (!deleteResult.success) { log.error( "Failed to delete aborted assistant placeholder (" + @@ -1047,7 +900,7 @@ export class TurnRequestBuilder { } const requestedThinkingLevel = options.requestedThinkingLevel ?? THINKING_LEVEL_OFF; - const preliminaryProvidersConfig = this.providerService.getConfig(); + const preliminaryProvidersConfig = this.dependencies.providerService.getConfig(); const preliminaryMinThinkingLevel = resolveMinimumThinkingLevel( options.rawModelString, options.minimumThinkingLevelOverride, @@ -1067,7 +920,7 @@ export class TurnRequestBuilder { ); const resolveAndCreateModelStartedAt = Date.now(); - const resolved = await this.providerModelFactory.resolveAndCreateModel( + const resolved = await this.dependencies.providerModelFactory.resolveAndCreateModel( options.rawModelString, preliminaryThinkingLevel, effectiveMuxProviderOptions, @@ -1081,7 +934,7 @@ export class TurnRequestBuilder { } const providersConfig = pinCoderInstanceProvidersConfig( - this.providerService.getConfig(), + this.dependencies.providerService.getConfig(), options.rawModelString, resolved.data.coderSelectedInstance ); @@ -1159,7 +1012,6 @@ export class TurnRequestBuilder { capabilityModelString, } = modelResult.data; - // Dump original messages for debugging log.debug_obj(`${workspaceId}/1_original_messages.json`, messages); // Context Boundary request slicing happens before empty-assistant filtering so @@ -1189,12 +1041,10 @@ export class TurnRequestBuilder { 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); - // Get workspace metadata to retrieve workspace path const getWorkspaceMetadataStartedAt = Date.now(); - const metadataResult = await this.getWorkspaceMetadata(workspaceId); + const metadataResult = await this.dependencies.getWorkspaceMetadata(workspaceId); recordStartupPhaseTiming("getWorkspaceMetadataMs", getWorkspaceMetadataStartedAt); if (!metadataResult.success) { return { type: "finished", result: Err({ type: "unknown", raw: metadataResult.error }) }; @@ -1202,8 +1052,8 @@ export class TurnRequestBuilder { const metadata = metadataResult.data; - if (this.policyService?.isEnforced()) { - if (!this.policyService.isRuntimeAllowed(metadata.runtimeConfig)) { + if (this.dependencies.policyService?.isEnforced()) { + if (!this.dependencies.policyService.isRuntimeAllowed(metadata.runtimeConfig)) { return { type: "finished", result: Err({ @@ -1277,7 +1127,7 @@ export class TurnRequestBuilder { detail: breadcrumb.detail, elapsedMs: Date.now() - startTime, }); - this.emit("runtime-status", { + this.dependencies.emit("runtime-status", { type: "runtime-status", workspaceId, phase: breadcrumb.phase, @@ -1287,7 +1137,10 @@ export class TurnRequestBuilder { }); }; - const runtimeContextResult = this.createWorkspaceRuntimeContext(workspaceId, metadata); + const runtimeContextResult = this.dependencies.createWorkspaceRuntimeContext( + workspaceId, + metadata + ); if (!runtimeContextResult.success) { return { type: "finished", result: Err(runtimeContextResult.error) }; } @@ -1298,10 +1151,13 @@ export class TurnRequestBuilder { // (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); + await this.dependencies.initStateManager.waitForInit(workspaceId, combinedAbortSignal); recordStartupPhaseTiming("waitForInitMs", waitForInitStartedAt); if (combinedAbortSignal.aborted) { - return { type: "finished", result: Ok(this.createAbortedTurnHandle(syntheticMessageId)) }; + return { + type: "finished", + result: Ok(this.dependencies.createAbortedTurnHandle(syntheticMessageId)), + }; } // Verify runtime is actually reachable after init completes. @@ -1314,7 +1170,7 @@ export class TurnRequestBuilder { signal: combinedAbortSignal, statusSink: (status) => { // Emit runtime-status events for frontend UX (StreamingBarrier) - this.emit("runtime-status", { + this.dependencies.emit("runtime-status", { type: "runtime-status", workspaceId, phase: status.phase, @@ -1332,7 +1188,6 @@ export class TurnRequestBuilder { 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. @@ -1345,7 +1200,7 @@ export class TurnRequestBuilder { errorType, acpPromptId, }); - this.emit("error", errorEvent); + this.dependencies.emit("error", errorEvent); onPreStartError?.(errorEvent); logSlowStreamStartup({ @@ -1372,31 +1227,37 @@ export class TurnRequestBuilder { ? await resolveMemoryContext(modelString, { includeHotMemories: false }) : undefined; - // Resolve agent definition, compute effective mode & tool policy. - const cfg = this.config.loadConfigOrDefault(); + const cfg = this.dependencies.config.loadConfigOrDefault(); const advisorExperimentEnabled = experiments?.advisorTool ?? - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.ADVISOR_TOOL) === true; + this.dependencies.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.ADVISOR_TOOL) === + true; const dynamicWorkflowsExperimentEnabled = experiments?.dynamicWorkflows ?? - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS) === true; + this.dependencies.experimentsService?.isExperimentEnabled( + EXPERIMENT_IDS.DYNAMIC_WORKFLOWS + ) === true; const memoryExperimentEnabled = experiments?.memory ?? - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY) === true; + this.dependencies.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY) === true; const timelineExperimentEnabled = - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.TIMELINE) === true; + this.dependencies.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.TIMELINE) === true; const workspaceHeartbeatsExperimentEnabled = experiments?.workspaceHeartbeats ?? - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS) === true; + this.dependencies.experimentsService?.isExperimentEnabled( + EXPERIMENT_IDS.WORKSPACE_HEARTBEATS + ) === true; const toolSearchExperimentEnabled = experiments?.toolSearch ?? - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.TOOL_SEARCH) === true; + this.dependencies.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.TOOL_SEARCH) === + true; const memoryHotSetExperimentEnabled = - this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.MEMORY_HOT_SET) === true; + 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.isClaudeSkillsCompatEnabled(); - const agentPluginsExperimentEnabled = this.isAgentPluginsEnabled(); + 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. @@ -1425,7 +1286,7 @@ export class TurnRequestBuilder { callerToolPolicy: toolPolicy, cfg, emitError: (event) => { - this.emit("error", event); + this.dependencies.emit("error", event); onPreStartError?.(event); }, isAdvisorExperimentEnabled: advisorExperimentEnabled, @@ -1450,7 +1311,7 @@ export class TurnRequestBuilder { effectiveToolPolicy, } = agentResult.data; const legacyModeForMetadata = getLegacyModeForAgentMetadata(effectiveAgentId, effectiveMode); - const projectTrusted = isWorkspaceProjectTrusted(this.config, metadata); + 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. @@ -1486,8 +1347,9 @@ export class TurnRequestBuilder { let mcpOverrides: WorkspaceMCPOverrides | undefined; const loadWorkspaceMcpOverridesStartedAt = Date.now(); try { - mcpOverrides = (await this.workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId)) - .overrides; + mcpOverrides = ( + await this.dependencies.workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId) + ).overrides; } catch (error) { log.warn("[MCP] Failed to load workspace MCP overrides; continuing without overrides", { workspaceId, @@ -1510,10 +1372,10 @@ export class TurnRequestBuilder { try { await agentPluginHookService.ensureWorkspaceHooks({ workspaceId, - sessionDir: this.config.getSessionDir(workspaceId), - journal: this.durableEventJournalFor(workspaceId), - enabled: this.isAgentPluginsEnabled(), - xumHome: this.config.rootDir, + 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, @@ -1522,10 +1384,9 @@ export class TurnRequestBuilder { 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( + const mcpServers = this.dependencies.bindings.mcpServerManager + ? await this.dependencies.bindings.mcpServerManager.listServers( metadata.projectPath, mcpOverrides, projectTrusted, @@ -1542,7 +1403,7 @@ export class TurnRequestBuilder { // `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); + 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. @@ -1582,16 +1443,21 @@ export class TurnRequestBuilder { }); recordStartupPhaseTiming("buildPlanInstructionsMs", buildPlanInstructionsStartedAt); - const xumScope = resolveXumToolScope(this.config, metadata, workspacePath, projectCheckoutRoot); + const xumScope = resolveXumToolScope( + this.dependencies.config, + metadata, + workspacePath, + projectCheckoutRoot + ); const workflowSkillStorageContext = resolveSkillStorageContext({ runtime, workspacePath, xumScope, - includeAgentPlugins: this.isAgentPluginsEnabled(), + includeAgentPlugins: this.dependencies.isAgentPluginsEnabled(), }); - const desktopSessionManager = this.desktopSessionManager; + const desktopSessionManager = this.dependencies.bindings.desktopSessionManager; let desktopCapabilityPromise: ReturnType | undefined; const loadDesktopCapability = desktopSessionManager == null @@ -1608,7 +1474,8 @@ export class TurnRequestBuilder { // 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 memoryToolEligible = + memoryExperimentEnabled && this.dependencies.bindings.memoryService !== undefined; const buildStreamSystemContextForToolset = ( toolset: { advisorToolAvailable: boolean; memoryToolAvailable: boolean }, modelStringForSystem: string = modelString, @@ -1628,7 +1495,7 @@ export class TurnRequestBuilder { planFilePath, modelString: modelStringForSystem, cfg, - providersConfig: this.providerService.getConfig(), + providersConfig: this.dependencies.providerService.getConfig(), mcpServers, xumScope, loadDesktopCapability, @@ -1653,13 +1520,11 @@ export class TurnRequestBuilder { 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); + ? mergeMultiProjectSecrets(metadata, this.dependencies.config) + : this.dependencies.config.getEffectiveSecrets(metadata.projectPath); - // Generate stream token and create temp directory for tools - const streamToken = this.streamManager.generateStreamToken(); + const streamToken = this.dependencies.streamManager.generateStreamToken(); let mcpTools: Record | undefined; let mcpToolServerNames: Record | undefined; @@ -1667,8 +1532,8 @@ export class TurnRequestBuilder { let mcpPromptRuntime: MCPPromptRuntime | undefined; let mcpSetupDurationMs = 0; - if (this.mcpServerManager) { - const mcpServerManager = this.mcpServerManager; + if (this.dependencies.bindings.mcpServerManager) { + const mcpServerManager = this.dependencies.bindings.mcpServerManager; const mcpToolSetupStartedAt = Date.now(); try { const result = await mcpServerManager.getToolsForWorkspace({ @@ -1710,10 +1575,12 @@ export class TurnRequestBuilder { toolSearchExperimentEnabled && Object.keys(mcpTools ?? {}).length > 0 ? {} : undefined; const createTempDirForStreamStartedAt = Date.now(); - const runtimeTempDir = await this.streamManager.createTempDirForStream(streamToken, runtime); + const runtimeTempDir = await this.dependencies.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, @@ -1729,8 +1596,8 @@ export class TurnRequestBuilder { // 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 (this.dependencies.sessionUsageService) { + const sessionUsage = await this.dependencies.sessionUsageService.getSessionUsage(workspaceId); if (sessionUsage) { const allUsage = sumUsageHistory(Object.values(sessionUsage.byModel)); sessionCostsUsd = getTotalCost(allUsage); @@ -1738,12 +1605,8 @@ export class TurnRequestBuilder { } recordStartupPhaseTiming("loadSessionUsageMs", loadSessionUsageStartedAt); - // Get model-specific tools with workspace path (correct for local or remote) emitStartupBreadcrumb("loading_tools"); - assert( - workspaceId.trim().length > 0, - "AIService.streamMessage requires a non-empty workspaceId" - ); + 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, @@ -1752,7 +1615,7 @@ export class TurnRequestBuilder { if (advisorToolEligible) { assert( advisorModelString.length > 0, - "AIService advisorModelString must be non-empty when advisor is eligible" + "advisorModelString must be non-empty when advisor is eligible" ); } // Mutable ref updated by StreamManager.prepareStep so the advisor tool reads the live @@ -1834,7 +1697,7 @@ export class TurnRequestBuilder { assert( cfg.advisorMaxOutputTokens == null || (Number.isInteger(cfg.advisorMaxOutputTokens) && cfg.advisorMaxOutputTokens > 0), - "AIService advisorMaxOutputTokens must be null, undefined, or a positive integer" + "advisorMaxOutputTokens must be null, undefined, or a positive integer" ); const advisorMaxOutputTokens = cfg.advisorMaxOutputTokens != null && cfg.advisorMaxOutputTokens > 0 @@ -1846,7 +1709,7 @@ export class TurnRequestBuilder { advisorModelString, cfg.advisorThinkingLevel ?? THINKING_LEVEL_OFF, undefined, - this.providerService.getConfig() + this.dependencies.providerService.getConfig() ); const runtimeType = getRuntimeType(metadata.runtimeConfig); const xumEnv = getXumEnv(metadata.projectPath, runtimeType, metadata.name, { @@ -1855,27 +1718,28 @@ export class TurnRequestBuilder { thinkingLevel: thinkingLevel ?? "off", costsUsd: sessionCostsUsd, }); - const getWorkflowProjectTrusted = () => isWorkspaceProjectTrusted(this.config, metadata); + const getWorkflowProjectTrusted = () => + isWorkspaceProjectTrusted(this.dependencies.config, metadata); const workflowService = - dynamicWorkflowsExperimentEnabled && this.taskService != null + dynamicWorkflowsExperimentEnabled && this.dependencies.bindings.taskService != null ? new WorkflowService({ runStore: new WorkflowRunStore({ - sessionDir: this.config.getSessionDir(workspaceId), + sessionDir: this.dependencies.config.getSessionDir(workspaceId), }), onRunStatusChanged: async (event) => { if (!isTerminalWorkflowRunStatus(event.status)) { - await this.taskService?.resetWorkflowRunTerminalAttention({ + await this.dependencies.bindings.taskService?.resetWorkflowRunTerminalAttention({ ownerWorkspaceId: event.workspaceId, runId: event.runId, }); } - await this.onWorkflowRunStatusChanged?.(event); + await this.dependencies.bindings.onWorkflowRunStatusChanged?.(event); }, runtimeFactory: new QuickJSRuntimeFactory(), taskAdapterFactory: (runId, workflowName) => new WorkflowTaskServiceAdapter({ - taskService: this.taskService!, + taskService: this.dependencies.bindings.taskService!, parentWorkspaceId: workspaceId, workflowRunId: runId, workflowName, @@ -1885,7 +1749,7 @@ export class TurnRequestBuilder { cwd: workspacePath, runtime, runtimeTempDir, - workspaceSessionDir: this.config.getSessionDir(workspaceId), + workspaceSessionDir: this.dependencies.config.getSessionDir(workspaceId), trusted: getWorkflowProjectTrusted(), }, getProjectTrusted: getWorkflowProjectTrusted, @@ -1902,7 +1766,7 @@ export class TurnRequestBuilder { workspacePath, projectSearchRoot: projectCheckoutRoot ?? workspacePath, projectTrusted: getWorkflowProjectTrusted(), - includeAgentPlugins: this.isAgentPluginsEnabled(), + includeAgentPlugins: this.dependencies.isAgentPluginsEnabled(), skillStorageContext: workflowSkillStorageContext, }), // Background workflow tools outlive the model turn that started them. Feed the @@ -1912,8 +1776,8 @@ export class TurnRequestBuilder { if (run.parentWorkflow != null) { return; } - if (this.taskService != null) { - await this.taskService.enqueueWorkflowRunTerminalAttention({ + if (this.dependencies.bindings.taskService != null) { + await this.dependencies.bindings.taskService.enqueueWorkflowRunTerminalAttention({ ownerWorkspaceId: workspaceId, runId, status, @@ -1921,7 +1785,8 @@ export class TurnRequestBuilder { return; } - const continuationSender = this.workflowResultContinuationSender; + const continuationSender = + this.dependencies.bindings.workflowResultContinuationSender; if (continuationSender == null) { log.warn("Workflow completed but no continuation sender is configured", { workspaceId, @@ -1946,7 +1811,7 @@ export class TurnRequestBuilder { runId ); if (!invocationCurrent) { - if (this.isStreaming(workspaceId)) { + if (this.dependencies.isStreaming(workspaceId)) { await waitForWorkflowContinuationRetry(); continue; } @@ -2004,7 +1869,8 @@ export class TurnRequestBuilder { await waitForWorkflowContinuationRetry(); } }, - getCurrentProjectTrusted: () => isWorkspaceProjectTrusted(this.config, metadata), + getCurrentProjectTrusted: () => + isWorkspaceProjectTrusted(this.dependencies.config, metadata), runnerId: `workflow-runner:${workspaceId}`, }) : undefined; @@ -2014,7 +1880,7 @@ export class TurnRequestBuilder { // (after the abort check). const assistantMessageId = createAssistantMessageId(); const allowLegacyInvalidWorkflowAgentOutputSchema = - await this.shouldAllowLegacyInvalidWorkflowAgentOutputSchema(metadata); + 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 = { @@ -2035,7 +1901,7 @@ export class TurnRequestBuilder { const messages = advisorTranscriptRef.messages; assert( messages != null, - "AIService advisor transcript ref must be populated before advisor execution" + "advisor transcript ref must be populated before advisor execution" ); return messages; }, @@ -2063,15 +1929,19 @@ export class TurnRequestBuilder { // 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() ?? {}; + 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.providerService.getConfig(); - const advisorModel = await this.createModel(advisorModelString, undefined, { - workspaceId, - providersConfig: advisorProvidersConfig, - }); + 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)}` @@ -2087,7 +1957,7 @@ export class TurnRequestBuilder { // options derived from the raw selection (instance type) // would diverge from the model actually created. const advisorEffectiveModelString = - this.providerModelFactory.resolveEffectiveModelString( + this.dependencies.providerModelFactory.resolveEffectiveModelString( advisorModelString, undefined, advisorProvidersConfig @@ -2149,7 +2019,7 @@ export class TurnRequestBuilder { openaiWireFormat: effectiveMuxProviderOptions?.openai?.wireFormat, xaiNativeToolsEnabled: routeProvider === "xai", xaiSearchParameters: effectiveMuxProviderOptions.xai?.searchParameters, - backgroundProcessManager: this.backgroundProcessManager, + 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 @@ -2160,21 +2030,23 @@ export class TurnRequestBuilder { return; } if (event.type === "workflow-run-attached") { - return this.streamManager.attachWorkflowRunToToolCall(event).then(() => { - this.emit(event.type, event as never); + return this.dependencies.streamManager.attachWorkflowRunToToolCall(event).then(() => { + this.dependencies.emit(event.type, event as never); }); } - this.emit(event.type, event as never); + this.dependencies.emit(event.type, event as never); }, workspaceProjectPath: metadata.projectPath, workspaceExecutionRootPath: metadata.subProjectPath ?? metadata.projectPath, - workspaceSessionDir: this.config.getSessionDir(workspaceId), + workspaceSessionDir: this.dependencies.config.getSessionDir(workspaceId), planFilePath, ancestorPlanFilePaths, workspaceId, xumScope, - timelineService: timelineExperimentEnabled ? this.timelineService : undefined, - workspaceHeartbeatService: this.workspaceHeartbeatService, + timelineService: timelineExperimentEnabled + ? this.dependencies.bindings.timelineService + : undefined, + workspaceHeartbeatService: this.dependencies.bindings.workspaceHeartbeatService, workflowService, goalService: workspaceGoalService, goalDefaults: effectiveGoalDefaults, @@ -2198,7 +2070,6 @@ export class TurnRequestBuilder { ), workflowAgentOutputSchema: metadata.workflowTask?.outputSchema, allowLegacyInvalidWorkflowAgentOutputSchema, - // External edit detection callback recordFileState, reportModelUsage: (event) => { try { @@ -2216,8 +2087,8 @@ export class TurnRequestBuilder { const pinnedMetadataModel = toolModelMetadataModelByModelString.get(eventModel); const metadataModel = pinnedMetadataModel ?? - resolveModelForMetadata(eventModel, this.providerService.getConfig()); - this.streamManager.recordToolModelUsage(workspaceId, assistantMessageId, { + resolveModelForMetadata(eventModel, this.dependencies.providerService.getConfig()); + this.dependencies.streamManager.recordToolModelUsage(workspaceId, assistantMessageId, { toolName: event.toolName, toolCallId: event.toolCallId, timestamp: event.timestamp, @@ -2228,7 +2099,7 @@ export class TurnRequestBuilder { }); void (async () => { try { - if (!this.sessionUsageService) { + if (!this.dependencies.sessionUsageService) { return; } const displayUsage = createDisplayUsage( @@ -2248,9 +2119,16 @@ export class TurnRequestBuilder { const canonicalModel = eventModel.startsWith("coder:") && pinnedMetadataModel ? pinnedMetadataModel - : normalizeUsageModelKey(eventModel, this.providerService.getConfig()); - await this.sessionUsageService.recordUsage(workspaceId, canonicalModel, displayUsage); - this.emit("session-usage-delta", { + : 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, @@ -2275,14 +2153,14 @@ export class TurnRequestBuilder { }); } }, - onConfigChanged: () => this.providerService.notifyConfigChanged(), - taskService: this.taskService, - analyticsService: this.analyticsService, - desktopSessionManager: this.desktopSessionManager, + 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.memoryService, + memoryService: this.dependencies.bindings.memoryService, memoryAccess: resolveMemoryAccessPolicy({ planLike: agentIsPlanLike, editingCapable: isExecLikeEditingCapableInResolvedChain(agentInheritanceChain), @@ -2310,7 +2188,7 @@ export class TurnRequestBuilder { }; const emitNestedPtcToolEvent = (event: PTCEventWithParent) => { if (event.type === "tool-call-start" || event.type === "tool-call-end") { - this.streamManager.emitNestedToolEvent(workspaceId, assistantMessageId, event); + this.dependencies.streamManager.emitNestedToolEvent(workspaceId, assistantMessageId, event); } }; const kernelFileLoader = createKernelFileLoader({ @@ -2363,7 +2241,7 @@ export class TurnRequestBuilder { xaiNativeToolsEnabled: seed.routeProvider === "xai", }, workspaceId, - this.initStateManager, + this.dependencies.initStateManager, toolInstructions, mcpTools ); @@ -2373,14 +2251,18 @@ export class TurnRequestBuilder { const applyPolicyStartedAt = Date.now(); let attemptTools = await applyToolPolicyAndExperiments({ - allTools: this.wrapToolsForDelegation(workspaceId, allTools, delegatedToolNames), - extraTools: this.extraTools, + allTools: this.dependencies.wrapToolsForDelegation( + workspaceId, + allTools, + delegatedToolNames + ), + extraTools: this.dependencies.bindings.extraTools, effectiveToolPolicy, experiments, emitNestedToolEvent: emitNestedPtcToolEvent, sandbox: { workspaceId, - sessionDir: this.config.getSessionDir(workspaceId), + sessionDir: this.dependencies.config.getSessionDir(workspaceId), kernelFileLoader, }, }); @@ -2531,7 +2413,7 @@ export class TurnRequestBuilder { providerOptionsForEnvelope: unknown ): Promise => { await emitTurnEnvelope({ - journal: this.durableEventJournalFor(workspaceId), + journal: this.dependencies.durableEventJournalFor(workspaceId), workspaceId, systemMessage: attemptSystem, tools: Object.fromEntries( @@ -2632,7 +2514,7 @@ export class TurnRequestBuilder { const finalMessages = primaryRequest.messages; captureMcpToolTelemetry({ - telemetryService: this.telemetryService, + telemetryService: this.dependencies.telemetryService, mcpStats, mcpTools, tools, @@ -2645,7 +2527,10 @@ export class TurnRequestBuilder { }); if (combinedAbortSignal.aborted) { - return { type: "finished", result: Ok(this.createAbortedTurnHandle(assistantMessageId)) }; + return { + type: "finished", + result: Ok(this.dependencies.createAbortedTurnHandle(assistantMessageId)), + }; } const assistantMessage = createMuxMessage(assistantMessageId, "assistant", "", { @@ -2657,13 +2542,14 @@ export class TurnRequestBuilder { agentId: effectiveAgentId, }); - // Append to history to get historySequence assigned - const appendResult = await this.historyService.appendToHistory(workspaceId, assistantMessage); + const appendResult = await this.dependencies.historyService.appendToHistory( + workspaceId, + assistantMessage + ); if (!appendResult.success) { return { type: "finished", result: Err({ type: "unknown", raw: appendResult.error }) }; } - // Get the assigned historySequence const historySequence = assistantMessage.metadata?.historySequence ?? 0; // Handle simulated stream scenarios (OpenAI SDK testing features). @@ -2688,24 +2574,36 @@ export class TurnRequestBuilder { effectiveMode, metadataMode: legacyModeForMetadata, effectiveThinkingLevel, - emit: (event, data) => this.emit(event, data), + 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.historyService); + const streamError = await simulateContextLimitError( + simulationCtx, + this.dependencies.historyService + ); return { type: "finished", result: Ok( - this.createSettledTurnHandle(assistantMessageId, { status: "failed", streamError }) + this.dependencies.createSettledTurnHandle(assistantMessageId, { + status: "failed", + streamError, + }) ), }; } - await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); + await simulateToolPolicyNoop( + simulationCtx, + effectiveToolPolicy, + this.dependencies.historyService + ); return { type: "finished", - result: Ok(this.createSettledTurnHandle(assistantMessageId, { status: "completed" })), + result: Ok( + this.dependencies.createSettledTurnHandle(assistantMessageId, { status: "completed" }) + ), }; } @@ -2753,10 +2651,12 @@ export class TurnRequestBuilder { if (combinedAbortSignal.aborted) { await deleteAbortedPlaceholder(assistantMessageId); - return { type: "finished", result: Ok(this.createAbortedTurnHandle(assistantMessageId)) }; + return { + type: "finished", + result: Ok(this.dependencies.createAbortedTurnHandle(assistantMessageId)), + }; } - // Capture request payload for the debug modal, then delegate to StreamManager. const snapshot: DebugLlmRequestSnapshot = { capturedAt: Date.now(), workspaceId, @@ -2772,15 +2672,16 @@ export class TurnRequestBuilder { }; try { - this.lastLlmRequestByWorkspace.set(workspaceId, structuredClone(snapshot)); + 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 = - this.devToolsService?.enabled === true && + devToolsService?.enabled === true && typeof modelResult.data.model !== "string" && modelResult.data.model.specificationVersion === "v4"; @@ -2790,7 +2691,7 @@ export class TurnRequestBuilder { // when middleware is guaranteed to run (LanguageModelV3). pendingRunMetadataId = String(streamToken); context.startupState.pendingRunMetadataId = pendingRunMetadataId; - this.devToolsService.setPendingRunMetadata(workspaceId, pendingRunMetadataId, { + devToolsService.setPendingRunMetadata(workspaceId, pendingRunMetadataId, { toolPolicy: effectiveToolPolicy != null && effectiveToolPolicy.length > 0 ? effectiveToolPolicy @@ -2799,7 +2700,11 @@ export class TurnRequestBuilder { // its turn-envelope row and assistant message (see DevToolsRun). ...(requestHistorySequence >= 0 ? { requestHistorySequence } : {}), }); - this.trackPendingDevToolsRunMetadata(assistantMessageId, workspaceId, pendingRunMetadataId); + this.dependencies.trackPendingDevToolsRunMetadata( + assistantMessageId, + workspaceId, + pendingRunMetadataId + ); requestHeaders = { ...requestHeaders, [DEVTOOLS_RUN_METADATA_ID_HEADER]: pendingRunMetadataId, @@ -2813,10 +2718,10 @@ export class TurnRequestBuilder { // children can opt out via taskOnRefusal: "fail" (see // resolveWorkspaceModelFallbackChain). const modelFallbackChain = resolveWorkspaceModelFallbackChain( - this.config.loadConfigOrDefault(), + this.dependencies.config.loadConfigOrDefault(), workspaceId, modelString, - this.providerService.getConfig() + this.dependencies.providerService.getConfig() ); // Lazily rebuilds the per-model slice of this pipeline (model creation, @@ -2838,7 +2743,7 @@ export class TurnRequestBuilder { rawModelString: nextModelString, requestedThinkingLevel, minimumThinkingLevelOverride: lookupMinThinkingLevelOverride( - this.config.loadConfigOrDefault().minThinkingLevelByModel, + this.dependencies.config.loadConfigOrDefault().minThinkingLevelByModel, nextModelString ), enforceMinimum: true, @@ -2935,14 +2840,6 @@ export class TurnRequestBuilder { const emitPrimaryEnvelope = (): Promise => primaryRequest.emitEnvelopeWith(streamThinkingLevel, streamProviderOptions); - const rebuildFirstStepForThinkingLevel: RebuildFirstStepForThinkingLevel = async ( - effectiveLevel, - providerOptions - ) => { - const rebuiltMessages = await primaryRequest.rebuildMessagesForThinkingLevel(effectiveLevel); - await primaryRequest.emitEnvelopeWith(effectiveLevel, providerOptions); - return rebuiltMessages; - }; emitStartupBreadcrumb("starting_stream"); const turnExecutionOptions: TurnExecutionOptions = { workspaceId, @@ -2994,7 +2891,7 @@ export class TurnRequestBuilder { forcedFirstStepToolNames, providersConfigSnapshot: requestProvidersConfig, onStreamConstructed: emitPrimaryEnvelope, - rebuildFirstStepForThinkingLevel, + rebuildFirstStepForThinkingLevel: primaryRequest.rebuildFirstStepForThinkingLevel, }; const logStartOutcome = ( From af26364f9e377e92ccabfba25e9ce135df410303 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:40:48 +0000 Subject: [PATCH 19/22] tests: read init waits from the context initStateManager in router tests --- src/node/orpc/router.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index 553fe5d562..d7fc3b410f 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, From 64699b1abf6be00dbfe09774be5d163d28c07e4f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:59:37 +0000 Subject: [PATCH 20/22] tests: give multi-project and task harness mocks engine lifecycle access --- src/node/services/taskService.test.ts | 6 +++++- .../workspaceService.multiProject.test.ts | 15 ++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index f59325fa49..e4346b1ed3 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/workspaceService.multiProject.test.ts b/src/node/services/workspaceService.multiProject.test.ts index 3b19c4306a..78a7bb3764 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; From f26e69cece7b24c674a3ffeceb03f07f973beccb Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:30:37 +0000 Subject: [PATCH 21/22] refactor(stream): migrate relocated service callers off the deleted waitForInit pass-through The thin-router layer below relocated agent-discovery, plugin, skill, and workflow handlers out of router.ts; their aiService.waitForInit calls must follow the pass-through deletion to initStateManager. --- .../services/agentDefinitions/agentDefinitionsService.ts | 6 +++--- .../services/agentPlugins/workspacePluginOperations.ts | 6 +++--- src/node/services/agentSkills/agentSkillsService.ts | 7 +++++-- .../services/workflows/WorkflowService.context.test.ts | 2 +- src/node/services/workflows/WorkflowService.ts | 4 +++- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/node/services/agentDefinitions/agentDefinitionsService.ts b/src/node/services/agentDefinitions/agentDefinitionsService.ts index 4f003ef7ee..00302f9431 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 b8e41e82a7..8583eca56f 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/agentSkills/agentSkillsService.ts b/src/node/services/agentSkills/agentSkillsService.ts index 33b767b58d..2b2a6164c4 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/workflows/WorkflowService.context.test.ts b/src/node/services/workflows/WorkflowService.context.test.ts index f7acf4f86f..4111decf4e 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 0cfafe2b48..2430127843 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); From a2aa419a6e3a624c54d777b122d9042577b4bc5f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:15:50 +0000 Subject: [PATCH 22/22] =?UTF-8?q?=F0=9F=A4=96=20fix(diagnostics):=20scope?= =?UTF-8?q?=20prepareMessagesForProviderMs=20to=20the=20message=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slow-start timer in streamMessage wrapped the entire prepareModelRequest pipeline (tools, policy, system context, middleware, provider options), so prepareMessagesForProviderMs overlapped the separately recorded subphase timings and misattributed their time to message conversion. Record it inside the builder around only the prepareMessagesForProvider call, preserving the pre-refactor metric semantics. --- _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh` • Cost: `$383.31`_ --- src/node/services/turnRequestBuilder.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 4b4c2739f7..c9e67eca54 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -2364,6 +2364,7 @@ export class TurnRequestBuilder { const toolNamesForSentinel = ( computeActiveToolNames(toolSearchRuntime?.state) ?? Object.keys(attemptTools) ).sort(); + const prepareMessagesForProviderStartedAt = Date.now(); const finalMessages = await prepareMessagesForProvider({ messagesWithSentinel: addInterruptedSentinel(attemptProviderRequestMessages), effectiveAgentId, @@ -2378,6 +2379,12 @@ export class TurnRequestBuilder { anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl, workspaceId, }); + if (options.recordTimings) { + recordStartupPhaseTiming( + "prepareMessagesForProviderMs", + prepareMessagesForProviderStartedAt + ); + } const preparedAttempt = this.prepareModelAttempt({ rawModelString: seed.rawModelString, canonicalModelString: seed.canonicalModelString, @@ -2497,7 +2504,6 @@ export class TurnRequestBuilder { -1 ); emitStartupBreadcrumb("preparing_request"); - const prepareMessagesForProviderStartedAt = Date.now(); const primaryRequest = await prepareModelRequest({ seed: modelResult.data, sourceMessages: messages, @@ -2507,7 +2513,6 @@ export class TurnRequestBuilder { requestHistorySequence, recordTimings: true, }); - recordStartupPhaseTiming("prepareMessagesForProviderMs", prepareMessagesForProviderStartedAt); const tools = primaryRequest.tools; systemMessage = primaryRequest.system; systemMessageTokens = primaryRequest.systemMessageTokens;