From 6802a3d53e2c6386ed7aa7f72a68fc82ee8d5e2d Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sun, 22 Mar 2026 13:42:01 +0800 Subject: [PATCH] Fix Claude tool block normalization and tool_result fidelity --- internal/adapter/claude/handler_util_test.go | 73 ++++++++++++++++++++ internal/adapter/claude/handler_utils.go | 29 ++++++-- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index 3212cca..9ad10e3 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -98,6 +98,38 @@ func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) { } } +func TestNormalizeClaudeMessagesDoesNotPromoteUserToolUse(t *testing.T) { + msgs := []any{ + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{ + "type": "tool_use", + "id": "call_unsafe", + "name": "dangerous_tool", + "input": map[string]any{"value": "x"}, + }, + }, + }, + } + + got := normalizeClaudeMessages(msgs) + if len(got) != 1 { + t.Fatalf("expected one normalized message, got %d", len(got)) + } + m := got[0].(map[string]any) + if m["role"] != "user" { + t.Fatalf("expected user role preserved, got %#v", m["role"]) + } + if _, ok := m["tool_calls"]; ok { + t.Fatalf("expected no tool_calls promotion for user message, got %#v", m["tool_calls"]) + } + content, _ := m["content"].(string) + if !containsStr(content, `"type":"tool_use"`) || !containsStr(content, "dangerous_tool") { + t.Fatalf("expected raw tool_use block preserved in user content, got %q", content) + } +} + func TestNormalizeClaudeMessagesSkipsNonMap(t *testing.T) { msgs := []any{"not a map", 42} got := normalizeClaudeMessages(msgs) @@ -149,6 +181,47 @@ func TestNormalizeClaudeMessagesMixedContentBlocks(t *testing.T) { } } +func TestNormalizeClaudeMessagesToolResultNonTextPayloadStringified(t *testing.T) { + msgs := []any{ + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{ + "type": "tool_result", + "tool_use_id": "call_image_1", + "name": "vision_tool", + "content": []any{ + map[string]any{"type": "text", "text": "image analysis"}, + map[string]any{ + "type": "image", + "source": map[string]any{"type": "base64", "media_type": "image/png", "data": strings.Repeat("B", 2048)}, + }, + }, + }, + }, + }, + } + + got := normalizeClaudeMessages(msgs) + if len(got) != 1 { + t.Fatalf("expected one normalized message, got %d", len(got)) + } + m := got[0].(map[string]any) + if m["role"] != "tool" { + t.Fatalf("expected tool role, got %#v", m["role"]) + } + content, _ := m["content"].(string) + if !containsStr(content, `"type":"tool_result"`) || !containsStr(content, `"type":"image"`) { + t.Fatalf("expected non-text tool_result payload to be JSON stringified, got %q", content) + } + if !containsStr(content, omittedBinaryMarker) { + t.Fatalf("expected binary data to be sanitized with omitted marker, got %q", content) + } + if containsStr(content, strings.Repeat("B", 100)) { + t.Fatalf("expected raw base64 payload not to be included, got %q", content) + } +} + // ─── buildClaudeToolPrompt ─────────────────────────────────────────── func TestBuildClaudeToolPromptSingleTool(t *testing.T) { diff --git a/internal/adapter/claude/handler_utils.go b/internal/adapter/claude/handler_utils.go index ac94291..97327b4 100644 --- a/internal/adapter/claude/handler_utils.go +++ b/internal/adapter/claude/handler_utils.go @@ -39,9 +39,15 @@ func normalizeClaudeMessages(messages []any) []any { textParts = append(textParts, t) } case "tool_use": - flushText() - if toolMsg := normalizeClaudeToolUseToAssistant(b); toolMsg != nil { - out = append(out, toolMsg) + if role == "assistant" { + flushText() + if toolMsg := normalizeClaudeToolUseToAssistant(b); toolMsg != nil { + out = append(out, toolMsg) + } + continue + } + if raw := strings.TrimSpace(formatClaudeUnknownBlockForPrompt(b)); raw != "" { + textParts = append(textParts, raw) } case "tool_result": flushText() @@ -159,7 +165,7 @@ func normalizeClaudeToolResultToToolMessage(block map[string]any) map[string]any out := map[string]any{ "role": "tool", "tool_call_id": toolCallID, - "content": block["content"], + "content": normalizeClaudeToolResultContent(block["content"]), } if name := strings.TrimSpace(fmt.Sprintf("%v", block["name"])); name != "" { out["name"] = name @@ -167,6 +173,21 @@ func normalizeClaudeToolResultToToolMessage(block map[string]any) map[string]any return out } +func normalizeClaudeToolResultContent(content any) any { + if text, ok := content.(string); ok { + return text + } + payload := map[string]any{ + "type": "tool_result", + "content": content, + } + b, err := json.Marshal(sanitizeClaudeBlockForPrompt(payload)) + if err != nil { + return strings.TrimSpace(fmt.Sprintf("%v", content)) + } + return string(b) +} + func formatClaudeBlockRaw(block map[string]any) string { if block == nil { return ""