From 1ebaf1cab09551df6537540a3cae4aa4733a976f Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 9 Oct 2026 22:15:37 -0300 Subject: [PATCH] fix(mod): report blank worker turns only, not refusals Measured on Claude Code 2.1.296, a real API refusal ends the turn through StopFailure (error invalid_request), then turn.complete with reason refusal, and fires no classic.Stop. The worker-answer mod only posts after reading the main-loop Stop, so its refusal branch never ran. API errors behave the same, and neither carries background_tasks, so the mod cannot tell a failed turn with nothing running from one whose background work resumes it later; posting would answer the errand twice. The tests that pinned "a refusal posts unanswered refusal" asserted a contract the provider never delivers: they fed a Stop before a refusal completion, which Claude Code 2.1.296 does not produce. They are replaced by a test that a refusal turn (turn.complete reason refusal, no Stop) posts nothing. --- CHANGELOG.md | 14 +++++++------- docs/mod-answer.md | 25 ++++++++++++------------- hooks/worker-answer.js | 29 +++++++++++++++-------------- tests/worker-answer.engine.test.ts | 19 ++++++++++++------- tests/worker-answer.test.mjs | 23 ++++++----------------- 5 files changed, 52 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de97954..c34a648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **A lich subagent worker whose turn has nothing to answer now says so.** A - final turn that ends blank or refused, with nothing left running in the - background, posts `unanswered` (`blank` or `refusal`) to `/mod/answer`, - so the caller hears the errand ended instead of waiting on it. A turn an API - error ended still reports nothing: Claude Code's `StopFailure` carries no - `background_tasks`, so the mod cannot tell it left nothing running. Needs a - lich that accepts `unanswered`. +- **A lich subagent worker whose turn ends blank now says so.** A final turn + whose message is blank, with nothing left running in the background, posts + `unanswered: "blank"` to `/mod/answer`, so the caller hears the errand ended + instead of waiting on it. The plugin reports blank turns only: Claude Code + ends a refused turn and one an API error ended through `StopFailure`, with + no `Stop` and no `background_tasks`, so the mod cannot tell they left nothing + running and does not report them. Needs a lich that accepts `unanswered`. - **Every state report names the conversation it comes from**, in `provider_session_id`, so lich can drop one from an agent CLI the session runs as a tool (`claude -p`, `cursor-agent -p`), which inherits the session's diff --git a/docs/mod-answer.md b/docs/mod-answer.md index 7e7f59d..e25a452 100644 --- a/docs/mod-answer.md +++ b/docs/mod-answer.md @@ -18,7 +18,6 @@ reply instructions, and answers the errand with what this mod posts. |-------------------------------------------|-------------------------------------------------------------| | `classic.Stop`, then `turn.complete{reason=answer}` | `POST /mod/answer` with `{"session_id", "text"}` | | `classic.Stop` with a blank message, then `turn.complete{reason=answer}` | `POST /mod/answer` with `{"session_id", "unanswered": "blank"}` | -| `classic.Stop`, then `turn.complete{reason=refusal}` | `POST /mod/answer` with `{"session_id", "unanswered": "refusal"}` | ## How it behaves @@ -35,20 +34,20 @@ reply instructions, and answers the errand with what this mod posts. subagent, a monitor, a workflow); Claude Code resumes in a new turn when that work finishes, and that turn's `Stop`, with nothing listed, is the one that answers. -- **A turn with nothing to answer says so.** One whose final message is blank - posts `unanswered: "blank"`, a refused one `unanswered: "refusal"`, so the - caller hears the errand ended instead of waiting on it. Both follow the rule - above: only when the turn's own `classic.Stop` lists nothing running. A - refused turn with no `Stop` of its own reports nothing. +- **A blank turn says so.** One whose final message is blank posts + `unanswered: "blank"`, so the caller hears the errand ended instead of + waiting on it, and only when the turn's own `classic.Stop` lists nothing + running. The plugin reports blank turns only. - **An aborted turn does not report**: its user took the worker over, and the turn they start next is the one that answers. -- **A failed turn does not report either**, though the contract has - `unanswered: "error"` for it. An API error fires `StopFailure` instead of - `Stop`, and read off the Claude Code 2.1.296 bundle neither `StopFailure`'s - input (`error`, `error_details`, `last_assistant_message`) nor - `turn.complete`'s carries `background_tasks`, and no mod engine call lists - them. Nothing tells whether the failed turn left work running, so `error` is - pending until Claude Code exposes that list on the failure. +- **A refused or failed turn does not report either**, though the contract has + `unanswered: "refusal"` and `"error"` for them. Measured on Claude Code + 2.1.296, both end through `StopFailure` (a refusal with error + `invalid_request`) and then `turn.complete`, with no `Stop`, and neither + carries `background_tasks`, nor does any mod engine call list them: the mod + cannot tell a failed turn with nothing running from one whose background + work resumes it later and answers then, so a report would answer the errand + twice. Both are pending until Claude Code exposes that list on the failure. - `turn.complete` is matched once per `reason` (`answer`, `refusal`, `error`, `aborted`), which keeps the hooks beside mod-control's own `turn.complete` hook: Claude Code refuses one plugin's second hook on an event diff --git a/hooks/worker-answer.js b/hooks/worker-answer.js index 56a5fbd..d6cbd48 100644 --- a/hooks/worker-answer.js +++ b/hooks/worker-answer.js @@ -16,16 +16,18 @@ // handed work to the background, and the turn Claude Code resumes in once it // finishes is the one that reports). Its `turn.complete` then says what: an // answer that is not blank is the worker's answer, a blank one is -// `unanswered: "blank"`, a refusal `unanswered: "refusal"`. Measured on -// Claude Code 2.1.289: `classic.Stop` fires on the main loop only, before -// `turn.complete`, and its `last_assistant_message` equals the completion's -// `answer`; an aborted turn fires no Stop. +// `unanswered: "blank"`. Measured on Claude Code 2.1.289: `classic.Stop` fires +// on the main loop only, before `turn.complete`, and its +// `last_assistant_message` equals the completion's `answer`; an aborted turn +// fires no Stop. // -// A turn an API error ended reports nothing, though the contract has -// `unanswered: "error"` for it: such a turn fires `StopFailure` instead of -// `Stop`, and read off the Claude Code 2.1.296 bundle neither `StopFailure`'s -// input nor `turn.complete`'s carries `background_tasks`, so nothing tells -// whether the failed turn left work running. +// A refused turn and one an API error ended report nothing, though the +// contract has `unanswered: "refusal"` and `"error"` for them. Measured on +// Claude Code 2.1.296, both end through `StopFailure` (a refusal with error +// `invalid_request`) and then `turn.complete`, with no `Stop`, and neither +// carries `background_tasks`: the mod cannot tell a failed turn with nothing +// running from one whose background work resumes it later and answers then, +// so reporting would answer the errand twice. // // Every function that takes `$` is declared here at the top level: the loader // refuses a module that hands `$` to a nested function. @@ -46,7 +48,7 @@ const ANSWER_LIMIT = 16000 * @typedef {{ base: string, token: string, session: string }} Link * @typedef {{ idle: boolean, text: string }} Stop * @typedef {{ link?: Link, stop?: Stop }} State - * @typedef {{ text: string } | { unanswered: "blank" | "refusal" }} Report + * @typedef {{ text: string } | { unanswered: "blank" }} Report */ /** @@ -94,7 +96,6 @@ function cut(text) { */ function reportOf(stop, e) { if (stop === undefined || !stop.idle) return undefined - if (e.reason === "refusal") return { unanswered: "refusal" } if (e.reason !== "answer") return undefined if (stop.text.trim() === "") return e.answer.trim() === "" ? { unanswered: "blank" } : undefined return stop.text === e.answer ? { text: cut(stop.text) } : undefined @@ -155,9 +156,9 @@ export function register(on) { // One hook per reason a turn ends with: the matchers keep them beside // mod-control.js's own turn.complete hook, which has none, and Claude Code - // refuses one plugin's second hook on an event with no matcher. The aborted - // and failed ones report nothing; they are here so every turn drops the Stop - // it read, and a later turn never inherits it. + // refuses one plugin's second hook on an event with no matcher. The refused, + // aborted and failed ones report nothing; they are here so every turn drops + // the Stop it read, and a later turn never inherits it. on("turn.complete", { reason: "answer" }, ($, e, next) => settleTurn($, state, e, next)) on("turn.complete", { reason: "refusal" }, ($, e, next) => settleTurn($, state, e, next)) on("turn.complete", { reason: "error" }, ($, e, next) => settleTurn($, state, e, next)) diff --git a/tests/worker-answer.engine.test.ts b/tests/worker-answer.engine.test.ts index 19ada81..a41d956 100644 --- a/tests/worker-answer.engine.test.ts +++ b/tests/worker-answer.engine.test.ts @@ -70,18 +70,23 @@ test('a worker lich names by its depth answers with subagent cards left on', asy expect(world.answers).toEqual([{ session_id: 'lich-1', text: 'Rewrote docs/cli.md.' }]) }) -test('a blank turn is reported unanswered, and a refused one too', async ($, on) => { +test('a blank turn is reported unanswered', async ($, on) => { mock.env(on, WORKER) const clock = mock.clock(on) const world = lich(on) await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) await $.classic.Stop({ stop_hook_active: false, last_assistant_message: '', background_tasks: [] }) await $.turn.complete({ ...ANSWERED, answer: '' }) - await $.classic.Stop({ stop_hook_active: false, last_assistant_message: 'No.', background_tasks: [] }) - await $.turn.complete({ ...ANSWERED, answer: 'No.', reason: 'refusal' }) await clock.settle() - expect(world.answers).toEqual([ - { session_id: 'lich-1', unanswered: 'blank' }, - { session_id: 'lich-1', unanswered: 'refusal' }, - ]) + expect(world.answers).toEqual([{ session_id: 'lich-1', unanswered: 'blank' }]) +}) + +test('a refused turn, which fires no Stop, reports nothing', async ($, on) => { + mock.env(on, WORKER) + const clock = mock.clock(on) + const world = lich(on) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await $.turn.complete({ ...ANSWERED, answer: '', reason: 'refusal' }) + await clock.settle() + expect(world.answers).toEqual([]) }) diff --git a/tests/worker-answer.test.mjs b/tests/worker-answer.test.mjs index 992b20c..897bfc4 100644 --- a/tests/worker-answer.test.mjs +++ b/tests/worker-answer.test.mjs @@ -117,29 +117,18 @@ test('a blank final message is reported unanswered as the contract spells it', a for (const request of mod.requests) assertContractHonoured('/mod/answer', request) }) -test('a refused turn is reported unanswered as the contract spells it', async () => { - const mod = load() - await mod.start() - - await mod.turn(stop('I cannot help with that.'), complete('I cannot help with that.', 'refusal')) - - assert.deepEqual(mod.bodies(), [{ session_id: LICH_SESSION_ID, unanswered: 'refusal' }]) - assertContractHonoured('/mod/answer', mod.requests[0]) -}) - -test('a blank or refused turn that handed work to the background reports nothing', async () => { +test('a blank turn that handed work to the background reports nothing', async () => { const mod = load() await mod.start() await mod.turn(stop('', [SHELL]), complete('')) - await mod.turn(stop('I cannot help with that.', [SHELL]), complete('I cannot help with that.', 'refusal')) assert.equal(mod.requests.length, 0) }) -// A refused turn reads its background work off its own Stop; one without a -// Stop cannot tell whether work is still running. -test('a refused turn with no Stop of its own reports nothing', async () => { +// Measured on Claude Code 2.1.296: a refusal ends the turn through StopFailure, +// then turn.complete with reason refusal, and fires no Stop. +test('a refused turn reports nothing', async () => { const mod = load() await mod.start() @@ -162,12 +151,12 @@ test('an aborted or failed turn reports nothing', async () => { assert.equal(mod.requests.length, 0) }) -test('an aborted turn drops the Stop it read, so a later refusal cannot use it', async () => { +test('an aborted turn drops the Stop it read, so a later blank turn cannot use it', async () => { const mod = load() await mod.start() await mod.turn(stop(''), complete('', 'aborted')) - await mod.turn(undefined, complete('', 'refusal')) + await mod.turn(undefined, complete('')) assert.equal(mod.requests.length, 0) })