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
This commit is contained in:
Levi Neely 2026-04-21 15:45:46 +02:00
parent 38dd34ea20
commit 5cfa5a0df9
2 changed files with 131 additions and 3 deletions

View File

@ -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":

View File

@ -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))
}
}