Ctrl K
ComunidadGuías…

tests/assistant.unit.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, string>()
  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<string, unknown>]> }) {
  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, unknown>]> } | 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<ReturnType<typeof getSession>>) => 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()
  })
})