When a Claude assistant message contains [text, tool_use, text], the Antigravity API internally splits the model message at functionCall boundaries, creating an extra assistant turn between tool_use and the following tool_result. Claude then rejects with: tool_use ids were found without tool_result blocks immediately after Fix: extend the existing 2-way part reordering (thinking-first) to a 3-way partition: thinking → regular → functionCall. This ensures functionCall parts are always last, so Antigravity's split cannot insert an extra assistant turn before the user's tool_result. Fixes #989
1574 lines
46 KiB
Go
1574 lines
46 KiB
Go
package claude
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/cache"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "Hello"}
|
|
]
|
|
}
|
|
],
|
|
"system": [
|
|
{"type": "text", "text": "You are helpful"}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check model
|
|
if gjson.Get(outputStr, "model").String() != "claude-sonnet-4-5" {
|
|
t.Errorf("Expected model 'claude-sonnet-4-5', got '%s'", gjson.Get(outputStr, "model").String())
|
|
}
|
|
|
|
// Check contents exist
|
|
contents := gjson.Get(outputStr, "request.contents")
|
|
if !contents.Exists() || !contents.IsArray() {
|
|
t.Error("request.contents should exist and be an array")
|
|
}
|
|
|
|
// Check role mapping (assistant -> model)
|
|
firstContent := gjson.Get(outputStr, "request.contents.0")
|
|
if firstContent.Get("role").String() != "user" {
|
|
t.Errorf("Expected role 'user', got '%s'", firstContent.Get("role").String())
|
|
}
|
|
|
|
// Check systemInstruction
|
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
|
if !sysInstruction.Exists() {
|
|
t.Error("systemInstruction should exist")
|
|
}
|
|
if sysInstruction.Get("parts.0.text").String() != "You are helpful" {
|
|
t.Error("systemInstruction text mismatch")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{"role": "user", "content": [{"type": "text", "text": "Hi"}]},
|
|
{"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// assistant should be mapped to model
|
|
secondContent := gjson.Get(outputStr, "request.contents.1")
|
|
if secondContent.Get("role").String() != "model" {
|
|
t.Errorf("Expected role 'model' (mapped from 'assistant'), got '%s'", secondContent.Get("role").String())
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
// Valid signature must be at least 50 characters
|
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
|
thinkingText := "Let me think..."
|
|
|
|
// Pre-cache the signature (simulating a previous response for the same thinking text)
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Test user message"}]
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"},
|
|
{"type": "text", "text": "Answer"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check thinking block conversion (now in contents.1 due to user message)
|
|
firstPart := gjson.Get(outputStr, "request.contents.1.parts.0")
|
|
if !firstPart.Get("thought").Bool() {
|
|
t.Error("thinking block should have thought: true")
|
|
}
|
|
if firstPart.Get("text").String() != thinkingText {
|
|
t.Error("thinking text mismatch")
|
|
}
|
|
if firstPart.Get("thoughtSignature").String() != validSignature {
|
|
t.Errorf("Expected thoughtSignature '%s', got '%s'", validSignature, firstPart.Get("thoughtSignature").String())
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *testing.T) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
// Unsigned thinking blocks should be removed entirely (not converted to text)
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "thinking", "thinking": "Let me think..."},
|
|
{"type": "text", "text": "Answer"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Without signature, thinking block should be removed (not converted to text)
|
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(parts) != 1 {
|
|
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
|
|
}
|
|
|
|
// Only text part should remain
|
|
if parts[0].Get("thought").Bool() {
|
|
t.Error("Thinking block should be removed, not preserved")
|
|
}
|
|
if parts[0].Get("text").String() != "Answer" {
|
|
t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String())
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolDeclarations(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [],
|
|
"tools": [
|
|
{
|
|
"name": "test_tool",
|
|
"description": "A test tool",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"}
|
|
},
|
|
"required": ["name"]
|
|
}
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("gemini-1.5-pro", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check tools structure
|
|
tools := gjson.Get(outputStr, "request.tools")
|
|
if !tools.Exists() {
|
|
t.Error("Tools should exist in output")
|
|
}
|
|
|
|
funcDecl := gjson.Get(outputStr, "request.tools.0.functionDeclarations.0")
|
|
if funcDecl.Get("name").String() != "test_tool" {
|
|
t.Errorf("Expected tool name 'test_tool', got '%s'", funcDecl.Get("name").String())
|
|
}
|
|
|
|
// Check input_schema renamed to parametersJsonSchema
|
|
if funcDecl.Get("parametersJsonSchema").Exists() {
|
|
t.Log("parametersJsonSchema exists (expected)")
|
|
}
|
|
if funcDecl.Get("input_schema").Exists() {
|
|
t.Error("input_schema should be removed")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolChoice_SpecificTool(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "gemini-3-flash-preview",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "hi"}
|
|
]
|
|
}
|
|
],
|
|
"tools": [
|
|
{
|
|
"name": "json",
|
|
"description": "A JSON tool",
|
|
"input_schema": {
|
|
"type": "object",
|
|
"properties": {}
|
|
}
|
|
}
|
|
],
|
|
"tool_choice": {"type": "tool", "name": "json"}
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("gemini-3-flash-preview", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if got := gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
|
|
t.Fatalf("Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got)
|
|
}
|
|
allowed := gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Array()
|
|
if len(allowed) != 1 || allowed[0].String() != "json" {
|
|
t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "call_123",
|
|
"name": "get_weather",
|
|
"input": "{\"location\": \"Paris\"}"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Now we expect only 1 part (tool_use), no dummy thinking block injected
|
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(parts) != 1 {
|
|
t.Fatalf("Expected 1 part (tool only, no dummy injection), got %d", len(parts))
|
|
}
|
|
|
|
// Check function call conversion at parts[0]
|
|
funcCall := parts[0].Get("functionCall")
|
|
if !funcCall.Exists() {
|
|
t.Error("functionCall should exist at parts[0]")
|
|
}
|
|
if funcCall.Get("name").String() != "get_weather" {
|
|
t.Errorf("Expected function name 'get_weather', got '%s'", funcCall.Get("name").String())
|
|
}
|
|
if funcCall.Get("id").String() != "call_123" {
|
|
t.Errorf("Expected function id 'call_123', got '%s'", funcCall.Get("id").String())
|
|
}
|
|
// Verify skip_thought_signature_validator is added (bypass for tools without valid thinking)
|
|
expectedSig := "skip_thought_signature_validator"
|
|
actualSig := parts[0].Get("thoughtSignature").String()
|
|
if actualSig != expectedSig {
|
|
t.Errorf("Expected thoughtSignature '%s', got '%s'", expectedSig, actualSig)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolUse_WithSignature(t *testing.T) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
|
thinkingText := "Let me think..."
|
|
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Test user message"}]
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "call_123",
|
|
"name": "get_weather",
|
|
"input": "{\"location\": \"Paris\"}"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check function call has the signature from the preceding thinking block (now in contents.1)
|
|
part := gjson.Get(outputStr, "request.contents.1.parts.1")
|
|
if part.Get("functionCall.name").String() != "get_weather" {
|
|
t.Errorf("Expected functionCall, got %s", part.Raw)
|
|
}
|
|
if part.Get("thoughtSignature").String() != validSignature {
|
|
t.Errorf("Expected thoughtSignature '%s' on tool_use, got '%s'", validSignature, part.Get("thoughtSignature").String())
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
// Case: text block followed by thinking block -> should be reordered to thinking first
|
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
|
thinkingText := "Planning..."
|
|
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Test user message"}]
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Here is the plan."},
|
|
{"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Verify order: Thinking block MUST be first (now in contents.1 due to user message)
|
|
parts := gjson.Get(outputStr, "request.contents.1.parts").Array()
|
|
if len(parts) != 2 {
|
|
t.Fatalf("Expected 2 parts, got %d", len(parts))
|
|
}
|
|
|
|
if !parts[0].Get("thought").Bool() {
|
|
t.Error("First part should be thinking block after reordering")
|
|
}
|
|
if parts[1].Get("text").String() != "Here is the plan." {
|
|
t.Error("Second part should be text block")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ReorderTextAfterFunctionCall(t *testing.T) {
|
|
// Bug: text part after tool_use in an assistant message causes Antigravity
|
|
// to split at functionCall boundary, creating an extra assistant turn that
|
|
// breaks tool_use↔tool_result adjacency (upstream issue #989).
|
|
// Fix: reorder parts so functionCall comes last.
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Let me check..."},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "call_abc",
|
|
"name": "Read",
|
|
"input": {"file": "test.go"}
|
|
},
|
|
{"type": "text", "text": "Reading the file now"}
|
|
]
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "call_abc",
|
|
"content": "file content"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(parts) != 3 {
|
|
t.Fatalf("Expected 3 parts, got %d", len(parts))
|
|
}
|
|
|
|
// Text parts should come before functionCall
|
|
if parts[0].Get("text").String() != "Let me check..." {
|
|
t.Errorf("Expected first text part first, got %s", parts[0].Raw)
|
|
}
|
|
if parts[1].Get("text").String() != "Reading the file now" {
|
|
t.Errorf("Expected second text part second, got %s", parts[1].Raw)
|
|
}
|
|
if !parts[2].Get("functionCall").Exists() {
|
|
t.Errorf("Expected functionCall last, got %s", parts[2].Raw)
|
|
}
|
|
if parts[2].Get("functionCall.name").String() != "Read" {
|
|
t.Errorf("Expected functionCall name 'Read', got '%s'", parts[2].Get("functionCall.name").String())
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ReorderParallelFunctionCalls(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Reading both files."},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "call_1",
|
|
"name": "Read",
|
|
"input": {"file": "a.go"}
|
|
},
|
|
{"type": "text", "text": "And this one too."},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "call_2",
|
|
"name": "Read",
|
|
"input": {"file": "b.go"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(parts) != 4 {
|
|
t.Fatalf("Expected 4 parts, got %d", len(parts))
|
|
}
|
|
|
|
if parts[0].Get("text").String() != "Reading both files." {
|
|
t.Errorf("Expected first text, got %s", parts[0].Raw)
|
|
}
|
|
if parts[1].Get("text").String() != "And this one too." {
|
|
t.Errorf("Expected second text, got %s", parts[1].Raw)
|
|
}
|
|
if parts[2].Get("functionCall.name").String() != "Read" || parts[2].Get("functionCall.id").String() != "call_1" {
|
|
t.Errorf("Expected fc1 third, got %s", parts[2].Raw)
|
|
}
|
|
if parts[3].Get("functionCall.name").String() != "Read" || parts[3].Get("functionCall.id").String() != "call_2" {
|
|
t.Errorf("Expected fc2 fourth, got %s", parts[3].Raw)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ReorderThinkingAndTextBeforeFunctionCall(t *testing.T) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
|
thinkingText := "Let me think about this..."
|
|
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Hello"}]
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Before thinking"},
|
|
{"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "call_xyz",
|
|
"name": "Bash",
|
|
"input": {"command": "ls"}
|
|
},
|
|
{"type": "text", "text": "After tool call"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// contents.1 = assistant message (contents.0 = user)
|
|
parts := gjson.Get(outputStr, "request.contents.1.parts").Array()
|
|
if len(parts) != 4 {
|
|
t.Fatalf("Expected 4 parts, got %d", len(parts))
|
|
}
|
|
|
|
// Order: thinking → text → text → functionCall
|
|
if !parts[0].Get("thought").Bool() {
|
|
t.Error("First part should be thinking")
|
|
}
|
|
if parts[1].Get("functionCall").Exists() || parts[1].Get("thought").Bool() {
|
|
t.Errorf("Second part should be text, got %s", parts[1].Raw)
|
|
}
|
|
if parts[2].Get("functionCall").Exists() || parts[2].Get("thought").Bool() {
|
|
t.Errorf("Third part should be text, got %s", parts[2].Raw)
|
|
}
|
|
if !parts[3].Get("functionCall").Exists() {
|
|
t.Errorf("Last part should be functionCall, got %s", parts[3].Raw)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResult(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "get_weather-call-123",
|
|
"name": "get_weather",
|
|
"input": {"location": "Paris"}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "get_weather-call-123",
|
|
"content": "22C sunny"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check function response conversion
|
|
funcResp := gjson.Get(outputStr, "request.contents.1.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Error("functionResponse should exist")
|
|
}
|
|
if funcResp.Get("id").String() != "get_weather-call-123" {
|
|
t.Errorf("Expected function id, got '%s'", funcResp.Get("id").String())
|
|
}
|
|
if funcResp.Get("name").String() != "get_weather" {
|
|
t.Errorf("Expected function name 'get_weather', got '%s'", funcResp.Get("name").String())
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultName_TouluFormat(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-haiku-4-5-20251001",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "toolu_tool-48fca351f12844eabf49dad8b63886d2",
|
|
"name": "Glob",
|
|
"input": {"pattern": "**/*.py"}
|
|
},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "toolu_tool-cf2d061f75f845c49aacc18ee75ee708",
|
|
"name": "Bash",
|
|
"input": {"command": "ls"}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "toolu_tool-48fca351f12844eabf49dad8b63886d2",
|
|
"content": "file1.py\nfile2.py"
|
|
},
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "toolu_tool-cf2d061f75f845c49aacc18ee75ee708",
|
|
"content": "total 10"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-haiku-4-5-20251001", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
funcResp0 := gjson.Get(outputStr, "request.contents.1.parts.0.functionResponse")
|
|
if !funcResp0.Exists() {
|
|
t.Fatal("first functionResponse should exist")
|
|
}
|
|
if got := funcResp0.Get("name").String(); got != "Glob" {
|
|
t.Errorf("Expected name 'Glob' for toolu_ format, got '%s'", got)
|
|
}
|
|
|
|
funcResp1 := gjson.Get(outputStr, "request.contents.1.parts.1.functionResponse")
|
|
if !funcResp1.Exists() {
|
|
t.Fatal("second functionResponse should exist")
|
|
}
|
|
if got := funcResp1.Get("name").String(); got != "Bash" {
|
|
t.Errorf("Expected name 'Bash' for toolu_ format, got '%s'", got)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultName_CustomFormat(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-haiku-4-5-20251001",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "Read-1773420180464065165-1327",
|
|
"name": "Read",
|
|
"input": {"file_path": "/tmp/test.py"}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "Read-1773420180464065165-1327",
|
|
"content": "file content here"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-haiku-4-5-20251001", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.1.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
if got := funcResp.Get("name").String(); got != "Read" {
|
|
t.Errorf("Expected name 'Read', got '%s'", got)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultName_NoMatchingToolUse_Heuristic(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "get_weather-call-123",
|
|
"content": "22C sunny"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
if got := funcResp.Get("name").String(); got != "get_weather" {
|
|
t.Errorf("Expected heuristic-derived name 'get_weather', got '%s'", got)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultName_NoMatchingToolUse_RawID(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "toolu_tool-48fca351f12844eabf49dad8b63886d2",
|
|
"content": "result data"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
got := funcResp.Get("name").String()
|
|
if got == "" {
|
|
t.Error("functionResponse.name must not be empty")
|
|
}
|
|
if got != "toolu_tool-48fca351f12844eabf49dad8b63886d2" {
|
|
t.Errorf("Expected raw ID as last-resort name, got '%s'", got)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ThinkingConfig(t *testing.T) {
|
|
// Note: This test requires the model to be registered in the registry
|
|
// with Thinking metadata. If the registry is not populated in test environment,
|
|
// thinkingConfig won't be added. We'll test the basic structure only.
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [],
|
|
"thinking": {
|
|
"type": "enabled",
|
|
"budget_tokens": 8000
|
|
}
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check thinking config conversion (only if model supports thinking in registry)
|
|
thinkingConfig := gjson.Get(outputStr, "request.generationConfig.thinkingConfig")
|
|
if thinkingConfig.Exists() {
|
|
if thinkingConfig.Get("thinkingBudget").Int() != 8000 {
|
|
t.Errorf("Expected thinkingBudget 8000, got %d", thinkingConfig.Get("thinkingBudget").Int())
|
|
}
|
|
if !thinkingConfig.Get("includeThoughts").Bool() {
|
|
t.Error("includeThoughts should be true")
|
|
}
|
|
} else {
|
|
t.Log("thinkingConfig not present - model may not be registered in test registry")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ImageContent(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "image/png",
|
|
"data": "iVBORw0KGgoAAAANSUhEUg=="
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Check inline data conversion
|
|
inlineData := gjson.Get(outputStr, "request.contents.0.parts.0.inlineData")
|
|
if !inlineData.Exists() {
|
|
t.Error("inlineData should exist")
|
|
}
|
|
if inlineData.Get("mimeType").String() != "image/png" {
|
|
t.Error("mimeType mismatch")
|
|
}
|
|
if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") {
|
|
t.Error("data mismatch")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_GenerationConfig(t *testing.T) {
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [],
|
|
"temperature": 0.7,
|
|
"top_p": 0.9,
|
|
"top_k": 40,
|
|
"max_tokens": 2000
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
genConfig := gjson.Get(outputStr, "request.generationConfig")
|
|
if genConfig.Get("temperature").Float() != 0.7 {
|
|
t.Errorf("Expected temperature 0.7, got %f", genConfig.Get("temperature").Float())
|
|
}
|
|
if genConfig.Get("topP").Float() != 0.9 {
|
|
t.Errorf("Expected topP 0.9, got %f", genConfig.Get("topP").Float())
|
|
}
|
|
if genConfig.Get("topK").Float() != 40 {
|
|
t.Errorf("Expected topK 40, got %f", genConfig.Get("topK").Float())
|
|
}
|
|
if genConfig.Get("maxOutputTokens").Float() != 2000 {
|
|
t.Errorf("Expected maxOutputTokens 2000, got %f", genConfig.Get("maxOutputTokens").Float())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Trailing Unsigned Thinking Block Removal
|
|
// ============================================================================
|
|
|
|
func TestConvertClaudeRequestToAntigravity_TrailingUnsignedThinking_Removed(t *testing.T) {
|
|
// Last assistant message ends with unsigned thinking block - should be removed
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Hello"}]
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Here is my answer"},
|
|
{"type": "thinking", "thinking": "I should think more..."}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// The last part of the last assistant message should NOT be a thinking block
|
|
lastMessageParts := gjson.Get(outputStr, "request.contents.1.parts")
|
|
if !lastMessageParts.IsArray() {
|
|
t.Fatal("Last message should have parts array")
|
|
}
|
|
parts := lastMessageParts.Array()
|
|
if len(parts) == 0 {
|
|
t.Fatal("Last message should have at least one part")
|
|
}
|
|
|
|
// The unsigned thinking should be removed, leaving only the text
|
|
lastPart := parts[len(parts)-1]
|
|
if lastPart.Get("thought").Bool() {
|
|
t.Error("Trailing unsigned thinking block should be removed")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testing.T) {
|
|
cache.ClearSignatureCache("")
|
|
|
|
// Last assistant message ends with signed thinking block - should be kept
|
|
validSignature := "abc123validSignature1234567890123456789012345678901234567890"
|
|
thinkingText := "Valid thinking..."
|
|
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Hello"}]
|
|
},
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Here is my answer"},
|
|
{"type": "thinking", "thinking": "` + thinkingText + `", "signature": "` + validSignature + `"}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
cache.CacheSignature("claude-sonnet-4-5-thinking", thinkingText, validSignature)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// The signed thinking block should be preserved
|
|
lastMessageParts := gjson.Get(outputStr, "request.contents.1.parts")
|
|
parts := lastMessageParts.Array()
|
|
if len(parts) < 2 {
|
|
t.Error("Signed thinking block should be preserved")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_Removed(t *testing.T) {
|
|
// Middle message has unsigned thinking - should be removed entirely
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "thinking", "thinking": "Middle thinking..."},
|
|
{"type": "text", "text": "Answer"}
|
|
]
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Follow up"}]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// Unsigned thinking should be removed entirely
|
|
parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(parts) != 1 {
|
|
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
|
|
}
|
|
|
|
// Only text part should remain
|
|
if parts[0].Get("thought").Bool() {
|
|
t.Error("Thinking block should be removed, not preserved")
|
|
}
|
|
if parts[0].Get("text").String() != "Answer" {
|
|
t.Errorf("Expected text 'Answer', got '%s'", parts[0].Get("text").String())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tool + Thinking System Hint Injection
|
|
// ============================================================================
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolAndThinking_HintInjected(t *testing.T) {
|
|
// When both tools and thinking are enabled, hint should be injected into system instruction
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
|
"system": [{"type": "text", "text": "You are helpful."}],
|
|
"tools": [
|
|
{
|
|
"name": "get_weather",
|
|
"description": "Get weather",
|
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
|
}
|
|
],
|
|
"thinking": {"type": "enabled", "budget_tokens": 8000}
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// System instruction should contain the interleaved thinking hint
|
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
|
if !sysInstruction.Exists() {
|
|
t.Fatal("systemInstruction should exist")
|
|
}
|
|
|
|
// Check if hint is appended
|
|
sysText := sysInstruction.Get("parts").Array()
|
|
found := false
|
|
for _, part := range sysText {
|
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Interleaved thinking hint should be injected when tools and thinking are both active, got: %v", sysInstruction.Raw)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolsOnly_NoHint(t *testing.T) {
|
|
// When only tools are present (no thinking), hint should NOT be injected
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5",
|
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
|
"system": [{"type": "text", "text": "You are helpful."}],
|
|
"tools": [
|
|
{
|
|
"name": "get_weather",
|
|
"description": "Get weather",
|
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// System instruction should NOT contain the hint
|
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
|
if sysInstruction.Exists() {
|
|
for _, part := range sysInstruction.Get("parts").Array() {
|
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
|
t.Error("Hint should NOT be injected when only tools are present (no thinking)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ThinkingOnly_NoHint(t *testing.T) {
|
|
// When only thinking is enabled (no tools), hint should NOT be injected
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
|
"system": [{"type": "text", "text": "You are helpful."}],
|
|
"thinking": {"type": "enabled", "budget_tokens": 8000}
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// System instruction should NOT contain the hint (no tools)
|
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
|
if sysInstruction.Exists() {
|
|
for _, part := range sysInstruction.Get("parts").Array() {
|
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
|
t.Error("Hint should NOT be injected when only thinking is present (no tools)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultNoContent(t *testing.T) {
|
|
// Bug repro: tool_result with no content field produces invalid JSON
|
|
inputJSON := []byte(`{
|
|
"model": "claude-opus-4-6-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "MyTool-123-456",
|
|
"name": "MyTool",
|
|
"input": {"key": "value"}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "MyTool-123-456"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, true)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Errorf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
// Verify the functionResponse has a valid result value
|
|
fr := gjson.Get(outputStr, "request.contents.1.parts.0.functionResponse.response.result")
|
|
if !fr.Exists() {
|
|
t.Error("functionResponse.response.result should exist")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultNullContent(t *testing.T) {
|
|
// Bug repro: tool_result with null content produces invalid JSON
|
|
inputJSON := []byte(`{
|
|
"model": "claude-opus-4-6-thinking",
|
|
"messages": [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "MyTool-123-456",
|
|
"name": "MyTool",
|
|
"input": {"key": "value"}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "MyTool-123-456",
|
|
"content": null
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, true)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Errorf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultWithImage(t *testing.T) {
|
|
// tool_result with array content containing text + image should place
|
|
// image data inside functionResponse.parts as inlineData, not as a
|
|
// sibling part in the outer content (to avoid base64 context bloat).
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "Read-123-456",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "File content here"
|
|
},
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "image/png",
|
|
"data": "iVBORw0KGgoAAAANSUhEUg=="
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
// Image should be inside functionResponse.parts, not as outer sibling part
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// Text content should be in response.result
|
|
resultText := funcResp.Get("response.result.text").String()
|
|
if resultText != "File content here" {
|
|
t.Errorf("Expected response.result.text = 'File content here', got '%s'", resultText)
|
|
}
|
|
|
|
// Image should be in functionResponse.parts[0].inlineData
|
|
inlineData := funcResp.Get("parts.0.inlineData")
|
|
if !inlineData.Exists() {
|
|
t.Fatal("functionResponse.parts[0].inlineData should exist")
|
|
}
|
|
if inlineData.Get("mimeType").String() != "image/png" {
|
|
t.Errorf("Expected mimeType 'image/png', got '%s'", inlineData.Get("mimeType").String())
|
|
}
|
|
if !strings.Contains(inlineData.Get("data").String(), "iVBORw0KGgo") {
|
|
t.Error("data mismatch")
|
|
}
|
|
|
|
// Image should NOT be in outer parts (only functionResponse part should exist)
|
|
outerParts := gjson.Get(outputStr, "request.contents.0.parts")
|
|
if outerParts.IsArray() && len(outerParts.Array()) > 1 {
|
|
t.Errorf("Expected only 1 outer part (functionResponse), got %d", len(outerParts.Array()))
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultWithSingleImage(t *testing.T) {
|
|
// tool_result with single image object as content should place
|
|
// image data inside functionResponse.parts, not as outer sibling part.
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "Read-789-012",
|
|
"content": {
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": "image/jpeg",
|
|
"data": "/9j/4AAQSkZJRgABAQ=="
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// response.result should be empty (image only)
|
|
if funcResp.Get("response.result").String() != "" {
|
|
t.Errorf("Expected empty response.result for image-only content, got '%s'", funcResp.Get("response.result").String())
|
|
}
|
|
|
|
// Image should be in functionResponse.parts[0].inlineData
|
|
inlineData := funcResp.Get("parts.0.inlineData")
|
|
if !inlineData.Exists() {
|
|
t.Fatal("functionResponse.parts[0].inlineData should exist")
|
|
}
|
|
if inlineData.Get("mimeType").String() != "image/jpeg" {
|
|
t.Errorf("Expected mimeType 'image/jpeg', got '%s'", inlineData.Get("mimeType").String())
|
|
}
|
|
|
|
// Image should NOT be in outer parts
|
|
outerParts := gjson.Get(outputStr, "request.contents.0.parts")
|
|
if outerParts.IsArray() && len(outerParts.Array()) > 1 {
|
|
t.Errorf("Expected only 1 outer part, got %d", len(outerParts.Array()))
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultWithMultipleImagesAndTexts(t *testing.T) {
|
|
// tool_result with array content: 2 text items + 2 images
|
|
// All images go into functionResponse.parts, texts into response.result array
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "Multi-001",
|
|
"content": [
|
|
{"type": "text", "text": "First text"},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": "image/png", "data": "AAAA"}
|
|
},
|
|
{"type": "text", "text": "Second text"},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": "image/jpeg", "data": "BBBB"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// Multiple text items => response.result is an array
|
|
resultArr := funcResp.Get("response.result")
|
|
if !resultArr.IsArray() {
|
|
t.Fatalf("Expected response.result to be an array, got: %s", resultArr.Raw)
|
|
}
|
|
results := resultArr.Array()
|
|
if len(results) != 2 {
|
|
t.Fatalf("Expected 2 result items, got %d", len(results))
|
|
}
|
|
|
|
// Both images should be in functionResponse.parts
|
|
imgParts := funcResp.Get("parts").Array()
|
|
if len(imgParts) != 2 {
|
|
t.Fatalf("Expected 2 image parts in functionResponse.parts, got %d", len(imgParts))
|
|
}
|
|
if imgParts[0].Get("inlineData.mimeType").String() != "image/png" {
|
|
t.Errorf("Expected first image mimeType 'image/png', got '%s'", imgParts[0].Get("inlineData.mimeType").String())
|
|
}
|
|
if imgParts[0].Get("inlineData.data").String() != "AAAA" {
|
|
t.Errorf("Expected first image data 'AAAA', got '%s'", imgParts[0].Get("inlineData.data").String())
|
|
}
|
|
if imgParts[1].Get("inlineData.mimeType").String() != "image/jpeg" {
|
|
t.Errorf("Expected second image mimeType 'image/jpeg', got '%s'", imgParts[1].Get("inlineData.mimeType").String())
|
|
}
|
|
if imgParts[1].Get("inlineData.data").String() != "BBBB" {
|
|
t.Errorf("Expected second image data 'BBBB', got '%s'", imgParts[1].Get("inlineData.data").String())
|
|
}
|
|
|
|
// Only 1 outer part (the functionResponse itself)
|
|
outerParts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(outerParts) != 1 {
|
|
t.Errorf("Expected 1 outer part, got %d", len(outerParts))
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultWithOnlyMultipleImages(t *testing.T) {
|
|
// tool_result with only images (no text) — response.result should be empty string
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "ImgOnly-001",
|
|
"content": [
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": "image/png", "data": "PNG1"}
|
|
},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": "image/gif", "data": "GIF1"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// No text => response.result should be empty string
|
|
if funcResp.Get("response.result").String() != "" {
|
|
t.Errorf("Expected empty response.result, got '%s'", funcResp.Get("response.result").String())
|
|
}
|
|
|
|
// Both images in functionResponse.parts
|
|
imgParts := funcResp.Get("parts").Array()
|
|
if len(imgParts) != 2 {
|
|
t.Fatalf("Expected 2 image parts, got %d", len(imgParts))
|
|
}
|
|
if imgParts[0].Get("inlineData.mimeType").String() != "image/png" {
|
|
t.Error("first image mimeType mismatch")
|
|
}
|
|
if imgParts[1].Get("inlineData.mimeType").String() != "image/gif" {
|
|
t.Error("second image mimeType mismatch")
|
|
}
|
|
|
|
// Only 1 outer part
|
|
outerParts := gjson.Get(outputStr, "request.contents.0.parts").Array()
|
|
if len(outerParts) != 1 {
|
|
t.Errorf("Expected 1 outer part, got %d", len(outerParts))
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultImageNotBase64(t *testing.T) {
|
|
// image with source.type != "base64" should be treated as non-image (falls through)
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "NotB64-001",
|
|
"content": [
|
|
{"type": "text", "text": "some output"},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "url", "url": "https://example.com/img.png"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// Non-base64 image is treated as non-image, so it goes into the filtered results
|
|
// along with the text item. Since there are 2 non-image items, result is array.
|
|
resultArr := funcResp.Get("response.result")
|
|
if !resultArr.IsArray() {
|
|
t.Fatalf("Expected response.result to be an array (2 non-image items), got: %s", resultArr.Raw)
|
|
}
|
|
results := resultArr.Array()
|
|
if len(results) != 2 {
|
|
t.Fatalf("Expected 2 result items, got %d", len(results))
|
|
}
|
|
|
|
// No functionResponse.parts (no base64 images collected)
|
|
if funcResp.Get("parts").Exists() {
|
|
t.Error("functionResponse.parts should NOT exist when no base64 images")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultImageMissingData(t *testing.T) {
|
|
// image with source.type=base64 but missing data field
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "NoData-001",
|
|
"content": [
|
|
{"type": "text", "text": "output"},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "media_type": "image/png"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// The image is still classified as base64 image (type check passes),
|
|
// but data field is missing => inlineData has mimeType but no data
|
|
imgParts := funcResp.Get("parts").Array()
|
|
if len(imgParts) != 1 {
|
|
t.Fatalf("Expected 1 image part, got %d", len(imgParts))
|
|
}
|
|
if imgParts[0].Get("inlineData.mimeType").String() != "image/png" {
|
|
t.Error("mimeType should still be set")
|
|
}
|
|
if imgParts[0].Get("inlineData.data").Exists() {
|
|
t.Error("data should not exist when source.data is missing")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolResultImageMissingMediaType(t *testing.T) {
|
|
// image with source.type=base64 but missing media_type field
|
|
inputJSON := []byte(`{
|
|
"model": "claude-3-5-sonnet-20240620",
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "tool_result",
|
|
"tool_use_id": "NoMime-001",
|
|
"content": [
|
|
{"type": "text", "text": "output"},
|
|
{
|
|
"type": "image",
|
|
"source": {"type": "base64", "data": "AAAA"}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
if !gjson.Valid(outputStr) {
|
|
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
|
|
}
|
|
|
|
funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
|
|
if !funcResp.Exists() {
|
|
t.Fatal("functionResponse should exist")
|
|
}
|
|
|
|
// The image is still classified as base64 image,
|
|
// but media_type is missing => inlineData has data but no mimeType
|
|
imgParts := funcResp.Get("parts").Array()
|
|
if len(imgParts) != 1 {
|
|
t.Fatalf("Expected 1 image part, got %d", len(imgParts))
|
|
}
|
|
if imgParts[0].Get("inlineData.mimeType").Exists() {
|
|
t.Error("mimeType should not exist when media_type is missing")
|
|
}
|
|
if imgParts[0].Get("inlineData.data").String() != "AAAA" {
|
|
t.Error("data should still be set")
|
|
}
|
|
}
|
|
|
|
func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *testing.T) {
|
|
// When tools + thinking but no system instruction, should create one with hint
|
|
inputJSON := []byte(`{
|
|
"model": "claude-sonnet-4-5-thinking",
|
|
"messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}],
|
|
"tools": [
|
|
{
|
|
"name": "get_weather",
|
|
"description": "Get weather",
|
|
"input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}
|
|
}
|
|
],
|
|
"thinking": {"type": "enabled", "budget_tokens": 8000}
|
|
}`)
|
|
|
|
output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
|
|
outputStr := string(output)
|
|
|
|
// System instruction should be created with hint
|
|
sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
|
|
if !sysInstruction.Exists() {
|
|
t.Fatal("systemInstruction should be created when tools + thinking are active")
|
|
}
|
|
|
|
sysText := sysInstruction.Get("parts").Array()
|
|
found := false
|
|
for _, part := range sysText {
|
|
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("Interleaved thinking hint should be in created systemInstruction, got: %v", sysInstruction.Raw)
|
|
}
|
|
}
|