| author | |
| committer | |
| log | 0d280262f6c9dcaf529376b2007dc7447f86fa3c |
| tree | 2bfa577e6ad1302c1a2d3d706b0203192075655a |
| parent | c9b07d665d11ff19fdb655be56a014cd60d9166b |
| signature | Signed by PGP key B5690EEEBB952194 |
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( |
| 465 | 465 | payload: { |
| 466 | 466 | itemType: event.payload.itemType, |
| 467 | 467 | ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), |
| 468 | ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), | |
| 468 | 469 | }, |
| 469 | 470 | turnId: toTurnId(event.turnId) ?? null, |
| 470 | 471 | ...maybeSequence, |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+91| ... | ... | @@ -966,6 +966,97 @@ describe("ClaudeAdapterLive", () => { |
| 966 | 966 | ); |
| 967 | 967 | }); |
| 968 | 968 | |
| 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 | ||
| 969 | 1060 | it.effect("classifies Claude Task tool invocations as collaboration agent work", () => { |
| 970 | 1061 | const harness = makeHarness(); |
| 971 | 1062 | return Effect.gen(function* () { |
apps/server/src/provider/Layers/ClaudeAdapter.ts+62| ... | ... | @@ -459,6 +459,34 @@ function classifyRequestType(toolName: string): CanonicalRequestType { |
| 459 | 459 | : "dynamic_tool_call"; |
| 460 | 460 | } |
| 461 | 461 | |
| 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 | ||
| 462 | 490 | function summarizeToolRequest(toolName: string, input: Record<string, unknown>): string { |
| 463 | 491 | const commandValue = input.command ?? input.cmd; |
| 464 | 492 | const command = typeof commandValue === "string" ? commandValue : undefined; |
| ... | ... | @@ -466,6 +494,20 @@ function summarizeToolRequest(toolName: string, input: Record<string, unknown>): |
| 466 | 494 | return `${toolName}: ${command.trim().slice(0, 400)}`; |
| 467 | 495 | } |
| 468 | 496 | |
| 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 | ||
| 469 | 511 | const serialized = JSON.stringify(input); |
| 470 | 512 | if (serialized.length <= 400) { |
| 471 | 513 | return `${toolName}: ${serialized}`; |
| ... | ... | @@ -1616,6 +1658,26 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 1616 | 1658 | payload: message, |
| 1617 | 1659 | }, |
| 1618 | 1660 | }); |
| 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 | } | |
| 1619 | 1681 | } |
| 1620 | 1682 | return; |
| 1621 | 1683 | } |
apps/web/src/components/ChatView.tsx+19-8| ... | ... | @@ -1107,6 +1107,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1107 | 1107 | () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), |
| 1108 | 1108 | [activeLatestTurn?.turnId, threadActivities], |
| 1109 | 1109 | ); |
| 1110 | const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; | |
| 1110 | 1111 | const showPlanFollowUpPrompt = |
| 1111 | 1112 | pendingUserInputs.length === 0 && |
| 1112 | 1113 | interactionMode === "plan" && |
| ... | ... | @@ -1888,10 +1889,8 @@ export default function ChatView(props: ChatViewProps) { |
| 1888 | 1889 | const togglePlanSidebar = useCallback(() => { |
| 1889 | 1890 | setPlanSidebarOpen((open) => { |
| 1890 | 1891 | 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__"; | |
| 1895 | 1894 | } else { |
| 1896 | 1895 | planSidebarDismissedForTurnRef.current = null; |
| 1897 | 1896 | } |
| ... | ... | @@ -1989,6 +1988,18 @@ export default function ChatView(props: ChatViewProps) { |
| 1989 | 1988 | planSidebarDismissedForTurnRef.current = null; |
| 1990 | 1989 | }, [activeThread?.id]); |
| 1991 | 1990 | |
| 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 | ||
| 1992 | 2003 | useEffect(() => { |
| 1993 | 2004 | setIsRevertingCheckpoint(false); |
| 1994 | 2005 | }, [activeThread?.id]); |
| ... | ... | @@ -3298,6 +3309,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3298 | 3309 | activeProposedPlan={activeProposedPlan} |
| 3299 | 3310 | activePlan={activePlan as { turnId?: TurnId } | null} |
| 3300 | 3311 | sidebarProposedPlan={sidebarProposedPlan as { turnId?: TurnId } | null} |
| 3312 | planSidebarLabel={planSidebarLabel} | |
| 3301 | 3313 | planSidebarOpen={planSidebarOpen} |
| 3302 | 3314 | runtimeMode={runtimeMode} |
| 3303 | 3315 | interactionMode={interactionMode} |
| ... | ... | @@ -3386,6 +3398,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3386 | 3398 | <PlanSidebar |
| 3387 | 3399 | activePlan={activePlan} |
| 3388 | 3400 | activeProposedPlan={sidebarProposedPlan} |
| 3401 | label={planSidebarLabel} | |
| 3389 | 3402 | environmentId={environmentId} |
| 3390 | 3403 | markdownCwd={gitCwd ?? undefined} |
| 3391 | 3404 | workspaceRoot={activeWorkspaceRoot} |
| ... | ... | @@ -3393,10 +3406,8 @@ export default function ChatView(props: ChatViewProps) { |
| 3393 | 3406 | onClose={() => { |
| 3394 | 3407 | setPlanSidebarOpen(false); |
| 3395 | 3408 | // 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__"; | |
| 3400 | 3411 | }} |
| 3401 | 3412 | /> |
| 3402 | 3413 | ) : null} |
apps/web/src/components/PlanSidebar.tsx+4-2| ... | ... | @@ -54,6 +54,7 @@ function stepStatusIcon(status: string): React.ReactNode { |
| 54 | 54 | interface PlanSidebarProps { |
| 55 | 55 | activePlan: ActivePlanState | null; |
| 56 | 56 | activeProposedPlan: LatestProposedPlanState | null; |
| 57 | label?: string; | |
| 57 | 58 | environmentId: EnvironmentId; |
| 58 | 59 | markdownCwd: string | undefined; |
| 59 | 60 | workspaceRoot: string | undefined; |
| ... | ... | @@ -64,6 +65,7 @@ interface PlanSidebarProps { |
| 64 | 65 | const PlanSidebar = memo(function PlanSidebar({ |
| 65 | 66 | activePlan, |
| 66 | 67 | activeProposedPlan, |
| 68 | label = "Plan", | |
| 67 | 69 | environmentId, |
| 68 | 70 | markdownCwd, |
| 69 | 71 | workspaceRoot, |
| ... | ... | @@ -129,7 +131,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 129 | 131 | variant="secondary" |
| 130 | 132 | className="rounded-md bg-blue-500/10 px-1.5 py-0 text-[10px] font-semibold tracking-wide text-blue-400 uppercase" |
| 131 | 133 | > |
| 132 | Plan | |
| 134 | {label} | |
| 133 | 135 | </Badge> |
| 134 | 136 | {activePlan ? ( |
| 135 | 137 | <span className="text-[11px] text-muted-foreground/60"> |
| ... | ... | @@ -170,7 +172,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 170 | 172 | size="icon-xs" |
| 171 | 173 | variant="ghost" |
| 172 | 174 | onClick={onClose} |
| 173 | aria-label="Close plan sidebar" | |
| 175 | aria-label={`Close ${label.toLowerCase()} sidebar`} | |
| 174 | 176 | className="text-muted-foreground/50 hover:text-foreground/70" |
| 175 | 177 | > |
| 176 | 178 | <PanelRightCloseIcon className="size-3.5" /> |
apps/web/src/components/chat/ChatComposer.tsx+11-2| ... | ... | @@ -162,6 +162,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 162 | 162 | interactionMode: ProviderInteractionMode; |
| 163 | 163 | runtimeMode: RuntimeMode; |
| 164 | 164 | showPlanToggle: boolean; |
| 165 | planSidebarLabel: string; | |
| 165 | 166 | planSidebarOpen: boolean; |
| 166 | 167 | onToggleInteractionMode: () => void; |
| 167 | 168 | onRuntimeModeChange: (mode: RuntimeMode) => void; |
| ... | ... | @@ -243,10 +244,14 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 243 | 244 | size="sm" |
| 244 | 245 | type="button" |
| 245 | 246 | 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 | } | |
| 247 | 252 | > |
| 248 | 253 | <ListTodoIcon /> |
| 249 | <span className="sr-only sm:not-sr-only">Plan</span> | |
| 254 | <span className="sr-only sm:not-sr-only">{props.planSidebarLabel}</span> | |
| 250 | 255 | </Button> |
| 251 | 256 | </> |
| 252 | 257 | ) : null} |
| ... | ... | @@ -380,6 +385,7 @@ export interface ChatComposerProps { |
| 380 | 385 | activeProposedPlan: Thread["proposedPlans"][number] | null; |
| 381 | 386 | activePlan: { turnId?: TurnId } | null; |
| 382 | 387 | sidebarProposedPlan: { turnId?: TurnId } | null; |
| 388 | planSidebarLabel: string; | |
| 383 | 389 | planSidebarOpen: boolean; |
| 384 | 390 | |
| 385 | 391 | // Mode |
| ... | ... | @@ -474,6 +480,7 @@ export const ChatComposer = memo( |
| 474 | 480 | activeProposedPlan, |
| 475 | 481 | activePlan, |
| 476 | 482 | sidebarProposedPlan, |
| 483 | planSidebarLabel, | |
| 477 | 484 | planSidebarOpen, |
| 478 | 485 | runtimeMode, |
| 479 | 486 | interactionMode, |
| ... | ... | @@ -1923,6 +1930,7 @@ export const ChatComposer = memo( |
| 1923 | 1930 | <CompactComposerControlsMenu |
| 1924 | 1931 | activePlan={showPlanSidebarToggle} |
| 1925 | 1932 | interactionMode={interactionMode} |
| 1933 | planSidebarLabel={planSidebarLabel} | |
| 1926 | 1934 | planSidebarOpen={planSidebarOpen} |
| 1927 | 1935 | runtimeMode={runtimeMode} |
| 1928 | 1936 | traitsMenuContent={providerTraitsMenuContent} |
| ... | ... | @@ -1945,6 +1953,7 @@ export const ChatComposer = memo( |
| 1945 | 1953 | interactionMode={interactionMode} |
| 1946 | 1954 | runtimeMode={runtimeMode} |
| 1947 | 1955 | showPlanToggle={showPlanSidebarToggle} |
| 1956 | planSidebarLabel={planSidebarLabel} | |
| 1948 | 1957 | planSidebarOpen={planSidebarOpen} |
| 1949 | 1958 | onToggleInteractionMode={toggleInteractionMode} |
| 1950 | 1959 | onRuntimeModeChange={handleRuntimeModeChange} |
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+1| ... | ... | @@ -123,6 +123,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str |
| 123 | 123 | <CompactComposerControlsMenu |
| 124 | 124 | activePlan={false} |
| 125 | 125 | interactionMode="default" |
| 126 | planSidebarLabel="Plan" | |
| 126 | 127 | planSidebarOpen={false} |
| 127 | 128 | runtimeMode="approval-required" |
| 128 | 129 | traitsMenuContent={ |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+4-1| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { |
| 16 | 16 | activePlan: boolean; |
| 17 | 17 | interactionMode: ProviderInteractionMode; |
| 18 | planSidebarLabel: string; | |
| 18 | 19 | planSidebarOpen: boolean; |
| 19 | 20 | runtimeMode: RuntimeMode; |
| 20 | 21 | traitsMenuContent?: ReactNode; |
| ... | ... | @@ -72,7 +73,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 72 | 73 | <MenuDivider /> |
| 73 | 74 | <MenuItem onClick={props.onTogglePlanSidebar}> |
| 74 | 75 | <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`} | |
| 76 | 79 | </MenuItem> |
| 77 | 80 | </> |
| 78 | 81 | ) : null} |
apps/web/src/session-logic.test.ts+59-2| ... | ... | @@ -343,6 +343,30 @@ describe("deriveActivePlanState", () => { |
| 343 | 343 | steps: [{ step: "Implement Codex user input", status: "inProgress" }], |
| 344 | 344 | }); |
| 345 | 345 | }); |
| 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 | }); | |
| 346 | 370 | }); |
| 347 | 371 | |
| 348 | 372 | describe("findLatestProposedPlan", () => { |
| ... | ... | @@ -568,7 +592,7 @@ describe("deriveWorkLogEntries", () => { |
| 568 | 592 | expect(entries.map((entry) => entry.id)).toEqual(["tool-complete"]); |
| 569 | 593 | }); |
| 570 | 594 | |
| 571 | it("omits task start and completion lifecycle entries", () => { | |
| 595 | it("omits task.started but shows task.progress and task.completed", () => { | |
| 572 | 596 | const activities: OrchestrationThreadActivity[] = [ |
| 573 | 597 | makeActivity({ |
| 574 | 598 | id: "task-start", |
| ... | ... | @@ -594,7 +618,40 @@ describe("deriveWorkLogEntries", () => { |
| 594 | 618 | ]; |
| 595 | 619 | |
| 596 | 620 | 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"); | |
| 598 | 655 | }); |
| 599 | 656 | |
| 600 | 657 | it("filters by turn id when provided", () => { |
apps/web/src/session-logic.ts+36-14| ... | ... | @@ -348,16 +348,15 @@ export function deriveActivePlanState( |
| 348 | 348 | latestTurnId: TurnId | undefined, |
| 349 | 349 | ): ActivePlanState | null { |
| 350 | 350 | 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; | |
| 361 | 360 | if (!latest) { |
| 362 | 361 | return null; |
| 363 | 362 | } |
| ... | ... | @@ -472,7 +471,7 @@ export function deriveWorkLogEntries( |
| 472 | 471 | const entries = ordered |
| 473 | 472 | .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) |
| 474 | 473 | .filter((activity) => activity.kind !== "tool.started") |
| 475 | .filter((activity) => activity.kind !== "task.started" && activity.kind !== "task.completed") | |
| 474 | .filter((activity) => activity.kind !== "task.started") | |
| 476 | 475 | .filter((activity) => activity.kind !== "context-window.updated") |
| 477 | 476 | .filter((activity) => activity.summary !== "Checkpoint captured") |
| 478 | 477 | .filter((activity) => !isPlanBoundaryToolActivity(activity)) |
| ... | ... | @@ -502,16 +501,39 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo |
| 502 | 501 | const commandPreview = extractToolCommand(payload); |
| 503 | 502 | const changedFiles = extractChangedFiles(payload); |
| 504 | 503 | 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; | |
| 505 | 517 | const entry: DerivedWorkLogEntry = { |
| 506 | 518 | id: activity.id, |
| 507 | 519 | 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, | |
| 510 | 527 | activityKind: activity.kind, |
| 511 | 528 | }; |
| 512 | 529 | const itemType = extractWorkLogItemType(payload); |
| 513 | 530 | 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 | ) { | |
| 515 | 537 | const detail = stripTrailingExitCode(payload.detail).output; |
| 516 | 538 | if (detail) { |
| 517 | 539 | entry.detail = detail; |