diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 3ced9d3f4..f2ea7f3eb 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -80,10 +80,26 @@ export function getMessageGroups(messages: Message[]): MessageGroup[] { if (open) { open.messages.push(message); } else { - console.error( - "Unexpected tool message outside a processing group", - message, - ); + // Fallback for orphan tool messages — LangGraph `messages-tuple` can + // emit tool-result events out of order or replay them from subagent + // state (e.g. bash subagent under LocalSandboxProvider with + // allow_host_bash). When that happens, the tool message arrives after + // a terminal group and lastOpenGroup() returns null. Previously we + // dropped the message with console.error, silently hiding the tool + // result from the UI. Attach to the most recent group instead so the + // user can still see what the agent did. + const lastGroup = groups[groups.length - 1]; + if (lastGroup) { + lastGroup.messages.push(message); + } else { + // groups is empty (shouldn't happen — the outer for loop is guarded + // by `messages.length === 0 -> return []`), but keep the diagnostic + // just in case. + console.error( + "Unexpected tool message with no preceding group", + message, + ); + } } } continue; diff --git a/frontend/tests/unit/core/messages/utils.test.ts b/frontend/tests/unit/core/messages/utils.test.ts index 999b2d4b8..dde79e7ca 100644 --- a/frontend/tests/unit/core/messages/utils.test.ts +++ b/frontend/tests/unit/core/messages/utils.test.ts @@ -541,3 +541,101 @@ describe("multi-part content with bare-string continuations", () => { ); }); }); + +describe("orphan tool messages", () => { + // LangGraph stream-mode "messages-tuple" can emit tool-result events out of order or + // replayed from subagent state (e.g. bash subagent under LocalSandboxProvider with + // allow_host_bash). When that happens, the tool message arrives after a terminal + // assistant/human group, so getMessageGroups' lastOpenGroup() returns null. + // + // The previous behaviour was console.error + drop, which silently hid the tool + // result from the UI. The fix falls back to attaching the orphan tool to the most + // recent group so the user can still see what the agent did. + + test("attaches orphan tool message to the most recent group instead of dropping it", () => { + const messages = [ + { id: "h-1", type: "human", content: "Run something" }, + { + id: "ai-1", + type: "ai", + content: "ok", + tool_calls: [{ id: "call-1", name: "bash", args: {} }], + }, + { + id: "t-1", + type: "tool", + name: "bash", + tool_call_id: "call-1", + content: "output-1", + }, + { id: "ai-2", type: "ai", content: "Done." }, // terminal assistant group + // Orphan tool: arrives after a terminal group, no preceding processing group + { + id: "t-2", + type: "tool", + name: "bash", + tool_call_id: "call-2", + content: "output-2", + }, + ] as Message[]; + + const groups = getMessageGroups(messages); + + // Expect groups: human, assistant:processing (ai-1 + t-1), assistant (ai-2), and + // t-2 should be attached to the last group (assistant), not dropped. + const types = groups.map((g) => g.type); + expect(types).toEqual(["human", "assistant:processing", "assistant"]); + + // t-2 must be retrievable from one of the groups — must NOT be silently dropped + const allMessages = groups.flatMap((g) => g.messages); + const t2 = allMessages.find((m) => m.id === "t-2"); + expect(t2).toBeDefined(); + expect(t2?.type).toBe("tool"); + }); + + test("replayed tool with same tool_call_id is not lost (duplicate stream events)", () => { + // LangGraph subagent state restoration can replay tool-result events. The + // frontend log shows the same tool_call_id arriving twice. Both occurrences + // should be visible in the UI, not just the first. + const messages = [ + { id: "h-1", type: "human", content: "q" }, + { + id: "ai-1", + type: "ai", + content: "", + tool_calls: [{ id: "call-x", name: "bash", args: {} }], + }, + { + id: "t-1a", + type: "tool", + name: "bash", + tool_call_id: "call-x", + content: "first delivery", + }, + // Terminal assistant group ends the turn and closes the processing group. + // Without this interleave the replayed t-1b would still take the + // unchanged happy path; with it, t-1b arrives when lastOpenGroup() + // returns null and must take the new fallback branch to be visible. + { id: "ai-2", type: "ai", content: "Done." }, + // Replayed tool-result for the original tool_call — must reach the new + // else-if (groups.length > 0) branch instead of being dropped. + { + id: "t-1b", + type: "tool", + name: "bash", + tool_call_id: "call-x", + content: "first delivery", + }, + ] as Message[]; + + const groups = getMessageGroups(messages); + const allMessages = groups.flatMap((g) => g.messages); + + // Strict assertion: the replayed tool message must be reachable from a + // group (i.e. attached via the new fallback). Before the fix this was + // silently dropped by console.error. + const t1b = allMessages.find((m) => m.id === "t-1b"); + expect(t1b).toBeDefined(); + expect(t1b?.type).toBe("tool"); + }); +});