Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions .debug/turn-1753378911946-pre-infer.json

Large diffs are not rendered by default.

327 changes: 327 additions & 0 deletions .debug/turn-stream-1753377027751-pre-infer.json

Large diffs are not rendered by default.

384 changes: 384 additions & 0 deletions .debug/turn-stream-1753377052636-pre-infer.json

Large diffs are not rendered by default.

327 changes: 327 additions & 0 deletions .debug/turn-stream-1753378833402-pre-infer.json

Large diffs are not rendered by default.

384 changes: 384 additions & 0 deletions .debug/turn-stream-1753378879013-pre-infer.json

Large diffs are not rendered by default.

327 changes: 327 additions & 0 deletions .debug/turn-stream-1753379449301-pre-infer.json

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions .tmp/issue_comment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
## Root Cause Analysis & Fix Available

I encountered the same errors and tracked down the root cause. The issue is in the message format conversion process in `packages/core/src/core/openaiContentGenerator.ts`.

### Technical Root Cause
Tool results are being serialized with `role: "user"` instead of `role: "tool"` during Gemini-to-OpenAI format conversion. This violates the OpenAI API specification that requires tool responses to have `role: "tool"` with a `tool_call_id`.

### Evidence
I added debug instrumentation that dumps message payloads to `.debug/` files. Here's what shows the bug:

**Current (incorrect) serialization:**
```json
{
"role": "user", // ❌ Wrong - causes the 400 error you're seeing
"parts": [
{
"functionResponse": {
"id": "call_ikfws9zl",
"name": "read_many_files",
"response": {...}
}
}
]
}
```

**Expected (correct) serialization:**
```json
{
"role": "tool", // ✅ Correct
"tool_call_id": "call_ikfws9zl",
"content": "{...stringified response...}"
}
```

### Why This Causes Your 400 Error
The OpenAI API validates that every assistant message with `tool_calls` is followed by corresponding tool messages (with `role: "tool"` and matching `tool_call_id`). When tool responses get labeled as `role: "user"`, the API can't match them up and throws:

```
InternalError.Algo.InvalidParameter: An assistant message with "tool_calls" must be followed by tool messages responding to each "tool_call_id"
```

### Location of Bug
**File:** `packages/core/src/core/openaiContentGenerator.ts`
**Lines:** ~821-858 (fallback role assignment logic)

The issue is in the else-fallback that defaults non-model content to `role: "user"` instead of properly handling `functionResponse` parts.

### Fix Status
I'm preparing a PR with:
1. ✅ Corrected role assignment for tool results
2. ✅ Regression test to prevent recurrence
3. ✅ Debug instrumentation to help diagnose similar issues

The fix ensures only genuine human input gets `role: "user"` while tool results properly get `role: "tool"`.

**Branch:** `fix/tool-role-serialization` (will reference this issue in PR)
15 changes: 15 additions & 0 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,28 @@ export async function main() {
}

let input = config.getQuestion();

// Debug: Log key values for troubleshooting
if (process.env.QC_DUMP_PRE_INFER === '1') {
console.log('🐛 Debug - CLI Flow:');
console.log(' - input:', JSON.stringify(input));
console.log(' - argv.prompt:', JSON.stringify(argv.prompt));
console.log(' - argv.promptInteractive:', JSON.stringify(argv.promptInteractive));
console.log(' - process.stdin.isTTY:', process.stdin.isTTY);
}

const startupWarnings = [
...(await getStartupWarnings()),
...(await getUserStartupWarnings(workspaceRoot)),
];

const shouldBeInteractive =
!!argv.promptInteractive || (process.stdin.isTTY && input?.length === 0);

// Debug: Log interactive decision
if (process.env.QC_DUMP_PRE_INFER === '1') {
console.log(' - shouldBeInteractive:', shouldBeInteractive);
}

// Render UI, passing necessary config values. Check that there is no command line question.
if (shouldBeInteractive) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/nonInteractiveCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export async function runNonInteractive(
}
}
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
currentMessages = [{ role: 'function', parts: toolResponseParts }];
} else {
process.stdout.write('\n'); // Ensure a final newline
return;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ export const useGeminiStream = (
}
}
geminiClient.addHistory({
role: 'user',
role: 'function',
parts: combinedParts,
});
}
Expand Down
279 changes: 279 additions & 0 deletions packages/core/src/core/__tests__/toolRoleSerialization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OpenAIContentGenerator } from '../openaiContentGenerator.js';
import { Config } from '../../config/config.js';

// Mock OpenAI client
vi.mock('openai', () => ({
default: class MockOpenAI {
constructor() {}
chat = {
completions: {
create: vi.fn()
}
};
}
}));

// Mock logger modules
vi.mock('../../telemetry/loggers.js', () => ({
logApiResponse: vi.fn(),
}));

vi.mock('../../utils/openaiLogger.js', () => ({
openaiLogger: {
logInteraction: vi.fn(),
},
}));

describe('Tool Role Serialization', () => {
let generator: OpenAIContentGenerator;
let mockConfig: Config;

beforeEach(() => {
// Mock config
mockConfig = {
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'openai',
enableOpenAILogging: false,
timeout: 120000,
maxRetries: 3,
}),
} as unknown as Config;

// Create generator instance
generator = new OpenAIContentGenerator('test-api-key', 'gpt-4', mockConfig);
});

it('should emit role "tool" for functionResponse parts', () => {
// Access the private method for testing
const convertMethod = (generator as any).convertToOpenAIFormat.bind(generator);

const request = {
contents: [
// Assistant message with tool call (required for tool response to not be orphaned)
{
role: 'model' as const,
parts: [
{
text: 'I will call a function.'
},
{
functionCall: {
id: 'call_test123',
name: 'test_function',
args: { input: 'test' }
}
}
]
},
// Function response
{
role: 'model' as const, // Function responses can have any role, detection is based on parts
parts: [
{
functionResponse: {
id: 'call_test123',
name: 'test_function',
response: { result: 'test data' }
}
}
]
}
]
};

const result = convertMethod(request);

// Should produce assistant message with tool call + tool response
expect(result).toHaveLength(2);

// First message should be assistant with tool call
expect(result[0].role).toBe('assistant');
expect(result[0]).toHaveProperty('tool_calls');

// Second message should be tool response
expect(result[1]).toEqual({
role: 'tool',
tool_call_id: 'call_test123',
content: JSON.stringify({ result: 'test data' })
});
});

it('should not assign role "user" to tool-result content', () => {
const convertMethod = (generator as any).convertToOpenAIFormat.bind(generator);

const request = {
contents: [
// Assistant message with tool calls
{
role: 'model' as const,
parts: [
{
text: 'I need to call a function.'
},
{
functionCall: {
id: 'call_abc123',
name: 'read_file',
args: { filename: 'test.txt' }
}
}
]
},
// Tool response (this should NOT get role: "user")
{
role: 'model' as const, // Role doesn't matter for function responses
parts: [
{
functionResponse: {
id: 'call_abc123',
name: 'read_file',
response: { content: 'file contents' }
}
}
]
}
]
};

const result = convertMethod(request);

// Verify no tool responses have role: "user"
const toolResponses = result.filter((msg: any) =>
'tool_call_id' in msg ||
(msg.role === 'tool')
);

expect(toolResponses).toHaveLength(1);
expect(toolResponses[0].role).toBe('tool');
expect(toolResponses[0]).toHaveProperty('tool_call_id', 'call_abc123');

// Verify no message with tool content has role: "user"
const userMessages = result.filter((msg: any) => msg.role === 'user');
expect(userMessages).toHaveLength(0);
});

it('should preserve genuine user messages with role "user"', () => {
const convertMethod = (generator as any).convertToOpenAIFormat.bind(generator);

const request = {
contents: [{
role: 'user' as const,
parts: [
{
text: 'This is a genuine user message'
}
]
}]
};

const result = convertMethod(request);

expect(result).toHaveLength(1);
expect(result[0]).toEqual({
role: 'user',
content: 'This is a genuine user message'
});
});

it('should handle mixed conversation with tools correctly', () => {
const convertMethod = (generator as any).convertToOpenAIFormat.bind(generator);

const request = {
contents: [
// User question
{
role: 'user' as const,
parts: [{ text: 'Please read test.txt' }]
},
// Assistant with tool call
{
role: 'model' as const,
parts: [
{ text: 'I will read the file for you.' },
{
functionCall: {
id: 'call_read123',
name: 'read_file',
args: { filename: 'test.txt' }
}
}
]
},
// Tool response
{
role: 'model' as const, // Role doesn't matter for function responses
parts: [
{
functionResponse: {
id: 'call_read123',
name: 'read_file',
response: { content: 'Hello world!' }
}
}
]
},
// Assistant final response
{
role: 'model' as const,
parts: [{ text: 'The file contains: Hello world!' }]
}
]
};

const result = convertMethod(request);

expect(result).toHaveLength(4);

// User message
expect(result[0].role).toBe('user');
expect(result[0]).toHaveProperty('content', 'Please read test.txt');

// Assistant with tool call
expect(result[1].role).toBe('assistant');
expect(result[1]).toHaveProperty('tool_calls');

// Tool response (NOT user!)
expect(result[2].role).toBe('tool');
expect(result[2]).toHaveProperty('tool_call_id', 'call_read123');
expect(result[2]).toHaveProperty('content', JSON.stringify({ content: 'Hello world!' }));

// Final assistant response
expect(result[3].role).toBe('assistant');
expect(result[3]).toHaveProperty('content', 'The file contains: Hello world!');

// Critical: No tool responses should have role: "user"
const userMessages = result.filter((msg: any) => msg.role === 'user');
expect(userMessages).toHaveLength(1); // Only the genuine user message
});

it('should handle fallback roles correctly without assigning user to non-user content', () => {
const convertMethod = (generator as any).convertToOpenAIFormat.bind(generator);

const request = {
contents: [
// System-like content that should not get role: "user"
{
role: 'system' as const,
parts: [{ text: 'This is system content' }]
},
// Unknown role content that should not get role: "user"
{
role: 'unknown' as const,
parts: [{ text: 'This is unknown role content' }]
}
]
};

const result = convertMethod(request);

// Both should get role: "system" due to our fix
expect(result).toHaveLength(2);
expect(result[0].role).toBe('system');
expect(result[1].role).toBe('system');

// No messages should have role: "user"
const userMessages = result.filter((msg: any) => msg.role === 'user');
expect(userMessages).toHaveLength(0);
});
});
Loading