diff --git a/docs/openrouter-migration-plan.md b/docs/openrouter-migration-plan.md index 775b669..06b0ab6 100644 --- a/docs/openrouter-migration-plan.md +++ b/docs/openrouter-migration-plan.md @@ -43,6 +43,45 @@ Chat no longer depends on a selected repository. Local-mode users can chat to up editor tab with no GitHub connection at all. A selected repository remains useful context when one is connected, but it is never a precondition. +## Implementation status (updated 2026-09-06) + +### Done + +- Phase 1 completed: chat extracted to `src/modules/chat` and decoupled from + `src/modules/github` imports. +- Phase 2 completed for core runtime path: + - OpenRouter chat completions endpoint is live. + - OpenRouter header and error handling is implemented. + - Streaming and fallback request paths are both wired. + - Live verification confirmed SSE keepalive comment handling and `[DONE]` sentinel flow. +- Phase 3 completed: + - Chat toggle remains visible regardless of GitHub PAT state. + - Chat works with no repository selected (local mode). + - In-drawer OpenRouter key controls are implemented with independent storage. + - PR visibility logic was split away from chat visibility behavior. +- Security and UX hardening completed after initial migration: + - Proposal/apply actions are intent-gated (explicit edit intent required). + - Read-only prompts do not surface apply actions from markdown fallback. + - Unmatched proposal targets show guidance instead of a misleading apply prompt. + - OpenRouter key controls now reuse the GitHub PAT-style control pattern and trash icon. +- Tests and checks completed for the implemented behaviors: + - Focused Playwright coverage added for intent gating, tab-context sending, and apply behavior. + - Lint checks are passing. + +### Remaining + +- Phase 4 model catalog work is not yet implemented in runtime code: + - No live `/api/v1/models` fetch integration yet. + - Free vs paid grouping in the model picker is still pending. + - Tool-support filtering from live model metadata is still pending. +- Phase 5 remains partial: + - Chat tests still live inside `playwright/github-byot-ai.spec.ts` rather than a split chat spec path. + - Dedicated OpenRouter usage docs listed below are not fully completed. +- Live production verification still pending for exhaustion states: + - 402 out-of-credits behavior. + - 429 rate-limit behavior. +- Optional one-time migration notice behavior is still pending. + ### Correction to a common assumption OpenRouter's free models are **not** keyless. Every request to the OpenRouter API requires @@ -71,8 +110,18 @@ plan's assumptions are measured rather than inferred. | Exposed response headers | Only `content-type` and `cf-ray` | | Catalog size | 430 models, 21 free, 18 free with `tools` support | -Still unverified, because both require a funded key: SSE keepalive handling, and the -402 / 404 / 429 error mappings. Both are Phase 2 opening tasks. +Still unverified, because both require exhausting an account: the 402 (out of credits) and +429 (rate limited) mappings. + +### Verified live with a funded key + +| Check | Result | +| ---------------------------- | -------------------------------------------------------------------------- | +| SSE keepalive comments | `: OPENROUTER PROCESSING` lines do appear; `parseSseDataLine` ignores them | +| `data: [DONE]` sentinel | Present and handled | +| Invalid model slug | Returns **400**, not 404 — `"... is not a valid model ID"` | +| Tool calling on a free model | `openrouter/free` emits a real `propose_editor_update` call | +| Apply + undo round trip | Proposal applies to the editor tab and the undo action appears | ## Current state @@ -238,15 +287,15 @@ still opens and renders. | Status | Meaning | Drawer message | | ------ | ---------------------- | ---------------------------------------------------- | + | 400 | Unknown model slug | Model unavailable; pick another | | 401 | Invalid or revoked key | Key rejected; re-enter or create a new one | | 402 | Out of credits | Out of credits; add credits or pick a free model | - | 404 | Unknown model slug | Model unavailable; pick another | | 429 | Rate limited | Free-model daily limit reached, or too many requests | The 402 and 429 cases are the ones users on free models will actually hit, so their copy should name the free-model limits explicitly and point at the free-model filter in the - picker. Only the 401 mapping is verified; OpenRouter checks auth before model validity, - so 402/404/429 could not be provoked with an invalid key. Confirm each during Phase 2. + picker. 400 and 401 are verified live. 402 and 429 remain unverified, since provoking + them means exhausting an account. 8. **Rate metadata.** Delete header-based rate parsing entirely rather than porting it. Verified: OpenRouter exposes only `content-type` and `cf-ray` to browser JavaScript via @@ -412,10 +461,10 @@ those 21 advertise tool support. | Browser CORS on `/api/v1/models` | Resolved | Verified: accessible cross-origin, no key required | | Free models lack tool support | Resolved | Verified: 18 of 21 free models advertise `tools` in `supported_parameters` | | Rate-limit headers unreadable in browser | Resolved | Verified: only `content-type` and `cf-ray` exposed. Drop header parsing; use `/api/v1/key` if needed | -| SSE keepalive comments break the stream reader | Open | Needs a funded key. First task of Phase 2 | -| 402/404/429 mappings unconfirmed | Open | Auth is checked first, so these need a valid key to provoke. Confirm during Phase 2 | -| `syncAiChatTokenVisibility` split leaves gaps | Open | Enumerated above; explicit matrix coverage in Playwright | -| Repository-independent chat hits untested paths | Open | Audit list above; local-mode specs with no PAT | +| SSE keepalive comments break the stream reader | Resolved | Verified live with a funded key; keepalive comments are ignored and stream completion is handled correctly | +| 402/404/429 mappings unconfirmed | Partial | 400 invalid model behavior is verified; 402 out-of-credits and 429 rate-limit remain to be validated against exhausted-account conditions | +| `syncAiChatTokenVisibility` split leaves gaps | Resolved | Chat visibility is decoupled from PAT gating; PR surface visibility remains PAT-scoped | +| Repository-independent chat hits untested paths | Partial | Core no-repository behavior is implemented and covered by focused tests; broader cross-browser matrix coverage remains | | Pinned default free slug goes away | Open | 404 on default falls back to the picker; periodic sanity check | | 50 req/day free limit feels broken to users | Open | Explicit 429 copy naming the limit and the credits threshold | | Key in `localStorage` is XSS-exposed | Accepted | Same threat model as the existing PAT; document it, and note the OpenRouter key is scoped to inference spend only, unlike the PAT which can write repositories | diff --git a/playwright/github-byot-ai.spec.ts b/playwright/github-byot-ai.spec.ts index ec587bd..b491630 100644 --- a/playwright/github-byot-ai.spec.ts +++ b/playwright/github-byot-ai.spec.ts @@ -5,7 +5,8 @@ import { appEntryPath, connectByotWithSingleRepo, ensureWorkspacesDrawerClosed, - ensureAiChatDrawerOpen, + connectOpenRouterKey, + openRouterTestKey, ensureOpenPrDrawerOpen, mockRepositoryBranches, openWorkspaceTab, @@ -20,7 +21,7 @@ import { } from './github-pr-drawer/github-pr-drawer.helpers.js' import { selectWorkspacesRepositoryFilter } from './github-pr-drawer/github-pr-drawer.helpers.js' -test('PR/BYOT controls are visible and chat stays hidden until token connect', async ({ +test('PR/BYOT controls are visible and chat is available without a GitHub token', async ({ page, }) => { await waitForAppReady(page) @@ -39,7 +40,7 @@ test('PR/BYOT controls are visible and chat stays hidden until token connect', a await expect(byotControls).toBeVisible() await expect(page.getByRole('textbox', { name: 'GitHub token' })).toBeVisible() await expect(page.getByRole('button', { name: 'Add GitHub token' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Chat' })).toBeHidden() + await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() await expect(page.getByRole('heading', { name: 'AI Chat' })).toBeHidden() await expect(prToggle).toHaveCount(1) await expect(prToggle).toBeHidden() @@ -47,6 +48,34 @@ test('PR/BYOT controls are visible and chat stays hidden until token connect', a await expect(workspacesToggle).toBeVisible() }) +test('chat drawer prompts for an OpenRouter key and gates the composer', async ({ + page, +}) => { + await waitForAppReady(page) + + await page.getByRole('button', { name: 'Chat', exact: true }).click() + await expect(page.getByRole('complementary', { name: 'AI Chat' })).toBeVisible() + + const keyInput = page.getByLabel('OpenRouter API key', { exact: true }) + await expect(keyInput).toBeVisible() + await expect( + page.getByRole('button', { name: 'Save OpenRouter API key' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Remove OpenRouter API key' }), + ).toBeHidden() + + await expect(page.getByLabel('Ask AI assistant')).toBeDisabled() + await expect(page.getByRole('button', { name: 'Send' })).toBeDisabled() + await expect(page.getByLabel('Chat model')).toBeDisabled() + + await connectOpenRouterKey(page) + + await expect(page.getByLabel('Ask AI assistant')).toBeEnabled() + await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled() + await expect(page.getByLabel('Chat model')).toBeEnabled() +}) + test('Workspaces repository filter is local-only and read-only without PAT', async ({ page, }) => { @@ -296,13 +325,52 @@ test('PAT connect after Local-only session preserves Local records and enables r ).toBe('local') }) -test('chat becomes available after token connect', async ({ page }) => { +test('GitHub token is never sent to OpenRouter and the chat key is never sent to GitHub', async ({ + page, +}) => { + const openRouterAuthHeaders: string[] = [] + const githubAuthHeaders: string[] = [] + + page.on('request', request => { + const auth = request.headers().authorization ?? '' + if (!auth) { + return + } + + if (request.url().includes('openrouter.ai')) { + openRouterAuthHeaders.push(auth) + } + + if (request.url().includes('api.github.com')) { + githubAuthHeaders.push(auth) + } + }) + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [{ message: { role: 'assistant', content: 'ok' } }], + }), + }) + }) + await waitForAppReady(page) await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) - await expect(page.getByRole('button', { name: 'Open pull request' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Workspaces' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() + await page.getByLabel('Ask AI assistant').fill('hello') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('ok', { exact: true })).toBeVisible() + + expect(openRouterAuthHeaders.length).toBeGreaterThan(0) + expect(githubAuthHeaders.length).toBeGreaterThan(0) + expect(openRouterAuthHeaders.every(header => header.includes(openRouterTestKey))).toBe( + true, + ) + expect(openRouterAuthHeaders.some(header => header.includes('github_pat'))).toBe(false) + expect(githubAuthHeaders.some(header => header.includes(openRouterTestKey))).toBe(false) }) test('workspace context status stays visible without PAT and after PAT connect', async ({ @@ -610,7 +678,7 @@ test('chat stays usable after opening a Local workspace with PAT connected', asy const localWorkspaceId = 'local_chat_issue_128' let streamRequestBody: ChatRequestBody | undefined - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -651,13 +719,13 @@ test('chat stays usable after opening a Local workspace with PAT connected', asy }, ]) - await connectByotWithSingleRepo(page) + await connectByotWithSingleRepo(page, { assertPrRepositorySelected: false }) await openStoredWorkspaceContextById(page, localWorkspaceId, { repositoryFilter: '__local__', }) await ensureWorkspacesDrawerClosed(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Confirm local workspace chat context.') await page.getByRole('button', { name: 'Send' }).click() @@ -694,7 +762,7 @@ test('BYOT controls render with default app entry', async ({ page }) => { await expect(byotControls).toBeVisible() await expect(page.getByRole('textbox', { name: 'GitHub token' })).toBeVisible() await expect(page.getByRole('button', { name: 'Add GitHub token' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Chat' })).toBeHidden() + await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible() await expect(prToggle).toHaveCount(1) await expect(prToggle).toBeHidden() await expect(workspacesToggle).toHaveCount(1) @@ -814,29 +882,10 @@ test('deleting saved GitHub token requires confirmation modal', async ({ page }) await expect(repositoryFilter).toHaveValue('__local__') }) -test('AI chat drawer opens and closes', async ({ page }) => { - await waitForAppReady(page, appEntryPath) - await connectByotWithSingleRepo(page) - - const chatToggle = page.getByRole('button', { name: 'Chat', exact: true }) - const chatDrawer = page.getByRole('heading', { name: 'AI Chat' }) - - await expect(chatToggle).toBeVisible() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') - - await chatToggle.click() - await expect(chatDrawer).toBeVisible() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'true') - - await page.getByRole('button', { name: 'Close AI chat drawer' }).click() - await expect(chatDrawer).toBeHidden() - await expect(chatToggle).toHaveAttribute('aria-expanded', 'false') -}) - test('AI chat prefers streaming responses when available', async ({ page }) => { let streamRequestBody: ChatRequestBody | undefined - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -855,25 +904,21 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Summarize this repository.') await page.getByRole('button', { name: 'Send' }).click() - await expect( - page.getByText('Response streamed from GitHub.', { exact: true }), - ).toHaveText('Response streamed from GitHub.') + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) await expect(page.getByText('Summarize this repository.')).toBeVisible() await expect(page.getByText('Streaming response ready')).toBeVisible() expect(streamRequestBody?.metadata).toBeUndefined() expect(streamRequestBody?.model).toBe(defaultChatModel) - expect(streamRequestBody?.tool_choice).toBe('auto') - expect( - streamRequestBody?.tools?.some( - tool => tool.type === 'function' && tool.function?.name === 'propose_editor_update', - ), - ).toBe(true) + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() expect(streamRequestBody?.messages?.[0]?.role).toBe('system') expect(streamRequestBody?.messages?.[0]?.content).toContain( 'expert software development assistant focused on CSS dialects and JSX syntax', @@ -923,10 +968,102 @@ test('AI chat prefers streaming responses when available', async ({ page }) => { ).toBe(true) }) +test('AI chat enables editor update tools only for explicit edit requests', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + streamRequestBody = route.request().postDataJSON() as ChatRequestBody + + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: [ + 'data: {"choices":[{"delta":{"content":"ok"}}]}', + '', + 'data: [DONE]', + '', + ].join('\n'), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await connectOpenRouterKey(page) + + await page + .getByLabel('Ask AI assistant') + .fill('Please update app.css to use blue text.') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) + + expect(streamRequestBody?.tool_choice).toBe('auto') + expect( + streamRequestBody?.tools?.some( + tool => tool.type === 'function' && tool.function?.name === 'propose_editor_update', + ), + ).toBe(true) +}) + +test('AI chat does not render apply actions for read-only visibility prompts', async ({ + page, +}) => { + let streamRequestBody: ChatRequestBody | undefined + + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + streamRequestBody = body + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: + 'Yes, I can see your editor content.\n\n```jsx\nconst App = () =>
Visible
\n```', + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () =>Before
') + await openWorkspaceTab(page, 'App.tsx') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Can you see my editor content?') + await page.getByRole('button', { name: 'Send' }).click() + + await expect(page.getByText('Fallback response loaded.', { exact: true })).toHaveText( + 'Fallback response loaded.', + ) + await expect(page.locator('button[data-action="request-apply"]')).toHaveCount(0) + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() +}) + test('AI chat can disable editor context payload via checkbox', async ({ page }) => { let streamRequestBody: ChatRequestBody | undefined - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -943,7 +1080,7 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) const includeEditorsToggle = page.getByLabel('Send tab content') await expect(includeEditorsToggle).toBeChecked() @@ -951,12 +1088,13 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) await page.getByLabel('Ask AI assistant').fill('No editor source this time.') await page.getByRole('button', { name: 'Send' }).click() - await expect( - page.getByText('Response streamed from GitHub.', { exact: true }), - ).toHaveText('Response streamed from GitHub.') + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) expect(streamRequestBody?.metadata).toBeUndefined() - expect(streamRequestBody?.tool_choice).toBe('none') + expect(streamRequestBody?.tool_choice).toBeUndefined() + expect(streamRequestBody?.tools).toBeUndefined() const systemMessages = streamRequestBody?.messages?.filter( (message: ChatRequestMessage) => message.role === 'system', ) @@ -982,7 +1120,7 @@ test('AI chat can disable editor context payload via checkbox', async ({ page }) test('AI chat proposals can be confirmed, applied, and undone per active tab', async ({ page, }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1041,7 +1179,7 @@ test('AI chat proposals can be confirmed, applied, and undone per active tab', a await setComponentEditorSource(page, 'const App = () => ') await setStylesEditorSource(page, '.button { color: red; }') await openWorkspaceTab(page, 'App.tsx') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') await page.getByRole('button', { name: 'Send' }).click() @@ -1109,7 +1247,7 @@ test('AI chat proposals can be confirmed, applied, and undone per active tab', a }) test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1166,7 +1304,7 @@ test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { await setComponentEditorSource(page, 'const App = () => ') await setStylesEditorSource(page, '.button { color: red; }') await openWorkspaceTab(page, 'App.tsx') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Suggest updates for both editors.') await page.getByRole('button', { name: 'Send' }).click() @@ -1195,7 +1333,7 @@ test('AI chat apply actions resolve dynamic tab targets', async ({ page }) => { test('AI chat applies the correct proposal when unresolved targets are filtered out', async ({ page, }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1251,7 +1389,7 @@ test('AI chat applies the correct proposal when unresolved targets are filtered await connectByotWithSingleRepo(page) await setComponentEditorSource(page, 'const App = () =>Before
') await openWorkspaceTab(page, 'App.tsx') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Update App tab only.') await page.getByRole('button', { name: 'Send' }).click() @@ -1269,7 +1407,7 @@ test('AI chat applies the correct proposal when unresolved targets are filtered test('AI chat renders a single apply action for multiple targets resolving to the same tab', async ({ page, }) => { - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (body?.stream) { @@ -1325,7 +1463,7 @@ test('AI chat renders a single apply action for multiple targets resolving to th await connectByotWithSingleRepo(page) await setComponentEditorSource(page, 'const App = () =>Before
') await openWorkspaceTab(page, 'App.tsx') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Update App tab once.') await page.getByRole('button', { name: 'Send' }).click() @@ -1335,12 +1473,73 @@ test('AI chat renders a single apply action for multiple targets resolving to th ) }) +test('AI chat shows guidance when an editor update target cannot be matched', async ({ + page, +}) => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { + const body = route.request().postDataJSON() as ChatRequestBody | null + + if (body?.stream) { + await route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ message: 'stream intentionally disabled in this test' }), + }) + return + } + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + choices: [ + { + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_unknown_target', + type: 'function', + function: { + name: 'propose_editor_update', + arguments: JSON.stringify({ + target: 'src/does-not-exist.ts', + content: 'export const value = 1', + }), + }, + }, + ], + }, + }, + ], + }), + }) + }) + + await waitForAppReady(page, `${appEntryPath}`) + await connectByotWithSingleRepo(page) + await setComponentEditorSource(page, 'const App = () =>Before
') + await openWorkspaceTab(page, 'App.tsx') + await connectOpenRouterKey(page) + + await page.getByLabel('Ask AI assistant').fill('Can you still see my tab content?') + await page.getByRole('button', { name: 'Send' }).click() + + await expect( + page.getByText( + 'Proposed editor update is ready, but I could not match its target to an open tab. Ask me to target the active tab or one of the listed tab ids or paths.', + ), + ).toHaveCount(1) + await expect(page.locator('button[data-action="request-apply"]')).toHaveCount(0) +}) + test('AI chat sends the currently active tab when context is enabled', async ({ page, }) => { let streamRequestBody: ChatRequestBody | undefined - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { streamRequestBody = route.request().postDataJSON() as ChatRequestBody await route.fulfill({ @@ -1358,13 +1557,13 @@ test('AI chat sends the currently active tab when context is enabled', async ({ await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) await setStylesEditorSource(page, '.button { color: red; }') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Use active tab context only.') await page.getByRole('button', { name: 'Send' }).click() - await expect( - page.getByText('Response streamed from GitHub.', { exact: true }), - ).toHaveText('Response streamed from GitHub.') + await expect(page.getByText('Response streamed.', { exact: true })).toHaveText( + 'Response streamed.', + ) const systemMessages = streamRequestBody?.messages?.filter( (message: ChatRequestMessage) => message.role === 'system', @@ -1393,7 +1592,7 @@ test('AI chat streaming text still updates while latest undo actions are visible }) => { let requestCount = 0 - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { requestCount += 1 const body = route.request().postDataJSON() as ChatRequestBody | null @@ -1465,7 +1664,7 @@ test('AI chat streaming text still updates while latest undo actions are visible await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) await setStylesEditorSource(page, '.button { color: red; }') - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('Suggest a styles update.') await page.getByRole('button', { name: 'Send' }).click() @@ -1493,7 +1692,7 @@ test('AI chat falls back to non-streaming response when streaming fails', async let fallbackAttemptCount = 0 const attemptedModels: string[] = [] - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody | null if (typeof body?.model === 'string') { attemptedModels.push(body.model) @@ -1532,9 +1731,9 @@ test('AI chat falls back to non-streaming response when streaming fails', async await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) - const selectedModel = 'openai/gpt-5-mini' + const selectedModel = 'openai/gpt-6-astra' await page.getByLabel('Chat model').selectOption(selectedModel) await expect(page.getByLabel('Chat model')).toHaveValue(selectedModel) @@ -1556,7 +1755,7 @@ test('clearing chat removes previous conversation context from new request', asy }) => { const streamBodies: ChatRequestBody[] = [] - await page.route('https://models.github.ai/inference/chat/completions', async route => { + await page.route('https://openrouter.ai/api/v1/chat/completions', async route => { const body = route.request().postDataJSON() as ChatRequestBody if (body?.stream) { streamBodies.push(body) @@ -1584,22 +1783,18 @@ test('clearing chat removes previous conversation context from new request', asy await waitForAppReady(page, `${appEntryPath}`) await connectByotWithSingleRepo(page) - await ensureAiChatDrawerOpen(page) + await connectOpenRouterKey(page) await page.getByLabel('Ask AI assistant').fill('First conversation prompt') await page.getByRole('button', { name: 'Send' }).click() - await expect( - page.getByText('Response streamed from GitHub.', { exact: true }), - ).toBeVisible() + await expect(page.getByText('Response streamed.', { exact: true })).toBeVisible() await page.getByRole('button', { name: 'Clear', exact: true }).click() await expect(page.getByText('Chat cleared.', { exact: true })).toBeVisible() await page.getByLabel('Ask AI assistant').fill('Second conversation prompt') await page.getByRole('button', { name: 'Send' }).click() - await expect( - page.getByText('Response streamed from GitHub.', { exact: true }), - ).toBeVisible() + await expect(page.getByText('Response streamed.', { exact: true })).toBeVisible() expect(streamBodies.length).toBeGreaterThanOrEqual(2) const latestMessages = streamBodies[streamBodies.length - 1]?.messages ?? [] diff --git a/playwright/helpers/app-test-helpers.ts b/playwright/helpers/app-test-helpers.ts index ef2772a..066bbda 100644 --- a/playwright/helpers/app-test-helpers.ts +++ b/playwright/helpers/app-test-helpers.ts @@ -408,8 +408,22 @@ export const ensureDiagnosticsDrawerClosed = async (page: Page) => { await expect(page.getByRole('complementary', { name: 'Diagnostics' })).toBeHidden() } +export const openRouterTestKey = 'sk-or-v1-fake-chat-key-1234567890' + +export const connectOpenRouterKey = async ( + page: Page, + key: string = openRouterTestKey, +) => { + await ensureAiChatDrawerOpen(page) + await page.getByLabel('OpenRouter API key', { exact: true }).fill(key) + await page.getByRole('button', { name: 'Save OpenRouter API key' }).click() + await expect( + page.getByRole('button', { name: 'Remove OpenRouter API key' }), + ).toBeVisible() +} + export const ensureAiChatDrawerOpen = async (page: Page) => { - const toggle = page.getByRole('button', { name: 'Chat' }) + const toggle = page.getByRole('button', { name: 'Chat', exact: true }) const isExpanded = await toggle.getAttribute('aria-expanded') if (isExpanded !== 'true') { diff --git a/src/app.js b/src/app.js index b120a23..c9fb9c5 100644 --- a/src/app.js +++ b/src/app.js @@ -132,6 +132,10 @@ const aiChatSend = document.getElementById('ai-chat-send') const aiChatStatus = document.getElementById('ai-chat-status') const aiChatRepository = document.getElementById('ai-chat-repository') const aiChatMessages = document.getElementById('ai-chat-messages') +const aiChatKey = document.getElementById('ai-chat-key') +const aiChatKeyInput = document.getElementById('ai-chat-key-input') +const aiChatKeyAdd = document.getElementById('ai-chat-key-add') +const aiChatKeyDelete = document.getElementById('ai-chat-key-delete') const githubPrToggle = document.getElementById('github-pr-toggle') const githubPrToggleLabel = document.getElementById('github-pr-toggle-label') const githubPrToggleIcon = document.getElementById('github-pr-toggle-icon') @@ -523,7 +527,6 @@ let chatDrawerController = { setOpen: () => {}, setSelectedRepository: () => {}, onActiveWorkspaceTabChange: () => {}, - setToken: () => {}, dispose: () => {}, } @@ -552,12 +555,8 @@ const prContextUi = createGitHubPrContextUiController({ stylesPrSyncIcon, stylesPrSyncIconPath, githubPrContextClose, - aiChatToggle, githubPrOpenIcon, githubPrPushCommitIcon, - closeChatDrawer: () => { - chatDrawerController.setOpen(false) - }, closePrDrawer: () => { prDrawerController.setOpen(false) }, @@ -634,8 +633,7 @@ const byotControls = createGitHubByotControls({ onTokenChange: token => { githubAiContextState.token = token workspaceContextStatusController.syncTokenState(token) - prContextUi.syncAiChatTokenVisibility(token) - chatDrawerController.setToken(token) + prContextUi.syncPrSurfaceVisibility(token) prDrawerController.setToken(token) editedIndicatorVisibilityController.refreshIndicators() }, @@ -1172,7 +1170,7 @@ const onPrContextStateChange = createPrContextStateChangeHandler({ editedIndicatorVisibilityController, }) -const githubChatWorkspaceActions = createChatWorkspaceActions({ +const chatWorkspaceActions = createChatWorkspaceActions({ getActiveWorkspaceTab, isStyleWorkspaceTab, getCssSource: () => getCssSource(), @@ -1383,9 +1381,12 @@ const chatWorkflows = initializeChatWorkflows({ aiChatStatus, aiChatRepository, aiChatMessages, - getToken: getCurrentGitHubToken, + aiChatKey, + aiChatKeyInput, + aiChatKeyAdd, + aiChatKeyDelete, getSelectedRepository: getCurrentSelectedRepository, - ...githubChatWorkspaceActions, + ...chatWorkspaceActions, getRenderMode: () => renderMode.value, getStyleMode: () => styleMode.value, getPersistedActivePrContext, diff --git a/src/index.html b/src/index.html index 8e08ae1..a79ebd0 100644 --- a/src/index.html +++ b/src/index.html @@ -176,30 +176,6 @@+ Chat needs an OpenRouter API key, including for free models. Free models cost + nothing but allow 50 requests per day without purchased credits. Your key is + stored only in this browser. + Create a key +
+