Repository navigation
Expand file tree
/
Copy pathmod-usage.test.mjs
More file actions
223 lines (186 loc) · 7.51 KB
/
Copy pathmod-usage.test.mjs
File metadata and controls
223 lines (186 loc) · 7.51 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
// Pins the Claude Code mod's usage report (hooks/mod-usage.js) to lich's
// mod-usage contract.
//
// Like mod-control.test.mjs, it imports the module and hands it a fake engine,
// recording every fetch as contract.mjs reads a request, so the reports answer
// to the same fixtures lich asserts (mod-usage.jsonl). The `session.measure`
// payloads below are what Claude Code 2.1.289 delivered against a stub lich.
//
// Run: node --test tests/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { LICH_SESSION_ID, ROOT, assertContractHonoured, lichEnv } from './contract.mjs'
import { register } from '../hooks/mod-usage.js'
import { register as registerEntry } from '../hooks/lich.js'
const PORT = 47998
const CONVERSATION = '3fb3ad05-5b58-45ea-a1e0-0d1d4b0a89b9'
const AT_START = {
context: { window: 200000 },
rateLimits: [
{ kind: 'five_hour', percentUsed: 14, resetsAt: '2026-10-05T00:30:00.000Z' },
{ kind: 'seven_day', percentUsed: 80, resetsAt: '2026-10-05T15:00:00.000Z' },
],
cost: { usd: 0 },
changed: ['context', 'rateLimits', 'cost'],
}
const AT_TURN_END = {
context: { tokens: 43592, window: 200000, percent: 22 },
rateLimits: AT_START.rateLimits,
cost: { usd: 0.0977631 },
changed: ['context', 'cost'],
}
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.
* `answer` answers each report.
*/
function load({ env = lichEnv(PORT), answer = () => Promise.resolve(NO_CONTENT) } = {}) {
const hooks = new Map()
register((event, ...rest) => {
const hook = rest.pop()
const matcher = rest[0] ?? {}
hooks.set(event, { matcher, hook })
})
const requests = []
const $ = {
env: { get: async (name) => env[name] },
session: { id: async () => CONVERSATION },
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 passOn = async (e) => e
const fire = (event, e) => {
const { matcher, hook } = hooks.get(event)
const matches = Object.entries(matcher).every(([key, value]) => e[key] === value)
return matches ? hook($, e, passOn) : passOn(e)
}
return {
requests,
reports: () => requests.map((r) => JSON.parse(r.raw)),
start: (isInteractive = true) => fire('session.start', { cwd: '/w', surface: 'terminal', isInteractive }),
measure: (e) => fire('session.measure', e),
}
}
test('a measurement reaches lich as the contract spells it', async () => {
const mod = load()
await mod.start()
await mod.measure(AT_TURN_END)
await until(() => mod.requests.length === 1, 'the report')
assertContractHonoured('/mod/usage', mod.requests[0])
assert.deepEqual(mod.reports()[0], {
session_id: LICH_SESSION_ID,
conversation_id: CONVERSATION,
context: { window: 200000, tokens: 43592, percent: 22 },
rate_limits: [
{ kind: 'five_hour', percent_used: 14, resets_at: '2026-10-05T00:30:00.000Z' },
{ kind: 'seven_day', percent_used: 80, resets_at: '2026-10-05T15:00:00.000Z' },
],
cost_usd: 0.0977631,
})
})
test('a figure Claude Code does not have is left out, never zeroed', async () => {
const mod = load()
await mod.start()
await mod.measure({ context: { window: 200000 }, rateLimits: [{ kind: 'five_hour', percentUsed: 3 }], changed: ['context'] })
await until(() => mod.requests.length === 1, 'the report')
assertContractHonoured('/mod/usage', mod.requests[0])
const body = mod.reports()[0]
assert.deepEqual(body.context, { window: 200000 })
assert.deepEqual(body.rate_limits, [{ kind: 'five_hour', percent_used: 3 }])
assert.ok(!('cost_usd' in body))
})
// Claude Code measures the session as it starts, which can land before the
// session.start hook has read the link.
test('a measurement taken before the session started is sent once it does', async () => {
const mod = load()
await mod.measure(AT_START)
await settle()
assert.equal(mod.requests.length, 0)
await mod.start()
await until(() => mod.requests.length === 1, 'the held report')
assertContractHonoured('/mod/usage', mod.requests[0])
})
test('the event is passed on without waiting for lich', async () => {
const mod = load({ answer: () => new Promise(() => {}) })
await mod.start()
const passed = await mod.measure(AT_START)
assert.equal(passed, AT_START)
})
test('outside lich nothing is sent', async () => {
const mod = load({ env: {} })
await mod.start()
await mod.measure(AT_TURN_END)
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.measure(AT_TURN_END)
await settle()
assert.equal(mod.requests.length, 0)
})
test('reports go out one at a time, in the order they were measured', 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.measure(AT_START)
await mod.measure(AT_TURN_END)
await settle()
assert.equal(mod.requests.length, 1, 'the second report went out before the first was answered')
release()
await until(() => mod.requests.length === 2, 'the second report')
assert.deepEqual(mod.reports().map((r) => r.cost_usd), [0, 0.0977631])
})
test('a report that fails is dropped and the next 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.measure(AT_START)
await mod.measure(AT_TURN_END)
await until(() => mod.requests.length === 2, 'the report after the failure')
await settle()
assert.equal(mod.requests.length, 2, 'the failed report was retried')
})
test('a lich that predates the contract is not asked again', async () => {
const mod = load({ answer: () => Promise.resolve({ status: 404, ok: false, headers: {}, text: '' }) })
await mod.start()
await mod.measure(AT_START)
await mod.measure(AT_TURN_END)
await settle()
assert.equal(mod.requests.length, 1)
})
// Claude Code takes one hooks module per plugin, so hooks.json names the entry
// that registers every mod of the plugin, this one included.
test('the plugin entry module registers the usage report beside control', () => {
const hooksJson = JSON.parse(readFileSync(path.join(ROOT, 'hooks', 'hooks.json'), 'utf8'))
assert.deepEqual(hooksJson.modules, ['./lich.js'])
const events = []
registerEntry((event) => events.push(event))
assert.ok(events.includes('session.measure'), 'mod-usage is not registered')
assert.ok(events.includes('turn.step'), 'mod-control is not registered')
})