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,327 @@
|
|
|
1
|
+
// M6: RouterClient against a local HTTP server — error envelope, retry policy,
|
|
2
|
+
// Retry-After handling, dialect routing, and the pure helpers. Fully offline.
|
|
3
|
+
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import type { AddressInfo } from 'node:net';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import {
|
|
9
|
+
RouterClient,
|
|
10
|
+
RouterError,
|
|
11
|
+
dialectFor,
|
|
12
|
+
errorMessage,
|
|
13
|
+
parseModelCatalog,
|
|
14
|
+
rootFromBase,
|
|
15
|
+
statusHint,
|
|
16
|
+
routerErrorFromResponse,
|
|
17
|
+
} from '../src/client.ts';
|
|
18
|
+
|
|
19
|
+
const KEY = 'sk-sub-test-0000000000';
|
|
20
|
+
const OPENAI_OK = {
|
|
21
|
+
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
|
|
22
|
+
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
|
23
|
+
};
|
|
24
|
+
const ANTHROPIC_OK = {
|
|
25
|
+
content: [{ type: 'text', text: 'hi' }],
|
|
26
|
+
stop_reason: 'end_turn',
|
|
27
|
+
usage: { input_tokens: 2, output_tokens: 1 },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
interface CannedServer {
|
|
31
|
+
baseUrl: string; // http://127.0.0.1:PORT/v1
|
|
32
|
+
requests: { method: string; url: string; headers: http.IncomingHttpHeaders }[];
|
|
33
|
+
close: () => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type Handler = (req: http.IncomingMessage, res: http.ServerResponse, n: number) => void;
|
|
37
|
+
|
|
38
|
+
async function startServer(handler: Handler): Promise<CannedServer> {
|
|
39
|
+
const requests: CannedServer['requests'] = [];
|
|
40
|
+
const server = http.createServer((req, res) => {
|
|
41
|
+
requests.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers });
|
|
42
|
+
handler(req, res, requests.length);
|
|
43
|
+
});
|
|
44
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
|
45
|
+
const port = (server.address() as AddressInfo).port;
|
|
46
|
+
return {
|
|
47
|
+
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
48
|
+
requests,
|
|
49
|
+
close: () => new Promise((r) => server.close(() => r())),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function send(res: http.ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
|
|
54
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
55
|
+
res.end(typeof body === 'string' ? body : JSON.stringify(body));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function client(baseUrl: string, sleeps?: number[]): { c: RouterClient; sleeps: number[] } {
|
|
59
|
+
const log: number[] = sleeps ?? [];
|
|
60
|
+
return {
|
|
61
|
+
c: new RouterClient({ baseUrl, apiKey: KEY, sleep: (ms) => (log.push(ms), Promise.resolve()) }),
|
|
62
|
+
sleeps: log,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('error envelope + no-retry statuses', () => {
|
|
67
|
+
it('400 maps {"error":{"message"}} to RouterError without retrying', async (t) => {
|
|
68
|
+
const srv = await startServer((_req, res) => send(res, 400, { error: { message: 'bad model' } }));
|
|
69
|
+
t.after(() => srv.close());
|
|
70
|
+
const { c } = client(srv.baseUrl);
|
|
71
|
+
await assert.rejects(
|
|
72
|
+
c.complete({ model: 'gpt-x', dialect: 'openai', messages: [{ role: 'user', content: 'hi' }] }),
|
|
73
|
+
(err: RouterError) => err instanceof RouterError && err.status === 400 && err.message === 'bad model',
|
|
74
|
+
);
|
|
75
|
+
assert.equal(srv.requests.length, 1); // 400 is never retried
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('401 and 403 pass through with one request each', async (t) => {
|
|
79
|
+
let n401 = 0;
|
|
80
|
+
let n403 = 0;
|
|
81
|
+
const srv = await startServer((_req, res, n) => {
|
|
82
|
+
if (n === 1) {
|
|
83
|
+
n401++;
|
|
84
|
+
send(res, 401, { error: { message: 'bad key' } });
|
|
85
|
+
} else {
|
|
86
|
+
n403++;
|
|
87
|
+
send(res, 403, { error: { message: 'not allowlisted' } });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
t.after(() => srv.close());
|
|
91
|
+
const { c } = client(srv.baseUrl);
|
|
92
|
+
const req = { model: 'gpt-x', dialect: 'openai' as const, messages: [{ role: 'user' as const, content: 'hi' }] };
|
|
93
|
+
await assert.rejects(c.complete(req), (err: RouterError) => err.status === 401);
|
|
94
|
+
await assert.rejects(c.complete(req), (err: RouterError) => err.status === 403 && err.message === 'not allowlisted');
|
|
95
|
+
assert.deepEqual([n401, n403, srv.requests.length], [1, 1, 2]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('bounds error text and falls back to the status hint when the body is unreadable', async () => {
|
|
99
|
+
// a parseable message longer than 1000 chars gets bounded()'d
|
|
100
|
+
const err = await routerErrorFromResponse(
|
|
101
|
+
502,
|
|
102
|
+
new Response(JSON.stringify({ error: { message: 'x'.repeat(3000) } }), { status: 502 }),
|
|
103
|
+
);
|
|
104
|
+
assert.equal(err.message.length, 1001); // 1000 chars capped with '…'
|
|
105
|
+
// a body over the 4 KB drain cap truncates the JSON — hint instead of garbage
|
|
106
|
+
const drained = await routerErrorFromResponse(
|
|
107
|
+
502,
|
|
108
|
+
new Response(JSON.stringify({ error: { message: 'x'.repeat(5000) } }), { status: 502 }),
|
|
109
|
+
);
|
|
110
|
+
assert.equal(drained.message, statusHint(502));
|
|
111
|
+
const bare = await routerErrorFromResponse(503, new Response('not json', { status: 503 }));
|
|
112
|
+
assert.equal(bare.message, statusHint(503));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('retry policy', () => {
|
|
117
|
+
const REQ = { model: 'gpt-x', dialect: 'openai' as const, messages: [{ role: 'user' as const, content: 'hi' }] };
|
|
118
|
+
|
|
119
|
+
it('429 with digit Retry-After waits exactly that many seconds and retries once', async (t) => {
|
|
120
|
+
const srv = await startServer((_req, res, n) => {
|
|
121
|
+
if (n === 1) send(res, 429, { error: { message: 'slow down' } }, { 'retry-after': '1' });
|
|
122
|
+
else send(res, 200, OPENAI_OK);
|
|
123
|
+
});
|
|
124
|
+
t.after(() => srv.close());
|
|
125
|
+
const { c, sleeps } = client(srv.baseUrl);
|
|
126
|
+
const turn = await c.complete(REQ);
|
|
127
|
+
assert.equal(turn.message.content, 'ok');
|
|
128
|
+
assert.deepEqual(sleeps, [1000]);
|
|
129
|
+
assert.equal(srv.requests.length, 2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('429 without Retry-After waits the 2s default', async (t) => {
|
|
133
|
+
const srv = await startServer((_req, res, n) => {
|
|
134
|
+
if (n === 1) send(res, 429, { error: { message: 'slow down' } });
|
|
135
|
+
else send(res, 200, OPENAI_OK);
|
|
136
|
+
});
|
|
137
|
+
t.after(() => srv.close());
|
|
138
|
+
const { c, sleeps } = client(srv.baseUrl);
|
|
139
|
+
await c.complete(REQ);
|
|
140
|
+
assert.deepEqual(sleeps, [2000]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('caps Retry-After at 60s and rejects non-digit headers', async () => {
|
|
144
|
+
const capped = await routerErrorFromResponse(
|
|
145
|
+
429,
|
|
146
|
+
new Response(JSON.stringify({ error: { message: 'slow' } }), { status: 429, headers: { 'retry-after': '120' } }),
|
|
147
|
+
);
|
|
148
|
+
assert.equal(capped.retryAfterSeconds, 60);
|
|
149
|
+
assert.match(capped.message, /Retry after 60 seconds\./);
|
|
150
|
+
const dated = await routerErrorFromResponse(
|
|
151
|
+
429,
|
|
152
|
+
new Response(JSON.stringify({ error: { message: 'slow' } }), {
|
|
153
|
+
status: 429,
|
|
154
|
+
headers: { 'retry-after': 'Wed, 21 Oct 2026 07:28:00 GMT' },
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
assert.equal(dated.retryAfterSeconds, undefined);
|
|
158
|
+
assert.doesNotMatch(dated.message, /Retry after/); // delta-seconds only (Swift parity)
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('502 retries once after 1s', async (t) => {
|
|
162
|
+
const srv = await startServer((_req, res, n) => {
|
|
163
|
+
if (n === 1) send(res, 502, { error: { message: 'provider down' } });
|
|
164
|
+
else send(res, 200, OPENAI_OK);
|
|
165
|
+
});
|
|
166
|
+
t.after(() => srv.close());
|
|
167
|
+
const { c, sleeps } = client(srv.baseUrl);
|
|
168
|
+
await c.complete(REQ);
|
|
169
|
+
assert.deepEqual(sleeps, [1000]);
|
|
170
|
+
assert.equal(srv.requests.length, 2);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('network failure (fetch TypeError) retries once', async (t) => {
|
|
174
|
+
const srv = await startServer((req, res, n) => {
|
|
175
|
+
if (n === 1) {
|
|
176
|
+
res.on('error', () => {});
|
|
177
|
+
req.socket.destroy(); // connection reset — undici throws TypeError
|
|
178
|
+
} else send(res, 200, OPENAI_OK);
|
|
179
|
+
});
|
|
180
|
+
t.after(() => srv.close());
|
|
181
|
+
const { c, sleeps } = client(srv.baseUrl);
|
|
182
|
+
const turn = await c.complete(REQ);
|
|
183
|
+
assert.equal(turn.message.content, 'ok');
|
|
184
|
+
assert.deepEqual(sleeps, [1000]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('never retries a user abort', async (t) => {
|
|
188
|
+
const srv = await startServer((_req, res) => send(res, 500, { error: { message: 'x' } }));
|
|
189
|
+
t.after(() => srv.close());
|
|
190
|
+
const { c, sleeps } = client(srv.baseUrl);
|
|
191
|
+
const ac = new AbortController();
|
|
192
|
+
ac.abort(); // pre-aborted: undici rejects before any request is made
|
|
193
|
+
await assert.rejects(c.complete(REQ, ac.signal), (err: unknown) => err instanceof DOMException && err.name === 'AbortError');
|
|
194
|
+
assert.deepEqual(sleeps, []);
|
|
195
|
+
assert.equal(srv.requests.length, 0);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('streaming chat', () => {
|
|
200
|
+
it('a truncated stream is retried only when nothing was emitted', async (t) => {
|
|
201
|
+
const srv = await startServer((_req, res, n) => {
|
|
202
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
203
|
+
if (n === 1) {
|
|
204
|
+
// one text frame, then the stream dies without [DONE] — but text was emitted
|
|
205
|
+
res.write('data: {"choices":[{"delta":{"content":"partial"}}]}\n\n');
|
|
206
|
+
res.end();
|
|
207
|
+
} else {
|
|
208
|
+
res.write(
|
|
209
|
+
'data: {"choices":[{"delta":{"content":"full"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}\n\n',
|
|
210
|
+
);
|
|
211
|
+
res.write('data: [DONE]\n\n');
|
|
212
|
+
res.end();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
t.after(() => srv.close());
|
|
216
|
+
const { c } = client(srv.baseUrl);
|
|
217
|
+
const events: string[] = [];
|
|
218
|
+
await assert.rejects(
|
|
219
|
+
c.chat({ model: 'gpt-x', dialect: 'openai', messages: [{ role: 'user', content: 'hi' }] }, (e) => events.push(e.kind)),
|
|
220
|
+
(err: RouterError) => err.message === 'The router closed the response before it finished.',
|
|
221
|
+
);
|
|
222
|
+
assert.deepEqual(events, ['text']); // partial text already streamed — a retry would duplicate it
|
|
223
|
+
assert.equal(srv.requests.length, 1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('an empty-then-closed stream retries and succeeds', async (t) => {
|
|
227
|
+
const srv = await startServer((_req, res, n) => {
|
|
228
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
229
|
+
if (n === 1) {
|
|
230
|
+
res.end(); // nothing emitted
|
|
231
|
+
} else {
|
|
232
|
+
res.write('data: {"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n');
|
|
233
|
+
res.end();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
t.after(() => srv.close());
|
|
237
|
+
const { c, sleeps } = client(srv.baseUrl);
|
|
238
|
+
const turn = await c.chat(
|
|
239
|
+
{ model: 'gpt-x', dialect: 'openai', messages: [{ role: 'user', content: 'hi' }] },
|
|
240
|
+
() => {},
|
|
241
|
+
);
|
|
242
|
+
assert.equal(turn.message.content, 'hi');
|
|
243
|
+
assert.equal(turn.stopReason, 'stop');
|
|
244
|
+
assert.deepEqual(sleeps, [1000]);
|
|
245
|
+
assert.equal(srv.requests.length, 2);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('dialect routing + wire translation', () => {
|
|
250
|
+
it('anthropic dialect POSTs /v1/messages off the bare root with both auth headers', async (t) => {
|
|
251
|
+
const srv = await startServer((_req, res) => send(res, 200, ANTHROPIC_OK));
|
|
252
|
+
t.after(() => srv.close());
|
|
253
|
+
const { c } = client(srv.baseUrl);
|
|
254
|
+
const turn = await c.complete({
|
|
255
|
+
model: 'anthropic/fake',
|
|
256
|
+
dialect: 'anthropic',
|
|
257
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
258
|
+
system: 'be brief',
|
|
259
|
+
});
|
|
260
|
+
assert.equal(turn.message.content, 'hi');
|
|
261
|
+
assert.equal(turn.stopReason, 'stop');
|
|
262
|
+
assert.equal(turn.usage?.inputTokens, 2);
|
|
263
|
+
const [r] = srv.requests;
|
|
264
|
+
assert.equal(r.url, '/v1/messages'); // /v1 stripped from the base, anthropic endpoint appended
|
|
265
|
+
assert.equal(r.headers.authorization, `Bearer ${KEY}`);
|
|
266
|
+
assert.equal(r.headers['x-api-key'], KEY);
|
|
267
|
+
assert.equal(r.headers['anthropic-version'], '2023-06-01');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('openai dialect POSTs /v1/chat/completions', async (t) => {
|
|
271
|
+
const srv = await startServer((_req, res) => send(res, 200, OPENAI_OK));
|
|
272
|
+
t.after(() => srv.close());
|
|
273
|
+
const { c } = client(srv.baseUrl);
|
|
274
|
+
await c.complete({ model: 'gpt-x', dialect: 'openai', messages: [{ role: 'user', content: 'hi' }] });
|
|
275
|
+
assert.equal(srv.requests[0].url, '/v1/chat/completions');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('pure helpers', () => {
|
|
280
|
+
it('errorMessage turns bare network failures into actionable copy', () => {
|
|
281
|
+
assert.equal(errorMessage(new TypeError('fetch failed')), 'Network error — could not reach the router.');
|
|
282
|
+
assert.equal(errorMessage(new RouterError('bad model')), 'bad model');
|
|
283
|
+
assert.equal(errorMessage('plain'), 'plain');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('statusHint maps every status', () => {
|
|
287
|
+
assert.match(statusHint(400), /model ID/);
|
|
288
|
+
assert.match(statusHint(401), /SUBROUTER_API_KEY/);
|
|
289
|
+
assert.match(statusHint(403), /allowlist/);
|
|
290
|
+
assert.match(statusHint(429), /\/usage/);
|
|
291
|
+
assert.match(statusHint(502), /provider is disconnected/);
|
|
292
|
+
assert.equal(statusHint(999), 'The router could not complete the request.');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('dialectFor: provider prefix wins, bare ids resolve, unknowns error', () => {
|
|
296
|
+
assert.equal(dialectFor('anthropic/claude-fake'), 'anthropic');
|
|
297
|
+
assert.equal(dialectFor('openai/claude-proxy'), 'openai'); // prefix wins, never silently rerouted
|
|
298
|
+
assert.equal(dialectFor('grok/grok-5'), 'openai');
|
|
299
|
+
assert.equal(dialectFor('claude-opus-5-5'), 'anthropic');
|
|
300
|
+
assert.equal(dialectFor('gpt-5'), 'openai');
|
|
301
|
+
assert.equal(dialectFor('deepseek-v4-pro'), 'openai');
|
|
302
|
+
assert.throws(() => dialectFor('opencode-gl'), /provider-prefixed id/);
|
|
303
|
+
assert.throws(() => dialectFor(''), /empty/);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('rootFromBase strips /v1 and trailing slashes', () => {
|
|
307
|
+
assert.equal(rootFromBase('https://router.eva.pink/v1'), 'https://router.eva.pink');
|
|
308
|
+
assert.equal(rootFromBase('https://router.eva.pink/v1/'), 'https://router.eva.pink');
|
|
309
|
+
assert.equal(rootFromBase('https://router.eva.pink'), 'https://router.eva.pink');
|
|
310
|
+
assert.equal(rootFromBase('http://127.0.0.1:9/v1'), 'http://127.0.0.1:9');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('parseModelCatalog accepts both documented shapes and string entries', () => {
|
|
314
|
+
const fromData = parseModelCatalog({ data: [{ id: 'grok/a' }, 'deepseek/b'] });
|
|
315
|
+
assert.ok(fromData);
|
|
316
|
+
assert.deepEqual(fromData.map((e) => e.id), ['grok/a', 'deepseek/b']);
|
|
317
|
+
const models = parseModelCatalog({
|
|
318
|
+
models: [{ name: 'x', context_length: 131072, max_output_tokens: 8192 }],
|
|
319
|
+
});
|
|
320
|
+
assert.ok(models);
|
|
321
|
+
assert.equal(models[0].id, 'x');
|
|
322
|
+
assert.equal(models[0].contextLength, 131072);
|
|
323
|
+
assert.equal(models[0].maxOutputTokens, 8192);
|
|
324
|
+
assert.equal(parseModelCatalog({}), null);
|
|
325
|
+
assert.equal(parseModelCatalog('garbage'), null);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { CLIPBOARD_SCRIPT, decodeClipboardImage, readClipboardImage } from '../src/clipboard.ts';
|
|
5
|
+
import { MAX_IMAGE_BYTES } from '../src/images.ts';
|
|
6
|
+
|
|
7
|
+
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWVAAAAAASUVORK5CYII=';
|
|
8
|
+
|
|
9
|
+
test('clipboard payload becomes the existing image attachment type', () => {
|
|
10
|
+
assert.deepEqual(decodeClipboardImage(JSON.stringify({ data: png })), {
|
|
11
|
+
name: 'clipboard.png', mediaType: 'image/png', data: png,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('empty, malformed, unsupported and oversized clipboard data fail without revealing content', () => {
|
|
16
|
+
assert.throws(() => decodeClipboardImage('{"error":"empty"}'), /no image in the clipboard/);
|
|
17
|
+
assert.throws(() => decodeClipboardImage('{"error":"large"}'), /10 MiB/);
|
|
18
|
+
assert.throws(() => decodeClipboardImage('{"error":"dimensions"}'), /smaller screenshot/);
|
|
19
|
+
for (const payload of ['private text', 'null', '"secret"', '{"data":"???"}', '{"data":"aQ"}', '{"error":"private text"}']) {
|
|
20
|
+
assert.throws(() => decodeClipboardImage(payload), (err: unknown) => {
|
|
21
|
+
assert.ok(err instanceof Error);
|
|
22
|
+
assert.match(err.message, /could not read/);
|
|
23
|
+
assert.ok(!err.message.includes(payload));
|
|
24
|
+
return true;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
assert.throws(() => decodeClipboardImage(JSON.stringify({ data: Buffer.from('not an image').toString('base64') })), /unsupported image/);
|
|
28
|
+
const large = Buffer.alloc(MAX_IMAGE_BYTES + 1).toString('base64');
|
|
29
|
+
assert.throws(() => decodeClipboardImage(JSON.stringify({ data: large })), /10 MiB/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('an already cancelled clipboard request does not spawn the native reader', async () => {
|
|
33
|
+
await assert.rejects(readClipboardImage(AbortSignal.abort()), { name: 'AbortError' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// These exercise AppKit against synthetic private pasteboards only. Never read or
|
|
37
|
+
// replace the person's general clipboard, and never contact the router.
|
|
38
|
+
for (const format of ['png', 'tiff', 'empty'] as const) {
|
|
39
|
+
test(`native macOS reader handles ${format} on an isolated pasteboard`, { skip: process.platform !== 'darwin' }, () => {
|
|
40
|
+
let setup = 'const board = $.NSPasteboard.pasteboardWithUniqueName; board.clearContents;';
|
|
41
|
+
if (format !== 'empty') {
|
|
42
|
+
setup += format === 'png'
|
|
43
|
+
? `board.setDataForType($.NSData.alloc.initWithBase64EncodedStringOptions('${png}', 0), $.NSPasteboardTypePNG);`
|
|
44
|
+
: `const fixture = $.NSBitmapImageRep.alloc.initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBytesPerRowBitsPerPixel(null, 1, 1, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0);
|
|
45
|
+
fixture.setColorAtXY($.NSColor.redColor, 0, 0);
|
|
46
|
+
board.setDataForType(fixture.TIFFRepresentation, $.NSPasteboardTypeTIFF);`;
|
|
47
|
+
}
|
|
48
|
+
const script = CLIPBOARD_SCRIPT
|
|
49
|
+
.replace('const board = $.NSPasteboard.generalPasteboard;', setup)
|
|
50
|
+
.replace('return encode(png);', '{ const output = encode(png); board.releaseGlobally; return output; }')
|
|
51
|
+
.replace("return JSON.stringify({ error: 'empty' });", "{ board.releaseGlobally; return JSON.stringify({ error: 'empty' }); }")
|
|
52
|
+
.replace('return encode(data);', 'const output = encode(data); board.releaseGlobally; return output;');
|
|
53
|
+
const child = spawnSync('/usr/bin/osascript', ['-l', 'JavaScript', '-e', script], { encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024 });
|
|
54
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
55
|
+
if (format === 'empty') assert.throws(() => decodeClipboardImage(child.stdout), /no image/);
|
|
56
|
+
else {
|
|
57
|
+
const image = decodeClipboardImage(child.stdout);
|
|
58
|
+
assert.equal(image.mediaType, 'image/png');
|
|
59
|
+
if (format === 'png') assert.equal(image.data, png);
|
|
60
|
+
else assert.ok(Buffer.from(image.data, 'base64').length > 8);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import { matchingCommands } from '../src/commands.ts';
|
|
5
|
+
|
|
6
|
+
function exercise(script: string): void {
|
|
7
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
10
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
11
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24, configurable: true });
|
|
12
|
+
Object.defineProperty(process.stdout, 'columns', { value: 80 });
|
|
13
|
+
process.stdin.setRawMode = () => {};
|
|
14
|
+
const { ui } = await import('./src/ui.ts');
|
|
15
|
+
const { LineInput } = await import('./src/lineinput.ts');
|
|
16
|
+
const submitted = [];
|
|
17
|
+
let selected = '';
|
|
18
|
+
let panel = [];
|
|
19
|
+
const menu = ui.menu.bind(ui);
|
|
20
|
+
ui.menu = (items, index, ...rest) => { selected = items[index]; return menu(items, index, ...rest); };
|
|
21
|
+
const showPanel = ui.panel.bind(ui);
|
|
22
|
+
ui.panel = (lines, ...rest) => { panel = lines; return showPanel(lines, ...rest); };
|
|
23
|
+
let pastedImages = 0, clearedImages = 0, interrupts = 0;
|
|
24
|
+
const input = new LineInput({ onLine: (line) => submitted.push(line), onInterrupt: () => { interrupts++; }, onEof: () => {},
|
|
25
|
+
onPasteImage: () => { pastedImages++; }, onClearImages: () => { clearedImages++; } });
|
|
26
|
+
ui.welcome('openai/test', '/tmp/project');
|
|
27
|
+
input.setModel('openai/test');
|
|
28
|
+
input.start();
|
|
29
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
30
|
+
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
try { ${script} } finally { input.stop(); }
|
|
32
|
+
`], { cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 15000 });
|
|
33
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('command matching filters prefixes and leaves argument input alone', () => {
|
|
37
|
+
assert.deepEqual(matchingCommands('/MO').map((c) => c.id), ['/model', '/models', '/mode']);
|
|
38
|
+
assert.equal(matchingCommands('/mode')[0].id, '/mode');
|
|
39
|
+
assert.equal(matchingCommands('/model')[0].id, '/model');
|
|
40
|
+
assert.equal(matchingCommands('/').length, 7);
|
|
41
|
+
for (const removed of ['/attach', '/detach', '/paste', '/help']) assert.deepEqual(matchingCommands(removed), []);
|
|
42
|
+
for (const text of ['hello', '/model openai/test', '/unknown']) assert.deepEqual(matchingCommands(text), []);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('slash palette supports fragmented arrows, Tab completion and Enter execution', () => {
|
|
46
|
+
exercise(String.raw`
|
|
47
|
+
key('/mo');
|
|
48
|
+
assert.match(selected, /^\/model /);
|
|
49
|
+
key('\x1b'); key('['); key('B');
|
|
50
|
+
await wait(130);
|
|
51
|
+
assert.match(selected, /^\/models /);
|
|
52
|
+
key('\t'); key('\r');
|
|
53
|
+
assert.deepEqual(submitted, ['/models ']);
|
|
54
|
+
key('/'); key('\x1b[B'); key('\x1b[B'); key('\r');
|
|
55
|
+
assert.deepEqual(submitted, ['/models ', '/usage']);
|
|
56
|
+
key('/model openai/direct\r');
|
|
57
|
+
assert.equal(submitted.at(-1), '/model openai/direct');
|
|
58
|
+
`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('Escape dismisses the palette until text changes, without losing typed input', () => {
|
|
62
|
+
exercise(String.raw`
|
|
63
|
+
key('/mo'); key('\x1b'); await wait(130);
|
|
64
|
+
key('\r');
|
|
65
|
+
assert.deepEqual(submitted, ['/mo']);
|
|
66
|
+
key('/mo'); key('\x1b'); await wait(130);
|
|
67
|
+
key('d');
|
|
68
|
+
assert.match(selected, /^\/model /);
|
|
69
|
+
key('\r');
|
|
70
|
+
assert.equal(submitted.at(-1), '/model');
|
|
71
|
+
`);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('usage view supports fragmented arrows, refresh and dismissal', () => {
|
|
75
|
+
exercise(String.raw`
|
|
76
|
+
let done = false;
|
|
77
|
+
let refreshes = 0;
|
|
78
|
+
const view = input.view(['initial usage'], async () => ['refreshed ' + ++refreshes]).then(() => { done = true; });
|
|
79
|
+
key('\x1b'); key('['); key('B');
|
|
80
|
+
await wait(130);
|
|
81
|
+
assert.equal(done, false);
|
|
82
|
+
key('r'); await wait();
|
|
83
|
+
assert.deepEqual(panel, ['refreshed 1']);
|
|
84
|
+
key('\x1b'); await wait(130);
|
|
85
|
+
await view;
|
|
86
|
+
assert.equal(done, true);
|
|
87
|
+
input.redraw(); key('hello\r');
|
|
88
|
+
assert.equal(submitted.at(-1), 'hello');
|
|
89
|
+
`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('wheel and Page keys scroll chat even while input is paused, without inserting mouse bytes', () => {
|
|
93
|
+
exercise(String.raw`
|
|
94
|
+
const scrolls = [];
|
|
95
|
+
ui.scrollChat = (delta) => scrolls.push(delta);
|
|
96
|
+
key('\x1b[<64;'); await wait(25); key('10;5M');
|
|
97
|
+
key('\x1b[<0;10;5M'); key('\x1b[<0;10;5m');
|
|
98
|
+
key('hello\r');
|
|
99
|
+
assert.deepEqual(submitted, ['hello']);
|
|
100
|
+
input.pause();
|
|
101
|
+
key('\x1b[5'); await wait(25); key('~');
|
|
102
|
+
key('\x1b[<65;10;5M'); key('\x1b[6~');
|
|
103
|
+
assert.deepEqual(scrolls, [3, 8, -3, -8]);
|
|
104
|
+
input.resume(); key('next\r');
|
|
105
|
+
assert.deepEqual(submitted, ['hello', 'next']);
|
|
106
|
+
`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('scroll keys navigate active controls instead of conversation history', () => {
|
|
110
|
+
exercise(String.raw`
|
|
111
|
+
ui.scrollChat = () => { throw new Error('modal must own navigation'); };
|
|
112
|
+
key('/'); key('\x1b[<65;10;22M'); key('\r');
|
|
113
|
+
assert.deepEqual(submitted, ['/models']);
|
|
114
|
+
const picker = input.select(['one', 'two', 'three', 'four']);
|
|
115
|
+
key('\x1b[6~'); key('\r');
|
|
116
|
+
assert.equal(await picker, 3);
|
|
117
|
+
let panelScroll = 0;
|
|
118
|
+
ui.scrollPanel = (delta) => { panelScroll += delta; };
|
|
119
|
+
const view = input.view(['usage'], async () => ['usage']);
|
|
120
|
+
key('\x1b[<65;10;22M'); key('\x1b[5~'); key('\r');
|
|
121
|
+
await view;
|
|
122
|
+
assert.equal(panelScroll, -5);
|
|
123
|
+
`);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('navigation decoding preserves bracketed paste with fragmented end markers', () => {
|
|
127
|
+
exercise(String.raw`
|
|
128
|
+
key('\x1b[200~hello\nworld\n\x1b[20'); key('1~');
|
|
129
|
+
assert.deepEqual(submitted, ['hello world']);
|
|
130
|
+
key('\x1b[200~plain paste\x1b[201~'); key('\r');
|
|
131
|
+
assert.deepEqual(submitted, ['hello world', 'plain paste']);
|
|
132
|
+
`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('image shortcuts preserve drafts and do not fire inside paste, questions, panels or paused input', () => {
|
|
136
|
+
exercise(String.raw`
|
|
137
|
+
key('draft'); key('\x16'); key('\x18'); key('\r');
|
|
138
|
+
assert.deepEqual(submitted, ['draft']);
|
|
139
|
+
assert.equal(pastedImages, 1); assert.equal(clearedImages, 1);
|
|
140
|
+
key('\x1b[200~text\x16\x18\x1b[201~'); key('\x15');
|
|
141
|
+
input.pause(); key('\x16\x18'); input.resume();
|
|
142
|
+
const question = input.question('allow?'); key('\x16\x18n\r'); await question;
|
|
143
|
+
const picker = input.select(['one']); key('\x16\x18\r'); await picker;
|
|
144
|
+
const panel = input.view(['usage'], async () => ['usage']); key('\x16\x18\r'); await panel;
|
|
145
|
+
assert.equal(pastedImages, 1); assert.equal(clearedImages, 1);
|
|
146
|
+
`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('working editor preserves drafts through approvals and interruption without printing into the stream', () => {
|
|
150
|
+
exercise(String.raw`
|
|
151
|
+
const printed = [];
|
|
152
|
+
const submit = ui.submitInput.bind(ui);
|
|
153
|
+
ui.submitInput = (prompt, text) => { printed.push(text); submit(prompt, text); };
|
|
154
|
+
input.setWorking(true);
|
|
155
|
+
key('steer this');
|
|
156
|
+
const approval = input.question('allow?');
|
|
157
|
+
key('n\r'); assert.equal(await approval, 'n');
|
|
158
|
+
key('\r');
|
|
159
|
+
assert.deepEqual(submitted, ['steer this']);
|
|
160
|
+
assert.deepEqual(printed, ['n']); // draft is queued, not mixed into the streaming answer
|
|
161
|
+
key('unfinished'); key('\x03');
|
|
162
|
+
assert.equal(interrupts, 1);
|
|
163
|
+
input.setWorking(false); key('\r');
|
|
164
|
+
assert.equal(submitted.at(-1), 'unfinished');
|
|
165
|
+
key('saved draft'); input.setWorking(true);
|
|
166
|
+
const cancelled = input.question('allow?'); key('\x03');
|
|
167
|
+
assert.equal(await cancelled, 'n');
|
|
168
|
+
assert.equal(interrupts, 2);
|
|
169
|
+
input.setWorking(false); key('\r');
|
|
170
|
+
assert.equal(submitted.at(-1), 'saved draft');
|
|
171
|
+
input.restoreDraft(['queued one', 'queued two']); key('\r');
|
|
172
|
+
assert.equal(submitted.at(-1), 'queued one queued two');
|
|
173
|
+
`);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('shrinking to a tiny terminal cancels the live usage view', () => {
|
|
177
|
+
exercise(String.raw`
|
|
178
|
+
const view = input.view(['usage'], async () => ['usage']);
|
|
179
|
+
Object.defineProperty(process.stdout, 'rows', { value: 8, configurable: true });
|
|
180
|
+
process.stdout.emit('resize');
|
|
181
|
+
await view;
|
|
182
|
+
`);
|
|
183
|
+
});
|