Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/browser/components/WorkspaceStatusDot.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/browser/components/icons/EmojiIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ const EMOJI_TO_ICON: Record<string, LucideIcon> = {
"πŸ”—": Link,
"πŸ”„": RefreshCw,
"πŸ§ͺ": Beaker,
// Used by auto-handoff routing status while selecting the executor.
"πŸ€”": CircleHelp,

// Directions
"➑": ArrowRight,
Expand Down
82 changes: 82 additions & 0 deletions src/browser/stories/App.chat.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: () => (
<AppWithMocks
setup={() =>
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.
*
Expand Down
15 changes: 15 additions & 0 deletions src/common/constants/planAutoRoutingStatus.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
25 changes: 23 additions & 2 deletions src/node/services/taskService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -157,6 +161,7 @@ function createWorkspaceServiceMocks(
emit: ReturnType<typeof mock>;
getInfo: ReturnType<typeof mock>;
replaceHistory: ReturnType<typeof mock>;
updateAgentStatus: ReturnType<typeof mock>;
}>
): {
workspaceService: WorkspaceService;
Expand All @@ -166,6 +171,7 @@ function createWorkspaceServiceMocks(
emit: ReturnType<typeof mock>;
getInfo: ReturnType<typeof mock>;
replaceHistory: ReturnType<typeof mock>;
updateAgentStatus: ReturnType<typeof mock>;
} {
const sendMessage =
overrides?.sendMessage ?? mock((): Promise<Result<void>> => Promise.resolve(Ok(undefined)));
Expand All @@ -178,6 +184,8 @@ function createWorkspaceServiceMocks(
const getInfo = overrides?.getInfo ?? mock(() => Promise.resolve(null));
const replaceHistory =
overrides?.replaceHistory ?? mock((): Promise<Result<void>> => Promise.resolve(Ok(undefined)));
const updateAgentStatus =
overrides?.updateAgentStatus ?? mock((): Promise<void> => Promise.resolve());

return {
workspaceService: {
Expand All @@ -187,13 +195,15 @@ function createWorkspaceServiceMocks(
emit,
getInfo,
replaceHistory,
updateAgentStatus,
} as unknown as WorkspaceService,
sendMessage,
resumeStream,
remove,
emit,
getInfo,
replaceHistory,
updateAgentStatus,
};
}

Expand Down Expand Up @@ -3477,7 +3487,7 @@ describe("TaskService", () => {
namedWorkspacePath: childWorkspacePath,
}));
const replaceHistory = mock((): Promise<Result<void>> => Promise.resolve(Ok(undefined)));
const { workspaceService, sendMessage } = createWorkspaceServiceMocks({
const { workspaceService, sendMessage, updateAgentStatus } = createWorkspaceServiceMocks({
getInfo,
replaceHistory,
sendMessage: options?.sendMessageOverride,
Expand All @@ -3496,6 +3506,7 @@ describe("TaskService", () => {
sendMessage,
replaceHistory,
createModel,
updateAgentStatus,
internal,
};
}
Expand Down Expand Up @@ -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",
});
Expand All @@ -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())
Expand Down
54 changes: 39 additions & 15 deletions src/node/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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}\``
Expand Down
2 changes: 1 addition & 1 deletion src/node/services/workspaceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,7 @@ export class WorkspaceService extends EventEmitter {
}
}

private async updateAgentStatus(
public async updateAgentStatus(
workspaceId: string,
agentStatus: WorkspaceAgentStatus | null
): Promise<void> {
Expand Down
Loading