mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
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:
parent
54c65d8ef6
commit
7c948a38c4
@ -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
|
||||
|
||||
|
||||
@ -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}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
110
frontend/src/core/tasks/subtask-render.ts
Normal file
110
frontend/src/core/tasks/subtask-render.ts
Normal 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 };
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
315
frontend/tests/unit/core/tasks/subtask-render.test.ts
Normal file
315
frontend/tests/unit/core/tasks/subtask-render.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user