diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index fe2c8cde9..76bad5d60 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -308,6 +308,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ role := originalRole if role == "assistant" { role = "model" + } else if role == "system" { + role = "user" } clientContentJSON := []byte(`{"role":"","parts":[]}`) clientContentJSON, _ = sjson.SetBytes(clientContentJSON, "role", role) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go index 017078d43..d843dd948 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go @@ -133,6 +133,53 @@ func TestConvertClaudeRequestToAntigravity_StripsClaudeCodeAttribution(t *testin } } +func TestConvertClaudeRequestToAntigravity_ConvertsMessageSystemRoleToUserContent(t *testing.T) { + inputJSON := []byte(`{ + "model": "gemini-3.5-flash", + "system": [{"type": "text", "text": "Top-level rules"}], + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, + {"role": "system", "content": "String mid-conversation rule"}, + {"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]} + ] + }`) + + output := ConvertClaudeRequestToAntigravity("gemini-3-flash-agent", inputJSON, false) + outputStr := string(output) + + if systemContent := gjson.Get(outputStr, `request.contents.#(role=="system")`); systemContent.Exists() { + t.Fatalf("system role should not be emitted in request.contents: %s", systemContent.Raw) + } + + contents := gjson.Get(outputStr, "request.contents").Array() + if len(contents) != 3 { + t.Fatalf("Expected the user and message-level system turns in request.contents, got %d: %s", len(contents), gjson.Get(outputStr, "request.contents").Raw) + } + if got := contents[0].Get("role").String(); got != "user" { + t.Fatalf("Expected first content role user, got %q", got) + } + if got := contents[1].Get("role").String(); got != "user" { + t.Fatalf("Expected message-level system content to be downgraded to user role, got %q", got) + } + if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" { + t.Fatalf("Unexpected string message-level system content text: %q", got) + } + if got := contents[2].Get("role").String(); got != "user" { + t.Fatalf("Expected array message-level system content to be downgraded to user role, got %q", got) + } + if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" { + t.Fatalf("Unexpected array message-level system content text: %q", got) + } + + parts := gjson.Get(outputStr, "request.systemInstruction.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.Get(outputStr, "request.systemInstruction.parts").Raw) + } + if got := parts[0].Get("text").String(); got != "Top-level rules" { + t.Fatalf("Unexpected first system part: %q", got) + } +} + func testNonAnthropicRawSignature(t *testing.T) string { t.Helper() diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index b21936a95..80e942118 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -77,6 +77,8 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] role := roleResult.String() if role == "assistant" { role = "model" + } else if role == "system" { + role = "user" } contentJSON := []byte(`{"role":"","parts":[]}`) diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go index ff0cea657..50a491fd9 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request_test.go @@ -61,3 +61,49 @@ func TestConvertClaudeRequestToCLI_StripsClaudeCodeAttribution(t *testing.T) { t.Fatalf("Unexpected system part: %q", got) } } + +func TestConvertClaudeRequestToCLI_ConvertsMessageSystemRoleToUserContent(t *testing.T) { + inputJSON := []byte(`{ + "model": "gemini-3-flash-preview", + "system": [{"type": "text", "text": "Top-level rules"}], + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, + {"role": "system", "content": "String mid-conversation rule"}, + {"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]} + ] + }`) + + output := ConvertClaudeRequestToCLI("gemini-3-flash-preview", inputJSON, false) + + if systemContent := gjson.GetBytes(output, `request.contents.#(role=="system")`); systemContent.Exists() { + t.Fatalf("system role should not be emitted in request.contents: %s", systemContent.Raw) + } + + contents := gjson.GetBytes(output, "request.contents").Array() + if len(contents) != 3 { + t.Fatalf("Expected the user and message-level system turns in request.contents, got %d: %s", len(contents), gjson.GetBytes(output, "request.contents").Raw) + } + if got := contents[0].Get("role").String(); got != "user" { + t.Fatalf("Expected first content role user, got %q", got) + } + if got := contents[1].Get("role").String(); got != "user" { + t.Fatalf("Expected message-level string system content to be downgraded to user role, got %q", got) + } + if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" { + t.Fatalf("Unexpected string message-level system content text: %q", got) + } + if got := contents[2].Get("role").String(); got != "user" { + t.Fatalf("Expected message-level array system content to be downgraded to user role, got %q", got) + } + if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" { + t.Fatalf("Unexpected array message-level system content text: %q", got) + } + + parts := gjson.GetBytes(output, "request.systemInstruction.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.GetBytes(output, "request.systemInstruction.parts").Raw) + } + if got := parts[0].Get("text").String(); got != "Top-level rules" { + t.Fatalf("Unexpected first system part: %q", got) + } +} diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index 128dac6e0..3347eaec1 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -71,6 +71,8 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) role := roleResult.String() if role == "assistant" { role = "model" + } else if role == "system" { + role = "user" } contentJSON := []byte(`{"role":"","parts":[]}`) diff --git a/internal/translator/gemini/claude/gemini_claude_request_test.go b/internal/translator/gemini/claude/gemini_claude_request_test.go index 01bed5f17..81b06214e 100644 --- a/internal/translator/gemini/claude/gemini_claude_request_test.go +++ b/internal/translator/gemini/claude/gemini_claude_request_test.go @@ -107,6 +107,52 @@ func TestConvertClaudeRequestToGemini_StripsClaudeCodeAttribution(t *testing.T) } } +func TestConvertClaudeRequestToGemini_ConvertsMessageSystemRoleToUserContent(t *testing.T) { + inputJSON := []byte(`{ + "model": "gemini-3-flash-preview", + "system": [{"type": "text", "text": "Top-level rules"}], + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "Hello"}]}, + {"role": "system", "content": "String mid-conversation rule"}, + {"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]} + ] + }`) + + output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false) + + if systemContent := gjson.GetBytes(output, `contents.#(role=="system")`); systemContent.Exists() { + t.Fatalf("system role should not be emitted in contents: %s", systemContent.Raw) + } + + contents := gjson.GetBytes(output, "contents").Array() + if len(contents) != 3 { + t.Fatalf("Expected the user and message-level system turns in contents, got %d: %s", len(contents), gjson.GetBytes(output, "contents").Raw) + } + if got := contents[0].Get("role").String(); got != "user" { + t.Fatalf("Expected first content role user, got %q", got) + } + if got := contents[1].Get("role").String(); got != "user" { + t.Fatalf("Expected message-level string system content to be downgraded to user role, got %q", got) + } + if got := contents[1].Get("parts.0.text").String(); got != "String mid-conversation rule" { + t.Fatalf("Unexpected string message-level system content text: %q", got) + } + if got := contents[2].Get("role").String(); got != "user" { + t.Fatalf("Expected message-level array system content to be downgraded to user role, got %q", got) + } + if got := contents[2].Get("parts.0.text").String(); got != "Array mid-conversation rule" { + t.Fatalf("Unexpected array message-level system content text: %q", got) + } + + parts := gjson.GetBytes(output, "system_instruction.parts").Array() + if len(parts) != 1 { + t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.GetBytes(output, "system_instruction.parts").Raw) + } + if got := parts[0].Get("text").String(); got != "Top-level rules" { + t.Fatalf("Unexpected first system part: %q", got) + } +} + func TestConvertClaudeRequestToGemini_SkipsEmptyTextParts(t *testing.T) { inputJSON := []byte(`{ "model": "claude-3-5-sonnet",