Files
zero/internal/zeroruntime/empty_toolcall_test.go
4d2ce6bc4d Streaming reliability: SSE idle-watchdog, dropped-call handling, finish_reason, usage (#105)
* Streaming reliability: SSE idle-watchdog, dropped-call handling, finish_reason, usage

First reviewable slice of the runtime-core work (split out of the large integration branch). Scope: internal/zeroruntime + internal/providers only — fully backward-compatible/additive (the rest of the tree builds unchanged against it).

- SSE idle-timeout watchdog (reader goroutine + idle timer + ctx-cancel) across OpenAI/Anthropic/Gemini via providerio.ScanSSEDataWithContext, so a stalled-but-open upstream no longer blocks the agent forever.

- Streamed tool-call assembly hardened: nameless/dropped tool calls reported (StreamEventToolCallDropped), start-order preserved, distinct empty-id calls no longer merge.

- Terminal finish/stop reason surfaced (CollectedStream.FinishReason + Truncated()) so length/content-filter responses aren't mistaken for normal completions; OpenAI requests stream_options.include_usage (token usage no longer 0); OpenAI delegates error/redact to providerio (503/529 classified) and joins multi-line SSE data; max_completion_tokens wired.

Build/vet/full-suite/-race green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address #105 review (CodeRabbit): ctx honored when idle watchdog off; cancel during retry backoff; test robustness

providerio: ScanSSEDataWithContext now runs the goroutine+select loop even when idleTimeout<=0 (idle case via a nil channel), so ctx cancellation is honored with the watchdog disabled (was a blocker: it short-circuited to the non-ctx-aware ScanSSEData). openai: a ctx cancellation during 5xx retry backoff is reported as the cancellation instead of being misclassified as the upstream 5xx. Tests: finish-reason/tool-call tests now assert the done/start event is actually emitted (not vacuously pass); new providerio regression test asserts cancel is honored with idleTimeout=0. Build/vet/-race green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: KRATOS <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-07 11:03:01 +05:30

82 lines
3.4 KiB
Go

package zeroruntime
import (
"context"
"testing"
)
// A malformed (nameless) tool call must never reach the agent — it would dispatch
// an empty tool name ("Unknown tool \"\""). Valid calls still pass through.
func TestCollectStreamDropsNamelessToolCalls(t *testing.T) {
events := make(chan StreamEvent, 8)
// valid call
events <- StreamEvent{Type: StreamEventToolCallStart, ToolCallID: "a", ToolName: "read_file"}
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "a", ArgumentsFragment: `{"path":"x"}`}
events <- StreamEvent{Type: StreamEventToolCallEnd, ToolCallID: "a"}
// malformed: a delta/end for an id that never got a (named) start
events <- StreamEvent{Type: StreamEventToolCallDelta, ToolCallID: "b", ArgumentsFragment: `{"path":"y"}`}
events <- StreamEvent{Type: StreamEventToolCallEnd, ToolCallID: "b"}
events <- StreamEvent{Type: StreamEventDone}
close(events)
got := CollectStream(context.Background(), events)
if len(got.ToolCalls) != 1 {
t.Fatalf("expected 1 valid tool call, got %d: %+v", len(got.ToolCalls), got.ToolCalls)
}
if got.ToolCalls[0].Name != "read_file" {
t.Errorf("kept call name = %q, want read_file", got.ToolCalls[0].Name)
}
for _, c := range got.ToolCalls {
if c.Name == "" {
t.Error("a nameless tool call leaked to the agent")
}
}
}
// An empty-id DELTA that arrives BEFORE its start event must not orphan its
// buffered arguments: a following empty-id start should adopt the in-flight
// call so the name and the early-buffered arguments end up on the same call.
func TestCollectStreamEmptyIDDeltaBeforeStartIsAdopted(t *testing.T) {
events := make(chan StreamEvent, 8)
// delta arrives first, with an empty id and no start yet
events <- StreamEvent{Type: StreamEventToolCallDelta, ArgumentsFragment: `{"path":`}
// start (empty id) arrives afterwards carrying the name
events <- StreamEvent{Type: StreamEventToolCallStart, ToolName: "read_file"}
events <- StreamEvent{Type: StreamEventToolCallDelta, ArgumentsFragment: `"x"}`}
events <- StreamEvent{Type: StreamEventToolCallEnd}
events <- StreamEvent{Type: StreamEventDone}
close(events)
got := CollectStream(context.Background(), events)
if len(got.ToolCalls) != 1 {
t.Fatalf("expected 1 tool call, got %d: %+v", len(got.ToolCalls), got.ToolCalls)
}
if got.ToolCalls[0].Name != "read_file" {
t.Fatalf("call name = %q, want read_file", got.ToolCalls[0].Name)
}
if got.ToolCalls[0].Arguments != `{"path":"x"}` {
t.Fatalf("arguments = %q, want full buffered args (early delta orphaned?)", got.ToolCalls[0].Arguments)
}
if got.DroppedToolCalls != 0 {
t.Fatalf("DroppedToolCalls = %d, want 0 (early delta should not orphan into a nameless call)", got.DroppedToolCalls)
}
}
// A provider-signalled dropped (nameless) tool call must be counted so the agent
// can tell the model to retry instead of silently treating it as a final answer.
func TestCollectStreamCountsDroppedToolCalls(t *testing.T) {
events := make(chan StreamEvent, 4)
events <- StreamEvent{Type: StreamEventText, Content: "I'll write the file."}
events <- StreamEvent{Type: StreamEventToolCallDropped}
events <- StreamEvent{Type: StreamEventDone}
close(events)
got := CollectStream(context.Background(), events)
if len(got.ToolCalls) != 0 {
t.Fatalf("expected no usable tool calls, got %d", len(got.ToolCalls))
}
if got.DroppedToolCalls != 1 {
t.Fatalf("expected DroppedToolCalls=1, got %d", got.DroppedToolCalls)
}
}