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