Skip to content

fix(web): dispatch ConsumeResult::ToolCalls in WebUI path#7

Merged
MettaMazza merged 1 commit into
MettaMazza:mainfrom
Scooter-DeJean:fix/multi-tool-call-dispatch
May 17, 2026
Merged

fix(web): dispatch ConsumeResult::ToolCalls in WebUI path#7
MettaMazza merged 1 commit into
MettaMazza:mainfrom
Scooter-DeJean:fix/multi-tool-call-dispatch

Conversation

@Scooter-DeJean
Copy link
Copy Markdown
Contributor

Pre-reviewed via the #dev-general submission earlier today (the bug-patch markdown). Your verification confirmed the bug is real, the diagnosis is correct, and the fix shape is right. The one revision you flagged — enforce_context_budget doesn't belong in the helper — has been applied (call removed; budget enforcement is intentionally deferred to the chain, mirroring platform_ingest.rs:192-215).

What this fixes

stream_consumer::consume_stream returns ConsumeResult::ToolCalls(vec) for tool_calls.len() > 1 (stream_consumer.rs:345). The platform/* handlers (platform_ingest.rs:192, platform_exec.rs:268, platform_reinfer.rs:60) all match this variant. ws.rs:377 and ws_l1.rs:203 only matched the singular ToolCall variant, with the plural case falling through to _ => {} — silently dropping the turn.

Symptom: model emits a response with ≥2 tool calls, SSE [DONE] arrives with tool_calls=N has_content=true, then nothing. No observer audit, no tool execution, no UI text. Reasoning trace renders (it streamed live) but the response body never lands. Black hole.

Hits any model that parallelizes tool calls (Qwen3.5/3.6, Gemma 4, Llama 3.2/3.3, recent Mistral with tools). The default LlamaCppConfig::default() model gemma-4-31B-it-Q4_K_M.gguf would trip this.

The fix

Adds a ConsumeResult::ToolCalls(calls) arm in both ws.rs (turn-1 dispatch) and ws_l1.rs (chain-iteration dispatch), sharing a new dispatch_leading_tool_calls helper in ws_l1.rs. The helper executes all but the last call inline (sending tool_executing / tool_completed UI events and appending messages), returns the last call so the caller can drive the L1 chain on it. Returns None on empty Vec (stream_consumer invariant violation), surfaced explicitly by callers via tracing::error! and a WS error event — no silent fall-through.

Governance alignment

  • §2.4 No silent fallbacks — explicit handling for the empty-Vec case
  • §2.6 Clean error handling — tracing::error! with context, WS error events surfaced
  • §8.1 Root cause — addresses the actual missing dispatch arm, not a symptom
  • §10.2 One concern per PR — single change: dispatch the multi-tool-call variant in WebUI path
  • §11 R3/R4/R7 — no unwrap, no silent fallback, single concern
  • §13.4 (your new §) — N/A; no networking changes

Test note

ws.rs and ws_l1.rs have effectively no test scaffolding (ws.rs has only test_ws_module_compiles; ws_l1.rs has none). Building a mockable WS sink + provider infra is larger than the fix. Per your #dev-general feedback, this gap is acceptable for this PR; happy to bootstrap test infra as a separate follow-up if you want.

Cousin sites flagged

While in here, platform_stream.rs:267 and ws_stream.rs:115/:169 have the same ConsumeResult::ToolCall-only matching pattern. Confirmed by your observer's verification. Per §10.2 these are not in this PR's scope; will file as a separate report after this lands.

Independent of the three mesh PRs (#4, #5, #6) — no blocker between them, can land in either order.

@MettaMazza
Copy link
Copy Markdown
Owner

🔴 REJECTED — Governance Violations (3)

Violation 1: §3.1 — Missing tests

pub async fn dispatch_leading_tool_calls() is a new public function with zero tests. §3.1 mandates: "Unit tests for every public function."

Required: Add tests covering at minimum:

  • Empty Vec input → returns None
  • Single-element Vec → returns that element as Some, no leading dispatch
  • Multi-element Vec → dispatches N-1 leading calls, returns the Nth

Violation 2: §1.1 / R11 — File length

This PR pushes src/web/ws.rs from 563 → 587 lines. §1.1 limit is 500. §11 R11 is an immediate rejection trigger.

Required: Split ws.rs as part of this PR. The file must be under 500 lines after merge.

Violation 3: R10 — Missing module doc comment

src/web/ws_l1.rs has no //! module doc comment. This PR adds 71 lines to it. R10 rejects PRs with missing doc comments on modules being modified.

Required: Add a //! doc comment to ws_l1.rs.


The bug diagnosis is correct — the _ => {} silent drop is a real V4-class incident and the fix logic is sound. The code quality is not the issue. The structural compliance is. Fix the three items above and resubmit.

`stream_consumer::consume_stream` returns `ConsumeResult::ToolCalls(vec)`
for `tool_calls.len() > 1` (`stream_consumer.rs:345`). The platform/*
handlers (`platform_ingest.rs:192`, `platform_exec.rs:268`,
`platform_reinfer.rs:60`) all match this variant. `ws.rs` and `ws_l1.rs`
only matched the singular `ToolCall`, with the plural case falling
through to `_ => {}` — silently dropping the turn.

Symptom: model emits a response with ≥2 tool calls, SSE [DONE] arrives
with `tool_calls=N has_content=true`, then nothing. No observer audit,
no tool execution, no UI text. Black hole. Hits any model that
parallelizes tool calls (Qwen3.5/3.6, Gemma 4, Llama 3.2/3.3, recent
Mistral with tools). The default LlamaCppConfig::default() model
`gemma-4-31B-it-Q4_K_M.gguf` would trip this.

Fix: adds `ConsumeResult::ToolCalls(calls)` arms in both `ws.rs`
(turn-1 dispatch) and `ws_l1.rs` (chain-iteration dispatch). Shared
helper `dispatch_leading_tool_calls` in `ws_l1.rs` executes all but the
last call inline (sending tool_executing / tool_completed UI events and
appending messages), returns the last call so the caller drives the L1
chain on it. Returns `None` only on empty Vec (stream_consumer invariant
violation), surfaced explicitly via tracing::error! and a WS error event.

Three v2 changes addressing the review:

§3.1 — tests for `dispatch_leading_tool_calls`. Refactored generic over
a `DispatchSink` + `ToolExecutor` trait pair scoped to ws_l1.rs only.
Production callers construct `WsDispatchSink` (forwarding to the
existing `send_ws`) + `StateToolExecutor` (forwarding to the existing
`execute_tool_with_state`); unit tests use `CapturingDispatchSink` +
`MockToolExecutor` to assert the full side-effect contract directly.
Tests in new sibling `src/web/ws_l1_tests.rs` cover all three review
cases:
  - empty Vec → None; no WS events fired; no messages pushed; executor
    uninvoked
  - single-element → Some(elem); no leading WS events; no leading
    messages pushed; executor uninvoked
  - multi-element(3) → Some(third); 4 WS events in exact order
    (tool_executing[id1], tool_completed[id1], tool_executing[id2],
    tool_completed[id2]); executor invoked exactly 2 times (on id1
    then id2); 4 messages pushed (2 assistant_tool_call + 2 tool_result)

This verifies "no leading dispatch" and "dispatches N-1 leading calls"
from the review wording directly.

§1.1 — ws.rs under 500. Extracted the new ConsumeResult::ToolCalls arm
body into pub fn `handle_initial_multi_tool_dispatch` in `ws_l1.rs`.
ws.rs final: 494 lines (was 510 after rebase onto v3.1).

R10 — `//!` module doc comment added to `ws_l1.rs` describing its L1
chain-iteration role.

Post-merge sizes: ws.rs 494, ws_l1.rs 441, ws_l1_tests.rs 154. All
under §1.1 cap.

cargo check + cargo test green.
@Scooter-DeJean
Copy link
Copy Markdown
Contributor Author

All three resolved. dispatch_leading_tool_calls is now generic over a DispatchSink + ToolExecutor trait pair (scoped to ws_l1.rs only) — production callers construct WsDispatchSink (thin wrapper over send_ws) + StateToolExecutor (thin wrapper over execute_tool_with_state); tests use CapturingDispatchSink + MockToolExecutor. The 3 new tests in ws_l1_tests.rs assert the full side-effect contract directly on dispatch_leading_tool_calls: empty Vec → None + no events/messages/executor calls; single-element → Some(elem) + no leading dispatch; multi(3) → Some(third) + exactly 2 executor invocations + 4 WS events in order + 4 messages pushed. ws.rs reduced to 494 lines via extracting the new arm body into handle_initial_multi_tool_dispatch. //! module doc added to ws_l1.rs. cargo check + cargo test green.

@Scooter-DeJean Scooter-DeJean force-pushed the fix/multi-tool-call-dispatch branch from 22c4807 to 3da2fde Compare May 14, 2026 19:09
Copy link
Copy Markdown
Owner

@MettaMazza MettaMazza left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ APPROVED

All three v1 governance violations resolved:

  • §3.1: dispatch_leading_tool_calls now has 3 unit tests via trait-pair injection (DispatchSink + ToolExecutor). Tests assert the full side-effect contract: empty Vec → None + zero side effects; single-element → Some(elem) + no leading dispatch; multi(3) → dispatches N-1 leading calls + returns the Nth. Real behavioural contracts, not theatre.
  • §1.1: ws.rs reduced to 494 lines via extracting handle_initial_multi_tool_dispatch to ws_l1.rs.
  • R10: //! module doc added to ws_l1.rs.

Code quality is high. The bug diagnosis (V4-class silent tool-call drop via _ => {}) is correct and the fix is sound. Commit message is forensic-grade.

Advisory (non-blocking): ws.rs at 494/500 — 6 lines of headroom. Next contributor touching this file risks R11. Consider proactive extraction in a follow-up chore PR.

Merging now — this is a critical bug fix affecting the default model.

@MettaMazza MettaMazza merged commit 482fbec into MettaMazza:main May 17, 2026
0 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants