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); + }); +});