package anthropic import ( "encoding/json" "fmt" "testing" "nex/backend/internal/conversion/canonical" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func makeAnthropicEvent(eventType string, data any) []byte { dataBytes, _ := json.Marshal(data) return []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", eventType, string(dataBytes))) } func TestStreamDecoder_MessageStart(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "message_start", "message": map[string]any{ "id": "msg_1", "model": "claude-3", "usage": map[string]any{"input_tokens": 10, "output_tokens": 0}, }, } raw := makeAnthropicEvent("message_start", payload) events := d.ProcessChunk(raw) require.NotEmpty(t, events) assert.Equal(t, canonical.EventMessageStart, events[0].Type) assert.Equal(t, "msg_1", events[0].Message.ID) assert.Equal(t, "claude-3", events[0].Message.Model) } func TestStreamDecoder_ContentBlockDelta(t *testing.T) { d := NewStreamDecoder() tests := []struct { name string deltaType string deltaData map[string]any checkField string checkValue string }{ { name: "text_delta", deltaType: "text_delta", deltaData: map[string]any{"type": "text_delta", "text": "你好"}, checkField: "text", checkValue: "你好", }, { name: "input_json_delta", deltaType: "input_json_delta", deltaData: map[string]any{"type": "input_json_delta", "partial_json": "{\"key\":"}, checkField: "partial_json", checkValue: "{\"key\":", }, { name: "thinking_delta", deltaType: "thinking_delta", deltaData: map[string]any{"type": "thinking_delta", "thinking": "思考中"}, checkField: "thinking", checkValue: "思考中", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { payload := map[string]any{ "type": "content_block_delta", "index": 0, "delta": tt.deltaData, } raw := makeAnthropicEvent("content_block_delta", payload) events := d.ProcessChunk(raw) require.NotEmpty(t, events) assert.Equal(t, canonical.EventContentBlockDelta, events[0].Type) assert.NotNil(t, events[0].Delta) switch tt.checkField { case "text": assert.Equal(t, tt.checkValue, events[0].Delta.Text) case "partial_json": assert.Equal(t, tt.checkValue, events[0].Delta.PartialJSON) case "thinking": assert.Equal(t, tt.checkValue, events[0].Delta.Thinking) } }) } } func TestStreamDecoder_RedactedThinking(t *testing.T) { d := NewStreamDecoder() // redacted_thinking block start 应被抑制 payload := map[string]any{ "type": "content_block_start", "index": 1, "content_block": map[string]any{ "type": "redacted_thinking", "data": "redacted-data", }, } raw := makeAnthropicEvent("content_block_start", payload) events := d.ProcessChunk(raw) assert.Empty(t, events) assert.True(t, d.redactedBlocks[1]) } func TestStreamDecoder_RedactedBlockStopSuppressed(t *testing.T) { d := NewStreamDecoder() d.redactedBlocks[2] = true // content_block_stop 对 redacted block 返回 nil payload := map[string]any{ "type": "content_block_stop", "index": 2, } raw := makeAnthropicEvent("content_block_stop", payload) events := d.ProcessChunk(raw) assert.Empty(t, events) // 应清理 redactedBlocks _, exists := d.redactedBlocks[2] assert.False(t, exists) } func TestStreamDecoder_ContentBlockStart(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "content_block_start", "index": 0, "content_block": map[string]any{ "type": "text", "text": "", }, } raw := makeAnthropicEvent("content_block_start", payload) events := d.ProcessChunk(raw) require.Len(t, events, 1) assert.Equal(t, canonical.EventContentBlockStart, events[0].Type) require.NotNil(t, events[0].ContentBlock) assert.Equal(t, "text", events[0].ContentBlock.Type) require.NotNil(t, events[0].Index) assert.Equal(t, 0, *events[0].Index) } func TestStreamDecoder_ContentBlockStart_ToolUse(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "content_block_start", "index": 1, "content_block": map[string]any{ "type": "tool_use", "id": "toolu_1", "name": "search", }, } raw := makeAnthropicEvent("content_block_start", payload) events := d.ProcessChunk(raw) require.Len(t, events, 1) assert.Equal(t, canonical.EventContentBlockStart, events[0].Type) require.NotNil(t, events[0].ContentBlock) assert.Equal(t, "tool_use", events[0].ContentBlock.Type) assert.Equal(t, "toolu_1", events[0].ContentBlock.ID) assert.Equal(t, "search", events[0].ContentBlock.Name) } func TestStreamDecoder_ContentBlockStop(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "content_block_stop", "index": 0, } raw := makeAnthropicEvent("content_block_stop", payload) events := d.ProcessChunk(raw) require.Len(t, events, 1) assert.Equal(t, canonical.EventContentBlockStop, events[0].Type) require.NotNil(t, events[0].Index) assert.Equal(t, 0, *events[0].Index) } func TestStreamDecoder_MessageDelta(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "message_delta", "delta": map[string]any{ "stop_reason": "end_turn", }, "usage": map[string]any{ "output_tokens": 42, }, } raw := makeAnthropicEvent("message_delta", payload) events := d.ProcessChunk(raw) require.Len(t, events, 1) assert.Equal(t, canonical.EventMessageDelta, events[0].Type) require.NotNil(t, events[0].StopReason) assert.Equal(t, canonical.StopReasonEndTurn, *events[0].StopReason) require.NotNil(t, events[0].Usage) assert.Equal(t, 42, events[0].Usage.OutputTokens) } func TestStreamDecoder_MessageStop(t *testing.T) { d := NewStreamDecoder() raw := makeAnthropicEvent("message_stop", map[string]any{"type": "message_stop"}) events := d.ProcessChunk(raw) require.Len(t, events, 1) assert.Equal(t, canonical.EventMessageStop, events[0].Type) } func TestStreamDecoder_Ping(t *testing.T) { d := NewStreamDecoder() raw := makeAnthropicEvent("ping", map[string]any{"type": "ping"}) events := d.ProcessChunk(raw) require.Len(t, events, 1) assert.Equal(t, canonical.EventPing, events[0].Type) } func TestStreamDecoder_Error(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "error", "error": map[string]any{ "type": "overloaded_error", "message": "服务过载", }, } raw := makeAnthropicEvent("error", payload) events := d.ProcessChunk(raw) require.Len(t, events, 1) assert.Equal(t, canonical.EventError, events[0].Type) require.NotNil(t, events[0].Error) assert.Equal(t, "overloaded_error", events[0].Error.Type) assert.Equal(t, "服务过载", events[0].Error.Message) } func TestStreamDecoder_RedactedDeltaSuppressed(t *testing.T) { d := NewStreamDecoder() d.redactedBlocks[1] = true payload := map[string]any{ "type": "content_block_delta", "index": 1, "delta": map[string]any{ "type": "text_delta", "text": "被抑制的内容", }, } raw := makeAnthropicEvent("content_block_delta", payload) events := d.ProcessChunk(raw) assert.Empty(t, events) } func TestStreamDecoder_ServerToolUse_Suppressed(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "content_block_start", "index": 2, "content_block": map[string]any{ "type": "server_tool_use", "id": "server_tool_1", "name": "web_search", }, } raw := makeAnthropicEvent("content_block_start", payload) events := d.ProcessChunk(raw) assert.Empty(t, events) assert.True(t, d.redactedBlocks[2]) } func TestStreamDecoder_WebSearchToolResult_Suppressed(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "content_block_start", "index": 3, "content_block": map[string]any{ "type": "web_search_tool_result", "tool_use_id": "search_1", }, } raw := makeAnthropicEvent("content_block_start", payload) events := d.ProcessChunk(raw) assert.Empty(t, events) assert.True(t, d.redactedBlocks[3]) } func TestStreamDecoder_CodeExecutionToolResult_Suppressed(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "content_block_start", "index": 4, "content_block": map[string]any{ "type": "code_execution_tool_result", }, } raw := makeAnthropicEvent("content_block_start", payload) events := d.ProcessChunk(raw) assert.Empty(t, events) assert.True(t, d.redactedBlocks[4]) } func TestStreamDecoder_CitationsDelta_Discarded(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "content_block_delta", "index": 0, "delta": map[string]any{ "type": "citations_delta", "citation": map[string]any{"title": "ref1"}, }, } raw := makeAnthropicEvent("content_block_delta", payload) events := d.ProcessChunk(raw) assert.Empty(t, events) } func TestStreamDecoder_SignatureDelta_Discarded(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "content_block_delta", "index": 0, "delta": map[string]any{ "type": "signature_delta", "signature": "sig_123", }, } raw := makeAnthropicEvent("content_block_delta", payload) events := d.ProcessChunk(raw) assert.Empty(t, events) } func TestStreamDecoder_UnknownEventType(t *testing.T) { d := NewStreamDecoder() raw := makeAnthropicEvent("unknown_event", map[string]any{"type": "unknown_event"}) events := d.ProcessChunk(raw) assert.Empty(t, events) } func TestStreamDecoder_InvalidJSON(t *testing.T) { d := NewStreamDecoder() raw := []byte("event: message_start\ndata: {invalid}\n\n") events := d.ProcessChunk(raw) assert.Empty(t, events) } func TestStreamDecoder_MultipleEventsInSingleChunk(t *testing.T) { d := NewStreamDecoder() startPayload := map[string]any{ "type": "message_start", "message": map[string]any{ "id": "msg_multi", "model": "claude-3", }, } deltaPayload := map[string]any{ "type": "content_block_delta", "index": 0, "delta": map[string]any{ "type": "text_delta", "text": "Hello", }, } stopPayload := map[string]any{"type": "message_stop"} var raw []byte raw = append(raw, makeAnthropicEvent("message_start", startPayload)...) raw = append(raw, makeAnthropicEvent("content_block_delta", deltaPayload)...) raw = append(raw, makeAnthropicEvent("message_stop", stopPayload)...) events := d.ProcessChunk(raw) require.Len(t, events, 3) assert.Equal(t, canonical.EventMessageStart, events[0].Type) assert.Equal(t, canonical.EventContentBlockDelta, events[1].Type) assert.Equal(t, canonical.EventMessageStop, events[2].Type) } func TestStreamDecoder_ErrorInvalidJSON(t *testing.T) { d := NewStreamDecoder() raw := []byte("event: error\ndata: {invalid}\n\n") events := d.ProcessChunk(raw) require.Len(t, events, 1) assert.Equal(t, canonical.EventError, events[0].Type) assert.Contains(t, events[0].Error.Message, "解析错误事件失败") } func TestStreamDecoder_MessageStartWithUsage(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "message_start", "message": map[string]any{ "id": "msg_usage", "model": "claude-3", "usage": map[string]any{"input_tokens": 25, "output_tokens": 0}, }, } raw := makeAnthropicEvent("message_start", payload) events := d.ProcessChunk(raw) require.Len(t, events, 1) assert.Equal(t, canonical.EventMessageStart, events[0].Type) require.NotNil(t, events[0].Message.Usage) assert.Equal(t, 25, events[0].Message.Usage.InputTokens) } func TestStreamDecoder_ThinkingBlockStart(t *testing.T) { d := NewStreamDecoder() payload := map[string]any{ "type": "content_block_start", "index": 0, "content_block": map[string]any{ "type": "thinking", "thinking": "", }, } raw := makeAnthropicEvent("content_block_start", payload) events := d.ProcessChunk(raw) require.Len(t, events, 1) assert.Equal(t, canonical.EventContentBlockStart, events[0].Type) require.NotNil(t, events[0].ContentBlock) assert.Equal(t, "thinking", events[0].ContentBlock.Type) } func TestStreamDecoder_MessageDelta_UsageNotAccumulated(t *testing.T) { d := NewStreamDecoder() startPayload := map[string]any{ "type": "message_start", "message": map[string]any{ "id": "msg_usage_test", "model": "claude-3", "usage": map[string]any{"input_tokens": 10, "output_tokens": 0}, }, } deltaPayload1 := map[string]any{ "type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn"}, "usage": map[string]any{"output_tokens": 25}, } d.ProcessChunk(makeAnthropicEvent("message_start", startPayload)) events := d.ProcessChunk(makeAnthropicEvent("message_delta", deltaPayload1)) require.Len(t, events, 1) assert.Equal(t, 25, events[0].Usage.OutputTokens) deltaPayload2 := map[string]any{ "type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn"}, "usage": map[string]any{"output_tokens": 30}, } events = d.ProcessChunk(makeAnthropicEvent("message_delta", deltaPayload2)) require.Len(t, events, 1) assert.Equal(t, 30, events[0].Usage.OutputTokens, "output_tokens should be replaced, not accumulated") assert.Equal(t, 30, d.accumulatedUsage.OutputTokens, "accumulated usage should match last value") }