mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-11 03:37:40 +08:00
150 lines
5.3 KiB
Go
150 lines
5.3 KiB
Go
package assistantturn
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
|
|
"ds2api/internal/promptcompat"
|
|
"ds2api/internal/sse"
|
|
)
|
|
|
|
func TestBuildTurnFromCollectedTextCitation(t *testing.T) {
|
|
turn := BuildTurnFromCollected(sse.CollectResult{
|
|
Text: "See [citation:1]",
|
|
CitationLinks: map[int]string{1: "https://example.com"},
|
|
}, BuildOptions{Model: "deepseek-v4-flash", Prompt: "prompt", SearchEnabled: true})
|
|
if turn.Text != "See [1](https://example.com)" {
|
|
t.Fatalf("text mismatch: %q", turn.Text)
|
|
}
|
|
if turn.StopReason != StopReasonStop {
|
|
t.Fatalf("stop reason mismatch: %q", turn.StopReason)
|
|
}
|
|
if turn.Error != nil {
|
|
t.Fatalf("unexpected error: %#v", turn.Error)
|
|
}
|
|
}
|
|
|
|
func TestBuildTurnFromCollectedKeepsNonStreamReferenceLinks(t *testing.T) {
|
|
turn := BuildTurnFromCollected(sse.CollectResult{
|
|
Text: "结论[reference:0],补充[reference:1]。",
|
|
CitationLinks: map[int]string{
|
|
1: "https://example.com/a",
|
|
2: "https://example.com/b",
|
|
},
|
|
}, BuildOptions{Model: "deepseek-v4-flash-search", Prompt: "prompt", SearchEnabled: true})
|
|
want := "结论[0](https://example.com/a),补充[1](https://example.com/b)。"
|
|
if turn.Text != want {
|
|
t.Fatalf("text mismatch: got %q want %q", turn.Text, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildTurnFromCollectedToolCall(t *testing.T) {
|
|
turn := BuildTurnFromCollected(sse.CollectResult{
|
|
Text: `<tool_calls><invoke name="Write"><parameter name="content">{"x":1}</parameter></invoke></tool_calls>`,
|
|
}, BuildOptions{
|
|
ToolNames: []string{"Write"},
|
|
ToolsRaw: []any{map[string]any{
|
|
"name": "Write",
|
|
"input_schema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"content": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
}},
|
|
})
|
|
if len(turn.ToolCalls) != 1 {
|
|
t.Fatalf("expected one tool call, got %d", len(turn.ToolCalls))
|
|
}
|
|
if turn.StopReason != StopReasonToolCalls {
|
|
t.Fatalf("stop reason mismatch: %q", turn.StopReason)
|
|
}
|
|
if _, ok := turn.ToolCalls[0].Input["content"].(string); !ok {
|
|
t.Fatalf("expected content coerced to string, got %#v", turn.ToolCalls[0].Input["content"])
|
|
}
|
|
}
|
|
|
|
func TestBuildTurnFromCollectedThinkingOnlyIsEmptyOutput(t *testing.T) {
|
|
turn := BuildTurnFromCollected(sse.CollectResult{Thinking: "hidden"}, BuildOptions{})
|
|
if turn.Error == nil || turn.Error.Code != "upstream_empty_output" {
|
|
t.Fatalf("expected empty output error, got %#v", turn.Error)
|
|
}
|
|
}
|
|
|
|
func TestBuildTurnFromCollectedPureEmptyOutputIsUpstreamUnavailable(t *testing.T) {
|
|
turn := BuildTurnFromCollected(sse.CollectResult{}, BuildOptions{})
|
|
if turn.Error == nil || turn.Error.Status != http.StatusServiceUnavailable || turn.Error.Code != "upstream_unavailable" {
|
|
t.Fatalf("expected upstream unavailable error, got %#v", turn.Error)
|
|
}
|
|
}
|
|
|
|
func TestBuildTurnFromCollectedToolChoiceRequired(t *testing.T) {
|
|
turn := BuildTurnFromCollected(sse.CollectResult{Text: "hello"}, BuildOptions{
|
|
ToolChoice: promptcompat.ToolChoicePolicy{Mode: promptcompat.ToolChoiceRequired},
|
|
})
|
|
if turn.Error == nil || turn.Error.Code != "tool_choice_violation" {
|
|
t.Fatalf("expected tool choice violation, got %#v", turn.Error)
|
|
}
|
|
}
|
|
|
|
func TestBuildTurnFromStreamSnapshotUsesVisibleTextAndRawToolDetection(t *testing.T) {
|
|
turn := BuildTurnFromStreamSnapshot(StreamSnapshot{
|
|
RawText: `<tool_calls><invoke name="Write"><parameter name="content">{"x":1}</parameter></invoke></tool_calls>`,
|
|
VisibleText: "",
|
|
}, BuildOptions{
|
|
ToolNames: []string{"Write"},
|
|
ToolsRaw: []any{map[string]any{
|
|
"name": "Write",
|
|
"schema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"content": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
}},
|
|
})
|
|
if len(turn.ToolCalls) != 1 {
|
|
t.Fatalf("expected stream snapshot tool call, got %d", len(turn.ToolCalls))
|
|
}
|
|
if _, ok := turn.ToolCalls[0].Input["content"].(string); !ok {
|
|
t.Fatalf("expected stream snapshot schema coercion, got %#v", turn.ToolCalls[0].Input["content"])
|
|
}
|
|
}
|
|
|
|
func TestBuildTurnFromStreamSnapshotAlreadyEmittedToolAvoidsEmptyError(t *testing.T) {
|
|
turn := BuildTurnFromStreamSnapshot(StreamSnapshot{AlreadyEmittedCalls: true}, BuildOptions{})
|
|
if turn.Error != nil {
|
|
t.Fatalf("unexpected empty-output error after emitted tool call: %#v", turn.Error)
|
|
}
|
|
if turn.StopReason != StopReasonToolCalls {
|
|
t.Fatalf("stop reason mismatch: %q", turn.StopReason)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeTurnStopOutcome(t *testing.T) {
|
|
turn := BuildTurnFromCollected(sse.CollectResult{Text: "hello"}, BuildOptions{})
|
|
outcome := FinalizeTurn(turn, FinalizeOptions{})
|
|
if outcome.ShouldFail {
|
|
t.Fatalf("unexpected failure: %#v", outcome.Error)
|
|
}
|
|
if outcome.FinishReason != "stop" || !outcome.HasVisibleText || !outcome.HasVisibleOutput {
|
|
t.Fatalf("unexpected outcome: %#v", outcome)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeTurnToolCallsOutcome(t *testing.T) {
|
|
turn := BuildTurnFromStreamSnapshot(StreamSnapshot{AlreadyEmittedCalls: true}, BuildOptions{})
|
|
outcome := FinalizeTurn(turn, FinalizeOptions{AlreadyEmittedToolCalls: true})
|
|
if outcome.ShouldFail || outcome.FinishReason != "tool_calls" || !outcome.HasToolCalls {
|
|
t.Fatalf("unexpected tool outcome: %#v", outcome)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeTurnContentFilterOutcome(t *testing.T) {
|
|
turn := BuildTurnFromCollected(sse.CollectResult{ContentFilter: true}, BuildOptions{})
|
|
outcome := FinalizeTurn(turn, FinalizeOptions{})
|
|
if !outcome.ShouldFail || outcome.Error == nil || outcome.Error.Code != "content_filter" {
|
|
t.Fatalf("expected content filter failure, got %#v", outcome)
|
|
}
|
|
}
|