fix(frontend): avoid subtask render state mutation (#3157)

* 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 <willem.jiang@gmail.com>
This commit is contained in:
Ryanba 2026-09-24 15:34:32 +08:00 committed by GitHub
parent 54c65d8ef6
commit 7c948a38c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 790 additions and 45 deletions

View File

@ -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

View File

@ -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<AssistantTurnUsageState | undefined>(
undefined,
);
@ -1316,44 +1335,15 @@ export function MessageList({
} else if (group.type === "assistant:subagent") {
const tasks = new Set<Subtask>();
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(
<SubtaskCard
key={"task-group-" + taskId}
@ -1402,6 +1396,7 @@ export function MessageList({
threadId={threadId}
runId={(message as { run_id?: string }).run_id}
isLoading={groupIsLoading}
fallbackTask={fallbackTask}
/>,
);
}

View File

@ -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);

View File

@ -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<string, Subtask>;
updates: Array<Partial<Subtask> & { 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<string, Subtask>();
const updates: Array<Partial<Subtask> & { 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 };
}

View File

@ -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<typeof useUpdateSubtask>;
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 (
<SubtaskCard
taskId="task-1"
threadId={history ? "thread-1" : undefined}
runId={history ? "run-1" : undefined}
isLoading
fallbackTask={rendered.tasks.get("task-1")!}
/>
);
}
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
client.setQueryData(["models"], {
models: [],
token_usage: { enabled: true },
});
const view = render(
<QueryClientProvider client={client}>
<I18nContext.Provider
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
>
<SubtasksProvider>
<Messages />
</SubtasksProvider>
</I18nContext.Provider>
</QueryClientProvider>,
);
return {
...view,
complete: () => completeArguments!(),
publish: (task: Partial<Subtask> & { 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<Subtask["steps"]>) => 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,
});
});
});

View File

@ -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> = {}): 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,
}),
),
),
),
);
}

View File

@ -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> = {}): 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",
});
});
});