diff --git a/CHANGELOG.md b/CHANGELOG.md index cc9721b..2145e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **A lich worker answers the way a native subagent does.** The worker's last + message is its report, so it is handed the task alone, with no ticket or reply + instructions; a worker that handed work to the background reports once that + work is done. Needs a lich that takes the answer (the mod-answer contract). + ### Fixed - **The edit guard names the other session by its card's label.** A note that a diff --git a/CLAUDE.md b/CLAUDE.md index 39e3dee..1fb4a14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,11 +17,12 @@ hooks/crush-hooks.json # hook registration, Crush (hand-merged into c hooks/ # hook scripts (all four run them; only how they are addressed differs) hooks/detail.jq # what a tool call acts on, shared by two of those scripts hooks/win-run.cmd # runs one of those scripts on Windows (Codex) -hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the four mods below +hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the five mods below hooks/mod-control.js # Claude Code mod: lich drives the session hooks/agent-cards.js # Claude Code mod: a general-purpose subagent runs as a lich session hooks/mod-usage.js # Claude Code mod: the session reports its own context, rate limits and cost hooks/edit-guard.js # Claude Code mod: an edit says which other lich session edited the file +hooks/worker-answer.js # Claude Code mod: a lich subagent worker's last message answers its task opencode/lich.js # opencode client: a module, not a command omp/lich.js # omp client: a module, not a command docs/ # lich ⇄ plugin communication contracts @@ -60,6 +61,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p - [docs/mod-control.md](docs/mod-control.md): lich drives a Claude Code session (prompt, abort, model, effort, command) and asks it side questions (ask) through a mod that long-polls for commands and acks each one; Claude Code only, the other harnesses have no mod system - [docs/agent-cards.md](docs/agent-cards.md): a general-purpose subagent Claude Code starts runs as a lich session, through the `lich open` CLI rather than an HTTP contract; Claude Code only - [docs/mod-usage.md](docs/mod-usage.md): a Claude Code session reports the context window, rate limits and cost it measured itself, through a mod hooking `session.measure`; Claude Code only +- [docs/mod-answer.md](docs/mod-answer.md): a lich subagent worker answers the task it was handed with its turn's final message, through a mod reading the main loop's `classic.Stop` and `turn.complete`; Claude Code only - [docs/edit-guard.md](docs/edit-guard.md): an edit to a file another lich session edited in the last minutes carries a note naming it, through markers in the checkout's git dir rather than an HTTP contract; Claude Code only - [docs/providers.md](docs/providers.md) — the per-harness map, including what installing on omp takes and what it cannot report @@ -94,7 +96,9 @@ lich it subscribes to nothing at all. Claude Code mod, handing it a fake `$` and asserting its acks against the mod-control fixtures and its polls against the commands lich sends; [tests/mod-usage.test.mjs](tests/mod-usage.test.mjs) does the same for its -usage reports against the mod-usage fixtures. +usage reports against the mod-usage fixtures, and +[tests/worker-answer.test.mjs](tests/worker-answer.test.mjs) for a worker's +answer against the mod-answer fixtures. [tests/edit-guard.test.mjs](tests/edit-guard.test.mjs) hands the edit guard a fake `$` with an in-memory git dir and asserts the markers it leaves and the note another session's marker adds. @@ -108,6 +112,7 @@ dependencies. [tests/mod-control.engine.test.ts](tests/mod-control.engine.test.ts) and [tests/agent-cards.engine.test.ts](tests/agent-cards.engine.test.ts) and [tests/mod-usage.engine.test.ts](tests/mod-usage.engine.test.ts) and +[tests/worker-answer.engine.test.ts](tests/worker-answer.engine.test.ts) and [tests/edit-guard.engine.test.ts](tests/edit-guard.engine.test.ts) run the mods under Claude Code's own engine (`claude plugin test .`); they need a `claude` binary, so CI does not run them. The kit does not check a `tool.call` @@ -160,6 +165,6 @@ Same standard as the `lich` repository (Keep a Changelog + SemVer). Releases are 1. Move the `[Unreleased]` entries in `CHANGELOG.md` under the new version heading (with date) and refresh the compare links at the bottom. 2. Pick the number by what changed: a release that sends anything lich's contract did not already accept (a new endpoint, header, field or value) bumps the **minor** (the major, from 1.0); anything else bumps the **patch**. lich accepts every release below the next minor of the contract it implements, so a contract change released as a patch reaches a lich that refuses it. `docs/hooks/README.md` in lich, Versioning, is the rule. -3. Align `version` in `.claude-plugin/plugin.json` **and** `.codex-plugin/plugin.json` with the tag, and `plugin_version` in each `hooks/report-*.sh` and `PLUGIN_VERSION` in `opencode/lich.js`, `omp/lich.js` and `hooks/mod-control.js`, the header every report sends. The suite fails while any of them disagrees with the Claude Code manifest. The root `plugin.json` is not a third one to bump: Antigravity's manifest takes only a `name`, and lich writes its own carrying the tag when it installs a release. +3. Align `version` in `.claude-plugin/plugin.json` **and** `.codex-plugin/plugin.json` with the tag, and `plugin_version` in each `hooks/report-*.sh` and `PLUGIN_VERSION` in `opencode/lich.js`, `omp/lich.js`, `hooks/mod-control.js`, `hooks/mod-usage.js` and `hooks/worker-answer.js`, the header every report sends. The suite fails while any of them disagrees with the Claude Code manifest. The root `plugin.json` is not a third one to bump: Antigravity's manifest takes only a `name`, and lich writes its own carrying the tag when it installs a release. 4. Annotated tag `vX.Y.Z` + push with the tag. 5. `gh release create vX.Y.Z` with the notes taken from the matching `CHANGELOG.md` section. diff --git a/README.md b/README.md index e46aee0..b21469c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ a lich session: a card you can watch and steer, working in the asking session's checkout as a native subagent does, or in a worktree of its own when the subagent asks for isolation. The Agent call returns at once as a background subagent, and the session's full report arrives at the asking session's prompt -on its own as a `[lich]` note; Explore, Plan and other agent types stay inside Claude Code. [docs/agent-cards.md](docs/agent-cards.md) has the +on its own as a `[lich]` note: the worker's last message is its report, as a native subagent's is +([docs/mod-answer.md](docs/mod-answer.md)). Explore, Plan and other agent types stay inside Claude Code. [docs/agent-cards.md](docs/agent-cards.md) has the rules and what happens when lich cannot be reached. Since that subagent shares the checkout, an edit to a file another lich session edited in the last ten minutes tells the model which session it was and how to reach it @@ -61,11 +62,12 @@ hooks/report-tool.sh # session-state hook: the tool a turn is runni hooks/report-session-start.sh # session-start hook hooks/report-title.sh # session-title hook hooks/report-touched.sh # session-touched hook -hooks/lich.js # the Claude Code mods' entry, which registers the four below +hooks/lich.js # the Claude Code mods' entry, which registers the five below hooks/mod-control.js # mod-control client, a Claude Code mod hooks/agent-cards.js # a general-purpose subagent as a lich session, a Claude Code mod hooks/mod-usage.js # mod-usage client, a Claude Code mod hooks/edit-guard.js # names the other lich session that edited a file, a Claude Code mod +hooks/worker-answer.js # mod-answer client: a lich worker's last message answers its task, a Claude Code mod opencode/lich.js # opencode client: all four reports plus the seven tools, one module omp/lich.js # omp client: the reports, one module docs/ # client-side docs, one per contract diff --git a/docs/agent-cards.md b/docs/agent-cards.md index 3d92dfd..4d9f8ed 100644 --- a/docs/agent-cards.md +++ b/docs/agent-cards.md @@ -86,7 +86,10 @@ for an isolated call. the model reads after it: where the worker is (and, without isolation, that it edits this same checkout), that its report arrives on its own, and that `send_to_session` or `lich send` reaches it where `SendMessage` cannot. -- **Without isolation the worker shares the checkout**, as a native subagent +- **The worker answers by itself.** Its own copy of this plugin posts the final + message of a turn that ends with nothing left running in the background as + its answer ([mod-answer](mod-answer.md)), so lich hands it the task with no + ticket and no reply instructions, as a native subagent gets its prompt.- **Without isolation the worker shares the checkout**, as a native subagent does: both edit the same files, and nothing keeps their changes apart. With `isolation: "worktree"` it gets a branch and worktree of its own. - **It falls back to the native agent only before lich has the task.** Claude @@ -111,11 +114,12 @@ for an isolated call. "N agents" hint counts background agents: `N workers` under the prompt (`$.ui.status`), which Claude Code shows after the plugin's name as `lich: N workers`, cleared at none. Every 5 seconds while any runs, the mod reads - `$LICH_BIN sessions --json`; a worker lich lists with a turn `done`, or no - longer lists at all, is finished. lich closes a worker in this checkout once - it reported and its turn ended (`lich open --subagent` in lich's docs/cli.md), - which is what takes it off the list. A list lich could not give leaves the - count as it was until the next read. + `$LICH_BIN sessions --json`. A worker in this checkout is finished once lich + no longer lists it: lich closes it after it reported and its turn ended + (`lich open --subagent` in lich's docs/cli.md), while a turn it ended with a + command left in the background is done without being finished. An isolated + worker keeps its card after it reported, so a turn lich lists `done` ends it. + A list lich could not give leaves the count as it was until the next read. - **Esc does not stop a worker, and TaskStop does**, as with a native background agent. Measured on Claude Code 2.1.289: Esc in the asking session interrupts its own turn and leaves its background agents running (a second Esc at the @@ -127,7 +131,8 @@ for an isolated call. and is still counting: a worker in this checkout is closed with `$LICH_BIN close `, having nothing of its own to keep, and an isolated one has its turn stopped with `$LICH_BIN control abort`, its card and - worktree left for the user. Either answers TaskStop's own result shape; a + worktree left for the user. A closed worker's task just ends: lich sends + the asking session no note about it. Either answers TaskStop's own result shape; a command lich refused denies the call with lich's reason. Any other task id goes to Claude Code. - **The worker runs with the permissions lich opens any Claude Code session diff --git a/docs/mod-answer.md b/docs/mod-answer.md new file mode 100644 index 0000000..cf2c009 --- /dev/null +++ b/docs/mod-answer.md @@ -0,0 +1,56 @@ +# Mod: worker answer + +Client side of the [mod-answer contract](https://github.com/omartelo/lich/blob/main/docs/hooks/mod-answer.md) +(the endpoint and the payload are defined in the lich repository; this plugin +only implements them). + +Makes a lich subagent worker (a session opened with `lich open --subagent`, +which is what [agent-cards](agent-cards.md) opens for an Agent call) answer the +task it was handed the way a native subagent does: the final message of its +turn is its result. lich hands such a worker its task with no ticket and no +reply instructions, and answers the errand with what this mod posts. + +| client | Claude Code | Codex | Antigravity | opencode | omp | Crush | +|--------------------------|----------------------------------------------------------|---------------------|---------------------|---------------------|---------------------|---------------------| +| `hooks/worker-answer.js` | mod, registered by `hooks/lich.js`, the `modules` entry | none: no mod system | none: no mod system | none: no mod system | none: no mod system | none: no mod system | + +| event | sent | +|-------------------------------------------|-------------------------------------------------------------| +| `classic.Stop`, then `turn.complete{reason=answer}` | `POST /mod/answer` with `{"session_id", "text"}` | + +## How it behaves + +- **Only a worker answers.** It reads `LICH_SUBAGENT_CARDS` in `session.start` + and reports only when it is `off`, which lich sets on every `--subagent` + session. A session the user turned subagent cards off for carries it too; + lich ignores a report from a session with no subagent errand open. +- **A turn that handed work to the background does not answer.** Its main-loop + `classic.Stop` lists what is still running in `background_tasks` (a shell, a + 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. +- **An aborted, failed or refused turn does not answer**, and neither does one + whose final message is blank. `turn.complete` is matched on + `reason: "answer"`, which also keeps the hook beside mod-control's own + `turn.complete` hook: Claude Code refuses one plugin's second hook on an event + with no matcher. +- **A subagent's turn inside the worker does not answer.** `classic.Stop` fires + on the main loop only, and a `turn.complete` that carries an `agentId` is + skipped. +- **A Stop answers only for its own turn.** The text is posted when the + completion's `answer` equals the Stop's `last_assistant_message`, and the + Stop's verdict is dropped once a completion read it: an aborted turn fires no + Stop, and must not inherit the last one. +- **The text is cut at 16,000 UTF-16 units** and marked `[truncated]`, as an + `ask`'s answer is. +- **The turn is never held up.** The answer is posted beside the chain; one + that fails is dropped, never retried. +- **Outside lich, and in a non-interactive run, it never reports.** + +Measured on Claude Code 2.1.289: `classic.Stop` fires on the main loop before +`turn.complete`, its `last_assistant_message` equals the completion's `answer`, +and its `background_tasks` is `[]` when nothing runs. `classic.SubagentStop` +fires for engine forks too, and a background subagent lists itself as still +running there, which is why only the main loop's `Stop` is read. A worker that +asks with `AskUserQuestion` blocks with no `Stop` until someone answers in its +card. diff --git a/docs/providers.md b/docs/providers.md index 2dcba91..a29e8d6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -24,11 +24,12 @@ This repository is one plugin, packaged for each harness it supports: | `hooks/crush-hooks.json` | Crush | hook registration, merged by hand | | `hooks/*.sh` | the four | the reports themselves | | `hooks/win-run.cmd` | Codex | runs a script on Windows | -| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the four below | +| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the five below | | `hooks/mod-control.js` | Claude Code | the mod-control client, a mod | | `hooks/agent-cards.js` | Claude Code | runs a general-purpose subagent as a lich session, a mod | | `hooks/mod-usage.js` | Claude Code | the mod-usage client, a mod | | `hooks/edit-guard.js` | Claude Code | names the other lich session that edited a file, a mod | +| `hooks/worker-answer.js` | Claude Code | the mod-answer client: a worker's last message answers its task, a mod | | `opencode/lich.js` | opencode | the whole client, as a module | | `omp/lich.js` | omp | the whole client, as a module | | `skills/` | all | skills, same layout | diff --git a/hooks/agent-cards.js b/hooks/agent-cards.js index 7a1dffc..1ce4ee6 100644 --- a/hooks/agent-cards.js +++ b/hooks/agent-cards.js @@ -350,16 +350,17 @@ function watchWorkers($, state) { } /** - * A worker still runs while lich lists it with a turn that is not done: busy, - * waiting on a permission, or not reported yet, which is a worker whose first - * turn has not started. One lich no longer lists was closed, which lich does to - * a worker in this checkout once it reported. + * A worker in this checkout still runs while lich lists it: lich closes one + * once it reported, and a turn it ended with a command left in the background + * is done without being finished. An isolated worker keeps its card after it + * reported, so for it a done turn is the end. * * @param {Worker} worker * @param {Peer[]} peers */ function stillRuns(worker, peers) { - return peers.some((p) => p.name === worker.name && p.state !== "done") + const listed = peers.find((p) => p.name === worker.name) + return listed !== undefined && (worker.shared || listed.state !== "done") } /** diff --git a/hooks/lich.js b/hooks/lich.js index ce79d8b..c947564 100644 --- a/hooks/lich.js +++ b/hooks/lich.js @@ -6,6 +6,7 @@ import { register as registerAgentCards } from "./agent-cards.js" import { register as registerEditGuard } from "./edit-guard.js" import { register as registerModControl } from "./mod-control.js" import { register as registerModUsage } from "./mod-usage.js" +import { register as registerWorkerAnswer } from "./worker-answer.js" /** @param {import('claude-code').On} on */ export function register(on) { @@ -13,4 +14,5 @@ export function register(on) { registerAgentCards(on) registerModUsage(on) registerEditGuard(on) + registerWorkerAnswer(on) } diff --git a/hooks/worker-answer.js b/hooks/worker-answer.js new file mode 100644 index 0000000..9b71a3c --- /dev/null +++ b/hooks/worker-answer.js @@ -0,0 +1,114 @@ +// Answers the task a lich subagent worker was handed with the worker's own last +// message, the way a native subagent's final message is its result. Contract: +// ../docs/mod-answer.md, canonical in +// https://github.com/omartelo/lich/blob/main/docs/hooks/mod-answer.md +// +// A Claude Code mod, registered by hooks/lich.js beside mod-control.js. Only a +// worker reports: lich spawns every `lich open --subagent` session with +// LICH_SUBAGENT_CARDS=off, which a session the user turned subagent cards off +// for also carries; lich ignores the report from a session with no subagent +// errand open, so that one costs a request per turn and nothing else. +// +// A turn is the worker's answer when its main-loop `classic.Stop` lists no +// background task (a shell, subagent, monitor or workflow still running means +// the turn handed work to the background, and the turn Claude Code resumes in +// once it finishes is the one that answers) and its `turn.complete` ended with +// an answer that is not 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. +// +// Every function that takes `$` is declared here at the top level: the loader +// refuses a module that hands `$` to a nested function. + +// Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release). +const PLUGIN_VERSION = "0.16.0" + +// The value lich spawns a `--subagent` session with. +const CARDS_OFF = "off" + +// The contract cuts an answer at this many UTF-16 units, as mod-control.js +// cuts an ask's, which keeps the body under lich's 64 KiB limit. +const ANSWER_LIMIT = 16000 + +/** + * @typedef {import('claude-code').EngineInterface} Engine + * @typedef {{ base: string, token: string, session: string }} Link + * @typedef {{ link?: Link, idleText?: string }} State + */ + +/** + * The link to lich, for a worker only. + * + * @param {Engine} $ + * @returns {Promise} + */ +async function workerLink($) { + const [port, token, session, cards] = await Promise.all([ + $.env.get("LICH_PORT"), + $.env.get("LICH_TOKEN"), + $.env.get("LICH_SESSION_ID"), + $.env.get("LICH_SUBAGENT_CARDS"), + ]) + if (!port || !token || !session || cards !== CARDS_OFF) return undefined + return { base: `http://127.0.0.1:${port}`, token, session } +} + +/** @param {string} text */ +function cut(text) { + if (text.length <= ANSWER_LIMIT) return text + return `${text.slice(0, ANSWER_LIMIT)}\n[truncated]` +} + +/** + * Posts one answer beside the chain. One that cannot be sent is dropped: the + * worker's card still holds it, and a second copy of a report is worse than + * none. + * + * @param {Engine} $ + * @param {Link} link + * @param {string} text + */ +async function report($, link, text) { + try { + await $.http.fetch(`${link.base}/mod/answer?token=${link.token}`, { + method: "POST", + headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION }, + body: JSON.stringify({ session_id: link.session, text: cut(text) }), + }) + } catch { + // The contract's client rule: a report that cannot be sent is dropped. + } +} + +/** @param {import('claude-code').On} on */ +export function register(on) { + /** @type {State} */ + const state = {} + + // Interactive sessions only: a `claude -p` started from a tool inside a + // worker inherits its variables, and its answers are not the worker's. + on("session.start", { isInteractive: true }, async ($, e, next) => { + state.link = await workerLink($) + return next(e) + }) + + on("classic.Stop", ($, e, next) => { + const idle = (e.background_tasks ?? []).length === 0 + const text = e.last_assistant_message ?? "" + state.idleText = idle && text.trim() !== "" ? text : undefined + return next(e) + }) + + // The matcher keeps this beside mod-control.js's own turn.complete hook, which + // has none: Claude Code refuses one plugin's second hook on an event with no + // matcher. An aborted, failed or refused turn never matches. + on("turn.complete", { reason: "answer" }, ($, e, next) => { + const text = state.idleText + state.idleText = undefined + const link = state.link + const isWorkersAnswer = link !== undefined && e.agentId === undefined && !e.isAborted && text === e.answer + if (isWorkersAnswer && text !== undefined) void report($, link, text) + return next(e) + }) +} diff --git a/tests/agent-cards.engine.test.ts b/tests/agent-cards.engine.test.ts index e0b34fa..de2b78e 100644 --- a/tests/agent-cards.engine.test.ts +++ b/tests/agent-cards.engine.test.ts @@ -138,7 +138,7 @@ test('turned off in lich, every subagent stays native and lich is never run', as expect(w.argvs).toEqual([]) }) -test('a running worker shows in the status line until lich lists it done, and TaskStop closes one', async ($, on) => { +test('a running worker shows in the status line until lich closes it, and TaskStop closes one', async ($, on) => { const pending = { ...OPENED, delivery: { ticket: 't1', target: BRANCH, status: 'pending', answer: '' } } const peer = (name: string, state: string) => ({ label: name, name, project: 'lich', kind: 'claude', state }) const w = world( @@ -147,7 +147,7 @@ test('a running worker shows in the status line until lich lists it done, and Ta exited(2, { ...pending, label: 'second', name: 'second-1a2b' }), exited(0, [peer(OPENED.name, 'busy'), peer('second-1a2b', 'busy')]), exited(0, ''), - exited(0, [peer(OPENED.name, 'done')]), + exited(0, []), ) await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) await $.tool.call(CALL) diff --git a/tests/agent-cards.test.mjs b/tests/agent-cards.test.mjs index 3a5003b..ad4f98a 100644 --- a/tests/agent-cards.test.mjs +++ b/tests/agent-cards.test.mjs @@ -474,21 +474,35 @@ const peer = (worker, state) => ({ label: worker.label, name: worker.name, proje const listed = (...peers) => exits(0, peers) const SESSIONS = [LICH_BIN, 'sessions', '--json'] -test('a backgrounded worker shows in the status line until lich lists its turn done', async () => { +// A worker in this checkout is closed once it reported, so it runs until lich +// stops listing it: a turn it ended with a command left in the background is +// done but not finished. +test('a worker in this checkout shows in the status line until lich closes it', async () => { const { mod } = await delegate([exits(2, openedHere('pending'))]) assert.deepEqual(mod.statuses, ['1 worker']) assert.equal(mod.timers.length, 1) await mod.tick(listed(peer(SHARED, 'busy'))) assert.deepEqual(mod.ran.at(-1).argv, SESSIONS) + await mod.tick(listed(peer(SHARED, 'done'))) assert.deepEqual(mod.statuses, ['1 worker']) assert.equal(mod.timers.length, 1) - await mod.tick(listed(peer(SHARED, 'done'))) + await mod.tick(listed()) assert.deepEqual(mod.statuses, ['1 worker', undefined]) assert.equal(mod.timers.length, 0) }) +// An isolated worker keeps its card and worktree after it reported, so a done +// turn is the end there. +test('an isolated worker shows in the status line until lich lists its turn done', async () => { + const { mod } = await delegate([onBranch('main'), exits(2, opened('pending'))], ISOLATED) + await mod.tick(listed(peer(OPENED, 'busy'))) + assert.deepEqual(mod.statuses, ['1 worker']) + await mod.tick(listed(peer(OPENED, 'done'))) + assert.deepEqual(mod.statuses, ['1 worker', undefined]) +}) + test('a worker waiting on a permission, or not reported yet, is still running', async () => { const { mod } = await delegate([exits(2, openedHere('pending'))]) await mod.tick(listed(peer(SHARED, 'waiting'))) @@ -512,7 +526,7 @@ test('two workers count as two, and one finishing leaves one', async () => { assert.deepEqual(mod.statuses, ['1 worker', '2 workers']) assert.equal(mod.timers.length, 1, 'one watch for every worker') - await mod.tick(listed(peer(SHARED, 'done'), peer(OPENED, 'busy'))) + await mod.tick(listed(peer(OPENED, 'busy'))) assert.deepEqual(mod.statuses.at(-1), '1 worker') }) diff --git a/tests/contract.mjs b/tests/contract.mjs index d3e92bd..c5a949e 100644 --- a/tests/contract.mjs +++ b/tests/contract.mjs @@ -39,6 +39,7 @@ export const FIXTURE_OF = { '/session-touched': 'session-touched', '/mod/acks': 'mod-control', '/mod/usage': 'mod-usage', + '/mod/answer': 'mod-answer', } const readFixture = (name) => @@ -133,6 +134,13 @@ export const REJECT_RULES = { 'negative cost': (b) => b.cost_usd < 0, 'malformed json': (b, raw) => !isJsonObject(raw), }, + '/mod/answer': { + 'missing session_id': (b) => !('session_id' in b), + 'missing text': (b) => !('text' in b), + 'blank text': (b) => typeof b.text === 'string' && b.text.trim() === '', + 'text is not a string': (b) => 'text' in b && typeof b.text !== 'string', + 'malformed json': (b, raw) => !isJsonObject(raw), + }, '/mod/acks': { 'missing session_id': (b) => !('session_id' in b), 'missing id': (b) => !('id' in b), diff --git a/tests/fixtures/mod-answer.jsonl b/tests/fixtures/mod-answer.jsonl new file mode 100644 index 0000000..d68e249 --- /dev/null +++ b/tests/fixtures/mod-answer.jsonl @@ -0,0 +1,8 @@ +{"name":"a worker's report","body":{"session_id":"s1","text":"Rewrote docs/cli.md; the tests pass."},"accept":{"session_id":"s1","text":"Rewrote docs/cli.md; the tests pass."}} +{"name":"a report keeps its lines","body":{"session_id":"s1","text":"Done.\n\n- a < b && c > d\n- `go test` green"},"accept":{"text":"Done.\n\n- a < b && c > d\n- `go test` green"}} +{"name":"extra fields are ignored","body":{"session_id":"s1","text":"Done.","turn_id":"t"},"accept":{"text":"Done."}} +{"name":"missing session_id","body":{"text":"Done."},"reject":"session_id is required"} +{"name":"missing text","body":{"session_id":"s1"},"reject":"text is required"} +{"name":"blank text","body":{"session_id":"s1","text":" \n\t"},"reject":"a blank text is no answer"} +{"name":"text is not a string","body":{"session_id":"s1","text":42},"reject":"text must be a string"} +{"name":"malformed json","raw":"{","reject":"body must be a JSON object"} diff --git a/tests/refresh-fixtures.sh b/tests/refresh-fixtures.sh index f90ad55..cba90d8 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -22,3 +22,11 @@ for file in session-state.jsonl session-start.jsonl session-title.jsonl \ curl -fsSL "$base/$file" -o "$here/fixtures/$file" echo "refreshed $file at $ref" done + +# The mod-answer contract is in no lich release yet: its fixture is read at the +# commit of lich's feat/worker-auto-answer branch that adds it, and folds into +# the list above once a release ships it. +answer=274b3d514ea18c0fd68c1da578de1c0b65ef8af0 +curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$answer/docs/hooks/fixtures/mod-answer.jsonl" \ + -o "$here/fixtures/mod-answer.jsonl" +echo "refreshed mod-answer.jsonl at $answer (unreleased)" diff --git a/tests/worker-answer.engine.test.ts b/tests/worker-answer.engine.test.ts new file mode 100644 index 0000000..b2b10a9 --- /dev/null +++ b/tests/worker-answer.engine.test.ts @@ -0,0 +1,60 @@ +// The worker's answer under Claude Code's own engine: `claude plugin test .` +// loads the plugin's hooks module (hooks/lich.js, which registers +// hooks/worker-answer.js) from hooks/hooks.json the way a session does, the +// test's hooks standing for lich (`http.fetch`). tests/worker-answer.test.mjs +// is the suite CI runs against the contract fixtures; this one proves the +// module loads beside mod-control.js's own turn.complete hook and that its +// hooks chain in the engine that runs it. + +import { expect, mock, test } from 'claude-code/testing' +import type { On, TurnCompleteInput } from 'claude-code' + +const WORKER = { LICH_PORT: '47999', LICH_TOKEN: 'tok', LICH_SESSION_ID: 'lich-1', LICH_SUBAGENT_CARDS: 'off' } + +const ANSWERED: TurnCompleteInput = { answer: 'Rewrote docs/cli.md.', reason: 'answer', isAborted: false, turnId: 't1', durationMs: 1000 } + +/** + * Records every answer and parks every poll, as lich does with nothing + * queued; also stands for the engine's own session start, Stop and turn end, which + * the kit leaves to the test. + */ +function lich(on: On) { + const answers: Record[] = [] + on('session.start', async (_$, e) => ({ cwd: e.cwd })) + on('turn.complete', async (_$, e) => ({ text: e.answer })) + on('classic.Stop', async () => ({})) + on('http.fetch', async (_$, e) => { + if (e.url.includes('/mod/answer')) { + answers.push(JSON.parse(e.init?.body ?? '{}')) + return { value: { status: 204, ok: true, headers: {}, text: '' } } + } + return new Promise(() => {}) + }) + return { answers } +} + +test("a worker's last message is posted as its answer", 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: 'Rewrote docs/cli.md.', background_tasks: [] }) + await $.turn.complete(ANSWERED) + await clock.settle() + expect(world.answers).toEqual([{ session_id: 'lich-1', text: 'Rewrote docs/cli.md.' }]) +}) + +test('a turn with work in the background answers 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 $.classic.Stop({ + stop_hook_active: false, + last_assistant_message: 'WAITING', + background_tasks: [{ id: 'b1', type: 'shell', status: 'running', description: 'sleep 20' }], + }) + await $.turn.complete({ ...ANSWERED, answer: 'WAITING' }) + await clock.settle() + expect(world.answers).toHaveLength(0) +}) diff --git a/tests/worker-answer.test.mjs b/tests/worker-answer.test.mjs new file mode 100644 index 0000000..9dd4241 --- /dev/null +++ b/tests/worker-answer.test.mjs @@ -0,0 +1,198 @@ +// Pins the Claude Code mod that answers a lich subagent worker's task +// (hooks/worker-answer.js) to lich's mod-answer contract. +// +// Like mod-usage.test.mjs, it imports the module and hands it a fake engine, +// recording every fetch as contract.mjs reads a request, so the reports answer +// to the same fixtures lich asserts (mod-answer.jsonl). The `classic.Stop` and +// `turn.complete` payloads below are the shapes Claude Code 2.1.289 delivered. +// +// Run: node --test tests/ + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { LICH_SESSION_ID, assertContractHonoured, lichEnv } from './contract.mjs' +import { register } from '../hooks/worker-answer.js' + +const PORT = 47997 +const WORKER_ENV = { ...lichEnv(PORT), LICH_SUBAGENT_CARDS: 'off' } +const NO_CONTENT = { status: 204, ok: true, headers: {}, text: '' } + +const SHELL = { id: 'bib80qpy1', type: 'shell', status: 'running', description: 'sleep 20', command: 'sleep 20' } + +const stop = (last, backgroundTasks = []) => ({ + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: last, + background_tasks: backgroundTasks, + session_crons: [], +}) + +const complete = (answer, reason = 'answer') => ({ + answer, + reason, + isAborted: reason === 'aborted', + turnId: 't1', + durationMs: 1000, +}) + +const settle = () => new Promise((r) => setTimeout(r, 20)) + +/** + * Registers the module against a fake engine. A hook registered with a + * matcher runs only for an event that matches it, as the engine runs it. + */ +function load({ env = WORKER_ENV, answer = () => Promise.resolve(NO_CONTENT) } = {}) { + const hooks = new Map() + register((event, ...rest) => { + const hook = rest.pop() + hooks.set(event, { matcher: rest[0] ?? {}, hook }) + }) + const requests = [] + const $ = { + env: { get: async (name) => env[name] }, + http: { + fetch: (url, init = {}) => { + const { pathname, search } = new URL(url) + const headers = Object.fromEntries(Object.entries(init.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])) + requests.push({ method: init.method ?? 'GET', url: pathname + search, headers, raw: init.body ?? '' }) + return answer() + }, + }, + } + const passOn = async (e) => e + const fire = (event, e) => { + const { matcher, hook } = hooks.get(event) + const matches = Object.entries(matcher).every(([key, value]) => e[key] === value) + return matches ? hook($, e, passOn) : passOn(e) + } + const turn = async (stopEvent, completeEvent) => { + if (stopEvent) await fire('classic.Stop', stopEvent) + await fire('turn.complete', completeEvent) + await settle() + } + return { + requests, + bodies: () => requests.map((r) => JSON.parse(r.raw)), + start: (isInteractive = true) => fire('session.start', { cwd: '/w', surface: 'terminal', isInteractive }), + turn, + } +} + +test("a worker's final message reaches lich as the contract spells it", async () => { + const mod = load() + await mod.start() + + await mod.turn(stop('Rewrote docs/cli.md.'), complete('Rewrote docs/cli.md.')) + + assert.equal(mod.requests.length, 1) + assertContractHonoured('/mod/answer', mod.requests[0]) + assert.deepEqual(mod.bodies()[0], { session_id: LICH_SESSION_ID, text: 'Rewrote docs/cli.md.' }) +}) + +test('a turn that handed work to the background answers only once it resumes', async () => { + const mod = load() + await mod.start() + + await mod.turn(stop('WAITING', [SHELL]), complete('WAITING')) + assert.equal(mod.requests.length, 0, 'answered while a shell still ran') + + await mod.turn(stop('FINISHED: DONE'), complete('FINISHED: DONE')) + assert.deepEqual(mod.bodies().map((b) => b.text), ['FINISHED: DONE']) +}) + +test('an aborted, failed or refused turn answers nothing', async () => { + const mod = load() + await mod.start() + + await mod.turn(undefined, complete('half a sentence', 'aborted')) + await mod.turn(stop('partial'), complete('partial', 'error')) + await mod.turn(stop('I cannot help with that.'), complete('I cannot help with that.', 'refusal')) + + assert.equal(mod.requests.length, 0) +}) + +test('a blank final message answers nothing', async () => { + const mod = load() + await mod.start() + + await mod.turn(stop(' \n'), complete(' \n')) + await mod.turn(stop(undefined), complete('')) + + assert.equal(mod.requests.length, 0) +}) + +// classic.Stop fires only on the main loop; turn.complete fires for every +// subagent's turn too, and one of those is never the worker's answer. +test("a subagent's turn inside the worker answers nothing", async () => { + const mod = load() + await mod.start() + + await mod.turn(undefined, { ...complete('SUBDONE'), agentId: 'af8fcd1f2b867337f' }) + + assert.equal(mod.requests.length, 0) +}) + +// An aborted turn fires no Stop, so the verdict of the turn before it must not +// stand in for one. +test("a turn without a Stop of its own does not reuse the last one's", async () => { + const mod = load() + await mod.start() + + await mod.turn(stop('WAITING', [SHELL]), complete('WAITING')) + await mod.turn(undefined, complete('something else')) + + assert.equal(mod.requests.length, 0) +}) + +test('an ordinary session never answers', async () => { + const mod = load({ env: lichEnv(PORT) }) + await mod.start() + + await mod.turn(stop('Done.'), complete('Done.')) + + assert.equal(mod.requests.length, 0) +}) + +test('outside lich nothing is sent', async () => { + const mod = load({ env: { LICH_SUBAGENT_CARDS: 'off' } }) + await mod.start() + + await mod.turn(stop('Done.'), complete('Done.')) + + assert.equal(mod.requests.length, 0) +}) + +test('a non-interactive run never answers for the card that started it', async () => { + const mod = load() + await mod.start(false) + + await mod.turn(stop('Done.'), complete('Done.')) + + assert.equal(mod.requests.length, 0) +}) + +test('a long answer is cut where the contract says', async () => { + const mod = load() + await mod.start() + const long = 'x'.repeat(20000) + + await mod.turn(stop(long), complete(long)) + + const text = mod.bodies()[0].text + assert.equal(text, `${'x'.repeat(16000)}\n[truncated]`) + assertContractHonoured('/mod/answer', mod.requests[0]) +}) + +test('the turn is never held up by lich, and a failure is dropped', async () => { + const hung = load({ answer: () => new Promise(() => {}) }) + await hung.start() + const e = complete('Done.') + await hung.turn(stop('Done.'), e) + assert.equal(hung.requests.length, 1) + + const failing = load({ answer: () => Promise.reject(new Error('connect ECONNREFUSED')) }) + await failing.start() + await failing.turn(stop('Done.'), complete('Done.')) + assert.equal(failing.requests.length, 1, 'a failed answer was retried') +})