404 lines
27 KiB
JavaScript
404 lines
27 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdirSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { createInterface } from 'node:readline';
|
|
import { createServer } from 'node:net';
|
|
import { buildBundle, SOURCES } from '../src/bundle.mjs';
|
|
import { callTool, typedTools } from '../src/tools.mjs';
|
|
import * as runner from '../src/runner.mjs';
|
|
import { HARNESS, REPO, REPO_SKILLS, resolvedFor, scratch } from './helpers.mjs';
|
|
import { Broker } from '../../bus/src/broker.mjs';
|
|
import { Store } from '../../bus/src/store.mjs';
|
|
import { createRequestStore } from '../../bus/src/request-store.mjs';
|
|
import { createRequestLifecycle } from '../../bus/src/request-lifecycle.mjs';
|
|
import { requestFixture } from '../../bus/tests/request-fixture.mjs';
|
|
|
|
const reads = ['intake_status', 'intake_projects'];
|
|
const writes = ['intake_open', 'intake_propose', 'intake_question'];
|
|
const intake = t => t.name.startsWith('intake_');
|
|
const shipped = name => JSON.parse(readFileSync(join(REPO, 'roles', `${name}.json`), 'utf8'));
|
|
const roleDefinition = role => role === 'chief' ? 'chief-of-staff' : role;
|
|
|
|
function bundle(t, role, harness, mutate) {
|
|
const resolved = resolvedFor(t, role, doc => {
|
|
if (role === 'assistant') doc.roles.assistant = { definition: 'assistant' };
|
|
doc.roles[role].vars = { ...doc.roles[role].vars, harness, model: 'claude-opus-5-5' };
|
|
mutate?.(doc);
|
|
});
|
|
const dir = scratch(t), workspace = join(dir, 'ws');
|
|
mkdirSync(workspace);
|
|
return buildBundle({ dir: join(dir, 'bundle'), resolved, contract: readFileSync(join(REPO, 'roles', `${roleDefinition(role)}.md`), 'utf8'),
|
|
tracker: false, credentials: [], skillsDir: REPO_SKILLS, harness: HARNESS[harness], harnessVersion: 'fixture',
|
|
session: { run: 'intake-run', workspace, toolSocket: join(dir, 'tools.sock'), turnMarker: join(dir, 'turn.done') } });
|
|
}
|
|
|
|
for (const harness of ['pi', 'claude-code']) {
|
|
for (const role of ['pm', 'cto', 'coder', 'reviewer', 'assistant']) {
|
|
test(`${harness}: ${role} actual pre-task manifest exposes only its intake tools`, t => {
|
|
const b = bundle(t, role, harness);
|
|
const expected = role === 'pm' ? [...reads, ...writes] : role === 'cto' ? reads : [];
|
|
const prefix = harness === 'pi' ? '' : 'mcp__mosaic__';
|
|
assert.deepEqual(b.manifest.typed.filter(n => n.startsWith(prefix + 'intake_')).sort(), expected.map(n => prefix + n).sort());
|
|
const tools = JSON.parse(readFileSync(b.files.tools, 'utf8'));
|
|
assert.deepEqual(tools.filter(intake).map(t => t.name).sort(), expected.toSorted());
|
|
assert.deepEqual(b.policy.typed.filter(n => n.startsWith('intake_')).sort(), expected.toSorted());
|
|
for (const tool of tools.filter(intake)) {
|
|
assert.equal(tool.parameters.additionalProperties, false);
|
|
assert.equal(tool.parameters.properties.request.type, 'string');
|
|
}
|
|
assert.ok(!tools.some(t => t.name === 'task_create'), 'discovery needs no tracker');
|
|
});
|
|
}
|
|
}
|
|
|
|
test('PM intake writes and staff request follow the existing message.send grant', t => {
|
|
const muted = bundle(t, 'pm', 'pi', doc => { doc.roles.pm.vars['limits.authority'] = ['task.assign', 'decision.resolve.delivery']; });
|
|
assert.deepEqual(muted.tools.filter(intake).map(t => t.name).sort(), reads.toSorted());
|
|
assert.ok(!muted.tools.some(t => t.name === 'request_staff'));
|
|
});
|
|
|
|
async function setup(t, harness = 'pi', { mute = false } = {}) {
|
|
const root = scratch(t, 'request-tools-'), store = new Store(root);
|
|
t.after(() => store.close());
|
|
const roles = Object.fromEntries(['pm', 'cto', 'coder', 'reviewer', 'assistant', 'chief'].map(role => {
|
|
const definition = roleDefinition(role);
|
|
return [role, { definition, authority: mute && role === 'pm' ? { withinRole: [], crossRole: [] } : shipped(definition).authority }];
|
|
}));
|
|
const business = { id: 'demo', human: 'jason', roles,
|
|
definitions: Object.fromEntries(Object.keys(roles).map(r => [roleDefinition(r), shipped(roleDefinition(r))])),
|
|
arbiters: { technical: 'cto', delivery: 'pm' },
|
|
launch: { by: 'chief', instances: ['pm', 'cto', 'coder', 'reviewer'], models: {}, limits: {} },
|
|
projects: { factory: { root: join(root, 'factory') } } };
|
|
const journal = createRequestStore(store);
|
|
const access = { accessVersion: 1, revision: 1, users: [], grants: [] };
|
|
const broker = new Broker({ store, businesses: { demo: business }, access: () => access, requestLifecycle: services => createRequestLifecycle({ journal, services: {
|
|
...services, listProjects: () => [{ business: 'demo', project: 'factory', name: 'Factory', tracker: { root: 1, project: 2 } }],
|
|
preparePlans: (state, args, source) => ['autonomous', 'guided'].map(mode => ({
|
|
version: 1, business: 'demo', request: state.request, project: args.project, name: args.name, chat_project: null, mode,
|
|
organization_revision: 'fixture-revision', source: { project: args.source_project, commit: 'a'.repeat(40), remote: null, forks: [] },
|
|
code_root: join(root, 'products/demo', args.project, 'code'), records_root: join(root, 'projects/demo', args.project),
|
|
tracker: { root: 1, project: args.tracker_project },
|
|
staff: { pm: 'pm', technical_reviewer: 'cto', coder: 'coder', reviewer: 'reviewer', chief: 'chief' },
|
|
intent: { text: source.text, sha256: createHash('sha256').update(source.text).digest('hex'), source_message: source.source_message, generated: false },
|
|
project_vars: {}, existing: false,
|
|
})), admitPlan: () => ({ ready: true }),
|
|
} }) });
|
|
const caps = { human: broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
|
const call = (role, verb, args = {}) => broker.request(caps[role], { verb, args });
|
|
const project = call('human', 'project.create', { name: 'Intake' }).id;
|
|
const thread = call('human', 'thread.create', { project, name: 'New product' }).id;
|
|
for (const role of Object.keys(roles)) {
|
|
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness, ...(role === 'assistant' ? { thread } : {}) });
|
|
call(role, 'role.claim');
|
|
}
|
|
const source = call('human', 'thread.send', { thread, body: 'Build a single-file timer.\nKeep the original intent.\n' });
|
|
const relay = call('assistant', 'message.send', { to: 'chief', body: `New product request ${source.request}`, in_reply_to: source.id });
|
|
call('chief', 'message.send', { to: 'pm', body: `Open request ${source.request}`, in_reply_to: relay.id });
|
|
const pm = bundle(t, 'pm', harness);
|
|
assert.equal(typeof runner.toolServer, 'function', 'exercise the production runner dispatch');
|
|
const socket = join(root, 'tools.sock');
|
|
const server = await runner.toolServer({ path: socket, tools: pm.tools, local: {}, client: { call: (verb, args) => call('pm', verb, args) } });
|
|
t.after(() => new Promise(resolve => server.close(resolve)));
|
|
return { root, store, broker, caps, call, source, pm, socket, access, project, thread, business,
|
|
tool: (name, args) => callTool(socket, name, args) };
|
|
}
|
|
|
|
function mcp(t, toolsFile, socket) {
|
|
const child = spawn(process.execPath, [SOURCES.mcpServer, toolsFile, socket], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
const waiting = new Map();
|
|
let next = 0;
|
|
createInterface({ input: child.stdout }).on('line', line => {
|
|
const m = JSON.parse(line); waiting.get(m.id)?.(m); waiting.delete(m.id);
|
|
});
|
|
t.after(async () => {
|
|
const exited = new Promise(resolve => child.once('exit', resolve));
|
|
child.stdin.end();
|
|
await exited;
|
|
});
|
|
return async (name, args) => {
|
|
const id = ++next;
|
|
const response = new Promise(resolve => waiting.set(id, resolve));
|
|
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }) + '\n');
|
|
const reply = await response;
|
|
assert.ok(!reply.error, reply.error?.message);
|
|
const result = reply.result;
|
|
assert.ok(!result.isError, result.content[0].text);
|
|
return JSON.parse(result.content[0].text);
|
|
};
|
|
}
|
|
|
|
for (const harness of ['pi', 'claude-code']) {
|
|
test(`${harness}: assistant through chief reaches PM intake and a pre-task new-project decision`, async t => {
|
|
const x = await setup(t, harness);
|
|
const tool = harness === 'claude-code' ? mcp(t, x.pm.files.tools, x.socket) : x.tool;
|
|
const args = { request: x.source.request };
|
|
const opened = await tool('intake_open', args);
|
|
assert.equal(opened.source_message, x.source.id);
|
|
assert.equal(opened.phase, 'selection');
|
|
assert.deepEqual(opened.tasks, []);
|
|
assert.deepEqual(await tool('intake_open', args), opened);
|
|
assert.deepEqual(await tool('intake_status', args), opened);
|
|
assert.deepEqual(await tool('intake_projects', args), [{ business: 'demo', project: 'factory', name: 'Factory', tracker: { root: 1, project: 2 } }]);
|
|
const proposed = await tool('intake_propose', { ...args, revision: opened.revision, op: 'timer-plan', source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 });
|
|
const d = x.store.get('SELECT * FROM decisions WHERE id=?', proposed.selection.decision);
|
|
assert.equal(d.action, 'project.bootstrap');
|
|
assert.equal(d.route_to, 'human');
|
|
assert.equal(d.blocking, 0);
|
|
assert.equal(d.task_ref, null);
|
|
assert.equal(d.requirement_ref, null);
|
|
assert.deepEqual(JSON.parse(d.options).map(o => o.key), ['autonomous', 'guided', 'existing']);
|
|
assert.equal(x.store.get("SELECT count(*) n FROM events WHERE kind='task.created'").n, 0);
|
|
assert.equal(x.store.get('SELECT body FROM messages WHERE id=?', x.source.id).body, 'Build a single-file timer.\nKeep the original intent.\n');
|
|
assert.throws(() => x.call('pm', 'decision.resolve', { id: d.id, choice: 'guided' }), /human-required/);
|
|
});
|
|
}
|
|
|
|
test('pre-task request_staff reaches the chief with roles and original request evidence', async t => {
|
|
const x = await setup(t);
|
|
const sent = await x.tool('request_staff', { request: x.source.request, roles: [{ role: 'cto', count: 1 }], note: 'Discovery before tasks' });
|
|
assert.equal(sent.to, 'chief');
|
|
const message = x.call('chief', 'message.receive').find(m => m.id === sent.message);
|
|
assert.equal(message.class, 'STAFF-REQUEST');
|
|
assert.match(message.body, new RegExp(x.source.request));
|
|
assert.match(message.body, /cto x1/);
|
|
assert.match(message.body, /Discovery before tasks/);
|
|
const body = JSON.parse(x.store.get("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.operation')='staff.request'").body);
|
|
assert.equal(body.staff_for, x.source.request);
|
|
assert.equal(body.action, 'message.send');
|
|
assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0, 'staffing does not require an opened intake');
|
|
const task = await x.tool('request_staff', { task_ref: 'vikunja:1/2', roles: [{ role: 'coder', count: 1 }] });
|
|
assert.match(x.store.get('SELECT body FROM messages WHERE id=?', task.message).body, /Staff request for vikunja:1\/2:/);
|
|
});
|
|
|
|
test('request_staff rejects both/neither references, invalid or unreachable requests and wrong roles', async t => {
|
|
const x = await setup(t), roles = [{ role: 'coder', count: 1 }];
|
|
for (const args of [{ roles }, { roles, task_ref: 'vikunja:1/2', request: x.source.request }, { roles, request: '' }, { roles, request: null }, { roles, request: 'absent' }, { roles, request: x.source.id }])
|
|
await assert.rejects(x.tool('request_staff', args), /invalid-request|request-origin-refused/);
|
|
const foreign = x.call('human', 'message.send', { to: 'cto', body: 'Unrelated request' });
|
|
await assert.rejects(x.tool('request_staff', { roles, request: foreign.request }), /request-origin-refused/);
|
|
for (const role of ['cto', 'coder', 'reviewer', 'assistant', 'chief'])
|
|
assert.throws(() => x.call(role, 'staff.request', { roles, request: x.source.request }), /staff-request-refused/);
|
|
assert.equal(x.store.get("SELECT count(*) n FROM messages WHERE class='STAFF-REQUEST'").n, 0);
|
|
});
|
|
|
|
test('broker keeps wrong-role intake refusals, with eligible CTO reads', async t => {
|
|
const x = await setup(t), args = { request: x.source.request };
|
|
const opened = await x.tool('intake_open', args);
|
|
for (const role of ['cto', 'coder', 'reviewer', 'assistant', 'chief']) {
|
|
for (const verb of role === 'cto' ? ['request.open'] : ['request.open', 'request.get', 'request.projects'])
|
|
await assert.rejects(async () => x.call(role, verb, args), /request-access-refused/);
|
|
await assert.rejects(async () => x.call(role, 'request.propose', { ...args, revision: opened.revision, op: 'bad-role', source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 }), /request-access-refused/);
|
|
}
|
|
assert.equal((await x.call('cto', 'request.get', args)).request, x.source.request);
|
|
assert.equal((await x.call('cto', 'request.projects', args))[0].project, 'factory');
|
|
await assert.rejects(x.tool('intake_open', { request: x.source.id }), /request-origin-refused/);
|
|
await assert.rejects(x.tool('intake_status', { ...args, extra: true }), /invalid-request/);
|
|
await assert.rejects(x.tool('intake_propose', { ...args, revision: 0, op: 'bad-revision', source_project: 'factory', project: 'timer', name: 'Timer', tracker_project: 2 }), /invalid-request/);
|
|
});
|
|
|
|
test('pre-task staffing still requires PM message.send authority', async t => {
|
|
const x = await setup(t, 'pi', { mute: true });
|
|
await assert.rejects(x.tool('request_staff', { request: x.source.request, roles: [{ role: 'cto', count: 1 }] }), /decision-required/);
|
|
assert.equal(x.store.get("SELECT count(*) n FROM messages WHERE class='STAFF-REQUEST'").n, 0);
|
|
});
|
|
|
|
test('assistant cannot coordinate directly with PM or reviewer even with a request citation', async t => {
|
|
const x = await setup(t);
|
|
for (const to of ['pm', 'reviewer'])
|
|
assert.throws(() => x.call('assistant', 'message.send', { to, body: `Request ${x.source.request}`, in_reply_to: x.source.id }), /message-recipient-refused/);
|
|
});
|
|
|
|
for (const harness of ['pi', 'claude-code']) test(`${harness}: PM submit and advance are typed without a tracker; others cannot write`, async t => {
|
|
const x = await setup(t, harness);
|
|
const invoke = harness === 'claude-code' ? mcp(t, x.pm.files.tools, x.socket) : x.tool;
|
|
const state = await invoke('intake_open', { request: x.source.request });
|
|
await assert.rejects(invoke('advance_request', { request: state.request, revision: state.revision, op: 'advance1', phase: 'complete' }), /invalid-request/);
|
|
await assert.rejects(invoke('advance_request', { request: state.request, revision: state.revision, op: 'advance1' }), /request-evidence-required/);
|
|
await assert.rejects(invoke('submit_prd', { request: state.request, revision: state.revision, op: 'prd1', prd_version: '1.0', records_commit: 'a'.repeat(40), prd_path: 'docs/prd-1.0.md', requirement_ids: ['REQ-TIMER-1'] }), /request-phase-refused/);
|
|
for (const role of ['cto','coder','reviewer','assistant']) {
|
|
const b = bundle(t, role, harness);
|
|
assert.ok(!b.tools.some(t => ['submit_prd','advance_request'].includes(t.name)));
|
|
}
|
|
});
|
|
|
|
test('product task and catalog tools carry project and exact snapshot fields', t => {
|
|
const resolved = resolvedFor(t, 'pm');
|
|
const tools = typedTools({ resolved, projects: ['timer'], tracker: true });
|
|
for (const name of ['task_create', 'list_requirements']) {
|
|
const schema = tools.find(t => t.name === name).parameters;
|
|
assert.equal(schema.properties.project_key.type, 'string');
|
|
assert.equal(schema.properties.requirement_snapshot.type, 'string');
|
|
}
|
|
});
|
|
|
|
for (const harness of ['pi', 'claude-code']) test(`${harness}: PM asks once and the owner answers through the bus`, async t => {
|
|
const x = await requestFixture(t, 'guided'), state = x.state;
|
|
assert.equal(state.phase, 'discovery');
|
|
const pm = bundle(t, 'pm', harness), socket = join(x.f.dataRoot, 'question-tools.sock');
|
|
const server = await runner.toolServer({ path: socket, tools: pm.tools, local: {}, client: { call: (verb, args) => x.call('pm', verb, args) } });
|
|
t.after(() => new Promise(resolve => server.close(resolve)));
|
|
x.pm = pm; x.socket = socket; x.tool = (name, args) => callTool(socket, name, args);
|
|
const invoke = harness === 'claude-code' ? mcp(t, x.pm.files.tools, x.socket) : x.tool;
|
|
const args = { request: state.request, revision: state.revision, op: 'question-1', text: 'Which platform?', options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }] };
|
|
const asked = await invoke('intake_question', args);
|
|
assert.equal(asked.question.text, 'Which platform?');
|
|
assert.deepEqual(asked.question.options, args.options);
|
|
assert.equal(asked.question.answer, null);
|
|
assert.deepEqual(await invoke('intake_question', args), asked);
|
|
for (const role of ['cto', 'coder', 'reviewer', 'chief'])
|
|
await assert.rejects(async () => x.call(role, 'request.question', { ...args, op: 'foreign' }), /request-access-refused/);
|
|
assert.equal(x.store.get("SELECT count(*) n FROM request_events WHERE kind='question.asked'").n, 1);
|
|
await assert.rejects(x.tool('intake_question', { ...args, text: 'Changed?' }), /request-operation-conflict/);
|
|
const answered = await x.call('console', 'request.answer.console', { request: state.request, revision: asked.revision, question: asked.question.id, op: 'answer-1', text: 'Web' });
|
|
assert.ok(answered.question.answer);
|
|
assert.equal(x.store.get('SELECT kind FROM events WHERE id=?', answered.question.answer).kind, 'human.input');
|
|
assert.equal((await x.tool('intake_status', { request: state.request })).question.answer, answered.question.answer);
|
|
});
|
|
|
|
test('intake_question refuses malformed arguments and broker phase refusal', async t => {
|
|
const x = await setup(t), state = await x.tool('intake_open', { request: x.source.request });
|
|
const valid = { request: state.request, revision: state.revision, op: 'q1', text: 'Question?' };
|
|
for (const invalid of [
|
|
{ ...valid, extra: true }, { ...valid, revision: 0 }, { ...valid, op: '' }, { ...valid, text: '' },
|
|
{ ...valid, text: 'a'.repeat(2001) }, { ...valid, text: 'bad\0text' },
|
|
{ ...valid, options: [{ key: 'a', text: 'A', extra: true }] },
|
|
{ ...valid, options: [{ key: 'a', text: 'A' }, { key: 'a', text: 'B' }] },
|
|
{ ...valid, options: Array.from({ length: 9 }, (_, i) => ({ key: `k${i}`, text: 'A' })) },
|
|
{ ...valid, options: [{ key: 'a', text: 'a'.repeat(201) }] },
|
|
]) await assert.rejects(x.tool('intake_question', invalid), /invalid-request/);
|
|
await assert.rejects(x.tool('intake_question', valid), /request-phase-refused/);
|
|
assert.equal(x.store.get("SELECT count(*) n FROM request_events WHERE kind='question.asked'").n, 0);
|
|
});
|
|
|
|
test('intake_question reports an unknown socket outcome without retrying', async t => {
|
|
const root = scratch(t), path = join(root, 'lost.sock');
|
|
let received = 0;
|
|
const server = createServer(socket => socket.on('data', () => { received++; socket.end(); }));
|
|
await new Promise(resolve => server.listen(path, resolve));
|
|
t.after(() => new Promise(resolve => server.close(resolve)));
|
|
await assert.rejects(callTool(path, 'intake_question', { request: 'r', revision: 1, op: 'q', text: 'Q?' }), /outcome.unknown/i);
|
|
assert.equal(received, 1);
|
|
});
|
|
|
|
test('intake_question exposes exactly the bounded broker fields', t => {
|
|
const schema = bundle(t, 'pm', 'pi').tools.find(t => t.name === 'intake_question').parameters;
|
|
assert.deepEqual(Object.keys(schema.properties).sort(), ['op', 'options', 'request', 'revision', 'text']);
|
|
assert.deepEqual(schema.required.sort(), ['op', 'request', 'revision', 'text']);
|
|
assert.equal(schema.additionalProperties, false);
|
|
assert.equal(schema.properties.text.maxLength, 2000);
|
|
assert.equal(schema.properties.options.maxItems, 8);
|
|
assert.equal(schema.properties.options.items.additionalProperties, false);
|
|
assert.equal(schema.properties.options.items.properties.text.maxLength, 200);
|
|
});
|
|
|
|
test('intake_question reports a bus unknown outcome without calling twice', async t => {
|
|
const root = scratch(t), path = join(root, 'unknown.sock'), pm = bundle(t, 'pm', 'pi');
|
|
let attempts = 0;
|
|
const server = await runner.toolServer({ path, tools: pm.tools, local: {}, client: { call: () => {
|
|
attempts++; throw Object.assign(new Error('lost response'), { code: 'outcome-unknown' });
|
|
} } });
|
|
t.after(() => new Promise(resolve => server.close(resolve)));
|
|
await assert.rejects(callTool(path, 'intake_question', { request: 'r', revision: 1, op: 'q', text: 'Q?' }), e => /outcome-unknown/.test(e.message) && !/refused/.test(e.message));
|
|
assert.equal(attempts, 1);
|
|
});
|
|
|
|
for (const harness of ['pi', 'claude-code']) {
|
|
test(`${harness}: linked relay carries original request in runner header and PM gets requirement shape`, async t => {
|
|
const x = await setup(t, harness);
|
|
const relay = x.call('pm', 'message.receive').find(m => m.from_role === 'chief' && m.body.includes(x.source.request));
|
|
assert.equal(relay.request, x.source.request);
|
|
assert.match(runner.turnRequest(relay), new RegExp(`request ${x.source.request}`));
|
|
const record = x.pm.tools.find(t => t.name === 'write_record');
|
|
assert.match(JSON.stringify(record), /recordVersion/);
|
|
assert.match(JSON.stringify(record), /requirements\/REQ/);
|
|
});
|
|
}
|
|
|
|
test('PM typed record guidance exposes the closed requirement shape in a minimal factory', async t => {
|
|
const x = await setup(t);
|
|
const record = x.pm.tools.find(t => t.name === 'write_record');
|
|
assert.match(JSON.stringify(record), /recordVersion/);
|
|
assert.match(JSON.stringify(record), /requirements\/REQ/);
|
|
});
|
|
|
|
test('unlinked or unrelated relays cannot carry or authorize an owner request', async t => {
|
|
const x = await setup(t);
|
|
const thread = x.store.get('SELECT thread FROM message_threads WHERE message=?', x.source.id).thread;
|
|
const a = x.call('human', 'thread.send', { thread, body: 'First isolated instruction' });
|
|
const missingFirst = x.call('assistant', 'message.send', { to: 'chief', body: `Request ${a.request}` });
|
|
const toPm = x.call('chief', 'message.send', { to: 'pm', in_reply_to: missingFirst.id, body: `Request ${a.request}` });
|
|
const b = x.call('human', 'thread.send', { thread, body: 'Second isolated instruction' });
|
|
x.call('assistant', 'message.send', { to: 'chief', in_reply_to: b.id, body: `Request ${b.request}` });
|
|
const missingSecond = x.call('chief', 'message.send', { to: 'pm', body: `Request ${b.request}` });
|
|
const unrelated = x.call('human', 'message.send', { to: 'cto', body: 'Different recipient' });
|
|
const forged = x.call('chief', 'message.send', { to: 'pm', in_reply_to: unrelated.id, body: `Request ${unrelated.request}` });
|
|
const messages = x.call('pm', 'message.receive');
|
|
for (const id of [toPm.id, missingSecond.id, forged.id]) assert.equal(messages.find(m => m.id === id).request, null);
|
|
for (const source of [a, b, unrelated]) await assert.rejects(x.tool('intake_open', { request: source.request }), /request-origin-refused/);
|
|
assert.equal(x.store.get('SELECT count(*) n FROM request_events').n, 0);
|
|
assert.throws(() => x.call('assistant', 'message.send', { to: 'pm', in_reply_to: a.id, body: 'Direct relay' }), /message-recipient-refused|message-sender-refused/);
|
|
});
|
|
|
|
test('a different owner thread cannot borrow an owner request by linking its message ID', async t => {
|
|
const x = await setup(t);
|
|
const owner = x.call('human', 'thread.send', { thread: x.thread, body: 'Owner-only request' });
|
|
x.access.grants = [{ id: 'grant-alice', user: 'alice', business: 'demo', projects: [x.project], instances: ['assistant'], level: 'collaborator' }];
|
|
x.caps.user = x.broker.bindUser({ business: 'demo', user: 'alice' });
|
|
const thread = x.call('user', 'thread.create', { project: x.project, name: 'Other owner' }).id;
|
|
x.caps.assistant = x.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'other-assistant-run', harness: 'pi', thread });
|
|
x.call('assistant', 'role.claim');
|
|
const relay = x.call('assistant', 'message.send', { to: 'chief', in_reply_to: owner.id, body: `Request ${owner.request}` });
|
|
const sent = x.call('chief', 'message.send', { to: 'pm', in_reply_to: relay.id, body: `Request ${owner.request}` });
|
|
const message = x.call('pm', 'message.receive').find(m => m.id === sent.id);
|
|
assert.equal(message.request, null);
|
|
await assert.rejects(x.tool('intake_open', { request: owner.request }), /request-origin-refused/);
|
|
});
|
|
|
|
for (const harness of ['pi', 'claude-code']) {
|
|
test(`${harness}: generated role prompts preserve linked relay and raw reviewer instructions`, t => {
|
|
for (const role of ['assistant', 'chief', 'reviewer']) {
|
|
const prompt = readFileSync(bundle(t, role, harness).files.prompt, 'utf8');
|
|
if (role === 'reviewer') assert.match(prompt, /raw JSON verification-document/);
|
|
else assert.match(prompt, /in_reply_to/);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('a relay through a removed role no longer carries or authorizes the original request', async t => {
|
|
const x = await setup(t);
|
|
const changed = structuredClone(x.business); delete changed.roles.assistant;
|
|
x.broker.reloadBusiness('demo', changed);
|
|
const received = x.call('pm', 'message.receive').find(m => m.from_role === 'chief' && m.body.includes(x.source.request));
|
|
assert.equal(received.request, null);
|
|
await assert.rejects(x.tool('intake_open', { request: x.source.request }), /request-origin-refused/);
|
|
});
|
|
|
|
test('advance_request exposes the named task evidence refusal to the PM tool', async t => {
|
|
const root = scratch(t, 'advance-reason-'), path = join(root, 'tool.sock');
|
|
const reason = 'Tasks without their own push_working and matching request_review: vikunja:2/43, vikunja:2/44, ' + Array.from({ length: 60 }, (_, i) => `vikunja:2/${i + 100}`).join(', ') + '.';
|
|
assert.ok(reason.length > 300);
|
|
// Exercise the real bus client and runner socket, including the existing 300-character bound.
|
|
const busPath = join(root, 'bus.sock');
|
|
const bus = createServer(socket => socket.on('data', data => {
|
|
const request = JSON.parse(data.toString());
|
|
const error = request.args?.other ? 'project-record-invalid' : 'request-evidence-required';
|
|
socket.end(JSON.stringify({ ok: false, error, reason }) + '\n');
|
|
}));
|
|
await new Promise(resolve => bus.listen(busPath, resolve));
|
|
t.after(() => new Promise(resolve => bus.close(resolve)));
|
|
const { Client } = await import('../../bus/src/client.mjs');
|
|
const server = await runner.toolServer({ path, tools: [{ name: 'advance_request', verb: 'request.advance' }, { name: 'other_tool', verb: 'request.advance' }], local: {}, client: new Client({ path: busPath, cap: 'fixture' }) });
|
|
t.after(() => new Promise(resolve => server.close(resolve)));
|
|
const refusal = message => error => {
|
|
assert.equal(error.message.split('\n')[0], message);
|
|
assert.match(error.message, /\nRail: .+\nNext step: .+/);
|
|
return true;
|
|
};
|
|
await assert.rejects(callTool(path, 'advance_request', {}), refusal(`refused: request-evidence-required: ${reason.slice(0, 300)}`));
|
|
await assert.rejects(callTool(path, 'advance_request', { other: true }), refusal('refused: project-record-invalid'));
|
|
await assert.rejects(callTool(path, 'other_tool', {}), refusal('refused: request-evidence-required'));
|
|
});
|