Files
stack/packages/harness/tests/write-record-object.test.mjs

82 lines
3.9 KiB
JavaScript

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