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(String(m.from_role || '?').slice(0, 1).toUpperCase())}${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() ? `` : ''}`;
+ // 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 ? `` : ''}`;
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 ? '- Messages unavailable.
' : '- No messages yet.
')}
${current.readFailed ? '
' : s.more ? '
Showing earlier history. Read forward to reach the current conversation.
' : '
Caught up to the last read.
'}
${threadWrites() ? `` : ''}${txt(receipt)}
${pane ? resume : ''}${redesigned || pane ? '' : ''}`;
}
// 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 });