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); });