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,201 @@
|
|
|
1
|
+
// Markdown renderer. Colors are gated on process.stdout.isTTY, so we fake a TTY before
|
|
2
|
+
// the module's first import (node --test isolates each file in its own process, so the
|
|
3
|
+
// dynamic import here is the module's only load) and assert real ANSI sequences.
|
|
4
|
+
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
|
|
8
|
+
test('renderMarkdown styles headings, bold, code, lists and drops link URLs', async () => {
|
|
9
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
10
|
+
const { renderMarkdown } = await import('../src/markdown.ts');
|
|
11
|
+
|
|
12
|
+
const md = [
|
|
13
|
+
'# Title',
|
|
14
|
+
'',
|
|
15
|
+
'Some **bold** and `code` and [a link](https://x.example/path).',
|
|
16
|
+
'',
|
|
17
|
+
'```ts',
|
|
18
|
+
'const x = 1;',
|
|
19
|
+
'```',
|
|
20
|
+
'',
|
|
21
|
+
'- item',
|
|
22
|
+
'1. first',
|
|
23
|
+
'',
|
|
24
|
+
'- [x] done',
|
|
25
|
+
].join('\n');
|
|
26
|
+
|
|
27
|
+
const out = renderMarkdown(md);
|
|
28
|
+
|
|
29
|
+
assert.match(out, /\x1b\[1m# Title\x1b\[0m/); // heading is bold
|
|
30
|
+
assert.match(out, /\x1b\[1mbold\x1b\[0m/); // **bold**
|
|
31
|
+
assert.match(out, /\x1b\[38;2;166;116;255m\x1b\[1m# Title/); // violet heading
|
|
32
|
+
assert.match(out, /\x1b\[38;2;143;167;255mcode\x1b\[0m/); // blue code span
|
|
33
|
+
assert.match(out, /\x1b\[38;2;143;167;255mconst x = 1;\x1b\[0m/); // blue fenced block
|
|
34
|
+
assert.match(out, /\x1b\[38;2;143;167;255m- \x1b\[0mitem/); // blue bullet
|
|
35
|
+
assert.match(out, /\x1b\[38;2;143;167;255m1\. \x1b\[0mfirst/); // blue number
|
|
36
|
+
assert.match(out, /\x1b\[92m☒ \x1b\[0mdone/); // task checkbox
|
|
37
|
+
assert.ok(out.includes('a link')); // link text kept…
|
|
38
|
+
assert.ok(!out.includes('https://x.example/path')); // …URL dropped
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('streamed replies trim boundary gaps and collapse prose spacing without changing code blocks', async () => {
|
|
42
|
+
const { MarkdownStream } = await import('../src/markdown.ts');
|
|
43
|
+
const { ui } = await import('../src/ui.ts');
|
|
44
|
+
const { stripVTControlCharacters } = await import('node:util');
|
|
45
|
+
const output: string[] = [];
|
|
46
|
+
const say = ui.say, streamText = ui.streamText;
|
|
47
|
+
ui.say = (line) => { output.push(line + '\n'); };
|
|
48
|
+
ui.streamText = (text) => { output.push(text); };
|
|
49
|
+
try {
|
|
50
|
+
const md = new MarkdownStream();
|
|
51
|
+
md.write('\n'); md.write('\nHi.\n\n\n\nParagraph.\n\n'); md.flush();
|
|
52
|
+
assert.equal(stripVTControlCharacters(output.join('')), 'Hi.\n\nParagraph.\n');
|
|
53
|
+
output.length = 0;
|
|
54
|
+
const code = new MarkdownStream();
|
|
55
|
+
code.write('\n\n```ts\nconst first = 1;\n\n\nconst second = 2;\n```\n\n\n');
|
|
56
|
+
code.flush();
|
|
57
|
+
assert.equal(stripVTControlCharacters(output.join('')), '```ts\nconst first = 1;\n\n\nconst second = 2;\n```\n');
|
|
58
|
+
} finally {
|
|
59
|
+
ui.say = say;
|
|
60
|
+
ui.streamText = streamText;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('partial prose is appended immediately without repainting or duplicate flush output', async () => {
|
|
65
|
+
const { MarkdownStream } = await import('../src/markdown.ts');
|
|
66
|
+
const { ui } = await import('../src/ui.ts');
|
|
67
|
+
const output: string[] = [];
|
|
68
|
+
const say = ui.say, streamText = ui.streamText, rewrite = ui.rewrite;
|
|
69
|
+
ui.say = (line) => { output.push(line + '\n'); };
|
|
70
|
+
ui.streamText = (text) => { output.push(text); };
|
|
71
|
+
ui.rewrite = () => { assert.fail('streaming Markdown must not repaint wrapped text'); };
|
|
72
|
+
try {
|
|
73
|
+
const md = new MarkdownStream();
|
|
74
|
+
md.write('The image shows ');
|
|
75
|
+
assert.equal(output.join(''), 'The image shows '); // no newline or flush
|
|
76
|
+
const long = 'a wide scene '.repeat(30);
|
|
77
|
+
md.write(long);
|
|
78
|
+
assert.equal(output.join(''), 'The image shows ' + long);
|
|
79
|
+
md.write('and follows the steering request.');
|
|
80
|
+
const visible = output.join('');
|
|
81
|
+
md.flush(); md.flush();
|
|
82
|
+
assert.equal(output.join(''), visible);
|
|
83
|
+
assert.ok(!visible.includes('\n')); // ui.endTurn() owns the trailing newline
|
|
84
|
+
} finally {
|
|
85
|
+
ui.say = say;
|
|
86
|
+
ui.streamText = streamText;
|
|
87
|
+
ui.rewrite = rewrite;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('chunked inline styles and fenced code remain styled while their content streams', async () => {
|
|
92
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
93
|
+
const { MarkdownStream } = await import('../src/markdown.ts');
|
|
94
|
+
const { ui, accent, bold, dim } = await import('../src/ui.ts');
|
|
95
|
+
const { stripVTControlCharacters: strip } = await import('node:util');
|
|
96
|
+
const output: string[] = [];
|
|
97
|
+
const say = ui.say, streamText = ui.streamText;
|
|
98
|
+
ui.say = (line) => { output.push(line + '\n'); };
|
|
99
|
+
ui.streamText = (text) => { output.push(text); };
|
|
100
|
+
try {
|
|
101
|
+
const md = new MarkdownStream();
|
|
102
|
+
md.write('Some *');
|
|
103
|
+
assert.equal(strip(output.join('')), 'Some ');
|
|
104
|
+
md.write('*bo');
|
|
105
|
+
assert.ok(output.join('').includes(bold('bo')));
|
|
106
|
+
md.write('ld*'); md.write('* and `co');
|
|
107
|
+
assert.ok(output.join('').includes(accent('co')));
|
|
108
|
+
md.write('de` and [a li'); md.write('nk](https://x.example'); md.write('/path).\n');
|
|
109
|
+
assert.equal(strip(output.join('')), 'Some bold and code and a link.\n');
|
|
110
|
+
assert.ok(!output.join('').includes('https://'));
|
|
111
|
+
assert.ok(output.join('').includes(accent('a link')));
|
|
112
|
+
|
|
113
|
+
output.length = 0;
|
|
114
|
+
const code = new MarkdownStream();
|
|
115
|
+
code.write('`'); code.write('`'); code.write('`ts\nconst ');
|
|
116
|
+
assert.equal(strip(output.join('')), '```ts\nconst ');
|
|
117
|
+
assert.ok(output.join('').includes(accent('const ')));
|
|
118
|
+
code.write('x = **literal**;\n\n \n# literal\n`'); code.write('``');
|
|
119
|
+
code.write('\nAfter **bo'); code.write('ld**'); code.flush();
|
|
120
|
+
assert.equal(strip(output.join('')), '```ts\nconst x = **literal**;\n\n \n# literal\n```\nAfter bold');
|
|
121
|
+
assert.ok(output.join('').includes(accent('x = **literal**;')));
|
|
122
|
+
assert.ok(output.join('').includes(accent('# literal')));
|
|
123
|
+
assert.ok(output.join('').includes(dim('```')));
|
|
124
|
+
assert.ok(output.join('').includes(bold('bo'))); // normal Markdown resumes after code
|
|
125
|
+
|
|
126
|
+
output.length = 0;
|
|
127
|
+
const fallback = new MarkdownStream();
|
|
128
|
+
const bracket = '[' + 'ordinary bracketed prose '.repeat(20);
|
|
129
|
+
fallback.write(bracket);
|
|
130
|
+
assert.equal(strip(output.join('')), bracket); // lookahead cannot swallow a long reply
|
|
131
|
+
fallback.write(' **still'); fallback.write(' styled**\nUnclosed **bold\nNormal `code`');
|
|
132
|
+
fallback.flush();
|
|
133
|
+
assert.equal(strip(output.join('')), bracket + ' still styled\nUnclosed bold\nNormal code');
|
|
134
|
+
assert.ok(output.join('').includes(bold('still')));
|
|
135
|
+
assert.ok(output.join('').includes(accent('code'))); // fallback and line end reset styles
|
|
136
|
+
} finally {
|
|
137
|
+
ui.say = say;
|
|
138
|
+
ui.streamText = streamText;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('chunked line prefixes retain headings, list markers and task checkboxes', async () => {
|
|
143
|
+
const { MarkdownStream } = await import('../src/markdown.ts');
|
|
144
|
+
const { ui, accent, violet, bold, green, dim } = await import('../src/ui.ts');
|
|
145
|
+
const { stripVTControlCharacters: strip } = await import('node:util');
|
|
146
|
+
const output: string[] = [];
|
|
147
|
+
const say = ui.say, streamText = ui.streamText;
|
|
148
|
+
ui.say = (line) => { output.push(line + '\n'); };
|
|
149
|
+
ui.streamText = (text) => { output.push(text); };
|
|
150
|
+
try {
|
|
151
|
+
const md = new MarkdownStream();
|
|
152
|
+
for (const chunk of ['#', ' Title\n-', ' item\n1', '. first\n- [', 'x', '] done\n>', ' quote']) md.write(chunk);
|
|
153
|
+
md.flush();
|
|
154
|
+
const out = output.join('');
|
|
155
|
+
assert.equal(strip(out), '# Title\n- item\n1. first\n☒ done\n> quote');
|
|
156
|
+
assert.ok(out.includes(violet(bold('# Title'))));
|
|
157
|
+
assert.ok(out.includes(accent('- ')));
|
|
158
|
+
assert.ok(out.includes(accent('1. ')));
|
|
159
|
+
assert.ok(out.includes(green('☒ ')));
|
|
160
|
+
assert.ok(out.includes(dim(' quote')));
|
|
161
|
+
} finally {
|
|
162
|
+
ui.say = say;
|
|
163
|
+
ui.streamText = streamText;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('partial prose defers blank gaps until content and drops whitespace-only tails', async () => {
|
|
168
|
+
const { MarkdownStream } = await import('../src/markdown.ts');
|
|
169
|
+
const { ui } = await import('../src/ui.ts');
|
|
170
|
+
const output: string[] = [];
|
|
171
|
+
const say = ui.say, streamText = ui.streamText;
|
|
172
|
+
ui.say = (line) => { output.push(line + '\n'); };
|
|
173
|
+
ui.streamText = (text) => { output.push(text); };
|
|
174
|
+
try {
|
|
175
|
+
const md = new MarkdownStream();
|
|
176
|
+
md.write(' \n\t\n ');
|
|
177
|
+
assert.equal(output.join(''), '');
|
|
178
|
+
md.write('Hello');
|
|
179
|
+
assert.equal(output.join(''), ' Hello');
|
|
180
|
+
md.write('\n\n \n\t\n ');
|
|
181
|
+
assert.equal(output.join(''), ' Hello\n');
|
|
182
|
+
md.write('Next');
|
|
183
|
+
assert.equal(output.join(''), ' Hello\n\n Next');
|
|
184
|
+
md.write('\n\n\n '); md.flush();
|
|
185
|
+
assert.equal(output.join(''), ' Hello\n\n Next\n');
|
|
186
|
+
} finally {
|
|
187
|
+
ui.say = say;
|
|
188
|
+
ui.streamText = streamText;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('code fences protect their content from heading/list parsing', async () => {
|
|
193
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
194
|
+
const { renderMarkdown } = await import('../src/markdown.ts');
|
|
195
|
+
|
|
196
|
+
const out = renderMarkdown('```\n# not a heading\n- not a list\n```');
|
|
197
|
+
// Inner lines use the code accent, not heading/bullet styles.
|
|
198
|
+
assert.ok(!/\x1b\[1m# not a heading/.test(out));
|
|
199
|
+
assert.ok(!out.includes('\x1b[38;2;166;116;255m'));
|
|
200
|
+
assert.match(out, /\x1b\[38;2;143;167;255m# not a heading\x1b\[0m/);
|
|
201
|
+
});
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
8
|
+
|
|
9
|
+
test('REPL panel follows model/provider, hides reasoning and restores the terminal', (t) => {
|
|
10
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-repl-ui-'));
|
|
11
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
12
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
|
13
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
14
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
15
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
16
|
+
Object.defineProperty(process.stdout, 'columns', { value: 100 });
|
|
17
|
+
process.stdin.setRawMode = () => {};
|
|
18
|
+
const { startRepl } = await import('./src/repl.ts');
|
|
19
|
+
const client = {
|
|
20
|
+
listModels: async () => [
|
|
21
|
+
{ id: 'openai/first', contextLength: 32000 },
|
|
22
|
+
{ id: 'deepseek/second', contextLength: 64000 },
|
|
23
|
+
],
|
|
24
|
+
usage: async () => ({ user: { rpm_limit: 60, today_tokens: 1000, daily_token_limit: 1000000, week_usd: 2, weekly_usd_limit: 10 }, pools: [
|
|
25
|
+
{ provider: 'openai', window: '5h', used_percent: 30 },
|
|
26
|
+
{ provider: 'deepseek', window: '5h', used_percent: 80 },
|
|
27
|
+
] }),
|
|
28
|
+
chat: async (req, onEvent) => {
|
|
29
|
+
onEvent({ kind: 'reasoning', text: 'PRIVATE_REASONING_SHOULD_NOT_APPEAR' });
|
|
30
|
+
onEvent({ kind: 'text', text: 'Hello **world**.\\n' });
|
|
31
|
+
return {
|
|
32
|
+
message: { role: 'assistant', content: 'Hello **world**.\\n' },
|
|
33
|
+
stopReason: 'stop', usage: { inputTokens: 12, outputTokens: 7 },
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
startRepl({ client, model: 'openai/first', config: {
|
|
38
|
+
maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: false,
|
|
39
|
+
} });
|
|
40
|
+
const settle = async () => {
|
|
41
|
+
await new Promise(setImmediate);
|
|
42
|
+
await new Promise(setImmediate);
|
|
43
|
+
};
|
|
44
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
45
|
+
await settle();
|
|
46
|
+
key('/model\\r');
|
|
47
|
+
await settle();
|
|
48
|
+
key('\\x1b[B\\r');
|
|
49
|
+
await settle();
|
|
50
|
+
key('hello\\r');
|
|
51
|
+
await settle();
|
|
52
|
+
key('/exit\\r');
|
|
53
|
+
await settle();
|
|
54
|
+
`], {
|
|
55
|
+
cwd: new URL('..', import.meta.url),
|
|
56
|
+
encoding: 'utf8', timeout: 10000,
|
|
57
|
+
env: { ...process.env, HOME: home, NO_COLOR: '' },
|
|
58
|
+
});
|
|
59
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
60
|
+
const output = stripVTControlCharacters(child.stdout.replace(/\x1b[78]/g, ''));
|
|
61
|
+
assert.match(output, /model set to deepseek\/second/);
|
|
62
|
+
assert.match(output, /deepseek: 5h 20% available/);
|
|
63
|
+
assert.match(output, /12 in \/ 7 out · deepseek/);
|
|
64
|
+
assert.ok(!output.includes('PRIVATE_REASONING_SHOULD_NOT_APPEAR'));
|
|
65
|
+
assert.match(output, /Hello /);
|
|
66
|
+
assert.match(output, /account: 60 rpm · day 1K\/1M tokens · week \$2\/\$10/);
|
|
67
|
+
assert.ok(child.stdout.includes('\x1b[1;18r'));
|
|
68
|
+
assert.ok(child.stdout.includes('\x1b[r'));
|
|
69
|
+
assert.match(output, /bye/);
|
|
70
|
+
const config = JSON.parse(fs.readFileSync(path.join(home, '.config/subrouter-cli/config.json'), 'utf8'));
|
|
71
|
+
assert.equal(config.default_model, 'deepseek/second');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('palette executes commands and live usage refreshes below input without printing into chat', (t) => {
|
|
75
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-command-ui-'));
|
|
76
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
77
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
78
|
+
import assert from 'node:assert/strict';
|
|
79
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
80
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
81
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
82
|
+
Object.defineProperty(process.stdout, 'columns', { value: 100 });
|
|
83
|
+
process.stdin.setRawMode = () => {};
|
|
84
|
+
const { ui } = await import('./src/ui.ts');
|
|
85
|
+
const { startRepl } = await import('./src/repl.ts');
|
|
86
|
+
const chat = [];
|
|
87
|
+
let panel = [];
|
|
88
|
+
const say = ui.say.bind(ui);
|
|
89
|
+
ui.say = (text) => { chat.push(text); say(text); };
|
|
90
|
+
const showPanel = ui.panel.bind(ui);
|
|
91
|
+
ui.panel = (lines, ...rest) => { panel = lines; return showPanel(lines, ...rest); };
|
|
92
|
+
let usageCalls = 0;
|
|
93
|
+
const client = {
|
|
94
|
+
listModels: async () => [{ id: 'openai/first' }, { id: 'anthropic/second' }],
|
|
95
|
+
usage: async () => {
|
|
96
|
+
usageCalls++;
|
|
97
|
+
return { user: { rpm_limit: 60, today_tokens: 100, daily_token_limit: 1000 }, pools: [
|
|
98
|
+
{ provider: 'openai', window: '5h', used_percent: 90 },
|
|
99
|
+
{ provider: 'anthropic', window: '5h', used_percent: usageCalls * 10 },
|
|
100
|
+
] };
|
|
101
|
+
},
|
|
102
|
+
chat: async () => { throw new Error('commands must never reach the agent'); },
|
|
103
|
+
};
|
|
104
|
+
startRepl({ client, model: 'openai/first', config: {
|
|
105
|
+
maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: false,
|
|
106
|
+
} });
|
|
107
|
+
const settle = async () => { await new Promise(setImmediate); await new Promise(setImmediate); };
|
|
108
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
109
|
+
await settle();
|
|
110
|
+
key('/\r'); await settle(); // palette -> model picker
|
|
111
|
+
key('\x1b[B\r'); await settle();
|
|
112
|
+
key('/'); key('\x1b[B\x1b[B\r'); await settle(); // palette -> usage
|
|
113
|
+
assert.match(panel.join('\n'), /anthropic: 5h 90% available/);
|
|
114
|
+
assert.ok(!panel.join('\n').includes('openai'));
|
|
115
|
+
key('r'); await settle();
|
|
116
|
+
assert.equal(usageCalls, 2);
|
|
117
|
+
assert.match(panel.join('\n'), /anthropic: 5h 80% available/);
|
|
118
|
+
key('\r'); await settle();
|
|
119
|
+
key('/unknown\r'); await settle();
|
|
120
|
+
key('/clear\r'); await settle();
|
|
121
|
+
assert.ok(!chat.some((line) => /model set|quota:|account:|unknown command|conversation cleared/.test(line)));
|
|
122
|
+
key('/exit\r'); await settle();
|
|
123
|
+
`], { cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 10000,
|
|
124
|
+
env: { ...process.env, HOME: home, NO_COLOR: '' } });
|
|
125
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
126
|
+
assert.match(child.stdout, /bye/);
|
|
127
|
+
assert.ok(child.stdout.includes('\x1b[r'));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('effort picker persists selection and file-write chunks update one live preview', (t) => {
|
|
131
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-live-code-'));
|
|
132
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
133
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
134
|
+
import assert from 'node:assert/strict';
|
|
135
|
+
import fs from 'node:fs';
|
|
136
|
+
import path from 'node:path';
|
|
137
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
138
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
139
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
140
|
+
Object.defineProperty(process.stdout, 'columns', { value: 100 });
|
|
141
|
+
process.stdin.setRawMode = () => {};
|
|
142
|
+
const { ui } = await import('./src/ui.ts');
|
|
143
|
+
const { startRepl } = await import('./src/repl.ts');
|
|
144
|
+
const previews = [];
|
|
145
|
+
const chat = [];
|
|
146
|
+
const preview = ui.preview.bind(ui);
|
|
147
|
+
ui.preview = (title, lines) => { previews.push({ title, lines }); preview(title, lines); };
|
|
148
|
+
const say = ui.say.bind(ui);
|
|
149
|
+
ui.say = (line) => { chat.push(line); say(line); };
|
|
150
|
+
process.chdir(process.env.HOME);
|
|
151
|
+
let calls = 0;
|
|
152
|
+
let completed = false;
|
|
153
|
+
const client = {
|
|
154
|
+
listModels: async () => [{ id: 'openai/first' }], usage: async () => ({}),
|
|
155
|
+
chat: async (req, emit) => {
|
|
156
|
+
assert.equal(req.effort, 'high');
|
|
157
|
+
if (++calls === 1) {
|
|
158
|
+
emit({ kind: 'tool_call_start', index: 0, id: 'write1', name: 'write_file' });
|
|
159
|
+
emit({ kind: 'tool_call_args', index: 0, delta: '{"path":"preview.ts","content":"const first = 1;' });
|
|
160
|
+
await new Promise((resolve) => setTimeout(resolve, 45));
|
|
161
|
+
emit({ kind: 'tool_call_args', index: 0, delta: '\\nconst second = 2;"}' });
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 45));
|
|
163
|
+
const args = { path: 'preview.ts', content: 'const first = 1;\nconst second = 2;' };
|
|
164
|
+
return { message: { role: 'assistant', content: '', toolCalls: [{ id: 'write1', name: 'write_file', arguments: args, argumentsJson: JSON.stringify(args) }] }, stopReason: 'tool_calls' };
|
|
165
|
+
}
|
|
166
|
+
emit({ kind: 'text', text: 'Done.\n' });
|
|
167
|
+
completed = true;
|
|
168
|
+
return { message: { role: 'assistant', content: 'Done.\n' }, stopReason: 'stop' };
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
startRepl({ client, model: 'openai/first', config: { maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: true } });
|
|
172
|
+
const settle = async () => { await new Promise(setImmediate); await new Promise(setImmediate); };
|
|
173
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
174
|
+
await settle(); key('/effort\r'); await settle();
|
|
175
|
+
key('\x1b[B\x1b[B\x1b[B\r'); await settle();
|
|
176
|
+
key('/effort invalid\r'); await settle();
|
|
177
|
+
key('write the file\r');
|
|
178
|
+
const deadline = Date.now() + 3000;
|
|
179
|
+
while (!completed && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
180
|
+
await settle();
|
|
181
|
+
assert.equal(completed, true);
|
|
182
|
+
assert.ok(previews.some((p) => p.lines.length === 1 && p.lines[0].includes('first')));
|
|
183
|
+
assert.ok(previews.some((p) => p.lines.some((line) => line.includes('second'))));
|
|
184
|
+
assert.equal(fs.readFileSync('preview.ts', 'utf8'), 'const first = 1;\nconst second = 2;');
|
|
185
|
+
assert.ok(!chat.some((line) => /write_file|const first|const second/.test(line)));
|
|
186
|
+
const config = JSON.parse(fs.readFileSync(path.join(process.env.HOME, '.config/subrouter-cli/config.json'), 'utf8'));
|
|
187
|
+
assert.equal(config.reasoning_effort, 'high');
|
|
188
|
+
key('/exit\r'); await settle();
|
|
189
|
+
`], { cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 10000,
|
|
190
|
+
env: { ...process.env, HOME: home, NO_COLOR: '' } });
|
|
191
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('parallel disk tools leave no spinner timers repainting after completion', (t) => {
|
|
195
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-parallel-tools-'));
|
|
196
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
197
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
198
|
+
import assert from 'node:assert/strict';
|
|
199
|
+
import fs from 'node:fs';
|
|
200
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
201
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
202
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
203
|
+
Object.defineProperty(process.stdout, 'columns', { value: 100 });
|
|
204
|
+
process.stdin.setRawMode = () => {};
|
|
205
|
+
delete process.env.NO_COLOR;
|
|
206
|
+
process.chdir(process.env.HOME);
|
|
207
|
+
fs.writeFileSync('fixture.txt', 'hello world\n');
|
|
208
|
+
const { ui } = await import('./src/ui.ts');
|
|
209
|
+
const { startRepl } = await import('./src/repl.ts');
|
|
210
|
+
const lateActivity = [];
|
|
211
|
+
let completed = false;
|
|
212
|
+
const activity = ui.activity.bind(ui);
|
|
213
|
+
ui.activity = (label, text) => {
|
|
214
|
+
if (completed) lateActivity.push({ label, text });
|
|
215
|
+
return activity(label, text);
|
|
216
|
+
};
|
|
217
|
+
let rounds = 0;
|
|
218
|
+
const client = {
|
|
219
|
+
listModels: async () => [{ id: 'openai/test' }],
|
|
220
|
+
usage: async () => ({}),
|
|
221
|
+
chat: async (_req, emit) => {
|
|
222
|
+
if (++rounds === 1) {
|
|
223
|
+
const calls = [
|
|
224
|
+
{ id: 'g1', name: 'grep', arguments: { pattern: 'hello', path: '.' } },
|
|
225
|
+
{ id: 'g2', name: 'grep', arguments: { pattern: 'world', path: '.' } },
|
|
226
|
+
{ id: 'r1', name: 'read_file', arguments: { path: 'fixture.txt' } },
|
|
227
|
+
].map((call) => ({ ...call, argumentsJson: JSON.stringify(call.arguments) }));
|
|
228
|
+
calls.forEach((call, index) => emit({ kind: 'tool_call_start', index, id: call.id, name: call.name }));
|
|
229
|
+
return { message: { role: 'assistant', content: '', toolCalls: calls }, stopReason: 'tool_calls' };
|
|
230
|
+
}
|
|
231
|
+
emit({ kind: 'text', text: 'Done.\n' });
|
|
232
|
+
completed = true;
|
|
233
|
+
return { message: { role: 'assistant', content: 'Done.\n' }, stopReason: 'stop' };
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
startRepl({ client, model: 'openai/test', config: {
|
|
237
|
+
maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: false,
|
|
238
|
+
} });
|
|
239
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
240
|
+
await new Promise(setImmediate);
|
|
241
|
+
key('search and read\r');
|
|
242
|
+
const deadline = Date.now() + 3000;
|
|
243
|
+
while (!completed && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
244
|
+
assert.equal(completed, true);
|
|
245
|
+
await new Promise((resolve) => setTimeout(resolve, 650));
|
|
246
|
+
assert.deepEqual(lateActivity, [], 'completed tools must not repaint the live card');
|
|
247
|
+
key('/exit\r');
|
|
248
|
+
`], { cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 10000,
|
|
249
|
+
env: { ...process.env, HOME: home, NO_COLOR: '' } });
|
|
250
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
251
|
+
const output = stripVTControlCharacters(child.stdout.replace(/\x1b[78]/g, ''));
|
|
252
|
+
assert.match(output, /3 tools · ✓ read_file/);
|
|
253
|
+
assert.match(output, /bye/);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('Ctrl+C still cancels a streaming turn while the anchored input is paused', (t) => {
|
|
257
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-repl-abort-'));
|
|
258
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
259
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
|
260
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
261
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
262
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
263
|
+
Object.defineProperty(process.stdout, 'columns', { value: 80 });
|
|
264
|
+
process.stdin.setRawMode = () => {};
|
|
265
|
+
const { startRepl } = await import('./src/repl.ts');
|
|
266
|
+
const client = {
|
|
267
|
+
listModels: async () => [{ id: 'openai/test' }],
|
|
268
|
+
usage: async () => ({}),
|
|
269
|
+
chat: async (_req, _onEvent, signal) => new Promise((_resolve, reject) => {
|
|
270
|
+
signal.addEventListener('abort', () => reject(new DOMException('stopped', 'AbortError')), { once: true });
|
|
271
|
+
}),
|
|
272
|
+
};
|
|
273
|
+
startRepl({ client, model: 'openai/test', config: {
|
|
274
|
+
maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: false,
|
|
275
|
+
} });
|
|
276
|
+
const settle = async () => {
|
|
277
|
+
await new Promise(setImmediate);
|
|
278
|
+
await new Promise(setImmediate);
|
|
279
|
+
};
|
|
280
|
+
await settle();
|
|
281
|
+
process.stdin.emit('data', Buffer.from('hello\\r'));
|
|
282
|
+
await settle();
|
|
283
|
+
process.stdin.emit('data', Buffer.from('\\x03'));
|
|
284
|
+
await settle();
|
|
285
|
+
process.stdin.emit('data', Buffer.from('/exit\\r'));
|
|
286
|
+
await settle();
|
|
287
|
+
`], { cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 10000,
|
|
288
|
+
env: { ...process.env, HOME: home, NO_COLOR: '' } });
|
|
289
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
290
|
+
assert.match(child.stdout, /\*\[stopped\]\*/);
|
|
291
|
+
assert.match(child.stdout, /bye/);
|
|
292
|
+
assert.ok(child.stdout.includes('\x1b[r'));
|
|
293
|
+
});
|
package/test/sse.test.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SSE frame parser tests — all offline, chunked at various boundaries.
|
|
2
|
+
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import { sseFrames, type SseFrame } from '../src/sse.ts';
|
|
6
|
+
|
|
7
|
+
function streamFrom(text: string, chunkSize?: number): ReadableStream<Uint8Array> {
|
|
8
|
+
const bytes = new TextEncoder().encode(text);
|
|
9
|
+
return new ReadableStream({
|
|
10
|
+
start(controller) {
|
|
11
|
+
if (!chunkSize) {
|
|
12
|
+
controller.enqueue(bytes);
|
|
13
|
+
} else {
|
|
14
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
15
|
+
controller.enqueue(bytes.slice(i, i + chunkSize));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
controller.close();
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function frames(text: string, chunkSize?: number): Promise<SseFrame[]> {
|
|
24
|
+
const result: SseFrame[] = [];
|
|
25
|
+
for await (const frame of sseFrames(streamFrom(text, chunkSize))) {
|
|
26
|
+
result.push(frame);
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('sseFrames', () => {
|
|
32
|
+
it('parses simple frames', async () => {
|
|
33
|
+
assert.deepEqual(await frames('data: a\n\ndata: b\n\n'), [{ data: 'a' }, { data: 'b' }]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('passes [DONE] through for the caller', async () => {
|
|
37
|
+
assert.deepEqual(await frames('data: [DONE]\n\n'), [{ data: '[DONE]' }]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('handles byte-by-byte chunking identically', async () => {
|
|
41
|
+
const text = 'data: {"a":1}\n\ndata: {"b":2}\n\n';
|
|
42
|
+
assert.deepEqual(await frames(text, 1), await frames(text));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('splits a frame across reads', async () => {
|
|
46
|
+
assert.deepEqual(await frames('data: hello\n\n', 3), [{ data: 'hello' }]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('handles CRLF endings', async () => {
|
|
50
|
+
assert.deepEqual(await frames('data: x\r\n\r\ndata: y\r\n\r\n'), [{ data: 'x' }, { data: 'y' }]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('joins multi-line data with newlines', async () => {
|
|
54
|
+
assert.deepEqual(await frames('data: line1\ndata: line2\n\n'), [{ data: 'line1\nline2' }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('ignores comments and captures event names', async () => {
|
|
58
|
+
assert.deepEqual(await frames(': keepalive\nevent: message_start\ndata: {}\n\n'), [
|
|
59
|
+
{ data: '{}', event: 'message_start' },
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('ignores id:/retry: fields without ending the frame', async () => {
|
|
64
|
+
assert.deepEqual(await frames('id: 1\nretry: 100\ndata: z\n\n'), [{ data: 'z' }]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('skips empty data payloads (heartbeats)', async () => {
|
|
68
|
+
assert.deepEqual(await frames('data: \n\ndata: real\n\n'), [{ data: 'real' }]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('emits a trailing frame without a blank line at EOF', async () => {
|
|
72
|
+
assert.deepEqual(await frames('data: tail'), [{ data: 'tail' }]);
|
|
73
|
+
});
|
|
74
|
+
});
|