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:
AnoobFeng
2026-06-19 16:25:17 +08:00
committed by GitHub
parent 29489c0f45
commit a692576993
5 changed files with 214 additions and 11 deletions
@@ -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}
/>,
);
}
+44 -2
View File
@@ -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;
}
+25
View File
@@ -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
View 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).
*