diff --git a/tests/copilot-adapter.test.ts b/tests/copilot-adapter.test.ts index 2ee4e0b..8ec8f15 100644 --- a/tests/copilot-adapter.test.ts +++ b/tests/copilot-adapter.test.ts @@ -115,6 +115,62 @@ describe('CopilotAdapter', () => { // ========================================================================= describe('token management', () => { + it('uses the device flow when no GitHub token is available', async () => { + vi.useFakeTimers() + const onDeviceCode = vi.fn() + globalThis.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + device_code: 'device-code', + user_code: 'ABCD-EFGH', + verification_uri: 'https://github.com/login/device', + interval: 0, + expires_in: 600, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ access_token: 'oauth_token' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + token: 'session_from_device_flow', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + text: () => Promise.resolve(''), + }) + + const adapter = new CopilotAdapter({ onDeviceCode }) + mockCreate.mockResolvedValue(makeCompletion()) + + const responsePromise = adapter.chat([textMsg('user', 'Hi')], chatOpts()) + await vi.runAllTimersAsync() + await responsePromise + + expect(onDeviceCode).toHaveBeenCalledWith( + 'https://github.com/login/device', + 'ABCD-EFGH', + ) + expect(globalThis.fetch).toHaveBeenNthCalledWith( + 3, + 'https://api.github.com/copilot_internal/v2/token', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'token oauth_token', + }), + }), + ) + expect(OpenAIMock).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: 'session_from_device_flow', + }), + ) + + vi.useRealTimers() + }) + it('exchanges GitHub token for Copilot session token', async () => { const fetchMock = mockFetchForToken('session_xyz') globalThis.fetch = fetchMock @@ -344,6 +400,23 @@ describe('CopilotAdapter', () => { expect(events.filter(e => e.type === 'error')).toHaveLength(1) }) + + it('handles malformed streamed tool arguments JSON', async () => { + mockCreate.mockResolvedValue(makeChunks([ + { + id: 'c1', model: 'gpt-4o', + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'search', arguments: '{broken' } }] }, finish_reason: 'tool_calls' }], + usage: null, + }, + { id: 'c1', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 3 } }, + ])) + + const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts())) + + const toolEvents = events.filter(e => e.type === 'tool_use') + expect(toolEvents).toHaveLength(1) + expect((toolEvents[0].data as ToolUseBlock).input).toEqual({}) + }) }) // ========================================================================= diff --git a/tests/gemini-adapter-contract.test.ts b/tests/gemini-adapter-contract.test.ts index bfd834f..221b1f0 100644 --- a/tests/gemini-adapter-contract.test.ts +++ b/tests/gemini-adapter-contract.test.ts @@ -120,6 +120,29 @@ describe('GeminiAdapter (contract)', () => { expect(parts[0].functionResponse.name).toBe('unknown_id') }) + it('serializes non-string tool_result content to JSON', async () => { + mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }])) + + await adapter.chat( + [{ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: 'call_1', + content: { answer: 42 } as never, + is_error: false, + } as never], + }], + chatOpts(), + ) + + const parts = mockGenerateContent.mock.calls[0][0].contents[0].parts + expect(parts[0].functionResponse.response).toEqual({ + content: '{"answer":42}', + isError: false, + }) + }) + it('converts image blocks to inlineData parts', async () => { mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }])) @@ -265,6 +288,17 @@ describe('GeminiAdapter (contract)', () => { expect(result.content).toEqual([]) }) + + it('throws for unsupported message block types', async () => { + mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }])) + + await expect(adapter.chat([ + { + role: 'user', + content: [{ type: 'unsupported' } as never], + }, + ], chatOpts())).rejects.toThrow('Unhandled content block type') + }) }) // ========================================================================= diff --git a/tests/openai-adapter.test.ts b/tests/openai-adapter.test.ts index a2fb4a1..34a8008 100644 --- a/tests/openai-adapter.test.ts +++ b/tests/openai-adapter.test.ts @@ -355,5 +355,31 @@ describe('OpenAIAdapter', () => { expect((toolEvents[0].data as ToolUseBlock).name).toBe('search') expect((toolEvents[1].data as ToolUseBlock).name).toBe('read') }) + + it('falls back to extracting tool calls from streamed text when no native tool deltas exist', async () => { + mockCreate.mockResolvedValue(makeChunks([ + textChunk('```json\n{"name":"search","input":{"query":"fallback"}}\n```', 'stop'), + { id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 6, completion_tokens: 4 } }, + ])) + + const events = await collectEvents( + adapter.stream( + [textMsg('user', 'Search for fallback handling')], + chatOpts({ tools: [toolDef('search')] }), + ), + ) + + const toolEvents = events.filter(e => e.type === 'tool_use') + expect(toolEvents).toHaveLength(1) + expect(toolEvents[0].data).toEqual({ + type: 'tool_use', + id: expect.any(String), + name: 'search', + input: { query: 'fallback' }, + }) + + const done = events.find(e => e.type === 'done') + expect((done!.data as LLMResponse).stop_reason).toBe('tool_use') + }) }) })