From 5cfa5a0df971eb1ba68dcaa899dd3e2af41e6151 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Tue, 21 Apr 2026 15:45:46 +0200 Subject: [PATCH] backend: 100% coverage for buildAnthropicMessages and streamAnthropicSSE - Test all buildAnthropicMessages branches: system concat, assistant tool-calls-only, nil arguments default, tool batching, unknown role, full multi-turn conversation - Test streamAnthropicSSE done re-entry: events after message_stop and error are ignored - Remove unreachable done guard in process closure --- pkg/backend/anthropic.go | 3 - pkg/backend/anthropic_stream_test.go | 131 +++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/pkg/backend/anthropic.go b/pkg/backend/anthropic.go index 9d1da4a..1e2f028 100644 --- a/pkg/backend/anthropic.go +++ b/pkg/backend/anthropic.go @@ -193,9 +193,6 @@ func streamAnthropicSSE(body io.Reader, ch chan<- StreamEvent) { done := false process := func(typ, data string) { - if done { - return - } raw := []byte(data) switch typ { case "message_start": diff --git a/pkg/backend/anthropic_stream_test.go b/pkg/backend/anthropic_stream_test.go index c9ed398..c78b40d 100644 --- a/pkg/backend/anthropic_stream_test.go +++ b/pkg/backend/anthropic_stream_test.go @@ -1,6 +1,7 @@ package backend import ( + "encoding/json" "fmt" "strings" "testing" @@ -184,3 +185,133 @@ func TestMapAnthropicStopReason(t *testing.T) { } } } + +// --- buildAnthropicMessages --- + +func TestBuildAnthropicMessages_SystemConcat(t *testing.T) { + sys, _ := buildAnthropicMessages([]Message{ + {Role: "system", Content: "a"}, + {Role: "system", Content: "b"}, + }) + if sys != "a\n\nb" { + t.Errorf("system = %q", sys) + } +} + +func TestBuildAnthropicMessages_AssistantToolCallsOnly(t *testing.T) { + _, out := buildAnthropicMessages([]Message{ + {Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Name: "f", Arguments: json.RawMessage(`{"k":"v"}`)}}}, + }) + if len(out) != 1 { + t.Fatalf("len = %d", len(out)) + } + // No text block, only tool_use + for _, b := range out[0].Content { + if b.Type == "text" { + t.Error("should not have text block when Content is empty") + } + } + if out[0].Content[0].Type != "tool_use" { + t.Errorf("type = %q", out[0].Content[0].Type) + } +} + +func TestBuildAnthropicMessages_NilArguments(t *testing.T) { + _, out := buildAnthropicMessages([]Message{ + {Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Name: "f", Arguments: nil}}}, + }) + block := out[0].Content[0] + if string(block.Input) != "{}" { + t.Errorf("input = %s; want {}", block.Input) + } +} + +func TestBuildAnthropicMessages_ToolBatching(t *testing.T) { + _, out := buildAnthropicMessages([]Message{ + {Role: "tool", Content: "r1", ToolCallID: "c1"}, + {Role: "tool", Content: "r2", ToolCallID: "c2"}, + }) + // Two consecutive tool messages should be batched into one user message. + if len(out) != 1 { + t.Fatalf("len = %d; want 1", len(out)) + } + if out[0].Role != "user" { + t.Errorf("role = %q", out[0].Role) + } + if len(out[0].Content) != 2 { + t.Fatalf("content len = %d; want 2", len(out[0].Content)) + } + if out[0].Content[0].Type != "tool_result" || out[0].Content[0].ToolUseID != "c1" { + t.Errorf("block[0] = %+v", out[0].Content[0]) + } + if out[0].Content[1].ToolUseID != "c2" { + t.Errorf("block[1] = %+v", out[0].Content[1]) + } +} + +func TestBuildAnthropicMessages_UnknownRole(t *testing.T) { + _, out := buildAnthropicMessages([]Message{ + {Role: "unknown", Content: "x"}, + {Role: "user", Content: "hi"}, + }) + // Unknown role should be skipped, user message should still appear. + if len(out) != 1 || out[0].Role != "user" { + t.Errorf("out = %+v", out) + } +} + +func TestBuildAnthropicMessages_FullConversation(t *testing.T) { + sys, out := buildAnthropicMessages([]Message{ + {Role: "system", Content: "sys"}, + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "let me check", ToolCalls: []ToolCall{{ID: "c1", Name: "f", Arguments: json.RawMessage(`{}`)}}}, + {Role: "tool", Content: "result", ToolCallID: "c1"}, + {Role: "assistant", Content: "done"}, + }) + if sys != "sys" { + t.Errorf("system = %q", sys) + } + if len(out) != 4 { + t.Fatalf("len = %d; want 4", len(out)) + } + // assistant[1] should have both text and tool_use blocks + if len(out[1].Content) != 2 { + t.Errorf("assistant content len = %d; want 2", len(out[1].Content)) + } +} + +// --- streamAnthropicSSE: done re-entry and scanner break --- + +func TestAnthropicSSE_EventsAfterStop(t *testing.T) { + // Events after message_stop should be ignored (done flag). + evs := collectAnthropic( + "event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + + "event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ghost\"}}\n\n", + ) + // Should get exactly one Done event, no "ghost" text. + var text string + var doneCount int + for _, ev := range evs { + text += ev.Content + if ev.Done { + doneCount++ + } + } + if text != "" { + t.Errorf("text = %q; want empty", text) + } + if doneCount != 1 { + t.Errorf("done count = %d; want 1", doneCount) + } +} + +func TestAnthropicSSE_EventsAfterError(t *testing.T) { + evs := collectAnthropic( + "event: error\ndata: {\"error\":{\"message\":\"bad\"}}\n\n" + + "event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ghost\"}}\n\n", + ) + if len(evs) != 1 || !evs[0].Done { + t.Fatalf("expected single Done event; got %d", len(evs)) + } +}