From caeb1a1be6a467edc70ef318ca51e1ac7c9a3db0 Mon Sep 17 00:00:00 2001 From: Victor Vargas Date: Fri, 10 Jul 2026 00:04:44 -0700 Subject: [PATCH] fix(agent): RunStream never surfaced tool calls to callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pkg/agent/loop.go | 13 +++++++++++++ pkg/agent/loop_test.go | 14 ++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 1c0916a..59dc93e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -205,6 +205,19 @@ func (l *Loop) RunStream(ctx context.Context, input string, history ...llm.Messa 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 diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4c59bcf..ccf5931 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -669,11 +669,17 @@ func TestRun_Stream_WithToolCalls(t *testing.T) { chunks = append(chunks, chunk) } - if len(chunks) != 1 { - t.Errorf("expected 1 chunk (only the 'done' chunk), got %d", len(chunks)) + // One chunk surfacing the tool call itself (so callers can observe + // 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" { - t.Errorf("expected 'done', got %q", chunks[0].Delta) + if len(chunks[0].ToolCalls) != 1 || chunks[0].ToolCalls[0].Name != "greet" { + 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) } }