This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-core/backend/anthropic_stream_test.go

514 lines
18 KiB
Go

package backend
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
func collectAnthropic(sse string) []StreamEvent {
ch := make(chan StreamEvent, 32)
go func() {
defer close(ch)
streamAnthropicSSE(strings.NewReader(sse), ch)
}()
var evs []StreamEvent
for ev := range ch {
evs = append(evs, ev)
}
return evs
}
func TestAnthropicSSE_TextStream(t *testing.T) {
evs := collectAnthropic(
"event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":10}}}\n\n" +
"event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n" +
"event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" world\"}}\n\n" +
"event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}\n\n" +
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
)
var text string
for _, ev := range evs {
text += ev.Content
}
if text != "hello world" {
t.Errorf("text = %q", text)
}
fin := evs[len(evs)-1]
if !fin.Done || fin.StopReason != "stop" {
t.Errorf("final = %+v", fin)
}
if fin.Usage.InputTokens != 10 || fin.Usage.OutputTokens != 5 {
t.Errorf("usage = %+v", fin.Usage)
}
}
func TestAnthropicSSE_ToolCall(t *testing.T) {
evs := collectAnthropic(
"event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":5}}}\n\n" +
"event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"c1\",\"name\":\"f\"}}\n\n" +
"event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"a\\\":\"}}\n\n" +
"event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"1}\"}}\n\n" +
"event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":3}}\n\n" +
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
)
fin := evs[len(evs)-1]
if len(fin.ToolCalls) != 1 {
t.Fatalf("tool_calls len = %d", len(fin.ToolCalls))
}
tc := fin.ToolCalls[0]
if tc.ID != "c1" || tc.Name != "f" || string(tc.Arguments) != `{"a":1}` {
t.Errorf("tc = %+v", tc)
}
if fin.StopReason != "tool_calls" {
t.Errorf("stop = %q", fin.StopReason)
}
}
func TestAnthropicSSE_ParallelToolCalls(t *testing.T) {
evs := collectAnthropic(
"event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":1}}}\n\n" +
"event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"a\",\"name\":\"x\"}}\n\n" +
"event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{}\"}}\n\n" +
"event: content_block_start\ndata: {\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"b\",\"name\":\"y\"}}\n\n" +
"event: content_block_delta\ndata: {\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{}\"}}\n\n" +
"event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"output_tokens\":1}}\n\n" +
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
)
fin := evs[len(evs)-1]
if len(fin.ToolCalls) != 2 {
t.Fatalf("len = %d", len(fin.ToolCalls))
}
ids := map[string]bool{}
for _, tc := range fin.ToolCalls {
ids[tc.ID] = true
}
if !ids["a"] || !ids["b"] {
t.Errorf("ids = %v", ids)
}
}
func TestAnthropicSSE_ErrorEvent(t *testing.T) {
evs := collectAnthropic(
"event: error\ndata: {\"error\":{\"message\":\"overloaded\"}}\n\n",
)
if len(evs) != 1 || !evs[0].Done {
t.Fatal("expected Done")
}
if !strings.Contains(evs[0].StopReason, "overloaded") {
t.Errorf("stop = %q", evs[0].StopReason)
}
}
func TestAnthropicSSE_TextBlockStartIgnored(t *testing.T) {
// content_block_start with type "text" should not create a tool accumulator.
evs := collectAnthropic(
"event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":1}}}\n\n" +
"event: content_block_start\ndata: {\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" +
"event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n" +
"event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n" +
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
)
fin := evs[len(evs)-1]
if len(fin.ToolCalls) != 0 {
t.Errorf("tool_calls = %+v; want empty", fin.ToolCalls)
}
}
func TestAnthropicSSE_InputJSONDeltaUnknownIndex(t *testing.T) {
// input_json_delta for an index with no content_block_start should be silently ignored.
evs := collectAnthropic(
"event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":1}}}\n\n" +
"event: content_block_delta\ndata: {\"index\":99,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"junk\"}}\n\n" +
"event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n" +
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
)
fin := evs[len(evs)-1]
if len(fin.ToolCalls) != 0 {
t.Errorf("tool_calls = %+v; want empty", fin.ToolCalls)
}
}
func TestAnthropicSSE_TrailingEventNoBlankLine(t *testing.T) {
// Event not terminated by a blank line should still be processed.
evs := collectAnthropic(
"event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n" +
"event: message_stop\ndata: {\"type\":\"message_stop\"}",
)
if len(evs) == 0 || !evs[len(evs)-1].Done {
t.Error("trailing event not processed")
}
}
func TestAnthropicSSE_EmptyStream(t *testing.T) {
evs := collectAnthropic("")
if len(evs) != 0 {
t.Errorf("expected no events; got %d", len(evs))
}
}
func TestAnthropicSSE_ScannerError(t *testing.T) {
r := newErrAfterReader(
"event: content_block_delta\ndata: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n\n",
fmt.Errorf("connection reset"),
)
ch := make(chan StreamEvent, 32)
go func() {
defer close(ch)
streamAnthropicSSE(r, ch)
}()
var evs []StreamEvent
for ev := range ch {
evs = append(evs, ev)
}
fin := evs[len(evs)-1]
if !fin.Done || !strings.Contains(fin.StopReason, "stream read") {
t.Errorf("final = %+v", fin)
}
}
// --- mapAnthropicStopReason ---
func TestMapAnthropicStopReason(t *testing.T) {
tests := []struct{ in, want string }{
{"end_turn", "stop"},
{"stop_sequence", "stop"},
{"max_tokens", "length"},
{"tool_use", "tool_calls"},
{"unknown", "unknown"},
{"", ""},
}
for _, tt := range tests {
if got := mapAnthropicStopReason(tt.in); got != tt.want {
t.Errorf("mapAnthropicStopReason(%q) = %q; want %q", tt.in, got, tt.want)
}
}
}
// --- buildAnthropicMessages ---
func TestBuildAnthropicMessages_SystemConcat(t *testing.T) {
sys, _ := buildAnthropicMessages([]Message{
{Role: "system", Content: "a"},
{Role: "system", Content: "b"},
})
if len(sys) != 1 || sys[0].Text != "a\n\nb" {
t.Errorf("system = %+v", sys)
}
if sys[0].CacheControl == nil || sys[0].CacheControl.Type != "ephemeral" {
t.Errorf("system block missing cache_control: %+v", sys[0])
}
}
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 len(sys) != 1 || sys[0].Text != "sys" {
t.Errorf("system = %+v", 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))
}
}
// --- tool schema caching ---
// TestToolSchemaCaching verifies that when multiple tools are provided, the
// last tool gets cache_control:ephemeral and all others do not.
func TestToolSchemaCaching(t *testing.T) {
tools := []Tool{
{Name: "alpha", Description: "a", Parameters: json.RawMessage(`{"type":"object"}`)},
{Name: "beta", Description: "b", Parameters: json.RawMessage(`{"type":"object"}`)},
{Name: "gamma", Description: "c", Parameters: json.RawMessage(`{"type":"object"}`)},
}
areq := anthropicRequest{}
for _, tool := range tools {
schema := tool.Parameters
if schema == nil {
schema = json.RawMessage(`{"type":"object","properties":{}}`)
}
areq.Tools = append(areq.Tools, anthropicTool{
Name: tool.Name,
Description: tool.Description,
InputSchema: schema,
})
}
if len(areq.Tools) > 0 {
areq.Tools[len(areq.Tools)-1].CacheControl = &anthropicCacheCtrl{Type: "ephemeral"}
}
for i, tool := range areq.Tools {
if i < len(areq.Tools)-1 {
if tool.CacheControl != nil {
t.Errorf("tool[%d] (%s) has cache_control; want none", i, tool.Name)
}
} else {
if tool.CacheControl == nil || tool.CacheControl.Type != "ephemeral" {
t.Errorf("last tool (%s) missing cache_control:ephemeral; got %+v", tool.Name, tool.CacheControl)
}
}
}
}
// TestToolSchemaCaching_SingleTool verifies the single-tool edge case: the
// one tool gets cache_control:ephemeral.
func TestToolSchemaCaching_SingleTool(t *testing.T) {
tools := []Tool{
{Name: "only", Parameters: json.RawMessage(`{"type":"object"}`)},
}
areq := anthropicRequest{}
for _, tool := range tools {
areq.Tools = append(areq.Tools, anthropicTool{Name: tool.Name, InputSchema: tool.Parameters})
}
if len(areq.Tools) > 0 {
areq.Tools[len(areq.Tools)-1].CacheControl = &anthropicCacheCtrl{Type: "ephemeral"}
}
if areq.Tools[0].CacheControl == nil || areq.Tools[0].CacheControl.Type != "ephemeral" {
t.Errorf("single tool missing cache_control: %+v", areq.Tools[0])
}
}
// --- large tool result caching ---
// TestToolResultCaching_LargeGetsCache verifies that a tool result ≥4096 bytes
// is marked with cache_control:ephemeral.
func TestToolResultCaching_LargeGetsCache(t *testing.T) {
large := strings.Repeat("x", 4096)
_, out := buildAnthropicMessages([]Message{
{Role: "user", Content: "hi"},
{Role: "assistant", Content: "ok", ToolCalls: []ToolCall{{ID: "c1", Name: "f", Arguments: json.RawMessage(`{}`)}}},
{Role: "tool", Content: large, ToolCallID: "c1"},
})
// Last user message contains the tool result.
last := out[len(out)-1]
if last.Role != "user" || len(last.Content) != 1 {
t.Fatalf("unexpected last message: %+v", last)
}
b := last.Content[0]
if b.Type != "tool_result" {
t.Fatalf("expected tool_result, got %q", b.Type)
}
if b.CacheControl == nil || b.CacheControl.Type != "ephemeral" {
t.Errorf("large tool result missing cache_control:ephemeral; got %+v", b.CacheControl)
}
}
// TestToolResultCaching_SmallNoCache verifies that a tool result below the
// 4096-byte threshold does not get cache_control.
func TestToolResultCaching_SmallNoCache(t *testing.T) {
_, out := buildAnthropicMessages([]Message{
{Role: "user", Content: "hi"},
{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Name: "f", Arguments: json.RawMessage(`{}`)}}},
{Role: "tool", Content: "small result", ToolCallID: "c1"},
})
last := out[len(out)-1]
for _, b := range last.Content {
if b.Type == "tool_result" && b.CacheControl != nil {
t.Errorf("small tool result has cache_control: %+v", b.CacheControl)
}
}
}
// TestToolResultCaching_LastLargeResult verifies that when multiple large tool
// results exist, only the LAST one gets cache_control (not the first).
func TestToolResultCaching_LastLargeResult(t *testing.T) {
large := strings.Repeat("y", 4096)
_, out := buildAnthropicMessages([]Message{
{Role: "user", Content: "hi"},
{Role: "assistant", ToolCalls: []ToolCall{
{ID: "c1", Name: "f", Arguments: json.RawMessage(`{}`)},
{ID: "c2", Name: "g", Arguments: json.RawMessage(`{}`)},
}},
{Role: "tool", Content: large, ToolCallID: "c1"},
{Role: "tool", Content: large, ToolCallID: "c2"},
})
last := out[len(out)-1]
if last.Role != "user" || len(last.Content) != 2 {
t.Fatalf("unexpected last message: role=%q len=%d", last.Role, len(last.Content))
}
if last.Content[0].CacheControl != nil {
t.Errorf("first large result should not have cache_control")
}
if last.Content[1].CacheControl == nil || last.Content[1].CacheControl.Type != "ephemeral" {
t.Errorf("last large result missing cache_control:ephemeral; got %+v", last.Content[1].CacheControl)
}
}
// --- image ContentBlocks ---
func TestBuildAnthropicMessages_UserImageBlocks(t *testing.T) {
_, out := buildAnthropicMessages([]Message{{
Role: "user",
ContentBlocks: []ContentBlock{
{Type: "text", Text: "what is this?"},
{Type: "image", ImageSource: &ImageSource{Type: "base64", MediaType: "image/png", Data: "iVBOR"}},
},
}})
if len(out) != 1 {
t.Fatalf("len = %d; want 1", len(out))
}
if len(out[0].Content) != 2 {
t.Fatalf("content blocks = %d; want 2", len(out[0].Content))
}
if out[0].Content[0].Type != "text" || out[0].Content[0].Text != "what is this?" {
t.Errorf("block[0] = %+v", out[0].Content[0])
}
if out[0].Content[1].Type != "image" || out[0].Content[1].Source == nil {
t.Fatalf("block[1] = %+v", out[0].Content[1])
}
if out[0].Content[1].Source.Type != "base64" || out[0].Content[1].Source.MediaType != "image/png" || out[0].Content[1].Source.Data != "iVBOR" {
t.Errorf("source = %+v", out[0].Content[1].Source)
}
}
func TestBuildAnthropicMessages_ToolImageBlocks(t *testing.T) {
_, out := buildAnthropicMessages([]Message{
{Role: "user", Content: "read image"},
{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Name: "file_read", Arguments: json.RawMessage(`{}`)}}},
{Role: "tool", ToolCallID: "c1", Content: "[image]", ContentBlocks: []ContentBlock{
{Type: "image", ImageSource: &ImageSource{Type: "base64", MediaType: "image/jpeg", Data: "AAAA"}},
{Type: "text", Text: "[image: image/jpeg]"},
}},
})
// Last message is the batched tool result (user role).
last := out[len(out)-1]
if last.Role != "user" {
t.Fatalf("role = %q; want user", last.Role)
}
// Should have image block + tool_result block.
var hasImage, hasToolResult bool
for _, b := range last.Content {
if b.Type == "image" && b.Source != nil && b.Source.MediaType == "image/jpeg" {
hasImage = true
}
if b.Type == "tool_result" && b.ToolUseID == "c1" {
hasToolResult = true
}
}
if !hasImage {
t.Error("missing image block in tool result")
}
if !hasToolResult {
t.Error("missing tool_result block")
}
}
func TestBuildAnthropicMessages_UserPlainTextFallback(t *testing.T) {
// When ContentBlocks is nil, should fall back to Content string.
_, out := buildAnthropicMessages([]Message{{Role: "user", Content: "hello"}})
if len(out) != 1 || len(out[0].Content) != 1 {
t.Fatalf("unexpected: %+v", out)
}
if out[0].Content[0].Type != "text" || out[0].Content[0].Text != "hello" {
t.Errorf("block = %+v", out[0].Content[0])
}
}