Files
stack/packages/harness/tests/request-tools.test.mjs

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