From 95675711a02366d5d99ae14eb353cece331cca16 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 22 Feb 2026 12:38:40 +0000 Subject: [PATCH 1/3] Show plan auto-routing status and add chromatic story --- src/browser/components/WorkspaceStatusDot.tsx | 2 +- src/browser/components/icons/EmojiIcon.tsx | 2 + src/browser/stories/App.chat.stories.tsx | 72 +++++++++++++++++++ src/node/services/taskService.test.ts | 20 +++++- src/node/services/taskService.ts | 47 ++++++++---- src/node/services/workspaceService.ts | 2 +- 6 files changed, 126 insertions(+), 19 deletions(-) diff --git a/src/browser/components/WorkspaceStatusDot.tsx b/src/browser/components/WorkspaceStatusDot.tsx index 848c77c1f8..e512b66d08 100644 --- a/src/browser/components/WorkspaceStatusDot.tsx +++ b/src/browser/components/WorkspaceStatusDot.tsx @@ -20,7 +20,7 @@ export const WorkspaceStatusDot = memo<{ recencyTimestamp, } = useWorkspaceSidebarState(workspaceId); - const isWorking = (canInterrupt || isStarting) && !awaitingUserQuestion; + const isWorking = (canInterrupt || isStarting || Boolean(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..25053f068d 100644 --- a/src/browser/stories/App.chat.stories.tsx +++ b/src/browser/stories/App.chat.stories.tsx @@ -1722,6 +1722,78 @@ 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", "🤔", "Deciding execution strategy…")], + }), + ], + }) + } + /> + ), + 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/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 61d39693d1..c9f873b56a 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -157,6 +157,7 @@ function createWorkspaceServiceMocks( emit: ReturnType; getInfo: ReturnType; replaceHistory: ReturnType; + updateAgentStatus: ReturnType; }> ): { workspaceService: WorkspaceService; @@ -166,6 +167,7 @@ function createWorkspaceServiceMocks( emit: ReturnType; getInfo: ReturnType; replaceHistory: ReturnType; + updateAgentStatus: ReturnType; } { const sendMessage = overrides?.sendMessage ?? mock((): Promise> => Promise.resolve(Ok(undefined))); @@ -178,6 +180,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 +191,7 @@ function createWorkspaceServiceMocks( emit, getInfo, replaceHistory, + updateAgentStatus, } as unknown as WorkspaceService, sendMessage, resumeStream, @@ -194,6 +199,7 @@ function createWorkspaceServiceMocks( emit, getInfo, replaceHistory, + updateAgentStatus, }; } @@ -3477,7 +3483,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 +3502,7 @@ describe("TaskService", () => { sendMessage, replaceHistory, createModel, + updateAgentStatus, internal, }; } @@ -3687,7 +3694,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 +3709,15 @@ describe("TaskService", () => { expect.objectContaining({ agentId: "exec" }), expect.objectContaining({ synthetic: true }) ); + expect(updateAgentStatus).toHaveBeenNthCalledWith( + 1, + childId, + expect.objectContaining({ + emoji: "🤔", + message: "Deciding execution strategy…", + }) + ); + 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..d0c7fd72bf 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -2480,21 +2480,38 @@ 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: "🤔", + message: "Deciding execution strategy…", + }); + } + + 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 { From d4dba38a1b3a828582447679257eb58c020324d9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 22 Feb 2026 12:59:44 +0000 Subject: [PATCH 2/3] Refine routing pulse logic to transient auto-handoff status --- src/browser/components/WorkspaceStatusDot.tsx | 4 +++- src/browser/stories/App.chat.stories.tsx | 12 +++++++++++- src/common/constants/planAutoRoutingStatus.ts | 15 +++++++++++++++ src/node/services/taskService.test.ts | 8 ++++++-- src/node/services/taskService.ts | 8 ++++++-- 5 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 src/common/constants/planAutoRoutingStatus.ts diff --git a/src/browser/components/WorkspaceStatusDot.tsx b/src/browser/components/WorkspaceStatusDot.tsx index e512b66d08..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 || Boolean(agentStatus)) && !awaitingUserQuestion; + const isWorking = + (canInterrupt || isStarting || isPlanAutoRoutingStatus(agentStatus)) && !awaitingUserQuestion; // Compute unread status if lastReadTimestamp provided (sidebar only) const unread = useMemo(() => { diff --git a/src/browser/stories/App.chat.stories.tsx b/src/browser/stories/App.chat.stories.tsx index 25053f068d..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, @@ -1776,7 +1780,13 @@ export const ProposePlanAutoRoutingDecisionGap: AppStory = { createAssistantMessage("msg-3", "Selecting the right executor for this plan.", { historySequence: 3, timestamp: STABLE_TIMESTAMP - 220000, - toolCalls: [createStatusTool("call-status-1", "🤔", "Deciding execution strategy…")], + toolCalls: [ + createStatusTool( + "call-status-1", + PLAN_AUTO_ROUTING_STATUS_EMOJI, + PLAN_AUTO_ROUTING_STATUS_MESSAGE + ), + ], }), ], }) 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 c9f873b56a..416d9207de 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"; @@ -3713,8 +3717,8 @@ describe("TaskService", () => { 1, childId, expect.objectContaining({ - emoji: "🤔", - message: "Deciding execution strategy…", + emoji: PLAN_AUTO_ROUTING_STATUS_EMOJI, + message: PLAN_AUTO_ROUTING_STATUS_MESSAGE, }) ); expect(updateAgentStatus).toHaveBeenNthCalledWith(2, childId, null); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index d0c7fd72bf..fc50ed07d3 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 { @@ -2485,8 +2489,8 @@ export class TaskService { if (shouldShowRoutingStatus) { // Auto routing can pause for up to the LLM timeout; surface progress in the sidebar. await this.workspaceService.updateAgentStatus(args.workspaceId, { - emoji: "🤔", - message: "Deciding execution strategy…", + emoji: PLAN_AUTO_ROUTING_STATUS_EMOJI, + message: PLAN_AUTO_ROUTING_STATUS_MESSAGE, }); } From 9d86cfc964490654380737db70c6fd23d13e5399 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 22 Feb 2026 13:11:58 +0000 Subject: [PATCH 3/3] Clear stale status URL during auto-routing handoff --- src/node/services/taskService.test.ts | 1 + src/node/services/taskService.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 416d9207de..f7c20e0f33 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -3719,6 +3719,7 @@ describe("TaskService", () => { expect.objectContaining({ emoji: PLAN_AUTO_ROUTING_STATUS_EMOJI, message: PLAN_AUTO_ROUTING_STATUS_MESSAGE, + url: "", }) ); expect(updateAgentStatus).toHaveBeenNthCalledWith(2, childId, null); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index fc50ed07d3..fee18682a7 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -2491,6 +2491,9 @@ export class TaskService { 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: "", }); }