fix(conversation): scope completed tool call ids to their round

A provider that reuses a tool call id across two rounds of one run
wedges the run permanently. `ToolDispatcher::start_batch` skips any call
whose id is in `ToolBatchState::completed`, so the second call is never
dispatched and never produces a `ToolCompletion`, while
`tool_round::execute` blocks waiting for `calls.len()` results with no
timeout on that path. The client sees the tool call appear and then
nothing: no completion, no further output, no end-stream frame.

The `completed` set is built once per run and never cleared, so it is
run-scoped. A tool call id is only unique within a round, which the
schema already states as `UNIQUE (round_id, call_id)`; the sibling
runtime completed-map is likewise already cleared per round at
output.rs:615.

Clear `completed` when `ExecuteToolRound` begins a new round, tracked
independently of `active_round` so it does not depend on the order in
which ToolRoundStarted and ExecuteToolRound are observed. Replaying a
round still skips the calls that round already committed.

The practical trigger is openai_chat.rs:196, which synthesizes
`call-{index}` from a per-stream index when a provider omits tool call
ids, so `call-0` recurs on every model call. That file is left alone
here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kevin9327
2026-09-03 07:35:10 +09:00
co-authored by Claude Opus 4.8
parent 8fdcdd7f84
commit a42f84cfe7
2 changed files with 156 additions and 0 deletions
+11
View File
@@ -158,6 +158,7 @@ impl ConversationOutput {
let mut streams = BTreeMap::<usize, ToolCallStream>::new();
let mut completions = HashMap::<String, ToolCompletion>::new();
let mut completed = HashSet::<String>::new();
let mut completed_round = None::<ToolRoundId>;
let mut response_text = String::new();
let mut response_thinking = String::new();
let mut active_round = None::<ToolRoundId>;
@@ -425,6 +426,16 @@ impl ConversationOutput {
round_id,
calls: round_calls,
} => {
// `completed` exists so that replaying ExecuteToolRound for a round
// does not dispatch a call this round already committed. Tool call ids
// are only unique *within* a round -- the schema says as much with
// `UNIQUE (round_id, call_id)` -- so an id retained from an earlier
// round would make start_batch skip a fresh call, and tool_round wait
// forever for a result nothing will ever produce.
if completed_round.as_ref() != Some(&round_id) {
completed.clear();
completed_round = Some(round_id.clone());
}
active_round = Some(round_id.clone());
active_tool_calls = round_calls
.iter()