diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index df702ef6c..813415479 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -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({ 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( , ); } diff --git a/frontend/src/core/tasks/context.tsx b/frontend/src/core/tasks/context.tsx index ea85772cb..52bfc7d4e 100644 --- a/frontend/src/core/tasks/context.tsx +++ b/frontend/src/core/tasks/context.tsx @@ -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; setTasks: (tasks: Record) => 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 & { 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; } diff --git a/frontend/src/core/tasks/subtask-result.ts b/frontend/src/core/tasks/subtask-result.ts index 8cec19c59..9efa45e8a 100644 --- a/frontend/src/core/tasks/subtask-result.ts +++ b/frontend/src/core/tasks/subtask-result.ts @@ -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 { diff --git a/frontend/tests/e2e/subtask-card.spec.ts b/frontend/tests/e2e/subtask-card.spec.ts new file mode 100644 index 000000000..452be375a --- /dev/null +++ b/frontend/tests/e2e/subtask-card.spec.ts @@ -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); + }); +}); diff --git a/frontend/tests/unit/core/tasks/subtask-result.test.ts b/frontend/tests/unit/core/tasks/subtask-result.test.ts index 37840b1d4..d6e8bfbd4 100644 --- a/frontend/tests/unit/core/tasks/subtask-result.test.ts +++ b/frontend/tests/unit/core/tasks/subtask-result.test.ts @@ -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). *