diff --git a/src/browser/components/WorkspaceStatusDot.tsx b/src/browser/components/WorkspaceStatusDot.tsx index 848c77c1f8..8b646bd757 100644 --- a/src/browser/components/WorkspaceStatusDot.tsx +++ b/src/browser/components/WorkspaceStatusDot.tsx @@ -1,6 +1,7 @@ import { cn } from "@/common/lib/utils"; import { useWorkspaceSidebarState } from "@/browser/stores/WorkspaceStore"; import { getStatusTooltip } from "@/browser/utils/ui/statusTooltip"; +import { isPlanAutoRoutingStatus } from "@/common/constants/planAutoRoutingStatus"; import { memo, useMemo } from "react"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; @@ -20,7 +21,8 @@ export const WorkspaceStatusDot = memo<{ recencyTimestamp, } = useWorkspaceSidebarState(workspaceId); - const isWorking = (canInterrupt || isStarting) && !awaitingUserQuestion; + const isWorking = + (canInterrupt || isStarting || isPlanAutoRoutingStatus(agentStatus)) && !awaitingUserQuestion; // Compute unread status if lastReadTimestamp provided (sidebar only) const unread = useMemo(() => { diff --git a/src/browser/components/icons/EmojiIcon.tsx b/src/browser/components/icons/EmojiIcon.tsx index 2765ad82bb..16db78cd8c 100644 --- a/src/browser/components/icons/EmojiIcon.tsx +++ b/src/browser/components/icons/EmojiIcon.tsx @@ -48,6 +48,8 @@ const EMOJI_TO_ICON: Record = { "🔗": Link, "🔄": RefreshCw, "🧪": Beaker, + // Used by auto-handoff routing status while selecting the executor. + "🤔": CircleHelp, // Directions "➡": ArrowRight, diff --git a/src/browser/stories/App.chat.stories.tsx b/src/browser/stories/App.chat.stories.tsx index 24d6c27b99..9d178a4f25 100644 --- a/src/browser/stories/App.chat.stories.tsx +++ b/src/browser/stories/App.chat.stories.tsx @@ -40,6 +40,10 @@ import { TooltipTrigger, } from "@/browser/components/ui/tooltip"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; +import { + PLAN_AUTO_ROUTING_STATUS_EMOJI, + PLAN_AUTO_ROUTING_STATUS_MESSAGE, +} from "@/common/constants/planAutoRoutingStatus"; export default { ...appMeta, @@ -1722,6 +1726,84 @@ graph TD }, }; +/** + * Captures the handoff pause after a plan is presented and before the executor stream starts. + * + * This reproduces the visual state where the sidebar shows "Deciding execution strategy…" + * while the proposed plan remains visible in the conversation. + */ +export const ProposePlanAutoRoutingDecisionGap: AppStory = { + render: () => ( + + setupSimpleChatStory({ + workspaceId: "ws-plan-auto-routing-gap", + workspaceName: "feature/plan-auto-routing", + messages: [ + createUserMessage( + "msg-1", + "Plan and implement a safe migration rollout for auth tokens.", + { + historySequence: 1, + timestamp: STABLE_TIMESTAMP - 240000, + } + ), + createAssistantMessage("msg-2", "Here is the implementation plan.", { + historySequence: 2, + timestamp: STABLE_TIMESTAMP - 230000, + toolCalls: [ + createProposePlanTool( + "call-plan-1", + `# Auth Token Migration Rollout + +## Goals + +- Migrate token validation to the new signing service. +- Maintain compatibility during rollout. +- Keep rollback simple and low risk. + +## Steps + +1. Add dual-read token validation behind a feature flag. +2. Ship telemetry for token verification outcomes. +3. Enable new validator for 10% of traffic. +4. Ramp to 100% after stability checks. +5. Remove legacy validator once metrics stay healthy. + +## Rollback + +- Disable the rollout flag to return to legacy validation immediately. +- Keep telemetry running to confirm recovery.` + ), + ], + }), + createAssistantMessage("msg-3", "Selecting the right executor for this plan.", { + historySequence: 3, + timestamp: STABLE_TIMESTAMP - 220000, + toolCalls: [ + createStatusTool( + "call-status-1", + PLAN_AUTO_ROUTING_STATUS_EMOJI, + PLAN_AUTO_ROUTING_STATUS_MESSAGE + ), + ], + }), + ], + }) + } + /> + ), + parameters: { + docs: { + description: { + story: + "Chromatic regression story for the plan auto-routing gap: after `propose_plan` succeeds, " + + "the sidebar stays in a working state with a 'Deciding execution strategy…' status before executor kickoff.", + }, + }, + }, +}; + /** * Mobile viewport version of ProposePlan. * diff --git a/src/common/constants/planAutoRoutingStatus.ts b/src/common/constants/planAutoRoutingStatus.ts new file mode 100644 index 0000000000..3dcfdd8959 --- /dev/null +++ b/src/common/constants/planAutoRoutingStatus.ts @@ -0,0 +1,15 @@ +import type { WorkspaceActivitySnapshot } from "@/common/types/workspace"; + +// Auto plan->executor routing can spend up to the router timeout selecting an executor. +// We surface this as a transient sidebar status so users know the handoff is still progressing. +export const PLAN_AUTO_ROUTING_STATUS_EMOJI = "🤔"; +export const PLAN_AUTO_ROUTING_STATUS_MESSAGE = "Deciding execution strategy…"; + +export function isPlanAutoRoutingStatus( + agentStatus: WorkspaceActivitySnapshot["agentStatus"] | null | undefined +): boolean { + return ( + agentStatus?.emoji === PLAN_AUTO_ROUTING_STATUS_EMOJI && + agentStatus.message === PLAN_AUTO_ROUTING_STATUS_MESSAGE + ); +} diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 61d39693d1..f7c20e0f33 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -20,6 +20,10 @@ import { defaultModel } from "@/common/utils/ai/models"; import type { PlanSubagentExecutorRouting } from "@/common/types/tasks"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { StreamEndEvent } from "@/common/types/stream"; +import { + PLAN_AUTO_ROUTING_STATUS_EMOJI, + PLAN_AUTO_ROUTING_STATUS_MESSAGE, +} from "@/common/constants/planAutoRoutingStatus"; import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import type { AIService } from "@/node/services/aiService"; @@ -157,6 +161,7 @@ function createWorkspaceServiceMocks( emit: ReturnType; getInfo: ReturnType; replaceHistory: ReturnType; + updateAgentStatus: ReturnType; }> ): { workspaceService: WorkspaceService; @@ -166,6 +171,7 @@ function createWorkspaceServiceMocks( emit: ReturnType; getInfo: ReturnType; replaceHistory: ReturnType; + updateAgentStatus: ReturnType; } { const sendMessage = overrides?.sendMessage ?? mock((): Promise> => Promise.resolve(Ok(undefined))); @@ -178,6 +184,8 @@ function createWorkspaceServiceMocks( const getInfo = overrides?.getInfo ?? mock(() => Promise.resolve(null)); const replaceHistory = overrides?.replaceHistory ?? mock((): Promise> => Promise.resolve(Ok(undefined))); + const updateAgentStatus = + overrides?.updateAgentStatus ?? mock((): Promise => Promise.resolve()); return { workspaceService: { @@ -187,6 +195,7 @@ function createWorkspaceServiceMocks( emit, getInfo, replaceHistory, + updateAgentStatus, } as unknown as WorkspaceService, sendMessage, resumeStream, @@ -194,6 +203,7 @@ function createWorkspaceServiceMocks( emit, getInfo, replaceHistory, + updateAgentStatus, }; } @@ -3477,7 +3487,7 @@ describe("TaskService", () => { namedWorkspacePath: childWorkspacePath, })); const replaceHistory = mock((): Promise> => Promise.resolve(Ok(undefined))); - const { workspaceService, sendMessage } = createWorkspaceServiceMocks({ + const { workspaceService, sendMessage, updateAgentStatus } = createWorkspaceServiceMocks({ getInfo, replaceHistory, sendMessage: options?.sendMessageOverride, @@ -3496,6 +3506,7 @@ describe("TaskService", () => { sendMessage, replaceHistory, createModel, + updateAgentStatus, internal, }; } @@ -3687,7 +3698,7 @@ describe("TaskService", () => { }); test("stream-end with propose_plan success in auto routing falls back to exec when plan content is unavailable", async () => { - const { config, childId, sendMessage, createModel, internal } = + const { config, childId, sendMessage, createModel, updateAgentStatus, internal } = await setupPlanModeStreamEndHarness({ planSubagentExecutorRouting: "auto", }); @@ -3702,6 +3713,16 @@ describe("TaskService", () => { expect.objectContaining({ agentId: "exec" }), expect.objectContaining({ synthetic: true }) ); + expect(updateAgentStatus).toHaveBeenNthCalledWith( + 1, + childId, + expect.objectContaining({ + emoji: PLAN_AUTO_ROUTING_STATUS_EMOJI, + message: PLAN_AUTO_ROUTING_STATUS_MESSAGE, + url: "", + }) + ); + expect(updateAgentStatus).toHaveBeenNthCalledWith(2, childId, null); const postCfg = config.loadConfigOrDefault(); const updatedTask = Array.from(postCfg.projects.values()) diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 37e446e496..fee18682a7 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -56,6 +56,10 @@ import { import { isPlanLikeInResolvedChain } from "@/common/utils/agentTools"; import { formatSendMessageError } from "@/node/services/utils/sendMessageError"; import { enforceThinkingPolicy } from "@/common/utils/thinking/policy"; +import { + PLAN_AUTO_ROUTING_STATUS_EMOJI, + PLAN_AUTO_ROUTING_STATUS_MESSAGE, +} from "@/common/constants/planAutoRoutingStatus"; import { taskQueueDebug } from "@/node/services/taskQueueDebug"; import { readSubagentGitPatchArtifact } from "@/node/services/subagentGitPatchArtifacts"; import { @@ -2480,21 +2484,41 @@ export class TaskService { }); } - const targetAgentId = await this.resolvePlanAutoHandoffTargetAgentId({ - workspaceId: args.workspaceId, - entry: { - projectPath: args.entry.projectPath, - workspace: { - id: args.entry.workspace.id, - name: args.entry.workspace.name, - path: args.entry.workspace.path, - runtimeConfig: args.entry.workspace.runtimeConfig, - taskModelString: args.entry.workspace.taskModelString, - }, - }, - routing: args.planSubagentExecutorRouting, - planContent: planSummary?.content ?? null, - }); + const targetAgentId = await (async () => { + const shouldShowRoutingStatus = args.planSubagentExecutorRouting === "auto"; + if (shouldShowRoutingStatus) { + // Auto routing can pause for up to the LLM timeout; surface progress in the sidebar. + await this.workspaceService.updateAgentStatus(args.workspaceId, { + emoji: PLAN_AUTO_ROUTING_STATUS_EMOJI, + message: PLAN_AUTO_ROUTING_STATUS_MESSAGE, + // ExtensionMetadataService carries forward the previous status URL when url is omitted. + // Use an explicit empty string sentinel to clear stale links for this transient status. + url: "", + }); + } + + try { + return await this.resolvePlanAutoHandoffTargetAgentId({ + workspaceId: args.workspaceId, + entry: { + projectPath: args.entry.projectPath, + workspace: { + id: args.entry.workspace.id, + name: args.entry.workspace.name, + path: args.entry.workspace.path, + runtimeConfig: args.entry.workspace.runtimeConfig, + taskModelString: args.entry.workspace.taskModelString, + }, + }, + routing: args.planSubagentExecutorRouting, + planContent: planSummary?.content ?? null, + }); + } finally { + if (shouldShowRoutingStatus) { + await this.workspaceService.updateAgentStatus(args.workspaceId, null); + } + } + })(); const summaryContent = planSummary ? `# Plan\n\n${planSummary.content}\n\nNote: This chat already contains the full plan; no need to re-open the plan file.\n\n---\n\n*Plan file preserved at:* \`${planSummary.path}\`` diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 6865b45ce5..f3998ce4af 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1162,7 +1162,7 @@ export class WorkspaceService extends EventEmitter { } } - private async updateAgentStatus( + public async updateAgentStatus( workspaceId: string, agentStatus: WorkspaceAgentStatus | null ): Promise {