bedrock: Fix duplicated messages and colliding tool call ids from Bedrock Mantle (#60998)
## Problem
AWS Bedrock's Mantle endpoint (used for GPT-5.x models via the
OpenAI-compatible Responses API) has two independent bugs that both
cause visible duplication/corruption in the agent panel:
1. **Duplicated message text.** Mantle streams a reply as a series of
new `message` output items, where each new item's text is a growing
prefix-superset of the previous one, and the full `output_text.delta`
sequence is replayed for each item. Forwarding these events through the
shared `OpenAiResponseEventMapper` unmodified caused the assistant's
reply (and its phase/reasoning metadata) to appear duplicated in the UI.
2. **Colliding tool call ids.** Mantle assigns raw `tool_use` ids as a
simple per-request counter (`call_1`, `call_2`, ...) that resets to
`call_1` on every request/response cycle to the model — including
separate cycles within a single agent turn as it performs tool calls
sequentially. Since `acp::ToolCallId` was built directly from that raw
id, and `AcpThread::upsert_tool_call` matches tool calls by id across a
thread's entire entry list, a later, unrelated tool call reusing a raw
id would silently overwrite an earlier, already-completed one instead of
appearing as a new entry — visible as "old tool calls getting replaced
by new ones."
Both were confirmed against real raw Mantle SSE traces and a real
persisted thread (`call_1` recurring across 10+ separate request cycles
within a single turn).
## Fix
**For message duplication:** `MantleResponseEventMapper` wraps
`OpenAiResponseEventMapper` and collapses adjacent, same-phase,
strict-prefix message extensions into a single logical message,
forwarding only the replayed suffix as ordinary incremental text.
- Only adjacent, same-phase, strict-prefix extensions are collapsed.
Equal, shrinking, divergent, or different-phase messages are still shown
as-is.
- Any non-message output item (reasoning, tool call) is a hard boundary:
it's forwarded unchanged and clears the merge state, so a message after
a tool call is never merged with the one before it.
- When a message is merged into the previous one, its `StartMessage` and
`ReasoningDetails` metadata events are suppressed, since they'd
otherwise duplicate what was already emitted for the first message.
**For tool call id collisions:** `scoped_tool_call_id(message_ix,
tool_use_id)` combines the raw provider id with the index of the
`AgentMessage` it belongs to in `Thread::messages`. That index is stable
for the lifetime of one request/response cycle (`flush_pending_message`
only runs on the next `StartMessage` event), so ids stay unique across
the whole thread without needing the raw provider id itself to be
globally unique. The index is threaded through explicitly from where
each tool call is created (`handle_tool_use_event`/`run_tool`) to where
it's later referenced (`process_tool_result`), rather than recomputed
later by re-reading `Thread::messages.len()`.
We deliberately did not rewrite the raw `tool_use` id itself at the
provider layer to make it globally unique, since that id round-trips
back to Mantle verbatim as `call_id` in `function_call_output` items on
the next request — rewriting it would require a fragile reverse-mapping
on every code path that replays tool results.
## Testing
- `cargo test -p language_models --lib bedrock` — 23/23 pass (11 tests
covering coalescing, independent messages, phase boundaries,
tool/reasoning boundaries, chained cumulative extensions, and metadata
deduplication).
- `cargo test -p agent --lib` — 699/699 pass (2 new regression tests for
tool call id scoping: one via live streaming across two request cycles
in one turn, one via persistence/`Thread::replay()` across two separate
turns).
- `script/clippy -p language_models -p agent` — clean.
Release Notes:
- Fixed duplicated assistant replies and colliding tool calls when using
GPT-5.x models on Amazon Bedrock's Mantle endpoint
9eb615298bAnant Goel committed on 7/23/2026, 6:40:30 PM· committed by GitHubparentf8851b6