Files
goclaw/internal/providers/openai_tool_message_name_test.go
Justin Truong d13cc0804a fix(providers): carry tool name on tool results for Gemini
Gemini drops tool_call_id and pairs functionCall/functionResponse by
function name, so its OpenAI-compat shim requires a non-empty
FunctionResponse.name. The name was only recoverable through a reverse
id->name lookup over assistant tool_calls still present in the request
window, which fails after pruning, truncation or tool_call collapse and
produces HTTP 400 "Name cannot be empty" on any follow-up iteration.

Add Message.ToolName, set it at every tool-result creation site and
preserve it through context pruning, then prefer it when serializing.
Fall back to the existing index for history persisted before the field,
so old sessions keep working without a reset.

When neither source resolves a name, drop the unlabelled tool result
instead of emitting an empty one: an empty name is a guaranteed 400 and
a synthetic name would match no prior functionCall. Gated behind the
existing Gemini detection so other OpenAI-compat hosts, which pair by
tool_call_id, are unaffected.

Field is JSON-optional, so session history needs no migration and older
binaries ignore it on rollback.
2026-08-03 22:50:06 +07:00

194 lines
7.0 KiB
Go

package providers
import (
"testing"
)
// TestBuildRequestBody_ToolMessageIncludesName verifies that role="tool" wire
// messages carry the originating tool's `name` field. Google Gemini's
// OpenAI-compat shim maps this to native `FunctionResponse.name`; an empty name
// trips HTTP 400 ("Name cannot be empty"). Trace: 019d8f33-2de1-7ab2-9a32-9df92cd610dd.
func TestBuildRequestBody_ToolMessageIncludesName(t *testing.T) {
p := NewOpenAIProvider("test-gemini", "key",
"https://generativelanguage.googleapis.com/v1beta/openai", "gemini-3-flash-preview")
req := ChatRequest{
Messages: []Message{
{Role: "user", Content: "hello"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{
{
ID: "call_1", Name: "write_file",
Arguments: map[string]any{"path": "USER.md", "content": "x"},
// thought_signature present → not collapsed by Gemini sig-missing collapser.
Metadata: map[string]string{"thought_signature": "sig-abc"},
},
}},
{Role: "tool", ToolCallID: "call_1", Content: "ok"},
},
}
body := p.buildRequestBody("gemini-3-flash-preview", req, false)
msgs, ok := body["messages"].([]map[string]any)
if !ok {
t.Fatalf("messages not []map[string]any: %T", body["messages"])
}
if len(msgs) != 3 {
t.Fatalf("expected 3 messages, got %d", len(msgs))
}
toolMsg := msgs[2]
if toolMsg["role"] != "tool" {
t.Fatalf("msg[2] role = %v, want tool", toolMsg["role"])
}
if toolMsg["tool_call_id"] != "call_1" {
t.Fatalf("msg[2] tool_call_id = %v, want call_1", toolMsg["tool_call_id"])
}
if got := toolMsg["name"]; got != "write_file" {
t.Fatalf("msg[2] name = %v, want write_file (Gemini 400 fix)", got)
}
}
// TestBuildRequestBody_ToolMessageNameLookupUsesRawID ensures the lookup map is
// keyed by the *raw* ToolCallID (pre-wire-truncation) so long IDs still resolve.
func TestBuildRequestBody_ToolMessageNameLookupUsesRawID(t *testing.T) {
longID := "call_0123456789abcdef0123456789abcdef0123456789abcdef" // > maxToolCallIDLen
p := NewOpenAIProvider("test-gemini", "key",
"https://generativelanguage.googleapis.com/v1beta/openai", "gemini-3-flash-preview")
req := ChatRequest{
Messages: []Message{
{Role: "user", Content: "hi"},
{Role: "assistant", ToolCalls: []ToolCall{
{
ID: longID, Name: "read_file",
Arguments: map[string]any{"path": "x"},
Metadata: map[string]string{"thought_signature": "sig-xyz"},
},
}},
{Role: "tool", ToolCallID: longID, Content: "data"},
},
}
body := p.buildRequestBody("gemini-3-flash-preview", req, false)
msgs := body["messages"].([]map[string]any)
toolMsg := msgs[2]
if got := toolMsg["name"]; got != "read_file" {
t.Fatalf("name lookup must use raw ID; got %v want read_file", got)
}
wireID := p.wireToolCallID(longID)
if got := toolMsg["tool_call_id"]; got != wireID {
t.Fatalf("tool_call_id should be wire-translated; got %v want %v", got, wireID)
}
}
// TestBuildRequestBody_ToolNameSurvivesMissingAssistant is the core regression
// test for the Gemini 400. Message.ToolName must be used directly, so a tool
// result still serializes a valid FunctionResponse.name even when the assistant
// tool_call it originated from is no longer in the request window (pruned,
// truncated, or collapsed). The reverse id→name index cannot help here.
func TestBuildRequestBody_ToolNameSurvivesMissingAssistant(t *testing.T) {
p := NewOpenAIProvider("test-gemini", "key",
"https://generativelanguage.googleapis.com/v1beta/openai", "gemini-3-flash-preview")
req := ChatRequest{
Messages: []Message{
{Role: "user", Content: "hi"},
// No assistant tool_call in the window — only the carried name can save this.
{Role: "tool", ToolCallID: "call_gone", ToolName: "mcp__srv__lookup", Content: "ok"},
},
}
body := p.buildRequestBody("gemini-3-flash-preview", req, false)
msgs := body["messages"].([]map[string]any)
if len(msgs) != 2 {
t.Fatalf("tool msg must be kept when ToolName is present, got %d msgs", len(msgs))
}
if got := msgs[1]["name"]; got != "mcp__srv__lookup" {
t.Fatalf("msg[1] name = %v, want mcp__srv__lookup", got)
}
}
// TestBuildRequestBody_ToolNameBeatsStaleIndex verifies precedence: the name
// carried on the message wins over the reverse index, so a rewritten/aliased
// tool_call ID cannot resurface a mismatched name.
func TestBuildRequestBody_ToolNameBeatsStaleIndex(t *testing.T) {
p := NewOpenAIProvider("test-gemini", "key",
"https://generativelanguage.googleapis.com/v1beta/openai", "gemini-3-flash-preview")
req := ChatRequest{
Messages: []Message{
{Role: "user", Content: "hi"},
{Role: "assistant", ToolCalls: []ToolCall{
{
ID: "call_1", Name: "indexed_name",
Metadata: map[string]string{"thought_signature": "sig"},
},
}},
{Role: "tool", ToolCallID: "call_1", ToolName: "carried_name", Content: "ok"},
},
}
body := p.buildRequestBody("gemini-3-flash-preview", req, false)
msgs := body["messages"].([]map[string]any)
if got := msgs[2]["name"]; got != "carried_name" {
t.Fatalf("carried ToolName must win; got %v", got)
}
}
// TestBuildRequestBody_UnresolvableToolNameDropped verifies that a tool result
// with neither a carried name nor an index match is DROPPED for Gemini. Emitting
// it with an empty (or absent) name is what produced HTTP 400 "Name cannot be
// empty" — Gemini has no tool_call_id fallback to pair on.
func TestBuildRequestBody_UnresolvableToolNameDropped(t *testing.T) {
p := NewOpenAIProvider("test-gemini", "key",
"https://generativelanguage.googleapis.com/v1beta/openai", "gemini-3-flash-preview")
req := ChatRequest{
Messages: []Message{
{Role: "user", Content: "hi"},
{Role: "tool", ToolCallID: "orphan_id", Content: "stale"},
},
}
body := p.buildRequestBody("gemini-3-flash-preview", req, false)
msgs := body["messages"].([]map[string]any)
for i, m := range msgs {
if m["role"] == "tool" {
t.Fatalf("msg[%d]: unresolvable tool result must be dropped, got %v", i, m)
}
if name, present := m["name"]; present && name == "" {
t.Fatalf("msg[%d]: empty name must never reach Gemini", i)
}
}
}
// TestBuildRequestBody_UnresolvableToolNameKeptForNonGemini is the regression
// guard for every other OpenAI-compat host sharing this code path (OpenAI, Qwen,
// DeepSeek, Together, ...). They pair by tool_call_id and never need `name`, so
// the drop must not apply — silently losing tool results there would be a bug.
func TestBuildRequestBody_UnresolvableToolNameKeptForNonGemini(t *testing.T) {
p := NewOpenAIProvider("together", "key",
"https://api.together.xyz/v1", "meta-llama/Llama-3-70b")
req := ChatRequest{
Messages: []Message{
{Role: "user", Content: "hi"},
{Role: "tool", ToolCallID: "orphan_id", Content: "stale"},
},
}
body := p.buildRequestBody("meta-llama/Llama-3-70b", req, false)
msgs := body["messages"].([]map[string]any)
if len(msgs) != 2 {
t.Fatalf("non-Gemini must keep tool msg, got %d msgs", len(msgs))
}
if msgs[1]["role"] != "tool" {
t.Fatalf("msg[1] role = %v, want tool", msgs[1]["role"])
}
if _, present := msgs[1]["name"]; present {
t.Fatalf("non-Gemini must not emit name, got %v", msgs[1]["name"])
}
}