import type { McpToolInfo } from '../src/services/assistantAgent.ts' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { nativePlatform, nativeRequest, getSession } = vi.hoisted(() => ({ nativePlatform: vi.fn(() => false), nativeRequest: vi.fn(), getSession: vi.fn<() => Promise<{ data: { session: { access_token: string } | null }, error: Error | null }>>(async () => ({ data: { session: { access_token: 'console-session' } }, error: null })), })) vi.mock('@capacitor/core', () => ({ Capacitor: { isNativePlatform: nativePlatform }, CapacitorHttp: { request: nativeRequest } })) vi.mock('~/services/console', () => ({ defaultApiHost: 'https://api.capgo.test', useConsole: () => ({ auth: { getSession } }) })) const { tokenizeAssistantText } = await import('../src/utils/assistantText.ts') const { parseChatGptCallback } = await import('../src/utils/chatgptCallback.ts') const { needsConfirmation, parseResponseStream, PREFERRED_MODEL, resolveModel, responsesTools, runAssistant, sanitizeConsolePath } = await import('../src/services/assistantAgent.ts') const chatgpt = await import('../src/services/chatgptAuth.ts') const { assistantOpenAIRequest } = await import('../src/services/assistantHttp.ts') class MemoryStorage { private values = new Map() getItem(key: string) { return this.values.get(key) ?? null } setItem(key: string, value: string) { this.values.set(key, value) } removeItem(key: string) { this.values.delete(key) } clear() { this.values.clear() } } const MCP_TOOLS: McpToolInfo[] = [ { name: 'capgo_list_apps', title: 'List apps', description: 'List apps.', inputSchema: { type: 'object' }, annotations: { destructiveHint: false } }, { name: 'capgo_delete_channel', title: 'Delete channel', description: 'Delete a channel. Devices fall back.', inputSchema: { type: 'object' }, annotations: { destructiveHint: true } }, ] function sse(events: unknown[]): string { return events.map(event => `event: ${(event as { type: string }).type}\ndata: ${JSON.stringify(event)}\n\n`).join('') } let callCounter = 0 function completed(output: { text?: string, calls?: Array<[string, Record]> }) { const items: unknown[] = [] if (output.text) items.push({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: output.text }] }) for (const [name, args] of output.calls ?? []) items.push({ type: 'function_call', call_id: `call_${++callCounter}`, name, namespace: name.startsWith('capgo_') ? 'capgo' : 'capgo_ui', arguments: JSON.stringify(args) }) return sse([{ type: 'response.created' }, { type: 'response.completed', response: { output: items } }]) } interface Recorded { url: string, auth: string | null, token: string | null, body: any } /** Fake OpenAI (models + responses) and the hosted Capgo MCP endpoint. */ function mockNetwork(outputs: Array<{ text?: string, calls?: Array<[string, Record]> } | string>, mcpResult: unknown = [{ app_id: 'com.example.app' }]) { const openai: Recorded[] = [] const mcp: Recorded[] = [] vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => { const auth = new Headers(init.headers).get('authorization') const token = new Headers(init.headers).get('x-chatgpt-token') if (url === 'https://api.capgo.test/private/assistant/models') { expect(auth).toBe('Bearer console-session') expect(token).toBe('chatgpt-token') return Response.json({ models: [{ slug: 'gpt-hidden', visibility: 'hide' }, { slug: 'gpt-plan-default', visibility: 'list' }] }) } if (url === 'https://api.capgo.test/mcp') { const body = JSON.parse(String(init.body)) mcp.push({ url, auth, token, body }) const result = body.method === 'tools/list' ? { tools: MCP_TOOLS } : { content: [{ type: 'text', text: JSON.stringify(mcpResult) }] } return Response.json({ jsonrpc: '2.0', id: body.id, result }) } openai.push({ url, auth, token, body: JSON.parse(String(init.body)) }) const next = outputs.shift() ?? { text: 'done' } return new Response(typeof next === 'string' ? next : completed(next), { status: 200 }) })) return { openai, mcp } } const base = { accessToken: 'chatgpt-token', context: { path: '/dashboard' } } describe('assistant helpers', () => { it.concurrent('only accepts same-origin console paths', () => { expect(sanitizeConsolePath('/app/com.example.app/channels')).toBe('/app/com.example.app/channels') expect(sanitizeConsolePath('https://evil.example')).toBeNull() expect(sanitizeConsolePath('//evil.example')).toBeNull() expect(sanitizeConsolePath('/\\evil.example')).toBeNull() expect(sanitizeConsolePath(42)).toBeNull() }) it.concurrent('asks for confirmation on destructive and outward-facing tools only', () => { expect(needsConfirmation(MCP_TOOLS[1])).toBe(true) expect(needsConfirmation({ ...MCP_TOOLS[0], annotations: { openWorldHint: true } })).toBe(true) expect(needsConfirmation(MCP_TOOLS[0])).toBe(false) }) it.concurrent('groups tools in namespaces as ChatGPT plan usage requires', () => { const tools = responsesTools(MCP_TOOLS) as Array<{ type: string, name: string, tools: Array<{ name: string }> }> expect(tools.map(tool => [tool.type, tool.name])).toEqual([['namespace', 'capgo_ui'], ['namespace', 'capgo']]) expect(tools[1].tools.map(tool => tool.name)).toEqual(['capgo_list_apps', 'capgo_delete_channel']) }) it.concurrent('renders bold, code and lists as plain text tokens', () => { expect(tokenizeAssistantText('Live on **production**: `1.2.3`\n\n- one')).toEqual([ { type: 'paragraph', spans: [{ text: 'Live on ', style: 'plain' }, { text: 'production', style: 'bold' }, { text: ': ', style: 'plain' }, { text: '1.2.3', style: 'code' }] }, { type: 'list', items: [[{ text: 'one', style: 'plain' }]] }, ]) }) it.concurrent('falls back to streamed items when response.completed has an empty output', () => { const item = { type: 'function_call', call_id: 'c1', name: 'show_link', namespace: 'capgo_ui', arguments: '{}' } expect(parseResponseStream(sse([{ type: 'response.output_item.done', item }, { type: 'response.completed', response: { output: [] } }])).calls) .toEqual([{ type: 'function_call', call_id: 'c1', name: 'show_link', namespace: 'capgo_ui', arguments: '{}' }]) expect(parseResponseStream(sse([{ type: 'response.failed', response: { error: { code: 'subscription_sharing_usage_limit_exceeded' } } }])).failedCode) .toBe('subscription_sharing_usage_limit_exceeded') expect(parseResponseStream(sse([{ type: 'response.output_text.delta', delta: 'hi' }])).failedCode).toBe('stream_interrupted') }) }) describe('sign in with ChatGPT (in the browser, like the Capgo browser app)', () => { beforeEach(() => { vi.stubGlobal('localStorage', new MemoryStorage()) vi.stubGlobal('sessionStorage', new MemoryStorage()) }) afterEach(() => { vi.unstubAllGlobals() }) it('registers a dynamic agent client with PKCE and the plan usage scope', async () => { const url = new URL(await chatgpt.beginChatGptSignIn('user-1')) const params = Object.fromEntries(url.searchParams) expect(url.origin + url.pathname).toBe('https://auth.openai.com/api/accounts/authorize') expect(params).toMatchObject({ client_id: 'dynamic_agent_client', agent_name_hint: 'Capgo', redirect_uri: 'http://127.0.0.1:1455/auth/callback', resource: 'https://api.openai.com/v1', code_challenge_method: 'S256', scope: 'openid profile email offline_access resource.invoke chatgpt.tokens.use.direct', }) expect(params.ext_agent_host_id).toMatch(/^urn:uuid:/) // The host id is stable for this user on this device. const again = new URL(await chatgpt.beginChatGptSignIn('user-1')) expect(again.searchParams.get('ext_agent_host_id')).toBe(params.ext_agent_host_id) }) it('exchanges the code in the browser and keeps the tokens on the device only', async () => { const url = new URL(await chatgpt.beginChatGptSignIn('user-1')) const state = url.searchParams.get('state')! const nonce = url.searchParams.get('nonce')! const idToken = `e30.${btoa(JSON.stringify({ iss: 'https://auth.openai.com', aud: 'oaiapp_issued', sub: 's', nonce, email: 'customer-onboarding@example.com' })).replace(/=+$/, '')}.sig` const tokenCalls: string[] = [] vi.stubGlobal('fetch', vi.fn(async (target: string, init: RequestInit) => { tokenCalls.push(`${target} ${String(init.body)}`) return Response.json({ access_token: 'at', refresh_token: 'rt', id_token: idToken, expires_in: 3600, scope: 'openid email offline_access chatgpt.tokens.use.direct' }) })) const credentials = await chatgpt.completeChatGptSignIn('user-1', { code: 'code-1', state, clientId: 'oaiapp_issued' }) expect(credentials).toMatchObject({ clientId: 'oaiapp_issued', email: 'customer-onboarding@example.com', accessToken: 'at', refreshToken: 'rt' }) expect(tokenCalls).toHaveLength(1) expect(tokenCalls[0]).toContain('https://auth.openai.com/api/accounts/oauth/token') expect(tokenCalls[0]).toContain('client_id=oaiapp_issued') expect(chatgpt.loadChatGptCredentials('user-1')?.accessToken).toBe('at') expect(chatgpt.loadChatGptCredentials('user-2')).toBeNull() // Later sign-ins reuse the issued client instead of registering again. expect(new URL(await chatgpt.beginChatGptSignIn('user-1')).searchParams.get('client_id')).toBe('oaiapp_issued') }) it('rejects a wrong state or a registration without an issued client', async () => { const url = new URL(await chatgpt.beginChatGptSignIn('user-1')) await expect(chatgpt.completeChatGptSignIn('user-1', { code: 'c', state: 'other', clientId: 'oaiapp_x' })).rejects.toThrow('chatgpt_sign_in_expired') await expect(chatgpt.completeChatGptSignIn('user-1', { code: 'c', state: url.searchParams.get('state')! })).rejects.toThrow('chatgpt_invalid_client') expect(chatgpt.resolveIssuedClientId('oaiapp_saved', 'oaiapp_other')).toBeNull() expect(chatgpt.resolveIssuedClientId('oaiapp_saved', undefined)).toBe('oaiapp_saved') }) it('refreshes an expired token and signs out on a terminal refresh error', async () => { localStorage.setItem('capgo-assistant-chatgpt:user-1', JSON.stringify({ clientId: 'oaiapp_1', email: '', accessToken: 'old', refreshToken: 'rt', expiresAt: 0, scopes: [] })) vi.stubGlobal('fetch', vi.fn(async () => Response.json({ access_token: 'new', refresh_token: 'rt2', expires_in: 3600 }))) expect(await chatgpt.getChatGptAccessToken('user-1')).toBe('new') expect(chatgpt.loadChatGptCredentials('user-1')?.refreshToken).toBe('rt2') vi.stubGlobal('fetch', vi.fn(async () => Response.json({ error: 'refresh_token_reused' }, { status: 400 }))) await expect(chatgpt.getChatGptAccessToken('user-1', true)).rejects.toThrow('chatgpt_reconnect_required') expect(chatgpt.loadChatGptCredentials('user-1')).toBeNull() }) it('parses only the loopback callback address', () => { expect(parseChatGptCallback('http://127.0.0.1:1455/auth/callback?code=abc&state=xyz&client_id=oaiapp_1', chatgpt.CHATGPT_REDIRECT_URI)) .toEqual({ code: 'abc', state: 'xyz', clientId: 'oaiapp_1', error: undefined }) expect(parseChatGptCallback('https://evil.example/auth/callback?code=abc', chatgpt.CHATGPT_REDIRECT_URI)).toBeNull() expect(parseChatGptCallback('nope', chatgpt.CHATGPT_REDIRECT_URI)).toBeNull() }) }) describe('assistant loop in the browser', () => { afterEach(() => { vi.unstubAllGlobals() }) it('proxies OpenAI with a console session and keeps the Capgo key on MCP calls', async () => { const { openai, mcp } = mockNetwork([ { calls: [['capgo_list_apps', {}]] }, { calls: [['show_link', { title: 'Channels', path: '/app/com.example.app/channels' }], ['show_link', { title: 'Bad', path: 'https://evil.example' }]] }, { text: 'Your channels are here.' }, ]) const result = await runAssistant({ ...base, apikey: 'capgo-key-1', messages: [{ role: 'user', content: 'Where are my channels?' }] }) expect(result.reply).toBe('Your channels are here.') expect(result.cards).toEqual([{ type: 'link', title: 'Channels', description: undefined, path: '/app/com.example.app/channels' }]) expect(result.actions).toEqual([{ tool: 'capgo_list_apps', title: 'List apps', ok: true }]) expect(openai.every(call => call.url === 'https://api.capgo.test/private/assistant/responses' && call.auth === 'Bearer console-session' && call.token === 'chatgpt-token')).toBe(true) expect(openai.every(call => call.body.store === false && call.body.stream === true && call.body.model === 'gpt-plan-default')).toBe(true) expect(openai[1].body.input.map((item: any) => item.type ?? item.role)).toEqual(['user', 'function_call', 'function_call_output']) expect(mcp.map(call => [call.body.method, call.auth])).toEqual([['tools/list', 'Bearer capgo-key-1'], ['tools/call', 'Bearer capgo-key-1']]) expect(mcp.every(call => call.token === null)).toBe(true) }) it('turns ask_user into a question card and stops', async () => { const { openai } = mockNetwork([{ calls: [['ask_user', { question: 'Which app?', options: ['com.a', 'com.b'] }]] }, { text: 'Pick an app.' }]) const result = await runAssistant({ ...base, apikey: 'capgo-key-2', messages: [{ role: 'user', content: 'Show devices' }] }) expect(result.cards).toEqual([{ type: 'question', question: 'Which app?', options: ['com.a', 'com.b'], allowFreeText: false }]) expect(result.reply).toBe('Pick an app.') expect(openai[1].body).toMatchObject({ tool_choice: 'none' }) }) it('never runs a destructive tool without confirmation, then runs it once confirmed', async () => { const first = mockNetwork([{ calls: [['capgo_delete_channel', { appId: 'com.example.app', channel: 'beta' }]] }, { text: 'Confirm to delete beta.' }]) const pending = await runAssistant({ ...base, apikey: 'capgo-key-3', messages: [{ role: 'user', content: 'Delete beta' }] }) expect(first.mcp.filter(call => call.body.method === 'tools/call')).toEqual([]) expect(pending.cards).toMatchObject([{ type: 'confirm', tool: 'capgo_delete_channel' }]) const second = mockNetwork([sse([{ type: 'response.failed', response: { error: { code: 'server_error' } } }])], { status: 'ok' }) const done = await runAssistant({ ...base, apikey: 'capgo-key-3', messages: [{ role: 'user', content: 'Delete beta' }], confirm: { tool: 'capgo_delete_channel', arguments: { appId: 'com.example.app', channel: 'beta' } } }) expect(second.mcp.filter(call => call.body.method === 'tools/call').map(call => call.body.params.name)).toEqual(['capgo_delete_channel']) // The summary failed, but the completed action is still reported. expect(done.actions).toEqual([{ tool: 'capgo_delete_channel', title: 'Delete channel', ok: true }]) expect(done.reply).toContain('completed') }) it('refuses to confirm a tool that does not need confirmation', async () => { mockNetwork([]) await expect(runAssistant({ ...base, apikey: 'capgo-key-4', messages: [{ role: 'user', content: 'hi' }], confirm: { tool: 'capgo_list_apps', arguments: {} } })).rejects.toThrow('invalid_confirm') }) it('reports the ChatGPT plan usage limit', async () => { mockNetwork([sse([{ type: 'response.failed', response: { error: { code: 'subscription_sharing_usage_limit_exceeded' } } }])]) await expect(runAssistant({ ...base, apikey: 'capgo-key-5', messages: [{ role: 'user', content: 'hi' }] })).rejects.toMatchObject({ code: 'chatgpt_usage_limit' }) }) }) describe('assistant model transport', () => { afterEach(() => { vi.restoreAllMocks() nativePlatform.mockReturnValue(false) nativeRequest.mockReset() getSession.mockReset() getSession.mockResolvedValue({ data: { session: { access_token: 'console-session' } }, error: null }) vi.unstubAllGlobals() }) it('does not send ChatGPT tokens without a console session', async () => { getSession.mockResolvedValueOnce({ data: { session: null }, error: null }) const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) await expect(assistantOpenAIRequest('models', 'chatgpt-token', { method: 'GET' })).rejects.toThrow('assistant_session_required') expect(fetchMock).not.toHaveBeenCalled() }) it.each(['invalid_jwt', 'no_jwt_apikey_or_subkey', 'not_authorized'])('distinguishes Capgo %s rejections from ChatGPT authentication errors', async (error) => { vi.stubGlobal('fetch', vi.fn(async (url: string) => url.endsWith('/mcp') ? Response.json({ result: { tools: [] } }) : Response.json({ error, message: 'Sign in to Capgo' }, { status: 401 }))) await expect(runAssistant({ ...base, accessToken: `capgo-session-error-${error}`, apikey: `auth-test-key-${error}`, messages: [{ role: 'user', content: 'Hello' }] })) .rejects.toMatchObject({ code: 'assistant_session_required' }) }) it('preserves a missing console session error through the agent loop', async () => { getSession.mockResolvedValue({ data: { session: null }, error: null }) vi.stubGlobal('fetch', vi.fn(async () => Response.json({ result: { tools: [] } }))) await expect(runAssistant({ ...base, accessToken: 'missing-capgo-session', apikey: 'missing-session-test-key', messages: [{ role: 'user', content: 'Hello' }] })) .rejects.toMatchObject({ code: 'assistant_session_required' }) }) it.each([ JSON.stringify({ error: { code: 'invalid_api_key' } }), 'Unauthorized', ])('keeps upstream 401s on the ChatGPT reconnect path (%s)', async (body) => { vi.stubGlobal('fetch', vi.fn(async (url: string) => url.endsWith('/mcp') ? Response.json({ result: { tools: [] } }) : new Response(body, { status: 401 }))) await expect(runAssistant({ ...base, apikey: `upstream-auth-test-key-${body}`, messages: [{ role: 'user', content: 'Hello' }] })) .rejects.toMatchObject({ code: 'chatgpt_reconnect_required' }) }) it('bounds a stalled session refresh without sending ChatGPT credentials', async () => { const controller = new AbortController() const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal) const removeListener = vi.spyOn(controller.signal, 'removeEventListener') getSession.mockReturnValueOnce(new Promise(() => {})) const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) const request = assistantOpenAIRequest('responses', 'chatgpt-token', { method: 'POST', timeoutMs: 90_000 }) const assertion = expect(request).rejects.toMatchObject({ name: 'TimeoutError' }) controller.abort(new DOMException('Request timed out', 'TimeoutError')) await assertion expect(timeout).toHaveBeenCalledExactlyOnceWith(90_000) expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) expect(fetchMock).not.toHaveBeenCalled() }) it('uses the original deadline for fetching and reading the response after session refresh', async () => { const controller = new AbortController() const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal) let finishRefresh!: (value: Awaited>) => void getSession.mockReturnValueOnce(new Promise(resolve => { finishRefresh = resolve })) const fetchMock = vi.fn(async (_url: string, init: RequestInit) => { expect(init.signal).toBe(controller.signal) return new Response(new ReadableStream({ start(stream) { init.signal!.addEventListener('abort', () => stream.error(init.signal!.reason), { once: true }) }, })) }) vi.stubGlobal('fetch', fetchMock) const request = assistantOpenAIRequest('responses', 'chatgpt-token', { method: 'POST', timeoutMs: 90_000 }) expect(fetchMock).not.toHaveBeenCalled() finishRefresh({ data: { session: { access_token: 'refreshed-console-session' } }, error: null }) await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) const assertion = expect(request).rejects.toMatchObject({ name: 'TimeoutError' }) controller.abort(new DOMException('Request timed out', 'TimeoutError')) await assertion expect(timeout).toHaveBeenCalledExactlyOnceWith(90_000) expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ headers: { authorization: 'Bearer refreshed-console-session', 'x-chatgpt-token': 'chatgpt-token' }, })) }) it.each(['session', 'upstream', 'json'])('retries model discovery after a temporary %s failure', async (failure) => { const accessToken = `model-discovery-recovery-${failure}` if (failure === 'session') getSession.mockResolvedValueOnce({ data: { session: null }, error: null }) const fetchMock = vi.fn(async () => Response.json({ models: [{ slug: 'available-model', visibility: 'list' }] })) if (failure === 'upstream') fetchMock.mockResolvedValueOnce(Response.json({ error: 'unavailable' }, { status: 503 })) if (failure === 'json') fetchMock.mockResolvedValueOnce(new Response('{')) vi.stubGlobal('fetch', fetchMock) expect(await resolveModel(accessToken)).toBe(PREFERRED_MODEL) expect(await resolveModel(accessToken)).toBe('available-model') expect(await resolveModel(accessToken)).toBe('available-model') expect(fetchMock).toHaveBeenCalledTimes(failure === 'session' ? 1 : 2) }) it('keeps native model requests direct without a console session lookup', async () => { nativePlatform.mockReturnValue(true) nativeRequest.mockResolvedValueOnce({ status: 200, data: 'native-result' }) const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) await expect(assistantOpenAIRequest('responses', 'native-chatgpt-token', { method: 'POST', body: '{"stream":true}', headers: { 'content-type': 'application/json' }, timeoutMs: 90_000, })).resolves.toEqual({ status: 200, text: 'native-result' }) expect(nativeRequest).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://api.openai.com/v1/responses', headers: { 'content-type': 'application/json', 'authorization': 'Bearer native-chatgpt-token' }, data: '{"stream":true}', connectTimeout: 90_000, })) expect(getSession).not.toHaveBeenCalled() expect(fetchMock).not.toHaveBeenCalled() }) })