Repository navigation
Expand file tree
/
Copy pathhooks.test.mjs
More file actions
1268 lines (1175 loc) · 51.2 KB
/
Copy pathhooks.test.mjs
File metadata and controls
1268 lines (1175 loc) · 51.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Pins this plugin's hook scripts to lich's contract fixtures.
//
// Every case here drives a real script as a subprocess — the command line taken
// from the hook registration a harness actually reads — against a stub HTTP
// server, and asserts the body it POSTs. The fixtures, the assertions and the
// stub live in contract.mjs, shared with the opencode client's suite.
//
// Run: node --test tests/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { spawn, spawnSync } from 'node:child_process'
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, symlinkSync, copyFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import {
CASES,
LICH_SESSION_ID,
PROVIDERS,
PENDING_UPSTREAM,
ROOT,
REJECT_RULES,
STATES,
TOKEN,
assertContractHonoured,
lichEnv,
rejected,
settleStubs,
startStub,
withStub,
} from './contract.mjs'
/** The ambient environment minus anything lich would inject. */
const BASE_ENV = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith('LICH_')),
)
/**
* Runs a hook exactly as its registration spells it: the command string from
* the harness's own hook file, through a shell.
*
* `pluginRoot` is what the harness gives the command to find the scripts with.
* Claude Code, Codex and Crush get $CLAUDE_PLUGIN_ROOT. Antigravity expands no
* such variable — it runs the command with the working directory set to the
* folder holding hooks.json — so it is run here with the variable unset, which
* is the regression test for a registration that reaches for it anyway: the
* command resolves to /hooks/<script> and exits 127.
*
* `tmpdir` is a scratch directory of the run's own, because a hook keeps state
* there: report-title.sh latches under $TMPDIR so it stops scanning once it has
* reported. A fresh one per call is what keeps these tests independent of each
* other and out of the developer's /tmp — the latch is keyed by session id, and
* every test here uses the same one. A test that means to exercise what carries
* across calls passes the same directory to both.
*/
function runHook(command, { env = {}, stdin = '', pluginRoot = true, tmpdir = null } = {}) {
return new Promise((resolve) => {
const child = spawn('/bin/sh', ['-c', command], {
cwd: ROOT,
env: {
...BASE_ENV,
TMPDIR: tmpdir ?? mkdtempSync(path.join(TMP, 'run-')),
...(pluginRoot ? { CLAUDE_PLUGIN_ROOT: SPACED_ROOT } : {}),
...env,
},
stdio: ['pipe', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (c) => {
stdout += c
})
child.stderr.on('data', (c) => {
stderr += c
})
child.stdin.on('error', () => {}) // a script that reads no stdin is fine
child.stdin.end(stdin)
child.on('close', (code) =>
settleStubs().then(() => resolve({ code, stdout, stderr })),
)
})
}
// A hook script never speaks on stdout: on Codex the session-state hook rides
// PermissionRequest, where output and a non-zero exit are an answer to the
// request rather than an observation.
//
// Antigravity is the harness that needs one anyway — it reads a JSON object off
// stdout and acts on it, and a PreToolUse handler that says nothing leaves the
// tool call without the `decision` its contract makes required. So the verdict
// is appended by the registration, not printed by the shared script, and what
// arrives on stdout is exactly that verdict and nothing the script added.
function assertHookSucceeded(result, stdout = '') {
assert.equal(result.code, 0, `exited ${result.code}: ${result.stderr}`)
assert.equal(result.stdout, stdout, `stdout was ${JSON.stringify(result.stdout)}`)
}
// ------------------------------------------------------------ registrations --
const TMP = mkdtempSync(path.join(tmpdir(), 'lich-plugin-test-'))
// The plugin root a harness hands the hooks, with a space in it: on macOS it
// sits under ~/Library/Application Support, and an unquoted expansion splits
// there and every hook runs a path that does not exist (issue #90).
const SPACED_ROOT = path.join(TMP, 'Application Support')
symlinkSync(ROOT, SPACED_ROOT)
const HOOK_FILES = [
{ provider: 'claude', file: 'hooks/hooks.json' },
{ provider: 'codex', file: 'hooks/codex-hooks.json' },
{ provider: 'crush', file: 'hooks/crush-hooks.json' },
// Antigravity fixes both the name and the place: a plugin's hooks are read
// from `hooks.json` beside its `plugin.json`, so this one alone sits at the
// plugin root rather than in hooks/ (docs/providers.md).
{ provider: 'antigravity', file: 'hooks.json', pluginRoot: false },
]
// Crush has no plugin root to expand, so its registration ships the clone's
// path as a placeholder the user replaces. Here it becomes this checkout, under
// the same spaced path the other harnesses get.
const PLUGIN_ROOT_PLACEHOLDER = '<lich-plugin>'
const SCRIPTS = {
'report-state.sh': '/hook',
'report-tool.sh': '/hook',
'report-session-start.sh': '/session-start',
'report-title.sh': '/session-title',
'report-touched.sh': '/session-touched',
}
/**
* What each harness registers. Claude Code, Codex and Antigravity carry every
* report; Crush carries the two its single `PreToolUse` event can honour,
* because a `busy` nothing can end would pin a spinner to a card
* (docs/providers.md).
*/
const REGISTERED_SCRIPTS = {
claude: Object.keys(SCRIPTS),
codex: Object.keys(SCRIPTS),
crush: ['report-session-start.sh', 'report-touched.sh'],
antigravity: Object.keys(SCRIPTS),
}
/**
* A registration group is either Claude Code's shape (a matcher wrapping a
* `hooks` array) or Crush's (the hook itself, matcher included). Antigravity
* uses both, one per event: `PreToolUse`/`PostToolUse` are grouped behind a
* matcher, `PreInvocation`/`Stop` are flat lists of handlers.
*/
const hooksIn = (group) => (Array.isArray(group.hooks) ? group.hooks : [group])
/**
* Every hook file maps events to handlers; only the key it hangs that map on
* differs. Claude Code, Codex and Crush use `hooks`; an Antigravity file is
* keyed by hook *name* instead, so several integrations can merge into one
* file — lich's is `lich`.
*/
const eventsIn = (json) => json.hooks ?? json.lich
/**
* Antigravity reads a JSON object off the hook's stdout and acts on it, so its
* registration appends the verdict the event's contract asks for. Split back
* off here, so every assertion below stays about the report the script sent.
*/
function splitVerdict(command) {
const m = command.match(/;\s*printf\s+'(.*)'\s*$/)
return m
? { report: command.slice(0, m.index), stdout: m[1] }
: { report: command, stdout: '' }
}
function registrations() {
const out = []
for (const { provider, file, pluginRoot = true } of HOOK_FILES) {
const json = JSON.parse(readFileSync(path.join(ROOT, file), 'utf8'))
const events = eventsIn(json)
assert.ok(events, `${file} hangs its events on no key this suite knows`)
for (const [event, groups] of Object.entries(events)) {
for (const group of groups) {
for (const hook of hooksIn(group)) {
const script = Object.keys(SCRIPTS).find((s) => hook.command.includes(s))
assert.ok(script, `unknown script in ${file}: ${hook.command}`)
const command = hook.command.split(PLUGIN_ROOT_PLACEHOLDER).join(SPACED_ROOT)
const { report, stdout } = splitVerdict(hook.command)
out.push({
provider,
file,
event,
matcher: group.matcher,
command,
pluginRoot,
stdout,
script,
endpoint: SCRIPTS[script],
argument: report.trim().split(/\s+/).slice(1).join(' '),
})
}
}
}
}
return out
}
const REGISTRATIONS = registrations()
// ------------------------------------------------------------- transcripts --
/** Claude Code keeps its generated title in the transcript as an ai-title line. */
function claudeTranscript(title, name = 'claude.jsonl') {
const file = path.join(TMP, name)
const lines = [
JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } }),
...(title === null ? [] : [JSON.stringify({ type: 'ai-title', aiTitle: title })]),
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: 'ok' } }),
]
writeFileSync(file, lines.join('\n') + '\n')
return file
}
/** Codex generates no title: its rollout carries the first user message. */
function codexTranscript(message, name = 'codex.jsonl') {
const file = path.join(TMP, name)
const lines = [
JSON.stringify({ timestamp: '2026-08-10T12:00:00.000Z', type: 'session_meta', payload: {} }),
...(message === null
? []
: [
JSON.stringify({
timestamp: '2026-08-10T12:00:01.000Z',
type: 'event_msg',
payload: { type: 'user_message', message, kind: 'plain' },
}),
]),
]
writeFileSync(file, lines.join('\n') + '\n')
return file
}
/**
* Codex 0.147.0 and later: a paginated rollout writes no user_message event,
* only the message as a completed UserMessage item (measured on 0.162.1).
*/
function codexPaginatedTranscript(message, name = 'codex-paginated.jsonl') {
const file = path.join(TMP, name)
const lines = [
JSON.stringify({ timestamp: '2026-10-21T12:00:00.000Z', type: 'session_meta', payload: { cli_version: '0.162.1' } }),
...(message === null
? []
: [
JSON.stringify({
timestamp: '2026-10-21T12:00:01.000Z',
type: 'event_msg',
payload: { type: 'item_started', item: { type: 'UserMessage', content: [{ type: 'text', text: message }] } },
}),
JSON.stringify({
timestamp: '2026-10-21T12:00:01.000Z',
type: 'event_msg',
payload: { type: 'item_completed', item: { type: 'UserMessage', content: [{ type: 'text', text: message }] } },
}),
]),
]
writeFileSync(file, lines.join('\n') + '\n')
return file
}
/** Antigravity names a thread after its first USER_INPUT content. */
function antigravityTranscript(content, name = 'antigravity.jsonl') {
const file = path.join(TMP, name)
const lines = [
JSON.stringify({
step_index: 0,
source: 'USER_EXPLICIT',
type: 'USER_INPUT',
status: 'DONE',
created_at: '2026-08-23T22:05:42Z',
content: content === null ? '' : `<USER_REQUEST>\n${content}\n</USER_REQUEST>`,
}),
]
writeFileSync(file, lines.join('\n') + '\n')
return file
}
/**
* A PATH holding every tool the hooks use except the named one — for the
* fallbacks a script keeps for machines where a tool is absent.
*/
function binWithout(missing) {
const dir = path.join(TMP, `bin-without-${missing}`)
mkdirSync(dir, { recursive: true })
for (const tool of ['sh', 'sed', 'grep', 'cut', 'head', 'tail', 'cat', 'sleep', 'curl', 'jq']) {
if (tool === missing) continue
const found = spawnSync('sh', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).stdout.trim()
if (!found) continue
try {
symlinkSync(found, path.join(dir, tool))
} catch (e) {
if (e.code !== 'EEXIST') throw e
}
}
return dir
}
const PROVIDER_SESSION_ID = {
claude: '9f1c7c1e-8f2a-4c3b-9d5e-2a7b6c4d1e05',
codex: '018f9c5a-0000-7000-9a3b-1c2d3e4f5c01',
crush: 'db3492a0-4a6b-4335-8c0b-d4cc43fc4cb0',
opencode: 'ses_011856954ffe5NEMhsYeTuWykR',
antigravity: 'a1604c72-3091-449e-a60b-27da041f8ca2',
}
// Crush has no transcript to point a hook at: its payload is about the tool.
const TRANSCRIPT = {
claude: claudeTranscript('Fix the replay ring overflow'),
codex: codexTranscript('Fix the replay ring overflow\nand its second line'),
antigravity: antigravityTranscript('Fix the replay ring overflow\nand its second line'),
}
/**
* The tool call a PreToolUse payload describes. Both harnesses spell the
* envelope the same way (`tool_name` / `tool_input`), and Codex reports a shell
* call as `Bash` with a plain-string `command` exactly as Claude Code does —
* verified against a real `codex exec` run, not assumed. Its one own word is
* `apply_patch` (see the details table below).
*/
const TOOL_CALL = {
claude: { tool_name: 'Bash', tool_input: { command: 'pnpm test', description: 'run the suite' } },
codex: { tool_name: 'Bash', tool_input: { command: 'go test ./...' } },
crush: { tool_name: 'write', tool_input: { file_path: 'probe.txt', content: 'ok\n' } },
antigravity: { toolCall: { name: 'run_command', args: { CommandLine: 'pnpm test' } } },
}
/** The event each harness raises a `waiting` report from. */
const WAITING_EVENT = { claude: 'Notification', codex: 'PermissionRequest' }
/**
* The payload behind a `waiting` report, per harness. Claude Code's
* `Notification` is the only one carrying a sentence written for a human, and
* it fires for the plain idle nudge with the same fields; Codex's
* `PermissionRequest` has no message at all — its required fields (measured
* against the schema in the codex 0.149.0 binary) are the PreToolUse envelope
* plus `model`, `permission_mode` and `turn_id`.
*/
const WAITING = {
claude: {
message: 'Claude needs your permission to use Bash',
title: 'Claude Code',
notification_type: 'permission_prompt',
},
codex: {
...TOOL_CALL.codex,
model: 'gpt-5.1-codex',
permission_mode: 'default',
turn_id: 'turn_01',
},
}
/** The reason each of those payloads must put on the card. */
const WAITING_REASON = {
claude: WAITING.claude.message,
codex: 'Bash: go test ./...',
}
/**
* What the harness puts on stdin for the event this registration rides. Crush's
* envelope is Claude Code's with two differences, both taken off a real run: it
* names the event `event`, and it carries no transcript.
*/
function stdinFor({ provider, event }) {
if (provider === 'crush') {
return JSON.stringify({
event,
session_id: PROVIDER_SESSION_ID.crush,
cwd: ROOT,
...(event === 'PreToolUse' ? TOOL_CALL.crush : {}),
})
}
if (provider === 'antigravity') {
return JSON.stringify({
conversationId: PROVIDER_SESSION_ID.antigravity,
transcriptPath: TRANSCRIPT.antigravity,
workspacePaths: [ROOT],
...(event === 'PreToolUse' ? TOOL_CALL.antigravity : {}),
})
}
return JSON.stringify({
session_id: PROVIDER_SESSION_ID[provider],
transcript_path: TRANSCRIPT[provider],
cwd: ROOT,
hook_event_name: event,
...(event === 'PreToolUse' ? TOOL_CALL[provider] : {}),
...(WAITING_EVENT[provider] === event ? WAITING[provider] : {}),
})
}
// ----------------------------------------------------------------- the suite --
test('the vendored fixtures parse and carry exactly one body and one verdict', () => {
for (const [endpoint, cases] of Object.entries(CASES)) {
assert.ok(cases.length > 0, `${endpoint} has no cases`)
const names = new Set()
for (const c of cases) {
assert.ok(typeof c.name === 'string' && c.name !== '', `case without a name in ${endpoint}`)
assert.ok(!names.has(c.name), `duplicate case name "${c.name}" in ${endpoint}`)
names.add(c.name)
assert.equal(
('body' in c ? 1 : 0) + ('raw' in c ? 1 : 0),
1,
`case "${c.name}" must carry exactly one of body/raw`,
)
assert.equal(
('accept' in c ? 1 : 0) + ('reject' in c ? 1 : 0),
1,
`case "${c.name}" must carry exactly one of accept/reject`,
)
}
}
assert.deepEqual([...STATES].sort(), ['busy', 'compacting', 'done', 'idle', 'waiting'])
assert.deepEqual(
[...PROVIDERS].sort(),
['antigravity', 'claude', 'codex', 'crush', 'cursor', 'kiro', 'omp', 'opencode'],
)
})
test('every rejected case is modelled by a client-side rule', () => {
for (const endpoint of Object.keys(CASES)) {
for (const c of rejected(endpoint)) {
assert.ok(
c.name in REJECT_RULES[endpoint],
`${endpoint} fixture rejects "${c.name}" (${c.reject}) with no rule in REJECT_RULES — ` +
`the contract moved upstream; model it here before trusting the suite`,
)
}
}
})
test('each harness registers exactly the scripts it can honour', () => {
for (const [provider, expected] of Object.entries(REGISTERED_SCRIPTS)) {
const scripts = new Set(REGISTRATIONS.filter((r) => r.provider === provider).map((r) => r.script))
assert.deepEqual([...scripts].sort(), [...expected].sort(), `${provider} registers the wrong set`)
}
})
test("Crush's registration keeps the placeholder for the clone's path", () => {
const raw = readFileSync(path.join(ROOT, 'hooks/crush-hooks.json'), 'utf8')
assert.ok(
raw.includes(PLUGIN_ROOT_PLACEHOLDER),
'crush-hooks.json must ship the placeholder, not a path from somebody\'s machine',
)
})
// Codex substitutes `${KEY}` and nothing else, then hands the line to a shell:
// `$SHELL -lc` on Unix, `cmd.exe /C` on Windows. A bare `$CLAUDE_PLUGIN_ROOT`
// therefore only works by accident — the Unix shell expands what Codex left
// alone, and cmd.exe does not, so every hook ran a path that does not exist.
test("Codex's registration spells the plugin root the way Codex expands it", () => {
const raw = readFileSync(path.join(ROOT, 'hooks/codex-hooks.json'), 'utf8')
assert.ok(
!/\$CLAUDE_PLUGIN_ROOT/.test(raw),
'codex-hooks.json must use ${CLAUDE_PLUGIN_ROOT}: Codex only substitutes the braced form',
)
})
// cmd.exe cannot execute a .sh, so the Windows line hands the same script and
// the same argument to win-run.cmd, which finds a POSIX shell for it.
test("Codex's registration runs every hook through the wrapper on Windows", () => {
const json = JSON.parse(readFileSync(path.join(ROOT, 'hooks/codex-hooks.json'), 'utf8'))
const hooks = Object.values(json.hooks).flatMap((groups) => groups.flatMap((g) => g.hooks))
assert.ok(hooks.length > 0)
for (const hook of hooks) {
assert.ok(hook.commandWindows, `no commandWindows for ${hook.command}`)
assert.equal(
hook.commandWindows,
`"\${CLAUDE_PLUGIN_ROOT}/hooks/win-run.cmd" ${hook.command}`,
`commandWindows must wrap the command unchanged: ${hook.command}`,
)
}
})
// Antigravity expands no plugin-root variable of any kind. It runs the command
// through `sh -c` with the working directory set to the folder holding
// hooks.json — which, for a plugin, is the plugin root — so the paths are
// relative to that and nothing else. A ${CLAUDE_PLUGIN_ROOT} borrowed from the
// Claude Code registration expands to nothing there and every hook exits 127
// on a path that starts at the filesystem root.
test("Antigravity's registration reaches for no plugin root", () => {
const raw = readFileSync(path.join(ROOT, 'hooks.json'), 'utf8')
assert.ok(
!/PLUGIN_ROOT/.test(raw),
'hooks.json must not name a plugin-root variable: Antigravity sets none',
)
for (const r of REGISTRATIONS.filter((r) => r.provider === 'antigravity')) {
assert.match(
r.command,
/^hooks\/[a-z-]+\.sh\b/,
`command must be relative to the plugin root: ${r.command}`,
)
}
})
// Antigravity reads a JSON object off the hook's stdout and acts on it, so
// silence is not the neutral answer it is everywhere else: `decision` is
// required on PreToolUse, and an unanswered tool call is a turn this plugin
// interfered with. The shared scripts stay silent — Codex reads their stdout as
// an answer to a permission request — so the verdict belongs to the
// registration, and every handler carries one.
const ANTIGRAVITY_VERDICT = {
// Let the tool run: this is an observation, never a gate.
PreToolUse: '{"decision":"allow"}',
// Anything other than "continue" lets the loop end, and a card must be able
// to reach `done`.
Stop: '{"decision":""}',
PostToolUse: '{}',
PreInvocation: '{}',
}
test("Antigravity's registration answers every event with its verdict", () => {
const registered = REGISTRATIONS.filter((r) => r.provider === 'antigravity')
assert.ok(registered.length > 0)
for (const r of registered) {
assert.equal(
r.stdout,
ANTIGRAVITY_VERDICT[r.event],
`${r.event} hook answers ${JSON.stringify(r.stdout)}`,
)
}
})
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 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: SCRIPTED_STATES,
codex: SCRIPTED_STATES,
antigravity: new Set(['busy', 'done']),
}
for (const [provider, scripts] of Object.entries(REGISTERED_SCRIPTS)) {
if (!scripts.includes('report-state.sh')) continue
const states = new Set(
REGISTRATIONS.filter((r) => r.provider === provider && r.script === 'report-state.sh').map(
(r) => r.argument,
),
)
assert.deepEqual(
[...states].sort(),
[...(EXPECTED_STATES[provider] ?? SCRIPTED_STATES)].sort(),
`${provider} misses a state`,
)
}
})
// Claude Code fires StopFailure *instead of* Stop when an API error ends the
// turn (a usage limit, an overload, a failed login), so a registration with
// Stop alone leaves the card spinning, and lich never gets to read the limit
// off the transcript (lich docs/hooks/session-state.md).
test('claude ends a turn an API error stopped like any other', () => {
const failure = REGISTRATIONS.filter(
(r) => r.provider === 'claude' && r.event === 'StopFailure' && r.script === 'report-state.sh',
)
assert.deepEqual(
failure.map((r) => [r.argument, r.matcher]),
[['done', undefined]],
'StopFailure reports done for every error',
)
})
test('every registered provider argument is a registered provider', () => {
for (const r of REGISTRATIONS.filter((r) => r.script === 'report-session-start.sh')) {
assert.ok(
PROVIDERS.has(r.argument) || PENDING_UPSTREAM.has(r.argument),
`registration reports unknown provider "${r.argument}"`,
)
assert.equal(r.argument, r.provider, `${r.file} reports the wrong provider`)
}
})
test('the pending set is still pending upstream', () => {
for (const provider of PENDING_UPSTREAM) {
assert.ok(
!PROVIDERS.has(provider),
`lich registers "${provider}" now — drop it from PENDING_UPSTREAM and let the ` +
`fixtures enumerate it like every other provider`,
)
}
})
// 1-4: shape, endpoint, token, no deprecated alias — for every script, as each
// harness registers it.
for (const registration of REGISTRATIONS) {
const { provider, event, script, endpoint, argument, matcher } = registration
const label = [provider, event, script, argument, matcher && `matcher ${matcher}`]
.filter(Boolean)
.join(' ')
test(`${label} posts an accepted payload`, async () => {
await withStub(async (stub) => {
const result = await runHook(registration.command, {
env: lichEnv(stub.port),
stdin: stdinFor(registration),
})
assertHookSucceeded(result, registration.stdout ?? '')
assert.equal(stub.requests.length, 1, `sent ${stub.requests.length} requests, expected 1`)
const { body } = assertContractHonoured(endpoint, stub.requests[0])
assert.equal(body.session_id, LICH_SESSION_ID)
if (script === 'report-state.sh') {
assert.equal(body.state, argument)
// Only `waiting` carries a reason — lich drops it on every other state,
// so sending one there would be noise the contract throws away.
if (argument === 'waiting') assert.equal(body.reason, WAITING_REASON[provider])
else assert.ok(!('reason' in body), `${argument} sent a reason: ${stub.requests[0].raw}`)
}
if (script === 'report-state.sh' || script === 'report-tool.sh') {
// Names the conversation that fired the hook, so lich can drop a report
// from an agent CLI nested in the session.
assert.equal(body.provider_session_id, PROVIDER_SESSION_ID[provider])
}
if (script === 'report-tool.sh') {
assert.equal(body.state, 'busy')
assert.equal(body.tool, TOOL_CALL[provider].tool_name ?? TOOL_CALL[provider].toolCall?.name)
}
if (script === 'report-session-start.sh') {
assert.equal(body.provider, provider)
assert.equal(body.provider_session_id, PROVIDER_SESSION_ID[provider])
}
if (script === 'report-title.sh') assert.equal(body.title, 'Fix the replay ring overflow')
})
})
}
test('session-start falls back to claude when the registration passes no provider', async () => {
await withStub(async (stub) => {
const result = await runHook(`"${ROOT}/hooks/report-session-start.sh"`, {
env: lichEnv(stub.port),
stdin: stdinFor({ provider: 'claude', event: 'SessionStart' }),
})
assertHookSucceeded(result)
const { body } = assertContractHonoured('/session-start', stub.requests[0])
assert.equal(body.provider, 'claude')
})
})
// The reason is what a bell on a card says the session is blocked on. It is
// optional in the contract, and the bell has to land either way — so every
// shape that cannot produce one still sends the plain `waiting`.
const NO_REASON = [
['a payload with neither a message nor a tool', { hook_event_name: 'Notification' }],
['a message that is whitespace', { message: ' ' }],
['a message that is not a string', { message: { text: 'permission' } }],
]
for (const [label, payload] of NO_REASON) {
test(`waiting still rings the bell for ${label}`, async () => {
await withStub(async (stub) => {
const result = await runHook(`"${ROOT}/hooks/report-state.sh" waiting`, {
env: lichEnv(stub.port),
stdin: JSON.stringify({ cwd: ROOT, ...payload }),
})
assertHookSucceeded(result)
assert.equal(stub.requests.length, 1, 'a reason it could not build cost the report')
const { body } = assertContractHonoured('/hook', stub.requests[0])
assert.equal(body.state, 'waiting')
assert.ok(!('reason' in body), `sent a reason it has no words for: ${stub.requests[0].raw}`)
})
})
}
// Windows ships sed (Git Bash) but rarely jq. A message is arbitrary text a
// hand-built body cannot escape, so it stays home; Codex's tool name is an
// identifier, and Codex is the harness that needs the Windows wrapper.
test('waiting reports the tool name as its reason without jq', async () => {
await withStub(async (stub) => {
const result = await runHook(`"${ROOT}/hooks/report-state.sh" waiting`, {
env: { ...lichEnv(stub.port), PATH: binWithout('jq') },
stdin: stdinFor({ provider: 'codex', event: 'PermissionRequest' }),
})
assertHookSucceeded(result)
const { body } = assertContractHonoured('/hook', stub.requests[0])
assert.equal(body.reason, TOOL_CALL.codex.tool_name)
})
})
test('waiting sends no message-shaped reason without jq', async () => {
await withStub(async (stub) => {
const result = await runHook(`"${ROOT}/hooks/report-state.sh" waiting`, {
env: { ...lichEnv(stub.port), PATH: binWithout('jq') },
stdin: stdinFor({ provider: 'claude', event: 'Notification' }),
})
assertHookSucceeded(result)
const { body } = assertContractHonoured('/hook', stub.requests[0])
assert.equal(body.state, 'waiting')
assert.ok(!('reason' in body), `escaped nothing and sent it anyway: ${stub.requests[0].raw}`)
})
})
// Only `waiting` reads stdin. The other three states are reported from events
// whose payload says nothing about a reason, and a script waiting on a pipe
// nobody writes to would hold the turn it must never block.
for (const state of ['busy', 'done', 'idle']) {
test(`${state} reports without reading stdin`, async () => {
await withStub(async (stub) => {
const child = spawn('/bin/sh', ['-c', `"${ROOT}/hooks/report-state.sh" ${state}`], {
env: { ...BASE_ENV, ...lichEnv(stub.port) },
stdio: ['pipe', 'ignore', 'ignore'],
})
child.stdin.on('error', () => {})
const code = await new Promise((resolve) => child.on('close', resolve))
child.stdin.destroy()
await settleStubs()
assert.equal(code, 0)
assert.equal(stub.requests.length, 1)
assert.equal(assertContractHonoured('/hook', stub.requests[0]).body.state, state)
})
})
}
for (const [script, argument] of [['report-state.sh', 'busy'], ['report-tool.sh', '']]) {
const command = `"${ROOT}/hooks/${script}" ${argument}`
test(`${script} names the conversation without jq`, async () => {
await withStub(async (stub) => {
const result = await runHook(command, {
env: { ...lichEnv(stub.port), PATH: binWithout('jq') },
stdin: stdinFor({ provider: 'claude', event: 'PreToolUse' }),
})
assertHookSucceeded(result)
const { body } = assertContractHonoured('/hook', stub.requests[0])
assert.equal(body.provider_session_id, PROVIDER_SESSION_ID.claude)
})
})
test(`${script} leaves the conversation out when the payload names none`, async () => {
await withStub(async (stub) => {
const { session_id: _, ...payload } = JSON.parse(stdinFor({ provider: 'claude', event: 'PreToolUse' }))
const result = await runHook(command, {
env: lichEnv(stub.port),
stdin: JSON.stringify(payload),
})
assertHookSucceeded(result)
const { body } = assertContractHonoured('/hook', stub.requests[0])
assert.ok(!('provider_session_id' in body), `sent an empty conversation: ${stub.requests[0].raw}`)
})
})
}
// A subagent's tool calls fire the session's own PreToolUse and PostToolUse,
// carrying `agent_id` (measured on Claude Code 2.1.302). Its tools still run
// after the session's Stop: a background agent, and the fork Claude Code runs to
// suggest the next prompt. Reported, they put a finished card back on busy.
for (const [script, argument] of [['report-state.sh', 'busy'], ['report-tool.sh', '']]) {
const command = `"${ROOT}/hooks/${script}" ${argument}`
const subagentCall = JSON.stringify({
...JSON.parse(stdinFor({ provider: 'claude', event: 'PreToolUse' })),
agent_id: 'aab6524dbdaf8ca5a',
agent_type: 'Explore',
})
for (const [how, env] of [['', {}], [' without jq', { PATH: binWithout('jq') }]]) {
test(`${script} leaves a subagent's tool call unreported${how}`, async () => {
await withStub(async (stub) => {
const result = await runHook(command, { env: { ...lichEnv(stub.port), ...env }, stdin: subagentCall })
assertHookSucceeded(result)
assert.equal(stub.requests.length, 0, `reported a subagent's call: ${stub.requests[0]?.raw}`)
})
})
}
test(`${script} still reports a call whose input names an agent_id`, async () => {
await withStub(async (stub) => {
const payload = JSON.parse(stdinFor({ provider: 'claude', event: 'PreToolUse' }))
payload.tool_input = { agent_id: 'not-the-caller' }
const result = await runHook(command, { env: lichEnv(stub.port), stdin: JSON.stringify(payload) })
assertHookSucceeded(result)
assert.equal(stub.requests.length, 1)
})
})
}
// lich writes the scripts of a file-shipped harness one by one (Antigravity,
// Kiro), so an install can carry them without conversation-id.sh. Each says so
// on stderr and reports what it can without it.
const WITHOUT_HELPER = [
['report-state.sh', 'busy', 'reporting without provider_session_id', 1],
['report-tool.sh', '', 'reporting without provider_session_id', 1],
['report-session-start.sh', 'claude', 'session-start not reported', 0],
]
for (const [script, argument, lost, reports] of WITHOUT_HELPER) {
test(`${script} without conversation-id.sh says so and ${reports ? 'reports without the id' : 'reports nothing'}`, async () => {
const dir = mkdtempSync(path.join(TMP, 'no-helper-'))
copyFileSync(path.join(ROOT, 'hooks', script), path.join(dir, script))
await withStub(async (stub) => {
const result = await runHook(`"${dir}/${script}" ${argument}`, {
env: lichEnv(stub.port),
stdin: stdinFor({ provider: 'claude', event: 'PreToolUse' }),
})
assertHookSucceeded(result)
assert.equal(result.stderr, `lich-plugin: hooks/conversation-id.sh missing; ${lost}\n`)
assert.equal(stub.requests.length, reports)
if (reports) {
const { body } = assertContractHonoured('/hook', stub.requests[0])
assert.ok(!('provider_session_id' in body), `sent a conversation it cannot read: ${stub.requests[0].raw}`)
}
})
})
}
test('session-start parses the payload without jq', async () => {
// Windows ships sed (Git Bash) but rarely jq; the script falls back to sed.
await withStub(async (stub) => {
const result = await runHook(`"${ROOT}/hooks/report-session-start.sh" claude`, {
env: { ...lichEnv(stub.port), PATH: binWithout('jq') },
stdin: stdinFor({ provider: 'claude', event: 'SessionStart' }),
})
assertHookSucceeded(result)
assert.equal(stub.requests.length, 1)
const { body } = assertContractHonoured('/session-start', stub.requests[0])
assert.equal(body.provider_session_id, PROVIDER_SESSION_ID.claude)
})
})
test('session-start no-ops when the payload carries no session id', async () => {
await withStub(async (stub) => {
const result = await runHook(`"${ROOT}/hooks/report-session-start.sh" codex`, {
env: lichEnv(stub.port),
stdin: JSON.stringify({ transcript_path: TRANSCRIPT.codex }),
})
assertHookSucceeded(result)
assert.equal(stub.requests.length, 0, 'sent a report with no provider session id')
})
})
// A title the contract calls blank must never leave the client: the fixture
// rejects "", " \n" and a missing field alike.
const NO_TITLE = [
['claude transcript without an ai-title line', () => claudeTranscript(null, 'no-title.jsonl')],
['claude ai-title that is empty', () => claudeTranscript('', 'empty-title.jsonl')],
['claude ai-title that is whitespace', () => claudeTranscript(' ', 'blank-title.jsonl')],
['codex rollout without a user message', () => codexTranscript(null, 'no-message.jsonl')],
['codex first line that is whitespace', () => codexTranscript(' \nreal work', 'blank-line.jsonl')],
['codex message that is a newline', () => codexTranscript('\nreal work', 'leading-nl.jsonl')],
['codex paginated rollout without a user message', () => codexPaginatedTranscript(null, 'paginated-no-message.jsonl')],
['codex paginated first line that is whitespace', () => codexPaginatedTranscript(' \nreal work', 'paginated-blank-line.jsonl')],
['antigravity transcript without user input', () => antigravityTranscript(null, 'no-user-input.jsonl')],
['antigravity user input that is whitespace', () => antigravityTranscript(' ', 'blank-user-input.jsonl')],
]
for (const [label, make] of NO_TITLE) {
test(`session-title stays silent: ${label}`, async () => {
await withStub(async (stub) => {
const result = await runHook(`"${ROOT}/hooks/report-title.sh"`, {
env: lichEnv(stub.port),
stdin: JSON.stringify({ session_id: 's', transcript_path: make() }),
})
assertHookSucceeded(result)
assert.equal(
stub.requests.length,
0,
`posted a blank title, which the contract rejects: ${stub.requests[0]?.raw}`,
)
})
})
}
// A hook inherits the harness's locale, and under a C one `cut -c` counts
// bytes: a title cut mid-character reaches lich as a replacement glyph, with
// nothing anywhere returning non-zero. The cap is a jq slice for that reason, so
// this runs in the locale that breaks the other spelling — and asserts the title
// survives the round trip character for character, which is what a byte cut
// cannot do. A title written in English would pass either way.
for (const [provider, make] of [
['codex', codexTranscript],
['codex paginated', codexPaginatedTranscript],
['antigravity', antigravityTranscript],
]) {
test(`session-title caps ${provider} by character, not by byte`, async () => {
// Accented, so a byte cut lands mid-character: 40 * 3 bytes, cut at 80.
const long = 'ação '.repeat(40).trim()
await withStub(async (stub) => {
const result = await runHook(`"${ROOT}/hooks/report-title.sh"`, {
env: { ...lichEnv(stub.port), LC_ALL: 'C', LANG: 'C' },
stdin: JSON.stringify({
session_id: 's',
transcript_path: make(long, `long-${provider}.jsonl`),
}),
})
assertHookSucceeded(result)
assert.equal(stub.requests.length, 1)
const { body } = assertContractHonoured('/session-title', stub.requests[0])
assert.equal([...body.title].length, 80, 'title is not 80 characters')
assert.equal(body.title, [...long].slice(0, 80).join(''))
assert.ok(!body.title.includes('\uFFFD'), `title carries a broken character: ${body.title}`)
})
})
}
test('session-title reports the first line of a paginated Codex rollout', async () => {
await withStub(async (stub) => {
const file = codexPaginatedTranscript('Fix the replay ring overflow\nand its second line')
const result = await runHook(`"${ROOT}/hooks/report-title.sh"`, {
env: lichEnv(stub.port),
stdin: JSON.stringify({ session_id: 's', transcript_path: file }),
})
assertHookSucceeded(result)
assert.equal(stub.requests.length, 1)
assert.equal(assertContractHonoured('/session-title', stub.requests[0]).body.title, 'Fix the replay ring overflow')
})
})
test('session-title trims the title it reports', async () => {
await withStub(async (stub) => {
const file = claudeTranscript(' Fix the replay ring overflow ', 'padded-title.jsonl')
const result = await runHook(`"${ROOT}/hooks/report-title.sh"`, {
env: lichEnv(stub.port),
stdin: JSON.stringify({ session_id: 's', transcript_path: file }),
})
assertHookSucceeded(result)
const { body } = assertContractHonoured('/session-title', stub.requests[0])
assert.equal(body.title, 'Fix the replay ring overflow')
})
})
// The in-turn registrations run the script as `once`, on every tool call, and
// finding a title means scanning a transcript that grows all turn. So `once`
// reports the first one it finds and then stops looking, while the Stop
// registration keeps scanning — that is the path a later retitle arrives on.
// Both calls share a $TMPDIR here, because the latch living there is the thing
// under test.
test('session-title reported once per session stops scanning', async () => {
await withStub(async (stub) => {
const scratch = mkdtempSync(path.join(TMP, 'latch-'))
const file = claudeTranscript('Fix the replay ring overflow', 'latched.jsonl')
const stdin = JSON.stringify({ session_id: 's', transcript_path: file })
const run = () =>
runHook(`"${ROOT}/hooks/report-title.sh" once`, {
env: lichEnv(stub.port),
stdin,
tmpdir: scratch,
})
assertHookSucceeded(await run())
assertContractHonoured('/session-title', stub.requests[0])
assertHookSucceeded(await run())
assert.equal(stub.requests.length, 1, `reported ${stub.requests.length} times, expected 1`)
})
})
test('session-title without the latch reports every time, for a retitle', async () => {
await withStub(async (stub) => {
const scratch = mkdtempSync(path.join(TMP, 'unlatched-'))
const first = claudeTranscript('Fix the replay ring overflow', 'retitle-first.jsonl')
const second = claudeTranscript('Rewrite the replay ring', 'retitle-second.jsonl')
const run = (file) =>
runHook(`"${ROOT}/hooks/report-title.sh"`, {
env: lichEnv(stub.port),
stdin: JSON.stringify({ session_id: 's', transcript_path: file }),
tmpdir: scratch,
})
assertHookSucceeded(await run(first))
assertHookSucceeded(await run(second))
assert.equal(stub.requests.length, 2, `reported ${stub.requests.length} times, expected 2`)
assert.equal(assertContractHonoured('/session-title', stub.requests[1]).body.title, 'Rewrite the replay ring')
})
})
// A refusal must not latch: a title lich did not take is one to look for again
// on the next tool call, where latching on it would wait for Stop instead.
test('session-title does not latch on a report lich refused', async () => {
await withStub(
async (stub) => {
const scratch = mkdtempSync(path.join(TMP, 'refused-'))
const file = claudeTranscript('Fix the replay ring overflow', 'refused.jsonl')
const stdin = JSON.stringify({ session_id: 's', transcript_path: file })
const run = () =>
runHook(`"${ROOT}/hooks/report-title.sh" once`, {
env: lichEnv(stub.port),
stdin,
tmpdir: scratch,
})
assertHookSucceeded(await run())
assertHookSucceeded(await run())
assert.equal(stub.requests.length, 2, `reported ${stub.requests.length} times, expected 2`)
},
{ status: 500 },
)
})
test('session-title escapes a title that would break the JSON body', async () => {
await withStub(async (stub) => {
const file = claudeTranscript('Fix "quoted" \\ and \ttabbed', 'quoted-title.jsonl')
const result = await runHook(`"${ROOT}/hooks/report-title.sh"`, {