From 8b041517e28fd97979602fff8d226733eed84212 Mon Sep 17 00:00:00 2001 From: Lars Grammel Date: Thu, 4 Jun 2026 09:46:27 +0200 Subject: [PATCH] fix(langchain): register key mapping for tool calls emitted via messages mode for HITL interrupt matching (#15793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Background When using `@ai-sdk/langchain` with `streamMode: ["messages", "values"]` for LangGraph HITL (Human-in-the-Loop) workflows, the `__interrupt__` handler fails to match tool calls that were already emitted via the `messages` stream mode. In `processLangGraphEvent`, tool calls emitted through `messages` events are added to `emittedToolCalls` (Set) but **not** to `emittedToolCallsByKey` (Map). When the subsequent `values` event arrives with the same tool call, the values mode tool call loop skips it because `emittedToolCalls.has(toolCall.id)` is already true — but this also skips the `emittedToolCallsByKey.set()` registration. When the `__interrupt__` handler then tries `emittedToolCallsByKey.get(toolCallKey)`, it fails and falls back to generating a new ID like `hitl-delete_file-1234567890`. This causes: 1. `tool-approval-request` gets a mismatched `toolCallId` (orphaned from the original tool call card in the UI) 2. Duplicate `tool-input-start` / `tool-input-available` events are emitted for the same tool call ## Summary Added an `else if` branch in the values mode tool call loop to register the key mapping for tool calls that were already emitted via messages mode: ```typescript } else if (toolCall.id && emittedToolCalls.has(toolCall.id)) { // Register key mapping for tool calls already emitted via messages mode // so that __interrupt__ handling can match them by key const toolCallKey = `${toolCall.name}:${JSON.stringify(toolCall.args)}`; emittedToolCallsByKey.set(toolCallKey, toolCall.id); } ``` This is consistent with the existing key mapping pattern at L1292 (values finalization block). **Changes:** - `packages/langchain/src/utils.ts`: 5-line else-if branch addition - `packages/langchain/src/adapter.test.ts`: 2 new test cases for the dual streamMode scenario - Updated existing HITL snapshot to reflect correct tool call ID matching ## Manual Verification Verified in a production application using `createReactAgent` + `interrupt()` + `streamMode: ["messages", "values"]`: - Before fix: `tool-approval-request` gets fallback ID → orphaned approval card in UI - After fix: `tool-approval-request` correctly references the original `toolCallId` → approval card connects to the existing tool call card The existing HITL fixture test (`LANGGRAPH_RESPONSE_1`) also demonstrates the fix — the snapshot previously contained the fallback ID `hitl-delete_file-1234567890`, now correctly shows the original `call_LOd3dMxgYxmNLWZVWXra9xWQ`. ## Related Issues * Fixes #12797 * Builds on and superseeds #12798 Co-authored-by: domuk-k [dannyworks102@gmail.com](mailto:dannyworks102@gmail.com) (cherry picked from commit 6e23bf28b228a7e71fcfb85ea5deed6f30a97207) --- .changeset/hitl-interrupt-key-mapping.md | 5 + .../langgraph-hitl-request-1.json | 19 +- packages/langchain/src/adapter.test.ts | 179 ++++++++++++++++++ packages/langchain/src/utils.ts | 5 + 4 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 .changeset/hitl-interrupt-key-mapping.md diff --git a/.changeset/hitl-interrupt-key-mapping.md b/.changeset/hitl-interrupt-key-mapping.md new file mode 100644 index 000000000000..df07b2088839 --- /dev/null +++ b/.changeset/hitl-interrupt-key-mapping.md @@ -0,0 +1,5 @@ +--- +'@ai-sdk/langchain': patch +--- + +fix(langchain): register key mapping for tool calls emitted via messages mode for HITL interrupt matching diff --git a/packages/langchain/src/__snapshots__/langgraph-hitl-request-1.json b/packages/langchain/src/__snapshots__/langgraph-hitl-request-1.json index 5a0bc4c7d80c..613afe825458 100644 --- a/packages/langchain/src/__snapshots__/langgraph-hitl-request-1.json +++ b/packages/langchain/src/__snapshots__/langgraph-hitl-request-1.json @@ -479,25 +479,10 @@ "type": "reasoning-end", "id": "run-019b259a-5def-7000-8000-0a449f226de9" }, - { - "type": "tool-input-start", - "toolCallId": "hitl-delete_file-1234567890", - "toolName": "delete_file", - "dynamic": true - }, - { - "type": "tool-input-available", - "toolCallId": "hitl-delete_file-1234567890", - "toolName": "delete_file", - "input": { - "filename": "report.pdf" - }, - "dynamic": true - }, { "type": "tool-approval-request", - "approvalId": "hitl-delete_file-1234567890", - "toolCallId": "hitl-delete_file-1234567890" + "approvalId": "call_LOd3dMxgYxmNLWZVWXra9xWQ", + "toolCallId": "call_LOd3dMxgYxmNLWZVWXra9xWQ" }, { "type": "finish-step" diff --git a/packages/langchain/src/adapter.test.ts b/packages/langchain/src/adapter.test.ts index 01974526cd52..628a3fb3b944 100644 --- a/packages/langchain/src/adapter.test.ts +++ b/packages/langchain/src/adapter.test.ts @@ -2181,6 +2181,185 @@ describe('toUIMessageStream with LangGraph HITL fixture', () => { }); }); +describe('toUIMessageStream HITL interrupt key matching with dual streamMode', () => { + beforeEach(() => { + vi.spyOn(Date, 'now').mockReturnValue(1234567890); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should match interrupt to tool call emitted via messages mode', async () => { + // This test verifies the fix for HITL interrupt key matching when using + // streamMode: ["messages", "values"]. The tool call is first emitted via + // messages mode, then the values event contains the same tool call plus + // an __interrupt__. The interrupt handler must find the original tool call ID. + const originalToolCallId = 'call_abc123'; + + const inputStream = convertArrayToReadableStream([ + // 1. messages mode: AI streams a tool call + [ + 'messages', + [ + { + lc: 1, + type: 'constructor', + id: ['langchain_core', 'messages', 'AIMessageChunk'], + kwargs: { + id: 'ai-msg-1', + content: [], + tool_call_chunks: [ + { + id: originalToolCallId, + name: 'delete_file', + args: '{"filename":"report.pdf"}', + index: 0, + }, + ], + tool_calls: [], + }, + }, + { + tags: [], + langgraph_step: 1, + langgraph_node: 'agent', + }, + ], + ], + // 2. values mode: full state with same tool call + __interrupt__ + [ + 'values', + { + messages: [ + { + type: 'constructor', + id: ['langchain_core', 'messages', 'HumanMessage'], + kwargs: { id: 'human-1', content: 'Delete report.pdf' }, + }, + { + type: 'constructor', + id: ['langchain_core', 'messages', 'AIMessage'], + kwargs: { + id: 'ai-msg-1', + content: '', + tool_calls: [ + { + id: originalToolCallId, + name: 'delete_file', + args: { filename: 'report.pdf' }, + type: 'tool_call', + }, + ], + }, + }, + ], + __interrupt__: [ + { + value: { + actionRequests: [ + { + name: 'delete_file', + args: { filename: 'report.pdf' }, + }, + ], + }, + }, + ], + }, + ], + ]); + + const result = await convertReadableStreamToArray( + toUIMessageStream(inputStream), + ); + + // Find the tool-approval-request event + const approvalEvents = result.filter( + (e: { type: string }) => e.type === 'tool-approval-request', + ); + + expect(approvalEvents).toHaveLength(1); + // The approval must reference the ORIGINAL tool call ID from messages mode, + // not a fallback-generated ID like "hitl-delete_file-1234567890" + expect(approvalEvents[0]).toMatchObject({ + type: 'tool-approval-request', + approvalId: originalToolCallId, + toolCallId: originalToolCallId, + }); + + // The interrupt handler should NOT emit duplicate tool-input-start/available + // since the tool call was already emitted via messages mode + const toolInputStartEvents = result.filter( + (e: { type: string; toolCallId?: string }) => + e.type === 'tool-input-start' && e.toolCallId !== originalToolCallId, + ); + expect(toolInputStartEvents).toHaveLength(0); + }); + + it('should handle interrupt for tool call only emitted via values mode', async () => { + // When there's no prior messages event, the values handler should + // still register the key mapping correctly + const toolCallId = 'call_values_only'; + + const inputStream = convertArrayToReadableStream([ + [ + 'values', + { + messages: [ + { + type: 'constructor', + id: ['langchain_core', 'messages', 'HumanMessage'], + kwargs: { id: 'human-1', content: 'Delete report.pdf' }, + }, + { + type: 'constructor', + id: ['langchain_core', 'messages', 'AIMessage'], + kwargs: { + id: 'ai-msg-1', + content: '', + tool_calls: [ + { + id: toolCallId, + name: 'delete_file', + args: { filename: 'report.pdf' }, + type: 'tool_call', + }, + ], + }, + }, + ], + __interrupt__: [ + { + value: { + actionRequests: [ + { + name: 'delete_file', + args: { filename: 'report.pdf' }, + }, + ], + }, + }, + ], + }, + ], + ]); + + const result = await convertReadableStreamToArray( + toUIMessageStream(inputStream), + ); + + const approvalEvents = result.filter( + (e: { type: string }) => e.type === 'tool-approval-request', + ); + + expect(approvalEvents).toHaveLength(1); + expect(approvalEvents[0]).toMatchObject({ + toolCallId: toolCallId, + }); + }); +}); + describe('toUIMessageStream end-to-end with processUIMessageStream', () => { // These tests verify that the adapter output is valid for the client-side // processUIMessageStream by feeding real fixture data through both. diff --git a/packages/langchain/src/utils.ts b/packages/langchain/src/utils.ts index 0f23c9eee0f2..57b4fc490f74 100644 --- a/packages/langchain/src/utils.ts +++ b/packages/langchain/src/utils.ts @@ -1698,6 +1698,11 @@ export function processLangGraphEvent( input: toolCall.args, dynamic: true, }); + } else if (toolCall.id && emittedToolCalls.has(toolCall.id)) { + // Register key mapping for tool calls already emitted via messages mode + // so that __interrupt__ handling can match them by key + const toolCallKey = `${toolCall.name}:${JSON.stringify(toolCall.args)}`; + emittedToolCallsByKey.set(toolCallKey, toolCall.id); } } }