mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 05:28:11 +00:00
fix(frontend): resolve stale subagent running state after stop (#3639)
* fix(frontend): resolve stale subagent running state after stop Subtask cards were recreated from task tool calls as in_progress whenever the thread was loading. If a user stopped a run before the task tool returned a ToolMessage, the card could remain running after reload, and could flip back to running when a later user turn started streaming. Derive pending subtask state from the current assistant turn instead of the global thread loading flag. A task now stays in progress only while its own turn is loading or while a matching ToolMessage exists for result parsing; otherwise it is marked failed. Add unit coverage for task ToolMessage matching and for the later-turn loading regression. * fix(frontend): refresh subagent card on terminal status transition - notify React via a ref + post-render effect when a subtask flips to completed/failed, so SubtaskCard updates without relying on an unrelated MessageList re-render - tighten the current-turn heuristic to `groupIndex === lastGroupIndex` (precomputed once) — fixes the rare case where an earlier subagent group in the same turn was kept in_progress, and drops the per-group slice+some scan to O(1) - rename `getPendingSubtaskStatus` → `derivePendingSubtaskStatus` - narrow `toolCall.id` at MessageList call sites; skip task tool calls without an id instead of `id!` - add Playwright e2e covering reload-after-stop showing `Subtask failed`
This commit is contained in:
parent
29489c0f45
commit
a692576993
@ -29,7 +29,10 @@ import {
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import { parseSubtaskResult } from "@/core/tasks/subtask-result";
|
||||
import {
|
||||
derivePendingSubtaskStatus,
|
||||
parseSubtaskResult,
|
||||
} from "@/core/tasks/subtask-result";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@ -180,6 +183,7 @@ export function MessageList({
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const messages = thread.messages;
|
||||
const groupedMessages = getMessageGroups(messages);
|
||||
const lastGroupIndex = groupedMessages.length - 1;
|
||||
const turnUsageMessagesByGroupIndex =
|
||||
getAssistantTurnUsageMessages(groupedMessages);
|
||||
const tokenDebugSteps = useMemo(
|
||||
@ -275,6 +279,8 @@ export function MessageList({
|
||||
/>
|
||||
{groupedMessages.map((group, groupIndex) => {
|
||||
const turnUsageMessages = turnUsageMessagesByGroupIndex[groupIndex];
|
||||
const groupIsLoading =
|
||||
thread.isLoading && groupIndex === lastGroupIndex;
|
||||
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return (
|
||||
@ -359,12 +365,24 @@ export function MessageList({
|
||||
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: toolCall.id!,
|
||||
id: taskId,
|
||||
subagent_type: toolCall.args.subagent_type,
|
||||
description: toolCall.args.description,
|
||||
prompt: toolCall.args.prompt,
|
||||
status: "in_progress",
|
||||
status,
|
||||
...(status === "failed"
|
||||
? { error: t.subtasks.failed }
|
||||
: {}),
|
||||
};
|
||||
updateSubtask(task);
|
||||
tasks.add(task);
|
||||
@ -402,7 +420,7 @@ export function MessageList({
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
isLoading={groupIsLoading}
|
||||
tokenDebugSteps={tokenDebugSteps.filter(
|
||||
(step) => step.messageId === message.id,
|
||||
)}
|
||||
@ -414,15 +432,15 @@ export function MessageList({
|
||||
} else if (message.id) {
|
||||
subagentDebugMessageIds.push(message.id);
|
||||
}
|
||||
const taskIds = message.tool_calls
|
||||
?.filter((toolCall) => toolCall.name === "task")
|
||||
.map((toolCall) => toolCall.id);
|
||||
const taskIds = message.tool_calls?.flatMap((toolCall) =>
|
||||
toolCall.name === "task" && toolCall.id ? [toolCall.id] : [],
|
||||
);
|
||||
for (const taskId of taskIds ?? []) {
|
||||
results.push(
|
||||
<SubtaskCard
|
||||
key={"task-group-" + taskId}
|
||||
taskId={taskId!}
|
||||
isLoading={thread.isLoading}
|
||||
taskId={taskId}
|
||||
isLoading={groupIsLoading}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import type { Subtask } from "./types";
|
||||
|
||||
function isTerminalSubtaskStatus(status: Subtask["status"] | undefined) {
|
||||
return status === "completed" || status === "failed";
|
||||
}
|
||||
|
||||
export interface SubtaskContextValue {
|
||||
tasks: Record<string, Subtask>;
|
||||
setTasks: (tasks: Record<string, Subtask>) => void;
|
||||
@ -40,14 +51,45 @@ export function useSubtask(id: string) {
|
||||
|
||||
export function useUpdateSubtask() {
|
||||
const { tasks, setTasks } = useSubtaskContext();
|
||||
const shouldNotifyAfterRenderRef = useRef(false);
|
||||
// No deps: must run after every render to check the ref set during render.
|
||||
useEffect(() => {
|
||||
if (!shouldNotifyAfterRenderRef.current) {
|
||||
return;
|
||||
}
|
||||
shouldNotifyAfterRenderRef.current = false;
|
||||
setTasks({ ...tasks });
|
||||
});
|
||||
|
||||
const updateSubtask = useCallback(
|
||||
(task: Partial<Subtask> & { id: string }) => {
|
||||
tasks[task.id] = { ...tasks[task.id], ...task } as Subtask;
|
||||
const previous = tasks[task.id];
|
||||
const previousStatus = previous?.status;
|
||||
// MessageList writes the pending task tool-call state before parsing the
|
||||
// matching ToolMessage in the same render. Keep terminal results stable
|
||||
// across the next render so the refresh notification does not loop.
|
||||
const next = {
|
||||
...previous,
|
||||
...task,
|
||||
...(task.status === "in_progress" &&
|
||||
isTerminalSubtaskStatus(previousStatus)
|
||||
? { status: previousStatus }
|
||||
: {}),
|
||||
} as Subtask;
|
||||
|
||||
const becameTerminal =
|
||||
isTerminalSubtaskStatus(next.status) && previousStatus !== next.status;
|
||||
|
||||
tasks[task.id] = next;
|
||||
|
||||
if (task.latestMessage) {
|
||||
setTasks({ ...tasks });
|
||||
} else if (becameTerminal) {
|
||||
shouldNotifyAfterRenderRef.current = true;
|
||||
}
|
||||
},
|
||||
[tasks, setTasks],
|
||||
);
|
||||
|
||||
return updateSubtask;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { Subtask } from "./types";
|
||||
|
||||
export type SubtaskStatus = Subtask["status"];
|
||||
@ -124,6 +126,29 @@ export function parseSubtaskResult(
|
||||
return update;
|
||||
}
|
||||
|
||||
export function hasSubtaskToolResult(
|
||||
toolCallId: string | undefined,
|
||||
messages: Message[],
|
||||
) {
|
||||
if (!toolCallId) {
|
||||
return false;
|
||||
}
|
||||
return messages.some(
|
||||
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
|
||||
);
|
||||
}
|
||||
|
||||
export function derivePendingSubtaskStatus(
|
||||
toolCallId: string | undefined,
|
||||
messages: Message[],
|
||||
isCurrentTurnLoading: boolean,
|
||||
): SubtaskStatus {
|
||||
if (isCurrentTurnLoading || hasSubtaskToolResult(toolCallId, messages)) {
|
||||
return "in_progress";
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function parseFromText(trimmed: string): SubtaskResultUpdate {
|
||||
if (trimmed.startsWith(SUCCESS_PREFIX)) {
|
||||
return {
|
||||
|
||||
66
frontend/tests/e2e/subtask-card.spec.ts
Normal file
66
frontend/tests/e2e/subtask-card.spec.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
|
||||
|
||||
const STOPPED_TASK_DESCRIPTION = "Research stopped reload regression";
|
||||
const STOPPED_TASK_PROMPT =
|
||||
"Investigate why the stopped subtask card should not remain running after reload.";
|
||||
|
||||
const stoppedSubtaskMessages = [
|
||||
{
|
||||
type: "human",
|
||||
id: "msg-human-stopped-subtask",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Start a subtask and then stop before the task tool returns.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-stopped-subtask",
|
||||
content: "",
|
||||
additional_kwargs: {},
|
||||
response_metadata: {},
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-stopped-subtask",
|
||||
name: "task",
|
||||
args: {
|
||||
subagent_type: "general-purpose",
|
||||
description: STOPPED_TASK_DESCRIPTION,
|
||||
prompt: STOPPED_TASK_PROMPT,
|
||||
},
|
||||
type: "tool_call",
|
||||
},
|
||||
],
|
||||
invalid_tool_calls: [],
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("Subtask card", () => {
|
||||
test("shows failed after a stopped task thread is reloaded", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "Stopped subtask",
|
||||
updated_at: "2026-06-18T12:00:00Z",
|
||||
messages: stoppedSubtaskMessages,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByText(STOPPED_TASK_DESCRIPTION)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByText("Subtask failed")).toBeVisible();
|
||||
await expect(page.getByText("Running subtask")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@ -1,11 +1,14 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SUBAGENT_ERROR_KEY,
|
||||
SUBAGENT_STATUS_KEY,
|
||||
derivePendingSubtaskStatus,
|
||||
hasSubtaskToolResult,
|
||||
parseSubtaskResult,
|
||||
} from "@/core/tasks/subtask-result";
|
||||
|
||||
@ -142,6 +145,55 @@ describe("parseSubtaskResult", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSubtaskToolResult", () => {
|
||||
it("matches a task tool call to its ToolMessage", () => {
|
||||
const messages = [
|
||||
{ type: "ai" },
|
||||
{ type: "tool", tool_call_id: "call_task_1" },
|
||||
] as Message[];
|
||||
|
||||
expect(hasSubtaskToolResult("call_task_1", messages)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when a task tool call has no ToolMessage", () => {
|
||||
const messages = [
|
||||
{ type: "ai" },
|
||||
{ type: "tool", tool_call_id: "call_other" },
|
||||
] as Message[];
|
||||
|
||||
expect(hasSubtaskToolResult("call_task_1", messages)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePendingSubtaskStatus", () => {
|
||||
it("keeps a task in progress while its own assistant turn is loading", () => {
|
||||
const messages = [{ type: "ai" }] as Message[];
|
||||
|
||||
expect(derivePendingSubtaskStatus("call_task_1", messages, true)).toBe(
|
||||
"in_progress",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not revive an earlier unfinished task during a later turn", () => {
|
||||
const messages = [{ type: "ai" }] as Message[];
|
||||
|
||||
expect(derivePendingSubtaskStatus("call_task_1", messages, false)).toBe(
|
||||
"failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves result parsing to the ToolMessage path when a result exists", () => {
|
||||
const messages = [
|
||||
{ type: "ai" },
|
||||
{ type: "tool", tool_call_id: "call_task_1" },
|
||||
] as Message[];
|
||||
|
||||
expect(derivePendingSubtaskStatus("call_task_1", messages, false)).toBe(
|
||||
"in_progress",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Structured-status path (bytedance/deer-flow#3146).
|
||||
*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user