import { test, expect, type Page } from '@playwright/test' const CONTROL_URL = '/control/' const LIVE = process.env.OPENSQUILLA_E2E_LIVE === '1' const RESEARCH_SESSION_KEY = 'agent:main:webchat:e2eresearchsources' async function seedSearchHistory(page: Page, toolName: string) { await page.routeWebSocket(/\/ws$/, ws => { ws.onMessage(message => { let frame: Record try { frame = JSON.parse(String(message)) as Record } catch { return } if (frame?.type !== 'req' || frame.id === undefined) return if (frame.method === 'connect') { ws.send(JSON.stringify({ protocol: 3, policy: {} })) return } if (frame?.type === 'req' && frame.method === 'chat.history') { ws.send(JSON.stringify({ type: 'res', id: frame.id, ok: true, payload: { messages: [ { role: 'assistant', text: 'The answer cites the research result.', id: 'msg-e2e-research-source', timestamp: Math.floor(Date.now() / 1000) - 60, tool_calls: [ { tool_use_id: 'tool-e2e-research-source', name: toolName, input: { query: 'OpenSquilla web search' }, result: JSON.stringify({ ok: true, results: [ { title: 'OpenSquilla Search Notes', url: 'https://example.com/opensquilla-search', domain: 'example.com', provider: 'tavily', excerpt: 'Compact citation-ready excerpt.', fetched: true, }, ], }), status: 'success', }, ], }, ], has_more: false, }, })) return } ws.send(JSON.stringify({ type: 'res', id: frame.id, ok: true, payload: {} })) }) ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} })) }) } async function seedPersistedSearchSourcesHistory(page: Page) { await page.routeWebSocket(/\/ws$/, ws => { ws.onMessage(message => { let frame: Record try { frame = JSON.parse(String(message)) as Record } catch { return } if (frame?.type !== 'req' || frame.id === undefined) return if (frame.method === 'connect') { ws.send(JSON.stringify({ protocol: 3, policy: {} })) return } if (frame?.type === 'req' && frame.method === 'chat.history') { ws.send(JSON.stringify({ type: 'res', id: frame.id, ok: true, payload: { messages: [ { role: 'assistant', text: 'The answer cites the persisted source.', id: 'msg-e2e-persisted-source', timestamp: Math.floor(Date.now() / 1000) - 60, tool_calls: [ { tool_use_id: 'tool-e2e-persisted-source', name: 'web_search', input: { query: 'OpenSquilla persisted source' }, sources: [ { title: 'OpenSquilla Persisted Source', url: 'https://example.com/opensquilla-persisted-source', domain: 'example.com', provider: 'duckduckgo', fetched: true, }, ], result: JSON.stringify({ ok: true, results: [ { title: 'Truncated fallback source', url: 'https://example.com/opensquilla-persisted-sour…', domain: 'example.com', provider: 'duckduckgo', excerpt: 'Compacted persisted result with a dead-link URL.', fetched: true, }, ], }), status: 'success', }, ], }, ], has_more: false, }, })) return } ws.send(JSON.stringify({ type: 'res', id: frame.id, ok: true, payload: {} })) }) ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} })) }) } test.describe('Sources row and thinking disclosure', () => { test('idle chat renders no sources row or thinking disclosure', async ({ page }) => { await page.goto(CONTROL_URL + 'chat/new') await page.waitForSelector('.conn-pill', { timeout: 10000 }) await expect(page.locator('.sources-row')).toHaveCount(0) await expect(page.locator('.thinking-fold')).toHaveCount(0) }) test('replayed web_search results render a sources row with links', async ({ page }) => { await seedSearchHistory(page, 'web_search') await page.goto(CONTROL_URL + 'chat?session=' + encodeURIComponent(RESEARCH_SESSION_KEY)) await page.waitForSelector('.conn-pill', { timeout: 10000 }) await expect(page.locator('.msg-ai .tool-row[data-op="web.search"]').first()).toBeVisible({ timeout: 10000 }) const sourcesRow = page.locator('.msg-ai .sources-row').first() await expect(sourcesRow).toBeVisible({ timeout: 10000 }) await expect(sourcesRow.locator('.sources-row__count')).toHaveText('1') await sourcesRow.locator('.sources-row__toggle').click() const link = sourcesRow.locator('.sources-row__link') await expect(link).toHaveAttribute('href', 'https://example.com/opensquilla-search') await expect(sourcesRow.locator('.sources-row__title')).toHaveText('OpenSquilla Search Notes') }) test('replayed web_search prefers persisted sources over truncated result URLs', async ({ page }) => { await seedPersistedSearchSourcesHistory(page) await page.goto(CONTROL_URL + 'chat?session=' + encodeURIComponent(RESEARCH_SESSION_KEY)) await page.waitForSelector('.conn-pill', { timeout: 10000 }) await expect(page.locator('.msg-ai .tool-row[data-op="web.search"]').first()).toBeVisible({ timeout: 10000 }) const sourcesRow = page.locator('.msg-ai .sources-row').first() await expect(sourcesRow).toBeVisible({ timeout: 10000 }) await expect(sourcesRow.locator('.sources-row__count')).toHaveText('1') await sourcesRow.locator('.sources-row__toggle').click() const link = sourcesRow.locator('.sources-row__link') await expect(link).toHaveAttribute('href', 'https://example.com/opensquilla-persisted-source') await expect(sourcesRow.locator('.sources-row__title')).toHaveText('OpenSquilla Persisted Source') }) test('replayed web_discover results render as discovery without sources row', async ({ page }) => { await seedSearchHistory(page, 'web_discover') await page.goto(CONTROL_URL + 'chat?session=' + encodeURIComponent(RESEARCH_SESSION_KEY)) await page.waitForSelector('.conn-pill', { timeout: 10000 }) await expect(page.locator('.msg-ai .tool-row[data-op="web.discover"]').first()).toBeVisible({ timeout: 10000 }) await expect(page.locator('.msg-ai .sources-row')).toHaveCount(0) }) test('live search turn renders a sources row with real links', async ({ page }) => { test.skip(!LIVE, 'Live gateway test; set OPENSQUILLA_E2E_LIVE=1 to run.') test.setTimeout(240000) await page.goto(CONTROL_URL + 'chat/new') await page.waitForSelector('.conn-pill', { timeout: 10000 }) const textarea = page.locator('.chat-textarea') await textarea.fill('Use your web search tool to find one recent headline about renewable energy, then answer in one sentence.') await page.locator('.chat-send-btn[aria-label="Send"]').click() // The turn runs and completes. const ribbon = page.locator('.stream-activity') await expect(ribbon).toBeVisible({ timeout: 30000 }) await expect(ribbon).toHaveCount(0, { timeout: 180000 }) // Sources row appears on the finished assistant turn, collapsed. const sourcesRow = page.locator('.msg-ai .sources-row').first() await expect(sourcesRow).toBeVisible({ timeout: 30000 }) const toggle = sourcesRow.locator('.sources-row__toggle') await expect(toggle).toHaveAttribute('aria-expanded', 'false') await expect(toggle.locator('.sources-row__count')).toHaveText(/^[1-9]\d*$/) // Expanding reveals real external links. await toggle.click() await expect(toggle).toHaveAttribute('aria-expanded', 'true') const links = sourcesRow.locator('.sources-row__link') expect(await links.count()).toBeGreaterThan(0) for (const href of await links.evaluateAll(nodes => nodes.map(node => node.getAttribute('href') || ''))) { expect(href).toMatch(/^https?:\/\//) // Compacted tool results truncate long strings with a '…' suffix; such // URLs are dead links and must never be rendered as hrefs. expect(href).not.toMatch(/…|%E2%80%A6/i) } const resolvedHrefs = await links.evaluateAll(nodes => nodes.map(node => (node as HTMLAnchorElement).href), ) for (const href of resolvedHrefs) { expect(href).not.toMatch(/…|%E2%80%A6/i) } const rels = await links.evaluateAll(nodes => nodes.map(node => node.getAttribute('rel') || '')) for (const rel of rels) { expect(rel).toContain('noopener') expect(rel).toContain('noreferrer') } // Thinking disclosure only renders when the routed model emitted // reasoning; when present it must default to collapsed. const folds = page.locator('.thinking-fold') if (await folds.count() > 0) { for (const isOpen of await folds.evaluateAll(nodes => nodes.map(node => node.hasAttribute('open')))) { expect(isOpen).toBe(false) } } // The row survives a reload: tool results replay through chat.history. await page.reload() await page.waitForSelector('.conn-pill', { timeout: 10000 }) const replayedRow = page.locator('.msg-ai .sources-row').first() await expect(replayedRow).toBeVisible({ timeout: 30000 }) await expect(replayedRow.locator('.sources-row__toggle')).toHaveAttribute('aria-expanded', 'false') }) test('measured reasoning time survives the post-turn history sync', async ({ page }) => { test.skip(!LIVE, 'Live gateway test; set OPENSQUILLA_E2E_LIVE=1 to run.') test.setTimeout(240000) // Force the incremental merge ON regardless of the rollout default, before // any page script reads the flag. The merge keeps a finished turn's measured // reasoning.seconds when the next history snapshot lands; without it the // snapshot maps seconds back to 0 and the disclosure loses its elapsed. await page.addInitScript(() => { try { window.localStorage.setItem('opensquilla.chat.historyMerge', '1') } catch { /* storage blocked */ } }) await page.goto(CONTROL_URL + 'chat/new') await page.waitForSelector('.conn-pill', { timeout: 10000 }) // A multi-step reasoning prompt so the measured think time is comfortably // over a second; a trivial one-word answer can resolve in under 1s, which // renders as "Thought process" (seconds < 1) and cannot exercise the // seconds-preservation contract this test guards. const textarea = page.locator('.chat-textarea') await textarea.fill( 'Reason thoroughly and at length, step by step, before answering. ' + 'Logic puzzle: Alice, Bob, and Carol each own a different pet ' + '(cat, dog, fish) and like a different color (red, green, blue). ' + 'Clue 1: Alice does not own the cat. Clue 2: the dog owner likes blue. ' + 'Clue 3: Carol likes red. Clue 4: Bob does not like green. Work through ' + 'every deduction explicitly, then give the full solution in one sentence.', ) await page.locator('.chat-send-btn[aria-label="Send"]').click() // The turn runs and completes. const ribbon = page.locator('.stream-activity') await expect(ribbon).toBeVisible({ timeout: 30000 }) await expect(ribbon).toHaveCount(0, { timeout: 180000 }) // The finished assistant turn's reasoning disclosure shows the measured // elapsed. ReasoningPart renders the summary as "Thought for Ns" when // seconds >= 1 and degrades to "Thought process" when seconds is 0 — the // latter is what a snapshot that drops the measured seconds would produce. // Bind the real summary element, not the illustrative .__elapsed node. const summary = page.locator('.msg-ai .thinking-fold .thinking-fold__summary').first() await expect(summary).toBeVisible({ timeout: 30000 }) await expect(summary).toHaveText(/Thought for \d+/) await expect(summary).not.toHaveText(/Thought process/) // Wait past the ~50ms history-sync debounce plus a generous margin, then // re-assert: a sync that clobbered the row would have reset the summary to // "Thought process" here; the merge keeps the measured seconds. await page.waitForTimeout(500) await expect(summary).toHaveText(/Thought for \d+/) await expect(summary).not.toHaveText(/Thought process/) }) })