mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 02:05:45 +00:00
* feat(subagents): show runtime metadata on task cards * fix(subagents): stop task-card render loop and dedupe model fetches Address code review on the runtime-metadata cards: - P1 render loop: the terminal ToolMessage is re-parsed on every MessageList render and always carries modelName/usage, so the presence-based setTasks condition fired a fresh state object each render -> "Maximum update depth exceeded". computeNextSubtask now returns a value-compared `changed` flag and a pure subtaskNotification() routes terminal transitions through the deferred after-render path while skipping no-op re-parses. - Per-card useModels refetch: add staleTime: Infinity to the ["models"] query so every subtask card shares one /api/models fetch instead of refetching on each mount. * make format * refactor(subagents): dedupe token-usage validators + tidy event narrowing Address PR review follow-ups: - DRY: extract one shared token-usage validator per side. Backend status_contract.normalize_token_usage() now backs both the terminal ToolMessage metadata and the subagent.step/.end run events (step_events.py), and frontend messages/usage.normalizeTokenUsage() backs both the live task_running event (lifecycle.ts) and the terminal ToolMessage metadata (subtask-result.ts). Prevents the input/output/ total_tokens validation from drifting across the four former copies. - Nit: onCustomEvent narrows event.type once instead of re-checking the object shape per branch; the redundant task_started early-return (already validated by taskEventToSubtaskUpdate) is dropped.
217 lines
6.5 KiB
TypeScript
217 lines
6.5 KiB
TypeScript
import { describe, expect, it } from "@rstest/core";
|
|
|
|
import type { SubtaskStep } from "@/core/tasks/steps";
|
|
import {
|
|
computeNextSubtask,
|
|
isTerminalSubtaskStatus,
|
|
subtaskNotification,
|
|
} from "@/core/tasks/subtask-update";
|
|
import type { Subtask } from "@/core/tasks/types";
|
|
|
|
function baseTask(overrides: Partial<Subtask> = {}): Subtask {
|
|
return {
|
|
id: "t1",
|
|
status: "in_progress",
|
|
subagent_type: "general-purpose",
|
|
description: "research",
|
|
prompt: "do it",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function step(message_index: number): SubtaskStep {
|
|
return {
|
|
kind: "tool",
|
|
message_index,
|
|
text: `step ${message_index}`,
|
|
truncated: false,
|
|
};
|
|
}
|
|
|
|
describe("computeNextSubtask", () => {
|
|
it("merges step deltas into the provided previous, preserving both", () => {
|
|
const previous = baseTask({ steps: [step(1), step(2)] });
|
|
|
|
const { next } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
steps: [step(3)],
|
|
});
|
|
|
|
// Regression for #3779 stale-closure race: next is derived from whatever
|
|
// `previous` is passed (the functional-update latest state), so concurrently
|
|
// arrived steps are kept, not clobbered by a backfill resolving late.
|
|
expect(next.steps?.map((s) => s.message_index)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("does not drop steps present only on the latest previous", () => {
|
|
// Simulates a backfill that fired when previous had 0 steps, but by the time
|
|
// it resolves the latest previous already has SSE steps 1..2. The backfill
|
|
// brings historical steps 1..3; the merge must retain all, not reset to [].
|
|
const latestPrevious = baseTask({ steps: [step(1), step(2)] });
|
|
|
|
const { next } = computeNextSubtask(latestPrevious, {
|
|
id: "t1",
|
|
steps: [step(1), step(2), step(3)],
|
|
});
|
|
|
|
expect(next.steps?.map((s) => s.message_index)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("keeps the latest cumulative token snapshot when an older event arrives late", () => {
|
|
const previous = baseTask({
|
|
usage: { inputTokens: 200, outputTokens: 40, totalTokens: 240 },
|
|
});
|
|
|
|
const { next } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
});
|
|
|
|
expect(next.usage).toEqual({
|
|
inputTokens: 200,
|
|
outputTokens: 40,
|
|
totalTokens: 240,
|
|
});
|
|
});
|
|
|
|
it("keeps a terminal status stable against a late in_progress write", () => {
|
|
const previous = baseTask({ status: "completed" });
|
|
|
|
const { next, becameTerminal } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
status: "in_progress",
|
|
});
|
|
|
|
expect(next.status).toBe("completed");
|
|
expect(becameTerminal).toBe(false);
|
|
});
|
|
|
|
it("flags becameTerminal on the first transition to a terminal status", () => {
|
|
const previous = baseTask({ status: "in_progress" });
|
|
|
|
const { next, becameTerminal } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
status: "completed",
|
|
result: "done",
|
|
});
|
|
|
|
expect(next.status).toBe("completed");
|
|
expect(next.result).toBe("done");
|
|
expect(becameTerminal).toBe(true);
|
|
});
|
|
|
|
it("handles an undefined previous (first write for a task)", () => {
|
|
const { next, becameTerminal } = computeNextSubtask(undefined, {
|
|
id: "t1",
|
|
status: "in_progress",
|
|
subagent_type: "bash",
|
|
description: "run",
|
|
prompt: "p",
|
|
steps: [step(1)],
|
|
});
|
|
|
|
expect(next.id).toBe("t1");
|
|
expect(next.steps?.map((s) => s.message_index)).toEqual([1]);
|
|
expect(becameTerminal).toBe(false);
|
|
});
|
|
|
|
it("reports changed=false when a terminal update carries identical runtime metadata", () => {
|
|
// The terminal ToolMessage is re-parsed on every MessageList render, so the
|
|
// same modelName/usage arrive again as a *new* object each time. Value-equal
|
|
// re-application must not be flagged as a change or the card loops forever.
|
|
const previous = baseTask({
|
|
status: "completed",
|
|
result: "done",
|
|
modelName: "opus",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
});
|
|
|
|
const { changed } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
status: "completed",
|
|
result: "done",
|
|
modelName: "opus",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
});
|
|
|
|
expect(changed).toBe(false);
|
|
});
|
|
|
|
it("reports changed=true when runtime metadata actually differs", () => {
|
|
const previous = baseTask({
|
|
status: "completed",
|
|
modelName: "opus",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
});
|
|
|
|
const { changed } = computeNextSubtask(previous, {
|
|
id: "t1",
|
|
status: "completed",
|
|
modelName: "opus",
|
|
usage: { inputTokens: 300, outputTokens: 60, totalTokens: 360 },
|
|
});
|
|
|
|
expect(changed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("subtaskNotification", () => {
|
|
it("defers a terminal transition (arrives during render, must not setState mid-render)", () => {
|
|
expect(
|
|
subtaskNotification(
|
|
{ id: "t1", status: "completed", modelName: "opus" },
|
|
{ becameTerminal: true, changed: true },
|
|
),
|
|
).toBe("deferred");
|
|
});
|
|
|
|
it("eagerly reflects a live SSE update that actually changed", () => {
|
|
expect(
|
|
subtaskNotification(
|
|
{
|
|
id: "t1",
|
|
usage: { inputTokens: 300, outputTokens: 60, totalTokens: 360 },
|
|
},
|
|
{ becameTerminal: false, changed: true },
|
|
),
|
|
).toBe("eager");
|
|
});
|
|
|
|
it("does nothing when a re-parsed terminal result carries unchanged metadata", () => {
|
|
// Regression: modelName/usage are present on every terminal re-parse, but the
|
|
// state did not change. Firing setTasks here is the render loop (P1).
|
|
expect(
|
|
subtaskNotification(
|
|
{
|
|
id: "t1",
|
|
status: "completed",
|
|
modelName: "opus",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
},
|
|
{ becameTerminal: false, changed: false },
|
|
),
|
|
).toBe("none");
|
|
});
|
|
|
|
it("does nothing when a replayed SSE usage snapshot did not change state", () => {
|
|
expect(
|
|
subtaskNotification(
|
|
{
|
|
id: "t1",
|
|
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
},
|
|
{ becameTerminal: false, changed: false },
|
|
),
|
|
).toBe("none");
|
|
});
|
|
});
|
|
|
|
describe("isTerminalSubtaskStatus", () => {
|
|
it("recognizes terminal statuses only", () => {
|
|
expect(isTerminalSubtaskStatus("completed")).toBe(true);
|
|
expect(isTerminalSubtaskStatus("failed")).toBe(true);
|
|
expect(isTerminalSubtaskStatus("in_progress")).toBe(false);
|
|
expect(isTerminalSubtaskStatus(undefined)).toBe(false);
|
|
});
|
|
});
|