From 7b3d2c7435b09b7775fd14f2523e1c9775ff3236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nathan=20=F0=9F=94=B6=20Tarbert?= <66887028+NathanTarbert@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:07:31 -0400 Subject: [PATCH] fix(langgraph): don't re-emit TOOL_CALL_START on HITL resume OnToolEnd's synthetic TOOL_CALL_START/ARGS/END triple was gated only on the per-run emittedToolCallStartIds Set, which handleStreamEvents resets to a fresh Set on every run(). On a HITL resume (a second run() on the same thread) the Set is empty, so the guard passes and the tool call the frontend already received in the prior run is re-announced. Symptom: parent follow-up text streams in, then disappears when MESSAGES_SNAPSHOT arrives, and the tool call reverts to a loading state. Dedupe against the durable thread history in this.messages (the full conversation the client replays on resume) instead: if the originating assistant message for the tool call is already present, skip the synthetic triple in both the update.messages path and the single-message path. TOOL_CALL_RESULT still fires normally. Fixes #2014 --- .../langgraph/typescript/src/agent.ts | 35 ++++- .../src/resume-tool-call-dedup.test.ts | 137 ++++++++++++++++++ 2 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 integrations/langgraph/typescript/src/resume-tool-call-dedup.test.ts diff --git a/integrations/langgraph/typescript/src/agent.ts b/integrations/langgraph/typescript/src/agent.ts index 393380405a..0fb108d61a 100644 --- a/integrations/langgraph/typescript/src/agent.ts +++ b/integrations/langgraph/typescript/src/agent.ts @@ -1196,6 +1196,26 @@ export class LangGraphAgent extends AbstractAgent { }); } + /** + * True when a tool call's originating assistant message is already present in + * the durable thread history (`this.messages`). On a HITL resume the client + * replays the full conversation, so the TOOL_CALL_START/ARGS/END triple for + * this tool call was already delivered in the prior run and must not be + * re-emitted by `OnToolEnd` (whose per-run `emittedToolCallStartIds` Set is + * reset on every run). `TOOL_CALL_RESULT` still fires normally. See #2014. + */ + private toolCallAnnouncedInPriorRun(toolCallId: string | undefined): boolean { + if (!toolCallId) { + return false; + } + return (this.messages ?? []).some( + (message: any) => + message?.role === "assistant" && + Array.isArray(message.toolCalls) && + message.toolCalls.some((toolCall: any) => toolCall?.id === toolCallId), + ); + } + handleSingleEvent(event: any): void { // messages-tuple data arrives as [AIMessageChunk, metadata] arrays, // not objects with an .event property like events-mode data. @@ -1489,7 +1509,12 @@ export class LangGraphAgent extends AbstractAgent { toolCallOutput.update?.messages .filter((message: MessageFields) => message.type === "tool") .forEach((message: MessageFields) => { - if (!this.activeRun!.hasFunctionStreaming) { + // Skip the synthetic START/ARGS on a HITL resume where the tool + // call was already announced in a prior run (#2014). + if ( + !this.activeRun!.hasFunctionStreaming && + !this.toolCallAnnouncedInPriorRun(message.tool_call_id) + ) { this.dispatchEvent({ type: EventType.TOOL_CALL_START, toolCallId: message.tool_call_id, @@ -1526,8 +1551,12 @@ export class LangGraphAgent extends AbstractAgent { // Emit TOOL_CALL_START + ARGS + END for tool calls that were not // already handled by the streaming path. Uses emittedToolCallStartIds - // to avoid duplicates from parallel tool calls. - if (!this.emittedToolCallStartIds.has(toolCallOutput.tool_call_id)) { + // to avoid duplicates from parallel tool calls, and the durable + // thread history to avoid re-announcing on a HITL resume (#2014). + if ( + !this.emittedToolCallStartIds.has(toolCallOutput.tool_call_id) && + !this.toolCallAnnouncedInPriorRun(toolCallOutput.tool_call_id) + ) { this.emittedToolCallStartIds.add(toolCallOutput.tool_call_id); this.dispatchEvent({ type: EventType.TOOL_CALL_START, diff --git a/integrations/langgraph/typescript/src/resume-tool-call-dedup.test.ts b/integrations/langgraph/typescript/src/resume-tool-call-dedup.test.ts new file mode 100644 index 0000000000..dc9f174363 --- /dev/null +++ b/integrations/langgraph/typescript/src/resume-tool-call-dedup.test.ts @@ -0,0 +1,137 @@ +/** + * Regression tests for issue #2014. + * + * On a HITL resume, `handleStreamEvents` resets `emittedToolCallStartIds` to a + * fresh Set, so `OnToolEnd`'s synthetic TOOL_CALL_START/ARGS/END triple — gated + * only on that per-run Set — re-announces a tool call the frontend already + * received in the prior run. That makes the parent text flash and the tool call + * revert to a loading state when MESSAGES_SNAPSHOT lands. + * + * The fix dedupes against the durable thread history in `this.messages` (the + * full conversation the client replays on resume): if the originating assistant + * message for the tool call is already present, the synthetic triple is skipped. + * TOOL_CALL_RESULT still fires normally. + */ + +import { describe, it, expect } from "vitest"; +import { EventType } from "@ag-ui/core"; +import { LangGraphAgent } from "./agent"; +import type { LangGraphAgentConfig } from "./agent"; + +function makeConfig(): LangGraphAgentConfig { + return { deploymentUrl: "http://localhost:2024", graphId: "test-graph" }; +} + +function createAgent(messages: any[] = []) { + const agent = new LangGraphAgent(makeConfig()); + const dispatched: any[] = []; + agent.dispatchEvent = (event: any) => { + dispatched.push(event); + return event as any; + }; + (agent as any).activeRun = { + id: "run-2", + threadId: "thread-1", + hasFunctionStreaming: false, + modelMadeToolCall: false, + }; + (agent as any).emittedToolCallStartIds = new Set(); + agent.messages = messages as any; + return { agent, dispatched }; +} + +/** An assistant message that already announced `toolCallId` in a prior run. */ +function priorAssistantMessage(toolCallId: string) { + return { + id: "asst-1", + role: "assistant", + content: "", + toolCalls: [ + { + id: toolCallId, + type: "function", + function: { name: "my_tool", arguments: "{}" }, + }, + ], + }; +} + +function singleToolEndEvent(toolCallId: string) { + return { + event: "on_tool_end", + metadata: { langgraph_node: "tools" }, + data: { + input: { foo: "bar" }, + output: { tool_call_id: toolCallId, name: "my_tool", content: "Done." }, + }, + }; +} + +function commandToolEndEvent(toolCallId: string) { + // LangGraph's Command-style shape: tool message nested in update.messages. + return { + event: "on_tool_end", + metadata: { langgraph_node: "tools" }, + data: { + input: { foo: "bar" }, + output: { + update: { + messages: [ + { + type: "tool", + tool_call_id: toolCallId, + name: "my_tool", + content: "Done.", + id: "msg-1", + }, + ], + }, + }, + }, + }; +} + +const startEvents = (d: any[]) => d.filter((e) => e.type === EventType.TOOL_CALL_START); +const resultEvents = (d: any[]) => d.filter((e) => e.type === EventType.TOOL_CALL_RESULT); + +describe("issue #2014: OnToolEnd must not re-emit TOOL_CALL_START on HITL resume", () => { + it("skips the synthetic triple when the tool call is already in prior-run history (single path)", () => { + const toolCallId = "tc-resumed"; + const { agent, dispatched } = createAgent([priorAssistantMessage(toolCallId)]); + + agent.handleSingleEvent(singleToolEndEvent(toolCallId)); + + // Re-announcement suppressed, result still delivered. + expect(startEvents(dispatched)).toHaveLength(0); + expect( + dispatched.filter((e) => e.type === EventType.TOOL_CALL_ARGS), + ).toHaveLength(0); + expect( + dispatched.filter((e) => e.type === EventType.TOOL_CALL_END), + ).toHaveLength(0); + expect(resultEvents(dispatched)).toHaveLength(1); + expect(resultEvents(dispatched)[0].toolCallId).toBe(toolCallId); + }); + + it("skips the synthetic triple on resume for the Command/update.messages path", () => { + const toolCallId = "tc-resumed-cmd"; + const { agent, dispatched } = createAgent([priorAssistantMessage(toolCallId)]); + + agent.handleSingleEvent(commandToolEndEvent(toolCallId)); + + expect(startEvents(dispatched)).toHaveLength(0); + expect(resultEvents(dispatched)).toHaveLength(1); + expect(resultEvents(dispatched)[0].toolCallId).toBe(toolCallId); + }); + + it("still emits START/ARGS/END for a fresh tool call not present in history", () => { + const toolCallId = "tc-fresh"; + const { agent, dispatched } = createAgent([]); // no prior history + + agent.handleSingleEvent(singleToolEndEvent(toolCallId)); + + expect(startEvents(dispatched)).toHaveLength(1); + expect(startEvents(dispatched)[0].toolCallId).toBe(toolCallId); + expect(resultEvents(dispatched)).toHaveLength(1); + }); +});