diff --git a/main.go b/main.go index ee77df1..60f5e2b 100644 --- a/main.go +++ b/main.go @@ -835,7 +835,13 @@ type streamDelta struct { func cleanMessagesForLLM(msgs []Message) []Message { out := make([]Message, len(msgs)) for i, m := range msgs { - out[i] = Message{Role: m.Role, Content: m.Content, ToolCalls: m.ToolCalls, ToolCallID: m.ToolCallID} + out[i] = Message{ + Role: m.Role, + Content: m.Content, + ReasoningContent: m.ReasoningContent, + ToolCalls: m.ToolCalls, + ToolCallID: m.ToolCallID, + } } return out } @@ -855,17 +861,22 @@ func filterText(s string) string { func sanitizeMessages(msgs []Message) { for i := range msgs { - if msgs[i].Role == "assistant" && len(msgs[i].ToolCalls) > 0 { - for j := range msgs[i].ToolCalls { - tc := &msgs[i].ToolCalls[j] - tc.Function.Arguments = filterText(tc.Function.Arguments) - astr := tc.Function.Arguments - var a map[string]any - if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil { - fixed, _ := json.Marshal(map[string]string{"invalid_raw": astr}) - tc.Function.Arguments = string(fixed) + if msgs[i].Role == "assistant" { + if len(msgs[i].ToolCalls) > 0 { + for j := range msgs[i].ToolCalls { + tc := &msgs[i].ToolCalls[j] + tc.Function.Arguments = filterText(tc.Function.Arguments) + astr := tc.Function.Arguments + var a map[string]any + if err := json.Unmarshal([]byte(astr), &a); err != nil || a == nil { + fixed, _ := json.Marshal(map[string]string{"invalid_raw": astr}) + tc.Function.Arguments = string(fixed) + } } } + if msgs[i].ReasoningContent != "" { + msgs[i].ReasoningContent = filterText(msgs[i].ReasoningContent) + } } else if msgs[i].Role == "tool" && msgs[i].Content != nil { msgs[i].Content = strp(filterText(*msgs[i].Content)) } diff --git a/main_test.go b/main_test.go index 02cf06a..e4136f0 100644 --- a/main_test.go +++ b/main_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -2282,3 +2283,109 @@ func TestSOCKS5ProxySupport(t *testing.T) { t.Fatalf("timed out waiting for SOCKS5 handshake through proxy") } } + +func TestCleanMessagesForLLMPreservesReasoningContent(t *testing.T) { + msgs := []Message{ + {Role: "user", Content: strp("hello")}, + {Role: "assistant", Content: strp("done"), ReasoningContent: "thinking steps"}, + {Role: "tool", ToolCallID: "tc1", Content: strp("result")}, + } + cleaned := cleanMessagesForLLM(msgs) + if len(cleaned) != 3 { + t.Fatalf("expected 3 messages, got %d", len(cleaned)) + } + if cleaned[1].ReasoningContent != "thinking steps" { + t.Errorf("expected ReasoningContent %q, got %q", "thinking steps", cleaned[1].ReasoningContent) + } + + // Verify JSON serialization includes reasoning_content for assistant + b, err := json.Marshal(cleaned[1]) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + if !strings.Contains(string(b), `"reasoning_content":"thinking steps"`) { + t.Errorf("expected JSON to contain reasoning_content, got %s", string(b)) + } + + // Verify JSON serialization omits reasoning_content when empty + bUser, err := json.Marshal(cleaned[0]) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + if strings.Contains(string(bUser), "reasoning_content") { + t.Errorf("expected JSON to omit reasoning_content for empty, got %s", string(bUser)) + } +} + +func TestLLMPreservesReasoningContentInRequestBody(t *testing.T) { + var receivedBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + receivedBody, err = io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body err: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"reply"}}]}`)) + })) + defer srv.Close() + + cfg := defCfg + cfg.Endpoint = srv.URL + cfg.Stream = false + cfg.APIKey = "-" + + msgs := []Message{ + {Role: "user", Content: strp("call tool")}, + { + Role: "assistant", + Content: strp(""), + ReasoningContent: "deep thoughts about tool", + ToolCalls: []ToolCall{ + { + ID: "call_1", + Type: "function", + Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{Name: "shell_exec", Arguments: `{"command":"ls"}`}, + }, + }, + }, + {Role: "tool", ToolCallID: "call_1", Content: strp("file.txt\n\nexit: 0")}, + } + + _, _, err := llm(context.Background(), &cfg, msgs, nil) + if err != nil { + t.Fatalf("unexpected llm error: %v", err) + } + + var reqPayload struct { + Messages []map[string]any `json:"messages"` + } + if err := json.Unmarshal(receivedBody, &reqPayload); err != nil { + t.Fatalf("unmarshal request payload: %v", err) + } + if len(reqPayload.Messages) != 3 { + t.Fatalf("expected 3 messages in request, got %d", len(reqPayload.Messages)) + } + asstMsg := reqPayload.Messages[1] + rc, ok := asstMsg["reasoning_content"].(string) + if !ok || rc != "deep thoughts about tool" { + t.Errorf("expected assistant message reasoning_content %q, got %v", "deep thoughts about tool", asstMsg["reasoning_content"]) + } +} + +func TestSanitizeMessagesCleansReasoningContent(t *testing.T) { + msgs := []Message{ + {Role: "assistant", ReasoningContent: "clean\x00\u200B\u00A0reasoning"}, + } + sanitizeMessages(msgs) + if strings.ContainsAny(msgs[0].ReasoningContent, "\x00") || strings.Contains(msgs[0].ReasoningContent, "\u200B") { + t.Errorf("sanitizeMessages did not strip control/invisible characters from ReasoningContent: %q", msgs[0].ReasoningContent) + } + if msgs[0].ReasoningContent != "cleanreasoning" { + t.Errorf("expected 'cleanreasoning', got %q", msgs[0].ReasoningContent) + } +} +