217 lines
13 KiB
JavaScript
217 lines
13 KiB
JavaScript
// Row 272 / 264e: user-facing sessions use the same human channel as threads.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { run } from '../src/cli.mjs';
|
|
import { io } from './helpers.mjs';
|
|
const T = '10000000-0000-4000-8000-000000000001';
|
|
const A = '20000000-0000-4000-8000-000000000002';
|
|
const B = '30000000-0000-4000-8000-000000000003';
|
|
function setup() {
|
|
const calls = [], rows = [{ id: A, name: 'default', default: true }, { id: B, name: 'second', default: false }];
|
|
const transport = () => async (verb, args) => {
|
|
if (verb === 'plugins') return { plugins: { channel: ['cli'] } };
|
|
calls.push([verb, args]);
|
|
if (verb === 'thread.list') return [{ id: T, project: A, name: 'thread' }];
|
|
if (verb === 'project.list') return [{ id: A, name: 'project' }];
|
|
if (verb === 'session.list') return rows;
|
|
if (verb === 'session.create') return { id: B, thread: T, name: args.name };
|
|
if (verb === 'thread.open') return { opened: T };
|
|
throw Object.assign(new Error('unknown-verb'), { code: 'unknown-verb' });
|
|
};
|
|
const cli = async args => {
|
|
const x = io(); const code = await run(['session', ...args, '--business', 'demo'], x, { system: { dataRoot: '/unused' }, transport });
|
|
return { code, out: x.out.text, err: x.err.text };
|
|
};
|
|
return { calls, rows, cli };
|
|
}
|
|
test('session list/show/open select one session without changing the saved default', async () => {
|
|
const s = setup();
|
|
const listed = await s.cli(['list', T, '--json']);
|
|
assert.equal(listed.code, 0, listed.err);
|
|
assert.deepEqual(JSON.parse(listed.out), s.rows);
|
|
const shown = await s.cli(['show', T, B, '--json']);
|
|
assert.equal(shown.code, 0, shown.err);
|
|
assert.equal(JSON.parse(shown.out).id, B);
|
|
const opened = await s.cli(['open', T, B, '--json']);
|
|
assert.equal(opened.code, 0, opened.err);
|
|
assert.deepEqual(s.calls.at(-1), ['thread.open', { thread: T, session: B }]);
|
|
assert.equal(s.rows[0].default, true);
|
|
});
|
|
test('create requires a reusable operation key and returns the broker identity', async () => {
|
|
const s = setup();
|
|
assert.equal((await s.cli(['create', T, 'second'])).code, 4);
|
|
assert.ok(!s.calls.some(([v]) => v === 'session.create'));
|
|
const made = await s.cli(['create', T, 'second', '--op', 'create-second', '--json']);
|
|
assert.equal(made.code, 0, made.err);
|
|
assert.equal(JSON.parse(made.out).id, B);
|
|
assert.deepEqual(s.calls.at(-1), ['session.create', { thread: T, name: 'second', op: 'create-second' }]);
|
|
});
|
|
test('invalid/foreign session selection never falls back to the default or opens a session', async () => {
|
|
const s = setup();
|
|
for (const ref of ['missing', T, '200']) assert.notEqual((await s.cli(['open', T, ref])).code, 0);
|
|
assert.ok(!s.calls.some(([v]) => v === 'thread.open'));
|
|
assert.equal((await s.cli(['list', T, '--session', A])).code, 4);
|
|
});
|
|
test('unsupported clear refuses with a reason and never opens or creates a replacement', async () => {
|
|
const s = setup();
|
|
const result = await s.cli(['clear', T, B, '--yes', '--op', 'clear-second']);
|
|
assert.notEqual(result.code, 0);
|
|
assert.match(result.err, /unavailable|not available|not implemented/);
|
|
assert.ok(!s.calls.some(([v]) => ['session.create', 'thread.open'].includes(v)));
|
|
});
|
|
|
|
test('clear waits by operation reads only, keeps revision, then reports pending at 30s', async () => {
|
|
const { sessionCommand } = await import('../src/session.mjs');
|
|
const x = io(), seen = [];
|
|
let clock = 0, accepted = false;
|
|
const pending = { op:'clear-one', thread:T, session:B, successor:A, state:'uncertain', start:null, refusal:null, at:null };
|
|
const call = async (verb, args) => {
|
|
seen.push([verb, args]);
|
|
if (verb === 'thread.list') return [{id:T,project:A,name:'thread'}];
|
|
if (verb === 'project.list') return [{id:A,name:'project'}];
|
|
if (verb === 'session.list') return [{id:B,name:'second',revision:3}];
|
|
if (verb === 'session.clear') { accepted=true; return pending; }
|
|
if (verb === 'session.operation') { if (!accepted) throw Object.assign(new Error('operation-not-found'),{code:'operation-not-found'}); return pending; }
|
|
assert.fail(verb);
|
|
};
|
|
await sessionCommand(['clear',T,B,'--yes','--op','clear-one'], x, () => call, { now:()=>clock, sleep:async ms=>{clock+=ms;} });
|
|
assert.equal(clock, 30000);
|
|
assert.equal(seen.filter(([v])=>v==='session.clear').length, 1);
|
|
assert.deepEqual(seen.find(([v])=>v==='session.clear')[1], {thread:T,session:B,revision:3,confirm:true,op:'clear-one'});
|
|
assert.match(x.out.text, /uncertain.*pending/);
|
|
assert.match(x.err.text, /session operation.*clear-one/);
|
|
assert.ok(seen.filter(([v])=>v==='session.operation').every(([,a])=>a.thread===T && a.op==='clear-one'));
|
|
});
|
|
test('count output distinguishes exact zero, lower bounds and unavailable collection', async () => {
|
|
const { compactionLabel } = await import('../src/session.mjs');
|
|
assert.equal(compactionLabel({known:true,count:0,reason:null}), '0');
|
|
assert.match(compactionLabel({known:false,count:0,reason:'harness-unsupported'}), /^unknown.*harness-unsupported/);
|
|
assert.match(compactionLabel(undefined), /^unavailable/);
|
|
assert.match(compactionLabel({known:true,count:-1}), /^unknown/);
|
|
});
|
|
|
|
test('session CLI real broker: create/list/open keeps default and selectors isolated', async t => {
|
|
const { Broker } = await import('../../bus/src/broker.mjs');
|
|
const { Store } = await import('../../bus/src/store.mjs');
|
|
const { mkdtempSync, rmSync } = await import('node:fs');
|
|
const { tmpdir } = await import('node:os');
|
|
const { join } = await import('node:path');
|
|
const root = mkdtempSync(join(tmpdir(),'mosaic-session-cli-'));
|
|
const store = new Store(root); t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
|
const b = new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'chief',delivery:'chief'},roles:{chief:{definition:'chief-of-staff',authority:{withinRole:[],crossRole:[]}},assistant:{definition:'assistant',authority:{withinRole:[],crossRole:[]}}},definitions:{assistant:{},'chief-of-staff':{}}}}});
|
|
const cap = b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
|
const call = async (verb,args={}) => verb === 'plugins' ? {plugins:{channel:['cli']}} : b.request(cap,{verb,args});
|
|
const project = (await call('project.create',{name:'work'})).id;
|
|
const thread = (await call('thread.create',{project,name:'one'})).id;
|
|
const command = async args => { const x=io();const code=await run(['session',...args,'--business','demo'],x,{system:{dataRoot:root},transport:()=>call});assert.equal(code,0,x.err.text);return x.out.text; };
|
|
const [defaultSession] = JSON.parse(await command(['list',thread,'--json']));
|
|
const second = JSON.parse(await command(['create',thread,'second','--op','new-second','--json']));
|
|
assert.notEqual(second.id, defaultSession.id);
|
|
await command(['open',thread,second.id]);
|
|
assert.equal(store.get('SELECT session FROM thread_opens ORDER BY seq DESC LIMIT 1').session, second.id);
|
|
assert.equal(JSON.parse(await command(['list',thread,'--json'])).find(r=>r.default).id, defaultSession.id);
|
|
});
|
|
|
|
test('explicit --session on thread reads, opens and talk never drops the selected id', async () => {
|
|
const B = '30000000-0000-8000-8000-000000000003'; // clear successor v8; thread T remains v4
|
|
const seen = [];
|
|
const transport = () => async (verb,args={}) => {
|
|
if (verb === 'plugins') return {plugins:{channel:['cli']}};
|
|
seen.push([verb,args]);
|
|
if (verb === 'thread.list') return [{id:T,project:A,name:'thread'}];
|
|
if (verb === 'project.list') return [{id:A,name:'project'}];
|
|
if (verb === 'thread.messages') return [];
|
|
if (verb === 'thread.open') return {opened:T};
|
|
if (verb === 'thread.send') return {id:A};
|
|
assert.fail(verb);
|
|
};
|
|
for (const args of [['thread','read',T,'--session',B], ['thread','open',T,'--session',B], ['talk','--thread',T,'hello','--session',B,'--wait','0']]) {
|
|
const x=io();assert.equal(await run([...args,'--business','demo'],x,{system:{dataRoot:'/unused'},transport}),0,x.err.text);
|
|
}
|
|
assert.ok(seen.filter(([v])=>['thread.messages','thread.open','thread.send'].includes(v)).every(([,a])=>a.session===B));
|
|
assert.equal(seen.filter(([v])=>v==='thread.send').length,1);
|
|
const x=io();assert.equal(await run(['talk','assistant','hello','--session',B],x,{system:{dataRoot:'/unused'},transport}),4);
|
|
});
|
|
|
|
test('repeating clear with a recorded operation queries it and never sends a new revision', async () => {
|
|
const { sessionCommand } = await import('../src/session.mjs');
|
|
const x=io(), seen=[];
|
|
const call=async (verb,args)=>{
|
|
seen.push(verb);
|
|
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
|
|
if(verb==='project.list')return [{id:A,name:'project'}];
|
|
if(verb==='session.list')return [{id:B,name:'second',revision:9,state:'retired'}];
|
|
if(verb==='session.operation')return {op:'old-op',thread:T,session:B,successor:A,state:'retired',start:'held'};
|
|
assert.fail(`unexpected write ${verb}`);
|
|
};
|
|
await sessionCommand(['clear',T,B,'--yes','--op','old-op','--json'],x,()=>call);
|
|
assert.equal(JSON.parse(x.out.text).state,'retired');
|
|
assert.equal(seen.filter(v=>v==='session.clear').length,0);
|
|
});
|
|
|
|
test('new CLI surface refuses forged agent identity before reaching transport', async () => {
|
|
const x=io();x.env.MOSAIC_RUN_ID='worker';let calls=0;
|
|
const code=await run(['session','list',T,'--business','demo'],x,{system:{dataRoot:'/unused'},transport:()=>{calls++;return async()=>[];}});
|
|
assert.equal(code,3);assert.equal(calls,0);assert.match(x.err.text,/outside any agent/);
|
|
});
|
|
|
|
test('cancel requires confirmation, uses the clear op and accepts cancelled before or after retirement', async () => {
|
|
const { sessionCommand } = await import('../src/session.mjs');
|
|
for (const retired of [null, true]) {
|
|
const seen=[], x=io();
|
|
const envelope={op:'clear-one',thread:T,session:B,successor:A,state:'cancelled',start:retired ? 'held' : null,retired};
|
|
const call=async (verb,args)=>{
|
|
seen.push([verb,args]);
|
|
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
|
|
if(verb==='project.list')return [{id:A,name:'project'}];
|
|
if(verb==='session.cancel')return envelope;
|
|
assert.fail(verb);
|
|
};
|
|
await assert.rejects(sessionCommand(['cancel',T,'clear-one'],x,()=>call),e=>e.exitCode===4);
|
|
assert.deepEqual(seen,[]);
|
|
await sessionCommand(['cancel',T,'clear-one','--yes','--json'],x,()=>call);
|
|
assert.deepEqual(JSON.parse(x.out.text),envelope);
|
|
assert.deepEqual(seen.at(-1),['session.cancel',{thread:T,op:'clear-one',confirm:true}]);
|
|
assert.ok(!seen.some(([v])=>['session.create','session.clear','thread.open'].includes(v)));
|
|
}
|
|
});
|
|
|
|
test('cancelled operation is terminal: clear polling stops without another write', async () => {
|
|
const { sessionCommand } = await import('../src/session.mjs');
|
|
const x=io();let slept=false;
|
|
const call=async verb=>{
|
|
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
|
|
if(verb==='project.list')return [{id:A,name:'project'}];
|
|
if(verb==='session.list')return [{id:B,name:'second',revision:3}];
|
|
if(verb==='session.operation')return {op:'cancelled-op',thread:T,session:B,successor:A,state:'cancelled',retired:null,start:null};
|
|
assert.fail(`unexpected write ${verb}`);
|
|
};
|
|
await sessionCommand(['clear',T,B,'--op','cancelled-op','--yes','--json'],x,()=>call,{sleep:async()=>{slept=true;}});
|
|
assert.equal(JSON.parse(x.out.text).state,'cancelled');assert.equal(slept,false);
|
|
});
|
|
|
|
test('routed message cancellation prints its stored receipt and never waits for a reply', async () => {
|
|
for(const cancelled of ['session-clearing','session-retired']) {
|
|
const x=io(), seen=[];
|
|
const transport=()=>async(verb,args)=>{seen.push(verb);assert.equal(verb,'message.send');return {id:A,cancelled};};
|
|
const code=await run(['talk','assistant','reply','--business','demo'],x,{system:{dataRoot:'/unused'},transport});
|
|
assert.equal(code,3);assert.match(x.out.text,new RegExp(A));assert.match(x.err.text,new RegExp(cancelled));
|
|
assert.deepEqual(seen,['message.send']);
|
|
}
|
|
});
|
|
|
|
test('272 N2: reusing another session clear key gives a precise refusal without a write', async () => {
|
|
const {sessionCommand}=await import('../src/session.mjs');
|
|
const x=io(), seen=[];
|
|
const call=async verb=>{
|
|
seen.push(verb);
|
|
if(verb==='thread.list')return [{id:T,project:A,name:'thread'}];
|
|
if(verb==='project.list')return [{id:A,name:'project'}];
|
|
if(verb==='session.list')return [{id:B,name:'selected',revision:1}];
|
|
if(verb==='session.operation')return {thread:T,op:'already-used',session:A,successor:B,state:'clearing',start:null};
|
|
assert.fail(`unexpected write ${verb}`);
|
|
};
|
|
await assert.rejects(sessionCommand(['clear',T,B,'--yes','--op','already-used'],x,()=>call),e=>e.exitCode===2 && /operation key already used for another session/.test(e.message));
|
|
assert.ok(!seen.includes('session.clear'));
|
|
});
|