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,205 @@
|
|
|
1
|
+
// OpenAI adapter tests — request building, response parsing, stream assembly.
|
|
2
|
+
// The fixture was recorded live from the router by scripts/sse-harness.ts (M0).
|
|
3
|
+
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import {
|
|
9
|
+
OpenAiStreamParser,
|
|
10
|
+
mapFinishReason,
|
|
11
|
+
parseCompleteBody,
|
|
12
|
+
parseToolArgs,
|
|
13
|
+
toWireBody,
|
|
14
|
+
} from '../src/adapters/openai.ts';
|
|
15
|
+
import { RouterError } from '../src/client.ts';
|
|
16
|
+
import type { ChatMessage, ChatRequest, StreamEvent } from '../src/types.ts';
|
|
17
|
+
|
|
18
|
+
function baseRequest(overrides: Partial<ChatRequest> = {}): ChatRequest {
|
|
19
|
+
return {
|
|
20
|
+
model: 'opencode/deepseek-v4-pro',
|
|
21
|
+
dialect: 'openai',
|
|
22
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
23
|
+
system: 'you are sub',
|
|
24
|
+
stream: false,
|
|
25
|
+
...overrides,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('toWireBody', () => {
|
|
30
|
+
it('folds the system prompt into a system message', () => {
|
|
31
|
+
const wire = toWireBody(baseRequest());
|
|
32
|
+
assert.deepEqual((wire.messages as unknown[])[0], { role: 'system', content: 'you are sub' });
|
|
33
|
+
assert.equal(wire.model, 'opencode/deepseek-v4-pro');
|
|
34
|
+
assert.equal(wire.stream, false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('serializes assistant tool calls with JSON-string arguments', () => {
|
|
38
|
+
const assistant: ChatMessage = {
|
|
39
|
+
role: 'assistant',
|
|
40
|
+
content: '',
|
|
41
|
+
toolCalls: [{ id: 'call_1', name: 'read_file', arguments: { path: 'a.ts' }, argumentsJson: '{"path":"a.ts"}' }],
|
|
42
|
+
};
|
|
43
|
+
const wire = toWireBody(baseRequest({ messages: [assistant] }));
|
|
44
|
+
const msg = (wire.messages as Record<string, unknown>[])[1]; // [0] is the folded system message
|
|
45
|
+
assert.deepEqual(msg.tool_calls, [
|
|
46
|
+
{ id: 'call_1', type: 'function', function: { name: 'read_file', arguments: '{"path":"a.ts"}' } },
|
|
47
|
+
]);
|
|
48
|
+
assert.equal(msg.content, undefined); // empty content dropped on tool_calls-only turns
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('maps tool messages to role tool + tool_call_id', () => {
|
|
52
|
+
const tool: ChatMessage = { role: 'tool', content: 'result', toolCallId: 'call_1' };
|
|
53
|
+
const wire = toWireBody(baseRequest({ messages: [tool], system: '' }));
|
|
54
|
+
assert.deepEqual(wire.messages, [{ role: 'tool', tool_call_id: 'call_1', content: 'result' }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('emits the tools array in function form', () => {
|
|
58
|
+
const wire = toWireBody(
|
|
59
|
+
baseRequest({
|
|
60
|
+
tools: [{ name: 'read_file', description: 'read', parameters: { type: 'object' }, requiresApproval: false }],
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
assert.deepEqual(wire.tools, [
|
|
64
|
+
{ type: 'function', function: { name: 'read_file', description: 'read', parameters: { type: 'object' } } },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('parseToolArgs', () => {
|
|
70
|
+
it('parses valid object JSON', () => {
|
|
71
|
+
const { arguments: args, invalid } = parseToolArgs('{"a":1}');
|
|
72
|
+
assert.deepEqual(args, { a: 1 });
|
|
73
|
+
assert.equal(invalid, false);
|
|
74
|
+
});
|
|
75
|
+
it('flags malformed JSON and arrays', () => {
|
|
76
|
+
assert.equal(parseToolArgs('not json').invalid, true);
|
|
77
|
+
assert.equal(parseToolArgs('[1,2]').invalid, true);
|
|
78
|
+
assert.equal(parseToolArgs('').arguments !== undefined, true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('parseCompleteBody', () => {
|
|
83
|
+
it('parses a plain text response', () => {
|
|
84
|
+
const turn = parseCompleteBody({
|
|
85
|
+
choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }],
|
|
86
|
+
usage: { prompt_tokens: 9, completion_tokens: 3 },
|
|
87
|
+
});
|
|
88
|
+
assert.equal(turn.message.content, 'hi');
|
|
89
|
+
assert.equal(turn.stopReason, 'stop');
|
|
90
|
+
assert.deepEqual(turn.usage, { inputTokens: 9, outputTokens: 3 });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('parses tool calls with lenient arguments', () => {
|
|
94
|
+
const turn = parseCompleteBody({
|
|
95
|
+
choices: [
|
|
96
|
+
{
|
|
97
|
+
index: 0,
|
|
98
|
+
message: {
|
|
99
|
+
role: 'assistant',
|
|
100
|
+
content: '',
|
|
101
|
+
tool_calls: [
|
|
102
|
+
{ id: 'c1', type: 'function', function: { name: 'echo', arguments: '{"x":1}' } },
|
|
103
|
+
{ id: 'c2', type: 'function', function: { name: 'echo', arguments: 'bogus' } },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
finish_reason: 'tool_calls',
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
assert.equal(turn.stopReason, 'tool_calls');
|
|
111
|
+
assert.equal(turn.message.toolCalls?.[0].name, 'echo');
|
|
112
|
+
assert.deepEqual(turn.message.toolCalls?.[0].arguments, { x: 1 });
|
|
113
|
+
assert.equal(turn.message.toolCalls?.[1].argumentsInvalid, true);
|
|
114
|
+
assert.deepEqual(turn.message.toolCalls?.[1].arguments, {});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('rejects an invalid envelope', () => {
|
|
118
|
+
assert.throws(() => parseCompleteBody({}), RouterError);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('mapFinishReason', () => {
|
|
123
|
+
it('maps the known reasons', () => {
|
|
124
|
+
assert.equal(mapFinishReason('stop'), 'stop');
|
|
125
|
+
assert.equal(mapFinishReason('tool_calls'), 'tool_calls');
|
|
126
|
+
assert.equal(mapFinishReason('length'), 'length');
|
|
127
|
+
assert.equal(mapFinishReason('content_filter'), 'refusal');
|
|
128
|
+
assert.equal(mapFinishReason('weird'), 'unknown');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('OpenAiStreamParser', () => {
|
|
133
|
+
it('assembles text, reasoning, and chunked tool calls (live shape)', () => {
|
|
134
|
+
const parser = new OpenAiStreamParser();
|
|
135
|
+
const events: StreamEvent[] = [];
|
|
136
|
+
const feed = (payload: string): void => {
|
|
137
|
+
events.push(...parser.push(payload));
|
|
138
|
+
};
|
|
139
|
+
feed('{"choices":[{"index":0,"delta":{"role":"assistant","content":"I will"},"finish_reason":null}]}');
|
|
140
|
+
feed('{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}');
|
|
141
|
+
feed('{"choices":[{"index":0,"delta":{"content":" call it."},"finish_reason":null}]}');
|
|
142
|
+
feed(
|
|
143
|
+
'{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"echo","arguments":""}}]},"finish_reason":null}]}',
|
|
144
|
+
);
|
|
145
|
+
feed('{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"x\\":1}"}}]},"finish_reason":null}]}');
|
|
146
|
+
feed('{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}');
|
|
147
|
+
|
|
148
|
+
assert.deepEqual(
|
|
149
|
+
events.map((e) => e.kind),
|
|
150
|
+
['text', 'reasoning', 'text', 'tool_call_start', 'tool_call_args', 'stop'],
|
|
151
|
+
);
|
|
152
|
+
const stop = events.at(-1);
|
|
153
|
+
assert.equal(stop?.kind, 'stop');
|
|
154
|
+
if (stop?.kind === 'stop') {
|
|
155
|
+
assert.equal(stop.stopReason, 'tool_calls');
|
|
156
|
+
assert.deepEqual(stop.usage, { inputTokens: 10, outputTokens: 5 });
|
|
157
|
+
}
|
|
158
|
+
const message = parser.assistantMessage();
|
|
159
|
+
assert.equal(message.content, 'I will call it.');
|
|
160
|
+
assert.equal(message.toolCalls?.[0].id, 'call_1');
|
|
161
|
+
assert.equal(message.toolCalls?.[0].name, 'echo');
|
|
162
|
+
assert.deepEqual(message.toolCalls?.[0].arguments, { x: 1 });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('throws on an error frame', () => {
|
|
166
|
+
const parser = new OpenAiStreamParser();
|
|
167
|
+
assert.throws(() => parser.push('{"error":{"message":"bad stuff"}}'), /bad stuff/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('emits nothing after the stop event', () => {
|
|
171
|
+
const parser = new OpenAiStreamParser();
|
|
172
|
+
parser.push('{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}');
|
|
173
|
+
assert.deepEqual(parser.push('{"choices":[{"index":0,"delta":{"content":"late"}}]}'), []);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('parses the live recorded fixture end to end', () => {
|
|
177
|
+
const fixture = path.join(import.meta.dirname, 'fixtures', 'openai-tools.sse');
|
|
178
|
+
const text = fs.readFileSync(fixture, 'utf8');
|
|
179
|
+
const parser = new OpenAiStreamParser();
|
|
180
|
+
let stopReason = '';
|
|
181
|
+
let usage;
|
|
182
|
+
let sawReasoning = false;
|
|
183
|
+
for (const block of text.split('\n\n')) {
|
|
184
|
+
const line = block.trim();
|
|
185
|
+
if (!line.startsWith('data: ')) continue;
|
|
186
|
+
const payload = line.slice(6);
|
|
187
|
+
if (payload === '[DONE]') break;
|
|
188
|
+
for (const e of parser.push(payload)) {
|
|
189
|
+
if (e.kind === 'reasoning') sawReasoning = true;
|
|
190
|
+
if (e.kind === 'stop') {
|
|
191
|
+
stopReason = e.stopReason;
|
|
192
|
+
usage = e.usage;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
assert.equal(stopReason, 'tool_calls');
|
|
197
|
+
assert.ok(usage);
|
|
198
|
+
assert.ok(sawReasoning, 'opencode models stream reasoning_content — must be surfaced');
|
|
199
|
+
const message = parser.assistantMessage();
|
|
200
|
+
assert.equal(message.toolCalls?.[0].name, 'echo');
|
|
201
|
+
assert.deepEqual(message.toolCalls?.[0].arguments, {
|
|
202
|
+
value: { message: 'Hello, world!', number: 42, valid: true },
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Anthropic adapter tests — hand-built fixtures in the 2023-06-01 Messages shapes
|
|
2
|
+
// (the user's key has no live anthropic model, so these pin the spec).
|
|
3
|
+
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import {
|
|
7
|
+
ANTHROPIC_DEFAULT_MAX_TOKENS,
|
|
8
|
+
AnthropicStreamParser,
|
|
9
|
+
mapAnthropicStopReason,
|
|
10
|
+
parseAnthropicCompleteBody,
|
|
11
|
+
toAnthropicBody,
|
|
12
|
+
} from '../src/adapters/anthropic.ts';
|
|
13
|
+
import { toWireBody } from '../src/adapters/openai.ts';
|
|
14
|
+
import { RouterError } from '../src/client.ts';
|
|
15
|
+
import type { ChatMessage, ChatRequest, StreamEvent, ToolCall } from '../src/types.ts';
|
|
16
|
+
|
|
17
|
+
type Wire = Record<string, unknown>;
|
|
18
|
+
|
|
19
|
+
function baseRequest(overrides: Partial<ChatRequest> = {}): ChatRequest {
|
|
20
|
+
return {
|
|
21
|
+
model: 'anthropic/claude-sonnet-5',
|
|
22
|
+
dialect: 'anthropic',
|
|
23
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
24
|
+
system: 'you are sub',
|
|
25
|
+
stream: true,
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const call: ToolCall = {
|
|
31
|
+
id: 'toolu_1',
|
|
32
|
+
name: 'read_file',
|
|
33
|
+
arguments: { path: 'a.ts' },
|
|
34
|
+
argumentsJson: '{"path":"a.ts"}',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('toAnthropicBody', () => {
|
|
38
|
+
it('folds the system prompt to the top level and defaults max_tokens to 8192', () => {
|
|
39
|
+
const wire = toAnthropicBody(baseRequest());
|
|
40
|
+
assert.equal(wire.system, 'you are sub');
|
|
41
|
+
assert.equal(wire.max_tokens, ANTHROPIC_DEFAULT_MAX_TOKENS);
|
|
42
|
+
assert.deepEqual(wire.messages, [{ role: 'user', content: 'hi' }]);
|
|
43
|
+
assert.equal(wire.stream, true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('serializes assistant tool calls as tool_use blocks with parsed input', () => {
|
|
47
|
+
const assistant: ChatMessage = { role: 'assistant', content: 'let me look', toolCalls: [call] };
|
|
48
|
+
const wire = toAnthropicBody(baseRequest({ messages: [assistant] }));
|
|
49
|
+
assert.deepEqual((wire.messages as Wire[])[0].content, [
|
|
50
|
+
{ type: 'text', text: 'let me look' },
|
|
51
|
+
{ type: 'tool_use', id: 'toolu_1', name: 'read_file', input: { path: 'a.ts' } },
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('drops content-less assistant turns', () => {
|
|
56
|
+
const empty: ChatMessage = { role: 'assistant', content: '' };
|
|
57
|
+
const wire = toAnthropicBody(baseRequest({ messages: [empty, { role: 'user', content: 'hi' }] }));
|
|
58
|
+
assert.deepEqual(wire.messages, [{ role: 'user', content: 'hi' }]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('batches consecutive tool results into one user turn with is_error', () => {
|
|
62
|
+
const messages: ChatMessage[] = [
|
|
63
|
+
{ role: 'tool', content: 'result a', toolCallId: 't1' },
|
|
64
|
+
{ role: 'tool', content: 'failed b', toolCallId: 't2', isError: true },
|
|
65
|
+
];
|
|
66
|
+
const wire = toAnthropicBody(baseRequest({ messages }));
|
|
67
|
+
const wireMessages = wire.messages as Wire[];
|
|
68
|
+
assert.equal(wireMessages.length, 1);
|
|
69
|
+
assert.deepEqual(wireMessages[0], {
|
|
70
|
+
role: 'user',
|
|
71
|
+
content: [
|
|
72
|
+
{ type: 'tool_result', tool_use_id: 't1', content: 'result a', is_error: false },
|
|
73
|
+
{ type: 'tool_result', tool_use_id: 't2', content: 'failed b', is_error: true },
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('flushes the batch when a non-tool message intervenes', () => {
|
|
79
|
+
const messages: ChatMessage[] = [
|
|
80
|
+
{ role: 'tool', content: 'a', toolCallId: 't1' },
|
|
81
|
+
{ role: 'user', content: 'more context' },
|
|
82
|
+
{ role: 'tool', content: 'b', toolCallId: 't2' },
|
|
83
|
+
];
|
|
84
|
+
const wireMessages = toAnthropicBody(baseRequest({ messages })).messages as Wire[];
|
|
85
|
+
assert.deepEqual(wireMessages.map((m) => m.role), ['user', 'user', 'user']);
|
|
86
|
+
assert.deepEqual(wireMessages[0].content, [{ type: 'tool_result', tool_use_id: 't1', content: 'a', is_error: false }]);
|
|
87
|
+
assert.deepEqual(wireMessages[2].content, [{ type: 'tool_result', tool_use_id: 't2', content: 'b', is_error: false }]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('emits tools in input_schema form', () => {
|
|
91
|
+
const wire = toAnthropicBody(
|
|
92
|
+
baseRequest({
|
|
93
|
+
tools: [{ name: 'read_file', description: 'read', parameters: { type: 'object' }, requiresApproval: false }],
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
assert.deepEqual(wire.tools, [{ name: 'read_file', description: 'read', input_schema: { type: 'object' } }]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('mapAnthropicStopReason', () => {
|
|
101
|
+
it('maps the known reasons', () => {
|
|
102
|
+
assert.equal(mapAnthropicStopReason('end_turn'), 'stop');
|
|
103
|
+
assert.equal(mapAnthropicStopReason('stop_sequence'), 'stop');
|
|
104
|
+
assert.equal(mapAnthropicStopReason('tool_use'), 'tool_calls');
|
|
105
|
+
assert.equal(mapAnthropicStopReason('max_tokens'), 'length');
|
|
106
|
+
assert.equal(mapAnthropicStopReason('refusal'), 'refusal');
|
|
107
|
+
assert.equal(mapAnthropicStopReason('weird'), 'unknown');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('AnthropicStreamParser', () => {
|
|
112
|
+
it('assembles text, a tool use, and the stop event (spec shape)', () => {
|
|
113
|
+
const parser = new AnthropicStreamParser();
|
|
114
|
+
const events: StreamEvent[] = [];
|
|
115
|
+
const feed = (payload: string): void => {
|
|
116
|
+
events.push(...parser.push(payload));
|
|
117
|
+
};
|
|
118
|
+
feed('{"type":"message_start","message":{"usage":{"input_tokens":10,"output_tokens":1}}}');
|
|
119
|
+
feed('{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}');
|
|
120
|
+
feed('{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Let me"}}');
|
|
121
|
+
feed('{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" check."}}');
|
|
122
|
+
feed('{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hidden"}}'); // ignored
|
|
123
|
+
feed('{"type":"content_block_stop","index":0}');
|
|
124
|
+
feed('{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_9","name":"read_file","input":{}}}');
|
|
125
|
+
feed('{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"pa"}}');
|
|
126
|
+
feed('{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"th\\":\\"a.ts\\"}"}}');
|
|
127
|
+
feed('{"type":"content_block_stop","index":1}');
|
|
128
|
+
feed('{"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":25}}');
|
|
129
|
+
feed('{"type":"message_stop"}');
|
|
130
|
+
|
|
131
|
+
assert.deepEqual(
|
|
132
|
+
events.map((e) => e.kind),
|
|
133
|
+
['text', 'text', 'tool_call_start', 'tool_call_args', 'tool_call_args', 'stop'],
|
|
134
|
+
);
|
|
135
|
+
const stop = events.at(-1);
|
|
136
|
+
assert.equal(stop?.kind, 'stop');
|
|
137
|
+
if (stop?.kind === 'stop') {
|
|
138
|
+
assert.equal(stop.stopReason, 'tool_calls');
|
|
139
|
+
assert.deepEqual(stop.usage, { inputTokens: 10, outputTokens: 25 });
|
|
140
|
+
}
|
|
141
|
+
const message = parser.assistantMessage();
|
|
142
|
+
assert.equal(message.content, 'Let me check.');
|
|
143
|
+
assert.equal(message.toolCalls?.[0].id, 'toolu_9');
|
|
144
|
+
assert.equal(message.toolCalls?.[0].name, 'read_file');
|
|
145
|
+
assert.deepEqual(message.toolCalls?.[0].arguments, { path: 'a.ts' });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('surfaces refusals as text', () => {
|
|
149
|
+
const parser = new AnthropicStreamParser();
|
|
150
|
+
const events = parser.push('{"type":"content_block_start","index":0,"content_block":{"type":"refusal","refusal":""}}');
|
|
151
|
+
assert.deepEqual(events, []);
|
|
152
|
+
events.push(...parser.push('{"type":"content_block_delta","index":0,"delta":{"type":"refusal_delta","refusal":"I cannot."}}'));
|
|
153
|
+
assert.deepEqual(events.map((e) => e.kind), ['text']);
|
|
154
|
+
assert.equal(parser.assistantMessage().content, 'I cannot.');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('throws on an error event', () => {
|
|
158
|
+
const parser = new AnthropicStreamParser();
|
|
159
|
+
assert.throws(
|
|
160
|
+
() => parser.push('{"type":"error","error":{"type":"overloaded_error","message":"overloaded"}}'),
|
|
161
|
+
(err: unknown) => err instanceof RouterError && /overloaded/.test(err.message),
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('emits nothing after message_stop', () => {
|
|
166
|
+
const parser = new AnthropicStreamParser();
|
|
167
|
+
parser.push('{"type":"message_delta","delta":{"stop_reason":"end_turn"}}');
|
|
168
|
+
parser.push('{"type":"message_stop"}');
|
|
169
|
+
assert.deepEqual(parser.push('{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"late"}}'), []);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('parseAnthropicCompleteBody', () => {
|
|
174
|
+
it('parses text and tool_use blocks', () => {
|
|
175
|
+
const turn = parseAnthropicCompleteBody({
|
|
176
|
+
id: 'msg_1',
|
|
177
|
+
type: 'message',
|
|
178
|
+
role: 'assistant',
|
|
179
|
+
content: [
|
|
180
|
+
{ type: 'text', text: 'I will read it.' },
|
|
181
|
+
{ type: 'tool_use', id: 'toolu_2', name: 'read_file', input: { path: 'b.ts' } },
|
|
182
|
+
],
|
|
183
|
+
stop_reason: 'tool_use',
|
|
184
|
+
usage: { input_tokens: 12, output_tokens: 8 },
|
|
185
|
+
});
|
|
186
|
+
assert.equal(turn.stopReason, 'tool_calls');
|
|
187
|
+
assert.equal(turn.message.content, 'I will read it.');
|
|
188
|
+
assert.deepEqual(turn.message.toolCalls?.[0].arguments, { path: 'b.ts' });
|
|
189
|
+
assert.equal(turn.message.toolCalls?.[0].argumentsJson, '{"path":"b.ts"}');
|
|
190
|
+
assert.deepEqual(turn.usage, { inputTokens: 12, outputTokens: 8 });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('surfaces refusal blocks and maps the reason', () => {
|
|
194
|
+
const turn = parseAnthropicCompleteBody({
|
|
195
|
+
type: 'message',
|
|
196
|
+
role: 'assistant',
|
|
197
|
+
content: [{ type: 'refusal', refusal: 'not allowed' }],
|
|
198
|
+
stop_reason: 'refusal',
|
|
199
|
+
usage: { input_tokens: 1, output_tokens: 2 },
|
|
200
|
+
});
|
|
201
|
+
assert.equal(turn.stopReason, 'refusal');
|
|
202
|
+
assert.equal(turn.message.content, 'not allowed');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('throws on the error envelope', () => {
|
|
206
|
+
assert.throws(
|
|
207
|
+
() => parseAnthropicCompleteBody({ type: 'error', error: { type: 'api_error', message: 'bad key' } }),
|
|
208
|
+
(err: unknown) => err instanceof RouterError && /bad key/.test(err.message),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('round trips', () => {
|
|
214
|
+
it('an anthropic-parsed turn serializes back to both wire dialects', () => {
|
|
215
|
+
const turn = parseAnthropicCompleteBody({
|
|
216
|
+
type: 'message',
|
|
217
|
+
role: 'assistant',
|
|
218
|
+
content: [
|
|
219
|
+
{ type: 'text', text: 'opening it' },
|
|
220
|
+
{ type: 'tool_use', id: 'toolu_3', name: 'read_file', input: { path: 'c.ts' } },
|
|
221
|
+
],
|
|
222
|
+
stop_reason: 'tool_use',
|
|
223
|
+
usage: { input_tokens: 3, output_tokens: 4 },
|
|
224
|
+
});
|
|
225
|
+
const anthropicWire = toAnthropicBody(baseRequest({ messages: [turn.message] })) as Wire;
|
|
226
|
+
const blocks = ((anthropicWire.messages as Wire[])[0].content as Wire[]) as Wire[];
|
|
227
|
+
assert.deepEqual(blocks[1], { type: 'tool_use', id: 'toolu_3', name: 'read_file', input: { path: 'c.ts' } });
|
|
228
|
+
|
|
229
|
+
const openaiWire = toWireBody(
|
|
230
|
+
baseRequest({ messages: [turn.message], dialect: 'openai' }),
|
|
231
|
+
);
|
|
232
|
+
const openaiMsg = (openaiWire.messages as Wire[])[1]; // [0] is the folded system message
|
|
233
|
+
assert.equal(openaiMsg.content, 'opening it');
|
|
234
|
+
assert.deepEqual(openaiMsg.tool_calls, [
|
|
235
|
+
{ id: 'toolu_3', type: 'function', function: { name: 'read_file', arguments: '{"path":"c.ts"}' } },
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('an openai-shaped tool call round-trips through the anthropic wire', () => {
|
|
240
|
+
const assistant: ChatMessage = { role: 'assistant', content: '', toolCalls: [call] };
|
|
241
|
+
const anthropicWire = toAnthropicBody(baseRequest({ messages: [assistant] })) as Wire;
|
|
242
|
+
assert.deepEqual((anthropicWire.messages as Wire[])[0].content, [
|
|
243
|
+
{ type: 'tool_use', id: 'toolu_1', name: 'read_file', input: { path: 'a.ts' } },
|
|
244
|
+
]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
|
|
8
|
+
const root = new URL('..', import.meta.url);
|
|
9
|
+
|
|
10
|
+
test('guarded auto runs routine commands; file-only/ask modes and unknown commands still prompt', (t) => {
|
|
11
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-auto-ui-'));
|
|
12
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
13
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
17
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
18
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24 });
|
|
19
|
+
Object.defineProperty(process.stdout, 'columns', { value: 100 });
|
|
20
|
+
process.stdin.setRawMode = () => {};
|
|
21
|
+
process.chdir(process.env.HOME);
|
|
22
|
+
const { ui } = await import(${JSON.stringify(new URL('../src/ui.ts', import.meta.url).href)});
|
|
23
|
+
const { LineInput } = await import(${JSON.stringify(new URL('../src/lineinput.ts', import.meta.url).href)});
|
|
24
|
+
const { startRepl } = await import(${JSON.stringify(new URL('../src/repl.ts', import.meta.url).href)});
|
|
25
|
+
const requests = [], prompts = [], modes = [], cards = [];
|
|
26
|
+
let status = '';
|
|
27
|
+
const setStatus = ui.setStatus.bind(ui), setMode = ui.setMode.bind(ui), startTool = ui.startTool.bind(ui);
|
|
28
|
+
ui.setStatus = (value) => { status = value; setStatus(value); };
|
|
29
|
+
ui.setMode = (value) => { modes.push(value); setMode(value); };
|
|
30
|
+
ui.startTool = (name, preview) => { cards.push(preview); startTool(name, preview); };
|
|
31
|
+
const question = LineInput.prototype.question;
|
|
32
|
+
LineInput.prototype.question = function(prompt) { prompts.push(prompt); return question.call(this, prompt); };
|
|
33
|
+
const client = {
|
|
34
|
+
listModels: async () => [{ id: 'openai/test' }], usage: async () => ({}),
|
|
35
|
+
chat: async (req) => {
|
|
36
|
+
requests.push(structuredClone(req));
|
|
37
|
+
if (req.messages.at(-1).role === 'tool') return { message: { role: 'assistant', content: 'Done.' }, stopReason: 'stop' };
|
|
38
|
+
const user = req.messages.at(-1).content;
|
|
39
|
+
const name = user === 'file' ? 'write_file' : 'run_command';
|
|
40
|
+
const args = user === 'file' ? { path: 'new-file', content: 'hello' } : { command: user === 'risk' ? 'printf bad > marker' : 'pwd' };
|
|
41
|
+
return { message: { role: 'assistant', content: '', toolCalls: [{ id: 'c' + requests.length, name, arguments: args, argumentsJson: JSON.stringify(args) }] }, stopReason: 'tool_calls' };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
startRepl({ client, model: 'openai/test', autoMode: true, config: { apiKey: 'synthetic-key', maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: false } });
|
|
45
|
+
const key = (text) => process.stdin.emit('data', Buffer.from(text));
|
|
46
|
+
const until = async (predicate) => {
|
|
47
|
+
const deadline = Date.now() + 5000;
|
|
48
|
+
while (!predicate() && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
49
|
+
assert.ok(predicate()); await new Promise(setImmediate);
|
|
50
|
+
};
|
|
51
|
+
key('routine\r'); await until(() => requests.length === 2 && status === 'ready');
|
|
52
|
+
assert.equal(prompts.length, 0);
|
|
53
|
+
assert.equal(JSON.parse(requests[1].messages.at(-1).content).status, 'completed');
|
|
54
|
+
assert.ok(cards.some((card) => card.title === 'Terminal command · auto'));
|
|
55
|
+
key('/mode files\r'); await until(() => status === 'ready');
|
|
56
|
+
key('routine\r'); await until(() => prompts.length === 1);
|
|
57
|
+
key('a\r'); await until(() => requests.length === 4 && status === 'ready');
|
|
58
|
+
assert.match(requests[3].messages.at(-1).content, /denied/);
|
|
59
|
+
key('/mode auto\r'); await until(() => status === 'ready');
|
|
60
|
+
key('risk\r'); await until(() => prompts.length === 2);
|
|
61
|
+
key('n\r'); await until(() => requests.length === 6 && status === 'ready');
|
|
62
|
+
assert.ok(!fs.existsSync('marker'));
|
|
63
|
+
key('file\r'); await until(() => requests.length === 8 && status === 'ready');
|
|
64
|
+
assert.equal(prompts.length, 2);
|
|
65
|
+
assert.equal(fs.readFileSync('new-file', 'utf8'), 'hello');
|
|
66
|
+
key('/mode\r'); await until(() => status === 'choose permission mode');
|
|
67
|
+
key('\x1b[A\x1b[A\r'); await until(() => status === 'ready');
|
|
68
|
+
assert.equal(modes.at(-1), 'ask before writes');
|
|
69
|
+
key('file\r'); await until(() => prompts.length === 3);
|
|
70
|
+
key('n\r'); await until(() => requests.length === 10 && status === 'ready');
|
|
71
|
+
assert.ok(modes.includes('files allowed · commands ask'));
|
|
72
|
+
assert.ok(modes.includes('guarded auto'));
|
|
73
|
+
key('/exit\r');
|
|
74
|
+
`], { cwd: root, encoding: 'utf8', timeout: 15000, env: { ...process.env, HOME: home, NO_COLOR: '1' } });
|
|
75
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
76
|
+
assert.ok(!child.stdout.includes('synthetic-key'));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('CLI distinguishes guarded auto from file-only auto and honors file config defaults', (t) => {
|
|
80
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-auto-cli-'));
|
|
81
|
+
t.after(() => fs.rmSync(home, { recursive: true, force: true }));
|
|
82
|
+
fs.mkdirSync(path.join(home, '.config/subrouter-cli'), { recursive: true });
|
|
83
|
+
fs.writeFileSync(path.join(home, '.config/subrouter-cli/config.json'), JSON.stringify({ auto_approve: true }));
|
|
84
|
+
for (const [flags, expected] of [[[], 'files allowed · commands ask'], [['--auto'], 'guarded auto'], [['--auto-approve'], 'files allowed · commands ask']] as const) {
|
|
85
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
|
|
86
|
+
import assert from 'node:assert/strict';
|
|
87
|
+
process.argv = [process.execPath, 'sub', '--model', 'openai/test', ...${JSON.stringify(flags)}];
|
|
88
|
+
process.chdir(process.env.HOME);
|
|
89
|
+
const { RouterClient } = await import(${JSON.stringify(new URL('../src/client.ts', import.meta.url).href)});
|
|
90
|
+
RouterClient.prototype.listModels = async () => [{ id: 'openai/test' }];
|
|
91
|
+
RouterClient.prototype.usage = async () => ({});
|
|
92
|
+
const { ui } = await import(${JSON.stringify(new URL('../src/ui.ts', import.meta.url).href)});
|
|
93
|
+
let mode;
|
|
94
|
+
const setMode = ui.setMode.bind(ui);
|
|
95
|
+
ui.setMode = (value) => { mode = value; setMode(value); };
|
|
96
|
+
await import(${JSON.stringify(new URL('../src/index.ts', import.meta.url).href)});
|
|
97
|
+
assert.equal(mode, ${JSON.stringify(expected)});
|
|
98
|
+
process.stdin.emit('end');
|
|
99
|
+
`], { cwd: root, encoding: 'utf8', timeout: 5000, env: { ...process.env, HOME: home, SUBROUTER_API_KEY: 'synthetic-key' }, input: '' });
|
|
100
|
+
assert.equal(child.status, 0, child.stderr || String(child.error));
|
|
101
|
+
assert.ok(!child.stdout.includes('synthetic-key'));
|
|
102
|
+
}
|
|
103
|
+
const conflict = spawnSync(process.execPath, ['src/index.ts', '--auto', '--auto-approve'], { cwd: root, encoding: 'utf8', timeout: 5000, env: { ...process.env, HOME: home } });
|
|
104
|
+
assert.equal(conflict.status, 2);
|
|
105
|
+
assert.match(conflict.stderr, /not both/);
|
|
106
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { stripVTControlCharacters as plain } from 'node:util';
|
|
4
|
+
import { ChatViewport } from '../src/chatviewport.ts';
|
|
5
|
+
|
|
6
|
+
test('viewport clamps scrolling and preserves the viewed rows during streaming', () => {
|
|
7
|
+
const view = new ChatViewport();
|
|
8
|
+
view.append(Array.from({ length: 30 }, (_, i) => `answer ${i}`).join('\n'), 40);
|
|
9
|
+
assert.deepEqual(view.visible(40, 5), ['answer 25', 'answer 26', 'answer 27', 'answer 28', 'answer 29']);
|
|
10
|
+
view.scroll(8, 40, 5);
|
|
11
|
+
const history = view.visible(40, 5);
|
|
12
|
+
view.append('\nnew answer\nstreaming', 40);
|
|
13
|
+
assert.deepEqual(view.visible(40, 5), history);
|
|
14
|
+
view.scroll(Infinity, 40, 5);
|
|
15
|
+
assert.equal(view.visible(40, 5)[0], 'answer 0');
|
|
16
|
+
view.scroll(-Infinity, 40, 5);
|
|
17
|
+
assert.equal(view.visible(40, 5).at(-1), 'streaming');
|
|
18
|
+
view.clear();
|
|
19
|
+
assert.equal(view.offset, 0);
|
|
20
|
+
assert.deepEqual(view.visible(40, 5), ['']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('styled history reflows on resize without leaking terminal controls', () => {
|
|
24
|
+
const view = new ChatViewport();
|
|
25
|
+
view.append('\x1b[92mabcdef\n界é界\x1b[0m\x1b[2K', 10);
|
|
26
|
+
assert.deepEqual(view.rows(3).map(plain), ['abc', 'def', '界é', '界']);
|
|
27
|
+
assert.ok(view.rows(3).every((row) => row.startsWith('\x1b[92m')));
|
|
28
|
+
assert.deepEqual(view.rows(10).map(plain), ['abcdef', '界é界']);
|
|
29
|
+
assert.deepEqual(view.caret(10, 5), { row: 2, column: 6 });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('viewport retains a pending full-width wrap and supports rewritten lines', () => {
|
|
33
|
+
const view = new ChatViewport();
|
|
34
|
+
view.append('12345', 5);
|
|
35
|
+
assert.deepEqual(view.caret(5, 3), { row: 1, column: 6 });
|
|
36
|
+
view.append('6', 5);
|
|
37
|
+
assert.deepEqual(view.visible(5, 3), ['12345', '6']);
|
|
38
|
+
view.append('\r\x1b[Kdone', 5);
|
|
39
|
+
assert.deepEqual(view.visible(5, 3), ['done']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('display history is bounded to the latest ten thousand logical lines', () => {
|
|
43
|
+
const view = new ChatViewport();
|
|
44
|
+
view.append(Array.from({ length: 10050 }, (_, i) => String(i)).join('\n'), 80);
|
|
45
|
+
view.scroll(Infinity, 80, 10);
|
|
46
|
+
assert.equal(view.visible(80, 10)[0], '50');
|
|
47
|
+
view.append('\nlast', 80);
|
|
48
|
+
assert.equal(view.visible(80, 10)[0], '51');
|
|
49
|
+
});
|