fix(claude): guard thinking tool-call fallback when final text exists

- only parse tool_calls from thinking when finalText is empty

- apply the same guard in stream runtime finalizer

- add regression tests for non-stream and stream paths
This commit is contained in:
BigUncle
2026-02-26 00:41:39 +08:00
parent 255feb2e65
commit d3b5493d2e
4 changed files with 69 additions and 2 deletions

View File

@@ -209,6 +209,40 @@ func TestHandleClaudeStreamRealtimeToolDetectionFromThinkingFallback(t *testing.
}
}
func TestHandleClaudeStreamRealtimeSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(
`data: {"p":"response/thinking_content","v":"{\"tool_calls\":[{\"name\":\"search\""}`,
`data: {"p":"response/thinking_content","v":",\"input\":{\"q\":\"go\"}}]}"}`,
`data: {"p":"response/content","v":"normal answer"}`,
`data: [DONE]`,
)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil)
h.handleClaudeStreamRealtime(rec, req, resp, "claude-sonnet-4-5", []any{map[string]any{"role": "user", "content": "use tool"}}, true, false, []string{"search"})
frames := parseClaudeFrames(t, rec.Body.String())
for _, f := range findClaudeFrames(frames, "content_block_start") {
contentBlock, _ := f.Payload["content_block"].(map[string]any)
if contentBlock["type"] == "tool_use" {
t.Fatalf("unexpected tool_use block when final text exists, body=%s", rec.Body.String())
}
}
foundEndTurn := false
for _, f := range findClaudeFrames(frames, "message_delta") {
delta, _ := f.Payload["delta"].(map[string]any)
if delta["stop_reason"] == "end_turn" {
foundEndTurn = true
break
}
}
if !foundEndTurn {
t.Fatalf("expected stop_reason=end_turn, body=%s", rec.Body.String())
}
}
func TestHandleClaudeStreamRealtimeUpstreamErrorEvent(t *testing.T) {
h := &Handler{}
resp := makeClaudeSSEHTTPResponse(

View File

@@ -46,7 +46,7 @@ func (s *claudeStreamRuntime) finalize(stopReason string) {
if s.bufferToolContent {
detected := util.ParseToolCalls(finalText, s.toolNames)
if len(detected) == 0 && finalThinking != "" {
if len(detected) == 0 && finalText == "" && finalThinking != "" {
detected = util.ParseToolCalls(finalThinking, s.toolNames)
}
if len(detected) > 0 {

View File

@@ -9,7 +9,7 @@ import (
func BuildMessageResponse(messageID, model string, normalizedMessages []any, finalThinking, finalText string, toolNames []string) map[string]any {
detected := util.ParseToolCalls(finalText, toolNames)
if len(detected) == 0 && finalThinking != "" {
if len(detected) == 0 && finalText == "" && finalThinking != "" {
detected = util.ParseToolCalls(finalThinking, toolNames)
}
content := make([]map[string]any, 0, 4)

View File

@@ -27,3 +27,36 @@ func TestBuildMessageResponseDetectsToolCallsFromThinkingFallback(t *testing.T)
t.Fatalf("expected tool name search, got=%#v", last["name"])
}
}
func TestBuildMessageResponseSkipsThinkingFallbackWhenFinalTextExists(t *testing.T) {
resp := BuildMessageResponse(
"msg_1",
"claude-sonnet-4-5",
[]any{map[string]any{"role": "user", "content": "hi"}},
`{"tool_calls":[{"name":"search","input":{"q":"go"}}]}`,
"normal answer",
[]string{"search"},
)
if resp["stop_reason"] != "end_turn" {
t.Fatalf("expected stop_reason=end_turn, got=%#v", resp["stop_reason"])
}
content, _ := resp["content"].([]map[string]any)
foundText := false
foundTool := false
for _, block := range content {
if block["type"] == "text" && block["text"] == "normal answer" {
foundText = true
}
if block["type"] == "tool_use" {
foundTool = true
}
}
if !foundText {
t.Fatalf("expected text block with finalText, got=%#v", resp["content"])
}
if foundTool {
t.Fatalf("unexpected tool_use block when finalText exists, got=%#v", resp["content"])
}
}