parent
99d9d7f52e
commit
910ed0592e
|
|
@ -115,6 +115,62 @@ describe('CopilotAdapter', () => {
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
describe('token management', () => {
|
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 () => {
|
it('exchanges GitHub token for Copilot session token', async () => {
|
||||||
const fetchMock = mockFetchForToken('session_xyz')
|
const fetchMock = mockFetchForToken('session_xyz')
|
||||||
globalThis.fetch = fetchMock
|
globalThis.fetch = fetchMock
|
||||||
|
|
@ -344,6 +400,23 @@ describe('CopilotAdapter', () => {
|
||||||
|
|
||||||
expect(events.filter(e => e.type === 'error')).toHaveLength(1)
|
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({})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,29 @@ describe('GeminiAdapter (contract)', () => {
|
||||||
expect(parts[0].functionResponse.name).toBe('unknown_id')
|
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 () => {
|
it('converts image blocks to inlineData parts', async () => {
|
||||||
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }]))
|
mockGenerateContent.mockResolvedValue(makeGeminiResponse([{ text: 'ok' }]))
|
||||||
|
|
||||||
|
|
@ -265,6 +288,17 @@ describe('GeminiAdapter (contract)', () => {
|
||||||
|
|
||||||
expect(result.content).toEqual([])
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -355,5 +355,31 @@ describe('OpenAIAdapter', () => {
|
||||||
expect((toolEvents[0].data as ToolUseBlock).name).toBe('search')
|
expect((toolEvents[0].data as ToolUseBlock).name).toBe('search')
|
||||||
expect((toolEvents[1].data as ToolUseBlock).name).toBe('read')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue