package scheduler import ( "context" "encoding/json" "strings" "testing" "time" "llmsproxy/internal/types" ) // TestResultIsEmptyMatchesTheReasoningOnlyResponse guards the production bug // behind "model AUTO returned a completed response with no content". // // Production measurement: 2 of 20 AUTO requests returned zero content, both // served by claude-opus-4-8. Direct upstream capture showed 28 chunks of // `reasoning_content` and NO `content`, finish_reason=length — the token budget // was consumed by thinking before any text was emitted. runTier accepted it // because err == nil. func TestResultIsEmptyMatchesTheReasoningOnlyResponse(t *testing.T) { // Exactly what the upstream returned, as a decoded response. onlyReasoning := &types.UnifiedResponse{ Model: "claude-opus-4-8", ReasoningContent: "Alright, the user just said \"hi\". Simple greeting…", FinishReason: "length", TokenUsage: types.TokenUsage{Prompt: 14, Completion: 30, Total: 44}, } if !resultIsEmpty(onlyReasoning) { t.Error("a reasoning-only response with usage must count as empty; " + "it is what the client sees as \"completed with no content\"") } // The same response WITH text is a normal answer. if resultIsEmpty(&types.UnifiedResponse{Content: "Hi! 👋", ReasoningContent: "hmm"}) { t.Error("a response with content is not empty") } // A tool-calling agent turn has no text and is perfectly valid. if resultIsEmpty(&types.UnifiedResponse{ ToolCalls: []types.ToolCall{{Name: "read_file", Arguments: map[string]interface{}{"path": "x"}}}, }) { t.Error("a tool-call-only response must NOT count as empty — agents " + "legitimately produce tool calls with no text") } // An image slot returns no text by design. if resultIsEmpty(&types.UnifiedResponse{ ImageData: []types.ImageData{{URL: "http://x/y.png"}}, }) { t.Error("an image response must NOT count as empty") } // Whitespace-only content is as useless to a client as none at all. if !resultIsEmpty(&types.UnifiedResponse{Content: " \n\t"}) { t.Error("whitespace-only content must count as empty") } if resultIsEmpty(nil) { t.Error("nil is not an empty response; it is an absent one") } } // TestPeekStreamHoldsReasoningAndReportsEmpty is the streaming half: a channel // of reasoning-only chunks must be reported as empty so chainDrive degrades. func TestPeekStreamHoldsReasoningAndReportsEmpty(t *testing.T) { in := make(chan types.UnifiedChunk, 8) for i := 0; i < 5; i++ { in <- types.UnifiedChunk{ReasoningContent: "thinking…"} } in <- types.UnifiedChunk{Done: true, FinishReason: "length"} close(in) _, peek := peekStream(in) if peek() { t.Error("a reasoning-only stream must be reported empty") } } // TestPeekStreamForwardsContentAfterReasoning: the common case still works, // and the reasoning preamble is not forwarded ahead of the content (holding it // back is what keeps the degrade path available). func TestPeekStreamForwardsContentAfterReasoning(t *testing.T) { in := make(chan types.UnifiedChunk, 8) in <- types.UnifiedChunk{ReasoningContent: "let me think"} in <- types.UnifiedChunk{Content: "Hi"} in <- types.UnifiedChunk{Content: "!"} in <- types.UnifiedChunk{Done: true, FinishReason: "stop"} close(in) out, peek := peekStream(in) if !peek() { t.Fatal("a stream with content must be reported as having content") } var got []string for ck := range out { if ck.Content != "" { got = append(got, ck.Content) } if strings.TrimSpace(ck.ReasoningContent) != "" { t.Error("reasoning preamble must not be forwarded before content; " + "that is what pins the client to a stream it cannot escape") } } if strings.Join(got, "") != "Hi!" { t.Errorf("forwarded content = %q, want %q", got, "Hi!") } } // TestPeekStreamDoesNotDeadlockOnToolCalls: a tool-call delta is content for // this purpose and must unblock peek immediately. func TestPeekStreamDoesNotDeadlockOnToolCalls(t *testing.T) { raw, _ := json.Marshal([]types.ToolCall{{Name: "ls"}}) in := make(chan types.UnifiedChunk, 4) in <- types.UnifiedChunk{ToolCalls: raw} in <- types.UnifiedChunk{Done: true, FinishReason: "tool_calls"} close(in) _, peek := peekStream(in) done := make(chan bool, 1) go func() { done <- peek() }() select { case ok := <-done: if !ok { t.Error("a tool-call delta must count as content") } case <-time.After(2 * time.Second): t.Fatal("peek blocked on a tool-call-only stream") } _ = context.Background() }