subrouter-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CLAUDE.md +51 -0
  2. package/README.md +397 -2
  3. package/config.example.json +9 -0
  4. package/package.json +21 -4
  5. package/scripts/sse-harness.ts +252 -0
  6. package/scripts/sub-wrapper.sh +11 -0
  7. package/src/adapters/anthropic.ts +311 -0
  8. package/src/adapters/openai.ts +227 -0
  9. package/src/approval.ts +21 -0
  10. package/src/chatviewport.ts +123 -0
  11. package/src/client.ts +398 -0
  12. package/src/clipboard.ts +62 -0
  13. package/src/commandpolicy.ts +272 -0
  14. package/src/commands.ts +18 -0
  15. package/src/config.ts +107 -0
  16. package/src/effort.ts +18 -0
  17. package/src/images.ts +77 -0
  18. package/src/index.ts +248 -0
  19. package/src/lineinput.ts +726 -0
  20. package/src/loop.ts +201 -0
  21. package/src/markdown.ts +244 -0
  22. package/src/repl.ts +700 -0
  23. package/src/sessions.ts +58 -0
  24. package/src/sse.ts +64 -0
  25. package/src/terminal.ts +228 -0
  26. package/src/token.ts +36 -0
  27. package/src/toolpreview.ts +54 -0
  28. package/src/tools.ts +790 -0
  29. package/src/types.ts +73 -0
  30. package/src/ui.ts +813 -0
  31. package/src/usage.ts +186 -0
  32. package/test/absolute-tools.test.ts +296 -0
  33. package/test/absolute-ui.test.ts +153 -0
  34. package/test/adapters.test.ts +205 -0
  35. package/test/anthropic.test.ts +246 -0
  36. package/test/auto-ui.test.ts +106 -0
  37. package/test/chatviewport.test.ts +49 -0
  38. package/test/client.test.ts +327 -0
  39. package/test/clipboard.test.ts +63 -0
  40. package/test/command-input.test.ts +183 -0
  41. package/test/command-tools.test.ts +141 -0
  42. package/test/commandpolicy.test.ts +252 -0
  43. package/test/disk-tools.test.ts +220 -0
  44. package/test/effort.test.ts +123 -0
  45. package/test/fixtures/openai-tools.sse +84 -0
  46. package/test/image-adapters.test.ts +78 -0
  47. package/test/image-ui.test.ts +151 -0
  48. package/test/images.test.ts +60 -0
  49. package/test/loop.test.ts +387 -0
  50. package/test/m5.test.ts +127 -0
  51. package/test/markdown.test.ts +201 -0
  52. package/test/repl-ui.test.ts +293 -0
  53. package/test/sse.test.ts +74 -0
  54. package/test/steering-ui.test.ts +182 -0
  55. package/test/steering.test.ts +68 -0
  56. package/test/terminal-ui.test.ts +144 -0
  57. package/test/terminal.test.ts +229 -0
  58. package/test/toolpreview.test.ts +51 -0
  59. package/test/tools.test.ts +227 -0
  60. package/test/ui.test.ts +635 -0
  61. package/test/usage-footer.test.ts +180 -0
  62. package/tsconfig.json +17 -0
@@ -0,0 +1,387 @@
1
+ // loop.ts tests — scripted fake client driving the round driver: tool→result→answer,
2
+ // permission gate (y/a/n), denial, round cap, budget warnings, abort.
3
+
4
+ import assert from 'node:assert/strict';
5
+ import { describe, it } from 'node:test';
6
+ import { runAgentTurn, type Approval, type ChatClient, type ToolProgressPhase } from '../src/loop.ts';
7
+ import { estimateTokens } from '../src/token.ts';
8
+ import { TOOL_DEFINITIONS } from '../src/tools.ts';
9
+ import type { AssistantTurn, ChatMessage, ChatRequest, StreamEvent, ToolCall, ToolDefinition } from '../src/types.ts';
10
+
11
+ const WRITE_TOOL: ToolDefinition = {
12
+ name: 'write_file',
13
+ description: 'write',
14
+ parameters: { type: 'object' },
15
+ requiresApproval: true,
16
+ };
17
+ const READ_TOOL: ToolDefinition = {
18
+ name: 'read_file',
19
+ description: 'read',
20
+ parameters: { type: 'object' },
21
+ requiresApproval: false,
22
+ };
23
+
24
+ function toolCall(name: string, id = 'call_1'): ToolCall {
25
+ return { id, name, arguments: { path: 'x' }, argumentsJson: '{"path":"x"}' };
26
+ }
27
+
28
+ function turnWithCalls(calls: ToolCall[]): AssistantTurn {
29
+ return {
30
+ message: { role: 'assistant', content: 'thinking…', toolCalls: calls },
31
+ stopReason: 'tool_calls',
32
+ usage: { inputTokens: 10, outputTokens: 5 },
33
+ };
34
+ }
35
+
36
+ function finalTurn(content: string): AssistantTurn {
37
+ return { message: { role: 'assistant', content }, stopReason: 'stop', usage: { inputTokens: 3, outputTokens: 2 } };
38
+ }
39
+
40
+ /** A scripted client: each chat() call consumes the next script entry and records the request. */
41
+ function scriptedClient(script: AssistantTurn[]): ChatClient & { requests: ChatRequest[] } {
42
+ const requests: ChatRequest[] = [];
43
+ return {
44
+ requests,
45
+ async chat(req: ChatRequest, _onEvent: (e: StreamEvent) => void): Promise<AssistantTurn> {
46
+ requests.push(req);
47
+ const turn = script.shift();
48
+ if (!turn) throw new Error('script exhausted');
49
+ return turn;
50
+ },
51
+ };
52
+ }
53
+
54
+ interface Harness {
55
+ history: ChatMessage[];
56
+ progress: { phase: ToolProgressPhase; call: ToolCall; note?: string }[];
57
+ askLog: ToolCall[];
58
+ executeLog: ToolCall[];
59
+ budgetLog: (80 | 95)[];
60
+ requests: ChatRequest[];
61
+ result: Awaited<ReturnType<typeof runAgentTurn>>;
62
+ }
63
+
64
+ /** The request size loop.ts sees on the first round (system + user + tool defs). */
65
+ function budgetTotal(): number {
66
+ const texts = [
67
+ 'system prompt',
68
+ 'do it',
69
+ `${READ_TOOL.name}${READ_TOOL.description}${JSON.stringify(READ_TOOL.parameters)}`,
70
+ `${WRITE_TOOL.name}${WRITE_TOOL.description}${JSON.stringify(WRITE_TOOL.parameters)}`,
71
+ ];
72
+ return texts.reduce((sum, t) => sum + estimateTokens(t), 0);
73
+ }
74
+
75
+ async function run(overrides: {
76
+ script: AssistantTurn[];
77
+ tools?: ToolDefinition[];
78
+ ask?: (call: ToolCall) => Promise<Approval>;
79
+ autoApprove?: boolean;
80
+ fileApproval?: () => boolean;
81
+ maxRounds?: number;
82
+ contextTokens?: number;
83
+ countTokens?: (model: string, system: string, messages: ChatMessage[], tools?: ToolDefinition[]) => Promise<number>;
84
+ signal?: AbortSignal;
85
+ }): Promise<Harness> {
86
+ const client = scriptedClient(overrides.script);
87
+ const history: ChatMessage[] = [{ role: 'user', content: 'do it' }];
88
+ const progress: Harness['progress'] = [];
89
+ const askLog: ToolCall[] = [];
90
+ const executeLog: ToolCall[] = [];
91
+ const budgetLog: Harness['budgetLog'] = [];
92
+ const result = await runAgentTurn({
93
+ client,
94
+ model: 'opencode/fake',
95
+ history,
96
+ system: 'system prompt',
97
+ tools: overrides.tools ?? [READ_TOOL, WRITE_TOOL],
98
+ execute: async (call) => {
99
+ executeLog.push(call);
100
+ return { toolCallId: call.id, content: 'tool ok' };
101
+ },
102
+ ask: async (call) => {
103
+ askLog.push(call); // log centrally — custom ask callbacks in tests bypass the default
104
+ return (overrides.ask ?? (async () => 'yes' as Approval))(call);
105
+ },
106
+ autoApprove: overrides.autoApprove ?? false,
107
+ fileApproval: overrides.fileApproval,
108
+ maxRounds: overrides.maxRounds ?? 3,
109
+ maxTokens: 1024,
110
+ contextTokens: overrides.contextTokens,
111
+ countTokens: overrides.countTokens,
112
+ signal: overrides.signal ?? new AbortController().signal,
113
+ onEvent: () => {},
114
+ onToolProgress: (phase, call, note) => progress.push({ phase, call, note }),
115
+ onBudget: (p) => budgetLog.push(p),
116
+ });
117
+ return { history, progress, askLog, executeLog, budgetLog, requests: client.requests, result };
118
+ }
119
+
120
+ describe('runAgentTurn', () => {
121
+ it('live file permission state overrides a captured file approve-all latch', async () => {
122
+ const denied = await run({
123
+ script: [turnWithCalls([toolCall('write_file')]), finalTurn('Denied.')],
124
+ autoApprove: true,
125
+ fileApproval: () => false,
126
+ ask: async () => 'no',
127
+ });
128
+ assert.equal(denied.askLog.length, 1);
129
+ assert.equal(denied.executeLog.length, 0);
130
+ const allowed = await run({
131
+ script: [turnWithCalls([toolCall('write_file')]), finalTurn('Done.')],
132
+ autoApprove: false,
133
+ fileApproval: () => true,
134
+ });
135
+ assert.equal(allowed.askLog.length, 0);
136
+ assert.equal(allowed.executeLog.length, 1);
137
+ });
138
+
139
+ it('drives tool → result → answer and returns the final turn', async () => {
140
+ const { history, result, executeLog, requests } = await run({
141
+ script: [turnWithCalls([toolCall('read_file')]), finalTurn('done!')],
142
+ });
143
+ assert.equal(result.stopReason, 'stop');
144
+ assert.equal(result.rounds, 1);
145
+ assert.deepEqual(result.usage, { inputTokens: 13, outputTokens: 7 }); // summed across rounds
146
+ assert.deepEqual(history.map((m) => m.role), ['user', 'assistant', 'tool', 'assistant']);
147
+ assert.equal(history.at(-1)?.content, 'done!');
148
+ assert.equal(history[2].toolCallId, 'call_1');
149
+ assert.equal(history[2].content, 'tool ok');
150
+ assert.equal(executeLog.length, 1);
151
+ assert.deepEqual(requests[0].messages, [{ role: 'user', content: 'do it' }]); // snapshot before the round
152
+ assert.deepEqual(requests[1].messages.map((m) => m.role), ['user', 'assistant', 'tool']); // includes the result
153
+ assert.ok(requests[1].tools);
154
+ });
155
+
156
+ it('permission-gates every filesystem mutation and lets inspection tools run without prompts', async () => {
157
+ const calls = TOOL_DEFINITIONS.map((tool, i) => toolCall(tool.name, `c${i}`));
158
+ const { askLog, executeLog, history } = await run({
159
+ tools: TOOL_DEFINITIONS,
160
+ script: [turnWithCalls(calls), finalTurn('ok')],
161
+ ask: async () => 'no',
162
+ });
163
+ assert.deepEqual(askLog.map((call) => call.name), TOOL_DEFINITIONS.filter((tool) => tool.requiresApproval).map((tool) => tool.name));
164
+ assert.deepEqual(executeLog.map((call) => call.name), TOOL_DEFINITIONS.filter((tool) => !tool.requiresApproval).map((tool) => tool.name));
165
+ assert.equal(history.filter((message) => message.role === 'tool' && message.isError).length, askLog.length);
166
+ });
167
+
168
+ it('terminal commands always ask despite auto-approve or file approve-all', async () => {
169
+ for (const autoApprove of [false, true]) {
170
+ const { askLog, executeLog } = await run({
171
+ tools: TOOL_DEFINITIONS, autoApprove,
172
+ script: [turnWithCalls([toolCall('write_file', 'f'), toolCall('run_command', 'a'), toolCall('run_command', 'b')]), turnWithCalls([toolCall('run_command', 'c')]), finalTurn('done')],
173
+ ask: async (call) => call.name === 'write_file' ? 'all' : 'yes',
174
+ });
175
+ assert.deepEqual(askLog.filter((call) => call.name === 'run_command').map((call) => call.id), ['a', 'b', 'c']);
176
+ assert.equal(executeLog.length, 4);
177
+ }
178
+ });
179
+
180
+ it('terminal all is denied and terminal yes never grants file approval', async () => {
181
+ const { askLog, executeLog, history } = await run({
182
+ tools: TOOL_DEFINITIONS,
183
+ script: [turnWithCalls([toolCall('run_command', 'a'), toolCall('write_file', 'f'), toolCall('run_command', 'b')]), finalTurn('done')],
184
+ ask: async (call) => call.id === 'a' ? 'all' : call.id === 'b' ? 'yes' : 'no',
185
+ });
186
+ assert.deepEqual(askLog.map((call) => call.id), ['a', 'f', 'b']);
187
+ assert.deepEqual(executeLog.map((call) => call.id), ['b']);
188
+ assert.equal(history.filter((message) => message.role === 'tool' && message.isError).length, 2);
189
+ });
190
+
191
+ it('pairs every tool call on cancellation during approval, without executing', async () => {
192
+ const controller = new AbortController();
193
+ const client = scriptedClient([turnWithCalls([toolCall('run_command', 'a'), toolCall('write_file', 'b')])]);
194
+ const history: ChatMessage[] = [];
195
+ let executed = 0;
196
+ await assert.rejects(runAgentTurn({
197
+ client, model: 'openai/test', history, system: '', tools: TOOL_DEFINITIONS,
198
+ ask: async () => { controller.abort(); return 'yes'; },
199
+ execute: async (call) => { executed++; return { toolCallId: call.id, content: 'bad' }; },
200
+ autoApprove: false, maxRounds: 3, maxTokens: 1024, signal: controller.signal, onEvent: () => {},
201
+ }), { name: 'AbortError' });
202
+ assert.equal(executed, 0);
203
+ assert.deepEqual(history.map((message) => message.role), ['assistant', 'tool', 'tool']);
204
+ assert.deepEqual(history.slice(1).map((message) => message.toolCallId), ['a', 'b']);
205
+ assert.ok(history.slice(1).every((message) => message.isError));
206
+ });
207
+
208
+ it('keeps completed siblings and normalizes execution rejection before propagating cancellation', async () => {
209
+ const controller = new AbortController();
210
+ const client = scriptedClient([turnWithCalls([toolCall('run_command', 'a'), toolCall('read_file', 'b'), toolCall('read_file', 'c')])]);
211
+ const history: ChatMessage[] = [];
212
+ await assert.rejects(runAgentTurn({
213
+ client, model: 'openai/test', history, system: '', tools: TOOL_DEFINITIONS,
214
+ ask: async () => 'yes',
215
+ execute: async (call, signal) => {
216
+ assert.equal(signal, controller.signal);
217
+ if (call.id === 'a') { await new Promise(setImmediate); controller.abort(); return { toolCallId: call.id, content: 'cancelled', isError: true }; }
218
+ if (call.id === 'b') return { toolCallId: call.id, content: 'completed' };
219
+ throw new Error('controlled rejection');
220
+ },
221
+ autoApprove: false, maxRounds: 3, maxTokens: 1024, signal: controller.signal, onEvent: () => {},
222
+ }), { name: 'AbortError' });
223
+ assert.deepEqual(history.slice(1).map((message) => [message.toolCallId, message.content]), [['a', 'cancelled'], ['b', 'completed'], ['c', 'controlled rejection']]);
224
+ });
225
+
226
+ it('does not prompt for read-only tools', async () => {
227
+ const { askLog } = await run({ script: [turnWithCalls([toolCall('read_file')]), finalTurn('ok')] });
228
+ assert.equal(askLog.length, 0);
229
+ });
230
+
231
+ it('prompts per call and denies on n, feeding an error result', async () => {
232
+ const { history, askLog, executeLog, progress } = await run({
233
+ script: [turnWithCalls([toolCall('write_file', 'c1'), toolCall('write_file', 'c2')]), finalTurn('ok')],
234
+ ask: async (call) => (call.id === 'c1' ? 'no' : 'yes'),
235
+ });
236
+ assert.equal(askLog.length, 2);
237
+ assert.equal(executeLog.length, 1); // only c2 ran
238
+ const denied = history.find((m) => m.toolCallId === 'c1');
239
+ assert.equal(denied?.isError, true);
240
+ assert.deepEqual(JSON.parse(denied?.content ?? '{}'), { error: 'Tool call denied by user' });
241
+ assert.deepEqual(progress.map((p) => p.phase), ['start', 'start', 'denied', 'done']);
242
+ });
243
+
244
+ it('approve-all stops prompting for the rest of the session', async () => {
245
+ const { askLog, executeLog } = await run({
246
+ script: [
247
+ turnWithCalls([toolCall('write_file', 'c1')]),
248
+ turnWithCalls([toolCall('write_file', 'c2')]),
249
+ finalTurn('ok'),
250
+ ],
251
+ ask: async () => 'all',
252
+ });
253
+ assert.equal(askLog.length, 1); // only the first write prompted
254
+ assert.equal(executeLog.length, 2);
255
+ });
256
+
257
+ it('autoApprove skips the gate entirely', async () => {
258
+ const { askLog, executeLog } = await run({
259
+ script: [turnWithCalls([toolCall('write_file')]), finalTurn('ok')],
260
+ autoApprove: true,
261
+ });
262
+ assert.equal(askLog.length, 0);
263
+ assert.equal(executeLog.length, 1);
264
+ });
265
+
266
+ it('caps rounds with a stop message', async () => {
267
+ const { result, history, executeLog } = await run({
268
+ script: Array.from({ length: 2 }, () => turnWithCalls([toolCall('read_file')])), // every round asks for tools
269
+ maxRounds: 2,
270
+ });
271
+ assert.equal(result.stopReason, 'length');
272
+ assert.equal(executeLog.length, 2);
273
+ assert.equal(history.at(-1)?.content, '(stopped after too many tool calls)');
274
+ });
275
+
276
+ it('fires the 95% budget warning once across rounds', async () => {
277
+ const total = budgetTotal();
278
+ const { budgetLog } = await run({
279
+ script: [turnWithCalls([toolCall('read_file')]), finalTurn('ok')],
280
+ contextTokens: Math.floor(total / 0.95), // just over the 95% threshold
281
+ });
282
+ assert.deepEqual(budgetLog, [95]);
283
+ });
284
+
285
+ it('fires only the 80% warning when between thresholds', async () => {
286
+ const total = budgetTotal();
287
+ const { budgetLog } = await run({
288
+ script: [finalTurn('ok')], // single round — the request fits between the thresholds
289
+ contextTokens: Math.floor(total / 0.8), // over 80%, under 95%
290
+ });
291
+ assert.deepEqual(budgetLog, [80]);
292
+ });
293
+
294
+ it('uses the exact counter when injected', async () => {
295
+ const { budgetLog } = await run({
296
+ script: [finalTurn('ok')],
297
+ contextTokens: 1000, // the estimate alone would never trip this window
298
+ countTokens: async () => 2000,
299
+ });
300
+ assert.deepEqual(budgetLog, [95]);
301
+ });
302
+
303
+ it('falls back to the estimate when the exact counter fails', async () => {
304
+ const total = budgetTotal();
305
+ const { budgetLog } = await run({
306
+ script: [finalTurn('ok')],
307
+ contextTokens: Math.floor(total / 0.95), // estimate trips 95%
308
+ countTokens: async () => {
309
+ throw new Error('counter down');
310
+ },
311
+ });
312
+ assert.deepEqual(budgetLog, [95]);
313
+ });
314
+
315
+ it('throws an AbortError on an aborted signal', async () => {
316
+ const controller = new AbortController();
317
+ controller.abort();
318
+ await assert.rejects(
319
+ () =>
320
+ runAgentTurn({
321
+ client: scriptedClient([finalTurn('never')]),
322
+ model: 'opencode/fake',
323
+ history: [{ role: 'user', content: 'hi' }],
324
+ system: 's',
325
+ tools: [READ_TOOL],
326
+ execute: async (c) => ({ toolCallId: c.id, content: 'ok' }),
327
+ ask: async () => 'no',
328
+ autoApprove: false,
329
+ maxRounds: 3,
330
+ maxTokens: 1024,
331
+ signal: controller.signal,
332
+ onEvent: () => {},
333
+ }),
334
+ (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
335
+ );
336
+ });
337
+
338
+ it('passes text and reasoning events straight through', async () => {
339
+ const events: StreamEvent[] = [];
340
+ const script: AssistantTurn[] = [finalTurn('ok')];
341
+ const client = {
342
+ async chat(_req: ChatRequest, onEvent: (e: StreamEvent) => void): Promise<AssistantTurn> {
343
+ onEvent({ kind: 'text', text: 'a' });
344
+ onEvent({ kind: 'reasoning', text: 'b' });
345
+ return script.shift() as AssistantTurn;
346
+ },
347
+ };
348
+ await runAgentTurn({
349
+ client,
350
+ model: 'opencode/fake',
351
+ history: [{ role: 'user', content: 'hi' }],
352
+ system: 's',
353
+ tools: [READ_TOOL],
354
+ execute: async (c) => ({ toolCallId: c.id, content: 'ok' }),
355
+ ask: async () => 'no',
356
+ autoApprove: false,
357
+ maxRounds: 3,
358
+ maxTokens: 1024,
359
+ signal: new AbortController().signal,
360
+ onEvent: (e) => events.push(e),
361
+ });
362
+ assert.deepEqual(events.map((e) => e.kind), ['text', 'reasoning']);
363
+ });
364
+
365
+ it('marks failed tool executions as errors', async () => {
366
+ const client = scriptedClient([turnWithCalls([toolCall('read_file')]), finalTurn('ok')]);
367
+ const phases: { phase: ToolProgressPhase }[] = [];
368
+ const h: ChatMessage[] = [{ role: 'user', content: 'hi' }];
369
+ await runAgentTurn({
370
+ client,
371
+ model: 'opencode/fake',
372
+ history: h,
373
+ system: 's',
374
+ tools: [READ_TOOL],
375
+ execute: async (c) => ({ toolCallId: c.id, content: 'boom', isError: true }),
376
+ ask: async () => 'no',
377
+ autoApprove: false,
378
+ maxRounds: 3,
379
+ maxTokens: 1024,
380
+ signal: new AbortController().signal,
381
+ onEvent: () => {},
382
+ onToolProgress: (phase) => phases.push({ phase }),
383
+ });
384
+ assert.equal(h[2].isError, true);
385
+ assert.deepEqual(phases.map((p) => p.phase), ['start', 'error']);
386
+ });
387
+ });
@@ -0,0 +1,127 @@
1
+ // M5 tests: sessions transcripts, usage rendering, and the pure line-editor core.
2
+
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { describe, it } from 'node:test';
8
+ import { editLine } from '../src/lineinput.ts';
9
+ import { appendMarkdown, appendRecord, startSession, type TurnRecord } from '../src/sessions.ts';
10
+ import { bar, renderUsage } from '../src/usage.ts';
11
+
12
+ describe('sessions', () => {
13
+ it('creates a chmod-600 transcript pair and appends', () => {
14
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-sessions-'));
15
+ const session = startSession(dir);
16
+ assert.ok(session.id.startsWith('sess-'));
17
+ assert.ok(fs.existsSync(session.mdPath));
18
+ assert.ok(fs.existsSync(session.jsonlPath));
19
+ assert.equal(fs.statSync(session.mdPath).mode & 0o777, 0o600);
20
+ assert.equal(fs.statSync(session.jsonlPath).mode & 0o777, 0o600);
21
+ const header = fs.readFileSync(session.mdPath, 'utf8');
22
+ assert.match(header, /^# sub session sess-/);
23
+ appendMarkdown(session, 'hello');
24
+ assert.match(fs.readFileSync(session.mdPath, 'utf8'), /hello/);
25
+ const record: TurnRecord = {
26
+ type: 'turn',
27
+ ts: '2026-10-10T00:00:00.000Z',
28
+ model: 'opencode/fake',
29
+ stopReason: 'stop',
30
+ rounds: 0,
31
+ messages: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'yo' }],
32
+ };
33
+ appendRecord(session, record);
34
+ const lines = fs.readFileSync(session.jsonlPath, 'utf8').trim().split('\n');
35
+ assert.equal(lines.length, 1);
36
+ assert.deepEqual(JSON.parse(lines[0]), record);
37
+ fs.rmSync(dir, { recursive: true, force: true });
38
+ });
39
+ });
40
+
41
+ describe('bar', () => {
42
+ it('scales percents to a 20-char block bar', () => {
43
+ assert.equal(bar(0), '░'.repeat(20));
44
+ assert.equal(bar(100), '█'.repeat(20));
45
+ assert.equal(bar(50), '█'.repeat(10) + '░'.repeat(10));
46
+ assert.equal(bar(-5), '░'.repeat(20)); // clamped
47
+ assert.equal(bar(120), '█'.repeat(20)); // clamped
48
+ assert.equal(bar('nope'), 'unknown'); // never a fabricated number
49
+ });
50
+ });
51
+
52
+ describe('renderUsage', () => {
53
+ it('renders the live user-quota shape with null limits as unlimited', () => {
54
+ const lines = renderUsage({
55
+ user: {
56
+ rpm_limit: 60,
57
+ daily_token_limit: null,
58
+ today_tokens: 250000,
59
+ resets_at: '2026-10-11T00:00:00.000Z',
60
+ weekly_usd_limit: 5,
61
+ week_usd: 1.25,
62
+ },
63
+ pools: [],
64
+ });
65
+ assert.match(lines[0], /60 rpm · 250,000 tokens today \/ unlimited · \$5\/week · \$1\.25 used · resets 2026-10-11/);
66
+ });
67
+
68
+ it('keeps the documented quotas-key fallback', () => {
69
+ const lines = renderUsage({
70
+ quotas: { rpm_limit: 60, daily_token_limit: 1000000, weekly_usd_limit: 5, daily_tokens_used: 250000 },
71
+ pools: [],
72
+ });
73
+ // today_tokens is absent from the docs' shape — reported unknown, never zero
74
+ assert.match(lines[0], /60 rpm · unknown tokens today \/ 1,000,000 · \$5\/week/);
75
+ });
76
+
77
+ it('renders live provider/window pool bars', () => {
78
+ const lines = renderUsage({
79
+ pools: [
80
+ { provider: 'opencode', window: '7d', used_percent: 57, resets_at: 1791763200 },
81
+ { provider: 'grok', window: 'weekly', used_percent: 'nope', resets_at: '2026-10-11T04:00:00Z' },
82
+ ],
83
+ });
84
+ assert.equal(lines[1], 'pools:');
85
+ const expectedReset = new Date(1791763200 * 1000).toISOString().slice(0, 16);
86
+ assert.match(lines[2], new RegExp(`opencode 7d █{11}░{9} 57% · resets ${expectedReset}`));
87
+ assert.match(lines[3], /grok weekly unknown unknown · resets 2026-10-11/);
88
+ });
89
+
90
+ it('tolerates missing everything without fabricating zeros', () => {
91
+ const lines = renderUsage({});
92
+ assert.equal(lines.length, 1);
93
+ assert.match(lines[0], /unknown rpm/);
94
+ const lines2 = renderUsage('garbage');
95
+ assert.equal(lines2[0], 'usage: unrecognized response');
96
+ });
97
+ });
98
+
99
+ describe('editLine', () => {
100
+ const s = (buffer = '', cursor = 0): { buffer: string; cursor: number } => ({ buffer, cursor });
101
+
102
+ it('inserts characters at the cursor', () => {
103
+ assert.deepEqual(editLine('ab', s()), { buffer: 'ab', cursor: 2 });
104
+ const mid = editLine('x', s('ab', 1));
105
+ assert.equal(mid.buffer, 'axb');
106
+ assert.equal(mid.cursor, 2);
107
+ });
108
+
109
+ it('backspaces at the cursor', () => {
110
+ assert.deepEqual(editLine('\x7f', s('ab', 2)), { buffer: 'a', cursor: 1 });
111
+ assert.deepEqual(editLine('\x7f', s('ab', 0)), { buffer: 'ab', cursor: 0 }); // nothing to delete
112
+ });
113
+
114
+ it('supports Ctrl+A/E/U/K', () => {
115
+ assert.equal(editLine('\x01', s('abc', 3)).cursor, 0);
116
+ assert.equal(editLine('\x05', s('abc', 0)).cursor, 3);
117
+ assert.deepEqual(editLine('\x15', s('abc', 3)), { buffer: '', cursor: 0 });
118
+ assert.deepEqual(editLine('\x0b', s('abc', 1)), { buffer: 'a', cursor: 1 });
119
+ });
120
+
121
+ it('submits on newline and interrupts on Ctrl+C', () => {
122
+ assert.equal(editLine('\r', s('done')).submit, 'done');
123
+ const interrupted = editLine('\x03', s('partial', 4));
124
+ assert.equal(interrupted.interrupt, true);
125
+ assert.deepEqual({ buffer: interrupted.buffer, cursor: interrupted.cursor }, { buffer: '', cursor: 0 });
126
+ });
127
+ });