diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 4a9806bc6e..0ee7ad1687 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -65,6 +65,8 @@ import { ExperimentsService } from "./experimentsService"; import type { DevToolsService } from "./devToolsService"; import { TelemetryService } from "@/node/services/telemetryService"; import type { WorkspaceGoalService } from "./workspaceGoalService"; +import type { WorkspaceMcpOverridesService } from "./workspaceMcpOverridesService"; +import * as additionalSystemContext from "./additionalSystemContext"; import * as agentResolution from "./agentResolution"; import * as streamContextBuilder from "./streamContextBuilder"; import * as messagePipeline from "./messagePipeline"; @@ -1442,6 +1444,69 @@ describe("AIService.streamMessage compaction boundary slicing", () => { mock.restore(); }); + it("starts independent workspace context loads without serial waiting", async () => { + using xumHome = new DisposableTempDir("ai-service-concurrent-workspace-context"); + const projectPath = path.join(xumHome.path, "project"); + await fs.mkdir(projectPath, { recursive: true }); + + const workspaceId = "workspace-concurrent-context"; + const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); + let mcpOverridesLoadStarted = false; + let scratchpadLoadStarted = false; + const goalLoadStarted = Promise.withResolvers(); + const goalLoad = Promise.withResolvers(); + const workspaceMcpOverridesService = { + getOverridesForWorkspace: mock(() => { + mcpOverridesLoadStarted = true; + return Promise.resolve({ overrides: undefined }); + }), + } as unknown as WorkspaceMcpOverridesService; + const { config, historyService, initStateManager, providerService } = createBasicAIService( + xumHome.path + ); + const concurrentService = new AIService( + config, + historyService, + initStateManager, + providerService, + undefined, + undefined, + workspaceMcpOverridesService + ); + stubCommonStreamMessageDependencies({ + service: concurrentService, + config, + historyService, + initStateManager, + metadata, + }); + spyOn(additionalSystemContext, "readAdditionalSystemContext").mockImplementation(() => { + scratchpadLoadStarted = true; + return Promise.resolve(null); + }); + const goalService = { + getGoal: mock(() => { + goalLoadStarted.resolve(); + return goalLoad.promise; + }), + } as unknown as WorkspaceGoalService; + + const streamPromise = concurrentService.streamMessage({ + messages: [createMuxMessage("latest-user", "user", "hello")], + workspaceId, + modelString: "openai:gpt-5.2", + thinkingLevel: "off", + workspaceGoalService: goalService, + }); + + await goalLoadStarted.promise; + expect(mcpOverridesLoadStarted).toBe(true); + expect(scratchpadLoadStarted).toBe(true); + + goalLoad.resolve(null); + expect((await streamPromise).success).toBe(true); + }); + it("keeps set_goal disabled for one-shot streams that do not opt into agent-created goals", async () => { using xumHome = new DisposableTempDir("ai-service-set-goal-disabled"); const projectPath = path.join(xumHome.path, "project"); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 5a1e543d85..5fcfb01452 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1988,12 +1988,60 @@ export class AIService extends EventEmitter { const advisorToolEligible = advisorExperimentEnabled && agentAdvisorEnabled && advisorModelString.length > 0; + // These independent workspace reads can all be slow (especially on remote filesystems), + // so start them together once agent resolution has established the request context. + const currentGoalForToolsPromise: Promise = workspaceGoalService + ? workspaceGoalService.getGoal(workspaceId) + : Promise.resolve(null); + + // Fetch workspace MCP overrides (for filtering servers and tools). + // NOTE: Stored in /.xum/mcp.local.jsonc (not ~/.xum/config.json). + const loadWorkspaceMcpOverridesStartedAt = Date.now(); + const mcpOverridesPromise: Promise = + this.workspaceMcpOverridesService + .getOverridesForWorkspace(workspaceId) + .then(({ overrides }) => overrides) + .catch((error: unknown) => { + log.warn("[MCP] Failed to load workspace MCP overrides; continuing without overrides", { + workspaceId, + error, + }); + return undefined; + }) + .finally(() => { + recordStartupPhaseTiming( + "loadWorkspaceMcpOverridesMs", + loadWorkspaceMcpOverridesStartedAt + ); + }); + + const loadAdditionalSystemContextStartedAt = Date.now(); + const workspaceAdditionalSystemContextPromise: Promise = ( + additionalSystemContext != null + ? Promise.resolve(additionalSystemContext) + : readAdditionalSystemContext(this.config, workspaceId) + .then(effectiveAdditionalSystemContext) + .catch((error: unknown) => { + // 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, + } + ); + return ""; + }) + ).finally(() => { + recordStartupPhaseTiming( + "loadAdditionalSystemContextMs", + loadAdditionalSystemContextStartedAt + ); + }); + // 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 currentGoalForTools = await currentGoalForToolsPromise; const effectiveGoalDefaults = mergeGoalDefaults( normalizeGoalDefaults(cfg.goalDefaults ?? DEFAULT_GOAL_DEFAULTS), metadata.goalDefaults ?? null @@ -2005,23 +2053,6 @@ export class AIService extends EventEmitter { 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 @@ -2047,7 +2078,9 @@ export class AIService extends EventEmitter { log.warn("Agent plugin hooks: ensure failed; continuing without plugin hooks", { error }); } - // Fetch MCP server config for system prompt (before building message). + // Fetch MCP server config for system prompt (before building message). Awaiting the + // override read here still overlaps it with the goal and scratchpad reads above. + const mcpOverrides = await mcpOverridesPromise; const listMcpServersStartedAt = Date.now(); const mcpServers = this.mcpServerManager ? await this.mcpServerManager.listServers( @@ -2059,33 +2092,13 @@ export class AIService extends EventEmitter { : 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 = ""; - } - } + // 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 scratchpadAdditionalSystemInstructions = mergeAdditionalSystemInstructions( - workspaceAdditionalSystemContext, + await workspaceAdditionalSystemContextPromise, 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