Files
stack/packages/bus/tests/threads.test.mjs
T
jason.woltjeandClaude Opus 5.5 b2f57ca70d bus, business, harness, webui: the human and the chief of staff configure the assistant (row 155, Q169, #1649)
Jason's Q169: only the chief of staff removes the assistant; the chief
and the human configure it and its seat name.

Configure. A new organization key, assistants, replaces the vars,
tracker or definition of an instance whose definition is already the
assistant. holder and credentials are refused, and the whole document
still goes through validateBusinessDocument. set() refuses
organization-definition-refused if any other instance's definition
changed. THREAD_DEFINITION and END_PROTECTED move to
packages/business/src/vocabulary.mjs, so the broker and
launch-settings.mjs read one list. The human writes it with
mosaic limits set --file or the Console Settings view's Assistant
section; the chief writes it with change_organization. Both carry the
file's revision.

Seat name. The chief gets a typed set_name tool (name.set,
instance.name). The Console gets POST /api/name on the settings
boundary, through human-cli.

Remove. role.end and role.revoke of a threaded instance revoke every
thread of that instance whose latest claim is a claim, and report
threads: N. The raise and the check both record a missing holder run as
null. A new human-only verb, assistant.stop, revokes the human's own
threads of an assistant instance; the Console calls it from POST
/api/assistant-stop. The bundle, the end_run description and
roles/chief-of-staff.md now say the chief ends and configures the
assistant. No schema step.

Owner Darkwing, brief docs/plans/2026-10-16_q169-assistant-config.md.
Reviewers: Dewey (security) r1 approve 29012, r2 29014, r3 29018; Filbert r1 29010,
r2 29015, r3 29017, on candidate manifest 96cae28e...9016. Round 2 is
confirm-only after rows 162 and 125 moved the base under docs/TOOLS.md
and the cli README. Round 3 bumps row 164's webui/src/bus.mjs byte
baseline in packages/bus/tests/channel-proof.test.mjs for the eight
STATUS entries; Sage's gate on r2 failed only there. Mos's decision 123 go for the public/bus.js diff
(mos-0816) covers 9f9ce31b, unchanged here. Sage gate on 197722caa:
GATE_PASSED 25/25 (node 2370, selftests 422) 21:11:38Z, a rerun on the same tree after a first run stopped at pkg-webui on a board-held.test.mjs timeout that passes singly.

Nothing live. Follow-up brief in the packet: the names read rethrow, one
shared settings snapshot, null tests for the two new routes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 16:18:47 -05:00

548 lines
28 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, THREAD_PAGE } from '../src/broker.mjs';
// Row 121 (#1615): projects and threads in the bus. The assistant's messaging lists are the
// shipped roles/assistant.json's, so the chain below runs under the lists the stack runs.
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const roles = {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } },
};
const definitions = {
'chief-of-staff': {},
assistant: { messaging: shipped('assistant').messaging },
pm: {},
coder: {},
};
const demo = (extra = {}) => ({
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'chief' },
roles,
definitions,
projects: { stack: { root: '/nonexistent' } },
...extra,
},
});
function setup(t, businesses = demo()) {
const root = mkdtempSync(join(tmpdir(), 'bus-threads-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const b = new Broker({ store, businesses });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const caps = { human };
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const launch = (name, role, extra = {}) => {
caps[name] = b.bindLaunch({ business: 'demo', role, run: `${name}-run`, harness: 'pi', ...extra });
return caps[name];
};
const claim = (name) => call(name, 'role.claim');
const count = (sql, ...args) => store.get(sql, ...args).n;
const threadOf = (message) => store.get('SELECT thread FROM message_threads WHERE message=?', message)?.thread ?? null;
return { b, store, caps, call, launch, claim, count, threadOf };
}
// Two threads in one project, each with its own assistant run holding it.
function twoThreads(t, businesses) {
const ctx = setup(t, businesses);
const project = ctx.call('human', 'project.create', { name: 'stack work', repo: 'stack' }).id;
const A = ctx.call('human', 'thread.create', { project, name: 'alpha' }).id;
const B = ctx.call('human', 'thread.create', { project, name: 'beta' }).id;
for (const r of ['chief', 'pm', 'coder']) {
ctx.launch(r, r);
ctx.claim(r);
}
ctx.launch('a', 'assistant', { thread: A });
ctx.launch('b', 'assistant', { thread: B });
ctx.claim('a');
ctx.claim('b');
return { ...ctx, project, A, B };
}
const now = () => new Date().toISOString();
test('REQ-ROLE-2 REQ-THREAD-1: one holder per thread, through the broker and the trigger', (t) => {
const { b, store, call, launch, claim, A, B } = twoThreads(t);
launch('a2', 'assistant', { thread: A });
assert.throws(() => claim('a2'), { code: 'thread-already-held' });
// The trigger refuses a second claim that skips the broker.
const second = () =>
store.run(
"INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,'claim',?,?,?)",
now(),
'demo',
A,
'a2-run',
'pi',
'a2-run',
);
assert.throws(second, /thread already held/);
// Second case: one run holds one thread. a-run already holds A.
call('b', 'role.release');
assert.throws(
() =>
store.run(
"INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,'claim',?,?,?)",
now(),
'demo',
B,
'a-run',
'pi',
'a-run',
),
/run already holds a thread/,
);
// A released thread can be claimed again by a new run; the claim is the thread's, not the role's.
launch('b2', 'assistant', { thread: B });
assert.deepEqual(claim('b2'), { role: 'assistant', run: 'b2-run', thread: B });
assert.equal(store.get("SELECT count(*) n FROM role_claims WHERE role='assistant'").n, 0, 'no role claim for the instance');
// endLaunch releases the thread claim.
b.endLaunch({ business: 'demo', run: 'b2-run', reason: 'idle' });
assert.equal(store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', B).op, 'release');
});
test('REQ-ROLE-2 REQ-THREAD-1: the 3e triggers refuse writes that skip the broker', (t) => {
// A bare database from schema.sql, as schema.test.mjs does, so nothing but the triggers stands
// between a write and the tables.
const root = mkdtempSync(join(tmpdir(), 'bus-threads-sql-'));
const db = new DatabaseSync(join(root, 'bus.sqlite'));
t.after(() => {
db.close();
rmSync(root, { recursive: true, force: true });
});
db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'));
const run = (sql, ...args) => db.prepare(sql).run(...args);
const claim = (op, run_, extra = {}) =>
run(
'INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by,reason,decision) VALUES(?,?,?,?,?,?,?,?,?)',
now(),
extra.business ?? 'demo',
extra.thread ?? 'th-1',
op,
run_,
'pi',
run_,
extra.reason ?? null,
null,
);
run("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p-1',?,'demo','work',NULL,'jason')", now());
run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('th-1',?,'demo','p-1','assistant','jason','one')", now());
run("INSERT INTO thread_opens(at,thread,by) VALUES(?,'th-1','jason')", now());
run("INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES('m-1',?,'demo','human','human-cli','assistant','INFO','hi')", now());
run("INSERT INTO message_threads(message,thread) VALUES('m-1','th-1')");
// A thread is in its project's business.
assert.throws(
() => run("INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES('th-x',?,'other','p-1','assistant','jason','x')", now()),
/a thread belongs to its project's business/,
);
// A claim names the thread's business.
assert.throws(() => claim('claim', 'a-run', { business: 'other' }), /a thread claim names the thread's business/);
// Nothing releases or revokes a thread nobody holds.
assert.throws(() => claim('release', 'a-run'), /thread is not held/);
assert.throws(() => claim('revoke', 'a-run', { reason: 'stuck' }), /thread is not held/);
claim('claim', 'a-run');
// Only the holder releases; a revoke needs a decision or a reason.
assert.throws(() => claim('release', 'b-run'), /only the holder releases; others revoke/);
assert.throws(() => claim('revoke', 'a-run'), /revoke needs a resolved decision or a reason/);
claim('revoke', 'a-run', { reason: 'stuck' });
// Every 3e table is append-only: no UPDATE, no DELETE, no INSERT OR REPLACE.
const rows = {
projects: ["id='p-1'", 'name', "INSERT OR REPLACE INTO projects(seq,id,at,business,name,created_by) SELECT seq,id,at,business,name,created_by FROM projects WHERE id='p-1'"],
threads: ["id='th-1'", 'name', "INSERT OR REPLACE INTO threads(seq,id,at,business,project,instance,owner,name) SELECT seq,id,at,business,project,instance,owner,name FROM threads WHERE id='th-1'"],
thread_claims: ['seq=1', 'reason', 'INSERT OR REPLACE INTO thread_claims(seq,at,business,thread,op,holder_run,harness,by) SELECT seq,at,business,thread,op,holder_run,harness,by FROM thread_claims WHERE seq=1'],
message_threads: ["message='m-1'", 'thread', "INSERT OR REPLACE INTO message_threads(message,thread) VALUES('m-1','th-1')"],
thread_opens: ['seq=1', 'by', 'INSERT OR REPLACE INTO thread_opens(seq,at,thread,by) SELECT seq,at,thread,by FROM thread_opens WHERE seq=1'],
};
for (const [table, [where, col, replace]] of Object.entries(rows)) {
const only = new RegExp(`${table} is append-only`);
assert.throws(() => db.exec(`UPDATE ${table} SET ${col}=${col} WHERE ${where}`), only, `${table} UPDATE`);
assert.throws(() => db.exec(`DELETE FROM ${table} WHERE ${where}`), only, `${table} DELETE`);
assert.throws(() => db.exec(replace), only, `${table} INSERT OR REPLACE`);
}
});
test('REQ-MSG-1: a message to or from the assistant names its thread, or is refused', (t) => {
const { b, store, call, launch, count, threadOf, A, B } = twoThreads(t);
const before = count('SELECT count(*) n FROM messages');
// The chief writes to the assistant only in reply to thread work.
assert.throws(() => call('chief', 'message.send', { to: 'assistant', body: 'hi' }), {
code: 'thread-required-reply-to-thread-message',
});
// The user writes to a thread with thread.send, which checks the owner.
assert.throws(() => call('human', 'message.send', { to: 'assistant', body: 'hi' }), {
code: 'thread-required-use-thread-send',
});
assert.equal(count('SELECT count(*) n FROM messages'), before, 'a refused send stores nothing');
// A run of the instance names its thread at bind, and no other run names one.
assert.throws(() => launch('x', 'assistant'), { code: 'thread-required' });
assert.throws(() => launch('y', 'assistant', { thread: 'no-such-thread' }), { code: 'thread-not-found' });
assert.throws(() => launch('z', 'pm', { thread: A }), { code: 'thread-not-allowed' });
// Accepted messages: the user's, the assistant's to the user and the chief, a decision close.
const asked = call('human', 'thread.send', { thread: A, body: 'look at the build' });
const toUser = call('a', 'message.send', { to: 'human', body: 'on it', in_reply_to: asked.id });
const toChief = call('b', 'message.send', { to: 'chief', body: 'need a coder', class: 'REQUEST' });
assert.equal(threadOf(asked.id), A);
assert.equal(threadOf(toUser.id), A);
assert.equal(threadOf(toChief.id), B);
// The decision close to the assistant goes to the thread the raising run held.
const d = call('b', 'decision.raise', {
action: 'spend',
target: 'demo',
question: 'spend?',
options: [
{ key: 'yes', text: 'Yes' },
{ key: 'no', text: 'No' },
],
recommendation: 'no',
blocking: false,
});
call('human', 'decision.resolve', { id: d.id, choice: 'no' });
const close = store.get("SELECT id FROM messages WHERE class='DECISION' AND decision=?", d.id);
assert.equal(threadOf(close.id), B);
assert.equal(
count(
"SELECT count(*) n FROM messages m WHERE 'assistant' IN (m.to_role, m.from_role) AND NOT EXISTS(SELECT 1 FROM message_threads t WHERE t.message=m.id)",
),
0,
'every message to or from the assistant has its thread',
);
// The store refuses a thread on a message the instance is not party to.
const pmOnly = call('pm', 'message.send', { to: 'coder', body: 'unrelated' });
assert.throws(
() => store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', pmOnly.id, A),
/to or from the thread's instance/,
);
void b;
});
test('REQ-MSG-1: the chief of staff\'s reply comes back to the thread that sent the work', (t) => {
const { call, threadOf, A, B } = twoThreads(t);
// Each thread asks the chief, who hands the work to the coder and answers when the coder does.
const run = (who) => {
const ask = call(who, 'message.send', { to: 'chief', body: `work for ${who}`, class: 'REQUEST' });
return ask;
};
const askA = run('a');
const askB = run('b');
// The assistant's messages reach the chief through the broker; the trigger only binds messages to the instance.
const got = call('chief', 'message.receive').map((m) => m.id);
assert.deepEqual(got, [askA.id, askB.id]);
const assignB = call('chief', 'message.send', { to: 'coder', body: 'do B', class: 'ASSIGNMENT', in_reply_to: askB.id });
const assignA = call('chief', 'message.send', { to: 'coder', body: 'do A', class: 'ASSIGNMENT', in_reply_to: askA.id });
const doneA = call('coder', 'message.send', { to: 'chief', body: 'A done', class: 'RESULT', in_reply_to: assignA.id });
const doneB = call('coder', 'message.send', { to: 'chief', body: 'B done', class: 'RESULT', in_reply_to: assignB.id });
const replyB = call('chief', 'message.send', { to: 'assistant', body: 'B is done', class: 'RESULT', in_reply_to: doneB.id });
const replyA = call('chief', 'message.send', { to: 'assistant', body: 'A is done', class: 'RESULT', in_reply_to: doneA.id });
assert.equal(threadOf(replyA.id), A);
assert.equal(threadOf(replyB.id), B);
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [replyA.id]);
assert.deepEqual(call('b', 'message.receive').map((m) => m.id), [replyB.id]);
// A long chain that never loops: the walk follows it to the end, with no depth limit.
let at = call('a', 'message.send', { to: 'chief', body: 'long one', class: 'REQUEST' }).id;
for (let i = 0; i < 400; i++)
at = call(i % 2 ? 'chief' : 'coder', 'message.send', {
to: i % 2 ? 'coder' : 'chief',
body: `step ${i}`,
in_reply_to: at,
}).id;
const far = call('chief', 'message.send', { to: 'assistant', body: 'long one done', in_reply_to: at });
assert.equal(threadOf(far.id), A);
// A coder message with no threaded ancestor breaks the chain, and the refusal says how to recover.
const loose = call('coder', 'message.send', { to: 'chief', body: 'unrelated', class: 'INFO' });
assert.throws(() => call('chief', 'message.send', { to: 'assistant', body: 'fyi', in_reply_to: loose.id }), {
code: 'thread-required-reply-to-thread-message',
});
});
test('REQ-MSG-1: a reply can\'t carry one thread instance\'s thread to another thread instance', (t) => {
// thread.create refuses a business with two thread instances, so the second instance's thread
// is made by hand. The trigger would refuse the message_threads row too; the broker refuses first,
// with a code that says why.
const helper = { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } };
const { store, call, launch, claim, count } = setup(t, demo({ roles: { ...roles, helper } }));
const project = call('human', 'project.create', { name: 'two instances' }).id;
for (const [id, instance] of [['th-a', 'assistant'], ['th-h', 'helper']])
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, now(), 'demo', project, instance, 'jason', id);
launch('chief', 'chief');
claim('chief');
launch('a', 'assistant', { thread: 'th-a' });
claim('a');
const ask = call('a', 'message.send', { to: 'chief', body: 'work', class: 'REQUEST' });
const before = count('SELECT count(*) n FROM messages');
assert.throws(() => call('chief', 'message.send', { to: 'helper', body: 'done', in_reply_to: ask.id }), {
code: 'thread-instance-mismatch',
});
assert.equal(count('SELECT count(*) n FROM messages'), before);
// The same reply to the thread's own instance is fine.
call('chief', 'message.send', { to: 'assistant', body: 'done', in_reply_to: ask.id });
});
test('REQ-THREAD-1 REQ-USER-2: one thread\'s messages never reach another thread\'s holder', (t) => {
const { store, call, threadOf, A, B } = twoThreads(t);
const toA = call('human', 'thread.send', { thread: A, body: 'for A' });
const toB = call('human', 'thread.send', { thread: B, body: 'for B' });
assert.deepEqual(call('b', 'message.receive').map((m) => m.id), [toB.id]);
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [toA.id]);
assert.equal(threadOf(toA.id), A);
// A delivery of A's message to B's holder that skips the broker is refused by the store.
const toA2 = call('human', 'thread.send', { thread: A, body: 'again for A' });
for (const op of ['routed', 'delivered'])
assert.throws(
() =>
store.run(
'INSERT INTO deliveries(message,at,op,holder_run,transport,address) VALUES(?,?,?,?,?,?)',
toA2.id,
now(),
op,
'b-run',
'pi',
null,
),
/only to its thread's holder/,
op,
);
// B's holder can't read A's message either.
assert.throws(() => call('b', 'message.read', { id: toA.id }), { code: 'not-recipient' });
});
test('REQ-LAUNCH-1: a message to a thread with no holder waits, and the next holder gets it', (t) => {
const { b, call, launch, claim, A, B } = twoThreads(t);
b.endLaunch({ business: 'demo', run: 'a-run', reason: 'idle' });
const waiting = call('human', 'thread.send', { thread: A, body: 'while idle' });
const state = () => Object.fromEntries(b.threadsState('demo').map((s) => [s.thread, s]));
assert.equal(state()[A].pending, 1);
assert.equal(state()[A].holder, null);
assert.equal(state()[B].holder, 'b-run');
assert.equal(state()[B].pending, 0);
launch('a2', 'assistant', { thread: A });
claim('a2');
assert.equal(state()[A].holder, 'a2-run');
assert.deepEqual(call('a2', 'message.receive').map((m) => m.id), [waiting.id]);
assert.equal(state()[A].pending, 0);
assert.equal(state()[A].unread, 1, 'delivered and not read: a turn in progress');
call('a2', 'message.read', { id: waiting.id });
assert.equal(state()[A].unread, 0);
// An open is recorded for the launcher, with no message.
assert.equal(state()[B].lastOpen, null);
call('human', 'thread.open', { thread: B });
assert.ok(state()[B].lastOpen);
});
// Dewey's F3 (28591): the launcher stops an idle thread's run only through threadIdle, which
// rechecks the thread and releases it in one call.
test('REQ-THREAD-2 REQ-LAUNCH-1: threadIdle releases only a thread with nothing waiting, and only for its holder', (t) => {
const { b, call, launch, claim, A, B } = twoThreads(t);
const idle = (run, thread = A) => b.threadIdle({ business: 'demo', thread, run });
const state = () => Object.fromEntries(b.threadsState('demo').map((s) => [s.thread, s]));
const sent = call('human', 'thread.send', { thread: A, body: 'for A' });
assert.deepEqual(idle('a-run'), { released: false, pending: 1, unread: 0 });
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [sent.id]);
assert.deepEqual(idle('a-run'), { released: false, pending: 0, unread: 1 });
call('a', 'message.read', { id: sent.id });
// Only the thread's holder, for its own thread.
assert.throws(() => idle('b-run'), { code: 'not-holder' });
assert.throws(() => idle('a-run', B), { code: 'not-holder' });
assert.throws(() => idle('chief-run'), { code: 'not-holder' });
assert.throws(() => idle('nobody-run'), { code: 'unknown-run' });
assert.equal(state()[A].holder, 'a-run', 'a refusal releases nothing');
assert.deepEqual(idle('a-run'), { released: true });
assert.equal(state()[A].holder, null);
assert.equal(state()[B].holder, 'b-run');
// Once released, the run can't take the thread's next message; it waits for the next holder.
assert.throws(() => idle('a-run'), { code: 'not-holder' });
const later = call('human', 'thread.send', { thread: A, body: 'later for A' });
assert.throws(() => call('a', 'message.receive'), { code: 'not-holder' });
assert.equal(state()[A].pending, 1);
assert.equal(b.endLaunch({ business: 'demo', run: 'a-run', reason: 'idle' }).released, false);
assert.throws(() => idle('a-run'), { code: 'run-ended' });
launch('a2', 'assistant', { thread: A });
claim('a2');
assert.deepEqual(call('a2', 'message.receive').map((m) => m.id), [later.id]);
});
// A run that ends with a message delivered and unread strands it, as row 80 does for a role.
// It mustn't also keep every later holder of the thread from idling out.
test('REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread doesn\'t hold up the next holder', (t) => {
const { b, call, launch, claim, A } = twoThreads(t);
const state = () => b.threadsState('demo').find((s) => s.thread === A);
const stranded = call('human', 'thread.send', { thread: A, body: 'taken, then the run died' });
assert.deepEqual(call('a', 'message.receive').map((m) => m.id), [stranded.id]);
b.endLaunch({ business: 'demo', run: 'a-run', reason: 'crash' });
assert.deepEqual([state().pending, state().unread, state().holder], [0, 0, null], 'nothing counts with no holder');
launch('a2', 'assistant', { thread: A });
claim('a2');
assert.deepEqual([state().pending, state().unread], [0, 0], 'nor for the next holder');
assert.deepEqual(call('a2', 'message.receive'), [], 'not redelivered');
assert.deepEqual(b.threadIdle({ business: 'demo', thread: A, run: 'a2-run' }), { released: true });
});
// Dewey's F2 in 28591, documented rather than supported: a thread run is stopped, not revoked.
test('REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant', (t) => {
const { store, call, count, A, B } = twoThreads(t);
const d = call('pm', 'decision.raise', {
action: 'role.revoke',
target: 'assistant',
question: 'Stop the assistant?',
options: [
{ key: 'yes', text: 'Yes' },
{ key: 'no', text: 'No' },
],
recommendation: 'yes',
blocking: false,
});
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
// Row 155 (#1649): the assistant has no role_claims row, so the decision names no holder run, and
// the revoke reads the thread claims instead. Every held thread gets a revoke citing the decision.
assert.deepEqual(call('pm', 'role.revoke', { role: 'assistant', decision: d.id }), { revoked: true, threads: 2 });
assert.equal(count("SELECT count(*) n FROM role_claims WHERE role='assistant'"), 0);
for (const [thread, run] of [
[A, 'a-run'],
[B, 'b-run'],
])
assert.deepEqual(
{ ...store.get('SELECT op, holder_run, by, decision FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread) },
{ op: 'revoke', holder_run: run, by: 'pm-run', decision: d.id },
);
assert.throws(() => call('pm', 'role.revoke', { role: 'assistant', decision: d.id }), /decision-consumed/);
});
// Row 155 (#1649): the human stops the assistant from the Console. Q169 reads "remove" as ending
// its runs; the instance stays in the organization file.
test('Q169 REQ-USER-2: assistant.stop ends the human\'s own assistant thread runs, and only the human\'s', (t) => {
const { b, store, caps, call, launch, claim, project, A, B } = twoThreads(t);
const other = 'thread-of-someone-else';
store.run(
'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
other,
now(),
'demo',
project,
'assistant',
'someone-else',
'theirs',
);
launch('o', 'assistant', { thread: other });
claim('o');
const last = (thread) => ({
...store.get('SELECT op, holder_run, by, decision, reason FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread),
});
// A human session from inside an agent run is refused at the bind, so it never reaches the verb.
assert.throws(() => b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: false }), { code: 'human-required' });
for (const who of ['pm', 'chief', 'a'])
assert.throws(() => call(who, 'assistant.stop', { instance: 'assistant' }), { code: 'human-required' }, who);
assert.throws(() => call('human', 'assistant.stop', { instance: 'ghost' }), { code: 'unknown-role' });
assert.throws(() => call('human', 'assistant.stop', { instance: 'pm' }), { code: 'not-assistant' });
assert.throws(() => call('human', 'assistant.stop', {}), { code: 'invalid-request' });
assert.throws(() => call('human', 'assistant.stop', { instance: 'assistant', run: 'a-run' }), { code: 'invalid-request' });
assert.equal(last(A).op, 'claim');
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }), {
stopped: [
{ thread: A, run: 'a-run' },
{ thread: B, run: 'b-run' },
],
});
for (const [thread, run] of [
[A, 'a-run'],
[B, 'b-run'],
])
assert.deepEqual(last(thread), { op: 'revoke', holder_run: run, by: 'jason', decision: null, reason: 'stopped by the human' });
assert.deepEqual(last(other), { op: 'claim', holder_run: 'o-run', by: 'o-run', decision: null, reason: null });
// The stopped run's next call is refused, which its runner reads as the session gone, and it can't
// claim the thread again. The next run for the thread can.
assert.throws(() => call('a', 'inbox'), { code: 'not-holder' });
assert.throws(() => call('a', 'role.claim'), { code: 'run-revoked' });
assert.throws(() => call('human', 'assistant.stop', { instance: 'assistant' }), { code: 'not-held' });
launch('a2', 'assistant', { thread: A });
claim('a2');
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }), { stopped: [{ thread: A, run: 'a2-run' }] });
});
test('REQ-USER-2: the human verbs refuse another owner\'s thread', (t) => {
const { store, call, project, A } = twoThreads(t);
const other = 'thread-of-someone-else';
store.run(
'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
other,
now(),
'demo',
project,
'assistant',
'someone-else',
'theirs',
);
for (const [verb, args] of [
['thread.open', { thread: other }],
['thread.send', { thread: other, body: 'hi' }],
['thread.messages', { thread: other }],
])
assert.throws(() => call('human', verb, args), { code: 'thread-not-owned' }, verb);
assert.throws(() => call('human', 'thread.send', { thread: 'nope', body: 'hi' }), { code: 'thread-not-found' });
assert.deepEqual(
call('human', 'thread.list').map((x) => x.id),
[A, store.get("SELECT id FROM threads WHERE name='beta'").id],
'the list shows the user\'s own threads only',
);
// Agents have no thread verbs.
assert.throws(() => call('pm', 'thread.list'), { code: 'human-required' });
});
test('REQ-THREAD-1: thread.create refuses when the business has no thread instance or more than one', (t) => {
const none = demo({ roles: { ...roles, assistant: { ...roles.assistant, definition: 'pm' } } });
const a = setup(t, none);
const p1 = a.call('human', 'project.create', { name: 'p' }).id;
assert.throws(() => a.call('human', 'thread.create', { project: p1, name: 'x' }), { code: 'thread-instance-missing' });
const two = demo({ roles: { ...roles, helper: { ...roles.assistant } } });
const c = setup(t, two);
const p2 = c.call('human', 'project.create', { name: 'p' }).id;
assert.throws(() => c.call('human', 'thread.create', { project: p2, name: 'x' }), { code: 'thread-instance-ambiguous' });
assert.equal(a.count('SELECT count(*) n FROM threads'), 0);
assert.equal(c.count('SELECT count(*) n FROM threads'), 0);
});
test('REQ-ORG-2: projects and threads are named once, and the repo names a business project', (t) => {
const { call, project, count } = twoThreads(t);
assert.throws(() => call('human', 'project.create', { name: 'stack work' }), { code: 'project-exists' });
assert.throws(() => call('human', 'project.create', { name: 'n', repo: 'missing' }), { code: 'unknown-repo' });
assert.throws(() => call('human', 'project.create', { name: 'two\nlines' }), { code: 'invalid-request' });
assert.throws(() => call('human', 'thread.create', { project, name: 'alpha' }), { code: 'thread-exists' });
assert.throws(() => call('human', 'thread.create', { project: 'nope', name: 'x' }), { code: 'project-not-found' });
assert.deepEqual(
call('human', 'project.list').map(({ name, repo }) => ({ name, repo })),
[{ name: 'stack work', repo: 'stack' }],
);
assert.equal(count('SELECT count(*) n FROM projects'), 1);
});
test('REQ-THREAD-2: thread.messages returns the thread\'s history in pages and writes nothing', (t) => {
const { store, call, A, B } = twoThreads(t);
const sent = [];
for (let i = 0; i < THREAD_PAGE + 3; i++) sent.push(call('human', 'thread.send', { thread: A, body: `m${i}` }).id);
call('human', 'thread.send', { thread: B, body: 'other thread' });
const writes = () => store.get('SELECT (SELECT count(*) FROM deliveries) + (SELECT count(*) FROM events) n').n;
const before = writes();
const page = call('human', 'thread.messages', { thread: A });
assert.deepEqual(page.map((m) => m.id), sent.slice(0, THREAD_PAGE));
const rest = call('human', 'thread.messages', { thread: A, after: page.at(-1).id });
assert.deepEqual(rest.map((m) => m.id), sent.slice(THREAD_PAGE));
assert.deepEqual(call('human', 'thread.messages', { thread: A, limit: 2 }).map((m) => m.id), sent.slice(0, 2));
assert.equal(writes(), before, 'reading the history writes nothing');
for (const limit of [0, 201, 1.5, '5'])
assert.throws(() => call('human', 'thread.messages', { thread: A, limit }), { code: 'invalid-request' }, String(limit));
});