Files
stack/packages/bus/tests/threads.test.mjs

659 lines
36 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 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
// legacy thread, with the thread's id.
const DEFAULT_SESSION = [
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
];
// 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. Row 264a: the claim is of the
// thread's default session, with the generation the trigger expects.
const sessionOf = (thread) => store.get('SELECT session FROM session_defaults WHERE thread=?', thread).session;
const second = () =>
store.run(
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,?,?,?,2,'claim',?,?,?)",
now(),
'demo',
A,
sessionOf(A),
'a2-run',
'pi',
'a2-run',
);
assert.throws(second, /session already held/);
// Second case: one run holds one session. a-run already holds A's.
call('b', 'role.release');
assert.throws(
() =>
store.run(
"INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by) VALUES(?,?,?,?,2,'claim',?,?,?)",
now(),
'demo',
B,
sessionOf(B),
'a-run',
'pi',
'a-run',
),
/run already holds a session/,
);
// 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);
// Row 264a (#1769): a claim row names its session and the generation thread_claim_generation
// expects, so each refusal below comes from the trigger it names.
const generation = (op) =>
op === 'claim'
? 1 + db.prepare("SELECT count(*) n FROM thread_claims WHERE session='s-1' AND op='claim'").get().n
: (db.prepare("SELECT generation FROM thread_claims WHERE session='s-1' ORDER BY seq DESC LIMIT 1").get()?.generation ?? null);
const claim = (op, run_, extra = {}) =>
run(
'INSERT INTO thread_claims(at,business,thread,session,generation,op,holder_run,harness,by,reason,decision) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
now(),
extra.business ?? 'demo',
extra.thread ?? 'th-1',
's-1',
generation(op),
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 sessions(id,at,business,thread,instance,owner,name,op) VALUES('s-1',?,'demo','th-1','assistant','jason','default','default')", now());
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,'th-1','s-1','jason')", now());
run("INSERT INTO thread_opens(at,thread,session,by) VALUES(?,'th-1','s-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,session) VALUES('m-1','th-1','s-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'), /session is not held/);
assert.throws(() => claim('revoke', 'a-run', { reason: 'stuck' }), /session 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, and row 264a's two, 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'"],
sessions: ["id='s-1'", 'name', "INSERT OR REPLACE INTO sessions(seq,id,at,business,thread,instance,owner,name,op) SELECT seq,id,at,business,thread,instance,owner,name,op FROM sessions WHERE id='s-1'"],
session_defaults: ['seq=1', 'by', 'INSERT OR REPLACE INTO session_defaults(seq,at,thread,session,by) SELECT seq,at,thread,session,by FROM session_defaults WHERE seq=1'],
thread_claims: ['seq=1', 'reason', 'INSERT OR REPLACE INTO thread_claims(seq,at,business,thread,session,generation,op,holder_run,harness,by) SELECT seq,at,business,thread,session,generation,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,session) VALUES('m-1','th-1','s-1')"],
thread_opens: ['seq=1', 'by', 'INSERT OR REPLACE INTO thread_opens(seq,at,thread,session,by) SELECT seq,at,thread,session,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 });
assert.equal(toUser.request, asked.request);
assert.ok(toUser.request, 'threaded reply retains the original request');
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,session) VALUES(?,?,(SELECT session FROM session_defaults WHERE thread=?))', pmOnly.id, A, 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);
for (const sql of DEFAULT_SESSION) store.run(sql, 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 session'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: 'launcher-released' });
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',
);
for (const sql of DEFAULT_SESSION) store.run(sql, other);
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 sql of DEFAULT_SESSION) store.run(sql, other);
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));
});
// Row 171 (#1668): a thread project named after a declared repository must carry it.
test('project.create refuses a repository name without its matching repo before any write', t => {
const { call, store } = setup(t, demo({ projects: { stack: {}, docs: {} } }));
const rows = () => store.all('SELECT * FROM projects');
for (const args of [{ name: 'stack' }, { name: 'stack', repo: 'docs' }]) {
assert.throws(() => call('human', 'project.create', args), { code: 'project-name-is-repo' });
assert.deepEqual(rows(), []);
}
// Unknown repo wins over the new refusal, which wins over an existing store name.
assert.throws(() => call('human', 'project.create', { name: 'stack', repo: 'missing' }), { code: 'unknown-repo' });
assert.deepEqual(rows(), []);
const made = call('human', 'project.create', { name: 'stack', repo: 'stack' });
assert.equal(rows().length, 1);
assert.equal(rows()[0].id, made.id);
assert.equal(rows()[0].repo, 'stack');
assert.throws(() => call('human', 'project.create', { name: 'stack' }), { code: 'project-name-is-repo' });
assert.throws(() => call('human', 'project.create', { name: 'stack', repo: 'stack' }), { code: 'project-exists' });
assert.equal(rows().length, 1);
});
test('project.create keeps unrelated names with and without a repository', t => {
const { call } = setup(t);
const loose = call('human', 'project.create', { name: 'notes' });
const linked = call('human', 'project.create', { name: 'work', repo: 'stack' });
assert.deepEqual(call('human', 'project.list').map(({ id, name, repo }) => ({ id, name, repo })), [
{ id: loose.id, name: 'notes', repo: null },
{ id: linked.id, name: 'work', repo: 'stack' },
]);
});
test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => {
const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])),
} }));
const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' });
for (const [instance, role] of Object.entries(extra)) {
const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance });
assert.equal(receipt.instance, instance);
assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance);
assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance);
ctx.launch(instance, instance, { thread: receipt.id });
assert.equal(ctx.claim(instance).thread, receipt.id);
const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` });
const read = ctx.call(instance, 'message.receive');
assert.deepEqual(read.map(m => m.id), [sent.id], role.definition);
assert.equal(ctx.threadOf(sent.id), receipt.id);
assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0);
assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }),
{ code: 'thread-required-use-thread-send' });
}
assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length);
});
test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => {
const ctx = setup(t);
const project = ctx.call('human', 'project.create', { name: 'Writing' }).id;
for (const instance of ['not-declared', 'pm', 'coder'])
assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' });
for (const instance of [null, 7, {}, [], '', 'bad name'])
assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' });
assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0);
const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' });
assert.equal(legacy.instance, 'assistant');
});
test('C2-09a: creative bindLaunch without a thread refuses thread-required', t => {
const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity'];
const extra = Object.fromEntries(creative.map((definition, i) => [`creative${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }]));
const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: {
...definitions, ...Object.fromEntries(creative.map(definition => [definition, {}])),
} }));
for (const instance of Object.keys(extra)) {
assert.throws(() => ctx.launch(`unthreaded-${instance}`, instance), { code: 'thread-required' });
}
assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0);
assert.equal(ctx.count('SELECT count(*) n FROM role_claims'), 0);
});