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.
- package/CLAUDE.md +51 -0
- package/README.md +397 -2
- package/config.example.json +9 -0
- package/package.json +21 -4
- package/scripts/sse-harness.ts +252 -0
- package/scripts/sub-wrapper.sh +11 -0
- package/src/adapters/anthropic.ts +311 -0
- package/src/adapters/openai.ts +227 -0
- package/src/approval.ts +21 -0
- package/src/chatviewport.ts +123 -0
- package/src/client.ts +398 -0
- package/src/clipboard.ts +62 -0
- package/src/commandpolicy.ts +272 -0
- package/src/commands.ts +18 -0
- package/src/config.ts +107 -0
- package/src/effort.ts +18 -0
- package/src/images.ts +77 -0
- package/src/index.ts +248 -0
- package/src/lineinput.ts +726 -0
- package/src/loop.ts +201 -0
- package/src/markdown.ts +244 -0
- package/src/repl.ts +700 -0
- package/src/sessions.ts +58 -0
- package/src/sse.ts +64 -0
- package/src/terminal.ts +228 -0
- package/src/token.ts +36 -0
- package/src/toolpreview.ts +54 -0
- package/src/tools.ts +790 -0
- package/src/types.ts +73 -0
- package/src/ui.ts +813 -0
- package/src/usage.ts +186 -0
- package/test/absolute-tools.test.ts +296 -0
- package/test/absolute-ui.test.ts +153 -0
- package/test/adapters.test.ts +205 -0
- package/test/anthropic.test.ts +246 -0
- package/test/auto-ui.test.ts +106 -0
- package/test/chatviewport.test.ts +49 -0
- package/test/client.test.ts +327 -0
- package/test/clipboard.test.ts +63 -0
- package/test/command-input.test.ts +183 -0
- package/test/command-tools.test.ts +141 -0
- package/test/commandpolicy.test.ts +252 -0
- package/test/disk-tools.test.ts +220 -0
- package/test/effort.test.ts +123 -0
- package/test/fixtures/openai-tools.sse +84 -0
- package/test/image-adapters.test.ts +78 -0
- package/test/image-ui.test.ts +151 -0
- package/test/images.test.ts +60 -0
- package/test/loop.test.ts +387 -0
- package/test/m5.test.ts +127 -0
- package/test/markdown.test.ts +201 -0
- package/test/repl-ui.test.ts +293 -0
- package/test/sse.test.ts +74 -0
- package/test/steering-ui.test.ts +182 -0
- package/test/steering.test.ts +68 -0
- package/test/terminal-ui.test.ts +144 -0
- package/test/terminal.test.ts +229 -0
- package/test/toolpreview.test.ts +51 -0
- package/test/tools.test.ts +227 -0
- package/test/ui.test.ts +635 -0
- package/test/usage-footer.test.ts +180 -0
- 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
|
+
});
|
package/test/m5.test.ts
ADDED
|
@@ -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
|
+
});
|