// SP6 (#1873): the Assistant is one system page. Up to three chat threads from any organization // open next to each other, each the chat thread view pinned to its own organization. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { browser } from './browser.mjs'; import { close } from './fixture.mjs'; const P = { alpha: '10000000-0000-4000-8000-00000000000a', beta: '10000000-0000-4000-8000-00000000000b', home: '10000000-0000-4000-8000-00000000000c' }; const T = { a1: '20000000-0000-4000-8000-0000000000a1', a2: '20000000-0000-4000-8000-0000000000a2', b1: '20000000-0000-4000-8000-0000000000b1', h1: '20000000-0000-4000-8000-0000000000c1', }; const NEW = '20000000-0000-4000-8000-0000000000b9'; // Three organizations. `state.down` names the ones whose bus is stopped; `state.sent` and // `state.created` record every chat write with the organization whose transport received it. function orgs(state) { const ORGS = [ { id: 'alpha', label: 'Alpha', theme: 'rosewood' }, { id: 'beta', label: 'Beta', theme: 'cobalt' }, { id: 'home', label: 'Home', theme: 'grove' }, ]; const PROJECTS = { alpha: [{ id: P.alpha, name: 'Spring launch' }], beta: [{ id: P.beta, name: 'API gateway' }], home: [{ id: P.home, name: 'Garden' }] }; const THREADS = { alpha: [{ id: T.a1, project: P.alpha, name: 'Launch email', instance: 'assistant' }, { id: T.a2, project: P.alpha, name: 'Kickoff notes', instance: 'assistant' }], beta: [{ id: T.b1, project: P.beta, name: 'Gateway auth', instance: 'assistant' }], home: [{ id: T.h1, project: P.home, name: 'Seed order', instance: 'assistant' }], }; const MESSAGES = { [T.a1]: 'Alpha draft is ready.', [T.a2]: 'Kickoff agenda.', [T.b1]: 'Beta tokens rotate nightly.', [T.h1]: 'Seeds arrive Monday.' }; const down = () => { throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); }; return { list: async () => ORGS.filter(o => !state.gone?.has(o.id)).map(o => ({ ...o, busHealthy: !state.down.has(o.id), ...(state.down.has(o.id) ? { busStopped: true } : {}) })), resolve: async id => { const org = ORGS.find(o => o.id === id); if (!org || state.gone?.has(id)) return null; if (state.down.has(id)) return { busHealthy: false, reads: null, bus: {}, chat: { projects: down, threads: down, sessions: down, shares: down, messages: down } }; const threads = () => [...THREADS[id], ...state.created.filter(c => c.org === id).map(c => ({ id: c.id, project: c.project, name: c.name, instance: c.instance }))]; return { busHealthy: true, send: async () => ({}), reads: { business: async () => ({ business: { id } }), settings: async () => ({}), busStatus: async () => ({ live: true, down: false }), queue: async () => ({ rows: [] }) }, bus: { inbox: async () => [], agents: async () => [], tasks: async () => [], 'thread.claims': async () => [], messages: async () => ({ messages: [], unread: 0 }), instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {} } } }), }, chat: { projects: async () => PROJECTS[id], threads: async ({ shared } = {}) => (shared ? [] : threads()), sessions: async ({ thread }) => [{ id: thread, name: 'default', default: true }], shares: async () => [], messages: async ({ thread }) => [ ...(MESSAGES[thread] ? [{ id: thread.replace(/^2/, '3'), from_role: 'assistant', body: MESSAGES[thread], at: '2026-11-15T10:00:00.000Z' }] : []), ...state.sent.filter(s => s.org === id && s.thread === thread).map(s => ({ id: s.id, from_role: 'human', body: s.body, at: '2026-11-15T10:01:00.000Z' })), ...(state.replies || []).filter(r => r.org === id && r.thread === thread).map((r, n) => ({ id: `50000000-0000-4000-8000-${String(n + 1).padStart(12, '0')}`, from_role: 'assistant', body: r.body, at: '2026-11-15T10:02:00.000Z' })), ], send: async ({ thread, body }) => { const sent = { org: id, thread, body, id: `40000000-0000-4000-8000-${String(state.sent.length + 1).padStart(12, '0')}` }; state.sent.push(sent); return { id: sent.id }; }, createThread: async ({ project, name, instance }) => { state.created.push({ org: id, project, name, instance, id: NEW }); return { id: NEW, instance }; }, }, }; }, }; } async function open(t, { width = 1440, height = 900, hash, state = { down: new Set(['home']), sent: [], created: [] } } = {}) { const organizations = orgs(state); const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server)); const b = await browser({ machine: false }); t.after(() => b.close()); await b.viewport(width, height); const base = `http://127.0.0.1:${server.address().port}/`; await b.navigate(base + hash); const wait = (expression, tries = 200) => b.evaluate(`(async()=>{for(let i=0;i<${tries};i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`); return { b, base, wait, state }; } const PANE = sel => `document.querySelector('[data-assist-pane="${sel}"]')`; const panes = `[...document.querySelectorAll('[data-assist-pane]')].map(p => p.dataset.assistPane)`; const shown = `[...document.querySelectorAll('[data-assist-pane]')].filter(p => p.getClientRects().length).map(p => p.dataset.assistPane)`; const query = `new URLSearchParams(location.hash.split('?')[1] || '').get('open')`; const ready = sel => `!!${PANE(sel)}?.querySelector('.chat-log li') && !!${PANE(sel)}?.querySelector('[data-ws-orgchip]')`; const SENT = '/Accepted message|Message shown in this conversation/'; const pick = (b, value) => b.evaluate(`(() => { const s = document.querySelector('[data-assist-open]'); s.value = ${JSON.stringify(value)}; if (s.value !== ${JSON.stringify(value)}) throw Error('no option ' + ${JSON.stringify(value)}); s.dispatchEvent(new Event('change', { bubbles: true })); })()`); test('the sidebar Assistant opens one system page, and two threads from two organizations open side by side with their chips', async t => { const { b, wait } = await open(t, { hash: '#/orgs/alpha/tasks' }); await wait(`!!document.querySelector('#sections a[href="#/assistant"]')`); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('#sections a')].map(a => a.textContent.trim())`).then(v => v.includes('Side by side')), false, 'no Side by side item'); await b.evaluate(`document.querySelector('#sections a[href="#/assistant"]').click()`); await wait(`location.hash.startsWith('#/assistant') && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`); assert.equal(await b.evaluate('document.querySelector(\'#sections a[href="#/assistant"]\').getAttribute("aria-current")'), 'page'); await wait(`!!document.querySelector('[data-assist-open] optgroup[label^="Beta"] option[value="beta:${T.b1}"]')`); await pick(b, `alpha:${T.a1}`); await pick(b, `beta:${T.b1}`); await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`]); assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`, `beta:${T.b1}`], 'both panes show at 1440'); const head = sel => b.evaluate(`(() => { const p = ${PANE(sel)}; return { title: p.querySelector('[data-assist-title]').textContent, org: p.querySelector('[data-ws-orgchip]').textContent, project: p.querySelector('[data-ws-projchip]')?.textContent ?? null, close: !!p.querySelector('button[data-assist-close]') }; })()`); assert.deepEqual(await head(`alpha:${T.a1}`), { title: 'Launch email', org: 'Alpha', project: 'Spring launch', close: true }); assert.deepEqual(await head(`beta:${T.b1}`), { title: 'Gateway auth', org: 'Beta', project: 'API gateway', close: true }); assert.match(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Alpha draft is ready\./); assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./); assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Beta tokens/); // One level-1 heading on the page; each pane's own form, field and ids are its own. assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view h1').length`), 1); const ids = await b.evaluate(`[...document.querySelectorAll('#bus-view [id]')].map(e => e.id)`); assert.equal(new Set(ids).size, ids.length, 'no duplicate ids across panes'); assert.equal(await b.evaluate(`document.querySelectorAll('#bus-view form textarea').length`), 2); }); test('the open set and its order live in the address, so a reload or a pasted address restores the same panes', async t => { const hash = `#/assistant?open=beta:${T.b1},alpha:${T.a2}`; const { b, base, wait } = await open(t, { hash }); await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)}`); assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`]); await pick(b, `alpha:${T.a1}`); await wait(`${ready(`alpha:${T.a1}`)}`); assert.equal(await b.evaluate(query), `beta:${T.b1},alpha:${T.a2},alpha:${T.a1}`); const address = await b.evaluate('location.hash'); await b.navigate('about:blank'); await b.navigate(base + address); await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a2}`)} && ${ready(`alpha:${T.a1}`)}`); assert.deepEqual(await b.evaluate(panes), [`beta:${T.b1}`, `alpha:${T.a2}`, `alpha:${T.a1}`]); // A hand-edited address is checked before use: bad selectors, repeats and a fourth pane are dropped. await b.navigate('about:blank'); await b.navigate(base + `#/assistant?open=alpha:${T.a1},alpha:${T.a1},nope,../x:${T.b1},beta:not-a-uuid,beta:${T.b1},alpha:${T.a2},home:${T.h1}`); await wait(`document.querySelectorAll('[data-assist-pane]').length === 3`); assert.deepEqual(await b.evaluate(panes), [`alpha:${T.a1}`, `beta:${T.b1}`, `alpha:${T.a2}`]); await wait(`${query} === 'alpha:${T.a1},beta:${T.b1},alpha:${T.a2}'`); // With three open the picker offers no fourth. assert.equal(await b.evaluate(`document.querySelector('[data-assist-open]').disabled`), true); }); test('a send in organization B\'s pane reaches B\'s thread while organization A was opened last', async t => { const state = { down: new Set(['home']), sent: [], created: [] }; const { b, wait } = await open(t, { hash: '#/orgs/beta/tasks', state }); await wait(`window.MosaicOrg?.id === 'beta'`); await b.evaluate(`location.hash = '#/orgs/alpha/tasks'`); await wait(`window.MosaicOrg?.id === 'alpha'`); await b.evaluate(`location.hash = '#/assistant?open=beta:${T.b1},alpha:${T.a1}'`); await wait(`${ready(`beta:${T.b1}`)} && ${ready(`alpha:${T.a1}`)}`); assert.equal(await b.evaluate('window.MosaicOrg.id'), 'alpha', 'alpha is the organization opened last'); const field = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`; await wait(`!${field}.disabled`); await b.evaluate(`(() => { const el = ${field}; el.value = 'rotate the beta key'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); await wait(`${SENT}.test(${PANE(`beta:${T.b1}`)}.innerText)`); assert.deepEqual(state.sent.map(({ org, thread, body }) => ({ org, thread, body })), [{ org: 'beta', thread: T.b1, body: 'rotate the beta key' }]); assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.innerText`), /Accepted message|Message shown in this conversation/, 'the other pane shows no receipt'); assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').value`), '', 'the other pane keeps its own draft'); // A send in A's pane reaches A. const other = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`; await b.evaluate(`(() => { const el = ${other}; el.value = 'alpha copy'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`); assert.deepEqual(state.sent.map(({ org, thread }) => `${org}:${thread}`), [`beta:${T.b1}`, `alpha:${T.a1}`]); // Live updates: a refresh shows the sent message in its own pane only. await b.evaluate('window.MosaicAssistant.refresh()'); await wait(`/rotate the beta key/.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`); assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /rotate the beta key/); }); test('closing a pane updates the address and leaves the others', async t => { const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` }); await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('button[data-assist-close]').click()`); await wait(`document.querySelectorAll('[data-assist-pane]').length === 1`); assert.equal(await b.evaluate(query), `beta:${T.b1}`); assert.match(await b.evaluate(`${PANE(`beta:${T.b1}`)}.innerText`), /Beta tokens rotate nightly\./); await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('button[data-assist-close]').click()`); await wait(`document.querySelectorAll('[data-assist-pane]').length === 0`); assert.equal(await b.evaluate(`location.hash`), '#/assistant'); assert.match(await b.evaluate(`document.getElementById('bus-view').innerText`), /No thread is open/); }); test('a stopped organization is named at the top, its pane says so, and the other panes keep working', async t => { const state = { down: new Set(['home']), sent: [], created: [] }; const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},home:${T.h1},beta:${T.b1}`, state }); await wait(`!!document.querySelector('#bus-view [data-ws-unread]') && ${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); assert.match(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Home/); assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view [data-ws-unread]').textContent`), /Alpha|Beta/); await wait(`/bus is stopped/i.test(${PANE(`home:${T.h1}`)}?.querySelector('[data-assist-line]')?.textContent || '')`); assert.equal(await b.evaluate(`${PANE(`home:${T.h1}`)}.querySelector('[data-ws-orgchip]').textContent`), 'Home'); assert.equal(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('form textarea').disabled`), false); // Beta's bus stops while its pane is open, with an unsent message in it: that pane says so and // only so, and holds the message; Alpha's still sends. const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`; await wait(`!!${betaField} && !${betaField}.disabled`); await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`); state.down.add('beta'); await b.evaluate('window.MosaicAssistant.refresh()'); await wait(`/bus is stopped/i.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && /Beta/.test(document.querySelector('#bus-view [data-ws-unread]')?.textContent || '')`); await wait(`!${betaField}`); const betaBody = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`); assert.match(betaBody, /Nothing is read for this thread while Beta's bus is stopped\. Your unsent message is kept/); assert.doesNotMatch(betaBody, /Refresh to try again|Read again|Conversation unavailable/, 'no line that says a refresh would help'); assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true, 'the held message counts as a draft'); const field = `${PANE(`alpha:${T.a1}`)}.querySelector('form textarea')`; assert.equal(await b.evaluate(`${field}.disabled`), false); await b.evaluate(`(() => { const el = ${field}; el.value = 'still here'; el.dispatchEvent(new Event('input', { bubbles: true })); el.form.requestSubmit(); })()`); await wait(`${SENT}.test(${PANE(`alpha:${T.a1}`)}.innerText)`); assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here']); // The picker names the stopped organizations and offers none of their threads. assert.match(await b.evaluate(`[...document.querySelectorAll('[data-assist-open] optgroup')].map(g => g.label).join('|')`), /Home ยท bus stopped/); // Beta's bus starts again: its pane reads again and the held message is back in its composer. state.down.delete('beta'); await b.evaluate('window.MosaicAssistant.refresh()'); await wait(`${ready(`beta:${T.b1}`)} && ${betaField}?.value === 'beta draft'`); assert.deepEqual(state.sent.map(({ org, body }) => `${org}:${body}`), ['alpha:still here'], 'the held message is not sent by itself'); }); test('a pane whose organization leaves the list keeps its unsent message and does not promise it comes back', async t => { const state = { down: new Set(['home']), gone: new Set(), sent: [], created: [] }; const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state }); await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); const betaField = `${PANE(`beta:${T.b1}`)}.querySelector('form textarea')`; await wait(`!!${betaField} && !${betaField}.disabled`); await b.evaluate(`(() => { const el = ${betaField}; el.value = 'beta draft'; el.dispatchEvent(new Event('input', { bubbles: true })); })()`); state.gone.add('beta'); await b.evaluate('window.MosaicAssistant.refresh()'); await wait(`/not on your organization list/.test(${PANE(`beta:${T.b1}`)}.querySelector('[data-assist-line]')?.textContent || '') && !${betaField}`); const body = await b.evaluate(`${PANE(`beta:${T.b1}`)}.querySelector('.assist-body').innerText`); assert.match(body, /Your unsent message is kept while this pane stays open\./); assert.doesNotMatch(body, /comes back/, 'no promise that an organization off the list reads again'); assert.equal(await b.evaluate('window.MosaicAssistant.hasDraft()'), true); assert.deepEqual(state.sent, []); }); test('an open pane shows a new reply on its own, with no refresh', async t => { const state = { down: new Set(['home']), sent: [], created: [], replies: [] }; const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state }); await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)}`); state.replies.push({ org: 'beta', thread: T.b1, body: 'Rotation finished.' }); // The page polls every 10 s; wait up to 15 s without calling refresh. await wait(`/Rotation finished\./.test(${PANE(`beta:${T.b1}`)}.querySelector('.chat-log').innerText)`, 500); assert.doesNotMatch(await b.evaluate(`${PANE(`alpha:${T.a1}`)}.querySelector('.chat-log').innerText`), /Rotation finished/); }); test('the old Side by side address lands on the Assistant page', async t => { const { b, wait } = await open(t, { hash: '#/orgs/alpha/side-by-side?a=alpha/organization&b=beta/organization&w=50&show=a' }); await wait(`location.hash === '#/assistant' && document.querySelector('#bus-view h1')?.textContent === 'Assistant'`); assert.equal(await b.evaluate(`!!window.MosaicPanes`), false, 'the Side by side module is gone'); await b.evaluate(`location.hash = '#/side-by-side'`); await wait(`location.hash === '#/assistant'`); // The old per-organization Assistant address, the messages page, still opens. await b.evaluate(`location.hash = '#/orgs/alpha/messages'`); await wait(`document.querySelector('#bus-view h1')?.textContent === 'Messages'`); }); test('at 390 one pane shows at a time with a tab strip to switch', async t => { const { b, base, wait } = await open(t, { width: 390, height: 844, hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}` }); await wait(`${ready(`alpha:${T.a1}`)} && document.querySelectorAll('[data-assist-tabs] [data-assist-show]').length === 2`); assert.deepEqual(await b.evaluate(shown), [`alpha:${T.a1}`]); assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-assist-tabs] [data-assist-show]')].map(x => [x.textContent.trim(), x.getAttribute('aria-pressed')])`), [['Launch email', 'true'], ['Gateway auth', 'false']]); await b.evaluate(`document.querySelector('[data-assist-tabs] [data-assist-show="beta:${T.b1}"]').click()`); await wait(`${ready(`beta:${T.b1}`)}`); assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]); assert.equal(await b.evaluate(`new URLSearchParams(location.hash.split('?')[1]).get('show')`), `beta:${T.b1}`); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= innerWidth'), true, 'no horizontal page scroll'); // A pasted address names the shown pane however its id is cased. await b.navigate('about:blank'); await b.navigate(base + `#/assistant?open=alpha:${T.a1},beta:${T.b1}&show=beta:${T.b1.toUpperCase()}`); await wait(`${ready(`beta:${T.b1}`)}`); assert.deepEqual(await b.evaluate(shown), [`beta:${T.b1}`]); }); test('"Open thread" starts a new thread in a chosen organization through that organization\'s own route', async t => { const state = { down: new Set(['home']), sent: [], created: [] }; const { b, wait } = await open(t, { hash: '#/assistant', state }); await wait(`!!document.querySelector('[data-assist-open] option[value="new:beta"]')`); await pick(b, 'new:beta'); await wait(`!!document.querySelector('[data-assist-new] select[name=project] option[value="${P.beta}"]') && !!document.querySelector('[data-assist-new] select[name=instance] option[value=assistant]')`); await b.evaluate(`(() => { const f = document.querySelector('[data-assist-new]'); f.elements.name.value = 'Rate limits'; f.requestSubmit(); })()`); await wait(`${ready(`beta:${NEW}`)} || !!${PANE(`beta:${NEW}`)}?.querySelector('form textarea')`); assert.deepEqual(state.created.map(({ org, project, name, instance }) => ({ org, project, name, instance })), [{ org: 'beta', project: P.beta, name: 'Rate limits', instance: 'assistant' }]); assert.equal(await b.evaluate(query), `beta:${NEW}`); });