fix: Drop tool_calls without thought_signature instead of converting to text, fix trace detail word-wrap

Gemini 2.5 flash doesn't return thought_signature in OpenAI-compat responses,
causing collapseToolCallsWithoutSig to convert ALL tool_calls to "(Used tool ...)"
text. Gemini then imitates this pattern instead of making actual tool calls.
Now drops tool_call cycles entirely, preserving only the assistant's text content.

Also fixes word-wrap in trace detail dialog (break-words instead of break-all)
and uses <pre> with whitespace-pre-wrap for trace-level input/output previews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
viettranxandClaude Opus 4.6 committed 2026-02-27 20:53:53 +07:00
1 parent 2e5cf4a9c9
commit ef3c35a8b5
2 files changed
+16 -52

No files matched your search

+12 -48
View File
@@ -1,20 +1,11 @@
package providers
import (
"encoding/json"
"fmt"
"strings"
)
// collapseToolCallsWithoutSig rewrites assistant messages whose tool_calls lack
// thought_signature (required by Gemini 2.5+) into plain text. This handles old
// session history stored before the thought_signature capture fix.
// Tool call cycles (assistant + tool results) are collapsed into:
// - assistant: original content + summary
// - user: tool result content
// collapseToolCallsWithoutSig strips tool_call cycles that lack thought_signature
// (required by Gemini 2.5+). Old session history stored before the thought_signature
// capture fix doesn't have it, and Gemini rejects those messages with HTTP 400.
//
// The collapsed format deliberately avoids looking like a tool call invocation
// to prevent Gemini from imitating the pattern instead of using structured tool_calls.
// The assistant's original text content (if any) is preserved; only the tool_calls
// and their corresponding tool-result messages are dropped.
func collapseToolCallsWithoutSig(msgs []Message) []Message {
// Collect tool_call IDs that need collapsing.
collapseIDs := make(map[string]bool)
@@ -24,7 +15,6 @@ func collapseToolCallsWithoutSig(msgs []Message) []Message {
}
for _, tc := range m.ToolCalls {
if tc.Metadata["thought_signature"] == "" {
// If any tool_call in the message is missing sig, collapse all of them.
for _, tc2 := range m.ToolCalls {
collapseIDs[tc2.ID] = true
}
@@ -40,49 +30,23 @@ func collapseToolCallsWithoutSig(msgs []Message) []Message {
for i := 0; i < len(msgs); i++ {
m := msgs[i]
// Collapse assistant with tool_calls → plain text assistant
// Strip tool_calls from assistant message, keep original content only.
if m.Role == "assistant" && len(m.ToolCalls) > 0 && collapseIDs[m.ToolCalls[0].ID] {
var sb strings.Builder
if m.Content != "" {
sb.WriteString(m.Content)
sb.WriteString("\n\n")
result = append(result, Message{
Role: "assistant",
Content: m.Content,
})
}
for _, tc := range m.ToolCalls {
argsJSON, _ := json.Marshal(tc.Arguments)
fmt.Fprintf(&sb, "(Used tool \"%s\" with %s)\n", tc.Name, string(argsJSON))
}
result = append(result, Message{
Role: "assistant",
Content: strings.TrimSpace(sb.String()),
})
// Collect consecutive tool results → one user message
var toolBuf strings.Builder
// Skip consecutive tool results belonging to these tool_calls.
for i+1 < len(msgs) && msgs[i+1].Role == "tool" && collapseIDs[msgs[i+1].ToolCallID] {
i++
toolMsg := msgs[i]
toolName := toolMsg.ToolCallID
for _, tc := range m.ToolCalls {
if tc.ID == toolMsg.ToolCallID {
toolName = tc.Name
break
}
}
if toolBuf.Len() > 0 {
toolBuf.WriteString("\n\n")
}
fmt.Fprintf(&toolBuf, "Result from %s: %s", toolName, toolMsg.Content)
}
if toolBuf.Len() > 0 {
result = append(result, Message{
Role: "user",
Content: toolBuf.String(),
})
}
continue
}
// Skip orphaned tool results whose assistant was already collapsed
// Skip orphaned tool results whose assistant was already collapsed.
if m.Role == "tool" && collapseIDs[m.ToolCallID] {
continue
}
@@ -132,14 +132,14 @@ export function TraceDetailDialog({ traceId, onClose, getTrace, onNavigateTrace
{trace.input_preview && (
<div className="rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">Input</p>
<p className="text-sm">{trace.input_preview}</p>
<pre className="max-h-[40vh] overflow-y-auto whitespace-pre-wrap break-words text-sm">{trace.input_preview}</pre>
</div>
)}
{trace.output_preview && (
<div className="rounded-md border p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground">Output</p>
<p className="text-sm">{trace.output_preview}</p>
<pre className="max-h-[40vh] overflow-y-auto whitespace-pre-wrap break-words text-sm">{trace.output_preview}</pre>
</div>
)}
@@ -255,7 +255,7 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
{span.input_preview && (
<div>
<p className="text-xs text-muted-foreground">Input:</p>
<pre className="mt-1 max-h-[40vh] overflow-y-auto break-all whitespace-pre-wrap rounded bg-muted/50 p-2 text-xs">
<pre className="mt-1 max-h-[40vh] overflow-y-auto break-words whitespace-pre-wrap rounded bg-muted/50 p-2 text-xs">
{span.input_preview}
</pre>
</div>
@@ -263,7 +263,7 @@ function SpanTreeNode({ node, depth }: { node: SpanNode; depth: number }) {
{span.output_preview && (
<div>
<p className="text-xs text-muted-foreground">Output:</p>
<pre className="mt-1 max-h-[40vh] overflow-y-auto break-all whitespace-pre-wrap rounded bg-muted/50 p-2 text-xs">
<pre className="mt-1 max-h-[40vh] overflow-y-auto break-words whitespace-pre-wrap rounded bg-muted/50 p-2 text-xs">
{span.output_preview}
</pre>
</div>