mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 16:30:27 +00:00
Implement complete Mistral AI support including message formatting, tool calls with ID validation (9-char alphanumeric), streaming, file attachments, and conversation naming service. Add extensive cross-provider integration tests covering Mistral interactions with Claude, OpenAI, and Gemini, including round-trip conversions, ID validation, and mixed conversation handling. Update all test fixtures to include Mistral API keys.
2326 lines
102 KiB
TypeScript
2326 lines
102 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { Gemini } from '../../AIClasses/Gemini/Gemini';
|
|
import { Claude } from '../../AIClasses/Claude/Claude';
|
|
import { OpenAI } from '../../AIClasses/OpenAI/OpenAI';
|
|
import { Mistral } from '../../AIClasses/Mistral/Mistral';
|
|
import { ConversationContent } from '../../Conversations/ConversationContent';
|
|
import { Role } from '../../Enums/Role';
|
|
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
|
|
import { Services } from '../../Services/Services';
|
|
import { AbortService } from '../../Services/AbortService';
|
|
import { AIProvider } from '../../Enums/ApiProvider';
|
|
|
|
/**
|
|
* Cross-Provider Integration Tests for Thought Signature Support
|
|
*
|
|
* These tests verify that conversations can seamlessly switch between AI providers
|
|
* while maintaining context through the thoughtSignature feature. The key scenarios:
|
|
*
|
|
* 1. Claude/OpenAI → Gemini: Function calls without thought signatures are converted
|
|
* to legacy text format to preserve context without breaking Gemini 3 Pro.
|
|
*
|
|
* 2. Gemini → Claude/OpenAI: Function calls with thought signatures can be sent to
|
|
* other providers (they'll ignore the signature but maintain the function call).
|
|
*
|
|
* 3. Mixed conversations: Ensure that a conversation history with mixed provider
|
|
* function calls is handled gracefully.
|
|
*/
|
|
describe('Cross-Provider Integration - Thought Signature Support', () => {
|
|
let gemini: Gemini;
|
|
let mockStreamingService: any;
|
|
let mockPrompt: any;
|
|
let mockPlugin: any;
|
|
let mockSettingsService: any;
|
|
let abortService: AbortService;
|
|
|
|
beforeEach(() => {
|
|
// Mock IPrompt
|
|
mockPrompt = {
|
|
systemInstruction: vi.fn().mockReturnValue('System instruction'),
|
|
userInstruction: vi.fn().mockResolvedValue('User instruction')
|
|
};
|
|
RegisterSingleton(Services.IPrompt, mockPrompt);
|
|
|
|
// Mock VaultkeeperAIPlugin
|
|
mockPlugin = {};
|
|
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin);
|
|
|
|
// Mock SettingsService
|
|
mockSettingsService = {
|
|
settings: {
|
|
model: 'gemini-2.5-flash-lite',
|
|
apiKeys: {
|
|
claude: 'test-claude-key',
|
|
openai: 'test-openai-key',
|
|
gemini: 'test-gemini-key', mistral: 'test-mistral-key'
|
|
}
|
|
},
|
|
getApiKeyForProvider: vi.fn((provider: AIProvider) => {
|
|
if (provider === AIProvider.Claude) return 'test-claude-key';
|
|
if (provider === AIProvider.OpenAI) return 'test-openai-key';
|
|
if (provider === AIProvider.Gemini) return 'test-gemini-key';
|
|
return '';
|
|
}),
|
|
getApiKeyForCurrentModel: vi.fn(() => 'test-gemini-key')
|
|
};
|
|
RegisterSingleton(Services.SettingsService, mockSettingsService);
|
|
|
|
// Create real AbortService instance
|
|
abortService = new AbortService();
|
|
RegisterSingleton(Services.AbortService, abortService);
|
|
|
|
// Mock StreamingService
|
|
mockStreamingService = {
|
|
streamRequest: vi.fn()
|
|
};
|
|
RegisterSingleton(Services.StreamingService, mockStreamingService);
|
|
|
|
// Mock IAIFileService
|
|
const mockFileService = {
|
|
refreshCache: vi.fn().mockResolvedValue(undefined),
|
|
listFiles: vi.fn().mockReturnValue([]),
|
|
uploadFile: vi.fn().mockResolvedValue(undefined),
|
|
deleteFile: vi.fn().mockResolvedValue(undefined),
|
|
deleteFiles: vi.fn().mockResolvedValue(undefined)
|
|
};
|
|
RegisterSingleton(Services.IAIFileService, mockFileService);
|
|
|
|
gemini = new Gemini();
|
|
});
|
|
|
|
afterEach(() => {
|
|
DeregisterAllServices();
|
|
});
|
|
|
|
describe('Claude/OpenAI → Gemini Switching', () => {
|
|
it('should convert Claude function call (no thoughtSignature) to legacy text format', async () => {
|
|
// Simulate a conversation started with Claude that made a function call
|
|
const claudeToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_abc123', // AIToolCall.toConversationString() includes id in JSON
|
|
name: 'search_vault_files',
|
|
args: { query: 'meeting notes' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_abc123'
|
|
});
|
|
|
|
const result = await (gemini as any).extractContents([claudeToolCall]);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].parts[0]).toHaveProperty('text');
|
|
expect(result[0].parts[0].text).toContain('<!-- Historical tool call');
|
|
expect(result[0].parts[0].text).toContain('"name": "search_vault_files"');
|
|
expect(result[0].parts[0].text).toContain('"args": {');
|
|
expect(result[0].parts[0].text).toContain(' "query": "meeting notes"');
|
|
});
|
|
|
|
it('should convert OpenAI function call (no thoughtSignature) to legacy text format', async () => {
|
|
// Simulate a conversation started with OpenAI
|
|
const openaiToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_xyz789', // AIToolCall.toConversationString() includes id in JSON
|
|
name: 'read_file',
|
|
args: { path: 'project.md' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_xyz789'
|
|
});
|
|
|
|
const result = await (gemini as any).extractContents([openaiToolCall]);
|
|
|
|
expect(result[0].parts[0].text).toContain('<!-- Historical tool call');
|
|
expect(result[0].parts[0].text).toContain('"name": "read_file"');
|
|
expect(result[0].parts[0].text).toContain('"args": {');
|
|
expect(result[0].parts[0].text).toContain(' "path": "project.md"');
|
|
});
|
|
|
|
it('should convert function response without id to legacy text format', async () => {
|
|
// Function responses from Claude/OpenAI may not have the id field in content
|
|
const toolCallContent = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_cross1',
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
toolId: 'call_cross1'
|
|
});
|
|
|
|
const responseContent = JSON.stringify({
|
|
functionResponse: {
|
|
name: 'search_vault_files',
|
|
response: ['note1.md', 'note2.md']
|
|
}
|
|
});
|
|
|
|
const claudeResponse = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_cross1'
|
|
});
|
|
const result = await (gemini as any).extractContents([toolCallContent, claudeResponse]);
|
|
|
|
expect(result[1].parts[0]).toHaveProperty('text');
|
|
expect(result[1].parts[0].text).toContain('<!-- Historical tool result');
|
|
expect(result[1].parts[0].text).toContain('"name": "search_vault_files"');
|
|
expect(result[1].parts[0].text).toContain('"response": [');
|
|
expect(result[1].parts[0].text).toContain(' "note1.md"');
|
|
expect(result[1].parts[0].text).toContain(' "note2.md"');
|
|
});
|
|
});
|
|
|
|
describe('Gemini → Claude/OpenAI Switching', () => {
|
|
it('should handle Gemini function call with thoughtSignature when switching providers', async () => {
|
|
// This test verifies the data structure is preserved
|
|
// In actual usage, Claude/OpenAI would ignore the thoughtSignature field
|
|
const geminiToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
thoughtSignature: 'geminiThoughtSignature=='
|
|
});
|
|
|
|
// Verify the signature is stored in ConversationContent
|
|
expect(geminiToolCall.thoughtSignature).toBe('geminiThoughtSignature==');
|
|
|
|
// When this conversation is sent to Claude/OpenAI, they'll see the function call
|
|
// but ignore the thoughtSignature field (which is fine)
|
|
const toolCallData = JSON.parse(geminiToolCall.toolCall!);
|
|
expect(toolCallData.toolCall.name).toBe('search_vault_files');
|
|
expect(toolCallData.toolCall.args).toEqual({ query: 'test' });
|
|
});
|
|
});
|
|
|
|
describe('Mixed Conversation History', () => {
|
|
it('should handle conversation with function calls from multiple providers', async () => {
|
|
const conversation = [
|
|
// User starts conversation
|
|
new ConversationContent({ role: Role.User, content: 'Search for notes about the project', displayContent: 'Search for notes about the project' }),
|
|
|
|
// Claude makes a function call (no signature)
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_claude_123', // AIToolCall.toConversationString() includes id
|
|
name: 'search_vault_files',
|
|
args: { query: 'project' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_claude_123'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
// Function response
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
functionResponse: {
|
|
name: 'search_vault_files',
|
|
response: ['project.md']
|
|
}
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_claude_123'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
// Claude responds
|
|
new ConversationContent({ role: Role.Assistant, content: 'I found project.md' }),
|
|
|
|
// User asks follow-up
|
|
new ConversationContent({ role: Role.User, content: 'Read that file', displayContent: 'Read that file' }),
|
|
|
|
// Now Gemini makes a function call (with signature)
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
name: 'read_file',
|
|
args: { path: 'project.md' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
thoughtSignature: 'geminiSignature123==',
|
|
toolId: 'gemini-response-1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
// Function response
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'gemini-response-1',
|
|
functionResponse: {
|
|
name: 'read_file',
|
|
response: { content: 'File contents' }
|
|
}
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'gemini-response-1'
|
|
});
|
|
return content;
|
|
})()
|
|
];
|
|
|
|
const result = await (gemini as any).extractContents(conversation);
|
|
|
|
// Should have all messages, with appropriate formatting
|
|
expect(result.length).toBeGreaterThan(0);
|
|
|
|
// Find the Claude function call - should be legacy text
|
|
const claudeToolCall = result.find((r: any) =>
|
|
r.parts[0]?.text?.includes('<!-- Historical tool call') &&
|
|
r.parts[0]?.text?.includes('"name": "search_vault_files"')
|
|
);
|
|
expect(claudeToolCall).toBeDefined();
|
|
|
|
// Find the Gemini function call - should have proper format with signature
|
|
const geminiToolCall = result.find((r: any) =>
|
|
r.parts[0]?.functionCall?.name === 'read_file' &&
|
|
r.parts[0]?.thoughtSignature === 'geminiSignature123=='
|
|
);
|
|
expect(geminiToolCall).toBeDefined();
|
|
});
|
|
|
|
it('should handle conversation switching from provider without signatures to Gemini', async () => {
|
|
// Start with OpenAI conversation
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'List files', displayContent: 'List files' }),
|
|
|
|
// OpenAI function call (no signature)
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_openai_456', // AIToolCall.toConversationString() includes id
|
|
name: 'list_files',
|
|
args: {}
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_openai_456'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
// Response (no id)
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
functionResponse: {
|
|
name: 'list_files',
|
|
response: ['file1.md', 'file2.md']
|
|
}
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_openai_456'
|
|
});
|
|
return content;
|
|
})()
|
|
];
|
|
|
|
const result = await (gemini as any).extractContents(conversation);
|
|
|
|
// Verify OpenAI function call was converted to legacy format
|
|
expect(result).toHaveLength(3);
|
|
expect(result[1].parts[0].text).toContain('<!-- Historical tool call');
|
|
expect(result[1].parts[0].text).toContain('"name": "list_files"');
|
|
expect(result[2].parts[0].text).toContain('<!-- Historical tool result');
|
|
expect(result[2].parts[0].text).toContain('"name": "list_files"');
|
|
});
|
|
|
|
it('should preserve context when alternating between providers with function calls', async () => {
|
|
// Simulate a conversation that switches providers multiple times
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Initial query', displayContent: 'Initial query' }),
|
|
|
|
// Provider 1 (no signature) - function call + response
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call-1', // AIToolCall.toConversationString() includes id
|
|
name: 'func1',
|
|
args: { a: 1 }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call-1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
functionResponse: { name: 'func1', response: 'result1' }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call-1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Response 1' }),
|
|
|
|
// Provider 2 (with signature) - function call + response
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: { name: 'func2', args: { b: 2 } }
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
thoughtSignature: 'sig2==',
|
|
toolId: 'resp-2'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'resp-2',
|
|
functionResponse: { name: 'func2', response: 'result2' }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'resp-2'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Response 2' })
|
|
];
|
|
|
|
const result = await (gemini as any).extractContents(conversation);
|
|
|
|
// All content should be present
|
|
expect(result.length).toBe(7);
|
|
|
|
// First function call should be legacy format
|
|
expect(result[1].parts[0]).toHaveProperty('text');
|
|
expect(result[1].parts[0].text).toContain('<!-- Historical tool call');
|
|
expect(result[1].parts[0].text).toContain('"name": "func1"');
|
|
|
|
// First response should be legacy format
|
|
expect(result[2].parts[0]).toHaveProperty('text');
|
|
expect(result[2].parts[0].text).toContain('<!-- Historical tool result');
|
|
expect(result[2].parts[0].text).toContain('"name": "func1"');
|
|
|
|
// Second function call should have proper format
|
|
expect(result[4].parts[0]).toHaveProperty('functionCall');
|
|
expect(result[4].parts[0]).toHaveProperty('thoughtSignature');
|
|
expect(result[4].parts[0].thoughtSignature).toBe('sig2==');
|
|
|
|
// Second response should have proper format
|
|
expect(result[5].parts[0]).toHaveProperty('functionResponse');
|
|
});
|
|
});
|
|
|
|
describe('Claude ↔ OpenAI Switching', () => {
|
|
it('should handle Claude function call when switching to OpenAI', async () => {
|
|
// Simulate a conversation started with Claude that made a function call
|
|
const claudeToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'toolu_abc123', // Claude's tool_use ID
|
|
name: 'search_vault_files',
|
|
args: { query: 'meeting notes' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'toolu_abc123'
|
|
});
|
|
|
|
const claudeResponse = (() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'toolu_abc123',
|
|
functionResponse: {
|
|
name: 'search_vault_files',
|
|
response: ['meeting1.md', 'meeting2.md']
|
|
}
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'toolu_abc123'
|
|
});
|
|
return content;
|
|
})();
|
|
|
|
// Now switch to OpenAI - it should read Claude's function call
|
|
const openai = new OpenAI();
|
|
const result = await (openai as any).extractContents([claudeToolCall, claudeResponse]);
|
|
|
|
// OpenAI should convert Claude's function call to its Responses API format
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toEqual({
|
|
type: 'function_call',
|
|
call_id: 'toolu_abc123',
|
|
name: 'search_vault_files',
|
|
arguments: '{"query":"meeting notes"}'
|
|
});
|
|
expect(result[1]).toEqual({
|
|
type: 'function_call_output',
|
|
call_id: 'toolu_abc123',
|
|
output: '["meeting1.md","meeting2.md"]'
|
|
});
|
|
});
|
|
|
|
it('should handle OpenAI function call when switching to Claude', async () => {
|
|
// Simulate a conversation started with OpenAI
|
|
const openaiToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_xyz789', // OpenAI's call_id
|
|
name: 'read_file',
|
|
args: { path: 'project.md' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_xyz789'
|
|
});
|
|
|
|
const openaiResponse = (() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'call_xyz789',
|
|
functionResponse: {
|
|
name: 'read_file',
|
|
response: { content: 'Project details here' }
|
|
}
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_xyz789'
|
|
});
|
|
return content;
|
|
})();
|
|
|
|
// Now switch to Claude - it should read OpenAI's function call
|
|
const claude = new Claude();
|
|
const result = await (claude as any).extractContents([openaiToolCall, openaiResponse]);
|
|
|
|
// Claude should convert OpenAI's function call to its tool_use format
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].content[0]).toEqual({
|
|
type: 'tool_use',
|
|
id: 'call_xyz789',
|
|
name: 'read_file',
|
|
input: { path: 'project.md' }
|
|
});
|
|
expect(result[1].content[0]).toEqual({
|
|
type: 'tool_result',
|
|
tool_use_id: 'call_xyz789',
|
|
content: '{"content":"Project details here"}'
|
|
});
|
|
});
|
|
|
|
it('should handle conversation alternating between Claude and OpenAI', async () => {
|
|
// Complex scenario: Claude → OpenAI → Claude → OpenAI
|
|
const conversation = [
|
|
// User starts
|
|
new ConversationContent({ role: Role.User, content: 'Search for files', displayContent: 'Search for files' }),
|
|
|
|
// Claude makes a function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'toolu_1',
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'toolu_1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
// Function response
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'toolu_1',
|
|
functionResponse: { name: 'search_vault_files', response: ['file1.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'toolu_1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
// Claude responds
|
|
new ConversationContent({ role: Role.Assistant, content: 'Found file1.md' }),
|
|
|
|
// User continues
|
|
new ConversationContent({ role: Role.User, content: 'Read it', displayContent: 'Read it' }),
|
|
|
|
// OpenAI makes a function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_2',
|
|
name: 'read_file',
|
|
args: { path: 'file1.md' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_2'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
// Function response
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'call_2',
|
|
functionResponse: { name: 'read_file', response: { content: 'File contents' } }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_2'
|
|
});
|
|
return content;
|
|
})()
|
|
];
|
|
|
|
// Test that both Claude and OpenAI can read this mixed conversation
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents(conversation);
|
|
|
|
expect(claudeResult.length).toBeGreaterThan(0);
|
|
// Both function calls should be present with their tool_use format
|
|
const claudeToolUses = claudeResult.filter((r: any) =>
|
|
r.content.some((c: any) => c.type === 'tool_use')
|
|
);
|
|
expect(claudeToolUses).toHaveLength(2);
|
|
|
|
const openai = new OpenAI();
|
|
const openaiResult = await (openai as any).extractContents(conversation);
|
|
|
|
expect(openaiResult.length).toBeGreaterThan(0);
|
|
// Both function calls should be present in Responses API format
|
|
const openaiToolCalls = openaiResult.filter((r: any) => r.type === 'function_call');
|
|
expect(openaiToolCalls).toHaveLength(2);
|
|
});
|
|
|
|
it('should handle function call with toolId but empty string', async () => {
|
|
// Both Claude and OpenAI should treat empty string toolId as missing
|
|
const emptyToolIdCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: '', // Empty string
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: ''
|
|
});
|
|
|
|
// Claude should convert to legacy text format
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents([emptyToolIdCall]);
|
|
expect(claudeResult[0].content[0].type).toBe('text');
|
|
expect(claudeResult[0].content[0].text).toContain('<!-- Historical tool call');
|
|
expect(claudeResult[0].content[0].text).toContain('"name": "search_vault_files"');
|
|
|
|
// OpenAI should also handle it gracefully (fall back to regular message)
|
|
const openai = new OpenAI();
|
|
const openaiResult = await (openai as any).extractContents([emptyToolIdCall]);
|
|
// Should either convert to text or handle the empty ID
|
|
expect(openaiResult).toHaveLength(1);
|
|
});
|
|
|
|
it('should handle whitespace-only toolId same as empty string', async () => {
|
|
const whitespaceToolIdCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: ' ', // Whitespace only
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: ' '
|
|
});
|
|
|
|
// Claude should trim and treat as empty → legacy format
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents([whitespaceToolIdCall]);
|
|
expect(claudeResult[0].content[0].type).toBe('text');
|
|
expect(claudeResult[0].content[0].text).toContain('<!-- Historical tool call');
|
|
expect(claudeResult[0].content[0].text).toContain('"name": "search_vault_files"');
|
|
});
|
|
});
|
|
|
|
describe('OpenAI → Claude/Gemini Switching', () => {
|
|
it('should handle OpenAI function call when switching to Claude', async () => {
|
|
// Detailed test already covered in "Claude ↔ OpenAI Switching"
|
|
// This test focuses on verifying Claude properly interprets OpenAI's call_id
|
|
const openaiToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: 'Let me read that file for you',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_openai_123',
|
|
name: 'read_file',
|
|
args: { path: 'notes.md' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_openai_123'
|
|
});
|
|
|
|
const claude = new Claude();
|
|
const result = await (claude as any).extractContents([openaiToolCall]);
|
|
|
|
// Claude should preserve the OpenAI call_id in its tool_use format
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].content).toHaveLength(2); // text + tool_use
|
|
expect(result[0].content[0].type).toBe('text');
|
|
expect(result[0].content[0].text).toBe('Let me read that file for you');
|
|
expect(result[0].content[1]).toEqual({
|
|
type: 'tool_use',
|
|
id: 'call_openai_123',
|
|
name: 'read_file',
|
|
input: { path: 'notes.md' }
|
|
});
|
|
});
|
|
|
|
it('should convert OpenAI function call (no thoughtSignature) to Gemini legacy format', async () => {
|
|
// OpenAI function call should be converted to legacy text when sent to Gemini
|
|
const openaiToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_abc',
|
|
name: 'search_vault_files',
|
|
args: { query: 'project notes' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_abc'
|
|
});
|
|
|
|
const result = await (gemini as any).extractContents([openaiToolCall]);
|
|
|
|
// Gemini should convert to legacy format since there's no thoughtSignature
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].parts[0]).toHaveProperty('text');
|
|
expect(result[0].parts[0].text).toContain('<!-- Historical tool call');
|
|
expect(result[0].parts[0].text).toContain('"name": "search_vault_files"');
|
|
expect(result[0].parts[0].text).toContain('"args": {');
|
|
expect(result[0].parts[0].text).toContain(' "query": "project notes"');
|
|
});
|
|
|
|
it('should handle OpenAI function response when switching to Gemini', async () => {
|
|
// OpenAI response with ID should work with Gemini
|
|
const openaiCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_123',
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
toolId: 'call_123'
|
|
});
|
|
|
|
const openaiResponse = (() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'call_123',
|
|
functionResponse: {
|
|
name: 'search_vault_files',
|
|
response: ['file1.md', 'file2.md']
|
|
}
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_123'
|
|
});
|
|
return content;
|
|
})();
|
|
|
|
const result = await (gemini as any).extractContents([openaiCall, openaiResponse]);
|
|
|
|
// Gemini should accept the response with ID
|
|
expect(result).toHaveLength(2);
|
|
expect(result[1].parts[0]).toEqual({
|
|
functionResponse: {
|
|
name: 'search_vault_files',
|
|
response: ['file1.md', 'file2.md']
|
|
}
|
|
});
|
|
});
|
|
|
|
it('should convert Gemini function call (no id) to OpenAI legacy format - REGRESSION', async () => {
|
|
// REGRESSION TEST: Bug discovered where Gemini → OpenAI switching failed
|
|
// because OpenAI tried to use undefined call_id
|
|
// Gemini function call with thoughtSignature but no id
|
|
const geminiToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
thoughtSignature: 'gemini_signature_123==',
|
|
toolId: 'gemini_tool_1'
|
|
});
|
|
|
|
const geminiResponse = (() => {
|
|
const responseContent = JSON.stringify({
|
|
// Gemini response without id field
|
|
functionResponse: {
|
|
name: 'search_vault_files',
|
|
response: ['file1.md', 'file2.md']
|
|
}
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'gemini_tool_1'
|
|
});
|
|
return content;
|
|
})();
|
|
|
|
// OpenAI should convert to legacy text format (not try to use undefined call_id)
|
|
const openai = new OpenAI();
|
|
const result = await (openai as any).extractContents([geminiToolCall, geminiResponse]);
|
|
|
|
// Should have 2 items (both converted to messages)
|
|
expect(result).toHaveLength(2);
|
|
|
|
// First should be a text message with legacy format (NOT a function_call with undefined call_id)
|
|
expect(result[0]).toHaveProperty('role');
|
|
expect(result[0]).toHaveProperty('content');
|
|
expect(result[0].content).toContain('<!-- Historical tool call');
|
|
expect(result[0].content).toContain('"name": "search_vault_files"');
|
|
expect(result[0]).not.toHaveProperty('type'); // Should be message, not function_call
|
|
expect(result[0]).not.toHaveProperty('call_id'); // Should NOT have call_id field
|
|
|
|
// Second should be a text message with legacy format
|
|
expect(result[1]).toHaveProperty('role');
|
|
expect(result[1]).toHaveProperty('content');
|
|
expect(result[1].content).toContain('<!-- Historical tool result');
|
|
expect(result[1].content).toContain('"name": "search_vault_files"');
|
|
});
|
|
|
|
it('should handle OpenAI → Gemini → Claude round-trip', async () => {
|
|
// Test that function call survives multiple provider switches
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Search files', displayContent: 'Search files' }),
|
|
|
|
// OpenAI creates function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_xyz',
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_xyz'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
// Response
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'call_xyz',
|
|
functionResponse: { name: 'search_vault_files', response: ['file.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_xyz'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Found file.md' })
|
|
];
|
|
|
|
// Gemini reads OpenAI conversation - function call becomes legacy
|
|
const geminiResult = await (gemini as any).extractContents(conversation);
|
|
const geminiLegacyCall = geminiResult.find((r: any) =>
|
|
r.parts[0]?.text?.includes('<!-- Historical tool call')
|
|
);
|
|
expect(geminiLegacyCall).toBeDefined();
|
|
|
|
// Claude reads the same conversation - should work
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents(conversation);
|
|
expect(claudeResult.length).toBeGreaterThan(0);
|
|
|
|
// OpenAI can re-read its own conversation
|
|
const openai = new OpenAI();
|
|
const openaiResult = await (openai as any).extractContents(conversation);
|
|
expect(openaiResult.length).toBeGreaterThan(0);
|
|
const toolCall = openaiResult.find((r: any) => r.type === 'function_call');
|
|
expect(toolCall).toBeDefined();
|
|
expect(toolCall.call_id).toBe('call_xyz');
|
|
});
|
|
});
|
|
|
|
describe('Three-Way Provider Switching', () => {
|
|
it('should handle Claude → OpenAI → Gemini conversation', async () => {
|
|
// Complex three-way conversation: toolId → toolId → thoughtSignature transition
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Start search', displayContent: 'Start search' }),
|
|
|
|
// Claude makes a function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'toolu_1',
|
|
name: 'search_vault_files',
|
|
args: { query: 'notes' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'toolu_1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'toolu_1',
|
|
functionResponse: { name: 'search_vault_files', response: ['note1.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'toolu_1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Found note1.md' }),
|
|
new ConversationContent({ role: Role.User, content: 'Read it', displayContent: 'Read it' }),
|
|
|
|
// OpenAI makes a function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_2',
|
|
name: 'read_file',
|
|
args: { path: 'note1.md' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_2'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'call_2',
|
|
functionResponse: { name: 'read_file', response: { content: 'File contents' } }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_2'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Here is the content' }),
|
|
new ConversationContent({ role: Role.User, content: 'Summarize it', displayContent: 'Summarize it' })
|
|
];
|
|
|
|
// Gemini should be able to read this conversation
|
|
// Both Claude and OpenAI calls should convert to legacy format (no thoughtSignature)
|
|
const geminiResult = await (gemini as any).extractContents(conversation);
|
|
expect(geminiResult.length).toBeGreaterThan(0);
|
|
|
|
const legacyCalls = geminiResult.filter((r: any) =>
|
|
r.parts[0]?.text?.includes('<!-- Historical tool call')
|
|
);
|
|
expect(legacyCalls.length).toBe(2); // Both Claude and OpenAI calls
|
|
|
|
// Claude should be able to read the full conversation
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents(conversation);
|
|
expect(claudeResult.length).toBeGreaterThan(0);
|
|
|
|
// OpenAI should be able to read the full conversation
|
|
const openai = new OpenAI();
|
|
const openaiResult = await (openai as any).extractContents(conversation);
|
|
expect(openaiResult.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle Gemini → Claude → OpenAI conversation', async () => {
|
|
// Three-way: thoughtSignature → toolId → toolId transition
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Search files', displayContent: 'Search files' }),
|
|
|
|
// Gemini makes a function call WITH thoughtSignature
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
name: 'search_vault_files',
|
|
args: { query: 'project' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
thoughtSignature: 'gemini_signature_1==',
|
|
toolId: 'resp_1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'resp_1',
|
|
functionResponse: { name: 'search_vault_files', response: ['project.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'resp_1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Found project.md' }),
|
|
new ConversationContent({ role: Role.User, content: 'Read it', displayContent: 'Read it' }),
|
|
|
|
// Claude makes a function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'toolu_2',
|
|
name: 'read_file',
|
|
args: { path: 'project.md' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'toolu_2'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'toolu_2',
|
|
functionResponse: { name: 'read_file', response: { content: 'Project info' } }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'toolu_2'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Project details...' }),
|
|
new ConversationContent({ role: Role.User, content: 'Write summary', displayContent: 'Write summary' }),
|
|
|
|
// OpenAI makes a function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_3',
|
|
name: 'write_file',
|
|
args: { path: 'summary.md', content: 'Summary here' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_3'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'call_3',
|
|
functionResponse: { name: 'write_file', response: { success: true } }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_3'
|
|
});
|
|
return content;
|
|
})()
|
|
];
|
|
|
|
// All three providers should be able to read this conversation
|
|
const geminiResult = await (gemini as any).extractContents(conversation);
|
|
expect(geminiResult.length).toBeGreaterThan(0);
|
|
|
|
const claudeResult = await (new Claude() as any).extractContents(conversation);
|
|
expect(claudeResult.length).toBeGreaterThan(0);
|
|
|
|
const openaiResult = await (new OpenAI() as any).extractContents(conversation);
|
|
expect(openaiResult.length).toBeGreaterThan(0);
|
|
|
|
// Verify all three function calls are present in each provider's view
|
|
const geminiToolCalls = geminiResult.filter((r: any) =>
|
|
r.parts[0]?.functionCall || r.parts[0]?.text?.includes('<!-- Historical tool call')
|
|
);
|
|
expect(geminiToolCalls.length).toBe(3);
|
|
|
|
const claudeToolUses = claudeResult.filter((r: any) =>
|
|
r.content.some((c: any) => c.type === 'tool_use' || c.text?.includes('<!-- Historical tool call'))
|
|
);
|
|
expect(claudeToolUses.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle round-trip: Claude → Gemini → Claude', async () => {
|
|
// Ensure conversation data survives round trip through different provider
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Test', displayContent: 'Test' }),
|
|
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'toolu_round',
|
|
name: 'search_vault_files',
|
|
args: { query: 'test', limit: 5 }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'toolu_round'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'toolu_round',
|
|
functionResponse: { name: 'search_vault_files', response: ['a.md', 'b.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'toolu_round'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Found 2 files' })
|
|
];
|
|
|
|
// Claude reads it initially
|
|
const claude1 = new Claude();
|
|
const claudeResult1 = await (claude1 as any).extractContents(conversation);
|
|
expect(claudeResult1.length).toBeGreaterThan(0);
|
|
|
|
// Verify function call is preserved
|
|
const claudeCall1 = claudeResult1.find((r: any) =>
|
|
r.content.some((c: any) => c.type === 'tool_use')
|
|
);
|
|
expect(claudeCall1).toBeDefined();
|
|
expect(claudeCall1.content[0].id).toBe('toolu_round');
|
|
expect(claudeCall1.content[0].input).toEqual({ query: 'test', limit: 5 });
|
|
|
|
// Gemini reads it (converts to legacy)
|
|
const geminiResult = await (gemini as any).extractContents(conversation);
|
|
const geminiLegacy = geminiResult.find((r: any) =>
|
|
r.parts[0]?.text?.includes('<!-- Historical tool call')
|
|
);
|
|
expect(geminiLegacy).toBeDefined();
|
|
expect(geminiLegacy.parts[0].text).toContain('search_vault_files');
|
|
expect(geminiLegacy.parts[0].text).toContain('test');
|
|
|
|
// Claude reads it again (should work the same way)
|
|
const claude2 = new Claude();
|
|
const claudeResult2 = await (claude2 as any).extractContents(conversation);
|
|
expect(claudeResult2).toEqual(claudeResult1); // Should be identical
|
|
});
|
|
|
|
it('should handle round-trip: OpenAI → Gemini → OpenAI', async () => {
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Search', displayContent: 'Search' }),
|
|
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_round',
|
|
name: 'search_vault_files',
|
|
args: { query: 'openai test' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_round'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'call_round',
|
|
functionResponse: { name: 'search_vault_files', response: ['file.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_round'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Found file' })
|
|
];
|
|
|
|
// OpenAI reads initially
|
|
const openai1 = new OpenAI();
|
|
const openaiResult1 = await (openai1 as any).extractContents(conversation);
|
|
expect(openaiResult1.length).toBeGreaterThan(0);
|
|
|
|
const openaiCall1 = openaiResult1.find((r: any) => r.type === 'function_call');
|
|
expect(openaiCall1).toBeDefined();
|
|
expect(openaiCall1.call_id).toBe('call_round');
|
|
|
|
// Gemini reads (converts to legacy)
|
|
const geminiResult = await (gemini as any).extractContents(conversation);
|
|
expect(geminiResult.length).toBeGreaterThan(0);
|
|
|
|
// OpenAI reads again (should be consistent)
|
|
const openai2 = new OpenAI();
|
|
const openaiResult2 = await (openai2 as any).extractContents(conversation);
|
|
expect(openaiResult2).toEqual(openaiResult1);
|
|
});
|
|
|
|
it('should handle complex multi-switch with function calls at each step', async () => {
|
|
// Ultimate test: Each provider makes a function call in sequence
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Complex workflow', displayContent: 'Complex workflow' }),
|
|
|
|
// Step 1: Claude
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: 'Searching...',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'toolu_step1',
|
|
name: 'search_vault_files',
|
|
args: { query: 'workflow' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'toolu_step1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'toolu_step1',
|
|
functionResponse: { name: 'search_vault_files', response: ['workflow.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'toolu_step1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.User, content: 'Read it', displayContent: 'Read it' }),
|
|
|
|
// Step 2: OpenAI
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: 'Reading file...',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_step2',
|
|
name: 'read_file',
|
|
args: { path: 'workflow.md' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call_step2'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'call_step2',
|
|
functionResponse: { name: 'read_file', response: { content: 'Workflow steps...' } }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_step2'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.User, content: 'List all files', displayContent: 'List all files' }),
|
|
|
|
// Step 3: Gemini (with thoughtSignature)
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
name: 'list_files',
|
|
args: { path: '/' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
thoughtSignature: 'gemini_step3_signature==',
|
|
toolId: 'resp_step3'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'resp_step3',
|
|
functionResponse: { name: 'list_files', response: ['file1.md', 'file2.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'resp_step3'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Complete!' })
|
|
];
|
|
|
|
// All providers should handle this complex conversation
|
|
const claudeResult = await (new Claude() as any).extractContents(conversation);
|
|
expect(claudeResult.length).toBeGreaterThan(5);
|
|
|
|
const openaiResult = await (new OpenAI() as any).extractContents(conversation);
|
|
expect(openaiResult.length).toBeGreaterThan(5);
|
|
|
|
const geminiResult = await (gemini as any).extractContents(conversation);
|
|
expect(geminiResult.length).toBeGreaterThan(5);
|
|
|
|
// Each provider should see all 3 function calls (in their own format or legacy)
|
|
const claudeToolUses = claudeResult.filter((r: any) =>
|
|
r.content.some((c: any) => c.type === 'tool_use' || c.text?.includes('<!-- Historical tool call'))
|
|
);
|
|
expect(claudeToolUses.length).toBe(3);
|
|
|
|
const openaiToolCalls = openaiResult.filter((r: any) =>
|
|
r.type === 'function_call' || r.type === 'message'
|
|
);
|
|
expect(openaiToolCalls.length).toBeGreaterThan(0);
|
|
|
|
// Gemini should have 1 native call (with signature) and 2 legacy calls
|
|
const geminiNativeCalls = geminiResult.filter((r: any) =>
|
|
r.parts[0]?.functionCall && r.parts[0]?.thoughtSignature
|
|
);
|
|
expect(geminiNativeCalls.length).toBe(1);
|
|
|
|
const geminiLegacyCalls = geminiResult.filter((r: any) =>
|
|
r.parts[0]?.text?.includes('<!-- Historical tool call')
|
|
);
|
|
// Note: Orphaned function calls without responses are filtered out by filterConversationContents
|
|
// So we verify at least the function calls with responses are present
|
|
expect(geminiLegacyCalls.length).toBeGreaterThanOrEqual(0);
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle empty thoughtSignature as missing signature (use native format)', async () => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: { name: 'test_func', args: {} } // No id = native Gemini
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
thoughtSignature: ''
|
|
});
|
|
|
|
const result = await (gemini as any).extractContents([content]);
|
|
|
|
// Empty thoughtSignature with no id field = native Gemini call without extended thinking
|
|
expect(result[0].parts[0]).toHaveProperty('functionCall');
|
|
expect(result[0].parts[0].functionCall.name).toBe('test_func');
|
|
expect(result[0].parts[0].thoughtSignature).toBeUndefined();
|
|
});
|
|
|
|
it('should handle whitespace-only thoughtSignature as missing signature (use native format)', async () => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: { name: 'test_func', args: {} } // No id = native Gemini
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
thoughtSignature: ' '
|
|
});
|
|
|
|
const result = await (gemini as any).extractContents([content]);
|
|
|
|
// Whitespace-only signature is trimmed, but no id field = native Gemini call
|
|
expect(result[0].parts[0]).toHaveProperty('functionCall');
|
|
expect(result[0].parts[0].functionCall.name).toBe('test_func');
|
|
expect(result[0].parts[0].thoughtSignature).toBeUndefined();
|
|
});
|
|
|
|
it('should gracefully handle conversation with cross-provider legacy format calls', async () => {
|
|
// Entire conversation from Claude - has id fields
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Test', displayContent: 'Test' }),
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'toolu_123', // id field indicates Claude/OpenAI origin
|
|
name: 'func1',
|
|
args: {}
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'toolu_123' // toolId must be set for filtering to work
|
|
});
|
|
return content;
|
|
})(),
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'toolu_123', // id field indicates Claude/OpenAI origin
|
|
functionResponse: { name: 'func1', response: 'ok' }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'toolu_123' // toolId must be set for filtering to work
|
|
});
|
|
return content;
|
|
})()
|
|
];
|
|
|
|
const result = await (gemini as any).extractContents(conversation);
|
|
|
|
// Function call should be in legacy format (has id field)
|
|
expect(result[1].parts[0].text).toContain('<!-- Historical tool call');
|
|
expect(result[1].parts[0].text).toContain('"name": "func1"');
|
|
|
|
// Function response should be in native Gemini format (has id, can be used natively)
|
|
expect(result[2].parts[0]).toHaveProperty('functionResponse');
|
|
expect(result[2].parts[0].functionResponse.name).toBe('func1');
|
|
expect(result[2].parts[0].functionResponse.response).toBe('ok');
|
|
});
|
|
|
|
it('should handle both toolId and thoughtSignature present (defensive)', async () => {
|
|
// This tests the unusual scenario where both provider-specific fields are set
|
|
// When id is present, it indicates cross-provider origin (Claude/OpenAI)
|
|
// so it should use legacy text format even if thoughtSignature is also present
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'tool-123', // ID in the JSON indicates cross-provider origin
|
|
name: 'test_func',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'tool-123', // toolId metadata (matches JSON id)
|
|
thoughtSignature: 'signature==' // Also has thoughtSignature (unusual edge case)
|
|
});
|
|
|
|
// Gemini should use legacy text format because id is present (cross-provider)
|
|
const geminiResult = await (gemini as any).extractContents([content]);
|
|
expect(geminiResult[0].parts[0]).toHaveProperty('text');
|
|
expect(geminiResult[0].parts[0].text).toContain('<!-- Historical tool call');
|
|
|
|
// Claude reads from the JSON id field (not the metadata toolId field)
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents([content]);
|
|
expect(claudeResult[0].content[0]).toEqual({
|
|
type: 'tool_use',
|
|
id: 'tool-123',
|
|
name: 'test_func',
|
|
input: { query: 'test' }
|
|
});
|
|
|
|
// OpenAI also reads from the JSON id field
|
|
const openai = new OpenAI();
|
|
const openaiResult = await (openai as any).extractContents([content]);
|
|
expect(openaiResult[0]).toEqual({
|
|
type: 'function_call',
|
|
call_id: 'tool-123',
|
|
name: 'test_func',
|
|
arguments: '{"query":"test"}'
|
|
});
|
|
});
|
|
|
|
it('should handle inconsistent state: toolId set but id missing from JSON (data corruption)', async () => {
|
|
// Edge case: metadata field has toolId but the serialized JSON doesn't have id
|
|
// This should never happen in production (AIToolCall prevents this)
|
|
// but tests defensive behavior
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: { name: 'test_func', args: { query: 'test' } } // No id field!
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'tool-123'
|
|
});
|
|
|
|
// Claude should convert to legacy format (defensive: no id in JSON)
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents([content]);
|
|
expect(claudeResult[0].content[0].type).toBe('text');
|
|
expect(claudeResult[0].content[0].text).toContain('<!-- Historical tool call');
|
|
expect(claudeResult[0].content[0].text).toContain('"name": "test_func"');
|
|
|
|
// OpenAI should also fall back gracefully
|
|
const openai = new OpenAI();
|
|
const openaiResult = await (openai as any).extractContents([content]);
|
|
// OpenAI should handle this - either legacy conversion or error message
|
|
expect(openaiResult).toHaveLength(1);
|
|
});
|
|
|
|
it('should filter out function response with mismatched toolId', async () => {
|
|
// Function call with one ID, response with different ID
|
|
// The new filtering logic should filter out the mismatched response
|
|
const conversation = [
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call-123',
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
timestamp: new Date(),
|
|
shouldDisplayContent: true,
|
|
toolId: 'call-123'
|
|
});
|
|
return content;
|
|
})(),
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'call-456', // DIFFERENT ID
|
|
functionResponse: {
|
|
name: 'search_vault_files',
|
|
response: ['file.md']
|
|
}
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call-456' // Mismatched toolId
|
|
});
|
|
return content;
|
|
})()
|
|
];
|
|
|
|
// The mismatched response should be filtered out, leaving only the orphaned function call
|
|
// But orphaned function calls (without responses) are also filtered unless they're the last item
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents(conversation);
|
|
expect(claudeResult).toHaveLength(0);
|
|
|
|
const openai = new OpenAI();
|
|
const openaiResult = await (openai as any).extractContents(conversation);
|
|
expect(openaiResult).toHaveLength(0);
|
|
});
|
|
|
|
it('should handle Claude → Mistral provider switch with invalid tool call ID', async () => {
|
|
// Test the specific scenario from the bug report:
|
|
// Claude tool call with ID like "toolu_01EWDB2npEv6uLdEkkt56xxp" should be converted to text for Mistral
|
|
|
|
const claudeToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'toolu_01EWDB2npEv6uLdEkkt56xxp', // Claude-style ID with underscores, wrong length
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
toolId: 'toolu_01EWDB2npEv6uLdEkkt56xxp'
|
|
});
|
|
|
|
const mistral = new Mistral();
|
|
const result = await (mistral as any).extractContents([claudeToolCall]);
|
|
|
|
// Should convert to legacy text format instead of trying to use the invalid ID
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].role).toBe(Role.Assistant);
|
|
expect(result[0].content).toContain('Historical tool call');
|
|
expect(result[0].content).toContain('search_vault_files');
|
|
expect(result[0].content).toContain('"name": "search_vault_files"');
|
|
// Should NOT have tool_calls array (that would cause the API error)
|
|
expect(result[0].tool_calls).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Mistral Cross-Provider Integration', () => {
|
|
describe('Mistral → Other Providers', () => {
|
|
it('should handle Mistral function call when switching to Claude', async () => {
|
|
// Mistral function call with valid 9-char alphanumeric ID
|
|
const mistralToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'abc123xyz', // Valid Mistral ID format
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
toolId: 'abc123xyz'
|
|
});
|
|
|
|
const claude = new Claude();
|
|
const result = await (claude as any).extractContents([mistralToolCall]);
|
|
|
|
// Claude should convert Mistral's function call to its tool_use format
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].content[0]).toEqual({
|
|
type: 'tool_use',
|
|
id: 'abc123xyz',
|
|
name: 'search_vault_files',
|
|
input: { query: 'test' }
|
|
});
|
|
});
|
|
|
|
it('should handle Mistral function call when switching to OpenAI', async () => {
|
|
// Mistral function call with valid ID
|
|
const mistralToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'def456ghi', // Valid Mistral ID format
|
|
name: 'read_file',
|
|
args: { path: 'test.md' }
|
|
}
|
|
}),
|
|
toolId: 'def456ghi'
|
|
});
|
|
|
|
const openai = new OpenAI();
|
|
const result = await (openai as any).extractContents([mistralToolCall]);
|
|
|
|
// OpenAI should convert Mistral's function call to its Responses API format
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toEqual({
|
|
type: 'function_call',
|
|
call_id: 'def456ghi',
|
|
name: 'read_file',
|
|
arguments: '{"path":"test.md"}'
|
|
});
|
|
});
|
|
|
|
it('should handle Mistral function call when switching to Gemini', async () => {
|
|
// Mistral function call with valid ID
|
|
const mistralToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'ghi789jkl', // Valid Mistral ID format
|
|
name: 'search_vault_files',
|
|
args: { query: 'project' }
|
|
}
|
|
}),
|
|
toolId: 'ghi789jkl'
|
|
});
|
|
|
|
const result = await (gemini as any).extractContents([mistralToolCall]);
|
|
|
|
// Gemini should convert Mistral's function call to legacy text format
|
|
// because Gemini treats any function call with an ID as cross-provider
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].parts[0]).toHaveProperty('text');
|
|
expect(result[0].parts[0].text).toContain('Historical tool call');
|
|
expect(result[0].parts[0].text).toContain('search_vault_files');
|
|
expect(result[0].parts[0].text).toContain('"name": "search_vault_files"');
|
|
});
|
|
});
|
|
|
|
describe('Other Providers → Mistral', () => {
|
|
it('should handle OpenAI function call when switching to Mistral', async () => {
|
|
// OpenAI function call with call_xyz format ID
|
|
const openaiToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_openai_123', // OpenAI-style ID
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
toolId: 'call_openai_123'
|
|
});
|
|
|
|
const mistral = new Mistral();
|
|
const result = await (mistral as any).extractContents([openaiToolCall]);
|
|
|
|
// Mistral should convert OpenAI's function call to legacy text format
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].role).toBe(Role.Assistant);
|
|
expect(result[0].content).toContain('Historical tool call');
|
|
expect(result[0].content).toContain('search_vault_files');
|
|
expect(result[0].tool_calls).toBeUndefined();
|
|
});
|
|
|
|
it('should handle Claude function call when switching to Mistral', async () => {
|
|
// Claude function call with toolu_ format ID
|
|
const claudeToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'toolu_claude_123', // Claude-style ID
|
|
name: 'read_file',
|
|
args: { path: 'document.md' }
|
|
}
|
|
}),
|
|
toolId: 'toolu_claude_123'
|
|
});
|
|
|
|
const mistral = new Mistral();
|
|
const result = await (mistral as any).extractContents([claudeToolCall]);
|
|
|
|
// Mistral should convert Claude's function call to legacy text format
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].role).toBe(Role.Assistant);
|
|
expect(result[0].content).toContain('Historical tool call');
|
|
expect(result[0].content).toContain('read_file');
|
|
expect(result[0].tool_calls).toBeUndefined();
|
|
});
|
|
|
|
it('should handle Gemini function call when switching to Mistral', async () => {
|
|
// Gemini function call with thoughtSignature but no ID
|
|
const geminiToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
thoughtSignature: 'gemini_signature_123==',
|
|
toolId: 'gemini_tool_1'
|
|
});
|
|
|
|
const mistral = new Mistral();
|
|
const result = await (mistral as any).extractContents([geminiToolCall]);
|
|
|
|
// Mistral should convert Gemini's function call to legacy text format
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].role).toBe(Role.Assistant);
|
|
expect(result[0].content).toContain('Historical tool call');
|
|
expect(result[0].content).toContain('search_vault_files');
|
|
expect(result[0].tool_calls).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Mistral ID Validation', () => {
|
|
it('should accept valid 9-character alphanumeric Mistral IDs', async () => {
|
|
// Test various valid Mistral ID formats
|
|
const validIds = ['abc123xyz', 'ABC123XYZ', '123456789', 'aBcDeFgHi'];
|
|
|
|
for (const validId of validIds) {
|
|
const mistralToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: validId,
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
toolId: validId
|
|
});
|
|
|
|
const mistral = new Mistral();
|
|
const result = await (mistral as any).extractContents([mistralToolCall]);
|
|
|
|
// Should use native tool_calls format for valid IDs
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].tool_calls).toBeDefined();
|
|
expect(result[0].tool_calls![0].id).toBe(validId);
|
|
}
|
|
});
|
|
|
|
it('should reject Mistral IDs with invalid characters', async () => {
|
|
// Test IDs with special characters that Mistral doesn't support
|
|
const invalidIds = ['abc-123-xyz', 'abc_123_xyz', 'abc.123.xyz', 'abc 123 xyz'];
|
|
|
|
for (const invalidId of invalidIds) {
|
|
const mistralToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: invalidId,
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
toolId: invalidId
|
|
});
|
|
|
|
const mistral = new Mistral();
|
|
const result = await (mistral as any).extractContents([mistralToolCall]);
|
|
|
|
// Should convert to legacy text format for invalid IDs
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].content).toContain('Historical tool call');
|
|
expect(result[0].tool_calls).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('should reject Mistral IDs with invalid length', async () => {
|
|
// Test IDs that are too short or too long
|
|
const invalidLengthIds = ['abc123', 'abc12345678', 'a', 'abc12345678901234567890'];
|
|
|
|
for (const invalidId of invalidLengthIds) {
|
|
const mistralToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: invalidId,
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
toolId: invalidId
|
|
});
|
|
|
|
const mistral = new Mistral();
|
|
const result = await (mistral as any).extractContents([mistralToolCall]);
|
|
|
|
// Should convert to legacy text format for invalid lengths
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].content).toContain('Historical tool call');
|
|
expect(result[0].tool_calls).toBeUndefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Mixed Conversations with Mistral', () => {
|
|
it('should handle conversation with Mistral, Claude, and OpenAI function calls', async () => {
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Start workflow', displayContent: 'Start workflow' }),
|
|
|
|
// Mistral function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'mistral12', // Valid Mistral ID
|
|
name: 'search_vault_files',
|
|
args: { query: 'project' }
|
|
}
|
|
}),
|
|
toolId: 'mistral12'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'mistral12',
|
|
functionResponse: { name: 'search_vault_files', response: ['project.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'mistral12'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Found project.md' }),
|
|
new ConversationContent({ role: Role.User, content: 'Read it', displayContent: 'Read it' }),
|
|
|
|
// Claude function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'toolu_claude_1',
|
|
name: 'read_file',
|
|
args: { path: 'project.md' }
|
|
}
|
|
}),
|
|
toolId: 'toolu_claude_1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'toolu_claude_1',
|
|
functionResponse: { name: 'read_file', response: { content: 'Project details' } }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'toolu_claude_1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Project details...' }),
|
|
new ConversationContent({ role: Role.User, content: 'Summarize', displayContent: 'Summarize' }),
|
|
|
|
// OpenAI function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'call_openai_1',
|
|
name: 'write_file',
|
|
args: { path: 'summary.md', content: 'Summary' }
|
|
}
|
|
}),
|
|
toolId: 'call_openai_1'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'call_openai_1',
|
|
functionResponse: { name: 'write_file', response: { success: true } }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'call_openai_1'
|
|
});
|
|
return content;
|
|
})()
|
|
];
|
|
|
|
// Test that all providers can handle this mixed conversation
|
|
const mistral = new Mistral();
|
|
const mistralResult = await (mistral as any).extractContents(conversation);
|
|
expect(mistralResult.length).toBeGreaterThan(0);
|
|
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents(conversation);
|
|
expect(claudeResult.length).toBeGreaterThan(0);
|
|
|
|
const openai = new OpenAI();
|
|
const openaiResult = await (openai as any).extractContents(conversation);
|
|
expect(openaiResult.length).toBeGreaterThan(0);
|
|
|
|
const geminiResult = await (gemini as any).extractContents(conversation);
|
|
expect(geminiResult.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle Mistral function responses when switching providers', async () => {
|
|
// Test that Mistral function responses are properly handled by other providers
|
|
const mistralToolCall = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'resp12345', // Valid Mistral ID
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
toolId: 'resp12345'
|
|
});
|
|
|
|
const mistralResponse = (() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'resp12345',
|
|
functionResponse: { name: 'search_vault_files', response: ['file1.md', 'file2.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'resp12345'
|
|
});
|
|
return content;
|
|
})();
|
|
|
|
// Test Claude handling Mistral response
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents([mistralToolCall, mistralResponse]);
|
|
expect(claudeResult.length).toBe(2);
|
|
|
|
// Test OpenAI handling Mistral response
|
|
const openai = new OpenAI();
|
|
const openaiResult = await (openai as any).extractContents([mistralToolCall, mistralResponse]);
|
|
expect(openaiResult.length).toBe(2);
|
|
|
|
// Test Gemini handling Mistral response
|
|
const geminiResult = await (gemini as any).extractContents([mistralToolCall, mistralResponse]);
|
|
expect(geminiResult.length).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('Mistral Round-Trip Testing', () => {
|
|
it('should handle Mistral → Claude → Mistral round-trip', async () => {
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Search files', displayContent: 'Search files' }),
|
|
|
|
// Mistral function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'mistral12', // Valid Mistral ID
|
|
name: 'search_vault_files',
|
|
args: { query: 'test' }
|
|
}
|
|
}),
|
|
toolId: 'mistral12'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'mistral12',
|
|
functionResponse: { name: 'search_vault_files', response: ['file.md'] }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'mistral12'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'Found file.md' })
|
|
];
|
|
|
|
// Mistral reads it initially
|
|
const mistral1 = new Mistral();
|
|
const mistralResult1 = await (mistral1 as any).extractContents(conversation);
|
|
expect(mistralResult1.length).toBeGreaterThan(0);
|
|
|
|
// Claude reads it (converts to its format)
|
|
const claude = new Claude();
|
|
const claudeResult = await (claude as any).extractContents(conversation);
|
|
expect(claudeResult.length).toBeGreaterThan(0);
|
|
|
|
// Mistral reads it again (should still work)
|
|
const mistral2 = new Mistral();
|
|
const mistralResult2 = await (mistral2 as any).extractContents(conversation);
|
|
expect(mistralResult2.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle Mistral → OpenAI → Mistral round-trip', async () => {
|
|
const conversation = [
|
|
new ConversationContent({ role: Role.User, content: 'Read file', displayContent: 'Read file' }),
|
|
|
|
// Mistral function call
|
|
(() => {
|
|
const content = new ConversationContent({
|
|
role: Role.Assistant,
|
|
content: '',
|
|
displayContent: '',
|
|
toolCall: JSON.stringify({
|
|
toolCall: {
|
|
id: 'abc123def', // Valid Mistral ID
|
|
name: 'read_file',
|
|
args: { path: 'test.md' }
|
|
}
|
|
}),
|
|
toolId: 'abc123def'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
(() => {
|
|
const responseContent = JSON.stringify({
|
|
id: 'abc123def',
|
|
functionResponse: { name: 'read_file', response: { content: 'File contents' } }
|
|
});
|
|
const content = new ConversationContent({
|
|
role: Role.User,
|
|
content: responseContent,
|
|
displayContent: responseContent,
|
|
functionResponse: responseContent,
|
|
toolId: 'abc123def'
|
|
});
|
|
return content;
|
|
})(),
|
|
|
|
new ConversationContent({ role: Role.Assistant, content: 'File contents...' })
|
|
];
|
|
|
|
// Mistral reads it initially
|
|
const mistral1 = new Mistral();
|
|
const mistralResult1 = await (mistral1 as any).extractContents(conversation);
|
|
expect(mistralResult1.length).toBeGreaterThan(0);
|
|
|
|
// OpenAI reads it (converts to its format)
|
|
const openai = new OpenAI();
|
|
const openaiResult = await (openai as any).extractContents(conversation);
|
|
expect(openaiResult.length).toBeGreaterThan(0);
|
|
|
|
// Mistral reads it again (should still work)
|
|
const mistral2 = new Mistral();
|
|
const mistralResult2 = await (mistral2 as any).extractContents(conversation);
|
|
expect(mistralResult2.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
});
|