first commit
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// A recorded OpenAI Chat Completions SSE stream covering a text delta followed
|
||||
// by a two-fragment tool call, ending with finish_reason=tool_calls and a final
|
||||
// usage-only chunk. Trimmed but structurally faithful to the real wire format
|
||||
// (the transport strips the `data:` prefix and the trailing [DONE]).
|
||||
const openaiToolCallSSE = `data: {"id":"chatcmpl-1","model":"gpt-4o","choices":[{"delta":{"role":"assistant"}}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","model":"gpt-4o","choices":[{"delta":{"content":"Let me "}}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","model":"gpt-4o","choices":[{"delta":{"content":"check."}}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":"}}]}}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","model":"gpt-4o","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":" \"SF\"}"}}]}}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"tool_calls"}]}
|
||||
|
||||
data: {"id":"chatcmpl-1","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":11,"completion_tokens":8}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
`
|
||||
|
||||
func TestOpenAIDecoderToolCallStream(t *testing.T) {
|
||||
dec := NewOpenAIDecoder()
|
||||
events, final := feedSSE(t, dec, openaiToolCallSSE)
|
||||
|
||||
// The last emitted event must be the terminal done event.
|
||||
if len(events) == 0 || events[len(events)-1].EventKind() != StreamEventDone {
|
||||
t.Fatalf("expected a done event last, got %v", eventKinds(events))
|
||||
}
|
||||
// Stop reason: tool_calls → tool_use.
|
||||
if final.StopReason != agentcore.StopReasonToolUse {
|
||||
t.Errorf("stop reason = %q, want tool_use", final.StopReason)
|
||||
}
|
||||
// Usage: prompt→input, completion→output.
|
||||
if final.Usage == nil || final.Usage.InputTokens != 11 || final.Usage.OutputTokens != 8 {
|
||||
t.Errorf("usage = %+v, want input=11 output=8", final.Usage)
|
||||
}
|
||||
// Response identity.
|
||||
if final.ResponseID != "chatcmpl-1" || final.ResponseModel != "gpt-4o" {
|
||||
t.Errorf("response id/model = %q/%q", final.ResponseID, final.ResponseModel)
|
||||
}
|
||||
|
||||
// Content blocks: text first, then the tool call.
|
||||
if len(final.Content) != 2 {
|
||||
t.Fatalf("expected 2 content blocks, got %d: %+v", len(final.Content), final.Content)
|
||||
}
|
||||
txt, ok := final.Content[0].(agentcore.TextContent)
|
||||
if !ok || txt.Text != "Let me check." {
|
||||
t.Errorf("text block = %+v", final.Content[0])
|
||||
}
|
||||
tool, ok := final.Content[1].(agentcore.ToolCallContent)
|
||||
if !ok || tool.Name != "get_weather" || tool.ID != "call_1" {
|
||||
t.Fatalf("tool block = %+v", final.Content[1])
|
||||
}
|
||||
// tool_call arguments must have accumulated into valid JSON across fragments.
|
||||
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 TestOpenAIDecoderTextOnlyStop(t *testing.T) {
|
||||
body := `data: {"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":"Hello"}}]}
|
||||
|
||||
data: {"id":"c1","model":"gpt-4o","choices":[{"delta":{"content":" world"}}]}
|
||||
|
||||
data: {"id":"c1","model":"gpt-4o","choices":[{"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
`
|
||||
dec := NewOpenAIDecoder()
|
||||
_, 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 TestOpenAIDecoderLengthMapsToLength(t *testing.T) {
|
||||
body := `data: {"id":"c","model":"m","choices":[{"delta":{"content":"truncated"}}]}
|
||||
|
||||
data: {"id":"c","model":"m","choices":[{"delta":{},"finish_reason":"length"}]}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
`
|
||||
dec := NewOpenAIDecoder()
|
||||
_, final := feedSSE(t, dec, body)
|
||||
if final.StopReason != agentcore.StopReasonLength {
|
||||
t.Errorf("length finish_reason must map to length, got %q", final.StopReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIDecoderInlineError verifies an inline error object becomes a decode
|
||||
// error (which the transport turns into a terminal error event), never a panic.
|
||||
func TestOpenAIDecoderInlineError(t *testing.T) {
|
||||
dec := NewOpenAIDecoder()
|
||||
_, err := dec.Decode([]byte(`{"error":{"type":"rate_limit_exceeded","message":"slow down"}}`))
|
||||
if err == nil {
|
||||
t.Fatal("inline error object must return a decode error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate_limit_exceeded") {
|
||||
t.Errorf("error should name the type, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIDecoderMalformedPayload verifies invalid JSON is a returned error
|
||||
// (rides the stream as terminal error), not a panic.
|
||||
func TestOpenAIDecoderMalformedPayload(t *testing.T) {
|
||||
dec := NewOpenAIDecoder()
|
||||
if _, err := dec.Decode([]byte(`{not json`)); err == nil {
|
||||
t.Fatal("malformed payload must return an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIDecoderFinishFlushesPartial verifies a stream cut short (no
|
||||
// finish_reason) still yields a done event on Finish, defaulting to end_turn.
|
||||
func TestOpenAIDecoderFinishFlushesPartial(t *testing.T) {
|
||||
body := `data: {"id":"c","model":"m","choices":[{"delta":{"content":"partial"}}]}
|
||||
|
||||
`
|
||||
dec := NewOpenAIDecoder()
|
||||
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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIDecoderThroughTransport wires the decoder through the real transport
|
||||
// pump against a recorded SSE server, exercising the full path including the
|
||||
// [DONE] terminator handling.
|
||||
func TestOpenAIDecoderThroughTransport(t *testing.T) {
|
||||
srv := sseServer(t, openaiToolCallSSE)
|
||||
defer srv.Close()
|
||||
|
||||
stream, err := StreamRequest(context.Background(), TransportConfig{
|
||||
NewRequest: newReqFn(srv.URL),
|
||||
Decoder: NewOpenAIDecoder(),
|
||||
})
|
||||
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) != 2 {
|
||||
t.Errorf("expected 2 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user