subrouter-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CLAUDE.md +51 -0
  2. package/README.md +397 -2
  3. package/config.example.json +9 -0
  4. package/package.json +21 -4
  5. package/scripts/sse-harness.ts +252 -0
  6. package/scripts/sub-wrapper.sh +11 -0
  7. package/src/adapters/anthropic.ts +311 -0
  8. package/src/adapters/openai.ts +227 -0
  9. package/src/approval.ts +21 -0
  10. package/src/chatviewport.ts +123 -0
  11. package/src/client.ts +398 -0
  12. package/src/clipboard.ts +62 -0
  13. package/src/commandpolicy.ts +272 -0
  14. package/src/commands.ts +18 -0
  15. package/src/config.ts +107 -0
  16. package/src/effort.ts +18 -0
  17. package/src/images.ts +77 -0
  18. package/src/index.ts +248 -0
  19. package/src/lineinput.ts +726 -0
  20. package/src/loop.ts +201 -0
  21. package/src/markdown.ts +244 -0
  22. package/src/repl.ts +700 -0
  23. package/src/sessions.ts +58 -0
  24. package/src/sse.ts +64 -0
  25. package/src/terminal.ts +228 -0
  26. package/src/token.ts +36 -0
  27. package/src/toolpreview.ts +54 -0
  28. package/src/tools.ts +790 -0
  29. package/src/types.ts +73 -0
  30. package/src/ui.ts +813 -0
  31. package/src/usage.ts +186 -0
  32. package/test/absolute-tools.test.ts +296 -0
  33. package/test/absolute-ui.test.ts +153 -0
  34. package/test/adapters.test.ts +205 -0
  35. package/test/anthropic.test.ts +246 -0
  36. package/test/auto-ui.test.ts +106 -0
  37. package/test/chatviewport.test.ts +49 -0
  38. package/test/client.test.ts +327 -0
  39. package/test/clipboard.test.ts +63 -0
  40. package/test/command-input.test.ts +183 -0
  41. package/test/command-tools.test.ts +141 -0
  42. package/test/commandpolicy.test.ts +252 -0
  43. package/test/disk-tools.test.ts +220 -0
  44. package/test/effort.test.ts +123 -0
  45. package/test/fixtures/openai-tools.sse +84 -0
  46. package/test/image-adapters.test.ts +78 -0
  47. package/test/image-ui.test.ts +151 -0
  48. package/test/images.test.ts +60 -0
  49. package/test/loop.test.ts +387 -0
  50. package/test/m5.test.ts +127 -0
  51. package/test/markdown.test.ts +201 -0
  52. package/test/repl-ui.test.ts +293 -0
  53. package/test/sse.test.ts +74 -0
  54. package/test/steering-ui.test.ts +182 -0
  55. package/test/steering.test.ts +68 -0
  56. package/test/terminal-ui.test.ts +144 -0
  57. package/test/terminal.test.ts +229 -0
  58. package/test/toolpreview.test.ts +51 -0
  59. package/test/tools.test.ts +227 -0
  60. package/test/ui.test.ts +635 -0
  61. package/test/usage-footer.test.ts +180 -0
  62. package/tsconfig.json +17 -0
@@ -0,0 +1,123 @@
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 { EFFORT_LEVELS, parseEffort } from '../src/effort.ts';
8
+ import { toWireBody } from '../src/adapters/openai.ts';
9
+ import { toAnthropicBody, AnthropicStreamParser, parseAnthropicCompleteBody } from '../src/adapters/anthropic.ts';
10
+ import { runAgentTurn } from '../src/loop.ts';
11
+ import type { ChatRequest, ChatMessage, AnthropicThinkingBlock } from '../src/types.ts';
12
+
13
+ const request: ChatRequest = { model: 'openai/test', dialect: 'openai', messages: [], system: '', stream: false, maxTokens: 8192 };
14
+
15
+ test('effort levels validate input without guessing aliases or unsupported values', () => {
16
+ for (const effort of EFFORT_LEVELS) assert.equal(parseEffort(effort), effort);
17
+ assert.equal(parseEffort(' HIGH '), 'high');
18
+ for (const value of [undefined, null, 0, 'ultracode', 'off', 'unknown']) assert.equal(parseEffort(value), undefined);
19
+ });
20
+
21
+ test('Chat Completions sends selected reasoning effort and omits automatic defaults', () => {
22
+ for (const effort of EFFORT_LEVELS.filter((level) => level !== 'auto'))
23
+ assert.equal(toWireBody({ ...request, effort }).reasoning_effort, effort);
24
+ assert.ok(!('reasoning_effort' in toWireBody(request)));
25
+ assert.ok(!('reasoning_effort' in toWireBody({ ...request, effort: 'auto' })));
26
+ });
27
+
28
+ test('Messages translates effort into thinking budgets with output headroom', () => {
29
+ for (const [effort, budget] of Object.entries({ low: 2048, medium: 4096, high: 8192, xhigh: 16384, max: 32000 })) {
30
+ const body = toAnthropicBody({ ...request, dialect: 'anthropic', effort: parseEffort(effort) });
31
+ assert.deepEqual(body.thinking, { type: 'enabled', budget_tokens: budget });
32
+ assert.equal(body.max_tokens, Math.max(8192, budget + 4096));
33
+ assert.ok(!('reasoning_effort' in body));
34
+ }
35
+ assert.equal(toAnthropicBody({ ...request, effort: 'low', maxTokens: 40000 }).max_tokens, 40000);
36
+ for (const effort of [undefined, 'auto'] as const) assert.ok(!('thinking' in toAnthropicBody({ ...request, effort })));
37
+ });
38
+
39
+ test('signed and redacted thinking is retained for tool continuation, not emitted as answer text', () => {
40
+ const parser = new AnthropicStreamParser();
41
+ const push = (data: unknown) => parser.push(JSON.stringify(data));
42
+ assert.deepEqual(push({ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '', signature: '' } }), []);
43
+ assert.deepEqual(push({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'private thought' } }), []);
44
+ assert.deepEqual(push({ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'signed' } }), []);
45
+ push({ type: 'content_block_start', index: 1, content_block: { type: 'redacted_thinking', data: 'opaque' } });
46
+ push({ type: 'content_block_start', index: 2, content_block: { type: 'tool_use', id: 'call1', name: 'read_file', input: {} } });
47
+ push({ type: 'content_block_delta', index: 2, delta: { type: 'input_json_delta', partial_json: '{"path":"x"}' } });
48
+ const blocks: AnthropicThinkingBlock[] = [
49
+ { type: 'thinking', thinking: 'private thought', signature: 'signed' },
50
+ { type: 'redacted_thinking', data: 'opaque' },
51
+ ];
52
+ const message = parser.assistantMessage();
53
+ assert.equal(message.content, '');
54
+ assert.deepEqual(message.thinkingBlocks, blocks);
55
+ const body = toAnthropicBody({ ...request, messages: [message, { role: 'tool', toolCallId: 'call1', content: 'result' }] });
56
+ const messages = body.messages as { content: unknown[] }[];
57
+ assert.deepEqual(messages[0].content.slice(0, 2), blocks);
58
+ assert.equal((messages[0].content[2] as { type: string }).type, 'tool_use');
59
+ const complete = parseAnthropicCompleteBody({ content: [...blocks, { type: 'text', text: 'answer' }], stop_reason: 'end_turn' });
60
+ assert.deepEqual(complete.message.thinkingBlocks, blocks);
61
+ assert.equal(complete.message.content, 'answer');
62
+ assert.deepEqual((toWireBody({ ...request, messages: [complete.message] }).messages as unknown[]), [{ role: 'assistant', content: 'answer' }]);
63
+ });
64
+
65
+ test('effort stays selected across every tool round', async () => {
66
+ const requests: ChatRequest[] = [];
67
+ const history: ChatMessage[] = [{ role: 'user', content: 'do it' }];
68
+ await runAgentTurn({
69
+ model: 'openai/test', effort: 'high', history, system: '', tools: [], autoApprove: false,
70
+ maxRounds: 3, maxTokens: 8192, signal: new AbortController().signal, onEvent: () => {},
71
+ ask: async () => 'yes', execute: async (call) => ({ toolCallId: call.id, content: 'done' }),
72
+ client: { chat: async (req) => {
73
+ requests.push(req);
74
+ return requests.length === 1 ? {
75
+ message: { role: 'assistant', content: '', toolCalls: [{ id: 'call1', name: 'read_file', arguments: { path: 'x' }, argumentsJson: '{"path":"x"}' }] },
76
+ stopReason: 'tool_calls',
77
+ } : { message: { role: 'assistant', content: 'done' }, stopReason: 'stop' };
78
+ } },
79
+ });
80
+ assert.deepEqual(requests.map((req) => req.effort), ['high', 'high']);
81
+ });
82
+
83
+ test('saved effort is validated, defaults to auto, and never appears as a wire auto enum', (t) => {
84
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-effort-config-'));
85
+ t.after(() => fs.rmSync(home, { recursive: true, force: true }));
86
+ const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
87
+ import assert from 'node:assert/strict';
88
+ import fs from 'node:fs';
89
+ const { resolveConfig, saveConfigFile, configPath } = await import('./src/config.ts');
90
+ assert.equal(resolveConfig().config.reasoningEffort, 'auto');
91
+ saveConfigFile({ reasoning_effort: 'high' });
92
+ assert.equal(resolveConfig().config.reasoningEffort, 'high');
93
+ const json = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
94
+ json.reasoning_effort = 'unknown';
95
+ fs.writeFileSync(configPath(), JSON.stringify(json));
96
+ assert.equal(resolveConfig().config.reasoningEffort, 'auto');
97
+ assert.ok(resolveConfig().warnings.some((warning) => warning.includes('reasoning_effort')));
98
+ `], { cwd: new URL('..', import.meta.url), encoding: 'utf8', env: { ...process.env, HOME: home } });
99
+ assert.equal(child.status, 0, child.stderr);
100
+ });
101
+
102
+ test('one-shot --effort overrides saved effort; invalid or missing levels exit before network', (t) => {
103
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-effort-cli-'));
104
+ t.after(() => fs.rmSync(home, { recursive: true, force: true }));
105
+ const directory = path.join(home, '.config/subrouter-cli');
106
+ fs.mkdirSync(directory, { recursive: true });
107
+ fs.writeFileSync(path.join(directory, 'config.json'), JSON.stringify({ default_model: 'openai/test', reasoning_effort: 'low' }));
108
+ const preload = `import assert from 'node:assert/strict'; globalThis.fetch = async (_url, init) => {
109
+ const body = JSON.parse(init.body); assert.equal(body.reasoning_effort, 'high');
110
+ return new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }] }), { status: 200 });
111
+ };`;
112
+ const child = spawnSync(process.execPath, ['--import', 'data:text/javascript,' + encodeURIComponent(preload), 'src/index.ts', '--effort', 'high', '-p', 'test'], {
113
+ cwd: new URL('..', import.meta.url), encoding: 'utf8', timeout: 5000,
114
+ env: { ...process.env, HOME: home, SUBROUTER_API_KEY: 'test-placeholder', SUBROUTER_BASE_URL: 'https://example.invalid/v1' },
115
+ });
116
+ assert.equal(child.status, 0, child.stderr || String(child.error));
117
+ assert.match(child.stdout, /ok/);
118
+ for (const args of [['--effort'], ['--effort', 'invalid']]) {
119
+ const invalid = spawnSync(process.execPath, ['src/index.ts', ...args], { cwd: new URL('..', import.meta.url), encoding: 'utf8', env: { ...process.env, HOME: home } });
120
+ assert.equal(invalid.status, 2);
121
+ assert.match(invalid.stderr, /--effort requires/);
122
+ }
123
+ });
@@ -0,0 +1,84 @@
1
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
2
+
3
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":"The"},"finish_reason":null}]}
4
+
5
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" user"},"finish_reason":null}]}
6
+
7
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" wants"},"finish_reason":null}]}
8
+
9
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" me"},"finish_reason":null}]}
10
+
11
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" to"},"finish_reason":null}]}
12
+
13
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" call"},"finish_reason":null}]}
14
+
15
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" the"},"finish_reason":null}]}
16
+
17
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" echo"},"finish_reason":null}]}
18
+
19
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" tool"},"finish_reason":null}]}
20
+
21
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" with"},"finish_reason":null}]}
22
+
23
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" any"},"finish_reason":null}]}
24
+
25
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" JSON"},"finish_reason":null}]}
26
+
27
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":","},"finish_reason":null}]}
28
+
29
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" then"},"finish_reason":null}]}
30
+
31
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" summarize"},"finish_reason":null}]}
32
+
33
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" what"},"finish_reason":null}]}
34
+
35
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" I"},"finish_reason":null}]}
36
+
37
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" did"},"finish_reason":null}]}
38
+
39
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":"."},"finish_reason":null}]}
40
+
41
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" I"},"finish_reason":null}]}
42
+
43
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" need"},"finish_reason":null}]}
44
+
45
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" to"},"finish_reason":null}]}
46
+
47
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" make"},"finish_reason":null}]}
48
+
49
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" a"},"finish_reason":null}]}
50
+
51
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" tool"},"finish_reason":null}]}
52
+
53
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" call"},"finish_reason":null}]}
54
+
55
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":"."},"finish_reason":null}]}
56
+
57
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" Let"},"finish_reason":null}]}
58
+
59
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" me"},"finish_reason":null}]}
60
+
61
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" call"},"finish_reason":null}]}
62
+
63
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" echo"},"finish_reason":null}]}
64
+
65
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" with"},"finish_reason":null}]}
66
+
67
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" a"},"finish_reason":null}]}
68
+
69
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" simple"},"finish_reason":null}]}
70
+
71
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" JSON"},"finish_reason":null}]}
72
+
73
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":" object"},"finish_reason":null}]}
74
+
75
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"reasoning_content":"."},"finish_reason":null}]}
76
+
77
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_00_nhRG5dbexAZN8QG7Q2Ja3727","type":"function","function":{"name":"echo","arguments":""}}]},"finish_reason":null}]}
78
+
79
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"value\": {\"message\": \"Hello, world!\", \"number\": 42, \"valid\": true}}"}}]},"finish_reason":null}]}
80
+
81
+ data: {"id":"chatcmpl-34339030298fa04a51c59cef","object":"chat.completion.chunk","created":1791649790,"model":"opencode/deepseek-v4-pro","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":377,"completion_tokens":97,"total_tokens":474,"prompt_tokens_details":{"cached_tokens":0}}}
82
+
83
+ data: [DONE]
84
+
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { toWireBody } from '../src/adapters/openai.ts';
4
+ import { toAnthropicBody } from '../src/adapters/anthropic.ts';
5
+ import { runAgentTurn } from '../src/loop.ts';
6
+ import type { ChatMessage, ChatRequest } from '../src/types.ts';
7
+ import type { ImageAttachment } from '../src/images.ts';
8
+
9
+ const image: ImageAttachment = { name: 'shot.png', mediaType: 'image/png', data: 'aW1hZ2U=' };
10
+ const request = (messages: ChatMessage[]): ChatRequest => ({
11
+ model: 'openai/test', dialect: 'openai', system: '', messages, stream: true,
12
+ });
13
+
14
+ test('both wire formats support text plus multiple images and image-only turns', () => {
15
+ for (const text of ['Explain this screenshot', '']) {
16
+ const req = request([{ role: 'user', content: text, images: [image, image] }]);
17
+ const textBlocks = text ? [{ type: 'text', text }] : [];
18
+ assert.deepEqual(toWireBody(req).messages, [{ role: 'user', content: [
19
+ ...textBlocks,
20
+ ...[image, image].map((i) => ({ type: 'image_url', image_url: { url: `data:${i.mediaType};base64,${i.data}` } })),
21
+ ] }]);
22
+ assert.deepEqual(toAnthropicBody(req).messages, [{ role: 'user', content: [
23
+ ...textBlocks,
24
+ ...[image, image].map((i) => ({ type: 'image', source: { type: 'base64', media_type: i.mediaType, data: i.data } })),
25
+ ] }]);
26
+ }
27
+ });
28
+
29
+ test('text-only shapes and tool result ordering are unchanged by image support', () => {
30
+ const req = request([
31
+ { role: 'user', content: 'plain' },
32
+ { role: 'assistant', content: '', toolCalls: [{ id: 't', name: 'grep', arguments: {}, argumentsJson: '{}' }] },
33
+ { role: 'tool', toolCallId: 't', content: 'result' },
34
+ { role: 'user', content: '', images: [image] },
35
+ ]);
36
+ const openai = toWireBody(req).messages as { role: string; content: unknown }[];
37
+ const anthropic = toAnthropicBody(req).messages as { role: string; content: unknown }[];
38
+ assert.deepEqual(openai[0], { role: 'user', content: 'plain' });
39
+ assert.deepEqual(anthropic[0], { role: 'user', content: 'plain' });
40
+ assert.deepEqual(openai[2], { role: 'tool', tool_call_id: 't', content: 'result' });
41
+ assert.deepEqual(anthropic[2], { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't', content: 'result', is_error: false }] });
42
+ assert.ok(Array.isArray(anthropic[3].content));
43
+ });
44
+
45
+ test('images survive every tool round; rough budgets count images rather than base64 text', async () => {
46
+ const history: ChatMessage[] = [{ role: 'user', content: '', images: [image] }];
47
+ const warnings: number[] = [];
48
+ let rounds = 0;
49
+ await runAgentTurn({
50
+ client: { chat: async (req) => {
51
+ assert.deepEqual(req.messages[0].images, [image]);
52
+ if (++rounds === 1) return { message: { role: 'assistant', content: '', toolCalls: [
53
+ { id: 't', name: 'grep', arguments: {}, argumentsJson: '{}' },
54
+ ] }, stopReason: 'tool_calls' };
55
+ return { message: { role: 'assistant', content: 'done' }, stopReason: 'stop' };
56
+ } },
57
+ model: 'openai/test', history, system: '', tools: [],
58
+ execute: async (call) => ({ toolCallId: call.id, content: 'found' }),
59
+ ask: async () => 'yes', autoApprove: true, maxRounds: 3, maxTokens: 200,
60
+ contextTokens: 2500, onBudget: (percent) => warnings.push(percent),
61
+ signal: new AbortController().signal, onEvent: () => {},
62
+ });
63
+ assert.equal(rounds, 2);
64
+ assert.deepEqual(warnings, [80]);
65
+ });
66
+
67
+ test('exact image-aware token counting takes precedence over the rough allowance', async () => {
68
+ const warnings: number[] = [];
69
+ await runAgentTurn({
70
+ client: { chat: async () => ({ message: { role: 'assistant', content: 'done' }, stopReason: 'stop' }) },
71
+ model: 'anthropic/claude-test', history: [{ role: 'user', content: '', images: [image] }],
72
+ system: '', tools: [], execute: async () => { throw new Error('no tools'); }, ask: async () => 'yes',
73
+ autoApprove: true, maxRounds: 1, maxTokens: 200, contextTokens: 1000,
74
+ countTokens: async (_m, _s, messages) => { assert.deepEqual(messages[0].images, [image]); return 100; },
75
+ onBudget: (percent) => warnings.push(percent), signal: new AbortController().signal, onEvent: () => {},
76
+ });
77
+ assert.deepEqual(warnings, []);
78
+ });
@@ -0,0 +1,151 @@
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 cwd = new URL('..', import.meta.url);
9
+ const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWVAAAAAASUVORK5CYII=', 'base64');
10
+
11
+ test('clipboard shortcuts queue images, preserve drafts, retry failures and clear explicitly without image commands', (t) => {
12
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-image-ui-'));
13
+ t.after(() => fs.rmSync(home, { recursive: true, force: true }));
14
+ fs.writeFileSync(path.join(home, 'shot two.png'), png);
15
+ const child = spawnSync(process.execPath, ['--input-type=module', '-e', String.raw`
16
+ import assert from 'node:assert/strict';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ Object.defineProperty(process.stdin, 'isTTY', { value: true });
20
+ Object.defineProperty(process.stdout, 'isTTY', { value: true });
21
+ Object.defineProperty(process.stdout, 'rows', { value: 24 });
22
+ Object.defineProperty(process.stdout, 'columns', { value: 100 });
23
+ process.stdin.setRawMode = () => {};
24
+ const { default: childProcess } = await import('node:child_process');
25
+ const { syncBuiltinESMExports } = await import('node:module');
26
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
27
+ const png = fs.readFileSync(path.join(process.env.HOME, 'shot two.png')).toString('base64');
28
+ let clipboard = { data: png }, delayClipboard = false, clipboardFailure = false, clipboardReads = 0;
29
+ childProcess.execFile = (file, args, options, callback) => {
30
+ assert.equal(file, '/usr/bin/osascript');
31
+ assert.deepEqual(args.slice(0, 3), ['-l', 'JavaScript', '-e']);
32
+ assert.equal(options.shell, false);
33
+ assert.equal(options.timeout, 10000);
34
+ clipboardReads++;
35
+ const finish = () => callback(options.signal.aborted ? options.signal.reason : clipboardFailure ? new Error(png) : null, JSON.stringify(clipboard), '');
36
+ if (delayClipboard) setTimeout(finish, 50);
37
+ else setImmediate(finish);
38
+ return {};
39
+ };
40
+ syncBuiltinESMExports();
41
+ const { ui } = await import('./src/ui.ts');
42
+ const { startRepl } = await import('./src/repl.ts');
43
+ process.chdir(process.env.HOME);
44
+ let status = 'ready';
45
+ let attached = [];
46
+ const setStatus = ui.setStatus.bind(ui), setAttachments = ui.setAttachments.bind(ui);
47
+ ui.setStatus = (value) => { status = value; setStatus(value); };
48
+ ui.setAttachments = (names) => { attached = names; setAttachments(names); };
49
+ const requests = [];
50
+ const client = {
51
+ listModels: async () => [{ id: 'openai/vision' }], usage: async () => ({}),
52
+ chat: async (req, emit) => {
53
+ requests.push(structuredClone(req));
54
+ if (requests.length === 1) throw new Error('temporary vision failure');
55
+ emit({ kind: 'text', text: '\n\nImage received.\n\n\n' });
56
+ return { message: { role: 'assistant', content: '\n\nImage received.\n\n\n' }, stopReason: 'stop' };
57
+ },
58
+ };
59
+ startRepl({ client, model: 'openai/vision', config: { maxToolRounds: 25, maxOutputTokens: 8192, autoApprove: false } });
60
+ const key = (text) => process.stdin.emit('data', Buffer.from(text));
61
+ const send = async (text) => {
62
+ key(text);
63
+ const deadline = Date.now() + 4000;
64
+ while (status !== 'ready' && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10));
65
+ assert.equal(status, 'ready');
66
+ await new Promise(setImmediate);
67
+ };
68
+ await new Promise(setImmediate);
69
+ for (const removed of ['/attach', '/detach', '/paste', '/help']) await send(removed + '\r');
70
+ assert.equal(clipboardReads, 0);
71
+ await send('\x16');
72
+ assert.deepEqual(attached, ['clipboard.png']);
73
+ assert.equal(requests.length, 0); // reading the clipboard never sends it
74
+ clipboard = { error: 'empty' };
75
+ await send('\x16');
76
+ assert.deepEqual(attached, ['clipboard.png']);
77
+ clipboard = { data: png };
78
+ clipboardFailure = true;
79
+ await send('\x16');
80
+ assert.deepEqual(attached, ['clipboard.png']);
81
+ clipboardFailure = false;
82
+ delayClipboard = true;
83
+ key('\x16\x16\x03');
84
+ await send(''); // cancellation must not add an image or start a second read
85
+ assert.equal(clipboardReads, 4);
86
+ assert.deepEqual(attached, ['clipboard.png']);
87
+ delayClipboard = false;
88
+ key('explain it');
89
+ await send('\x18');
90
+ assert.deepEqual(attached, []);
91
+ await send('\x16'); // keep the draft while awaiting clipboard data
92
+ assert.equal(requests.length, 0);
93
+ await send('\r');
94
+ assert.equal(requests[0].messages[0].content, 'explain it');
95
+ assert.deepEqual(attached, ['clipboard.png']); // failed request restores the queue
96
+ await send('\r');
97
+ assert.equal(requests[1].messages[0].content, '');
98
+ assert.equal(requests[1].messages[0].images[0].name, 'clipboard.png');
99
+ assert.deepEqual(attached, []);
100
+ await send('follow up\r');
101
+ const users = requests[2].messages.filter((message) => message.role === 'user');
102
+ assert.equal(users[0].images.length, 1); // sent images remain in history
103
+ assert.equal(users.at(-1).images, undefined); // queue is consumed exactly once
104
+ for (let i = 0; i < 5; i++) await send('\x16');
105
+ assert.equal(attached.length, 4); // rejected fifth paste leaves the queue intact
106
+ await send('\x18');
107
+ assert.deepEqual(attached, []);
108
+ await send('\x16');
109
+ await send('/clear\r');
110
+ assert.deepEqual(attached, []);
111
+ await send('fresh text\r');
112
+ assert.ok(requests.at(-1).messages.every((message) => !message.images));
113
+ const sessions = path.join(process.env.HOME, '.config/subrouter-cli/sessions');
114
+ const files = fs.readdirSync(sessions);
115
+ const markdown = files.filter((file) => file.endsWith('.md')).map((file) => fs.readFileSync(path.join(sessions, file), 'utf8')).join('\n');
116
+ const records = files.filter((file) => file.endsWith('.jsonl')).flatMap((file) => fs.readFileSync(path.join(sessions, file), 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)));
117
+ assert.match(markdown, /image: clipboard.png · image\/png/);
118
+ assert.ok(!markdown.includes(requests[1].messages[0].images[0].data));
119
+ assert.ok(records.some((record) => record.messages.some((message) => message.images?.length)));
120
+ key('/exit\r');
121
+ `], { cwd, encoding: 'utf8', timeout: 15000, env: { ...process.env, HOME: home, NO_COLOR: '' } });
122
+ assert.equal(child.status, 0, child.stderr || String(child.error));
123
+ assert.ok(!child.stdout.includes(png.toString('base64')), 'image bytes must never be rendered');
124
+ });
125
+
126
+ test('repeated --image flags reach one-shot wire requests; invalid paths fail before networking', (t) => {
127
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sub-image-cli-'));
128
+ t.after(() => fs.rmSync(home, { recursive: true, force: true }));
129
+ const file = path.join(home, 'shot.png');
130
+ fs.writeFileSync(file, png);
131
+ const preload = 'data:text/javascript,' + encodeURIComponent(`
132
+ import assert from 'node:assert/strict';
133
+ globalThis.fetch = async (_url, init) => {
134
+ const body = JSON.parse(init.body);
135
+ assert.equal(body.messages[1].content[0].text, 'explain');
136
+ assert.equal(body.messages[1].content.length, 3);
137
+ assert.equal(body.messages[1].content[1].image_url.url, ${JSON.stringify('data:image/png;base64,' + png.toString('base64'))});
138
+ return new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'Image received.' }, finish_reason: 'stop' }] }));
139
+ };
140
+ `);
141
+ const env = { ...process.env, HOME: home, SUBROUTER_API_KEY: 'test-only-key' };
142
+ const child = spawnSync(process.execPath, ['--import', preload, 'src/index.ts', '--model', 'openai/vision', '--image', file, '--image', file, '-p', 'explain'], { cwd, encoding: 'utf8', timeout: 15000, env });
143
+ assert.equal(child.status, 0, child.stderr || String(child.error));
144
+ assert.match(child.stdout, /Image received/);
145
+ assert.ok(!child.stdout.includes(png.toString('base64')));
146
+ for (const args of [['--image'], ['--image', 'missing.png', '-p', 'explain'], ['--check', '--image', file]]) {
147
+ const invalid = spawnSync(process.execPath, ['--import', 'data:text/javascript,globalThis.fetch=()=>{throw new Error("unexpected network")}', 'src/index.ts', ...args], { cwd, encoding: 'utf8', timeout: 15000, env });
148
+ assert.equal(invalid.status, 2, invalid.stderr || String(invalid.error));
149
+ assert.ok(!invalid.stderr.includes('unexpected network'));
150
+ }
151
+ });
@@ -0,0 +1,60 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { attachmentPath, loadImage, imageBytes, addImages, MAX_IMAGE_BYTES } from '../src/images.ts';
7
+
8
+ // Minimal signatures test format detection; the router/model validates actual image decoding.
9
+ const signatures = [
10
+ ['png', Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])],
11
+ ['jpeg', Buffer.from([255, 216, 255, 224])],
12
+ ['gif', Buffer.from('GIF89a')],
13
+ ['webp', Buffer.from('RIFF0000WEBP')],
14
+ ] as const;
15
+
16
+ test('explicit attachments resolve quoted, dragged, relative, absolute, and home paths', () => {
17
+ assert.equal(attachmentPath('"shots/my image.png"', '/tmp/project'), '/tmp/project/shots/my image.png');
18
+ assert.equal(attachmentPath("'shots/my image.png'", '/tmp/project'), '/tmp/project/shots/my image.png');
19
+ assert.equal(attachmentPath('/tmp/my\\ image.png', '/tmp/project'), '/tmp/my image.png');
20
+ assert.equal(attachmentPath('~/image.png', '/tmp/project'), path.join(os.homedir(), 'image.png'));
21
+ assert.throws(() => attachmentPath('', '/tmp/project'), /provide a local image path/);
22
+ assert.throws(() => attachmentPath('bad\0path', '/tmp/project'), /provide a local image path/);
23
+ });
24
+
25
+ test('loads supported local images using signatures rather than extensions', async (t) => {
26
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'sub-images-'));
27
+ t.after(() => fs.rm(cwd, { recursive: true, force: true }));
28
+ for (const [type, bytes] of signatures) {
29
+ const file = `sample.${type === 'png' ? 'txt' : type}`;
30
+ await fs.writeFile(path.join(cwd, file), bytes);
31
+ const image = await loadImage(file, cwd);
32
+ assert.equal(image.mediaType, `image/${type}`);
33
+ assert.equal(image.name, file);
34
+ assert.equal(image.data, bytes.toString('base64'));
35
+ assert.equal(imageBytes(image), bytes.length);
36
+ }
37
+ });
38
+
39
+ test('attachment queue enforces count and aggregate byte limits without mutating current images', () => {
40
+ const image = { name: 'a.png', mediaType: 'image/png' as const, data: 'aW1hZ2U=' };
41
+ const current = [image];
42
+ assert.equal(addImages(current, [image]).length, 2);
43
+ assert.equal(current.length, 1);
44
+ assert.throws(() => addImages(current, [image, image, image, image]), /at most 4/);
45
+ const large = { ...image, data: Buffer.alloc(MAX_IMAGE_BYTES).toString('base64') };
46
+ assert.throws(() => addImages([large, large], [image]), /20 MiB/);
47
+ });
48
+
49
+ test('rejects missing, non-image, directory, and oversized attachments', async (t) => {
50
+ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'sub-bad-images-'));
51
+ t.after(() => fs.rm(cwd, { recursive: true, force: true }));
52
+ await assert.rejects(loadImage('missing.png', cwd), /could not open/);
53
+ await assert.rejects(loadImage('.', cwd), /regular image files/);
54
+ await fs.writeFile(path.join(cwd, 'not-image.png'), 'not image bytes');
55
+ await assert.rejects(loadImage('not-image.png', cwd), /unsupported image/);
56
+ const handle = await fs.open(path.join(cwd, 'huge.png'), 'w');
57
+ await handle.truncate(MAX_IMAGE_BYTES + 1);
58
+ await handle.close();
59
+ await assert.rejects(loadImage('huge.png', cwd), /10 MiB/);
60
+ });