From dc311f8f86f6ea99d8f68cf33f17fef70cc5d0b5 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 4 Oct 2026 18:01:49 -0300 Subject: [PATCH 1/3] feat: answer a side question from lich with a fork of the session The mod-control contract adds an ask kind: the mod answers it with $.model.fork, the question behind a preamble saying tools are unavailable (without it a mid-turn fork reaches for one and answers in a second request, measured on Claude Code 2.1.289), and acks the reply in the new answer field, cut at 16,000 UTF-16 units. A fork can run past a minute, so an ask starts the moment its poll returns and is chained behind nothing, nor anything behind it. The contract is in no lich release yet: refresh-fixtures.sh now reads the two mod-control fixtures at lich 8b06b79 (branch feat/ask-session), and the vendored copies are that commit's byte for byte. CI's drift check fails until that commit is pushed. Tests changed because the contract changed: the fake engine serves model.fork, and the every-shape test expects the ask's fork ahead of the queued commands and its ack in no fixed place. contract.mjs models the two new rejected cases. --- CHANGELOG.md | 6 +++ CLAUDE.md | 2 +- README.md | 4 +- docs/mod-control.md | 29 +++++++++- hooks/mod-control.js | 47 ++++++++++++++-- tests/contract.mjs | 2 + tests/fixtures/mod-commands.json | 3 +- tests/fixtures/mod-control.jsonl | 6 ++- tests/mod-control.engine.test.ts | 44 ++++++++++++++- tests/mod-control.test.mjs | 93 +++++++++++++++++++++++++++++++- tests/refresh-fixtures.sh | 6 +-- 11 files changed, 226 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e48e04..7257403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 why. Esc stops the wait, not the session; its report then arrives as a [lich] note. Needs Claude Code 2.1.280 or later with mods on (measured on 2.1.289). +- **lich can ask a Claude Code session a side question without stopping it.** + The mod answers an `ask` with a fork of the session's own conversation, which + runs beside the turn, stays out of the conversation and is mostly served from + the prompt cache, and sends the answer back in its ack, cut at 16,000 + characters. An ask still answering holds back no other command. lich asks + with `lich ask` or an agent's `ask_session` tool (lich-plugin 0.15 contract). ## [0.14.0] - 2026-10-03 diff --git a/CLAUDE.md b/CLAUDE.md index 6b00fcd..f047750 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p - [docs/session-start.md](docs/session-start.md) — Claude session id via `SessionStart` - [docs/session-title.md](docs/session-title.md) — the provider's own session title, via `PostToolUse`/`PreInvocation` and `Stop` - [docs/session-touched.md](docs/session-touched.md) — git-status refresh signal via `PostToolUse` (file-mutating tools only) -- [docs/mod-control.md](docs/mod-control.md): lich drives a Claude Code session (prompt, abort, model, effort, command) through a mod that long-polls for commands and acks each one; Claude Code only, the other harnesses have no mod system +- [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` and `lich wait` CLI 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 diff --git a/README.md b/README.md index a70fc96..56d4b8e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,9 @@ On **Claude Code** it carries the other direction too, from lich to the session: a mod, a module Claude Code runs in its own process, takes the commands given with `lich control` or an agent's `control_session` tool (start a turn with a prompt, stop the running turn, override the model or the effort, run a slash -command) and reports how each one went. It needs Claude Code 2.1.280 or later +command), answers a side question asked with `lich ask` or `ask_session` from a +fork of the session's conversation without stopping its turn, and reports how +each one went. It needs Claude Code 2.1.280 or later with mods turned on; an older one keeps every report above, and `lich control` refuses its session, naming what to fix. [docs/mod-control.md](docs/mod-control.md) has what it does and which releases diff --git a/docs/mod-control.md b/docs/mod-control.md index 7d0677a..866256e 100644 --- a/docs/mod-control.md +++ b/docs/mod-control.md @@ -7,7 +7,9 @@ this plugin only implements them). Lets lich drive a Claude Code session, through `lich control` on the command line or the `control_session` MCP tool an agent calls: start a turn with a prompt, stop the running turn, override the model or the effort of every -request, and run one of its slash commands. It runs the other way from the +request, and run one of its slash commands; and ask it a side question it +answers without stopping, through `lich ask` or the `ask_session` MCP tool. It +runs the other way from the hooks: instead of reporting what happened, it holds a long poll open on lich, applies the commands that come back, and acks each one. @@ -31,9 +33,10 @@ nothing. | `model` | every `turn.step` sent with `model` | the override is set, or dropped when absent | | `effort` | every `turn.step` sent with `effort` | the override is set, or dropped when absent | | `command` | `$.command.run({ command: name, args })` | it ran, once the session was idle | +| `ask` | `$.model.fork({ prompt })`, the question behind a preamble | the fork answered; `answer` carries it | `GET /mod/commands?token=…&session_id=…` to fetch, `POST /mod/acks` with -`{"session_id", "id", "kind", "ok", "error"?}` to ack, `X-Lich-Plugin` on both. +`{"session_id", "id", "kind", "ok", "error"?, "answer"?}` to ack, `X-Lich-Plugin` on both. ## How it behaves @@ -58,6 +61,19 @@ nothing. turn ended, and behind a prompt it would then cancel the prompt's own turn instead. A `model` or `effort` sent meanwhile likewise applies to the running turn's next request. +- **An `ask` waits on nothing and nothing waits on it.** A fork runs beside the + turn and can take over a minute (measured at 109 seconds for a long answer on + 2.1.289), so it starts the moment its poll returns and acks whenever it + answers, outside the order the other commands keep. Several run at once. +- **The question goes behind a preamble.** It says the question is a side one, + that the answer stays out of the conversation, and that tools are unavailable. + Without it, a fork made mid-turn reaches for a tool, is refused, and answers + in a second request: twice the latency and the uncached tokens (measured on + 2.1.289). +- **An answer is cut at 16,000 UTF-16 units**, with `\n[truncated]` after it, + which keeps the ack under lich's 64 KiB body limit. A fork that does not + answer acks `ok: false` with its reason: `nothing-to-fork`, + `api-error `, `empty-reply` or `aborted`. - **Every other ack is awaited before the next command.** That is the contract's rule for an `abort`: lich ends the turn it has open when the ack lands, so a late one would end the turn the next `prompt` opened. An ack that @@ -114,3 +130,12 @@ the trust prompt is answered, the module after. - **`/model` and `/effort` are refused by lich, not by the mod.** Run through a mod, Claude Code saves what they set as the default for every new session; the `model` and `effort` commands are the per-session route. +- **A fork sees the conversation as the session's last request sent it.** A + reply being written, or a tool call running, when the question lands is not + in it. Measured on 2.1.289. +- **A fork cannot be cancelled, and survives an Esc on the turn.** It takes no + signal; measured on 2.1.289, it answered in full after the turn it ran beside + was interrupted. A module reload is what kills one, and then nothing is acked: + lich's wait runs out. +- **A fork's tokens are not in the transcript**, so nothing that reads cost + from it counts an `ask`. diff --git a/hooks/mod-control.js b/hooks/mod-control.js index b47d64d..5ec254d 100644 --- a/hooks/mod-control.js +++ b/hooks/mod-control.js @@ -27,10 +27,24 @@ const MAX_BACKOFF_MS = 10000 // the hook, so a level outside these is refused here, where lich hears of it. const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]) +// Put before an `ask`'s question. Without it, a fork made while a turn runs +// reaches for a tool, is refused (a fork has none) and answers in a second +// request, at twice the latency and the uncached tokens: measured on Claude +// Code 2.1.289. +const ASK_PREAMBLE = + "This is a side question asked from outside your turn, while you work. It does not " + + "interrupt your turn and your answer is not added to the conversation. Tools are " + + "unavailable: do not call any tool. Answer from what the conversation already holds, " + + "in plain text, briefly. Question: " + +// The contract cuts an answer at this many UTF-16 units, which keeps an ack +// under lich's 64 KiB body limit; a fork has no length bound of its own. +const ANSWER_LIMIT = 16000 + /** * @typedef {import('claude-code').EngineInterface} Engine * @typedef {'low' | 'medium' | 'high' | 'xhigh' | 'max'} Effort - * @typedef {{ id: string, kind: string, text?: string, model?: string, effort?: string, name?: string, args?: string }} Command + * @typedef {{ id: string, kind: string, text?: string, model?: string, effort?: string, name?: string, args?: string, question?: string }} Command * @typedef {{ * base: string, * token: string, @@ -84,6 +98,12 @@ async function poll($, state, link) { } link.backoffMs = 0 for (const command of commands) { + // An answer can take a minute and changes nothing the other commands + // depend on, so it is not queued behind them, nor they behind it. + if (command.kind === "ask") { + void applyAndAck($, state, link, command) + continue + } link.applying = link.applying.then(() => { const acked = applyAndAck($, state, link, command) // A prompt or a slash command settles only once the session is idle and @@ -108,11 +128,10 @@ async function poll($, state, link) { * @param {Command} command */ async function applyAndAck($, state, link, command) { - /** @type {{ ok: boolean, error?: string }} */ + /** @type {{ ok: boolean, error?: string, answer?: string }} */ let outcome try { - await apply($, state, command) - outcome = { ok: true } + outcome = { ok: true, ...(await apply($, state, command)) } } catch (error) { outcome = { ok: false, error: error instanceof Error ? error.message : String(error) } } @@ -131,6 +150,7 @@ async function applyAndAck($, state, link, command) { * @param {Engine} $ * @param {State} state * @param {Command} command + * @returns {Promise<{ answer: string } | undefined>} what the ack carries beyond `ok` */ async function apply($, state, command) { switch (command.kind) { @@ -153,11 +173,30 @@ async function apply($, state, command) { case "command": await $.command.run({ command: command.name ?? "", args: command.args }) return + case "ask": + return { answer: await answer($, command.question ?? "") } default: throw new Error("unknown kind") } } +/** + * The session's answer to a side question: a fork of its own conversation, cut + * at ANSWER_LIMIT. A fork with no answer throws its reason, which the ack + * carries as its error. + * + * @param {Engine} $ + * @param {string} question + */ +async function answer($, question) { + const reply = await $.model.fork({ prompt: ASK_PREAMBLE + question }) + if (!reply.isAnswered) { + throw new Error(reply.reason === "api-error" ? `api-error ${reply.status} ${reply.error}` : reply.reason) + } + if (reply.text.length <= ANSWER_LIMIT) return reply.text + return `${reply.text.slice(0, ANSWER_LIMIT)}\n[truncated]` +} + /** @param {import('claude-code').On} on */ export function register(on) { /** @type {State} */ diff --git a/tests/contract.mjs b/tests/contract.mjs index 16cd26f..daed500 100644 --- a/tests/contract.mjs +++ b/tests/contract.mjs @@ -122,6 +122,8 @@ export const REJECT_RULES = { 'missing kind': (b) => !('kind' in b), 'unknown kind': (b) => 'kind' in b && !MOD_KINDS.has(b.kind), 'ok is not a bool': (b) => 'ok' in b && typeof b.ok !== 'boolean', + 'answer on another kind': (b) => 'answer' in b && b.kind !== 'ask', + 'answer on a failed ask': (b) => 'answer' in b && b.kind === 'ask' && b.ok !== true, 'malformed json': (b, raw) => !isJsonObject(raw), }, } diff --git a/tests/fixtures/mod-commands.json b/tests/fixtures/mod-commands.json index 31b6d50..7df499c 100644 --- a/tests/fixtures/mod-commands.json +++ b/tests/fixtures/mod-commands.json @@ -6,5 +6,6 @@ {"id": "m5", "kind": "effort", "effort": "high"}, {"id": "m6", "kind": "effort"}, {"id": "m7", "kind": "command", "name": "compact", "args": "keep the test plan"}, - {"id": "m8", "kind": "command", "name": "clear"} + {"id": "m8", "kind": "command", "name": "clear"}, + {"id": "m9", "kind": "ask", "question": "what are you working on?"} ] diff --git a/tests/fixtures/mod-control.jsonl b/tests/fixtures/mod-control.jsonl index d6f584f..cc8b72a 100644 --- a/tests/fixtures/mod-control.jsonl +++ b/tests/fixtures/mod-control.jsonl @@ -4,9 +4,13 @@ {"name":"long error is clamped","body":{"session_id":"s1","id":"m5","kind":"command","ok":false,"error":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"},"accept":{"error":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}} {"name":"id from a previous process","body":{"session_id":"s1","id":"m999","kind":"prompt","ok":true},"accept":{"id":"m999"}} {"name":"extra fields are ignored","body":{"session_id":"s1","id":"m6","kind":"abort","ok":true,"turn_id":"t"},"accept":{"id":"m6"}} +{"name":"answered ask carries its answer","body":{"session_id":"s1","id":"m9","kind":"ask","ok":true,"answer":"Fixing the login test."},"accept":{"ok":true,"answer":"Fixing the login test."}} +{"name":"unanswered ask carries why","body":{"session_id":"s1","id":"m9","kind":"ask","ok":false,"error":"nothing-to-fork"},"accept":{"ok":false,"error":"nothing-to-fork"}} {"name":"missing session_id","body":{"id":"m1","kind":"abort","ok":true},"reject":"session_id is required"} {"name":"missing id","body":{"session_id":"s1","kind":"abort","ok":true},"reject":"id is required"} {"name":"missing kind","body":{"session_id":"s1","id":"m1","ok":true},"reject":"kind is required"} -{"name":"unknown kind","body":{"session_id":"s1","id":"m1","kind":"type","ok":true},"reject":"kind must be prompt, abort, model, effort or command"} +{"name":"unknown kind","body":{"session_id":"s1","id":"m1","kind":"type","ok":true},"reject":"kind must be prompt, abort, model, effort, command or ask"} +{"name":"answer on another kind","body":{"session_id":"s1","id":"m1","kind":"prompt","ok":true,"answer":"x"},"reject":"only an answered ask carries an answer"} +{"name":"answer on a failed ask","body":{"session_id":"s1","id":"m9","kind":"ask","ok":false,"error":"aborted","answer":"x"},"reject":"only an answered ask carries an answer"} {"name":"ok is not a bool","body":{"session_id":"s1","id":"m1","kind":"abort","ok":"true"},"reject":"ok must be a boolean"} {"name":"malformed json","raw":"{","reject":"body must be a JSON object"} diff --git a/tests/mod-control.engine.test.ts b/tests/mod-control.engine.test.ts index c0927ab..ba2f404 100644 --- a/tests/mod-control.engine.test.ts +++ b/tests/mod-control.engine.test.ts @@ -2,7 +2,7 @@ // hooks/lich.js from hooks/hooks.json the way a session does, which registers // hooks/mod-control.js, and runs these against it, the test's hooks standing // for lich (`http.fetch`) and for the engine's bottom (`prompt.submit`, -// `turn.abort`, `turn.step`, `command.run`). +// `turn.abort`, `turn.step`, `command.run`, `model.fork`). // // tests/mod-control.test.mjs is the suite CI runs, against the contract // fixtures; this one needs a claude binary, and proves the module loads and its @@ -144,3 +144,45 @@ test('an abort reaches the turn while a slash command never settles', async ($, expect(aborted).toEqual(['t1']) expect(world.acks).toEqual([{ session_id: 'lich-1', id: 'm2', kind: 'abort', ok: true }]) }) + +test('an ask from lich is answered by a fork and the answer rides the ack', async ($, on) => { + mock.env(on, ENV) + const clock = mock.clock(on) + const world = lich(on, [{ id: 'm9', kind: 'ask', question: 'what are you on?' }]) + const asked: string[] = [] + on('model.fork', async (_$, e) => { + asked.push(e.prompt) + return { + value: { + isAnswered: true, + text: 'Fixing the login test.', + usage: { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + } + }) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await clock.settle() + expect(asked.length).toBe(1) + expect(asked[0].endsWith('Question: what are you on?')).toBe(true) + expect(world.acks).toEqual([ + { session_id: 'lich-1', id: 'm9', kind: 'ask', ok: true, answer: 'Fixing the login test.' }, + ]) +}) + +test('an abort reaches the turn while an ask never answers', async ($, on) => { + mock.env(on, ENV) + const clock = mock.clock(on) + const world = lich(on, [{ id: 'm9', kind: 'ask', question: 'why?' }], [{ id: 'm2', kind: 'abort' }]) + const aborted: string[] = [] + on('turn.start', async (_$, e) => ({ turnId: e.turnId })) + on('model.fork', () => new Promise(() => {})) + on('turn.abort', async (_$, e) => { + aborted.push(e.turnId) + return { value: undefined } + }) + await $.turn.start({ text: 'go', turnId: 't1' }) + await $.session.start({ cwd: '/w', surface: 'terminal', isInteractive: true }) + await clock.settle() + expect(aborted).toEqual(['t1']) + expect(world.acks).toEqual([{ session_id: 'lich-1', id: 'm2', kind: 'abort', ok: true }]) +}) diff --git a/tests/mod-control.test.mjs b/tests/mod-control.test.mjs index 7d7868c..8d063d3 100644 --- a/tests/mod-control.test.mjs +++ b/tests/mod-control.test.mjs @@ -30,6 +30,8 @@ const status = (code) => () => Promise.resolve({ status: code, ok: code >= 200 && code < 300, headers: {}, text: '' }) const refused = () => () => Promise.reject(new Error('connect ECONNREFUSED 127.0.0.1')) const ACKED = { status: 204, ok: true, headers: {}, text: '' } +const ANSWER = 'Fixing the login test.' +const USAGE = { input_tokens: 24, output_tokens: 12, cache_read_input_tokens: 55730, cache_creation_input_tokens: 36 } function deferred() { let resolve, reject @@ -105,6 +107,12 @@ function load({ env = lichEnv(PORT), polls = [], engine = {} } = {}) { return engine.command ? engine.command(args) : { text: '' } }, }, + model: { + fork: async (args) => { + calls.push(['fork', args]) + return engine.fork ? engine.fork(args) : { isAnswered: true, text: ANSWER, usage: USAGE } + }, + }, } const passOn = async (e) => e @@ -327,17 +335,26 @@ test('a slash command waiting for idle holds back none of the commands after it' // ----------------------------------------------------------------- commands -- +// The contract changed here: an `ask` is applied outside the order, at once, +// so its fork comes ahead of the commands queued before it, and its ack lands +// whenever the fork answers. test('every command shape lich sends is applied in order and acked ok', async () => { const mod = await applied(COMMANDS, { before: (m) => m.turnStart('t1') }) + const asked = COMMANDS.find((c) => c.kind === 'ask') assert.deepEqual(mod.calls, [ + ['fork', { prompt: forkPrompt(asked.question) }], ['prompt', { text: 'run the tests' }], ['abort', { turnId: 't1' }], ['command', { command: 'compact', args: 'keep the test plan' }], ['command', { command: 'clear', args: undefined }], ]) assert.deepEqual( - mod.acks().map(({ id, kind, ok }) => ({ id, kind, ok })), - COMMANDS.map(({ id, kind }) => ({ id, kind, ok: true })), + mod.acks().filter((a) => a.kind !== 'ask').map(({ id, kind, ok }) => ({ id, kind, ok })), + COMMANDS.filter((c) => c !== asked).map(({ id, kind }) => ({ id, kind, ok: true })), + ) + assert.deepEqual( + mod.acks().filter((a) => a.kind === 'ask').map(({ id, ok }) => ({ id, ok })), + [{ id: asked.id, ok: true }], ) for (const request of mod.ackRequests()) { const { body } = assertContractHonoured('/mod/acks', request) @@ -477,3 +494,75 @@ test('a kind the mod does not know is acked as unknown, its kind echoed', async ]) assert.equal(mod.ackRequests()[0].headers['x-lich-plugin'], PLUGIN_VERSION) }) + +// --------------------------------------------------------------------- ask -- + +/** The prompt the fork is sent: the question behind the mod's preamble. */ +function forkPrompt(question) { + return `This is a side question asked from outside your turn, while you work. It does not interrupt your turn and your answer is not added to the conversation. Tools are unavailable: do not call any tool. Answer from what the conversation already holds, in plain text, briefly. Question: ${question}` +} + +test('an ask is answered by a fork of the session and its answer rides the ack', async () => { + const mod = await applied([{ id: 'm9', kind: 'ask', question: 'what are you on?' }]) + assert.deepEqual(mod.calls, [['fork', { prompt: forkPrompt('what are you on?') }]]) + assert.deepEqual(mod.acks(), [{ session_id: LICH_SESSION_ID, id: 'm9', kind: 'ask', ok: true, answer: ANSWER }]) + assertContractHonoured('/mod/acks', mod.ackRequests()[0]) +}) + +test('an ask with no answer acks the fork\'s reason', async () => { + const cases = [ + [{ isAnswered: false, reason: 'nothing-to-fork' }, 'nothing-to-fork'], + [{ isAnswered: false, reason: 'api-error', status: 529, error: 'overloaded', usage: USAGE }, 'api-error 529 overloaded'], + [{ isAnswered: false, reason: 'empty-reply', usage: USAGE }, 'empty-reply'], + [{ isAnswered: false, reason: 'aborted', usage: USAGE }, 'aborted'], + ] + for (const [reply, error] of cases) { + const mod = await applied([{ id: 'm9', kind: 'ask', question: 'why?' }], { engine: { fork: () => reply } }) + assert.deepEqual(mod.acks(), [{ session_id: LICH_SESSION_ID, id: 'm9', kind: 'ask', ok: false, error }]) + assertContractHonoured('/mod/acks', mod.ackRequests()[0]) + } +}) + +test('a long answer is cut at 16,000 characters and says so', async () => { + const text = 'x'.repeat(16001) + const mod = await applied([{ id: 'm9', kind: 'ask', question: 'essay?' }], { + engine: { fork: () => ({ isAnswered: true, text, usage: USAGE }) }, + }) + assert.equal(mod.acks()[0].answer, `${'x'.repeat(16000)}\n[truncated]`) +}) + +test('an answer of exactly 16,000 characters is left whole', async () => { + const text = 'x'.repeat(16000) + const mod = await applied([{ id: 'm9', kind: 'ask', question: 'essay?' }], { + engine: { fork: () => ({ isAnswered: true, text, usage: USAGE }) }, + }) + assert.equal(mod.acks()[0].answer, text) +}) + +// A fork can run for a minute and more (measured at 109 seconds on Claude Code +// 2.1.289), so neither the poll nor an abort may wait on one. +test('an ask still answering holds back neither the poll nor the commands after it', async () => { + const forked = deferred() + const mod = load({ + polls: [ok([{ id: 'm9', kind: 'ask', question: 'why?' }]), ok([{ id: 'm2', kind: 'abort' }])], + engine: { fork: () => forked.promise }, + }) + await mod.turnStart('t1') + await mod.start() + await until(() => mod.acks().length === 1, 'the abort ack') + await until(() => mod.parked() === 1, 'the poll after the abort') + assert.deepEqual(mod.acks()[0], { session_id: LICH_SESSION_ID, id: 'm2', kind: 'abort', ok: true }) + forked.resolve({ isAnswered: true, text: ANSWER, usage: USAGE }) + await until(() => mod.acks().length === 2, 'the ask ack') + assert.equal(mod.acks()[1].answer, ANSWER) +}) + +test('an ask does not wait on an abort ack still being answered', async () => { + const held = deferred() + const mod = load({ polls: [ok([{ id: 'm2', kind: 'abort' }, { id: 'm9', kind: 'ask', question: 'why?' }])] }) + mod.holdAcks((request) => (JSON.parse(request.raw).kind === 'abort' ? held.promise : Promise.resolve(ACKED))) + await mod.turnStart('t1') + await mod.start() + await until(() => mod.acks().some((a) => a.kind === 'ask'), 'the ask ack') + held.resolve(ACKED) +}) diff --git a/tests/refresh-fixtures.sh b/tests/refresh-fixtures.sh index 093346a..a715d8c 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -23,12 +23,12 @@ for file in session-state.jsonl session-start.jsonl session-title.jsonl \ echo "refreshed $file at $ref" done -# The mod-control `command` kind is in no lich release yet: these two files are -# read at the lich commit that carries it (branch feat/control-session), so the +# The mod-control `command` and `ask` kinds are in no lich release yet: these two files are +# read at the lich commit that carries them (branch feat/ask-session), so the # module is held to the contract lich is about to ship rather than to the one # it replaces. Once a release ships it, move tests/lich-ref there, fold these # files back into the list above and delete this block. -pending=f974f9ba2b55ebc7ead6115abd16027e730a9b7b +pending=8b06b79885bccd2cd4de04f04de07085302d8903 for file in mod-control.jsonl mod-commands.json; do curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$pending/docs/hooks/fixtures/$file" \ -o "$here/fixtures/$file" From 36332e0092fd14e833c425db912efdeea0555fd6 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 4 Oct 2026 18:06:48 -0300 Subject: [PATCH 2/3] docs: say a fork sees the conversation as of the last finished reply Measured in lich's live smoke: a prompt whose first reply is still streaming is not in the fork either. --- docs/mod-control.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/mod-control.md b/docs/mod-control.md index 866256e..efa47f0 100644 --- a/docs/mod-control.md +++ b/docs/mod-control.md @@ -130,9 +130,9 @@ the trust prompt is answered, the module after. - **`/model` and `/effort` are refused by lich, not by the mod.** Run through a mod, Claude Code saves what they set as the default for every new session; the `model` and `effort` commands are the per-session route. -- **A fork sees the conversation as the session's last request sent it.** A - reply being written, or a tool call running, when the question lands is not - in it. Measured on 2.1.289. +- **A fork sees the conversation as of the session's last finished model + response.** A reply being written, a tool call running, or a prompt whose + first reply is still being written is not in it. Measured on 2.1.289. - **A fork cannot be cancelled, and survives an Esc on the turn.** It takes no signal; measured on 2.1.289, it answered in full after the turn it ran beside was interrupted. A module reload is what kills one, and then nothing is acked: From 1b50b0e7d52ec5799f7214d823a141ff57a811dd Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 4 Oct 2026 19:29:06 -0300 Subject: [PATCH 3/3] test: read the unreleased fixtures at the lich commits that are pushed --- tests/refresh-fixtures.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/refresh-fixtures.sh b/tests/refresh-fixtures.sh index a715d8c..2094ff6 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -28,7 +28,7 @@ done # module is held to the contract lich is about to ship rather than to the one # it replaces. Once a release ships it, move tests/lich-ref there, fold these # files back into the list above and delete this block. -pending=8b06b79885bccd2cd4de04f04de07085302d8903 +pending=f6db5f95675413ec7029658d04dbb96f0bb375c0 for file in mod-control.jsonl mod-commands.json; do curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$pending/docs/hooks/fixtures/$file" \ -o "$here/fixtures/$file"