fix(world/analyst): recover reasoning-model answers so chat stops returning "empty response"

The analyst rendered "The analyst couldn't answer — empty response" whenever the
served model streamed its answer on the reasoning channel with an EMPTY content
channel — gpt-oss / harmony, deepseek-r1 and kin. The ONE completion path
(chatMessagesModel + chatMessagesModelStream) read only the content channel, so a
reasoning model's output was dropped and surfaced as a blank.

- Read the reasoning channel (message.reasoning_content / reasoning, and the SSE
  delta.reasoning_content / reasoning) as a fallback when content is empty, and
  forward reasoning deltas live so the replyExtractor shows them on the thinking line.
  Content is always preferred; non-reasoning models (best/zen5) are unaffected.
- Replace the opaque "empty response" with an honest, model-named error
  ("the <model> model returned an empty answer"): never misreads a normal
  completion's response id as an error code, never trips the 402 top-up CTA.

Tests: reasoning-channel recovery (buffered + SSE) and the honest empty-answer error.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
This commit is contained in:
hanzo-dev
2026-07-18 12:30:15 -07:00
parent 4c41abbb29
commit a17d63c10d
4 changed files with 213 additions and 11 deletions
+35 -3
View File
@@ -121,7 +121,13 @@ type chatResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
// A reasoning model (gpt-oss / harmony, deepseek-r1) surfaces its working
// on a SEPARATE channel; when it emits no final content we fall back to
// this so the analyst still recovers an answer, not a blank "empty response".
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
TotalTokens int `json:"total_tokens"`
@@ -203,10 +209,36 @@ func (a *AIClient) chatMessagesModel(ctx context.Context, s *Server, bearer, mod
logf("world: ai 2xx empty-choices error: status=%d code=%q model=%s", status, e.code, model)
return "", status, "", e
}
logf("world: ai 2xx empty response: status=%d model=%s", status, model)
return "", status, "", fmt.Errorf("empty response")
logf("world: ai 2xx no choices: status=%d model=%s", status, model)
return "", status, "", emptyAnswerErr(model)
}
return strings.TrimSpace(cr.Choices[0].Message.Content), cr.Usage.TotalTokens, cr.ID, nil
// Prefer the answer (content) channel; fall back to the reasoning channel a
// reasoning model (gpt-oss/harmony, deepseek-r1) uses when it returns no content —
// so the analyst recovers its envelope/prose instead of a blank "empty response".
msg := cr.Choices[0].Message
// Pre-trim so a whitespace-only content channel falls through to reasoning.
out := firstNonEmpty(strings.TrimSpace(msg.Content), strings.TrimSpace(msg.ReasoningContent), strings.TrimSpace(msg.Reasoning))
if out == "" {
// A 2xx with a real but content-free choice (e.g. a reasoning model that hit the
// token cap before emitting content). The body IS a normal completion here — do
// NOT run it through newAIError (its top-level `id` would be misread as an error
// code); surface the model + stop reason honestly instead.
logf("world: ai 2xx empty answer: status=%d model=%s finish=%s", status, model, cr.Choices[0].FinishReason)
return "", status, cr.ID, emptyAnswerErr(model)
}
return out, cr.Usage.TotalTokens, cr.ID, nil
}
// emptyAnswerErr is the honest error for a 2xx completion that carried no usable
// answer (neither a content nor a reasoning channel). It names the served model so
// the chat surfaces "the <model> model returned an empty answer" — actionable (switch
// model) — instead of an opaque "empty response". Not a balance error, so it never
// trips the top-up CTA.
func emptyAnswerErr(model string) error {
if model = strings.TrimSpace(model); model != "" {
return fmt.Errorf("the %s model returned an empty answer", model)
}
return fmt.Errorf("the model returned an empty answer")
}
// aiError is a typed inference error carrying the upstream HTTP status plus the
+33 -7
View File
@@ -20,7 +20,6 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
@@ -81,16 +80,21 @@ func (a *AIClient) chatMessagesModelStream(ctx context.Context, s *Server, beare
return "", resp.StatusCode, "", err
}
if len(cr.Choices) == 0 {
return "", resp.StatusCode, "", fmt.Errorf("empty response")
return "", resp.StatusCode, "", emptyAnswerErr(model)
}
out := strings.TrimSpace(cr.Choices[0].Message.Content)
if onDelta != nil && out != "" {
msg := cr.Choices[0].Message
out := firstNonEmpty(strings.TrimSpace(msg.Content), strings.TrimSpace(msg.ReasoningContent), strings.TrimSpace(msg.Reasoning))
if out == "" {
return "", resp.StatusCode, cr.ID, emptyAnswerErr(model)
}
if onDelta != nil {
onDelta(out)
}
return out, cr.Usage.TotalTokens, cr.ID, nil
}
var full strings.Builder
var reasoning strings.Builder
tokens := 0
id := ""
sc := bufio.NewScanner(resp.Body)
@@ -108,7 +112,9 @@ func (a *AIClient) chatMessagesModelStream(ctx context.Context, s *Server, beare
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
@@ -125,6 +131,20 @@ func (a *AIClient) chatMessagesModelStream(ctx context.Context, s *Server, beare
tokens = frame.Usage.TotalTokens
}
for _, c := range frame.Choices {
// Reasoning deltas (gpt-oss/harmony, deepseek-r1) ride a separate channel.
// Forward them too — the replyExtractor routes this pre-envelope prose to the
// live "thinking" line — and keep them as the answer fallback for a model that
// never emits a content channel. (Not trimmed: preserve inter-chunk spacing.)
rc := c.Delta.ReasoningContent
if rc == "" {
rc = c.Delta.Reasoning
}
if rc != "" {
reasoning.WriteString(rc)
if onDelta != nil {
onDelta(rc)
}
}
if c.Delta.Content != "" {
full.WriteString(c.Delta.Content)
if onDelta != nil {
@@ -135,11 +155,17 @@ func (a *AIClient) chatMessagesModelStream(ctx context.Context, s *Server, beare
}
if err := sc.Err(); err != nil {
// A mid-stream drop still yields whatever arrived; the caller decides.
if full.Len() == 0 {
if full.Len() == 0 && reasoning.Len() == 0 {
return "", resp.StatusCode, "", err
}
}
return strings.TrimSpace(full.String()), tokens, id, nil
// The content channel is the answer; fall back to the reasoning channel only when
// the model emitted no content at all (mirrors the buffered path above).
out := strings.TrimSpace(full.String())
if out == "" {
out = strings.TrimSpace(reasoning.String())
}
return out, tokens, id, nil
}
// ── incremental "reply" extraction ───────────────────────────────────────────
+1 -1
View File
@@ -304,7 +304,7 @@ func (s *Server) runAnalystLoop(ctx context.Context, bearer, model string, full
}
if err != nil || out == "" {
if err == nil {
err = fmt.Errorf("empty response")
err = emptyAnswerErr(firstNonEmpty(model, s.ai.model))
}
return "", nil, traces, tokens, id, err
}
+144
View File
@@ -1,6 +1,7 @@
package world
import (
"context"
"encoding/json"
"io"
"net/http"
@@ -308,6 +309,149 @@ func readAllString(r *http.Request) (string, error) {
return string(b), err
}
// writeChatMessage writes an OpenAI-shaped completion whose single choice carries
// the given assistant message object verbatim — used to model a reasoning model that
// leaves `content` empty and puts its answer on `reasoning_content`.
func writeChatMessage(w http.ResponseWriter, message map[string]any) {
resp := map[string]any{
"id": "resp_test",
"choices": []any{map[string]any{"message": message}},
"usage": map[string]any{"total_tokens": 7},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// postAnalyst drives the non-streaming analyst path with a signed-in bearer and
// returns the decoded response envelope.
func postAnalyst(t *testing.T, ts *httptest.Server, prompt string) map[string]any {
t.Helper()
reqBody, _ := json.Marshal(map[string]any{
"messages": []map[string]string{{"role": "user", "content": prompt}},
})
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/world/analyst", strings.NewReader(string(reqBody)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer user-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (never 5xx)", resp.StatusCode)
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
return out
}
// TestAnalystRecoversReasoningWhenNoContent is the regression guard for the "empty
// response" break: a reasoning model (gpt-oss-120b / harmony) returns a 2xx with an
// EMPTY content channel and its answer on `reasoning_content`. The completion path
// must fall back to that channel so the analyst recovers the reply — not blank out.
func TestAnalystRecoversReasoningWhenNoContent(t *testing.T) {
ai := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeChatMessage(w, map[string]any{
"content": "",
"reasoning_content": `{"reply":"Markets are steady.","actions":[]}`,
})
}))
defer ai.Close()
s := NewServer()
s.ai.base = ai.URL
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
defer ts.Close()
out := postAnalyst(t, ts, "Reply with strict JSON only")
if got, _ := out["reply"].(string); got != "Markets are steady." {
t.Fatalf("reasoning-channel answer not recovered: reply=%q full=%+v", got, out)
}
if out["error"] != nil {
t.Errorf("recovered answer should carry no error: %+v", out["error"])
}
}
// TestAnalystEmptyAnswerNamesModel pins the honest-error contract: a genuinely empty
// 2xx (a real choice, no content AND no reasoning) surfaces "the <model> model
// returned an empty answer" — never an opaque "empty response", and never a balance
// top-up (that is a distinct 402 contract).
func TestAnalystEmptyAnswerNamesModel(t *testing.T) {
ai := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeChatMessage(w, map[string]any{"content": ""})
}))
defer ai.Close()
s := NewServer() // default model "best"
s.ai.base = ai.URL
mux := http.NewServeMux()
s.Mount(mux)
ts := httptest.NewServer(mux)
defer ts.Close()
out := postAnalyst(t, ts, "hi")
if got, _ := out["reply"].(string); got != "" {
t.Fatalf("expected empty reply, got %q", got)
}
errStr, _ := out["error"].(string)
if strings.Contains(errStr, "empty response") {
t.Fatalf("still surfacing the opaque 'empty response': %q", errStr)
}
if !strings.Contains(errStr, "empty answer") || !strings.Contains(errStr, "best") {
t.Fatalf("empty error not honest/model-named: %q", errStr)
}
if out["topup"] == true {
t.Fatalf("empty answer must not be a balance/top-up error: %+v", out)
}
if out["fallback"] != true {
t.Errorf("empty answer should mark fallback=true: %+v", out)
}
}
// TestChatStreamRecoversReasoning covers the SSE path: a model that streams only
// `reasoning_content` deltas (no content channel) must still yield an answer, and the
// reasoning must be forwarded live (onDelta) so the UI shows the model working.
func TestChatStreamRecoversReasoning(t *testing.T) {
ai := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fl, _ := w.(http.Flusher)
for _, f := range []string{
`{"id":"resp_1","choices":[{"delta":{"reasoning_content":"thinking… "}}]}`,
`{"id":"resp_1","choices":[{"delta":{"reasoning_content":"{\"reply\":\"from reasoning\"}"}}]}`,
`[DONE]`,
} {
_, _ = io.WriteString(w, "data: "+f+"\n\n")
if fl != nil {
fl.Flush()
}
}
}))
defer ai.Close()
s := NewServer()
s.ai.base = ai.URL
var streamed strings.Builder
out, _, id, err := s.ai.chatMessagesModelStream(context.Background(), s, "Bearer u", "gpt-oss-120b",
[]chatMessage{{Role: "user", Content: "hi"}}, 0.4, 700, nil, func(tok string) { streamed.WriteString(tok) })
if err != nil {
t.Fatalf("stream err: %v", err)
}
if id != "resp_1" {
t.Errorf("id = %q, want resp_1", id)
}
want := `thinking… {"reply":"from reasoning"}`
if out != want {
t.Fatalf("stream reasoning fallback wrong:\n got=%q\nwant=%q", out, want)
}
if streamed.String() != want {
t.Errorf("reasoning deltas not forwarded live: %q", streamed.String())
}
}
func TestSanitizeModelAndAgentRef(t *testing.T) {
cases := map[string]string{
"zen5": "zen5",