package antigravity import ( "encoding/json" "strings" "testing" "github.com/stretchr/testify/require" ) func TestEnsureGeminiRequestSessionID(t *testing.T) { t.Run("prefers provided session id", func(t *testing.T) { body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`) updated, err := EnsureGeminiRequestSessionID(body, "session-from-header") require.NoError(t, err) var payload map[string]any require.NoError(t, json.Unmarshal(updated, &payload)) require.Equal(t, "session-from-header", payload["sessionId"]) }) t.Run("keeps existing session id", func(t *testing.T) { body := []byte(`{"sessionId":"session-in-body","contents":[{"role":"user","parts":[{"text":"hello"}]}]}`) updated, err := EnsureGeminiRequestSessionID(body, "session-from-header") require.NoError(t, err) var payload map[string]any require.NoError(t, json.Unmarshal(updated, &payload)) require.Equal(t, "session-in-body", payload["sessionId"]) }) t.Run("derives stable fallback from contents", func(t *testing.T) { body := []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`) first, err := EnsureGeminiRequestSessionID(body, "") require.NoError(t, err) second, err := EnsureGeminiRequestSessionID(body, "") require.NoError(t, err) var firstPayload map[string]any var secondPayload map[string]any require.NoError(t, json.Unmarshal(first, &firstPayload)) require.NoError(t, json.Unmarshal(second, &secondPayload)) require.NotEmpty(t, firstPayload["sessionId"]) require.Equal(t, firstPayload["sessionId"], secondPayload["sessionId"]) }) } func TestTransformClaudeToGeminiWithOptions_UsesMetadataSessionIDJSON(t *testing.T) { claudeReq := &ClaudeRequest{ Model: "claude-sonnet-4-5", Messages: []ClaudeMessage{ { Role: "user", Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), }, }, Metadata: &ClaudeMetadata{ UserID: `{"device_id":"d61f76d0aabbccdd00112233445566778899aabbccddeeff0011223344556677","account_uuid":"acc-uuid","session_id":"c72554f2-1234-5678-abcd-123456789abc"}`, }, } body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) require.NoError(t, err) var req V1InternalRequest require.NoError(t, json.Unmarshal(body, &req)) require.Equal(t, "c72554f2-1234-5678-abcd-123456789abc", req.Request.SessionID) } func TestTransformClaudeToGeminiWithOptions_UsesMetadataSessionIDLegacy(t *testing.T) { claudeReq := &ClaudeRequest{ Model: "claude-sonnet-4-5", Messages: []ClaudeMessage{ { Role: "user", Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), }, }, Metadata: &ClaudeMetadata{ UserID: "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account_550e8400-e29b-41d4-a716-446655440000_session_123e4567-e89b-12d3-a456-426614174000", }, } body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) require.NoError(t, err) var req V1InternalRequest require.NoError(t, json.Unmarshal(body, &req)) require.Equal(t, "123e4567-e89b-12d3-a456-426614174000", req.Request.SessionID) } func TestTransformClaudeToGeminiWithOptions_PrefersExplicitSessionWhenMetadataIsNotSessionPayload(t *testing.T) { opts := DefaultTransformOptions() opts.PreferredSessionID = "session-header-1" claudeReq := &ClaudeRequest{ Model: "claude-sonnet-4-5", Messages: []ClaudeMessage{ { Role: "user", Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), }, }, Metadata: &ClaudeMetadata{ UserID: "custom-user-42", }, } body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", opts) require.NoError(t, err) var req V1InternalRequest require.NoError(t, json.Unmarshal(body, &req)) require.Equal(t, "session-header-1", req.Request.SessionID) } // TestBuildParts_ThinkingBlockWithoutSignature 测试thinking block无signature时的处理 func TestBuildParts_ThinkingBlockWithoutSignature(t *testing.T) { tests := []struct { name string content string allowDummyThought bool expectedParts int description string }{ { name: "Claude model - downgrade thinking to text without signature", content: `[ {"type": "text", "text": "Hello"}, {"type": "thinking", "thinking": "Let me think...", "signature": ""}, {"type": "text", "text": "World"} ]`, allowDummyThought: false, expectedParts: 3, // thinking 内容降级为普通 text part description: "Claude模型缺少signature时应将thinking降级为text,并在上层禁用thinking mode", }, { name: "Claude model - preserve thinking block with signature", content: `[ {"type": "text", "text": "Hello"}, {"type": "thinking", "thinking": "Let me think...", "signature": "sig_real_123"}, {"type": "text", "text": "World"} ]`, allowDummyThought: false, expectedParts: 3, description: "Claude模型应透传带 signature 的 thinking block(用于 Vertex 签名链路)", }, { name: "Gemini model - use dummy signature", content: `[ {"type": "text", "text": "Hello"}, {"type": "thinking", "thinking": "Let me think...", "signature": ""}, {"type": "text", "text": "World"} ]`, allowDummyThought: true, expectedParts: 3, // 三个block都保留,thinking使用dummy signature description: "Gemini模型应该为无signature的thinking block使用dummy signature", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { toolIDToName := make(map[string]string) parts, _, err := buildParts(json.RawMessage(tt.content), toolIDToName, tt.allowDummyThought, false) if err != nil { t.Fatalf("buildParts() error = %v", err) } if len(parts) != tt.expectedParts { t.Errorf("%s: got %d parts, want %d parts", tt.description, len(parts), tt.expectedParts) } switch tt.name { case "Claude model - preserve thinking block with signature": if len(parts) != 3 { t.Fatalf("expected 3 parts, got %d", len(parts)) } if !parts[1].Thought || parts[1].ThoughtSignature != "sig_real_123" { t.Fatalf("expected thought part with signature sig_real_123, got thought=%v signature=%q", parts[1].Thought, parts[1].ThoughtSignature) } case "Claude model - downgrade thinking to text without signature": if len(parts) != 3 { t.Fatalf("expected 3 parts, got %d", len(parts)) } if parts[1].Thought { t.Fatalf("expected downgraded text part, got thought=%v signature=%q", parts[1].Thought, parts[1].ThoughtSignature) } if parts[1].Text != "Let me think..." { t.Fatalf("expected downgraded text %q, got %q", "Let me think...", parts[1].Text) } case "Gemini model - use dummy signature": if len(parts) != 3 { t.Fatalf("expected 3 parts, got %d", len(parts)) } if !parts[1].Thought || parts[1].ThoughtSignature != DummyThoughtSignature { t.Fatalf("expected dummy thought signature, got thought=%v signature=%q", parts[1].Thought, parts[1].ThoughtSignature) } } }) } } func TestBuildParts_ToolUseSignatureHandling(t *testing.T) { content := `[ {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}, "signature": "sig_tool_abc"} ]` t.Run("Gemini preserves provided tool_use signature", func(t *testing.T) { toolIDToName := make(map[string]string) parts, _, err := buildParts(json.RawMessage(content), toolIDToName, true, false) if err != nil { t.Fatalf("buildParts() error = %v", err) } if len(parts) != 1 || parts[0].FunctionCall == nil { t.Fatalf("expected 1 functionCall part, got %+v", parts) } if parts[0].ThoughtSignature != "sig_tool_abc" { t.Fatalf("expected preserved tool signature %q, got %q", "sig_tool_abc", parts[0].ThoughtSignature) } }) t.Run("Gemini falls back to dummy tool_use signature when missing", func(t *testing.T) { contentNoSig := `[ {"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}} ]` toolIDToName := make(map[string]string) parts, _, err := buildParts(json.RawMessage(contentNoSig), toolIDToName, true, false) if err != nil { t.Fatalf("buildParts() error = %v", err) } if len(parts) != 1 || parts[0].FunctionCall == nil { t.Fatalf("expected 1 functionCall part, got %+v", parts) } if parts[0].ThoughtSignature != DummyThoughtSignature { t.Fatalf("expected dummy tool signature %q, got %q", DummyThoughtSignature, parts[0].ThoughtSignature) } }) t.Run("Claude model - preserve valid signature for tool_use", func(t *testing.T) { toolIDToName := make(map[string]string) parts, _, err := buildParts(json.RawMessage(content), toolIDToName, false, false) if err != nil { t.Fatalf("buildParts() error = %v", err) } if len(parts) != 1 || parts[0].FunctionCall == nil { t.Fatalf("expected 1 functionCall part, got %+v", parts) } // Claude 模型应透传有效的 signature(Vertex/Google 需要完整签名链路) if parts[0].ThoughtSignature != "sig_tool_abc" { t.Fatalf("expected preserved tool signature %q, got %q", "sig_tool_abc", parts[0].ThoughtSignature) } }) } // TestBuildTools_CustomTypeTools 测试custom类型工具转换 func TestBuildTools_CustomTypeTools(t *testing.T) { tests := []struct { name string tools []ClaudeTool expectedLen int description string }{ { name: "Standard tool format", tools: []ClaudeTool{ { Name: "get_weather", Description: "Get weather information", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "location": map[string]any{"type": "string"}, }, }, }, }, expectedLen: 1, description: "标准工具格式应该正常转换", }, { name: "Custom type tool (MCP format)", tools: []ClaudeTool{ { Type: "custom", Name: "mcp_tool", Custom: &ClaudeCustomToolSpec{ Description: "MCP tool description", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "param": map[string]any{"type": "string"}, }, }, }, }, }, expectedLen: 1, description: "Custom类型工具应该从Custom字段读取description和input_schema", }, { name: "Mixed standard and custom tools", tools: []ClaudeTool{ { Name: "standard_tool", Description: "Standard tool", InputSchema: map[string]any{"type": "object"}, }, { Type: "custom", Name: "custom_tool", Custom: &ClaudeCustomToolSpec{ Description: "Custom tool", InputSchema: map[string]any{"type": "object"}, }, }, }, expectedLen: 1, // 返回一个GeminiToolDeclaration,包含2个function declarations description: "混合标准和custom工具应该都能正确转换", }, { name: "Invalid custom tool - nil Custom field", tools: []ClaudeTool{ { Type: "custom", Name: "invalid_custom", // Custom 为 nil }, }, expectedLen: 0, // 应该被跳过 description: "Custom字段为nil的custom工具应该被跳过", }, { name: "Invalid custom tool - nil InputSchema", tools: []ClaudeTool{ { Type: "custom", Name: "invalid_custom", Custom: &ClaudeCustomToolSpec{ Description: "Invalid", // InputSchema 为 nil }, }, }, expectedLen: 0, // 应该被跳过 description: "InputSchema为nil的custom工具应该被跳过", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := buildTools(tt.tools) if len(result) != tt.expectedLen { t.Errorf("%s: got %d tool declarations, want %d", tt.description, len(result), tt.expectedLen) } // 验证function declarations存在 if len(result) > 0 && result[0].FunctionDeclarations != nil { if len(result[0].FunctionDeclarations) != len(tt.tools) { t.Errorf("%s: got %d function declarations, want %d", tt.description, len(result[0].FunctionDeclarations), len(tt.tools)) } } }) } } func TestBuildTools_PreservesWebSearchAlongsideFunctions(t *testing.T) { tools := []ClaudeTool{ { Name: "get_weather", Description: "Get weather information", InputSchema: map[string]any{"type": "object"}, }, { Type: "web_search_20250305", Name: "web_search", }, } result := buildTools(tools) require.Len(t, result, 2) require.Len(t, result[0].FunctionDeclarations, 1) require.Equal(t, "get_weather", result[0].FunctionDeclarations[0].Name) require.NotNil(t, result[1].GoogleSearch) require.NotNil(t, result[1].GoogleSearch.EnhancedContent) require.NotNil(t, result[1].GoogleSearch.EnhancedContent.ImageSearch) require.Equal(t, 5, result[1].GoogleSearch.EnhancedContent.ImageSearch.MaxResultCount) } func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) { tests := []struct { name string model string thinking *ThinkingConfig wantBudget int wantPresent bool }{ { name: "enabled without budget defaults to dynamic (-1)", model: "claude-opus-4-6-thinking", thinking: &ThinkingConfig{Type: "enabled"}, wantBudget: -1, wantPresent: true, }, { name: "enabled with budget uses the provided value", model: "claude-opus-4-6-thinking", thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: 1024}, wantBudget: 1024, wantPresent: true, }, { name: "enabled with -1 budget uses dynamic (-1)", model: "claude-opus-4-6-thinking", thinking: &ThinkingConfig{Type: "enabled", BudgetTokens: -1}, wantBudget: -1, wantPresent: true, }, { name: "adaptive on opus4.6 maps to high budget (24576)", model: "claude-opus-4-6-thinking", thinking: &ThinkingConfig{Type: "adaptive", BudgetTokens: 20000}, wantBudget: ClaudeAdaptiveHighThinkingBudgetTokens, wantPresent: true, }, { name: "adaptive on non-opus model keeps default dynamic (-1)", model: "claude-sonnet-4-5-thinking", thinking: &ThinkingConfig{Type: "adaptive"}, wantBudget: -1, wantPresent: true, }, { // Google v1internal 要求 -thinking 模型必须携带 thinkingConfig,即使客户端明确 disabled。 // 不携带会导致 Google 立即返回错误(在生产中表现为快速 503)。 name: "disabled on -thinking model auto-injects thinkingConfig (Google requires it)", model: "claude-opus-4-6-thinking", thinking: &ThinkingConfig{Type: "disabled", BudgetTokens: 1024}, wantBudget: -1, // auto-injected dynamic budget wantPresent: true, }, { // Google v1internal 要求 -thinking 模型必须携带 thinkingConfig,nil 时自动注入。 name: "nil thinking on -thinking model auto-injects thinkingConfig (Google requires it)", model: "claude-opus-4-6-thinking", thinking: nil, wantBudget: -1, // auto-injected dynamic budget wantPresent: true, }, { // claude-sonnet-4-6 需要 thinkingConfig(无 -thinking 变体),budget 必须为 -1(动态) // 经测试:claude-sonnet-4-6-thinking → 404;claude-sonnet-4-6 + budget=-1 → 200 OK name: "nil thinking on claude-sonnet-4-6 auto-injects thinkingConfig (no -thinking variant exists)", model: "claude-sonnet-4-6", thinking: nil, wantBudget: -1, wantPresent: true, }, { // 非 -thinking 普通模型(如 claude-opus-4-6,服务层已转为 -thinking,此处测试原始名) name: "nil thinking on plain non-thinking model does not emit thinkingConfig", model: "claude-opus-4-6", thinking: nil, wantBudget: 0, wantPresent: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := &ClaudeRequest{ Model: tt.model, Thinking: tt.thinking, } cfg := buildGenerationConfig(req) if cfg == nil { t.Fatalf("expected non-nil generationConfig") } if tt.wantPresent { if cfg.ThinkingConfig == nil { t.Fatalf("expected thinkingConfig to be present") } if !cfg.ThinkingConfig.IncludeThoughts { t.Fatalf("expected includeThoughts=true") } if cfg.ThinkingConfig.ThinkingBudget != tt.wantBudget { t.Fatalf("expected thinkingBudget=%d, got %d", tt.wantBudget, cfg.ThinkingConfig.ThinkingBudget) } return } if cfg.ThinkingConfig != nil { t.Fatalf("expected thinkingConfig to be nil, got %+v", cfg.ThinkingConfig) } }) } } func TestTransformClaudeToGeminiWithOptions_PreservesBillingHeaderSystemBlock(t *testing.T) { tests := []struct { name string system json.RawMessage }{ { name: "system array", system: json.RawMessage(`[{"type":"text","text":"x-anthropic-billing-header keep"}]`), }, { name: "system string", system: json.RawMessage(`"x-anthropic-billing-header keep"`), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { claudeReq := &ClaudeRequest{ Model: "claude-3-5-sonnet-latest", System: tt.system, Messages: []ClaudeMessage{ { Role: "user", Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), }, }, } body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "gemini-2.5-flash", DefaultTransformOptions()) require.NoError(t, err) var req V1InternalRequest require.NoError(t, json.Unmarshal(body, &req)) require.NotNil(t, req.Request.SystemInstruction) found := false for _, part := range req.Request.SystemInstruction.Parts { if strings.Contains(part.Text, "x-anthropic-billing-header keep") { found = true break } } require.True(t, found, "转换后的 systemInstruction 应保留 x-anthropic-billing-header 内容") }) } } func TestTransformClaudeToGeminiWithOptions_PreservesWebSearchAlongsideFunctions(t *testing.T) { claudeReq := &ClaudeRequest{ Model: "claude-3-5-sonnet-latest", Messages: []ClaudeMessage{ { Role: "user", Content: json.RawMessage(`[{"type":"text","text":"hello"}]`), }, }, Tools: []ClaudeTool{ { Name: "get_weather", Description: "Get weather information", InputSchema: map[string]any{"type": "object"}, }, { Type: "web_search_20250305", Name: "web_search", }, }, } body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "gemini-2.5-flash", DefaultTransformOptions()) require.NoError(t, err) var req V1InternalRequest require.NoError(t, json.Unmarshal(body, &req)) require.Len(t, req.Request.Tools, 2) require.Len(t, req.Request.Tools[0].FunctionDeclarations, 1) require.Equal(t, "get_weather", req.Request.Tools[0].FunctionDeclarations[0].Name) require.NotNil(t, req.Request.Tools[1].GoogleSearch) } func TestTransformClaudeToGeminiWithOptions_ClaudeModelKeepsToolsAndValidatedToolConfig(t *testing.T) { claudeReq := &ClaudeRequest{ Model: "claude-sonnet-4-5", Messages: []ClaudeMessage{ { Role: "user", Content: json.RawMessage(`[{"type":"text","text":"read the file"}]`), }, }, Tools: []ClaudeTool{ { Name: "read_file", Description: "Read a file", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "file_path": map[string]any{"type": "string"}, }, }, }, }, } body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) require.NoError(t, err) var req V1InternalRequest require.NoError(t, json.Unmarshal(body, &req)) require.Len(t, req.Request.Tools, 1) require.Len(t, req.Request.Tools[0].FunctionDeclarations, 1) require.Equal(t, "Read", req.Request.Tools[0].FunctionDeclarations[0].Name) require.NotNil(t, req.Request.ToolConfig) require.NotNil(t, req.Request.ToolConfig.FunctionCallingConfig) require.Equal(t, "VALIDATED", req.Request.ToolConfig.FunctionCallingConfig.Mode) } func TestTransformClaudeToGeminiWithOptions_ClaudeModelToolChoiceSpecificTool(t *testing.T) { claudeReq := &ClaudeRequest{ Model: "claude-sonnet-4-5", ToolChoice: json.RawMessage(`{"type":"tool","name":"search_files"}`), Messages: []ClaudeMessage{ { Role: "user", Content: json.RawMessage(`[{"type":"text","text":"find todo"}]`), }, }, Tools: []ClaudeTool{ { Name: "search_files", Description: "Search files", InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "pattern": map[string]any{"type": "string"}, }, }, }, }, } body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) require.NoError(t, err) var req V1InternalRequest require.NoError(t, json.Unmarshal(body, &req)) require.NotNil(t, req.Request.ToolConfig) require.NotNil(t, req.Request.ToolConfig.FunctionCallingConfig) require.Equal(t, "ANY", req.Request.ToolConfig.FunctionCallingConfig.Mode) require.Equal(t, []string{"Grep"}, req.Request.ToolConfig.FunctionCallingConfig.AllowedFunctionNames) } func TestTransformClaudeToGeminiWithOptions_NormalizesInterruptedToolHistory(t *testing.T) { claudeReq := &ClaudeRequest{ Model: "claude-sonnet-4-5", Messages: []ClaudeMessage{ { Role: "assistant", Content: json.RawMessage(`[ {"type":"tool_use","id":"tool-1","name":"Bash","input":{"command":"pwd"}}, {"type":"text","text":"(no content)"} ]`), }, { Role: "user", Content: json.RawMessage(`[{"type":"text","text":"继续"}]`), }, }, } body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions()) require.NoError(t, err) var req V1InternalRequest require.NoError(t, json.Unmarshal(body, &req)) require.Len(t, req.Request.Contents, 3) first := req.Request.Contents[0] require.Equal(t, "model", first.Role) require.Len(t, first.Parts, 1) require.NotNil(t, first.Parts[0].FunctionCall) require.Equal(t, "tool-1", first.Parts[0].FunctionCall.ID) second := req.Request.Contents[1] require.Equal(t, "user", second.Role) require.Len(t, second.Parts, 1) require.NotNil(t, second.Parts[0].FunctionResponse) require.Equal(t, "tool-1", second.Parts[0].FunctionResponse.ID) resultBlocks, ok := second.Parts[0].FunctionResponse.Response["result"].([]any) require.True(t, ok) require.Len(t, resultBlocks, 1) resultBlock, ok := resultBlocks[0].(map[string]any) require.True(t, ok) require.Equal(t, "text", resultBlock["type"]) require.Equal(t, "[tool_result missing; tool execution interrupted]", resultBlock["text"]) third := req.Request.Contents[2] require.Equal(t, "user", third.Role) require.Len(t, third.Parts, 1) require.Equal(t, "继续", third.Parts[0].Text) } func TestNormalizeClaudeMessagesForAntigravity_ReordersThinkingAndSplitsToolResult(t *testing.T) { messages := []ClaudeMessage{ { Role: "assistant", Content: json.RawMessage(`[ {"type":"text","text":"before"}, {"type":"thinking","thinking":"deep thought","signature":"sig-1"}, {"type":"tool_use","id":"tool-2","name":"Bash","input":{"command":"ls"}}, {"type":"text","text":"(no content)"} ]`), }, { Role: "user", Content: json.RawMessage(`[ {"type":"tool_result","tool_use_id":"tool-2","content":[{"type":"text","text":"ok"}]}, {"type":"text","text":"下一步"} ]`), }, } normalized, err := normalizeClaudeMessagesForAntigravity(messages) require.NoError(t, err) require.Len(t, normalized, 3) var assistantBlocks []map[string]any require.NoError(t, json.Unmarshal(normalized[0].Content, &assistantBlocks)) require.Len(t, assistantBlocks, 3) require.Equal(t, "thinking", assistantBlocks[0]["type"]) require.Equal(t, "text", assistantBlocks[1]["type"]) require.Equal(t, "tool_use", assistantBlocks[2]["type"]) var toolResultBlocks []map[string]any require.NoError(t, json.Unmarshal(normalized[1].Content, &toolResultBlocks)) require.Len(t, toolResultBlocks, 1) require.Equal(t, "tool_result", toolResultBlocks[0]["type"]) var userTextBlocks []map[string]any require.NoError(t, json.Unmarshal(normalized[2].Content, &userTextBlocks)) require.Len(t, userTextBlocks, 1) require.Equal(t, "text", userTextBlocks[0]["type"]) require.Equal(t, "下一步", userTextBlocks[0]["text"]) } func TestParseToolResultContent_PreservesStructuredBlocks(t *testing.T) { content := json.RawMessage(`[ {"type":"text","text":"hello"}, {"type":"image","source":{"type":"base64","media_type":"image/png","data":"AAAA"}} ]`) result := parseToolResultContent(content, false) blocks, ok := result.([]map[string]any) require.True(t, ok) require.Len(t, blocks, 1) require.Equal(t, "text", blocks[0]["type"]) require.Equal(t, "hello", blocks[0]["text"]) } func TestBuildTools_CompactsDescriptions(t *testing.T) { longLine := strings.Repeat("schema detail ", 40) result := buildTools([]ClaudeTool{ { Name: "describe", Description: strings.Repeat("tool description\n", 20), InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]any{ "type": "string", "description": longLine, }, }, }, }, }) require.Len(t, result, 1) require.Len(t, result[0].FunctionDeclarations, 1) decl := result[0].FunctionDeclarations[0] require.LessOrEqual(t, len(decl.Description), maxAntigravityToolDescriptionChars+32) props, ok := decl.Parameters["properties"].(map[string]any) require.True(t, ok) query, ok := props["query"].(map[string]any) require.True(t, ok) description, ok := query["description"].(string) require.True(t, ok) require.LessOrEqual(t, len(description), maxAntigravitySchemaDescriptionChars+32) }