fix: conversational events - client side tool event responses should be queued#1760
fix: conversational events - client side tool event responses should be queued#1760norman-le wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the conversational chat bridge so tool resume responses (confirmToolCall / endToolCall) are matched and queued by tool_call_id, preventing earlier responses from being overwritten when multiple tools are invoked in a single LLM turn.
Changes:
- Reworked
SocketIOChatBridgetool-resume handling to track expected tool call IDs and route resume events to the correct waiter. - Updated/added bridge tests to register expected
tool_call_ids and validate concurrent/out-of-order resume behavior. - Bumped package version to
2.11.13and refresheduv.lock.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| packages/uipath/src/uipath/_cli/_chat/_bridge.py | Implements queued/keyed tool resume matching using a deque + results/pending structures. |
| packages/uipath/tests/cli/chat/test_bridge.py | Updates tests for new registration/matching behavior and adds concurrency scenarios. |
| packages/uipath/pyproject.toml | Version bump to 2.11.13. |
| packages/uipath/uv.lock | Lockfile refresh reflecting version and timestamp changes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
🚨 Heads up:
|
|
| """Wait for a tool resume event (confirmToolCall or endToolCall). | ||
|
|
||
| Pops the next expected tool_call_id (registered by emit_interrupt_event) | ||
| and returns the matching response. Two cases: | ||
|
|
||
| 1. Response already arrived (stored in _tool_resume_results) — return | ||
| immediately without blocking. | ||
| 2. Response hasn't arrived yet — create a Future in _tool_resume_pending, | ||
| block until _handle_conversation_event resolves it. | ||
|
|
||
| Returns: | ||
| The resume data dict, including ``tool_call_id``. | ||
| """ |
There was a problem hiding this comment.
Nice! Thanks for the detailed flow + demoes.
Two questions:
- In the second demo, if you didn't submit the tool results in order (e.g. instead of 1,2,3,4,5 you did 3,5,1,4,2) would it work?
- More of a design question, let me know if I'm misunderstanding anything: Any reason we need to dequeue just the next expected tool_call_id? If we just had a data-structure list with all N tool-calls, could we simplify by whenever a tool-resume event occurs, checking all the tool-call-ids in that list to see matches?
There was a problem hiding this comment.
Good questions.
-
Yes, it works out of order. Responses get stored in
_tool_resume_resultsby tool_call_id regardless of arrival order. Whenwait_for_resume()runs, it looks up by the expected ID, not by arrival order. The testtest_concurrent_tool_calls_out_of_ordertests this. -
The deque exists because
wait_for_resume()has no parameter — it doesn't know which tool_call_id to return. The runtime calls it N times in a sequential loop:
https://github.com/UiPath/uipath-runtime-python/blob/2b45546dbeb249541869a4fc227cac0839d51ca5/src/uipath/runtime/chat/runtime.py#L106
for trigger in api_triggers:
emit_interrupt_event(trigger) # "I'm about to wait for toolcall-3"
resume_data = wait_for_resume() # "give me toolcall-3's result"
resume_map[trigger.interrupt_id] = resume_dataEach call must return the result for that specific trigger. Without the deque, wait_for_resume() could return any available result and pair it with the wrong trigger (e.g. assign toolcall-1's result to toolcall-3, etc.)
One option was to pass tool_call_id as a parameter to wait_for_resume(tool_call_id) — then you'd only need the results/pending dicts, no deque. But that requires changing the UiPathChatProtocol, which I wanted to avoid. The deque bridges that gap without a protocol change.
There was a problem hiding this comment.
Overall I think I understand? But let's make sure to test all the edge-cases and left 2 questions here
Some other edge-cases I can think of:
- Parallel tool-calls with some client-side tools and others including/not-including tool-call confirmations (looks like you tested these already)
- Parallel tool-calls where some tools are Orchestrator process tools which do the suspend/resume - will your data-structures persist?



Summary
Problem
When the LLM calls multiple tools in one turn, the bridge stored each
endToolCall/confirmToolCallresponse in a single variable, overwriting previous values. If responses arrived beforewait_for_resume()consumed them, earlier values were lost — causing the exchange to hang.Fix:
_bridge.pyReplaced single-slot storage with keyed dict matching using 3 data structures:
_expected_tool_call_ids(deque) — ordered queue of tool_call_ids, populated byemit_interrupt_event(), consumed bywait_for_resume()to know which response to match_tool_resume_results(dict) — responses that arrived beforewait_for_resume()was called; checked first for immediate return_tool_resume_pending(dict of Futures) — created bywait_for_resume()when response hasn't arrived yet; resolved by_resolve_or_store_resume()when it doesFlow: wait_for_resume pops the next expected ID from the deque → checks results (already arrived?) → if yes, return it → if no, create a pending Future → _resolve_or_store_resume resolves it when the response finally comes in.
Key methods changed:
emit_interrupt_event()— now registers the trigger'stool_call_idin the deque (still no websocket emit, just using it here since it's naturally passed through with the known tool call ID)wait_for_resume()— pops expected ID from deque, checks results dict, falls back to Future_resolve_or_store_resume()— new helper that routes incoming responses to the correct Future or stores them_handle_conversation_event()— calls_resolve_or_store_resume()instead of setting a single variableTests:
test_bridge.pyNot changed
UiPathChatProtocol— same interfaceruntime.py— untouchedStill works for client side tools, tool confirmation and server side tools:
Screen.Recording.2026-06-24.at.1.31.04.PM.mov
From our react-sdk, there's a natural gap, so to emulate what a client can do I buffered the client side tool responses to be received at the same time in the runtime/bridge before the resume function is called for the interrupts (lines 22-33 in my log file). Can see how the logs were used in the first commit of the PR: 53303a4
Screen.Recording.2026-06-24.at.11.27.56.AM.mov