// Row224 M3: exact creative tool sets preserve the existing C2-08 web grants. import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { typedTools } from '../src/tools.mjs'; import { buildBundle } from '../src/bundle.mjs'; import { TYPES } from '../../library/src/mapping.mjs'; import { HARNESS, REPO_SKILLS, resolvedFor, scratch } from './helpers.mjs'; const LOOKUPS = ['library_find', 'library_get', 'library_search', 'library_manuscript', 'library_universe']; const CREATIVE = ['companion', 'research', 'ideas', 'proofreader', 'fact-checker', 'continuity']; const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => { d.roles.bean = { definition, credentials: { gitea: { ...d.roles.coder.credentials.gitea } }, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } }; }); const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails']; test('M3/TI: per-role snapshots contain nine names, or eleven with the C2-08 web grant', t => { for (const definition of CREATIVE) { const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true }); assert.deepEqual(tools.map(t => t.name), expected(definition), definition); assert.ok(tools.every(t => t.parameters.additionalProperties === false)); } }); test('every noncreative role gets lookups when a library is configured, not otherwise', t => { for (const instance of ['coder', 'reviewer', 'pm', 'chief']) { const resolved = resolvedFor(t, instance); const ordinary = typedTools({ resolved }).map(t => t.name); assert.ok(!ordinary.some(name => name.startsWith('library_'))); const tools = typedTools({ resolved, library: true }); assert.deepEqual(tools.filter(t => t.name.startsWith('library_')).map(t => t.name), LOOKUPS); assert.deepEqual(tools.filter(t => !t.name.startsWith('library_')).map(t => t.name), ordinary); } }); test('lookup schemas require a universe even for get, include paging, and cannot select repo or commit', t => { const tools = typedTools({ resolved: role(t, 'ideas'), library: true }); const required = [ ['universe', 'query'], ['universe', 'path'], ['universe', 'text'], ['universe', 'book'], ['universe'] ]; for (const [i, tool] of tools.filter(t => LOOKUPS.includes(t.name)).entries()) { assert.deepEqual(tool.parameters.required, required[i]); assert.equal(tool.verb, LOOKUPS[i].replace('_', '.')); assert.equal(tool.action, null); assert.equal(tool.parameters.additionalProperties, false); for (const key of ['repo', 'commit', 'business', 'accept']) assert.equal(Object.hasOwn(tool.parameters.properties, key), false); } const byName = Object.fromEntries(tools.map(t => [t.name, t])); assert.deepEqual(byName.library_find.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']); assert.deepEqual(byName.library_find.parameters.properties.type.enum, [...TYPES]); assert.equal(byName.library_find.parameters.properties.offset.minimum, 0); assert.equal(byName.library_get.parameters.properties.from.minimum, 1); assert.equal(byName.library_get.parameters.properties.links_from.minimum, 0); assert.equal(byName.library_manuscript.parameters.properties.chapter.type, 'string'); }); test('bundle flag reaches both harness tool manifests and creative prompt without generic tools', t => { for (const definition of CREATIVE) for (const harness of ['pi', 'claude-code']) { const dir = scratch(t), workspace = join(dir, 'ws'); mkdirSync(workspace); const b = buildBundle({ dir: join(dir, 'bundle'), resolved: role(t, definition, harness), library: true, contract: '# Creative\n', tracker: true, arbiter: true, skillsDir: REPO_SKILLS, session: { run: 'r224', launchedBy: 'chief', workspace, toolSocket: join(dir, 't.sock'), turnMarker: join(dir, 'turn.done') }, harness: HARNESS[harness] }); assert.deepEqual(b.tools.map(t => t.name), expected(definition), `${definition} ${harness}`); const serialized = JSON.parse(readFileSync(b.files.tools, 'utf8')); assert.deepEqual(serialized.map(t => t.name), expected(definition)); const prompt = readFileSync(b.files.prompt, 'utf8'); assert.match(prompt, /library_find/); assert.doesNotMatch(prompt, /Bus tools: [^\n]*(read_spec|list_agents|inbox|trail|write_record|raise_decision|list_tasks)/); } }); test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => { const tools = typedTools({ resolved: role(t, 'ideas'), library: true }); assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails']); for (const name of ['library_find', 'library_search', 'library_get']) { const tool = tools.find(t => t.name === name); assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']); assert.ok(!tool.parameters.required.includes('scope')); } assert.deepEqual(tools.find(t => t.name === 'library_manuscript').parameters.properties.scope.enum, ['canon', 'playground']); });