514 lines
18 KiB
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])
|
|
}
|
|
}
|