@@ -131,6 +131,103 @@ describe("Human-in-the-Loop (HITL) Integration Tests", () => {
131131 } ,
132132 ) ;
133133
134+ it . concurrent (
135+ "should not leave dangling tool_call_id when rejecting an interrupted tool with parallel tool calls (issue #15)" ,
136+ { timeout : 120000 } ,
137+ async ( ) => {
138+ // Regression test for GitHub issue #15:
139+ // When two tools are called in parallel (one interrupted, one not),
140+ // rejecting the interrupted tool should not leave a dangling tool_call_id.
141+ // The provider would throw: "An assistant message with 'tool_calls' must be
142+ // followed by tool messages responding to each 'tool_call_id'."
143+
144+ const checkpointer = new MemorySaver ( ) ;
145+
146+ // interrupted_tool requires approval, free_tool does not
147+ const interruptConfig : Record < string , boolean | InterruptOnConfig > = {
148+ sample_tool : true , // This one will be interrupted
149+ get_weather : false , // This one will run freely
150+ } ;
151+
152+ const agent = createDeepAgent ( {
153+ tools : [ sampleTool , getWeather ] ,
154+ interruptOn : interruptConfig ,
155+ checkpointer,
156+ } ) ;
157+
158+ const config = { configurable : { thread_id : uuidv4 ( ) } } ;
159+ assertAllDeepAgentQualities ( agent ) ;
160+
161+ // First invocation - ask agent to call both tools in parallel
162+ // sample_tool will be interrupted, get_weather will run freely
163+ const result = await agent . invoke (
164+ {
165+ messages : [
166+ {
167+ role : "user" ,
168+ content :
169+ "Call both sample_tool AND get_weather for New York in parallel." ,
170+ } ,
171+ ] ,
172+ } ,
173+ config ,
174+ ) ;
175+
176+ // Check that both tools were called
177+ const agentMessages = result . messages . filter ( ( msg : any ) =>
178+ AIMessage . isInstance ( msg ) ,
179+ ) ;
180+ const toolCalls = agentMessages . flatMap (
181+ ( msg : any ) => msg . tool_calls || [ ] ,
182+ ) ;
183+
184+ expect ( toolCalls . some ( ( tc : any ) => tc . name === "sample_tool" ) ) . toBe ( true ) ;
185+ expect ( toolCalls . some ( ( tc : any ) => tc . name === "get_weather" ) ) . toBe ( true ) ;
186+
187+ // Check that we have an interrupt for sample_tool
188+ expect ( result . __interrupt__ ) . toBeDefined ( ) ;
189+ expect ( result . __interrupt__ ) . toHaveLength ( 1 ) ;
190+
191+ const interrupts = result . __interrupt__ ?. [ 0 ] . value as HITLRequest ;
192+ expect ( interrupts . actionRequests ) . toHaveLength ( 1 ) ;
193+ expect ( interrupts . actionRequests [ 0 ] . name ) . toBe ( "sample_tool" ) ;
194+
195+ // REJECT the interrupted tool call
196+ // This is the key scenario from issue #15 - rejecting should not leave
197+ // a dangling tool_call_id
198+ const result2 = await agent . invoke (
199+ new Command ( {
200+ resume : {
201+ decisions : [ { type : "reject" } ] ,
202+ } ,
203+ } ) ,
204+ config ,
205+ ) ;
206+
207+ // The agent should complete without errors (no dangling tool_call_id)
208+ expect ( result2 . __interrupt__ ) . toBeUndefined ( ) ;
209+
210+ // Check that we have tool results for get_weather (the non-interrupted tool)
211+ const toolResults = result2 . messages . filter (
212+ ( msg : any ) => msg . _getType ( ) === "tool" ,
213+ ) ;
214+ expect ( toolResults . some ( ( tr : any ) => tr . name === "get_weather" ) ) . toBe (
215+ true ,
216+ ) ;
217+
218+ // The sample_tool should have a synthetic ToolMessage (cancelled/rejected)
219+ // or the tool call should be handled in some way that doesn't leave it dangling
220+ const sampleToolResult = toolResults . find (
221+ ( tr : any ) => tr . name === "sample_tool" ,
222+ ) ;
223+ // Either there's a result for sample_tool (rejection message) or
224+ // the agent handled it properly without leaving dangling tool_call_id
225+ if ( sampleToolResult ) {
226+ expect ( typeof sampleToolResult . content ) . toBe ( "string" ) ;
227+ }
228+ } ,
229+ ) ;
230+
134231 it . concurrent (
135232 "should handle HITL with subagents" ,
136233 { timeout : 120000 } ,
0 commit comments