authorgravatar for timothycrooker@gmail.comTim Crooker <timothycrooker@gmail.com> 2026-04-13 19:35:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-13 19:35:40-07:00
log0d280262f6c9dcaf529376b2007dc7447f86fa3c
tree2bfa577e6ad1302c1a2d3d706b0203192075655a
parentc9b07d665d11ff19fdb655be56a014cd60d9166b
signature Signed by PGP key B5690EEEBB952194

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>

10 files changed, 288 insertions(+), 29 deletions(-)

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+1
......@@ -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+91
......@@ -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+62
......@@ -459,6 +459,34 @@ function classifyRequestType(toolName: string): CanonicalRequestType {
459459 : "dynamic_tool_call";
460460}
461461
462function isTodoTool(toolName: string): boolean {
463 return toolName.toLowerCase().includes("todowrite");
464}
465
466type PlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
467
468function 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
462490function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string {
463491 const commandValue = input.command ?? input.cmd;
464492 const command = typeof commandValue === "string" ? commandValue : undefined;
......@@ -466,6 +494,20 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>):
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+19-8
......@@ -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,6 +3398,7 @@ 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}
......@@ -3393,10 +3406,8 @@ export default function ChatView(props: ChatViewProps) {
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+4-2
......@@ -54,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode {
5454interface 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 {
6465const 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+11-2
......@@ -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+1
......@@ -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+4-1
......@@ -15,6 +15,7 @@ import {
1515export 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}
apps/web/src/session-logic.test.ts+59-2
......@@ -343,6 +343,30 @@ describe("deriveActivePlanState", () => {
343343 steps: [{ step: "Implement Codex user input", status: "inProgress" }],
344344 });
345345 });
346
347 it("falls back to the most recent plan from a previous turn", () => {
348 const activities: OrchestrationThreadActivity[] = [
349 makeActivity({
350 id: "plan-from-turn-1",
351 createdAt: "2026-02-23T00:00:01.000Z",
352 kind: "turn.plan.updated",
353 summary: "Plan updated",
354 tone: "info",
355 turnId: "turn-1",
356 payload: {
357 plan: [{ step: "Write tests", status: "completed" }],
358 },
359 }),
360 ];
361
362 // Current turn is turn-2, which has no plan activity — should fall back to turn-1's plan
363 const result = deriveActivePlanState(activities, TurnId.make("turn-2"));
364 expect(result).toEqual({
365 createdAt: "2026-02-23T00:00:01.000Z",
366 turnId: "turn-1",
367 steps: [{ step: "Write tests", status: "completed" }],
368 });
369 });
346370});
347371
348372describe("findLatestProposedPlan", () => {
......@@ -568,7 +592,7 @@ describe("deriveWorkLogEntries", () => {
568592 expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]);
569593 });
570594
571 it("omits task start and completion lifecycle entries", () => {
595 it("omits task.started but shows task.progress and task.completed", () => {
572596 const activities: OrchestrationThreadActivity[] = [
573597 makeActivity({
574598 id: "task-start",
......@@ -594,7 +618,40 @@ describe("deriveWorkLogEntries", () => {
594618 ];
595619
596620 const entries = deriveWorkLogEntries(activities, undefined);
597 expect(entries.map((entry) => entry.id)).toEqual(["task-progress"]);
621 expect(entries.map((entry) => entry.id)).toEqual(["task-progress", "task-complete"]);
622 });
623
624 it("uses payload summary as label for task entries when available", () => {
625 const activities: OrchestrationThreadActivity[] = [
626 makeActivity({
627 id: "task-progress-with-summary",
628 createdAt: "2026-02-23T00:00:02.000Z",
629 kind: "task.progress",
630 summary: "Reasoning update",
631 tone: "info",
632 payload: { summary: "Searching for API endpoints" },
633 }),
634 ];
635
636 const entries = deriveWorkLogEntries(activities, undefined);
637 expect(entries[0]?.label).toBe("Searching for API endpoints");
638 });
639
640 it("uses payload detail as label for task.completed and preserves error tone", () => {
641 const activities: OrchestrationThreadActivity[] = [
642 makeActivity({
643 id: "task-completed-failed",
644 createdAt: "2026-02-23T00:00:03.000Z",
645 kind: "task.completed",
646 summary: "Task failed",
647 tone: "error",
648 payload: { detail: "Failed to deploy changes" },
649 }),
650 ];
651
652 const entries = deriveWorkLogEntries(activities, undefined);
653 expect(entries[0]?.label).toBe("Failed to deploy changes");
654 expect(entries[0]?.tone).toBe("error");
598655 });
599656
600657 it("filters by turn id when provided", () => {
apps/web/src/session-logic.ts+36-14
......@@ -348,16 +348,15 @@ export function deriveActivePlanState(
348348 latestTurnId: TurnId | undefined,
349349): ActivePlanState | null {
350350 const ordered = [...activities].toSorted(compareActivitiesByOrder);
351 const candidates = ordered.filter((activity) => {
352 if (activity.kind !== "turn.plan.updated") {
353 return false;
354 }
355 if (!latestTurnId) {
356 return true;
357 }
358 return activity.turnId === latestTurnId;
359 });
360 const latest = candidates.at(-1);
351 const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated");
352 // Prefer plan from the current turn; fall back to the most recent plan from any turn
353 // so that TodoWrite tasks persist across follow-up messages.
354 const latest =
355 (latestTurnId
356 ? allPlanActivities.filter((activity) => activity.turnId === latestTurnId).at(-1)
357 : undefined) ??
358 allPlanActivities.at(-1) ??
359 null;
361360 if (!latest) {
362361 return null;
363362 }
......@@ -472,7 +471,7 @@ export function deriveWorkLogEntries(
472471 const entries = ordered
473472 .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
474473 .filter((activity) => activity.kind !== "tool.started")
475 .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed")
474 .filter((activity) => activity.kind !== "task.started")
476475 .filter((activity) => activity.kind !== "context-window.updated")
477476 .filter((activity) => activity.summary !== "Checkpoint captured")
478477 .filter((activity) => !isPlanBoundaryToolActivity(activity))
......@@ -502,16 +501,39 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
502501 const commandPreview = extractToolCommand(payload);
503502 const changedFiles = extractChangedFiles(payload);
504503 const title = extractToolTitle(payload);
504 const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed";
505 const taskSummary =
506 isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0
507 ? payload.summary
508 : null;
509 const taskDetailAsLabel =
510 isTaskActivity &&
511 !taskSummary &&
512 typeof payload?.detail === "string" &&
513 payload.detail.length > 0
514 ? payload.detail
515 : null;
516 const taskLabel = taskSummary || taskDetailAsLabel;
505517 const entry: DerivedWorkLogEntry = {
506518 id: activity.id,
507519 createdAt: activity.createdAt,
508 label: activity.summary,
509 tone: activity.tone === "approval" ? "info" : activity.tone,
520 label: taskLabel || activity.summary,
521 tone:
522 activity.kind === "task.progress"
523 ? "thinking"
524 : activity.tone === "approval"
525 ? "info"
526 : activity.tone,
510527 activityKind: activity.kind,
511528 };
512529 const itemType = extractWorkLogItemType(payload);
513530 const requestKind = extractWorkLogRequestKind(payload);
514 if (payload && typeof payload.detail === "string" && payload.detail.length > 0) {
531 if (
532 !taskDetailAsLabel &&
533 payload &&
534 typeof payload.detail === "string" &&
535 payload.detail.length > 0
536 ) {
515537 const detail = stripTrailingExitCode(payload.detail).output;
516538 if (detail) {
517539 entry.detail = detail;