fix(agent): RunStream never surfaced tool calls to callers

Bug found while building rony-harness's "Edited Files" info panel: it
scanned the transcript for tool-call messages carrying write/edit
arguments, but those messages never appeared — not even for a plain,
successful top-level write with no delegation involved.

Root cause: RunStream's content-streaming gate (`if !hasToolCalls &&
(hasContent || hasUsage) { yield(chunk, nil) }`) suppresses yielding
*any* chunk once a tool call is seen in that iteration, including the
chunk carrying the tool call itself. So chunk.ToolCalls was executed
internally (hence approvals and results worked) but never yielded to
the caller. Every caller-side "which tool got called" hook depending on
the stream (not the Approver callback) was therefore dead code.

Fix: yield a dedicated chunk carrying just the executed ToolCalls right
after running them, independent of the content-streaming gate below.
Updated TestRun_Stream_WithToolCalls, which asserted the old (buggy)
1-chunk behavior.
This commit is contained in:
Victor Hugo Vargas Servin 2026-07-10 00:04:44 -07:00
parent 0a7a8d506d
commit caeb1a1be6
2 changed files with 23 additions and 4 deletions

View file

@ -205,6 +205,19 @@ func (l *Loop) RunStream(ctx context.Context, input string, history ...llm.Messa
Content: result.Content, Content: result.Content,
}) })
} }
// Surface which tools were actually called, and with
// what arguments, to the caller — a dedicated chunk,
// separate from the content-streaming gate below, since
// that gate exists to hide raw provider deltas during a
// tool-call round, not to hide the fact that a call
// happened at all. Without this, callers (e.g. a UI
// wanting to show "used tool X" or track which files a
// write/edit touched) have no way to observe tool
// calls unless they also happen to be the Approver.
if !yield(llm.StreamChunk{ToolCalls: chunk.ToolCalls}, nil) {
return
}
} }
// A trailing usage-only chunk (no Delta/ReasoningDelta, per // A trailing usage-only chunk (no Delta/ReasoningDelta, per

View file

@ -669,11 +669,17 @@ func TestRun_Stream_WithToolCalls(t *testing.T) {
chunks = append(chunks, chunk) chunks = append(chunks, chunk)
} }
if len(chunks) != 1 { // One chunk surfacing the tool call itself (so callers can observe
t.Errorf("expected 1 chunk (only the 'done' chunk), got %d", len(chunks)) // which tools ran and with what arguments), then the final "done"
// content chunk.
if len(chunks) != 2 {
t.Fatalf("expected 2 chunks (tool call + 'done'), got %d: %+v", len(chunks), chunks)
} }
if chunks[0].Delta != "done" { if len(chunks[0].ToolCalls) != 1 || chunks[0].ToolCalls[0].Name != "greet" {
t.Errorf("expected 'done', got %q", chunks[0].Delta) t.Errorf("expected the first chunk to surface the 'greet' tool call, got %+v", chunks[0].ToolCalls)
}
if chunks[1].Delta != "done" {
t.Errorf("expected 'done', got %q", chunks[1].Delta)
} }
} }