first commit
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// A recorded Anthropic Messages API SSE stream covering a text block, a
|
||||
// thinking block (with signature), and a tool_use block, ending with a
|
||||
// tool_use stop reason and output-token usage. Trimmed but structurally
|
||||
// faithful to the real wire format.
|
||||
const anthropicToolUseSSE = `event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_01ABC","model":"claude-opus-4-8","usage":{"input_tokens":42,"output_tokens":1}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Let me think. "}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Use the tool."}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sigABC"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"I'll check "}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"the weather."}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_01","name":"get_weather"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"city\":"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" \"SF\"}"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":2}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":57}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
`
|
||||
|
||||
// feedSSE splits a recorded SSE body into events (blank-line separated),
|
||||
// extracts each `data:` payload, and drives the decoder exactly as the
|
||||
// transport pump would, returning all emitted events plus the final message.
|
||||
func feedSSE(t *testing.T, dec Decoder, body string) ([]StreamEvent, agentcore.AssistantMessage) {
|
||||
t.Helper()
|
||||
var events []StreamEvent
|
||||
for _, block := range strings.Split(body, "\n\n") {
|
||||
var payload strings.Builder
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
payload.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
}
|
||||
}
|
||||
if payload.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
// [DONE] is a transport-level terminator, not a decoder payload.
|
||||
if payload.String() == "[DONE]" {
|
||||
continue
|
||||
}
|
||||
evs, err := dec.Decode([]byte(payload.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("decode %q: %v", payload.String(), err)
|
||||
}
|
||||
events = append(events, evs...)
|
||||
}
|
||||
finalEvents, err := dec.Finish()
|
||||
if err != nil {
|
||||
t.Fatalf("finish: %v", err)
|
||||
}
|
||||
events = append(events, finalEvents...)
|
||||
|
||||
var final agentcore.AssistantMessage
|
||||
for _, ev := range events {
|
||||
if d, ok := ev.(StreamDoneEvent); ok {
|
||||
final = d.Message
|
||||
}
|
||||
}
|
||||
return events, final
|
||||
}
|
||||
|
||||
func TestAnthropicDecoderToolUseStream(t *testing.T) {
|
||||
dec := NewAnthropicDecoder()
|
||||
events, final := feedSSE(t, dec, anthropicToolUseSSE)
|
||||
|
||||
// The first emitted event must be a start event.
|
||||
if len(events) == 0 || events[0].EventKind() != StreamEventStart {
|
||||
t.Fatalf("expected a start event first, got %v", eventKinds(events))
|
||||
}
|
||||
// The last emitted event must be the terminal done event.
|
||||
if events[len(events)-1].EventKind() != StreamEventDone {
|
||||
t.Fatalf("expected a done event last, got %v", eventKinds(events))
|
||||
}
|
||||
|
||||
// Stop reason: tool_use.
|
||||
if final.StopReason != agentcore.StopReasonToolUse {
|
||||
t.Errorf("stop reason = %q, want tool_use", final.StopReason)
|
||||
}
|
||||
// Usage: input from message_start, output from message_delta.
|
||||
if final.Usage == nil || final.Usage.InputTokens != 42 || final.Usage.OutputTokens != 57 {
|
||||
t.Errorf("usage = %+v, want input=42 output=57", final.Usage)
|
||||
}
|
||||
// Response identity from message_start.
|
||||
if final.ResponseID != "msg_01ABC" || final.ResponseModel != "claude-opus-4-8" {
|
||||
t.Errorf("response id/model = %q/%q", final.ResponseID, final.ResponseModel)
|
||||
}
|
||||
|
||||
// Content blocks in index order: thinking, text, tool_use.
|
||||
if len(final.Content) != 3 {
|
||||
t.Fatalf("expected 3 content blocks, got %d: %+v", len(final.Content), final.Content)
|
||||
}
|
||||
th, ok := final.Content[0].(agentcore.ThinkingContent)
|
||||
if !ok || th.Thinking != "Let me think. Use the tool." {
|
||||
t.Errorf("thinking block = %+v", final.Content[0])
|
||||
}
|
||||
if th.ThinkingSignature != "sigABC" {
|
||||
t.Errorf("thinking signature = %q, want sigABC", th.ThinkingSignature)
|
||||
}
|
||||
txt, ok := final.Content[1].(agentcore.TextContent)
|
||||
if !ok || txt.Text != "I'll check the weather." {
|
||||
t.Errorf("text block = %+v", final.Content[1])
|
||||
}
|
||||
tool, ok := final.Content[2].(agentcore.ToolCallContent)
|
||||
if !ok || tool.Name != "get_weather" || tool.ID != "toolu_01" {
|
||||
t.Fatalf("tool block = %+v", final.Content[2])
|
||||
}
|
||||
// tool_use JSON must have accumulated into valid arguments.
|
||||
var args map[string]string
|
||||
if err := json.Unmarshal(tool.Arguments, &args); err != nil {
|
||||
t.Fatalf("tool arguments not valid JSON %q: %v", tool.Arguments, err)
|
||||
}
|
||||
if args["city"] != "SF" {
|
||||
t.Errorf("tool arguments = %v, want city=SF", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicDecoderTextOnlyEndTurn(t *testing.T) {
|
||||
body := `data: {"type":"message_start","message":{"id":"msg_1","model":"claude-x","usage":{"input_tokens":10,"output_tokens":0}}}
|
||||
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
|
||||
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}
|
||||
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":3}}
|
||||
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
`
|
||||
dec := NewAnthropicDecoder()
|
||||
_, final := feedSSE(t, dec, body)
|
||||
if final.StopReason != agentcore.StopReasonEndTurn {
|
||||
t.Errorf("stop reason = %q, want end_turn", final.StopReason)
|
||||
}
|
||||
if len(final.Content) != 1 {
|
||||
t.Fatalf("expected 1 content block, got %d", len(final.Content))
|
||||
}
|
||||
txt, ok := final.Content[0].(agentcore.TextContent)
|
||||
if !ok || txt.Text != "Hello world" {
|
||||
t.Errorf("text = %+v", final.Content[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicDecoderMaxTokensMapsToLength(t *testing.T) {
|
||||
body := `data: {"type":"message_start","message":{"id":"m","model":"c","usage":{"input_tokens":5,"output_tokens":0}}}
|
||||
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"truncated"}}
|
||||
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":100}}
|
||||
|
||||
data: {"type":"message_stop"}
|
||||
|
||||
`
|
||||
dec := NewAnthropicDecoder()
|
||||
_, final := feedSSE(t, dec, body)
|
||||
if final.StopReason != agentcore.StopReasonLength {
|
||||
t.Errorf("max_tokens must map to length, got %q", final.StopReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnthropicDecoderErrorEvent verifies an Anthropic `error` event becomes a
|
||||
// decode error (which the transport turns into a terminal error event), never
|
||||
// a panic.
|
||||
func TestAnthropicDecoderErrorEvent(t *testing.T) {
|
||||
dec := NewAnthropicDecoder()
|
||||
_, err := dec.Decode([]byte(`{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`))
|
||||
if err == nil {
|
||||
t.Fatal("error event must return a decode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "overloaded_error") {
|
||||
t.Errorf("error should name the type, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnthropicDecoderMalformedPayload verifies invalid JSON is a returned
|
||||
// error (rides the stream as terminal error), not a panic.
|
||||
func TestAnthropicDecoderMalformedPayload(t *testing.T) {
|
||||
dec := NewAnthropicDecoder()
|
||||
if _, err := dec.Decode([]byte(`{not json`)); err == nil {
|
||||
t.Fatal("malformed payload must return an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnthropicDecoderFinishFlushesPartial verifies a stream cut short (no
|
||||
// message_stop) still yields a done event on Finish so the partial isn't lost.
|
||||
func TestAnthropicDecoderFinishFlushesPartial(t *testing.T) {
|
||||
body := `data: {"type":"message_start","message":{"id":"m","model":"c","usage":{"input_tokens":5,"output_tokens":0}}}
|
||||
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}
|
||||
|
||||
`
|
||||
dec := NewAnthropicDecoder()
|
||||
events, final := feedSSE(t, dec, body)
|
||||
if events[len(events)-1].EventKind() != StreamEventDone {
|
||||
t.Fatalf("Finish must emit a terminal done event, got %v", eventKinds(events))
|
||||
}
|
||||
// No message_delta arrived → default end_turn.
|
||||
if final.StopReason != agentcore.StopReasonEndTurn {
|
||||
t.Errorf("cut-short stream should default to end_turn, got %q", final.StopReason)
|
||||
}
|
||||
if len(final.Content) != 1 {
|
||||
t.Fatalf("expected the partial text block, got %+v", final.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnthropicDecoderThroughTransport wires the decoder through the real
|
||||
// transport pump against a recorded SSE server, exercising the full path.
|
||||
func TestAnthropicDecoderThroughTransport(t *testing.T) {
|
||||
srv := sseServer(t, anthropicToolUseSSE)
|
||||
defer srv.Close()
|
||||
|
||||
stream, err := StreamRequest(context.Background(), TransportConfig{
|
||||
NewRequest: newReqFn(srv.URL),
|
||||
Decoder: NewAnthropicDecoder(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamRequest: %v", err)
|
||||
}
|
||||
var kinds []string
|
||||
for ev := range stream.Events() {
|
||||
kinds = append(kinds, ev.EventKind())
|
||||
}
|
||||
final, resErr := stream.Result(context.Background())
|
||||
if resErr != nil {
|
||||
t.Fatalf("result: %v", resErr)
|
||||
}
|
||||
if final.StopReason != agentcore.StopReasonToolUse {
|
||||
t.Errorf("stop reason via transport = %q, want tool_use", final.StopReason)
|
||||
}
|
||||
if len(final.Content) != 3 {
|
||||
t.Errorf("expected 3 content blocks via transport, got %d", len(final.Content))
|
||||
}
|
||||
if kinds[len(kinds)-1] != StreamEventDone {
|
||||
t.Errorf("stream must end with done, got %v", kinds)
|
||||
}
|
||||
}
|
||||
|
||||
func eventKinds(events []StreamEvent) []string {
|
||||
out := make([]string, len(events))
|
||||
for i, ev := range events {
|
||||
out[i] = ev.EventKind()
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user