From 7c948a38c4915ad3ac2b147fe4026bf2d167f3c0 Mon Sep 17 00:00:00 2001 From: Ryanba <92616678+Gujiassh@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:34:32 +0800 Subject: [PATCH] fix(frontend): avoid subtask render state mutation (#3157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): avoid subtask render state mutation * fix(frontend): avoid subtask render state mutation * Fix the lint error * 修复子任务终态回退渲染 * fix(frontend): preserve subtask loading semantics * fix(frontend): harden subtask render merging --------- Co-authored-by: Willem Jiang --- frontend/src/AGENTS.md | 1 + .../workspace/messages/message-list.tsx | 83 +++-- .../workspace/messages/subtask-card.tsx | 6 +- frontend/src/core/tasks/subtask-render.ts | 110 ++++++ .../messages/subtask-card.dom.test.tsx | 216 ++++++++++++ .../workspace/messages/subtask-card.test.ts | 104 ++++++ .../unit/core/tasks/subtask-render.test.ts | 315 ++++++++++++++++++ 7 files changed, 790 insertions(+), 45 deletions(-) create mode 100644 frontend/src/core/tasks/subtask-render.ts create mode 100644 frontend/tests/unit/components/workspace/messages/subtask-card.dom.test.tsx create mode 100644 frontend/tests/unit/components/workspace/messages/subtask-card.test.ts create mode 100644 frontend/tests/unit/core/tasks/subtask-render.test.ts diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 9a0e249f90..8f35c824cc 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -152,6 +152,7 @@ Array previews coalesce consecutive generated markers only at the end into one o - Tool-step links in `message-group.tsx` (`web_fetch` args, `web_search` / `image_search` result URLs) are model- or provider-controlled, so they pass the markdown `isSafeHref` allowlist; every surface renders a rejected href through the shared `UnsafeLink` marker. - **Environment validation** uses `@t3-oss/env-nextjs` with Zod schemas (`src/env.js`). Skip with `SKIP_ENV_VALIDATION=1` - **Subtask step history and runtime metadata** (`core/tasks/`) — the subtask card shows a subagent's full step timeline (#3779): its assistant reasoning turns interleaved with the tools it ran. The task tool's model-visible `description` is an optional progress label; `MessageList` uses the required `prompt` (then the localized generic subtask label) when a provider omits it, so a valid task call never renders a blank card title. `Subtask.steps[]` is accumulated live from `task_running` events (appended via `mergeSteps`, not overwritten) and backfilled on expand for historical runs by `fetchSubtaskSteps`, which pages the events endpoint scoped to one task (GET `/runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…`) until a short page, so the run-wide limit can't truncate the timeline. `task_started` carries the effective `model_name`; `task_running` carries a cumulative usage snapshot after each completed LLM call. `core/tasks/lifecycle.ts` normalizes these additive events, and `computeNextSubtask` keeps the largest cumulative total so replayed or late SSE frames cannot double-count or roll the folded card backward. Terminal ToolMessage metadata (`subagent_model_name` / `subagent_token_usage`) restores the same values from normal history after reload; no per-card event fetch is needed. `core/tasks/steps.ts` is the pure step model: `messageToStep` (live), `eventsToSteps` (reload), `mergeSteps` (dedup by `message_index`), and `stepsForDisplay` (what the card renders — keeps tool steps + AI steps with text, drops the trailing final-answer AI step when completed since it's shown as `result`). `core/tasks/context.tsx`'s `useUpdateSubtask` applies updates against a `tasksRef` mirroring the latest state (not a closure snapshot), so a late-resolving `fetchSubtaskSteps` backfill merges into current state instead of clobbering SSE steps or sibling subtasks that arrived meanwhile. The owning `run_id` is carried onto history content messages in `buildVisibleHistoryMessages` so the card can resolve the events endpoint. +- **Subtask render synchronization** (`core/tasks/subtask-render.ts`) — `MessageList` derives message-backed task snapshots in `useMemo` and publishes them to task context from an effect, never during render. Current message arguments own the card title and prompt; live task state retains lifecycle and runtime metadata until a terminal message snapshot supplies status/result/error. Cards receive the same snapshot as a render fallback so effect timing cannot leave streamed arguments stale. ### Interaction Ownership diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 407a36d172..1c7fc3a38b 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -55,7 +55,6 @@ import { areStreamMetadataSnapshotsEqual, extractContentFromMessage, extractPresentFilesFromMessage, - extractTextFromMessage, getAssistantTurnCopyData, getBranchableAssistantGroupIds, getLatestEditableTurn, @@ -76,11 +75,7 @@ import { } from "@/core/sidecar"; import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; -import { resolveSubtaskDescription } from "@/core/tasks/presentation"; -import { - derivePendingSubtaskStatus, - parseSubtaskResult, -} from "@/core/tasks/subtask-result"; +import { collectRenderedSubtasks } from "@/core/tasks/subtask-render"; import type { AgentThreadState } from "@/core/threads"; import { cn } from "@/lib/utils"; @@ -503,6 +498,30 @@ export function MessageList({ }, [groupedMessages]); const updateSubtask = useUpdateSubtask(); const lastGroupIndex = groupedMessages.length - 1; + const renderedSubtasks = useMemo( + () => + collectRenderedSubtasks( + groupedMessages, + (groupIndex) => thread.isLoading && groupIndex === lastGroupIndex, + t.subtasks.failed, + t.subtasks.subtask, + ), + [ + groupedMessages, + lastGroupIndex, + t.subtasks.failed, + t.subtasks.subtask, + thread.isLoading, + ], + ); + + useEffect(() => { + // Synchronize message-derived snapshots after render. The task context + // ignores identical updates, so streamed re-renders remain idempotent. + for (const task of renderedSubtasks.updates) { + updateSubtask(task); + } + }, [renderedSubtasks, updateSubtask]); const previousTurnUsageStateRef = useRef( undefined, ); @@ -1316,44 +1335,15 @@ export function MessageList({ } else if (group.type === "assistant:subagent") { const tasks = new Set(); for (const message of group.messages) { - if (message.type === "ai") { - for (const toolCall of message.tool_calls ?? []) { - if (toolCall.name === "task") { - const taskId = toolCall.id; - if (!taskId) { - continue; - } - const status = derivePendingSubtaskStatus( - taskId, - group.messages, - groupIsLoading, - ); - const task: Subtask = { - id: taskId, - subagent_type: toolCall.args.subagent_type, - description: resolveSubtaskDescription( - toolCall.args.description, - toolCall.args.prompt, - t.subtasks.subtask, - ), - prompt: toolCall.args.prompt, - status, - ...(status === "failed" - ? { error: t.subtasks.failed } - : {}), - }; - updateSubtask(task); - tasks.add(task); - } - } - } else if (message.type === "tool") { - const taskId = message.tool_call_id; - if (taskId) { - const parsed = parseSubtaskResult( - extractTextFromMessage(message), - message.additional_kwargs, - ); - updateSubtask({ id: taskId, ...parsed }); + if (message.type !== "ai") { + continue; + } + for (const toolCall of message.tool_calls ?? []) { + const task = toolCall.id + ? renderedSubtasks.tasks.get(toolCall.id) + : undefined; + if (toolCall.name === "task" && task) { + tasks.add(task); } } } @@ -1395,6 +1385,10 @@ export function MessageList({ : [], ); for (const taskId of taskIds ?? []) { + const fallbackTask = renderedSubtasks.tasks.get(taskId); + if (!fallbackTask) { + continue; + } results.push( , ); } diff --git a/frontend/src/components/workspace/messages/subtask-card.tsx b/frontend/src/components/workspace/messages/subtask-card.tsx index 9b42ffce6a..980a53383b 100644 --- a/frontend/src/components/workspace/messages/subtask-card.tsx +++ b/frontend/src/components/workspace/messages/subtask-card.tsx @@ -28,6 +28,7 @@ import { SafeStreamdown, toStreamdownComponents, } from "@/core/streamdown/components"; +import type { Subtask } from "@/core/tasks"; import { fetchSubtaskSteps } from "@/core/tasks/api"; import { useSubtask, useUpdateSubtask } from "@/core/tasks/context"; import { @@ -35,6 +36,7 @@ import { resolveSubtaskModelLabel, } from "@/core/tasks/presentation"; import { stepsForDisplay } from "@/core/tasks/steps"; +import { resolveRenderedSubtask } from "@/core/tasks/subtask-render"; import { explainLastToolCall } from "@/core/tools/utils"; import { cn } from "@/lib/utils"; @@ -49,16 +51,18 @@ export function SubtaskCard({ threadId, runId, isLoading, + fallbackTask, }: { className?: string; taskId: string; threadId?: string; runId?: string; isLoading: boolean; + fallbackTask: Subtask; }) { const { t } = useI18n(); const [collapsed, setCollapsed] = useState(true); - const task = useSubtask(taskId)!; + const task = resolveRenderedSubtask(useSubtask(taskId), fallbackTask)!; const { models, tokenUsageEnabled } = useModels(); const updateSubtask = useUpdateSubtask(); const modelLabel = resolveSubtaskModelLabel(task.modelName, models); diff --git a/frontend/src/core/tasks/subtask-render.ts b/frontend/src/core/tasks/subtask-render.ts new file mode 100644 index 0000000000..db73364636 --- /dev/null +++ b/frontend/src/core/tasks/subtask-render.ts @@ -0,0 +1,110 @@ +import { extractTextFromMessage, type MessageGroup } from "../messages/utils"; + +import { resolveSubtaskDescription } from "./presentation"; +import { + derivePendingSubtaskStatus, + parseSubtaskResult, +} from "./subtask-result"; +import { isTerminalSubtaskStatus } from "./subtask-update"; +import type { Subtask } from "./types"; + +export interface RenderedSubtasks { + tasks: Map; + updates: Array & { id: string }>; +} + +export function resolveRenderedSubtask( + liveTask: Subtask | undefined, + fallbackTask: Subtask | undefined, +): Subtask | undefined { + if (!fallbackTask) { + return liveTask; + } + + if (!liveTask) { + return fallbackTask; + } + + if (!isTerminalSubtaskStatus(fallbackTask.status)) { + return { + ...fallbackTask, + ...liveTask, + subagent_type: fallbackTask.subagent_type ?? liveTask.subagent_type, + description: fallbackTask.description ?? liveTask.description, + prompt: fallbackTask.prompt ?? liveTask.prompt, + status: liveTask.status ?? fallbackTask.status, + }; + } + + return { + ...liveTask, + ...fallbackTask, + steps: liveTask.steps ?? fallbackTask.steps, + latestMessage: liveTask.latestMessage ?? fallbackTask.latestMessage, + }; +} + +export function collectRenderedSubtasks( + groups: MessageGroup[], + isGroupLoading: (groupIndex: number) => boolean, + failedLabel: string, + subtaskLabel: string, +): RenderedSubtasks { + const tasks = new Map(); + const updates: Array & { id: string }> = []; + + for (const [groupIndex, group] of groups.entries()) { + if (group.type !== "assistant:subagent") { + continue; + } + + const groupIsLoading = isGroupLoading(groupIndex); + + for (const message of group.messages) { + if (message.type === "ai") { + for (const toolCall of message.tool_calls ?? []) { + if (toolCall.name !== "task" || !toolCall.id) { + continue; + } + const status = derivePendingSubtaskStatus( + toolCall.id, + group.messages, + groupIsLoading, + ); + const task: Subtask = { + id: toolCall.id, + subagent_type: toolCall.args.subagent_type, + description: resolveSubtaskDescription( + toolCall.args.description, + toolCall.args.prompt, + subtaskLabel, + ), + prompt: toolCall.args.prompt, + status, + ...(status === "failed" ? { error: failedLabel } : {}), + }; + tasks.set(task.id, { ...(tasks.get(task.id) ?? {}), ...task }); + updates.push(task); + } + continue; + } + + if (message.type === "tool" && message.tool_call_id) { + const update = { + id: message.tool_call_id, + ...parseSubtaskResult( + extractTextFromMessage(message), + message.additional_kwargs, + ), + }; + const existingTask = tasks.get(message.tool_call_id); + if (existingTask) { + tasks.set(message.tool_call_id, { ...existingTask, ...update }); + } + updates.push(update); + } + } + } + + return { tasks, updates }; +} diff --git a/frontend/tests/unit/components/workspace/messages/subtask-card.dom.test.tsx b/frontend/tests/unit/components/workspace/messages/subtask-card.dom.test.tsx new file mode 100644 index 0000000000..db932c90cb --- /dev/null +++ b/frontend/tests/unit/components/workspace/messages/subtask-card.dom.test.tsx @@ -0,0 +1,216 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { useEffect, useMemo, useState } from "react"; + +import { SubtaskCard } from "@/components/workspace/messages/subtask-card"; +import { I18nContext } from "@/core/i18n/context"; +import { enUS } from "@/core/i18n/locales/en-US"; +import { fetchSubtaskSteps } from "@/core/tasks/api"; +import { + SubtasksProvider, + useSubtaskContext, + useUpdateSubtask, +} from "@/core/tasks/context"; +import { collectRenderedSubtasks } from "@/core/tasks/subtask-render"; +import type { Subtask } from "@/core/tasks/types"; + +rs.mock("@/core/tasks/api", () => ({ fetchSubtaskSteps: rs.fn() })); + +afterEach(() => { + cleanup(); + rs.clearAllMocks(); +}); + +function setup(history = false) { + let completeArguments: () => void; + let publish: ReturnType; + let readTask: () => Subtask; + function Messages() { + const [args, setArgs] = useState({ description: "Res", prompt: "" }); + const update = useUpdateSubtask(); + const { tasksRef } = useSubtaskContext(); + const rendered = useMemo( + () => + collectRenderedSubtasks( + [ + { + id: "group-1", + type: "assistant:subagent", + messages: [ + { + type: "ai", + id: "message-1", + content: "", + tool_calls: [ + { + id: "task-1", + name: "task", + args: { subagent_type: "researcher", ...args }, + }, + ], + } as Message, + ], + }, + ], + () => true, + "Subtask failed", + "Subtask", + ), + [args], + ); + useEffect(() => { + for (const task of rendered.updates) update(task); + }, [rendered, update]); + useEffect(() => { + publish = update; + readTask = () => tasksRef.current["task-1"]!; + completeArguments = () => { + update({ id: "task-1", modelName: "test-model" }); + setArgs({ + description: "Research the topic", + prompt: "Find and compare reliable sources", + }); + }; + }); + return ( + + ); + } + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(["models"], { + models: [], + token_usage: { enabled: true }, + }); + const view = render( + + undefined, t: enUS }} + > + + + + + , + ); + return { + ...view, + complete: () => completeArguments!(), + publish: (task: Partial & { id: string }) => publish!(task), + read: () => readTask!(), + }; +} + +describe("SubtaskCard message synchronization", () => { + it("merges a delayed history backfill with live steps after argument synchronization", async () => { + let finishBackfill!: (steps: NonNullable) => void; + rs.mocked(fetchSubtaskSteps).mockImplementation( + () => + new Promise((resolve) => { + finishBackfill = resolve; + }), + ); + const view = setup(true); + fireEvent.click(screen.getByRole("button")); + expect(fetchSubtaskSteps).toHaveBeenCalledWith( + "thread-1", + "run-1", + "task-1", + ); + const live = { + kind: "tool" as const, + message_index: 2, + text: "live", + tool_name: "web_search", + }; + const historical = { + kind: "tool" as const, + message_index: 1, + text: "history", + tool_name: "read_file", + }; + await act(async () => view.publish({ id: "task-1", steps: [live] })); + await act(async () => view.complete()); + await act(async () => finishBackfill([historical])); + expect(view.read().steps).toEqual([historical, live]); + expect(view.container.textContent).toContain("read_file"); + expect(view.container.textContent).toContain("web_search"); + expect(view.container.textContent).toContain( + "Find and compare reliable sources", + ); + }); + + it("updates the folded title when final arguments and task_started arrive together", async () => { + const view = setup(); + expect(screen.getByRole("button").textContent).toContain("Res"); + await act(async () => view.complete()); + expect(view.read().description).toBe("Research the topic"); + expect(screen.getByRole("button").textContent).toContain( + "Research the topic", + ); + expect(view.container.textContent).toContain("test-model"); + }); + + it("updates an already-expanded prompt without another event or click", async () => { + const view = setup(); + fireEvent.click(screen.getByRole("button")); + await act(async () => view.complete()); + expect(view.read().prompt).toBe("Find and compare reliable sources"); + expect(view.container.textContent).toContain( + "Find and compare reliable sources", + ); + expect(screen.getByRole("button").textContent).toContain( + "Research the topic", + ); + }); + + it("preserves live steps, usage and terminal state while completing arguments", async () => { + const view = setup(); + const step = { + kind: "tool" as const, + message_index: 1, + text: "Collected sources", + tool_name: "web_search", + truncated: false, + }; + const usage = { inputTokens: 800, outputTokens: 200, totalTokens: 1000 }; + await act(async () => view.publish({ id: "task-1", steps: [step], usage })); + await act(async () => + view.publish({ + id: "task-1", + status: "completed", + result: "Finished research", + }), + ); + await act(async () => view.complete()); + expect(screen.getByRole("button").textContent).toContain( + "Research the topic", + ); + expect(view.container.textContent).toContain("Subtask completed"); + expect(view.container.textContent).toContain("1,000 Tokens"); + expect(view.container.textContent).not.toContain("Running subtask"); + fireEvent.click(screen.getByRole("button")); + expect(view.container.textContent).toContain("web_search"); + expect(view.container.textContent).toContain("Finished research"); + expect(view.read()).toMatchObject({ + status: "completed", + steps: [step], + usage, + }); + }); +}); diff --git a/frontend/tests/unit/components/workspace/messages/subtask-card.test.ts b/frontend/tests/unit/components/workspace/messages/subtask-card.test.ts new file mode 100644 index 0000000000..fc07473518 --- /dev/null +++ b/frontend/tests/unit/components/workspace/messages/subtask-card.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { SubtaskCard } from "@/components/workspace/messages/subtask-card"; +import { I18nContext } from "@/core/i18n/context"; +import { enUS } from "@/core/i18n/locales/en-US"; +import { SubtaskContext } from "@/core/tasks/context"; +import type { Subtask } from "@/core/tasks/types"; + +function baseTask(overrides: Partial = {}): Subtask { + return { + id: "task-1", + status: "in_progress", + subagent_type: "researcher", + description: "Research the topic", + prompt: "Find sources", + ...overrides, + }; +} + +describe("SubtaskCard", () => { + it("renders a failed fallback snapshot ahead of stale in-progress context state", () => { + const html = renderCard({ + contextTask: baseTask({ status: "in_progress" }), + fallbackTask: baseTask({ + status: "failed", + error: "Subtask failed", + }), + }); + + expect(html).toContain("Subtask failed"); + expect(html).not.toContain("Running subtask"); + }); + + it("renders a completed fallback snapshot ahead of stale in-progress context state", () => { + const html = renderCard({ + contextTask: baseTask({ + status: "in_progress", + steps: [ + { + kind: "tool", + message_index: 1, + text: "Collected sources", + truncated: false, + }, + ], + }), + fallbackTask: baseTask({ + status: "completed", + result: "done", + }), + }); + + expect(html).toContain("Subtask completed"); + expect(html).not.toContain("Running subtask"); + }); +}); + +function renderCard({ + contextTask, + fallbackTask, +}: { + contextTask?: Subtask; + fallbackTask: Subtask; +}) { + const tasks = contextTask ? { [contextTask.id]: contextTask } : {}; + const queryClient = new QueryClient(); + + return renderToStaticMarkup( + createElement( + QueryClientProvider, + { + client: queryClient, + }, + createElement( + I18nContext.Provider, + { + value: { + locale: "en-US", + setLocale: () => undefined, + t: enUS, + }, + }, + createElement( + SubtaskContext.Provider, + { + value: { + tasks, + tasksRef: { current: tasks }, + setTasks: () => undefined, + }, + }, + createElement(SubtaskCard, { + taskId: fallbackTask.id, + isLoading: false, + fallbackTask, + }), + ), + ), + ), + ); +} diff --git a/frontend/tests/unit/core/tasks/subtask-render.test.ts b/frontend/tests/unit/core/tasks/subtask-render.test.ts new file mode 100644 index 0000000000..a138cf1170 --- /dev/null +++ b/frontend/tests/unit/core/tasks/subtask-render.test.ts @@ -0,0 +1,315 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { describe, expect, it } from "@rstest/core"; + +import type { MessageGroup } from "@/core/messages/utils"; +import { + collectRenderedSubtasks, + resolveRenderedSubtask, +} from "@/core/tasks/subtask-render"; +import type { Subtask } from "@/core/tasks/types"; + +function baseTask(overrides: Partial = {}): Subtask { + return { + id: "task-1", + status: "in_progress", + subagent_type: "researcher", + description: "Research the topic", + prompt: "Find sources", + ...overrides, + }; +} + +function subagentGroup(messages: Message[]) { + return { + id: "group-1", + type: "assistant:subagent", + messages, + } as const; +} + +describe("collectRenderedSubtasks", () => { + it("derives an in-progress task while the subagent turn is still streaming", () => { + const groups: MessageGroup[] = [ + { + id: "group-0", + type: "human", + messages: [], + }, + subagentGroup([ + { + type: "ai", + tool_calls: [ + { + id: "task-1", + name: "task", + args: { + subagent_type: "researcher", + description: "Research the topic", + prompt: "Find sources", + }, + }, + ], + content: "", + } as unknown as Message, + ]), + ]; + + const threadIsLoading = true; + const rendered = collectRenderedSubtasks( + groups, + (groupIndex) => threadIsLoading && groupIndex === groups.length - 1, + "failed", + "Subtask", + ); + + expect(rendered.tasks.get("task-1")).toMatchObject({ + id: "task-1", + status: "in_progress", + subagent_type: "researcher", + }); + expect(rendered.updates).toHaveLength(1); + }); + + it("marks a task as failed once its turn ended without a tool result", () => { + const groups = [ + subagentGroup([ + { + type: "ai", + tool_calls: [ + { + id: "task-1", + name: "task", + args: { + subagent_type: "researcher", + description: "Research the topic", + prompt: "Find sources", + }, + }, + ], + content: "", + } as unknown as Message, + ]), + ]; + + const rendered = collectRenderedSubtasks( + groups, + () => false, + "Subtask failed", + "Subtask", + ); + + expect(rendered.tasks.get("task-1")).toMatchObject({ + id: "task-1", + status: "failed", + error: "Subtask failed", + }); + }); + + it("merges a tool result into the fallback task snapshot", () => { + const groups = [ + subagentGroup([ + { + type: "ai", + tool_calls: [ + { + id: "task-1", + name: "task", + args: { + subagent_type: "researcher", + description: "Research the topic", + prompt: "Find sources", + }, + }, + ], + content: "", + } as unknown as Message, + { + type: "tool", + tool_call_id: "task-1", + content: "ignored by structured status", + additional_kwargs: { + subagent_status: "completed", + subagent_result_brief: "done", + }, + } as unknown as Message, + ]), + ]; + + const rendered = collectRenderedSubtasks( + groups, + () => false, + "failed", + "Subtask", + ); + + expect(rendered.tasks.get("task-1")).toMatchObject({ + id: "task-1", + status: "completed", + result: "done", + }); + expect(rendered.updates).toHaveLength(2); + }); +}); + +describe("collectRenderedSubtasks description fallback", () => { + it.each([ + [undefined, "Use the prompt", "Use the prompt"], + [" ", " Trimmed prompt ", "Trimmed prompt"], + [undefined, "", "Localized subtask"], + ])( + "preserves the current-main description fallback for %s / %s", + (description, prompt, expected) => { + const rendered = collectRenderedSubtasks( + [ + subagentGroup([ + { + type: "ai", + content: "", + tool_calls: [ + { + id: "task-1", + name: "task", + args: { description, prompt, subagent_type: "researcher" }, + }, + ], + } as Message, + ]), + ], + () => true, + "failed", + "Localized subtask", + ); + expect(rendered.tasks.get("task-1")?.description).toBe(expected); + }, + ); +}); + +describe("resolveRenderedSubtask", () => { + it("keeps live arguments when a partial snapshot omits them", () => { + const live = baseTask(); + const fallback = { id: "task-1", status: "in_progress" } as Subtask; + expect(resolveRenderedSubtask(live, fallback)).toEqual(live); + }); + + it.each(["completed", "failed"] as const)( + "preserves %s lifecycle and runtime data while refreshing message arguments", + (status) => { + const live = baseTask({ + status, + result: "done", + error: "failed", + stopReason: "token_capped", + modelName: "test-model", + usage: { inputTokens: 8, outputTokens: 2, totalTokens: 10 }, + steps: [{ kind: "tool", message_index: 1, text: "done" }], + latestMessage: { id: "live", type: "ai", content: "working" }, + }); + const resolved = resolveRenderedSubtask( + live, + baseTask({ + description: "Current title", + prompt: "Current prompt", + subagent_type: "general-purpose", + }), + ); + expect(resolved).toEqual({ + ...live, + description: "Current title", + prompt: "Current prompt", + subagent_type: "general-purpose", + }); + }, + ); + + it("uses an in-progress fallback before the live task is available", () => { + const fallbackTask = baseTask({ + status: "in_progress", + description: "fallback", + }); + + expect(resolveRenderedSubtask(undefined, fallbackTask)).toBe(fallbackTask); + }); + + it("prefers a terminal fallback snapshot over a stale live task", () => { + const resolved = resolveRenderedSubtask( + baseTask({ + status: "in_progress", + modelName: "claude-3-7-sonnet", + usage: { + inputTokens: 800, + outputTokens: 200, + totalTokens: 1_000, + }, + latestMessage: { id: "live-1" } as Subtask["latestMessage"], + }), + baseTask({ + status: "failed", + error: "Subtask failed", + }), + ); + + expect(resolved).toMatchObject({ + status: "failed", + error: "Subtask failed", + modelName: "claude-3-7-sonnet", + usage: { + inputTokens: 800, + outputTokens: 200, + totalTokens: 1_000, + }, + latestMessage: { id: "live-1" }, + }); + }); + + it("uses current message arguments while preserving live runtime state", () => { + const resolved = resolveRenderedSubtask( + baseTask({ + status: "in_progress", + description: "live", + modelName: "test-model", + prompt: "old prompt", + }), + baseTask({ status: "in_progress", description: "fallback", prompt: "" }), + ); + + expect(resolved).toMatchObject({ + status: "in_progress", + description: "fallback", + prompt: "", + modelName: "test-model", + }); + }); + + it("fills a metadata-only live task from an in-progress fallback", () => { + const resolved = resolveRenderedSubtask( + { + id: "task-1", + modelName: "claude-3-7-sonnet", + } as Subtask, + baseTask({ status: "in_progress", description: "fallback" }), + ); + + expect(resolved).toMatchObject({ + id: "task-1", + status: "in_progress", + description: "fallback", + modelName: "claude-3-7-sonnet", + }); + }); + + it("preserves fields available only on a live task after completion", () => { + const resolved = resolveRenderedSubtask( + { + ...baseTask({ status: "in_progress" }), + futureLiveMetadata: "preserved", + } as Subtask & { futureLiveMetadata: string }, + baseTask({ status: "completed", result: "done" }), + ) as Subtask & { futureLiveMetadata?: string }; + + expect(resolved).toMatchObject({ + status: "completed", + result: "done", + futureLiveMetadata: "preserved", + }); + }); +});