import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fixture, git } from '../../bus/tests/spec-fixtures.mjs'; import { serve } from '../../bus/src/server.mjs'; import { Client } from '../../bus/src/client.mjs'; import { toolServer } from '../src/runner.mjs'; import { callTool, typedTools } from '../src/tools.mjs'; const requirement = { recordVersion: 1, id: 'REQ-1', title: 'Title', text: 'Text', status: 'draft' }; const decision = { recordVersion: 1, id: 'DEC-1', question: 'Which?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', status: 'open', choice: null }; async function setup(t) { const f = await fixture(t); const busPath = join(f.root, 'bus.sock'); const bus = await serve({ broker: f.broker, path: busPath }); t.after(() => bus.close()); const client = new Client({ path: busPath, cap: f.coder }); const tools = typedTools({ resolved: { definition: 'coder', limits: { tools: [], authority: { withinRole: ['record.write'], crossRole: [], } } } }); const path = join(f.root, 'tools.sock'); const server = await toolServer({ path, tools, client, local: {} }); t.after(() => server.close()); return { ...f, write: (recordPath, record) => callTool(path, 'write_record', { project: 'stack', path: recordPath, record }) }; } for (const [kind, record] of [['requirements', requirement], ['decisions', decision]]) { test(`JSON string ${kind} records commit the same bytes as object records`, async t => { const object = await setup(t), string = await setup(t); const path = `${kind}/${record.id}.json`; const written = await object.write(path, record); const parsed = await string.write(path, JSON.stringify(record)); assert.match(parsed.commit, /^[0-9a-f]{40}$/); const expected = git(object.repo, 'show', `${written.commit}:${path}`) + '\n'; assert.equal(readFileSync(join(string.repo, path), 'utf8'), expected); assert.equal(git(string.repo, 'show', `${parsed.commit}:${path}`) + '\n', expected); const doc = '# Markdown remains text\n'; await string.write('docs/record.md', doc); assert.equal(readFileSync(join(string.repo, 'docs/record.md'), 'utf8'), doc); await assert.rejects(string.write('docs/object.md', { text: 'not Markdown' }), /project-record-invalid/); }); } for (const [name, value, detail] of [ ['unparseable', 'secret-marker {', /unparseable string/], ['array', '["secret-marker"]', /array/], ['number', '42', /number/], ['null', 'null', /null/], ]) { test(`agent sees safe detail for ${name} JSON record`, async t => { const f = await setup(t); await assert.rejects(f.write('requirements/REQ-1.json', value), e => { assert.match(e.message, /refused: project-record-invalid/); assert.match(e.message, detail); assert.doesNotMatch(e.message, /secret-marker/); return true; }); }); } test('agent sees validation detail and static guidance without unrelated refusal reasons', async t => { const f = await setup(t); await assert.rejects(f.write('requirements/REQ-1.json', { ...requirement, title: '' }), /refused: project-record-invalid: requirement\.title: too short/); const refusal = message => error => { assert.equal(error.message.split('\n')[0], message); assert.match(error.message, /\nRail: .+\nNext step: .+/); assert.doesNotMatch(error.message, /secret-marker/); return true; }; await assert.rejects(f.write('spec/secret-marker.md', '# text'), refusal('refused: project-spec-human-only')); const path = join(f.root, 'other-tools.sock'); const server = await toolServer({ path, tools: [{ name: 'write_record', verb: 'record.write' }], local: {}, client: { call() { throw Object.assign(new Error('hidden'), { code: 'project-busy', reason: 'secret-marker' }); } } }); t.after(() => server.close()); await assert.rejects(callTool(path, 'write_record', {}), refusal('refused: project-busy')); });