Skip to content

Commit 0d28026

Browse files
TimCrookerclaudejuliusmarmingecodex
authored
fix(claude): emit plan events for TodoWrite during input streaming (#1541)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: codex <codex@users.noreply.github.com>
1 parent c9b07d6 commit 0d28026

File tree

10 files changed

+288
-29
lines changed

10 files changed

+288
-29
lines changed

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ function runtimeEventToActivities(
465465
payload: {
466466
itemType: event.payload.itemType,
467467
...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}),
468+
...(event.payload.data !== undefined ? { data: event.payload.data } : {}),
468469
},
469470
turnId: toTurnId(event.turnId) ?? null,
470471
...maybeSequence,

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,97 @@ describe("ClaudeAdapterLive", () => {
966966
);
967967
});
968968

969+
it.effect("falls back to a default plan step label for blank TodoWrite content", () => {
970+
const harness = makeHarness();
971+
return Effect.gen(function* () {
972+
const adapter = yield* ClaudeAdapter;
973+
974+
const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 10).pipe(
975+
Stream.runCollect,
976+
Effect.forkChild,
977+
);
978+
979+
const session = yield* adapter.startSession({
980+
threadId: THREAD_ID,
981+
provider: "claudeAgent",
982+
runtimeMode: "full-access",
983+
});
984+
985+
const turn = yield* adapter.sendTurn({
986+
threadId: session.threadId,
987+
input: "hello",
988+
attachments: [],
989+
});
990+
991+
harness.query.emit({
992+
type: "stream_event",
993+
session_id: "sdk-session-todo-plan",
994+
uuid: "stream-todo-start",
995+
parent_tool_use_id: null,
996+
event: {
997+
type: "content_block_start",
998+
index: 1,
999+
content_block: {
1000+
type: "tool_use",
1001+
id: "tool-todo-1",
1002+
name: "TodoWrite",
1003+
input: {},
1004+
},
1005+
},
1006+
} as unknown as SDKMessage);
1007+
1008+
harness.query.emit({
1009+
type: "stream_event",
1010+
session_id: "sdk-session-todo-plan",
1011+
uuid: "stream-todo-input",
1012+
parent_tool_use_id: null,
1013+
event: {
1014+
type: "content_block_delta",
1015+
index: 1,
1016+
delta: {
1017+
type: "input_json_delta",
1018+
partial_json:
1019+
'{"todos":[{"content":" ","status":"in_progress"},{"content":"Ship it","status":"completed"}]}',
1020+
},
1021+
},
1022+
} as unknown as SDKMessage);
1023+
1024+
harness.query.emit({
1025+
type: "stream_event",
1026+
session_id: "sdk-session-todo-plan",
1027+
uuid: "stream-todo-stop",
1028+
parent_tool_use_id: null,
1029+
event: {
1030+
type: "content_block_stop",
1031+
index: 1,
1032+
},
1033+
} as unknown as SDKMessage);
1034+
1035+
harness.query.emit({
1036+
type: "result",
1037+
subtype: "success",
1038+
is_error: false,
1039+
errors: [],
1040+
session_id: "sdk-session-todo-plan",
1041+
uuid: "result-todo-plan",
1042+
} as unknown as SDKMessage);
1043+
1044+
const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber));
1045+
const planUpdated = runtimeEvents.find((event) => event.type === "turn.plan.updated");
1046+
assert.equal(planUpdated?.type, "turn.plan.updated");
1047+
if (planUpdated?.type === "turn.plan.updated") {
1048+
assert.equal(String(planUpdated.turnId), String(turn.turnId));
1049+
assert.deepEqual(planUpdated.payload.plan, [
1050+
{ step: "Task", status: "inProgress" },
1051+
{ step: "Ship it", status: "completed" },
1052+
]);
1053+
}
1054+
}).pipe(
1055+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
1056+
Effect.provide(harness.layer),
1057+
);
1058+
});
1059+
9691060
it.effect("classifies Claude Task tool invocations as collaboration agent work", () => {
9701061
const harness = makeHarness();
9711062
return Effect.gen(function* () {

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,13 +459,55 @@ function classifyRequestType(toolName: string): CanonicalRequestType {
459459
: "dynamic_tool_call";
460460
}
461461

462+
function isTodoTool(toolName: string): boolean {
463+
return toolName.toLowerCase().includes("todowrite");
464+
}
465+
466+
type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
467+
468+
function extractPlanStepsFromTodoInput(input: Record<string, unknown>): PlanStep[] | null {
469+
// TodoWrite format: { todos: [{ content, status, activeForm? }] }
470+
const todos = input.todos;
471+
if (!Array.isArray(todos) || todos.length === 0) {
472+
return null;
473+
}
474+
return todos
475+
.filter((t): t is Record<string, unknown> => t !== null && typeof t === "object")
476+
.map((todo) => ({
477+
step:
478+
typeof todo.content === "string" && todo.content.trim().length > 0
479+
? todo.content.trim()
480+
: "Task",
481+
status:
482+
todo.status === "completed"
483+
? "completed"
484+
: todo.status === "in_progress"
485+
? "inProgress"
486+
: "pending",
487+
}));
488+
}
489+
462490
function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string {
463491
const commandValue = input.command ?? input.cmd;
464492
const command = typeof commandValue === "string" ? commandValue : undefined;
465493
if (command && command.trim().length > 0) {
466494
return `${toolName}: ${command.trim().slice(0, 400)}`;
467495
}
468496

497+
// For agent/subagent tools, prefer human-readable description or prompt over raw JSON
498+
const itemType = classifyToolItemType(toolName);
499+
if (itemType === "collab_agent_tool_call") {
500+
const description =
501+
typeof input.description === "string" ? input.description.trim() : undefined;
502+
const prompt = typeof input.prompt === "string" ? input.prompt.trim() : undefined;
503+
const subagentType =
504+
typeof input.subagent_type === "string" ? input.subagent_type.trim() : undefined;
505+
const label = description || (prompt ? prompt.slice(0, 200) : undefined);
506+
if (label) {
507+
return subagentType ? `${subagentType}: ${label}` : label;
508+
}
509+
}
510+
469511
const serialized = JSON.stringify(input);
470512
if (serialized.length <= 400) {
471513
return `${toolName}: ${serialized}`;
@@ -1616,6 +1658,26 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
16161658
payload: message,
16171659
},
16181660
});
1661+
1662+
// Emit plan update when TodoWrite input is parsed
1663+
if (parsedInput && isTodoTool(nextTool.toolName)) {
1664+
const planSteps = extractPlanStepsFromTodoInput(parsedInput);
1665+
if (planSteps && planSteps.length > 0) {
1666+
const planStamp = yield* makeEventStamp();
1667+
yield* offerRuntimeEvent({
1668+
type: "turn.plan.updated",
1669+
eventId: planStamp.eventId,
1670+
provider: PROVIDER,
1671+
createdAt: planStamp.createdAt,
1672+
threadId: context.session.threadId,
1673+
...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
1674+
payload: {
1675+
plan: planSteps,
1676+
},
1677+
providerRefs: nativeProviderRefs(context),
1678+
});
1679+
}
1680+
}
16191681
}
16201682
return;
16211683
}

apps/web/src/components/ChatView.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,7 @@ export default function ChatView(props: ChatViewProps) {
11071107
() => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
11081108
[activeLatestTurn?.turnId, threadActivities],
11091109
);
1110+
const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks";
11101111
const showPlanFollowUpPrompt =
11111112
pendingUserInputs.length === 0 &&
11121113
interactionMode === "plan" &&
@@ -1888,10 +1889,8 @@ export default function ChatView(props: ChatViewProps) {
18881889
const togglePlanSidebar = useCallback(() => {
18891890
setPlanSidebarOpen((open) => {
18901891
if (open) {
1891-
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
1892-
if (turnKey) {
1893-
planSidebarDismissedForTurnRef.current = turnKey;
1894-
}
1892+
planSidebarDismissedForTurnRef.current =
1893+
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
18951894
} else {
18961895
planSidebarDismissedForTurnRef.current = null;
18971896
}
@@ -1989,6 +1988,18 @@ export default function ChatView(props: ChatViewProps) {
19891988
planSidebarDismissedForTurnRef.current = null;
19901989
}, [activeThread?.id]);
19911990

1991+
// Auto-open the plan sidebar when plan/todo steps arrive for the current turn.
1992+
// Don't auto-open for plans carried over from a previous turn (the user can open manually).
1993+
useEffect(() => {
1994+
if (!activePlan) return;
1995+
if (planSidebarOpen) return;
1996+
const latestTurnId = activeLatestTurn?.turnId ?? null;
1997+
if (latestTurnId && activePlan.turnId !== latestTurnId) return;
1998+
const turnKey = activePlan.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
1999+
if (planSidebarDismissedForTurnRef.current === turnKey) return;
2000+
setPlanSidebarOpen(true);
2001+
}, [activePlan, activeLatestTurn?.turnId, planSidebarOpen, sidebarProposedPlan?.turnId]);
2002+
19922003
useEffect(() => {
19932004
setIsRevertingCheckpoint(false);
19942005
}, [activeThread?.id]);
@@ -3298,6 +3309,7 @@ export default function ChatView(props: ChatViewProps) {
32983309
activeProposedPlan={activeProposedPlan}
32993310
activePlan={activePlan as { turnId?: TurnId } | null}
33003311
sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null}
3312+
planSidebarLabel={planSidebarLabel}
33013313
planSidebarOpen={planSidebarOpen}
33023314
runtimeMode={runtimeMode}
33033315
interactionMode={interactionMode}
@@ -3386,17 +3398,16 @@ export default function ChatView(props: ChatViewProps) {
33863398
<PlanSidebar
33873399
activePlan={activePlan}
33883400
activeProposedPlan={sidebarProposedPlan}
3401+
label={planSidebarLabel}
33893402
environmentId={environmentId}
33903403
markdownCwd={gitCwd ?? undefined}
33913404
workspaceRoot={activeWorkspaceRoot}
33923405
timestampFormat={timestampFormat}
33933406
onClose={() => {
33943407
setPlanSidebarOpen(false);
33953408
// Track that the user explicitly dismissed for this turn so auto-open won't fight them.
3396-
const turnKey = activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? null;
3397-
if (turnKey) {
3398-
planSidebarDismissedForTurnRef.current = turnKey;
3399-
}
3409+
planSidebarDismissedForTurnRef.current =
3410+
activePlan?.turnId ?? sidebarProposedPlan?.turnId ?? "__dismissed__";
34003411
}}
34013412
/>
34023413
) : null}

apps/web/src/components/PlanSidebar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode {
5454
interface PlanSidebarProps {
5555
activePlan: ActivePlanState | null;
5656
activeProposedPlan: LatestProposedPlanState | null;
57+
label?: string;
5758
environmentId: EnvironmentId;
5859
markdownCwd: string | undefined;
5960
workspaceRoot: string | undefined;
@@ -64,6 +65,7 @@ interface PlanSidebarProps {
6465
const PlanSidebar = memo(function PlanSidebar({
6566
activePlan,
6667
activeProposedPlan,
68+
label = "Plan",
6769
environmentId,
6870
markdownCwd,
6971
workspaceRoot,
@@ -129,7 +131,7 @@ const PlanSidebar = memo(function PlanSidebar({
129131
variant="secondary"
130132
className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase"
131133
>
132-
Plan
134+
{label}
133135
</Badge>
134136
{activePlan ? (
135137
<span className="text-[11px] text-muted-foreground/60">
@@ -170,7 +172,7 @@ const PlanSidebar = memo(function PlanSidebar({
170172
size="icon-xs"
171173
variant="ghost"
172174
onClick={onClose}
173-
aria-label="Close plan sidebar"
175+
aria-label={`Close ${label.toLowerCase()} sidebar`}
174176
className="text-muted-foreground/50 hover:text-foreground/70"
175177
>
176178
<PanelRightCloseIcon className="size-3.5" />

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
162162
interactionMode: ProviderInteractionMode;
163163
runtimeMode: RuntimeMode;
164164
showPlanToggle: boolean;
165+
planSidebarLabel: string;
165166
planSidebarOpen: boolean;
166167
onToggleInteractionMode: () => void;
167168
onRuntimeModeChange: (mode: RuntimeMode) => void;
@@ -243,10 +244,14 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
243244
size="sm"
244245
type="button"
245246
onClick={props.onTogglePlanSidebar}
246-
title={props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
247+
title={
248+
props.planSidebarOpen
249+
? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`
250+
: `Show ${props.planSidebarLabel.toLowerCase()} sidebar`
251+
}
247252
>
248253
<ListTodoIcon />
249-
<span className="sr-only sm:not-sr-only">Plan</span>
254+
<span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span>
250255
</Button>
251256
</>
252257
) : null}
@@ -380,6 +385,7 @@ export interface ChatComposerProps {
380385
activeProposedPlan: Thread["proposedPlans"][number] | null;
381386
activePlan: { turnId?: TurnId } | null;
382387
sidebarProposedPlan: { turnId?: TurnId } | null;
388+
planSidebarLabel: string;
383389
planSidebarOpen: boolean;
384390

385391
// Mode
@@ -474,6 +480,7 @@ export const ChatComposer = memo(
474480
activeProposedPlan,
475481
activePlan,
476482
sidebarProposedPlan,
483+
planSidebarLabel,
477484
planSidebarOpen,
478485
runtimeMode,
479486
interactionMode,
@@ -1923,6 +1930,7 @@ export const ChatComposer = memo(
19231930
<CompactComposerControlsMenu
19241931
activePlan={showPlanSidebarToggle}
19251932
interactionMode={interactionMode}
1933+
planSidebarLabel={planSidebarLabel}
19261934
planSidebarOpen={planSidebarOpen}
19271935
runtimeMode={runtimeMode}
19281936
traitsMenuContent={providerTraitsMenuContent}
@@ -1945,6 +1953,7 @@ export const ChatComposer = memo(
19451953
interactionMode={interactionMode}
19461954
runtimeMode={runtimeMode}
19471955
showPlanToggle={showPlanSidebarToggle}
1956+
planSidebarLabel={planSidebarLabel}
19481957
planSidebarOpen={planSidebarOpen}
19491958
onToggleInteractionMode={toggleInteractionMode}
19501959
onRuntimeModeChange={handleRuntimeModeChange}

apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
123123
<CompactComposerControlsMenu
124124
activePlan={false}
125125
interactionMode="default"
126+
planSidebarLabel="Plan"
126127
planSidebarOpen={false}
127128
runtimeMode="approval-required"
128129
traitsMenuContent={

apps/web/src/components/chat/CompactComposerControlsMenu.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: {
1616
activePlan: boolean;
1717
interactionMode: ProviderInteractionMode;
18+
planSidebarLabel: string;
1819
planSidebarOpen: boolean;
1920
runtimeMode: RuntimeMode;
2021
traitsMenuContent?: ReactNode;
@@ -72,7 +73,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
7273
<MenuDivider />
7374
<MenuItem onClick={props.onTogglePlanSidebar}>
7475
<ListTodoIcon className="size-4 shrink-0" />
75-
{props.planSidebarOpen ? "Hide plan sidebar" : "Show plan sidebar"}
76+
{props.planSidebarOpen
77+
? `Hide ${props.planSidebarLabel.toLowerCase()} sidebar`
78+
: `Show ${props.planSidebarLabel.toLowerCase()} sidebar`}
7679
</MenuItem>
7780
</>
7881
) : null}

0 commit comments

Comments
 (0)