From b6c3129d2e346d74b4e877c88101241613a5bb51 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 23 Oct 2026 14:39:55 -0500 Subject: [PATCH] row 217: early-review coverage and availability RED cases (Untested) --- BUILD-LOG.md | 2 + agents/beakley/work/queue-217/BUILD.md | 45 +++++++++++++++ agents/beakley/work/queue-217/MANIFEST.md | 11 ++++ .../beakley/work/queue-217/manifest.r2.sha256 | 23 ++++++++ agents/beakley/work/queue-217/mutants.py | 3 + .../bus/tests/citation-availability.test.mjs | 56 +++++++++++++++++++ packages/bus/tests/citation-guard.test.mjs | 20 ++++++- packages/bus/tests/citation-helpers.mjs | 3 +- packages/bus/tests/citation-runtime.test.mjs | 29 ++++++++++ packages/business/tests/reply-limits.test.mjs | 7 +++ .../harness/tests/citation-runner.test.mjs | 19 ++++++- 11 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 agents/beakley/work/queue-217/manifest.r2.sha256 create mode 100644 packages/bus/tests/citation-availability.test.mjs create mode 100644 packages/bus/tests/citation-runtime.test.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index d6595e48e..b0d7f0e9b 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -5415,3 +5415,5 @@ Row 299 (573, #1806, Gander, Filbert): before, the P10 band test timed a fault r 2026-10-23 | Beakley (88b97f65) | Row 217 #1718 takeover, before implementation | Clean base 45dca69f6 on beakley/row217. Recovering Launchpad handoff for citation guard, live settings, page store and runner retry. Acceptance is C2-06/design 5.2 plus sage-3595/3599/3602. Tracking agents/beakley/work/queue-217/BUILD.md; no product changes or Node/tests; Sage CLEAR required. 2026-10-23 | Beakley (88b97f65) | Row 217 #1718 untested draft checkpoint | Continued Launchpad e25ef0373; added runner retry/text/note implementation, pure protocol constants and sage-3640 approved single read-only mount, CLI documentation and new acceptance test files. Static checks caught missing projects compatibility, async request mutation and Git pathspec matching; bounded fixes and regression tests authored. git diff --check clean. No Node/test/mutant executed; Sage holds CLEAR. 283/216/285 dependency checks and all acceptance execution remain open. Packet agents/beakley/work/queue-217/MANIFEST.md. + +2026-10-23 | Beakley (88b97f65) | Row 217 early-look follow-up, Untested | Webby 0939c0682 E1 adds ordinary mallory human-swap test; E2 adds stored trailer-derived agent/file event evidence; E6 creative-sender matrix and E7 runtime readRaw tests authored. Sage-3668/3670 accepts direct writer substitution because setter keys exclude replyLimits (organization.set exists, correcting review premise), and requires distinct availability refusal for E4. Real held-lock/socket and runner-reason regressions plus M5 authored before that production fix; expected RED awaits Sage CLEAR. No Node/tests/mutants, no old-test edits, no green claim. diff --git a/agents/beakley/work/queue-217/BUILD.md b/agents/beakley/work/queue-217/BUILD.md index dbe712cdd..fc3fc92af 100644 --- a/agents/beakley/work/queue-217/BUILD.md +++ b/agents/beakley/work/queue-217/BUILD.md @@ -105,3 +105,48 @@ fetch of the cited URL. Each has new-file regression coverage. - No queue changes, protected branch pushes, gate claims or acceptance claims. Static obsolete-code/key search completed: only packages/library/src/render.mjs:6 and packages/library/tests/render.test.mjs:58 match no-cited-answer; both belong to row 225 per standing ruling. No owned-path hit. Repeat before in-review after dependency changes. + + +## Webby early look 0939c0682 / webby-0249 and sage-3668/3670 + +This remains an untested draft. The follow-up adds tests only; production E4 +changes wait for the held-lock regression's first RED under Sage CLEAR. + +- E1: added an ordinary actor `mallory` that sets both `human` to itself and + `replyLimits`. The existing role/tester actors did not distinguish M2 because + the actor-shape exclusions rejected them independently of the human comparison. + M2 is still unrun; no kill claimed. +- E2: added a real bus event assertion for `agent: "acme/scribe"` from a file's + Mosaic-Agent trailer. The cited tip includes a later unrelated commit, so the + event must store the file commit rather than the library tip. thread.messages + must show `last === file` for that unchanged path. Supporting scope still waits + for row 283's real fixture, as ruled. +- E3: sage-3668 accepts direct writer.write tests under chief and tester actors + as substitutes for the brief's setter cases (brief lines 483-491 at base + 45dca69f6). Correction to the early-look premise: organization.set DOES exist, + broker.mjs:4701/4709, but ORGANIZATION_KEYS excludes replyLimits; instances.set + also cannot carry it. Direct writes exercise the shared M11 boundary those + setters cannot reach with that input. Sage confirmed the accurate reason in + sage-3670; the unrelated actor case remains required. +- E4: traced readSyncState -> withLibraryLock -> library-sync-busy after five + seconds -> servedMirror catch-all -> null served -> misleading citation-invalid. + New real-flock/socket test first proves the cite valid, holds the lock, asserts + the exact citation-unavailable reason the agent receives, verifies no insert, + and checks fixed fallback delivery and recovery after unlock. Additional tests + distinguish malformed sync state from absent/new mirrors, and assert that the + actual runner retry request contains that reason. These tests are expected RED + until the fix. Planned production change: only an absent mirror or legitimate + empty state yields null; other failures become GuardError citation-unavailable; + bodies without path cites do not require the mirror lock. Add the code to + GUARD_CODES so the runner sends the agent that reason on its one retry. Per + sage-3670, existing four fallback bodies can remain only if that delivery is + verified. M5 restores catch-all behavior; authored, not run. +- E5: first-library-project selection is a Launchpad handoff implementation + choice, not a validated rule that a business can only have one library. + Cites into another library remain a limitation. No mapping/schema rule added. +- E6: the broker's full guard table now runs as both coder and research. +- E7: new citation-runtime.test.mjs starts the real runtime, reads limits from + the real organization file, then changes, corrupts and removes that fixture + file and checks library.status without replacing the library adapter. + +No Node, tests or mutants ran. No test that predated row 217 changed. diff --git a/agents/beakley/work/queue-217/MANIFEST.md b/agents/beakley/work/queue-217/MANIFEST.md index e96cdef65..b5a6670b5 100644 --- a/agents/beakley/work/queue-217/MANIFEST.md +++ b/agents/beakley/work/queue-217/MANIFEST.md @@ -55,3 +55,14 @@ drift must be checked against 216/285 before in-review. Landing follows 283. Full gate: library, business, bus, cli, harness and seat, plus every scripts/test-*.sh. Webby reviews the final tested manifest; Sage owns the gate, queue and landing. No push to a protected branch is authorized. + + +## Early-look follow-up packet (manifest.r2.sha256) + +Tests-only follow-up to Webby 0939c0682. Adds ordinary-actor M2 discrimination, +stored agent/file evidence at an unrelated tip, creative-sender table coverage, +and real runtime readRaw coverage. New citation-availability.test.mjs and runner +availability case are intentionally unrun regressions for E4/sage-3668/3670. +Production availability handling is not implemented yet: run that targeted RED +on CLEAR before applying its fix, then rerun the same cases. M5 restores the +catch-all. This packet is not ready for a green gate or manifest approval. diff --git a/agents/beakley/work/queue-217/manifest.r2.sha256 b/agents/beakley/work/queue-217/manifest.r2.sha256 new file mode 100644 index 000000000..e1553ad56 --- /dev/null +++ b/agents/beakley/work/queue-217/manifest.r2.sha256 @@ -0,0 +1,23 @@ +68c05644d29188b897e2d4695d8e7025e143d424d69a91bc07bc950f589d34bf packages/bus/src/broker.mjs +ad3c686a089f67f3c89610937c6fc9b32367cea5d9735125d89bef042082d4e8 packages/bus/src/client.mjs +49e01e5e73cc3e830ad48a1b6b13fe4d06a38950f17847dc049b75209d6d3f0a packages/bus/src/runtime.mjs +66b26d131a19f4f411686427274936850c1ff0aed11e9b281ae4aadcf4b289fc packages/bus/src/server.mjs +cdd0304ec25ef1ad3e31c52502fa46f2c80bd77c8d3c2e13f8ed4b5328f342c9 packages/bus/tests/citation-availability.test.mjs +8dc344b8a905c58d544b031ca84adb7eb869d9de88eb8e6b0fc1d9190702a38a packages/bus/tests/citation-guard.test.mjs +62d8a55945d4b700e22c8c49b6420a5e25719d266901a2ad20c72514735e65e9 packages/bus/tests/citation-helpers.mjs +168dcac99d9bfdfdf5ff0eaa2c0fff91595b351b4d0092dc299532c9c32869f0 packages/bus/tests/citation-runtime.test.mjs +825da818ba3cf45a4837f0f15a9c2cdba55a3d0bbb4479d55236f35799427c6f packages/business/src/business.mjs +dbd79a4dfc926620d034b540f98f39336d81e4c79f214424c3cf19250c1d1f8d packages/business/src/organization.mjs +4d2eb11beaa80175e524af1b95c81e7164990fba1d3e1754f5ba06eee390a14f packages/business/tests/reply-limits.test.mjs +1f1bea250f694a2aae9b9dc856b3c3424ceca153ccf826c99ccb98e6ea9cd394 packages/cli/README.md +2f291a71f490f2741edada7a3e3788500f8976a08eb8ab1411f6c5982f778260 packages/cli/src/limits.mjs +f38af2d46d5f5f6388f599912507b94b87a0bf9dcc2104bb2187c38ee35118d5 packages/cli/tests/reply-limits.test.mjs +6ad72b4245e0a66e32725941900c1ece64c2e5de2fb73d65c8e042b4f3a691a6 packages/harness/src/runner.mjs +a5ef1b65230239a7baf6d0e8f2b8a51c0516348c7c4339a55b3f0a1a6ff531ba packages/harness/tests/citation-runner.test.mjs +330bc71bf6c7e64d3ddb70440903d2d9acf7878d44a49d7e30c2352ee4c807ec packages/harness/tests/fixtures/citation-adapter.mjs +0365114cf5773cbaa9fba196fbd1e773b2ec5ad2300c40231c357ca58884b9e3 packages/library/src/guard.mjs +aaa8f7ba60a729a88a6ef57b3d5ff5a69bcf0e17b60e7db51a8dc5a697627b6c packages/library/src/index.mjs +3e04fa25a64334d33ffbec704205b32cd01992f2d341e908f933a345199beee0 packages/library/src/limits.mjs +c9e682eff87e822647921288d7fce0ebdc7b09f0a6dfdd6528f10249ae26e81b packages/library/src/reply-protocol.mjs +dd056409712113eb9c1b70e29b21022f5b30df6ffbfb2658e293edd6ec7195c7 packages/library/tests/guard.test.mjs +2f3ec99129d16394d4458e252b9cba4e38ff66cd9393e3889f8afc3140e15a8e packages/seat/src/sandbox.mjs diff --git a/agents/beakley/work/queue-217/mutants.py b/agents/beakley/work/queue-217/mutants.py index 1e6271bfd..cff7f03e2 100644 --- a/agents/beakley/work/queue-217/mutants.py +++ b/agents/beakley/work/queue-217/mutants.py @@ -31,6 +31,9 @@ MUTANTS = [ ["packages/business/tests/reply-limits.test.mjs"]), ("M3-split-check-transaction", [(BROKER, ATOMIC, ""), (BROKER, REENTER, SPLIT)], ["packages/bus/tests/citation-guard.test.mjs"]), + ("M5-hide-library-unavailable", [("packages/library/src/guard.mjs", + "refuse('citation-unavailable', 'The library is busy or unavailable. Retry the answer when the library is available.');", + "served = null;")], ["packages/bus/tests/citation-availability.test.mjs"]), ("M4-drop-client-reason", [(CLIENT, "new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined)", "new BusError(r.error)")], ["packages/bus/tests/citation-guard.test.mjs"]), ] diff --git a/packages/bus/tests/citation-availability.test.mjs b/packages/bus/tests/citation-availability.test.mjs new file mode 100644 index 000000000..554a3d1e6 --- /dev/null +++ b/packages/bus/tests/citation-availability.test.mjs @@ -0,0 +1,56 @@ +// E4/sage-3668: real flock contention must not turn a valid cite into an invalid-cite refusal. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { ensureMirror, mirrorPath, SYNC_STATE_FILE, withLibraryLock } from '../../library/src/mirror.mjs'; +import { guardReply } from '../../library/src/guard.mjs'; +import { git } from '../../library/tests/helpers.mjs'; +import { Client } from '../src/client.mjs'; +import { serve } from '../src/server.mjs'; +import { citationWorld, citedBody, fixtureCite } from './citation-helpers.mjs'; + +test('C2-06 held library lock gives the agent citation-unavailable and its retry reason', { timeout: 25000 }, async t => { + const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); + const context = { business: f.business, project: 'books', dataRoot }; + await ensureMirror(context); const mirror = mirrorPath(context); + git(f.repo, '-c', 'protocol.file.allow=always', 'push', '-q', mirror, 'HEAD:refs/heads/served'); + writeFileSync(join(mirror, SYNC_STATE_FILE), JSON.stringify({ version: 1, branch: 'main', served: f.commit, + lastFetch: null, lastError: null, diverged: null, problems: [] }), { mode: 0o600 }); + f.adapter.guard = (ctx, body, opts) => guardReply({ ...ctx, dataRoot }, body, opts); + f.call(f.agent, 'role.claim'); + const socket = join(f.root, 'availability.sock'), server = await serve({ broker: f.broker, path: socket }); + t.after(() => server.close()); const client = new Client({ path: socket, cap: f.agent, timeout: 15000 }); + const args = { to: 'human', body: citedBody([fixtureCite(f.commit)]) }; + await client.call('message.send', args); // Establish that the cite and real mirror are valid. + const before = f.store.get('SELECT count(*) n FROM messages').n; + await withLibraryLock(mirror, async () => { + await assert.rejects(client.call('message.send', args), e => { + assert.equal(e.code, 'citation-unavailable'); + assert.equal(e.reason, 'The library is busy or unavailable. Retry the answer when the library is available.'); + return true; + }); + assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before); + // A fixed fallback carries no library cite and must not need the contested lock. + await client.call('message.send', { to: 'human', body: '(no answer passed the citation check)' }); + }); + await client.call('message.send', args); // No lock mutation or lost valid cite after release. +}); + +test('C2-06 malformed sync state is availability failure, never a fabricated invalid cite', async t => { + const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); + const context = { business: f.business, project: 'books', dataRoot }; + await ensureMirror(context); const mirror = mirrorPath(context); + writeFileSync(join(mirror, SYNC_STATE_FILE), '{broken', { mode: 0o600 }); + await assert.rejects(guardReply(context, citedBody([fixtureCite(f.commit)]), { + limits: f.limits(), definition: 'coder', pages: [], + }), { code: 'citation-unavailable' }); +}); + +test('C2-06 no mirror or freshly initialized empty mirror still means no served citation', async t => { + const f = citationWorld(t), dataRoot = join(f.root, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); + const context = { business: f.business, project: 'books', dataRoot }; + const check = () => guardReply(context, citedBody([fixtureCite(f.commit)]), { limits: f.limits(), definition: 'coder', pages: [] }); + await assert.rejects(check(), { code: 'citation-invalid' }); + await ensureMirror(context); await assert.rejects(check(), { code: 'citation-invalid' }); +}); diff --git a/packages/bus/tests/citation-guard.test.mjs b/packages/bus/tests/citation-guard.test.mjs index bd9725be0..f19421ca4 100644 --- a/packages/bus/tests/citation-guard.test.mjs +++ b/packages/bus/tests/citation-guard.test.mjs @@ -12,8 +12,8 @@ import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers. import { resolveReplyLimits } from '../../library/src/limits.mjs'; import { citationWorld, citedBody, fixtureCite, guardCases } from './citation-helpers.mjs'; -test('C2-06 send_message guard covers every table refusal and a non-creative sender; inserts nothing', async t => { - const f = citationWorld(t); f.call(f.agent, 'role.claim'); +for (const definition of ['coder', 'research']) test(`C2-06 ${definition} send_message guard covers every table refusal; inserts nothing`, async t => { + const f = citationWorld(t, { definition }); f.call(f.agent, 'role.claim'); writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation: true } })); for (const [code, body] of guardCases(f.commit)) { const count = f.store.get('SELECT count(*) n FROM messages').n; @@ -27,6 +27,22 @@ test('C2-06 send_message guard covers every table refusal and a non-creative sen assert.deepEqual(JSON.parse(row.body).check, { citations: [{ scope: 'canon', agent: null, file: f.commit }] }); }); +test('C2-06 stored check carries the file author trailer at an unrelated cited tip', async t => { + const f = citationWorld(t, { definition: 'research' }); f.call(f.agent, 'role.claim'); + const path = 'Book One/Characters/Mara Vell.md'; + writeFileSync(join(f.repo, path), readFileSync(join(f.repo, path), 'utf8') + 'A fixture note.\n'); + git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'Fixture note\n\nMosaic-Agent: acme/scribe'); + const file = git(f.repo, 'rev-parse', 'HEAD'); + writeFileSync(join(f.repo, 'Inbox Note.md'), 'Unrelated fixture.\n'); git(f.repo, 'add', '-A'); git(f.repo, 'commit', '-qm', 'unrelated'); + const cited = git(f.repo, 'rev-parse', 'HEAD'); f.setServed(cited); + assert.notEqual(cited, file); + const sent = await f.call(f.agent, 'message.send', { to: 'human', body: citedBody([fixtureCite(cited)]) }); + const event = f.store.get("SELECT body FROM events WHERE subject=? AND json_extract(body,'$.action')='message.checked'", sent.id); + assert.deepEqual(JSON.parse(event.body).check, { citations: [{ scope: 'canon', agent: 'acme/scribe', file }] }); + const [row] = await f.call(f.human, 'thread.messages', { thread: f.thread.id }); + assert.deepEqual(row.check.citations, [{ scope: 'canon', agent: 'acme/scribe', file, last: file }]); +}); + test('C2-06 check evidence and insert are atomic when storing evidence fails', async t => { const f = citationWorld(t); f.call(f.agent, 'role.claim'); f.store.run(`CREATE TRIGGER fixture_check_refused BEFORE INSERT ON events diff --git a/packages/bus/tests/citation-helpers.mjs b/packages/bus/tests/citation-helpers.mjs index ace917a5b..178c6ef93 100644 --- a/packages/bus/tests/citation-helpers.mjs +++ b/packages/bus/tests/citation-helpers.mjs @@ -25,13 +25,12 @@ export function citationWorld(t, { definition = 'coder', library = true, guard: catch { return resolveReplyLimits({ readable: false }); } }; let served = r.commit; - const reader = libraryReader(r.repo, r.commit, map); const reads = []; const adapter = { GuardError, replyLimits: limits, guard: overrideGuard ?? ((context, body, opts) => checkReply(body, { - limits: opts.limits, mapping: map, library: reader, + limits: opts.limits, mapping: map, library: libraryReader(r.repo, served, map), web: { allowed: ['companion', 'research'].includes(opts.definition), pages: opts.pages }, })), last: async (context, paths) => { diff --git a/packages/bus/tests/citation-runtime.test.mjs b/packages/bus/tests/citation-runtime.test.mjs new file mode 100644 index 000000000..70d821e06 --- /dev/null +++ b/packages/bus/tests/citation-runtime.test.mjs @@ -0,0 +1,29 @@ +// E7: exercise startBroker's real live readRaw adapter, rather than replacing the library adapter. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { startBroker } from '../src/runtime.mjs'; +import { validateBusinessDocument } from '../../business/src/business.mjs'; +import { businessDoc, rolesCopy, writeJson } from '../../business/tests/helpers.mjs'; +import { mappingEntry } from '../../library/tests/helpers.mjs'; + +test('C2-06 runtime reads changed and unreadable reply limits without restarting', async t => { + const root = mkdtempSync(join(tmpdir(), 'citation-runtime-')); t.after(() => rmSync(root, { recursive: true, force: true })); + const doc = businessDoc(root), rolesDir = rolesCopy(root), dir = join(root, 'config'), dataRoot = join(root, 'data'); + doc.projects.books = mappingEntry(); doc.replyLimits = { requireCitation: false, citationsPerReply: 3 }; + const file = writeJson(join(dir, 'businesses/acme.json'), doc); + const business = validateBusinessDocument(doc, file, { rolesDir }); mkdirSync(dataRoot, { mode: 0o700 }); + const runtime = await startBroker({ dataRoot, businesses: { acme: business }, organization: { dir, rolesDir } }); + t.after(() => runtime.close()); + const human = runtime.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true }); + const status = () => runtime.broker.request(human, { verb: 'library.status', args: { project: 'books' } }); + assert.deepEqual((await status()).replyLimits, { requireCitation: false, citationsPerReply: 3, fallback: [], problem: null }); + doc.replyLimits = { requireCitation: true, citationsPerReply: 7 }; writeJson(file, doc); + assert.deepEqual((await status()).replyLimits, { requireCitation: true, citationsPerReply: 7, fallback: [], problem: null }); + writeFileSync(file, '{broken'); + assert.deepEqual((await status()).replyLimits, { requireCitation: true, citationsPerReply: 20, fallback: [], problem: 'reply-limits-unreadable' }); + rmSync(file); + assert.equal((await status()).replyLimits.problem, 'reply-limits-unreadable'); +}); diff --git a/packages/business/tests/reply-limits.test.mjs b/packages/business/tests/reply-limits.test.mjs index 7670acdb1..63be1e72d 100644 --- a/packages/business/tests/reply-limits.test.mjs +++ b/packages/business/tests/reply-limits.test.mjs @@ -47,6 +47,13 @@ test('C2-06 M11 serialization cannot replace settings after the first fence', t }); }); +// E1: unlike tester/role actors, mallory passes the other actor-shape exclusions. +// Comparing document.human instead of old.human must fail this case. +test('C2-06 M11 an unrelated actor cannot appoint itself human and change reply limits', t => { + const f = fixture(t); + refused(f, 'mallory', d => { d.human = 'mallory'; d.replyLimits.requireCitation = true; }); +}); + test('C2-06 invalid setter values preserve the business and changes.jsonl', t => { const f = fixture(t), before = readFileSync(f.file), log = readFileSync(f.log); for (const replyLimits of [{ citationsPerReply: 0 }, { citationsPerReply: 1001 }, { requireCitation: 'true' }, { invented: true }, []]) { diff --git a/packages/harness/tests/citation-runner.test.mjs b/packages/harness/tests/citation-runner.test.mjs index 86d147d39..0eecff292 100644 --- a/packages/harness/tests/citation-runner.test.mjs +++ b/packages/harness/tests/citation-runner.test.mjs @@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { serve } from '../../bus/src/server.mjs'; import { FIXED_TEXTS } from '../../library/src/reply-protocol.mjs'; +import { GuardError } from '../../library/src/guard.mjs'; import { citationWorld, citedBody, fixtureCite, guardCases } from '../../bus/tests/citation-helpers.mjs'; const RUNNER = new URL('../src/runner.mjs', import.meta.url).pathname; @@ -16,8 +17,8 @@ async function until(fn) { throw Error('fixture timed out'); } -async function setup(t, stepsFor, { library = true, requireCitation = true, sendAuthority = true } = {}) { - const f = citationWorld(t, { library, sendAuthority }), runDir = join(f.root, 'run'), bundle = join(runDir, 'bundle'); +async function setup(t, stepsFor, { library = true, requireCitation = true, sendAuthority = true, guard } = {}) { + const f = citationWorld(t, { library, sendAuthority, guard }), runDir = join(f.root, 'run'), bundle = join(runDir, 'bundle'); mkdirSync(bundle, { recursive: true }); mkdirSync(join(f.root, 'ws')); const steps = stepsFor(f.commit), brokerSocket = join(f.root, 'broker.sock'); writeFileSync(f.settings, JSON.stringify({ replyLimits: { requireCitation } })); @@ -127,3 +128,17 @@ test('C2-06 outside library a refused send keeps the existing no-retry behavior' assert.equal(f.count(), 1); assert.equal(f.store.get("SELECT count(*) n FROM messages WHERE from_role='coder'").n, 0); assert.equal(existsSync(join(f.runDir, 'fixture-requests.jsonl')), true); }); + + +// E4: the agent must receive the availability reason in its actual retry turn, +// rather than being told only that the source citation was invalid. +test('C2-06 the retry turn delivers citation-unavailable reason to the agent', async t => { + let sends = 0; + const reason = 'The library is busy or unavailable. Retry the answer when the library is available.'; + const f = await setup(t, commit => [{ text: citedBody([fixtureCite(commit)]) }, { text: citedBody([fixtureCite(commit)]) }], { + guard: async () => { if (sends++ === 0) throw new GuardError('citation-unavailable', reason); return null; }, + }); + await f.ask(); assert.equal(f.count(), 2); + const requests = readFileSync(join(f.runDir, 'fixture-requests.jsonl'), 'utf8').trim().split('\n').map(JSON.parse); + assert.equal(requests[1].includes(`citation-unavailable: ${reason}`), true); +});