From d50d79cdddb655969f4e2f3a8825f7aec08d3147 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 15 Nov 2026 09:19:14 -0600 Subject: [PATCH] fix(webui): sp11 item 3, Resume assistant shows only when the bus instances read says the thread's agent runs no process; a failed read or an unlisted agent keeps the control (#1873, lane sp11-fixes) --- packages/webui/src/public/chat.js | 22 ++++++++++++++++--- .../tests/assistant-threads-browser.test.mjs | 22 ++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index 6f55818bd..e5d0c284a 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -90,6 +90,16 @@ signedOut = true; forget(); document.dispatchEvent(new Event('mosaic-signed-out')); } + // SP11: whether the thread's agent runs no process, from the bus instances read the Agents page uses. + // true: paused or stopped; false: running; null: the read failed or does not list the agent. + async function agentIdle(instance, signal) { + try { + const res = await fetch(pane ? `/api/orgs/${encodeURIComponent(env.org)}/bus/instances` : '/api/bus/instances', { cache: 'no-store', redirect: 'manual', signal }); + if (!res.ok || res.type === 'opaqueredirect') return null; + const item = (await res.json())?.rows?.[0]?.instances?.[instance]; + return item && typeof item === 'object' && Array.isArray(item.running) ? item.running.length === 0 : null; + } catch (err) { if (err.name === 'AbortError') throw err; return null; } + } async function request(path, value, signal) { if (out()) throw new ChatError('signed-out'); let res; @@ -317,8 +327,10 @@ const messages = s.rows.map((m, i) => `
  • ${txt(m.from_role === 'human' ? 'You' : m.from_role)} ${stamp(m.at)}
    ${messageBody(m)}${!redesigned && withRequest.has(m.id) ? mount(m) : ''}
  • ${redesigned ? (mountsAfter.get(i) || []).map(origin => `
  • ${mount(origin)}
  • `).join('') : ''}`).join(''); const frame = pane ? '' : redesigned ? window.MosaicProjects.header(project, 'conversations', current.threads, t.name, true) : heading(t.name); // A pane leaves out the details: sessions and sharing are unavailable on an organization Console. - const resume = threadWrites() ? `` : ''; - const details = pane ? `

    Reading never wakes the assistant. An accepted message is not an answer.

    ` : redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${threadWrites() ? `` : ''}`; + // SP11: Resume shows unless the instances read says the agent is running. + const resumable = threadWrites() && current.agentIdle !== false; + const resume = resumable ? `` : ''; + const details = pane ? `

    Reading never wakes the assistant. An accepted message is not an answer.

    ` : redesigned ? `

    Conversation details

    ${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${resumable ? `` : ''}
    ` : `
    Conversation details${sessionControls(t)}${sharing(t)}

    Reading never wakes the assistant. An accepted message is not an answer.

    ${resumable ? `` : ''}`; return `
    ${pane ? '' : `

    ${link('projects', t.project, current.projects.find(p => p.id === t.project)?.name ?? 'Project')} / ${txt(t.instance)}

    `}${frame}${libraryLine(libraryOf(current, t))}${details}${redesigned ? '

    ' : ''}
      ${messages || (current.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${current.readFailed ? '' : s.more ? '

    Showing earlier history. Read forward to reach the current conversation.

    ' : '

    Caught up to the last read.

    '}
    ${threadWrites() ? `

    To: ${redesigned ? `${txt(t.instance)}` : `${txt(t.instance)} · Private conversation`}

    ${redesigned ? '

    Enter adds a new line.

    ' : ''}
    ` : ''}

    ${txt(receipt)}

    ${pane ? resume : ''}${redesigned || pane ? '' : '

    Files

    Files unavailable.
    '}
    `; } // C2-10c: rooms reuse the same state, renderer and generation fences as threads. @@ -789,7 +801,7 @@ paint(); return; } const old = current; - current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id }; + current = { container, kind, id, orgArchived: old?.orgArchived ?? false, loading: true, archiveUnknown: kind === 'thread' && !user && !tester, settings: !!settings, section, path: originalPath, canSend, user, tester, chatGrants: [], thread: old && old.id === id ? old.thread : null, shared: old && old.id === id ? old.shared : false, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', readFailed: false, channelDisabled: old?.channelDisabled ?? false, refused: false, legacyDefault: old && old.id === id ? old.legacyDefault : false, sessions: old && old.id === id ? old.sessions ?? [] : [], session: match[3] ?? (old && old.id === id ? old.session : null), key: match[3] ? id + '/' + match[3] : old && old.id === id ? old.key : id, agentIdle: old && old.id === id ? old.agentIdle : null }; current.projectArchived = current.projects.some(p => p.id === (kind === 'project' ? id : current.thread?.project) && p.archived); if (kind === 'thread' && old?.kind === 'thread' && old.id === id && !navigated && old.archiveUnknown === false && old.projects.some(p => p.id === old.thread?.project)) current.archiveUnknown = false; if (current.projectArchived) current.archiveUnknown = false; @@ -873,6 +885,10 @@ if (g !== generation || out()) return; current.shares = shares.rows; } + if (kind === 'thread' && current.thread && threadWrites()) { + current.agentIdle = await agentIdle(current.thread.instance, signal); + if (g !== generation || out()) return; + } let s = null, page = null, after = null; if (kind === 'thread' && catalog === null) { current.legacyDefault = true; current.sessions = []; current.session = null; diff --git a/packages/webui/tests/assistant-threads-browser.test.mjs b/packages/webui/tests/assistant-threads-browser.test.mjs index e265c43d6..096634248 100644 --- a/packages/webui/tests/assistant-threads-browser.test.mjs +++ b/packages/webui/tests/assistant-threads-browser.test.mjs @@ -42,7 +42,7 @@ function orgs(state) { 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: {} } } }), + instances: async () => ({ business: id, revision: 'a'.repeat(64), definitions: {}, instances: { assistant: { definition: 'assistant', vars: {}, ...(state.run?.[id] ? { running: state.run[id] } : {}) } } }), }, chat: { projects: async () => PROJECTS[id], @@ -304,6 +304,26 @@ test('SP7: a thread link on a project page opens the thread as a pane on the Ass assert.equal(await b.evaluate('location.hash'), `#/orgs/beta/projects/threads/${T.b1}`); }); +// SP11 (#1873): Resume assistant shows only when the organization's instances read says the thread's +// agent runs no process (paused or stopped). An agent the read does not say about keeps the control. +test('Resume assistant shows only in a pane whose agent the instances read says is not running', async t => { + const state = { down: new Set(), sent: [], created: [], run: { alpha: ['run-1'], beta: [] } }; + const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1},home:${T.h1}`, state }); + const resume = sel => `!!${PANE(sel)}.querySelector('button.pane-resume')`; + await wait(`${ready(`alpha:${T.a1}`)} && ${ready(`beta:${T.b1}`)} && ${ready(`home:${T.h1}`)}`); + await wait(resume(`beta:${T.b1}`)); + assert.equal(await b.evaluate(resume(`alpha:${T.a1}`)), false, 'a running assistant offers no resume'); + assert.equal(await b.evaluate(resume(`home:${T.h1}`)), true, 'a read that does not say keeps the control'); + // Alpha's assistant stops: the next read shows the control. + state.run.alpha = []; + await b.evaluate('window.MosaicAssistant.refresh()'); + await wait(resume(`alpha:${T.a1}`)); + // Beta's starts again: its control goes. + state.run.beta = ['run-2']; + await b.evaluate('window.MosaicAssistant.refresh()'); + await wait(`!${resume(`beta:${T.b1}`)}`); +}); + test('SP7: with three open, a fourth thread replaces the first pane with no unsent message; when all three hold one, nothing is replaced and the page says why', async t => { const state = { down: new Set(), sent: [], created: [] }; const { b, wait } = await open(t, { hash: `#/assistant?open=alpha:${T.a1},beta:${T.b1}`, state });