import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { createFakeChatSocket } from '@/spa/chat/test-support/fake-chat-socket'; import { MAX_MANIFEST_ITEMS } from '@/spa/chat/limits'; const { getSocketMock, destroySocketMock } = vi.hoisted(() => ({ getSocketMock: vi.fn(), destroySocketMock: vi.fn(), })); vi.mock('@/lib/socket', () => ({ getSocket: getSocketMock, destroySocket: destroySocketMock, })); import { ChatPage } from './chat'; function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string): void { const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; setter?.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); } function selectValue(el: HTMLSelectElement, value: string): void { const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set; setter?.call(el, value); el.dispatchEvent(new Event('change', { bubbles: true })); } function findButton(container: HTMLElement, text: string): HTMLButtonElement { const button = [...container.querySelectorAll('button')].find((candidate) => candidate.textContent?.includes(text), ); if (!button) throw new Error(`Button with text "${text}" not found`); return button; } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }); } const DEFAULT_CATALOG = { harnessId: 'pi', version: '2026-08-11', fingerprint: 'fp', models: [ { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5', displayName: 'GPT-5', reasoningCapability: true, inputTypes: ['text'], authState: 'ready', availability: 'available', }, { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude', displayName: 'Claude', reasoningCapability: true, inputTypes: ['text'], authState: 'ready', availability: 'available', }, ], }; /** A harness/catalog/selection HTTP stub for the chat-api the selection hook * drives. `selection` seeds the persisted tuple returned by the GET (a valid * in-catalog tuple by default, so `canSend` settles true after mount). */ function harnessFetch( selection: unknown = { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' }, ): typeof fetch { return vi.fn(async (input: unknown, init?: RequestInit) => { const url = String(input); const method = String(init?.method ?? 'GET').toUpperCase(); if (url === '/api/harnesses') { return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]); } if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) { return jsonResponse(DEFAULT_CATALOG); } if (url === '/api/chat/preferences/selection' && method === 'GET') { return jsonResponse({ selection }); } if (url === '/api/chat/preferences/selection' && method === 'PUT') { return jsonResponse({ selection: JSON.parse(String(init?.body)) }); } return new Response('not found', { status: 404 }); }) as unknown as typeof fetch; } /** Drains the selection hook's chained mount fetches (harnesses → selection → * catalog) and any pending PUT so derived `canSend` settles before assertions. */ async function flushAsync(times = 5): Promise { for (let i = 0; i < times; i += 1) { await act(async () => { await Promise.resolve(); }); } } /** Deterministic idempotency key for the Task Five red-first page send test. */ const PAGE_UUID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; /** Install a controllable `crypto.randomUUID` and return a restore fn. Uses * defineProperty on the crypto instance so it works whether or not the native * method is configurable (it lives on the prototype; an own property shadows it). */ function installRandomUUID(fn: () => string): () => void { const g = globalThis as { crypto?: { randomUUID?: () => string } }; if (!g.crypto) { Object.defineProperty(g, 'crypto', { configurable: true, writable: true, value: {} }); } const cryptoObj = g.crypto as { randomUUID?: () => string }; const original = Object.getOwnPropertyDescriptor(cryptoObj, 'randomUUID'); Object.defineProperty(cryptoObj, 'randomUUID', { configurable: true, writable: true, value: fn, }); return () => { if (original) { Object.defineProperty(cryptoObj, 'randomUUID', original); } else { Reflect.deleteProperty(cryptoObj, 'randomUUID'); } }; } /** * Task Five MAJOR-1: the send path is PROTOCOL-driven — the browser may send only * as the server advertised, once per connection, over the server-to-client-only * `chat:send-capability`. Model that advertisement for THIS connection id so the * page send tests take the intended branch. `legacy-message` is the default * (advertised in `beforeEach`/`remountWithFetch`); the pi turn-runtime tests * reset the generation and re-advertise `turn-send` via the helper below. */ function advertiseSendCapability(protocol: 'legacy-message' | 'turn-send' | 'unavailable'): void { fake.serverEmit('chat:send-capability', { protocol, connectionId: fake.socket.id }); } /** Reset the negotiated protocol to a fresh, unlocked generation (clearing the * default `legacy-message` advertisement + first-wins lock), then advertise the * pi turn-runtime `turn:send` protocol for this connection. The per-test override * for the page send tests that route through `turn:send`. */ async function advertiseTurnSendGeneration(): Promise { await act(async () => { fake.simulateReconnect(); }); await act(async () => { advertiseSendCapability('turn-send'); }); } let fake: ReturnType; let root: Root | null; let container: HTMLElement; beforeAll(() => { Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true, }); }); afterAll(() => { Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); }); beforeEach(async () => { fake = createFakeChatSocket(); getSocketMock.mockReset().mockReturnValue(fake.socket); destroySocketMock.mockReset(); vi.stubGlobal('fetch', harnessFetch()); container = document.createElement('div'); document.body.append(container); root = createRoot(container); await act(async () => { root?.render(); }); // Settle the selection hook's mount fetches so the default in-catalog tuple // persists and `canSend` is true for the existing send-path tests. await flushAsync(); // Model the server's post-auth send-capability advertisement (MAJOR-1). Most // page send tests exercise the legacy `message` branch; the pi turn-runtime // tests override to `turn-send` via advertiseTurnSendGeneration(). await act(async () => { advertiseSendCapability('legacy-message'); }); }); afterEach(async () => { await act(async () => { root?.unmount(); }); document.body.replaceChildren(); vi.unstubAllGlobals(); }); /** Re-mounts ChatPage against a custom fetch stub (e.g. an unset selection) for * tests that need a non-default selection scenario. */ async function remountWithFetch(fetchImpl: typeof fetch): Promise { await act(async () => { root?.unmount(); }); vi.stubGlobal('fetch', fetchImpl); root = createRoot(container); await act(async () => { root?.render(); }); await flushAsync(); // Re-advertise on the remounted connection — the prior generation's capability // does not carry across a remount (fresh hook instance, unadvertised protocol). await act(async () => { advertiseSendCapability('legacy-message'); }); } describe('ChatPage', () => { it('streams agent:text and agent:thinking, shows tool status, and finalizes on agent:end with usage', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmit('agent:start', { conversationId: 'c1' }); }); await act(async () => { fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'pondering…' }); fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Hel' }); fake.serverEmit('agent:text', { conversationId: 'c1', text: 'lo!' }); fake.serverEmit('agent:tool:start', { conversationId: 'c1', toolCallId: 't1', toolName: 'web_search', }); }); expect(container.textContent).toContain('pondering…'); expect(container.textContent).toContain('Hello!'); expect(container.textContent).toContain('web_search'); expect(container.textContent).toMatch(/running/i); await act(async () => { fake.serverEmit('agent:tool:end', { conversationId: 'c1', toolCallId: 't1', toolName: 'web_search', isError: false, }); fake.serverEmit('agent:end', { conversationId: 'c1', usage: { provider: 'anthropic', modelId: 'claude', thinkingLevel: 'medium', tokens: { input: 12, output: 34, cacheRead: 0, cacheWrite: 0, total: 46 }, cost: 0.02, context: { percent: 3, window: 200000 }, }, }); }); expect(container.textContent).toMatch(/success/i); expect(container.textContent).toContain('Hello!'); expect(container.textContent).toMatch(/46/); }); it('renders the commands manifest and session info, and lets the user pick a thinking level', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmit('commands:manifest', { manifest: { commands: [ { name: 'model', aliases: ['m'], description: 'Change the active model', scope: 'core', execution: 'socket', available: true, }, ], skills: [], version: 1, }, }); fake.serverEmit('session:info', { conversationId: 'c1', provider: 'anthropic', modelId: 'claude', thinkingLevel: 'medium', availableThinkingLevels: ['low', 'medium', 'high'], routingDecision: { model: 'claude', provider: 'anthropic', ruleName: 'default', reason: 'default routing', }, }); }); expect(container.textContent).toContain('model'); expect(container.textContent).toContain('Change the active model'); expect(container.textContent).toContain('anthropic'); expect(container.textContent).toContain('default routing'); const select = container.querySelector( 'select[aria-label="Thinking level"]', ) as HTMLSelectElement; expect(select).toBeTruthy(); expect([...select.options].map((o) => o.value)).toEqual(['low', 'medium', 'high']); await act(async () => { selectValue(select, 'high'); }); expect(fake.emitted).toContainEqual({ event: 'set:thinking', payload: { conversationId: 'c1', level: 'high' }, }); }); it('executes and approves commands with exact payloads and surfaces the approval affordance', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); }); const commandInput = container.querySelector( 'input[aria-label="Command name"]', ) as HTMLInputElement; const argsInput = container.querySelector( 'input[aria-label="Command arguments"]', ) as HTMLInputElement; await act(async () => { setValue(commandInput, 'model'); setValue(argsInput, 'gpt-5'); }); await act(async () => { findButton(container, 'Run command').click(); }); expect(fake.emitted).toContainEqual({ event: 'command:execute', payload: { conversationId: 'c1', command: 'model', args: 'gpt-5' }, }); await act(async () => { setValue(commandInput, 'deploy'); setValue(argsInput, 'prod'); }); await act(async () => { findButton(container, 'Request approval').click(); }); expect(fake.emitted).toContainEqual({ event: 'command:approve', payload: { conversationId: 'c1', command: 'deploy', args: 'prod' }, }); await act(async () => { fake.serverEmit('command:approval', { conversationId: 'c1', command: 'deploy', success: true, approvalId: 'ap1', expiresAt: '2026-01-01T00:00:00.000Z', }); }); expect(container.textContent).toMatch(/approved/i); await act(async () => { findButton(container, 'Run approved command').click(); }); expect(fake.emitted).toContainEqual({ event: 'command:execute', payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' }, }); }); it('shows visible alert surfaces for a server error and the structured contract reason for a failed command result', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' }); fake.serverEmit('command:result', { conversationId: 'c1', command: 'model', success: false, message: 'Unknown model', }); }); const alerts = [...container.querySelectorAll('[role="alert"]')]; const alertText = alerts.map((node) => node.textContent).join(' '); expect(alertText).toContain('The model is unavailable'); // The structured, contract-provided denial reason is visibly rendered. expect(alertText).toContain('Unknown model'); }); it('falls back to a stable "Command failed." copy when a failed command result has no usable message', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmitRaw('command:result', { conversationId: 'c1', command: 'model', success: false, message: { bad: 'object' }, }); }); const alerts = [...container.querySelectorAll('[role="alert"]')]; const alertText = alerts.map((node) => node.textContent).join(' '); expect(alertText).toContain('Command failed.'); }); it('caps availableThinkingLevels before storing and rendering a hostile session payload', async () => { const hostileLevels = Array.from({ length: MAX_MANIFEST_ITEMS + 50 }, (_, i) => `level-${i}`); await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmit('session:info', { conversationId: 'c1', provider: 'anthropic', modelId: 'claude', thinkingLevel: 'level-0', availableThinkingLevels: hostileLevels, }); }); const select = container.querySelector( 'select[aria-label="Thinking level"]', ) as HTMLSelectElement; expect(select).toBeTruthy(); expect(select.options.length).toBeLessThanOrEqual(MAX_MANIFEST_ITEMS); }); it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmitRaw('session:info', { conversationId: 'c1', provider: 'anthropic', modelId: 'claude', thinkingLevel: 'medium', availableThinkingLevels: null, }); }); expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy(); const select = container.querySelector( 'select[aria-label="Thinking level"]', ) as HTMLSelectElement; expect(select).toBeTruthy(); // A malformed level list still shows a visible, safe placeholder option // rather than a silently empty select. expect([...select.options]).toHaveLength(1); expect(select.options[0]?.textContent).toMatch(/unavailable/i); await act(async () => { selectValue(select, ''); }); expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0); }); it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmit('agent:start', { conversationId: 'c1' }); fake.serverEmitRaw('agent:end', { conversationId: 'c1', usage: { provider: { nested: 'object' }, modelId: undefined, thinkingLevel: 'medium', tokens: { total: 'not-a-number' }, cost: undefined, context: { percent: null, window: 200000 }, }, }); }); const usage = container.querySelector('[aria-label="Usage"]'); expect(usage).toBeTruthy(); expect(usage?.textContent).toContain('tokens unavailable'); expect(usage?.textContent).toContain('cost unavailable'); expect(usage?.textContent).not.toContain('0 tokens'); expect(usage?.textContent).not.toContain('$0.0000'); expect(usage?.textContent).toContain('unknown/unknown'); }); it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => { await expect( act(async () => { fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } }); }), ).resolves.not.toThrow(); const status = [...container.querySelectorAll('[role="status"]')].find((node) => node.textContent?.includes('Message accepted'), ); expect(status).toBeTruthy(); // A malformed messageId gets a stable, visible fallback — never blank, // never the raw object. expect(status?.textContent).toContain('unknown'); }); it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => { await expect( act(async () => { fake.serverEmitRaw('system:reload', { commands: [], skills: [], providers: [], message: { bad: 'object' }, }); }), ).resolves.not.toThrow(); const status = container.querySelector('[role="status"]'); expect(status).toBeTruthy(); // A malformed reload message renders a stable, visible fallback rather // than a silently empty status line. expect(status?.textContent).toContain('Commands reloaded.'); }); it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => { await expect( act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] }); }), ).resolves.not.toThrow(); expect(container.querySelector('[role="alert"]')).toBeTruthy(); }); it('renders harness and provider as separate selects (not merged) and no free-text provider/model inputs', async () => { // The old free-text inputs are gone. expect(container.querySelector('input[aria-label="Provider"]')).toBeNull(); expect(container.querySelector('input[aria-label="Model"]')).toBeNull(); const harnessSelect = container.querySelector( 'select[aria-label="Harness"]', ) as HTMLSelectElement; const providerSelect = container.querySelector( 'select[aria-label="Provider"]', ) as HTMLSelectElement; const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement; expect(harnessSelect).toBeTruthy(); expect(providerSelect).toBeTruthy(); expect(modelSelect).toBeTruthy(); // Harness and provider are distinct controls carrying distinct identifiers. expect(harnessSelect).not.toBe(providerSelect); expect([...harnessSelect.options].map((o) => o.value)).toContain('pi'); expect([...providerSelect.options].map((o) => o.value)).toContain('openai'); expect([...providerSelect.options].map((o) => o.value)).toContain('anthropic'); // The model options are catalog-derived (not hardcoded) and scoped to the // selected provider (openai, from the persisted tuple) using a collision-safe // composite identity — the anthropic row is absent, not a bare 'claude'. const modelValues = [...modelSelect.options].map((o) => o.value); expect(modelValues).toContain('openai:gpt-5'); expect(modelValues).not.toContain('anthropic:claude'); expect(modelValues).not.toContain('claude'); }); it('sends provider/model derived from the persisted catalog tuple (never free text) and emits abort from Stop', async () => { const textarea = container.querySelector( 'textarea[aria-label="Message"]', ) as HTMLTextAreaElement; const stopButtonBefore = container.querySelector( 'button[aria-label="Stop"]', ) as HTMLButtonElement; expect(stopButtonBefore.disabled).toBe(true); // Choose a fresh tuple from the catalog and let it persist. const providerSelect = container.querySelector( 'select[aria-label="Provider"]', ) as HTMLSelectElement; await act(async () => { selectValue(providerSelect, 'anthropic'); }); const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement; await act(async () => { // Composite provider+model option identity (provider was switched to // anthropic above); the bare 'claude' no longer identifies an option. selectValue(modelSelect, 'anthropic:claude'); }); await flushAsync(); await act(async () => { setValue(textarea, 'hello there'); }); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); // The projected provider/model come from the validated persisted tuple. expect(fake.emitted).toContainEqual({ event: 'message', payload: { conversationId: undefined, content: 'hello there', provider: 'anthropic', modelId: 'claude', }, }); expect(container.textContent).toContain('hello there'); await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmit('agent:start', { conversationId: 'c1' }); }); const stopButtonDuring = container.querySelector( 'button[aria-label="Stop"]', ) as HTMLButtonElement; expect(stopButtonDuring.disabled).toBe(false); await act(async () => { stopButtonDuring.click(); }); expect(fake.emitted).toContainEqual({ event: 'abort', payload: { conversationId: 'c1' } }); }); it('emits turn:send with the nested persisted selection tuple and a UUID idempotency key (never the legacy message event)', async () => { await advertiseTurnSendGeneration(); const restore = installRandomUUID(() => PAGE_UUID); try { // Send is disabled without an active conversation — establish one first. await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); }); const textarea = container.querySelector( 'textarea[aria-label="Message"]', ) as HTMLTextAreaElement; await act(async () => { setValue(textarea, 'hello there'); }); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); } finally { restore(); } const sends = fake.emitted.filter((e) => e.event === 'turn:send'); expect(sends).toHaveLength(1); expect(sends[0]?.payload).toEqual({ conversationId: 'c1', content: 'hello there', selection: { harnessId: 'pi', providerId: 'openai', modelId: 'gpt-5' }, idempotencyKey: PAGE_UUID, }); // The pi-rpc page send must not emit the embedded `message` event, and must // never send a flat {provider, modelId} that drops the harnessId. expect(fake.emitted.some((e) => e.event === 'message')).toBe(false); }); it('keeps the composer content and emits nothing when the send cannot mint an idempotency key, so the user can retry (composer clears only on success) — Task Five group 5', async () => { await advertiseTurnSendGeneration(); const failing = installRandomUUID(() => { throw new Error('secure random unavailable'); }); try { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); }); const textarea = container.querySelector( 'textarea[aria-label="Message"]', ) as HTMLTextAreaElement; await act(async () => { setValue(textarea, 'keep me'); }); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); // No wire traffic: neither the harness turn nor the legacy message. expect(fake.emitted.some((e) => e.event === 'turn:send')).toBe(false); expect(fake.emitted.some((e) => e.event === 'message')).toBe(false); // The composer retained its content — it clears ONLY on a successful send, // so the user can retry without retyping. expect(textarea.value).toBe('keep me'); // A visible, safe notice explains why nothing was sent. expect(container.querySelector('[role="alert"]')).toBeTruthy(); } finally { failing(); } }); it('clears the composer after a successful turn:send and never falls back to the legacy message event — Task Five group 5', async () => { await advertiseTurnSendGeneration(); const restore = installRandomUUID(() => PAGE_UUID); try { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); }); const textarea = container.querySelector( 'textarea[aria-label="Message"]', ) as HTMLTextAreaElement; await act(async () => { setValue(textarea, 'ship it'); }); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); const sends = fake.emitted.filter((e) => e.event === 'turn:send'); expect(sends).toHaveLength(1); expect(fake.emitted.some((e) => e.event === 'message')).toBe(false); // On a successful send the composer clears. expect(textarea.value).toBe(''); } finally { restore(); } }); it('sends the freshly persisted selection as a nested turn:send tuple after the user changes provider/model — never a stale default or flat fields — Task Five group 5', async () => { await advertiseTurnSendGeneration(); const restore = installRandomUUID(() => PAGE_UUID); try { // Change the selection away from the mount default and let it persist. const providerSelect = container.querySelector( 'select[aria-label="Provider"]', ) as HTMLSelectElement; await act(async () => { selectValue(providerSelect, 'anthropic'); }); const modelSelect = container.querySelector( 'select[aria-label="Model"]', ) as HTMLSelectElement; await act(async () => { selectValue(modelSelect, 'anthropic:claude'); }); await flushAsync(); await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); }); const textarea = container.querySelector( 'textarea[aria-label="Message"]', ) as HTMLTextAreaElement; await act(async () => { setValue(textarea, 'routed'); }); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); const sends = fake.emitted.filter((e) => e.event === 'turn:send'); expect(sends).toHaveLength(1); // The nested tuple reflects the CURRENTLY persisted selection, not the // mount default {openai, gpt-5}, and never flat provider/model fields. expect(sends[0]?.payload).toEqual({ conversationId: 'c1', content: 'routed', selection: { harnessId: 'pi', providerId: 'anthropic', modelId: 'claude' }, idempotencyKey: PAGE_UUID, }); expect(fake.emitted.some((e) => e.event === 'message')).toBe(false); } finally { restore(); } }); it('disables send until a selection has persisted — no send with an unset selection', async () => { await remountWithFetch(harnessFetch(null)); const sendButton = findButton(container, 'Send'); const textarea = container.querySelector( 'textarea[aria-label="Message"]', ) as HTMLTextAreaElement; await act(async () => { setValue(textarea, 'should not send'); }); // Content present, but no selection persisted → Send stays disabled. expect(sendButton.disabled).toBe(true); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(0); }); it('renders the session panel from a pre-ack session:info and keeps it visible after the later ack', async () => { const textarea = container.querySelector( 'textarea[aria-label="Message"]', ) as HTMLTextAreaElement; await act(async () => { setValue(textarea, 'hello'); }); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); await act(async () => { fake.serverEmit('session:info', { conversationId: 'c1', provider: 'anthropic', modelId: 'claude', thinkingLevel: 'medium', availableThinkingLevels: ['low', 'medium', 'high'], }); }); expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy(); expect(container.textContent).toContain('anthropic'); await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); }); expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy(); expect(container.textContent).toContain('anthropic'); }); it('surfaces a pre-ack error as an alert without leaving the Stop control stuck active', async () => { const textarea = container.querySelector( 'textarea[aria-label="Message"]', ) as HTMLTextAreaElement; await act(async () => { setValue(textarea, 'hello'); }); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); await act(async () => { fake.serverEmit('error', { conversationId: 'c1', error: 'Failed to start agent session. Please try again.', }); }); const alerts = [...container.querySelectorAll('[role="alert"]')]; expect(alerts.some((node) => node.textContent?.includes('Failed to start agent session'))).toBe( true, ); const stopButton = container.querySelector('button[aria-label="Stop"]') as HTMLButtonElement; expect(stopButton.disabled).toBe(true); }); it('shows an accessible status once the message is acknowledged', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); }); const statuses = [...container.querySelectorAll('[role="status"]')]; expect(statuses.some((node) => node.textContent?.includes('m1'))).toBe(true); }); it('renders finalized thinking text in the transcript after agent:end, not only while streaming', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmit('agent:start', { conversationId: 'c1' }); fake.serverEmit('agent:thinking', { conversationId: 'c1', text: 'reasoning about it' }); fake.serverEmit('agent:text', { conversationId: 'c1', text: 'Done.' }); }); expect(container.textContent).toContain('reasoning about it'); await act(async () => { fake.serverEmit('agent:end', { conversationId: 'c1' }); }); expect(container.textContent).toContain('reasoning about it'); expect(container.textContent).toContain('Done.'); }); it('ignores a concurrent approval request and only executes the approved command once', async () => { await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); }); const commandInput = container.querySelector( 'input[aria-label="Command name"]', ) as HTMLInputElement; const argsInput = container.querySelector( 'input[aria-label="Command arguments"]', ) as HTMLInputElement; await act(async () => { setValue(commandInput, 'deploy'); setValue(argsInput, 'prod'); }); await act(async () => { findButton(container, 'Request approval').click(); }); await act(async () => { setValue(argsInput, 'staging'); }); await act(async () => { findButton(container, 'Request approval').click(); }); expect(fake.emitted.filter((e) => e.event === 'command:approve')).toHaveLength(1); expect(fake.emitted).toContainEqual({ event: 'command:approve', payload: { conversationId: 'c1', command: 'deploy', args: 'prod' }, }); await act(async () => { fake.serverEmit('command:approval', { conversationId: 'c1', command: 'deploy', success: true, approvalId: 'ap1', expiresAt: '2026-01-01T00:00:00.000Z', }); }); await act(async () => { findButton(container, 'Run approved command').click(); findButton(container, 'Run approved command').click(); }); expect(fake.emitted.filter((e) => e.event === 'command:execute')).toHaveLength(1); expect(fake.emitted).toContainEqual({ event: 'command:execute', payload: { conversationId: 'c1', command: 'deploy', args: 'prod', approvalId: 'ap1' }, }); }); it('disables sending a second message while a turn is streaming', async () => { const textarea = container.querySelector( 'textarea[aria-label="Message"]', ) as HTMLTextAreaElement; await act(async () => { setValue(textarea, 'first'); }); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); await act(async () => { fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' }); fake.serverEmit('agent:start', { conversationId: 'c1' }); }); const sendButton = findButton(container, 'Send'); expect(sendButton.disabled).toBe(true); await act(async () => { setValue(textarea, 'second'); }); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); expect(fake.emitted.filter((e) => e.event === 'message')).toHaveLength(1); }); it('scopes the model options to the intentionally selected provider (cross-provider models absent)', async () => { await remountWithFetch(harnessFetch(null)); const harnessSelect = container.querySelector( 'select[aria-label="Harness"]', ) as HTMLSelectElement; await act(async () => { selectValue(harnessSelect, 'pi'); }); await flushAsync(); const providerSelect = container.querySelector( 'select[aria-label="Provider"]', ) as HTMLSelectElement; await act(async () => { selectValue(providerSelect, 'openai'); }); const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement; const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== ''); // Only the selected provider's models are offered — provider B's model // (anthropic:claude) is absent, so a user cannot pick across providers. expect(optionValues).toEqual(['openai:gpt-5']); expect(optionValues).not.toContain('anthropic:claude'); }); it('keeps identical modelIds under two providers distinct and resolves the pick to the exact tuple', async () => { const COLLIDING_CATALOG = { harnessId: 'pi', version: '2026-08-11', fingerprint: 'fp', models: [ { harnessId: 'pi', providerId: 'alpha', modelId: 'gpt-x', displayName: 'Alpha GPT-X', reasoningCapability: true, inputTypes: ['text'], authState: 'ready', availability: 'available', }, { harnessId: 'pi', providerId: 'beta', modelId: 'gpt-x', displayName: 'Beta GPT-X', reasoningCapability: true, inputTypes: ['text'], authState: 'ready', availability: 'available', }, ], }; const collidingFetch = vi.fn(async (input: unknown, init?: RequestInit) => { const url = String(input); const method = String(init?.method ?? 'GET').toUpperCase(); if (url === '/api/harnesses') { return jsonResponse([{ id: 'pi', displayName: 'Pi', capabilities: [] }]); } if (url.startsWith('/api/harnesses/') && url.endsWith('/catalog')) { return jsonResponse(COLLIDING_CATALOG); } if (url === '/api/chat/preferences/selection' && method === 'GET') { return jsonResponse({ selection: null }); } if (url === '/api/chat/preferences/selection' && method === 'PUT') { return jsonResponse({ selection: JSON.parse(String(init?.body)) }); } return new Response('not found', { status: 404 }); }) as unknown as typeof fetch; await remountWithFetch(collidingFetch); const harnessSelect = container.querySelector( 'select[aria-label="Harness"]', ) as HTMLSelectElement; await act(async () => { selectValue(harnessSelect, 'pi'); }); await flushAsync(); const providerSelect = container.querySelector( 'select[aria-label="Provider"]', ) as HTMLSelectElement; await act(async () => { selectValue(providerSelect, 'alpha'); }); const modelSelect = container.querySelector('select[aria-label="Model"]') as HTMLSelectElement; // The colliding modelId is provider-qualified in the option value, never a // bare id, so the two providers' 'gpt-x' rows are uniquely identifiable. const optionValues = [...modelSelect.options].map((o) => o.value).filter((v) => v !== ''); expect(optionValues).toEqual(['alpha:gpt-x']); await act(async () => { selectValue(modelSelect, 'alpha:gpt-x'); }); await flushAsync(); // The controlled select highlights the alpha row via the composite identity. expect(modelSelect.value).toBe('alpha:gpt-x'); const textarea = container.querySelector( 'textarea[aria-label="Message"]', ) as HTMLTextAreaElement; await act(async () => { setValue(textarea, 'ping'); }); await act(async () => { textarea.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }), ); }); // The persisted/sent tuple resolves to provider alpha — NOT beta — even // though the bare modelId 'gpt-x' exists under both providers. expect(fake.emitted).toContainEqual({ event: 'message', payload: { conversationId: undefined, content: 'ping', provider: 'alpha', modelId: 'gpt-x', }, }); }); it('removes socket handlers and tears down the socket on unmount, with no network calls', async () => { expect(fake.listeners.size).toBeGreaterThan(0); await act(async () => { root?.unmount(); }); root = null; for (const [, handlers] of fake.listeners) { expect(handlers.size).toBe(0); } expect(destroySocketMock).toHaveBeenCalledOnce(); }); });