diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfacb0..83b25b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **A Claude Code card says when its conversation is being compacted.** A new + mod, `hooks/mod-compacting.js`, reports the session-state contract's + `compacting` when `session.compact` starts and closes it when the compaction + settles, whether it stood or failed: `done` after a manual `/compact`, `busy` + after an automatic one, whose turn goes on. Until now a manual `/compact` left + the card on `done` and an automatic one on a bare spinner. A lich older than + the contract answers `compacting` with a 400 and still takes the closing state. + ## [0.18.4] - 2026-10-08 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 138bdc4..1fb6416 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,13 +17,14 @@ 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 six mods below +hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the 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 hooks/mod-status.js # Claude Code mod: the session's relay errands in the prompt footer +hooks/mod-compacting.js # Claude Code mod: the card says when the conversation is being compacted hooks/status-line.js # the lich line in the prompt footer, shared by the mods above opencode/lich.js # opencode client: a module, not a command omp/lich.js # omp client: a module, not a command @@ -56,7 +57,7 @@ registered for it. Contracts are **canonical in the lich repository** (`docs/hooks/` there); this plugin only implements the client side. Each hook has a doc in `docs/` here that points at its contract and describes the plugin-side behavior. Read it before creating or changing a hook: -- [docs/session-state.md](docs/session-state.md) — session-state reporting (`busy`/`done`) via `UserPromptSubmit`/`Stop`, and the tool a turn is running via `PreToolUse` +- [docs/session-state.md](docs/session-state.md) — session-state reporting (`busy`/`done`) via `UserPromptSubmit`/`Stop`, the tool a turn is running via `PreToolUse`, and `compacting` through a mod bracketing `session.compact` (Claude Code only) - [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) @@ -101,9 +102,11 @@ 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, [tests/worker-answer.test.mjs](tests/worker-answer.test.mjs) for a worker's -answer against the mod-answer fixtures, and +answer against the mod-answer fixtures, [tests/mod-status.test.mjs](tests/mod-status.test.mjs) for the footer line it -draws from the mod-status response. +draws from the mod-status response, and +[tests/mod-compacting.test.mjs](tests/mod-compacting.test.mjs) for the +`compacting` bracket against the session-state 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, and @@ -173,6 +176,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`, `hooks/mod-control.js`, `hooks/mod-usage.js`, `hooks/mod-status.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. +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`, `hooks/mod-status.js`, `hooks/mod-compacting.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/docs/providers.md b/docs/providers.md index 7ada90d..148c865 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -24,13 +24,14 @@ 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 five below | +| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the mods 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/mod-status.js` | Claude Code | the mod-status 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 | +| `hooks/mod-compacting.js` | Claude Code | the session-state `compacting` report, 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 | @@ -74,6 +75,7 @@ swallows its own errors and never awaits a report. | `busy` + tool | `PreToolUse` | `PreToolUse` | `PreToolUse` | `tool.execute.before` | `tool_call` | — | | `waiting` + reason | `Notification` | `PermissionRequest` | — (not measured) | any `*.asked` | — (see below) | — | | `done` | `Stop`, `StopFailure` | `Stop` | `Stop` | `session.status` (`idle`) | `session_stop` | — | +| `compacting` | `session.compact` (a mod) | — | — | — | — | — | | title | `Stop` | `Stop` | `Stop` | `session.updated` | `session_stop`, `turn_start` | — | | `idle` | `SessionEnd` | — (registered, never fires) | — (never fires) | — (nothing outlives it) | — (nothing outlives it) | — | | touched | `PostToolUse` (write tools) | `PostToolUse` (write tools) | `PostToolUse` (write tools) | `file.edited` | `tool_result` (write tools) | `PreToolUse` (write tools) | diff --git a/docs/session-state.md b/docs/session-state.md index 4181025..20ea612 100644 --- a/docs/session-state.md +++ b/docs/session-state.md @@ -16,6 +16,7 @@ toast) when the agent is blocked on the user. | `report-state.sh done` | `done` | `Stop` | `Stop` | `Stop` | `session.status` (`idle`) | `session_stop` | | `report-state.sh waiting` | `waiting`+`reason`| `Notification` | `PermissionRequest` | — (not measured) | any `*.asked` | — (not measured)| | `report-state.sh idle` | `idle` | `SessionEnd` | — (never fires) | — (never fires) | — (never fires) | — (never fires) | +| `mod-compacting.js` | `compacting` | `session.compact` (mod) | — | — | — | — | opencode and omp run no scripts: `opencode/lich.js` and `omp/lich.js` send the same payloads off the events their harness hands a loaded module. opencode's @@ -115,6 +116,46 @@ the turn without another tool call stays `waiting` until `Stop`. Codex recovers the same way: an approved request runs the tool it was asking about, and that tool's `PostToolUse` re-arms the spinner. +## Compacting + +`hooks/mod-compacting.js` reports `compacting` while Claude Code compacts the +conversation, so the card says so instead of sitting on `done` through a manual +`/compact` (no `UserPromptSubmit` fires for it) or on a bare spinner through an +automatic one. It is a mod, registered by `hooks/lich.js`, because it has to +see the compaction end whichever way it ends: `session.compact`'s `next(e)` +settles when it does, and a settings hook has nothing to match that with. +`PostCompact` never fires for a compaction that fails, and two do, measured on +Claude Code 2.1.295: + +| trigger | when | ends | closed with | +|----------|------------------------------------------|---------------------------------------------------------------------------|-------------| +| `manual` | `/compact`, at an idle prompt | resolves after the summary (~6s), or rejects on Esc ("Request was aborted") | `done` | +| `auto` | inside a turn, after `UserPromptSubmit`, before the model request | resolves after the summary (~14s), or rejects within ~40ms when the engine gives up ("reactive compaction did not settle ok") and the turn goes on | `busy` | + +`compacting` goes out before `next(e)` is called and the closing state once it +settles, either way; the error, if any, goes on to Claude Code untouched. An +automatic compaction closes with `busy` because its turn is still running; the +turn's own `Stop` reports `done`. Esc during an automatic compaction did not +abort it on the measured run: the summary finished and the turn ended normally. + +The rest follows the other mods: + +- **Outside lich, or in a `claude -p`, it never reports.** It reads the three + variables in a `session.start` hook matching `isInteractive: true`. +- **Only `manual` and `auto` are reported**, each by a hook matching its + `trigger`. `precompute` runs ahead of time, out of sight, and `plugin` (a + mod's own `$.session.compact`) was never observed. +- **A subagent's compaction is left alone.** It carries `agentId`; the main + conversation's does not. That field is read off Claude Code's type + declarations, not a payload: no subagent compaction was measured. +- **The compaction is never held up.** Reports go out beside the chain, one at + a time so the closing one never lands first, and one that fails is dropped. + +Known ceiling: an automatic compaction the engine gives up on still puts +`compacting` on the card for the ~40ms before `busy`. With the threshold forced +low (`CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=1`) that happened on most turns; how +often it happens at the default threshold was not measured. + ## The tool report `report-tool.sh` rides `PreToolUse` and reports the same `busy`, with the tool diff --git a/hooks/lich.js b/hooks/lich.js index 5f3247a..b808bc0 100644 --- a/hooks/lich.js +++ b/hooks/lich.js @@ -3,6 +3,7 @@ // from here. import { register as registerAgentCards } from "./agent-cards.js" +import { register as registerCompacting } from "./mod-compacting.js" import { register as registerEditGuard } from "./edit-guard.js" import { register as registerModControl } from "./mod-control.js" import { register as registerModStatus } from "./mod-status.js" @@ -18,5 +19,6 @@ export function register(on) { registerEditGuard(on) registerWorkerAnswer(on) registerModStatus(on) + registerCompacting(on) registerStatusLine(on) } diff --git a/hooks/mod-compacting.js b/hooks/mod-compacting.js new file mode 100644 index 0000000..4a52e7f --- /dev/null +++ b/hooks/mod-compacting.js @@ -0,0 +1,112 @@ +// Reports a Claude Code conversation being compacted to lich, as the +// session-state contract's `compacting`. Contract: ../docs/session-state.md, +// canonical in https://github.com/omartelo/lich/blob/main/docs/hooks/session-state.md +// +// A Claude Code mod, registered by hooks/lich.js. It brackets `session.compact`: +// `compacting` before the compaction runs, and the state the session goes back +// to once `next(e)` settles, whether the compaction stood or failed. A manual +// /compact runs at an idle prompt, so it closes with `done`; an automatic one +// runs inside a turn, ahead of the model request that follows, so it closes +// with `busy` and the turn's own Stop reports `done` later. +// +// A settings hook could not do this: `PostCompact` never fires for a +// compaction that fails, and two measured ones do (Claude Code 2.1.295). Esc +// on a manual /compact rejects `next(e)` with "Request was aborted", and an +// automatic one the engine gives up on rejects it within ~40ms with "reactive +// compaction did not settle ok" while the turn goes on. +// +// 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.18.4" + +/** + * @typedef {import('claude-code').EngineInterface} Engine + * @typedef {import('claude-code').SessionCompactInput} Compaction + * @typedef {import('claude-code').SessionCompactResult} Compacted + * @typedef {{ base: string, token: string, session: string }} Link + * @typedef {'done' | 'busy'} Closing + * @typedef {{ link?: Link, sending: Promise }} State + */ + +/** + * @param {Engine} $ + * @returns {Promise} + */ +async function linkFromEnv($) { + const [port, token, session] = await Promise.all([ + $.env.get("LICH_PORT"), + $.env.get("LICH_TOKEN"), + $.env.get("LICH_SESSION_ID"), + ]) + if (!port || !token || !session) return undefined + return { base: `http://127.0.0.1:${port}`, token, session } +} + +/** + * @param {Engine} $ + * @param {Link} link + * @param {'compacting' | Closing} sessionState + */ +async function report($, link, sessionState) { + try { + await $.http.fetch(`${link.base}/hook?token=${link.token}`, { + method: "POST", + headers: { "content-type": "application/json", "X-Lich-Plugin": PLUGIN_VERSION }, + body: JSON.stringify({ session_id: link.session, state: sessionState }), + }) + } catch { + // The contract drops a report that cannot be sent; it never retries one. + } +} + +/** + * Reports beside the chain, one at a time: lich keeps the latest state, so a + * closing report landing before its `compacting` would leave the card stuck. + * + * @param {Engine} $ + * @param {State} state + * @param {Link} link + * @param {'compacting' | Closing} sessionState + */ +function queue($, state, link, sessionState) { + state.sending = state.sending.then(() => report($, link, sessionState)) +} + +/** + * @param {Engine} $ + * @param {State} state + * @param {Compaction} e + * @param {(e: Compaction) => Promise} next + * @param {Closing} closing + */ +async function bracket($, state, e, next, closing) { + const link = state.link + // A subagent compacting its own transcript leaves the session's alone. + if (!link || e.agentId !== undefined) return next(e) + queue($, state, link, "compacting") + try { + return await next(e) + } finally { + queue($, state, link, closing) + } +} + +/** @param {import('claude-code').On} on */ +export function register(on) { + /** @type {State} */ + const state = { sending: Promise.resolve() } + + // Interactive sessions only: a `claude -p` started from a tool inside a lich + // session inherits its variables, and its compactions are not the card's. + on("session.start", { isInteractive: true }, async ($, e, next) => { + state.link = await linkFromEnv($) + return next(e) + }) + + // Only the two triggers measured. `precompute` runs ahead of time, out of + // sight, and a plugin's own `$.session.compact` was never observed. + on("session.compact", { trigger: "manual" }, ($, e, next) => bracket($, state, e, next, "done")) + on("session.compact", { trigger: "auto" }, ($, e, next) => bracket($, state, e, next, "busy")) +} diff --git a/tests/fixtures/session-state.jsonl b/tests/fixtures/session-state.jsonl index fa87adf..85e627d 100644 --- a/tests/fixtures/session-state.jsonl +++ b/tests/fixtures/session-state.jsonl @@ -2,6 +2,7 @@ {"name":"done","body":{"session_id":"s1","state":"done"},"accept":{"session_id":"s1","state":"done"}} {"name":"waiting","body":{"session_id":"s1","state":"waiting"},"accept":{"session_id":"s1","state":"waiting"}} {"name":"idle","body":{"session_id":"s1","state":"idle"},"accept":{"session_id":"s1","state":"idle"}} +{"name":"compacting","body":{"session_id":"s1","state":"compacting"},"accept":{"session_id":"s1","state":"compacting"}} {"name":"busy names the tool it is about to run","body":{"session_id":"s1","state":"busy","tool":"Bash","detail":"pnpm test"},"accept":{"session_id":"s1","state":"busy","tool":"Bash","detail":"pnpm test"}} {"name":"a tool with nothing to say about it","body":{"session_id":"s1","state":"busy","tool":"Read"},"accept":{"session_id":"s1","state":"busy","tool":"Read","detail":""}} {"name":"each harness names its own tools","body":{"session_id":"s1","state":"busy","tool":"apply_patch","detail":"internal/terminal/usage.go"},"accept":{"session_id":"s1","state":"busy","tool":"apply_patch","detail":"internal/terminal/usage.go"}} @@ -14,7 +15,7 @@ {"name":"a reason outside waiting names nothing","body":{"session_id":"s1","state":"busy","reason":"Claude needs your permission to use Bash"},"accept":{"session_id":"s1","state":"busy","reason":""}} {"name":"missing session_id","body":{"state":"busy"},"reject":"session_id is required"} {"name":"empty session_id","body":{"session_id":"","state":"busy"},"reject":"session_id is required"} -{"name":"unknown state","body":{"session_id":"s1","state":"cooking"},"reject":"state must be one of busy, done, waiting, idle"} -{"name":"missing state","body":{"session_id":"s1"},"reject":"state must be one of busy, done, waiting, idle"} -{"name":"state is case sensitive","body":{"session_id":"s1","state":"Busy"},"reject":"state must be one of busy, done, waiting, idle"} +{"name":"unknown state","body":{"session_id":"s1","state":"cooking"},"reject":"state must be one of busy, done, waiting, idle, compacting"} +{"name":"missing state","body":{"session_id":"s1"},"reject":"state must be one of busy, done, waiting, idle, compacting"} +{"name":"state is case sensitive","body":{"session_id":"s1","state":"Busy"},"reject":"state must be one of busy, done, waiting, idle, compacting"} {"name":"malformed json","raw":"{","reject":"body must be a JSON object"} diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index 5859a55..b4c96c2 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -382,7 +382,7 @@ test('the vendored fixtures parse and carry exactly one body and one verdict', ( ) } } - assert.deepEqual([...STATES].sort(), ['busy', 'done', 'idle', 'waiting']) + assert.deepEqual([...STATES].sort(), ['busy', 'compacting', 'done', 'idle', 'waiting']) assert.deepEqual( [...PROVIDERS].sort(), ['antigravity', 'claude', 'codex', 'crush', 'cursor', 'kiro', 'omp', 'opencode'], @@ -497,12 +497,15 @@ test('every registered state argument is an accepted state', () => { const sent = REGISTRATIONS.filter((r) => r.script === 'report-state.sh').map((r) => r.argument) assert.ok(sent.length > 0) for (const state of sent) assert.ok(STATES.has(state), `registration reports unknown state "${state}"`) - // A harness that reports state at all wires up the whole vocabulary; Codex's - // SessionEnd never fires today, but the entry is deliberate. Antigravity reports - // busy and done. Crush reports no state at all — see docs/providers.md for all three. + // A harness that reports state at all wires up the whole vocabulary but + // `compacting`, which only Claude Code reports, from a mod + // (mod-compacting.test.mjs); Codex's SessionEnd never fires today, but the + // entry is deliberate. Antigravity reports busy and done. Crush reports no + // state at all — see docs/providers.md for all three. + const SCRIPTED_STATES = new Set([...STATES].filter((state) => state !== 'compacting')) const EXPECTED_STATES = { - claude: STATES, - codex: STATES, + claude: SCRIPTED_STATES, + codex: SCRIPTED_STATES, antigravity: new Set(['busy', 'done']), } for (const [provider, scripts] of Object.entries(REGISTERED_SCRIPTS)) { @@ -514,7 +517,7 @@ test('every registered state argument is an accepted state', () => { ) assert.deepEqual( [...states].sort(), - [...(EXPECTED_STATES[provider] ?? STATES)].sort(), + [...(EXPECTED_STATES[provider] ?? SCRIPTED_STATES)].sort(), `${provider} misses a state`, ) } diff --git a/tests/mod-compacting.test.mjs b/tests/mod-compacting.test.mjs new file mode 100644 index 0000000..f537abc --- /dev/null +++ b/tests/mod-compacting.test.mjs @@ -0,0 +1,236 @@ +// Pins the Claude Code mod's compaction reports (hooks/mod-compacting.js) to +// lich's session-state 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 (session-state.jsonl). The +// `session.compact` payloads and the errors `next(e)` rejects with below are +// what Claude Code 2.1.295 delivered against a stub lich. +// +// 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/mod-compacting.js' +import { register as registerEntry } from '../hooks/lich.js' + +const PORT = 47997 + +const TRANSCRIPT = [ + { role: 'user', text: 'reply with the single word hi', toolUses: [], handle: '9a8f2342-326c-4003-b373-f52b8b4c14e9' }, + { role: 'assistant', text: 'hi', toolUses: [], handle: 'c9a8f906-975e-49de-9b29-359fa0a7e5a4' }, +] +const MANUAL = { trigger: 'manual', messages: TRANSCRIPT } +const AUTO = { trigger: 'auto', messages: TRANSCRIPT } +const COMPACTED = { + messages: [{ role: 'user', text: 'This session is being continued from a previous conversation that ran out of context.' }], +} + +const NO_CONTENT = { status: 204, ok: true, headers: {}, text: '' } + +/** Waits for `predicate` to hold, failing after a second of real time. */ +async function until(predicate, what) { + const deadline = Date.now() + 1000 + while (!predicate()) { + if (Date.now() > deadline) assert.fail(`timed out waiting for ${what}`) + await new Promise((r) => setImmediate(r)) + } +} + +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, and + * an event no hook matches goes straight to `compact`, Claude Code's own + * compaction. `answer` answers each report. + */ +function load({ env = lichEnv(PORT), answer = () => Promise.resolve(NO_CONTENT) } = {}) { + const hooks = [] + register((event, ...rest) => { + const hook = rest.pop() + hooks.push({ 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])) + const request = { method: init.method ?? 'GET', url: pathname + search, headers, raw: init.body ?? '' } + requests.push(request) + return answer(request) + }, + }, + } + const fire = (event, e, last) => { + const found = hooks.find((h) => h.event === event && Object.entries(h.matcher).every(([key, value]) => e[key] === value)) + return found ? found.hook($, e, last) : last(e) + } + return { + requests, + states: () => requests.map((r) => JSON.parse(r.raw).state), + start: (isInteractive = true) => fire('session.start', { cwd: '/w', surface: 'terminal', isInteractive }, async (e) => e), + compact: (e, compact = async () => COMPACTED) => fire('session.compact', e, compact), + } +} + +test('a manual /compact is bracketed by compacting and done, as the contract spells them', async () => { + const mod = load() + await mod.start() + + const result = await mod.compact(MANUAL) + await until(() => mod.requests.length === 2, 'both reports') + + assert.equal(result, COMPACTED) + for (const request of mod.requests) assertContractHonoured('/hook', request) + assert.deepEqual( + mod.requests.map((r) => JSON.parse(r.raw)), + [ + { session_id: LICH_SESSION_ID, state: 'compacting' }, + { session_id: LICH_SESSION_ID, state: 'done' }, + ], + ) +}) + +// An automatic compaction runs inside a turn, before the model request that +// follows it; the turn's own Stop reports done. +test('an automatic compaction hands the card back to the turn', async () => { + const mod = load() + await mod.start() + + await mod.compact(AUTO) + await until(() => mod.requests.length === 2, 'both reports') + + assert.deepEqual(mod.states(), ['compacting', 'busy']) +}) + +test('compacting goes out before the compaction runs', async () => { + const mod = load() + await mod.start() + + let reportedBefore + await mod.compact(MANUAL, async () => { + await until(() => mod.requests.length === 1, 'the compacting report') + reportedBefore = mod.states() + return COMPACTED + }) + + assert.deepEqual(reportedBefore, ['compacting']) +}) + +// Esc on a manual /compact rejects next(e); an automatic compaction the engine +// gives up on rejects it too, and its turn goes on. Neither fires PostCompact. +for (const [e, error, closing] of [ + [MANUAL, 'HooksError: API Error: Request was aborted.', 'done'], + [AUTO, 'HooksError: reactive compaction did not settle ok', 'busy'], +]) { + test(`${e.trigger} compaction that fails still closes with ${closing}, and its error goes on`, async () => { + const mod = load() + await mod.start() + + await assert.rejects( + mod.compact(e, async () => { + throw new Error(error) + }), + { message: error }, + ) + await until(() => mod.requests.length === 2, 'both reports') + + assert.deepEqual(mod.states(), ['compacting', closing]) + }) +} + +test("a subagent compacting its own transcript leaves the card alone", async () => { + const mod = load() + await mod.start() + + await mod.compact({ ...AUTO, agentId: 'a1b2c3' }) + await settle() + + assert.equal(mod.requests.length, 0) +}) + +// Only manual and auto were measured; precompute runs ahead of time, out of +// sight, and a plugin's own compaction was never observed. +for (const trigger of ['precompute', 'plugin']) { + test(`a ${trigger} compaction is not reported`, async () => { + const mod = load() + await mod.start() + + const result = await mod.compact({ trigger, messages: TRANSCRIPT }) + await settle() + + assert.equal(result, COMPACTED) + assert.equal(mod.requests.length, 0) + }) +} + +test('the compaction is never held up by lich', async () => { + const mod = load({ answer: () => new Promise(() => {}) }) + await mod.start() + + const result = await mod.compact(MANUAL) + + assert.equal(result, COMPACTED) +}) + +test('the closing report waits for compacting to be answered', async () => { + let release + const first = new Promise((r) => { + release = () => r(NO_CONTENT) + }) + const mod = load({ answer: () => (mod.requests.length === 1 ? first : Promise.resolve(NO_CONTENT)) }) + await mod.start() + + await mod.compact(MANUAL) + await settle() + assert.deepEqual(mod.states(), ['compacting'], 'done went out before compacting was answered') + release() + await until(() => mod.requests.length === 2, 'the closing report') + + assert.deepEqual(mod.states(), ['compacting', 'done']) +}) + +test('a report that fails is dropped and the closing one still goes out', async () => { + const mod = load({ + answer: () => + mod.requests.length === 1 ? Promise.reject(new Error('connect ECONNREFUSED')) : Promise.resolve(NO_CONTENT), + }) + await mod.start() + + await mod.compact(MANUAL) + await until(() => mod.requests.length === 2, 'the closing report') + await settle() + + assert.deepEqual(mod.states(), ['compacting', 'done']) +}) + +test('outside lich nothing is sent', async () => { + const mod = load({ env: {} }) + await mod.start() + + await mod.compact(MANUAL) + await settle() + + assert.equal(mod.requests.length, 0) +}) + +test('a non-interactive run never reports onto the card that started it', async () => { + const mod = load() + await mod.start(false) + + await mod.compact(AUTO) + await settle() + + assert.equal(mod.requests.length, 0) +}) + +test('the plugin entry module registers the compaction reports', () => { + const events = [] + registerEntry((event) => events.push(event)) + assert.ok(events.includes('session.compact'), 'mod-compacting is not registered') +}) diff --git a/tests/refresh-fixtures.sh b/tests/refresh-fixtures.sh index a7e7a95..5346198 100755 --- a/tests/refresh-fixtures.sh +++ b/tests/refresh-fixtures.sh @@ -19,13 +19,21 @@ ref=$(tr -d '[:space:]' <"$here/lich-ref") raw=https://raw.githubusercontent.com/omartelo/lich/$ref base=$raw/docs/hooks/fixtures -for file in session-state.jsonl session-start.jsonl session-title.jsonl \ +for file in session-start.jsonl session-title.jsonl \ session-touched.jsonl mod-usage.jsonl mod-control.jsonl mod-commands.json \ mod-answer.jsonl mod-status.json; do curl -fsSL "$base/$file" -o "$here/fixtures/$file" echo "refreshed $file at $ref" done +# session-state gained `compacting` in omartelo/lich#687, merged to main but in +# no release yet, so it is read at the merge commit. Once a release ships it, +# move tests/lich-ref to that release and put the file back in the loop above. +unreleased=f44818488bd2bb18023bec69f2d9476e31434256 +curl -fsSL "https://raw.githubusercontent.com/omartelo/lich/$unreleased/docs/hooks/fixtures/session-state.jsonl" \ + -o "$here/fixtures/session-state.jsonl" +echo "refreshed session-state.jsonl at $unreleased (unreleased)" + # validate.mjs reads its token sets off this copy, so a token lich adds reaches # the validator by moving tests/lich-ref, not by hand. curl -fsSL "$raw/themes/template.json" -o "$here/../skills/theme/template.json"