Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 12 additions & 13 deletions docs/mod-answer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
29 changes: 15 additions & 14 deletions hooks/worker-answer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
*/

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
19 changes: 12 additions & 7 deletions tests/worker-answer.engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([])
})
23 changes: 6 additions & 17 deletions tests/worker-answer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
})
Expand Down
Loading