@robota-sdk/agent-transport 3.0.0-beta.83 → 3.0.0-beta.85
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/CHANGELOG.md +62 -0
- package/README.md +80 -118
- package/dist/browser/client.d.ts +1 -1
- package/dist/browser/client.js +1 -1
- package/dist/browser/index.d.ts +60 -6
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/observer-messages-CEeisRaF.js +2 -0
- package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
- package/dist/browser/{observer-messages-DC_8VHk9.d.ts → observer-messages-Dnff2Tdb.d.ts} +125 -3
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +181 -6
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +181 -6
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/package.json +12 -11
- package/src/__tests__/handoff-chunking.test.ts +44 -1
- package/src/__tests__/message-decoders.test.ts +295 -1
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/session-directory-messages.test.ts +130 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-message-handler-observe.test.ts +25 -1
- package/src/__tests__/session-message-handler.test.ts +57 -3
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/handoff-chunking.ts +52 -12
- package/src/index.ts +4 -0
- package/src/message-decoders.ts +157 -3
- package/src/observer-messages.ts +11 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +17 -1
- package/src/session-directory-messages.ts +78 -1
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +7 -1
- package/src/session-message-handler.ts +58 -8
- package/src/session-query-messages.ts +18 -1
- package/src/session-resume-bridge.ts +8 -0
- package/src/settings-messages.ts +103 -0
- package/src/wire-messages.ts +133 -4
- package/dist/browser/observer-messages-Bx-xReUQ.js +0 -2
- package/dist/browser/observer-messages-Bx-xReUQ.js.map +0 -1
- package/dist/browser/observer-messages-DC_8VHk9.d.ts.map +0 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-transport",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.0-beta.85",
|
|
4
|
+
"description": "Carrier-neutral wire protocol, session dispatch and delivery for Robota SDK transports",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/node/index.js",
|
|
7
7
|
"types": "dist/node/index.d.ts",
|
|
@@ -72,9 +72,10 @@
|
|
|
72
72
|
},
|
|
73
73
|
"repository": {
|
|
74
74
|
"type": "git",
|
|
75
|
-
"url": "https://github.com/woojubb/robota.git"
|
|
75
|
+
"url": "https://github.com/woojubb/robota.git",
|
|
76
|
+
"directory": "packages/agent-transport"
|
|
76
77
|
},
|
|
77
|
-
"homepage": "https://robota.io/",
|
|
78
|
+
"homepage": "https://docs.robota.io/en/packages/agent-transport/",
|
|
78
79
|
"bugs": {
|
|
79
80
|
"url": "https://github.com/woojubb/robota/issues"
|
|
80
81
|
},
|
|
@@ -88,13 +89,13 @@
|
|
|
88
89
|
],
|
|
89
90
|
"dependencies": {
|
|
90
91
|
"jose": "6.2.3",
|
|
91
|
-
"@robota-sdk/agent-
|
|
92
|
-
"@robota-sdk/agent-interface-
|
|
93
|
-
"@robota-sdk/agent-
|
|
94
|
-
"@robota-sdk/agent-interface-execution": "3.0.0-beta.
|
|
95
|
-
"@robota-sdk/agent-interface-
|
|
96
|
-
"@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.
|
|
97
|
-
"@robota-sdk/agent-interface-transport": "3.0.0-beta.
|
|
92
|
+
"@robota-sdk/agent-interface-command": "3.0.0-beta.85",
|
|
93
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.85",
|
|
94
|
+
"@robota-sdk/agent-core": "3.0.0-beta.85",
|
|
95
|
+
"@robota-sdk/agent-interface-execution": "3.0.0-beta.85",
|
|
96
|
+
"@robota-sdk/agent-interface-analytics": "3.0.0-beta.85",
|
|
97
|
+
"@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.85",
|
|
98
|
+
"@robota-sdk/agent-interface-transport": "3.0.0-beta.85"
|
|
98
99
|
},
|
|
99
100
|
"devDependencies": {
|
|
100
101
|
"rimraf": "^5.0.10",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
chunkCountFor,
|
|
@@ -213,3 +213,46 @@ describe('HANDOFF-001 TC-06 — a mangled chunk is caught where it arrives', ()
|
|
|
213
213
|
expect(JSON.parse(last?.serialized ?? '{}')).toEqual(record);
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
describe('without Node’s Buffer, as in the browser build', () => {
|
|
218
|
+
afterEach(() => {
|
|
219
|
+
vi.unstubAllGlobals();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('chunks and reassembles a multi-byte payload', () => {
|
|
223
|
+
vi.stubGlobal('Buffer', undefined);
|
|
224
|
+
const serialized = JSON.stringify({ text: '핸드오프 ✓ '.repeat(200) });
|
|
225
|
+
const assembler = new HandoffChunkAssembler('h-browser');
|
|
226
|
+
let result: ReturnType<HandoffChunkAssembler['accept']> | undefined;
|
|
227
|
+
|
|
228
|
+
for (const chunk of chunkHandoffPayload('h-browser', serialized, 64)) {
|
|
229
|
+
result = assembler.accept(chunk);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
expect(result).toMatchObject({ outcome: 'complete', serialized });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('keeps a leading byte-order mark', () => {
|
|
236
|
+
vi.stubGlobal('Buffer', undefined);
|
|
237
|
+
const serialized = '\uFEFF{"text":"bom"}';
|
|
238
|
+
const assembler = new HandoffChunkAssembler('h-bom');
|
|
239
|
+
let result: ReturnType<HandoffChunkAssembler['accept']> | undefined;
|
|
240
|
+
|
|
241
|
+
for (const chunk of chunkHandoffPayload('h-bom', serialized, 4)) {
|
|
242
|
+
result = assembler.accept(chunk);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
expect(result).toMatchObject({ outcome: 'complete', serialized });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('refuses a chunk whose base64 is not canonical', () => {
|
|
249
|
+
vi.stubGlobal('Buffer', undefined);
|
|
250
|
+
const [chunk] = chunkHandoffPayload('h-browser', 'hello', 64);
|
|
251
|
+
const assembler = new HandoffChunkAssembler('h-browser');
|
|
252
|
+
|
|
253
|
+
expect(assembler.accept({ ...chunk!, data: ` ${chunk!.data}` })).toMatchObject({
|
|
254
|
+
outcome: 'refused',
|
|
255
|
+
rejection: 'undecodable',
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -28,9 +28,18 @@ const CLIENT_SAMPLES: Readonly<Record<TClientMessage['type'], TClientMessage>> =
|
|
|
28
28
|
'get-context': { type: 'get-context' },
|
|
29
29
|
'get-commands': { type: 'get-commands' },
|
|
30
30
|
'get-status': { type: 'get-status' },
|
|
31
|
+
'list-models': { type: 'list-models', requestId: 'request-6' },
|
|
32
|
+
'get-agent-definitions': { type: 'get-agent-definitions', requestId: 'request-7' },
|
|
31
33
|
'list-sessions': { type: 'list-sessions', requestId: 'request-3' },
|
|
32
34
|
'new-session': { type: 'new-session', requestId: 'request-4' },
|
|
33
35
|
'switch-session': { type: 'switch-session', sessionId: 'session-2', requestId: 'request-5' },
|
|
36
|
+
'rename-session': {
|
|
37
|
+
type: 'rename-session',
|
|
38
|
+
sessionId: 'session-2',
|
|
39
|
+
name: 'Renamed',
|
|
40
|
+
requestId: 'request-6',
|
|
41
|
+
},
|
|
42
|
+
'delete-session': { type: 'delete-session', sessionId: 'session-2', requestId: 'request-7' },
|
|
34
43
|
'get-usage-report': { type: 'get-usage-report' },
|
|
35
44
|
'get-personal-usage-report': {
|
|
36
45
|
type: 'get-personal-usage-report',
|
|
@@ -53,6 +62,9 @@ const CLIENT_SAMPLES: Readonly<Record<TClientMessage['type'], TClientMessage>> =
|
|
|
53
62
|
cursor: { offset: 20 },
|
|
54
63
|
},
|
|
55
64
|
'stop-waiting-loop': { type: 'stop-waiting-loop', requestId: 'loop-1' },
|
|
65
|
+
'project-status': { type: 'project-status', requestId: 'project-status-1' },
|
|
66
|
+
'project-diff': { type: 'project-diff', requestId: 'project-diff-1', path: 'a.txt' },
|
|
67
|
+
'project-memory': { type: 'project-memory', requestId: 'project-memory-1' },
|
|
56
68
|
'get-background-tasks': {
|
|
57
69
|
type: 'get-background-tasks',
|
|
58
70
|
filter: { kind: 'agent', includeClosed: true },
|
|
@@ -73,6 +85,12 @@ const CLIENT_SAMPLES: Readonly<Record<TClientMessage['type'], TClientMessage>> =
|
|
|
73
85
|
'ask-response': { type: 'ask-response', id: 'a1', response: { type: 'answer', values: ['y'] } },
|
|
74
86
|
resume: { type: 'resume', lastSeq: 4 },
|
|
75
87
|
ack: { type: 'ack', seq: 9 },
|
|
88
|
+
'get-settings': { type: 'get-settings', requestId: 'settings-1' },
|
|
89
|
+
'update-settings': {
|
|
90
|
+
type: 'update-settings',
|
|
91
|
+
requestId: 'settings-2',
|
|
92
|
+
patch: { field: 'outputStyle', styleId: 'concise' },
|
|
93
|
+
},
|
|
76
94
|
};
|
|
77
95
|
|
|
78
96
|
const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> = {
|
|
@@ -91,7 +109,17 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
|
|
|
91
109
|
success: true,
|
|
92
110
|
requestId: 'command-1',
|
|
93
111
|
},
|
|
94
|
-
|
|
112
|
+
// #3288 §2: `display` is optional (an older host may not send it) and foreign-owned
|
|
113
|
+
// (`IHistoryDisplaySegment`, `agent-interface-session`) — checked shallowly here, same as
|
|
114
|
+
// `tool_start`'s `state`, per this file's own header.
|
|
115
|
+
messages: {
|
|
116
|
+
type: 'messages',
|
|
117
|
+
messages: [],
|
|
118
|
+
display: [
|
|
119
|
+
{ type: 'text', role: 'user', content: 'hi' },
|
|
120
|
+
{ type: 'tool', tool: { toolName: 'Bash', firstArg: 'ls', isRunning: false } as never },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
95
123
|
history: {
|
|
96
124
|
type: 'history',
|
|
97
125
|
startIndex: 3,
|
|
@@ -111,6 +139,24 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
|
|
|
111
139
|
turn_source: { type: 'turn_source', source: 'peer' },
|
|
112
140
|
commands: { type: 'commands', commands: [], skills: [] },
|
|
113
141
|
session_status: { type: 'session_status', status: {} as never },
|
|
142
|
+
model_list: {
|
|
143
|
+
type: 'model_list',
|
|
144
|
+
requestId: 'request-6',
|
|
145
|
+
groups: [
|
|
146
|
+
{ profileName: 'anthropic', providerLabel: 'Anthropic', models: [{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }] },
|
|
147
|
+
],
|
|
148
|
+
currentProfile: 'anthropic',
|
|
149
|
+
currentModel: 'claude-sonnet-4-6',
|
|
150
|
+
},
|
|
151
|
+
agent_definitions: {
|
|
152
|
+
type: 'agent_definitions',
|
|
153
|
+
requestId: 'request-7',
|
|
154
|
+
agents: [
|
|
155
|
+
{ name: 'general-purpose', description: 'General-purpose task execution agent.', definedIn: 'Built-in' },
|
|
156
|
+
{ name: 'Explore', description: 'Read-only codebase exploration agent.', definedIn: 'Built-in' },
|
|
157
|
+
],
|
|
158
|
+
current: 'general-purpose',
|
|
159
|
+
},
|
|
114
160
|
sessions: { type: 'sessions', requestId: 'request-3', listing: {} as never },
|
|
115
161
|
sessions_error: {
|
|
116
162
|
type: 'sessions_error',
|
|
@@ -125,6 +171,24 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
|
|
|
125
171
|
message: 'Answer the pending prompt first.',
|
|
126
172
|
requestId: 'request-5',
|
|
127
173
|
},
|
|
174
|
+
session_renamed_in_list: {
|
|
175
|
+
type: 'session_renamed_in_list',
|
|
176
|
+
requestId: 'request-6',
|
|
177
|
+
sessionId: 'session-2',
|
|
178
|
+
name: 'Renamed',
|
|
179
|
+
},
|
|
180
|
+
session_rename_failed: {
|
|
181
|
+
type: 'session_rename_failed',
|
|
182
|
+
requestId: 'request-6',
|
|
183
|
+
message: 'No session session-2 in this workspace.',
|
|
184
|
+
},
|
|
185
|
+
session_deleted: { type: 'session_deleted', requestId: 'request-7', sessionId: 'session-2' },
|
|
186
|
+
session_delete_failed: {
|
|
187
|
+
type: 'session_delete_failed',
|
|
188
|
+
requestId: 'request-7',
|
|
189
|
+
code: 'live_elsewhere',
|
|
190
|
+
message: 'Another client is on this session.',
|
|
191
|
+
},
|
|
128
192
|
usage_report: { type: 'usage_report', report: {} as never },
|
|
129
193
|
personal_usage_report: {
|
|
130
194
|
type: 'personal_usage_report',
|
|
@@ -168,6 +232,31 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
|
|
|
168
232
|
requestId: 'loop-1',
|
|
169
233
|
outcome: { kind: 'stopped', loopId: 'loop-a', message: 'Stopped loop-a.' },
|
|
170
234
|
},
|
|
235
|
+
project_status: {
|
|
236
|
+
type: 'project_status',
|
|
237
|
+
requestId: 'project-status-1',
|
|
238
|
+
result: {
|
|
239
|
+
kind: 'status',
|
|
240
|
+
branch: 'main',
|
|
241
|
+
unborn: false,
|
|
242
|
+
files: [{ path: 'a.txt', status: 'Modified', added: 1, removed: 2 }],
|
|
243
|
+
truncated: false,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
project_diff: {
|
|
247
|
+
type: 'project_diff',
|
|
248
|
+
requestId: 'project-diff-1',
|
|
249
|
+
result: {
|
|
250
|
+
kind: 'diff',
|
|
251
|
+
diffLines: [{ type: 'add', text: 'new line', lineNumber: 3 }],
|
|
252
|
+
truncated: false,
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
project_memory: {
|
|
256
|
+
type: 'project_memory',
|
|
257
|
+
requestId: 'project-memory-1',
|
|
258
|
+
result: { kind: 'memory', content: '# Memory', path: '.robota/memory/MEMORY.md', truncated: false },
|
|
259
|
+
},
|
|
171
260
|
background_task_event: { type: 'background_task_event', event: {} as never },
|
|
172
261
|
background_job_group_event: { type: 'background_job_group_event', event: {} as never },
|
|
173
262
|
plan_event: { type: 'plan_event', event: {} as never },
|
|
@@ -192,6 +281,13 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
|
|
|
192
281
|
},
|
|
193
282
|
protocol_error: { type: 'protocol_error', message: 'm', requestId: 'command-1' },
|
|
194
283
|
resume_gap: { type: 'resume_gap' },
|
|
284
|
+
settings: { type: 'settings', requestId: 'settings-1', settings: {} as never },
|
|
285
|
+
settings_error: {
|
|
286
|
+
type: 'settings_error',
|
|
287
|
+
requestId: 'settings-1',
|
|
288
|
+
code: 'not_available',
|
|
289
|
+
message: 'Settings are not available on this host.',
|
|
290
|
+
},
|
|
195
291
|
};
|
|
196
292
|
|
|
197
293
|
const MALFORMED_CLIENT: ReadonlyArray<[string, unknown]> = [
|
|
@@ -239,6 +335,13 @@ const MALFORMED_CLIENT: ReadonlyArray<[string, unknown]> = [
|
|
|
239
335
|
['ack with NaN', { type: 'ack', seq: Number.NaN }],
|
|
240
336
|
['cancel-background-task with an empty taskId', { type: 'cancel-background-task', taskId: '' }],
|
|
241
337
|
['list-sessions without requestId', { type: 'list-sessions' }],
|
|
338
|
+
['list-models without requestId', { type: 'list-models' }],
|
|
339
|
+
['list-models with an empty requestId', { type: 'list-models', requestId: '' }],
|
|
340
|
+
['get-agent-definitions without requestId', { type: 'get-agent-definitions' }],
|
|
341
|
+
[
|
|
342
|
+
'get-agent-definitions with an empty requestId',
|
|
343
|
+
{ type: 'get-agent-definitions', requestId: '' },
|
|
344
|
+
],
|
|
242
345
|
['get-history from a negative index', { type: 'get-history', fromIndex: -1 }],
|
|
243
346
|
['get-history from a fractional index', { type: 'get-history', fromIndex: 1.5 }],
|
|
244
347
|
['command with an empty requestId', { type: 'command', name: 'n', requestId: '' }],
|
|
@@ -263,6 +366,101 @@ const MALFORMED_CLIENT: ReadonlyArray<[string, unknown]> = [
|
|
|
263
366
|
],
|
|
264
367
|
['stop-waiting-loop without requestId', { type: 'stop-waiting-loop' }],
|
|
265
368
|
['stop-waiting-loop with an empty requestId', { type: 'stop-waiting-loop', requestId: '' }],
|
|
369
|
+
['get-settings without requestId', { type: 'get-settings' }],
|
|
370
|
+
['get-settings with an empty requestId', { type: 'get-settings', requestId: '' }],
|
|
371
|
+
['update-settings without a patch', { type: 'update-settings', requestId: 'r' }],
|
|
372
|
+
[
|
|
373
|
+
'update-settings with an unknown patch field',
|
|
374
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'nope' } },
|
|
375
|
+
],
|
|
376
|
+
[
|
|
377
|
+
'update-settings with a prototype-only patch field',
|
|
378
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'toString' } },
|
|
379
|
+
],
|
|
380
|
+
[
|
|
381
|
+
'update-settings language patch with a numeric language',
|
|
382
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'language', language: 1 } },
|
|
383
|
+
],
|
|
384
|
+
[
|
|
385
|
+
'update-settings outputStyle patch missing styleId',
|
|
386
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'outputStyle' } },
|
|
387
|
+
],
|
|
388
|
+
[
|
|
389
|
+
'update-settings preset patch with an empty presetId',
|
|
390
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'preset', presetId: '' } },
|
|
391
|
+
],
|
|
392
|
+
[
|
|
393
|
+
'update-settings permissionMode patch missing mode',
|
|
394
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'permissionMode' } },
|
|
395
|
+
],
|
|
396
|
+
[
|
|
397
|
+
'update-settings sandbox patch with a string enabled',
|
|
398
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'sandbox', enabled: 'true' } },
|
|
399
|
+
],
|
|
400
|
+
[
|
|
401
|
+
'update-settings removePermissionRule patch with an unknown kind',
|
|
402
|
+
{
|
|
403
|
+
type: 'update-settings',
|
|
404
|
+
requestId: 'r',
|
|
405
|
+
patch: { field: 'removePermissionRule', scope: 'user', kind: 'nope', pattern: 'Bash(*)' },
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
[
|
|
409
|
+
'update-settings removePermissionRule patch missing pattern',
|
|
410
|
+
{
|
|
411
|
+
type: 'update-settings',
|
|
412
|
+
requestId: 'r',
|
|
413
|
+
patch: { field: 'removePermissionRule', scope: 'user', kind: 'allow' },
|
|
414
|
+
},
|
|
415
|
+
],
|
|
416
|
+
[
|
|
417
|
+
'update-settings mcpServerEnabled patch missing serverId',
|
|
418
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'mcpServerEnabled', enabled: true } },
|
|
419
|
+
],
|
|
420
|
+
[
|
|
421
|
+
'update-settings mcpServerEnabled patch with a string enabled',
|
|
422
|
+
{
|
|
423
|
+
type: 'update-settings',
|
|
424
|
+
requestId: 'r',
|
|
425
|
+
patch: { field: 'mcpServerEnabled', serverId: 'docs', enabled: 'true' },
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
[
|
|
429
|
+
'update-settings pluginEnabled patch with an empty pluginId',
|
|
430
|
+
{
|
|
431
|
+
type: 'update-settings',
|
|
432
|
+
requestId: 'r',
|
|
433
|
+
patch: { field: 'pluginEnabled', pluginId: '', enabled: true },
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
[
|
|
437
|
+
'update-settings installPlugin patch missing pluginId',
|
|
438
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'installPlugin' } },
|
|
439
|
+
],
|
|
440
|
+
[
|
|
441
|
+
'update-settings uninstallPlugin patch with a numeric pluginId',
|
|
442
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'uninstallPlugin', pluginId: 1 } },
|
|
443
|
+
],
|
|
444
|
+
[
|
|
445
|
+
'update-settings providerProfile patch missing profileName',
|
|
446
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'providerProfile' } },
|
|
447
|
+
],
|
|
448
|
+
[
|
|
449
|
+
'update-settings providerProfile patch with an empty profileName',
|
|
450
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'providerProfile', profileName: '' } },
|
|
451
|
+
],
|
|
452
|
+
[
|
|
453
|
+
'update-settings providerModel patch missing modelId',
|
|
454
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'providerModel', profileName: 'anthropic' } },
|
|
455
|
+
],
|
|
456
|
+
[
|
|
457
|
+
'update-settings providerModel patch missing profileName',
|
|
458
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'providerModel', modelId: 'claude-haiku-4-5' } },
|
|
459
|
+
],
|
|
460
|
+
[
|
|
461
|
+
'update-settings deleteProviderProfile patch missing profileName',
|
|
462
|
+
{ type: 'update-settings', requestId: 'r', patch: { field: 'deleteProviderProfile' } },
|
|
463
|
+
],
|
|
266
464
|
];
|
|
267
465
|
|
|
268
466
|
const MALFORMED_SERVER: ReadonlyArray<[string, unknown]> = [
|
|
@@ -275,6 +473,9 @@ const MALFORMED_SERVER: ReadonlyArray<[string, unknown]> = [
|
|
|
275
473
|
['thinking with a string flag', { type: 'thinking', isThinking: 'yes' }],
|
|
276
474
|
['messages with a non-array', { type: 'messages', messages: {} }],
|
|
277
475
|
['messages with a primitive entry', { type: 'messages', messages: ['x'] }],
|
|
476
|
+
['messages with a numeric driverId', { type: 'messages', messages: [], driverId: 12345 }],
|
|
477
|
+
['messages with a non-array display', { type: 'messages', messages: [], display: {} }],
|
|
478
|
+
['messages with a display holding a primitive entry', { type: 'messages', messages: [], display: ['x'] }],
|
|
278
479
|
['pending with a number', { type: 'pending', pending: 1, pendingCount: 1 }],
|
|
279
480
|
['pending with a negative count', { type: 'pending', pending: null, pendingCount: -1 }],
|
|
280
481
|
['pending with a fractional count', { type: 'pending', pending: 'p', pendingCount: 1.5 }],
|
|
@@ -289,6 +490,30 @@ const MALFORMED_SERVER: ReadonlyArray<[string, unknown]> = [
|
|
|
289
490
|
{ type: 'sessions_error', requestId: 'r', code: 'nope', message: 'm' },
|
|
290
491
|
],
|
|
291
492
|
['session_switched without event', { type: 'session_switched' }],
|
|
493
|
+
['model_list without requestId', { type: 'model_list', groups: [], currentModel: 'm' }],
|
|
494
|
+
[
|
|
495
|
+
'model_list with a non-array groups',
|
|
496
|
+
{ type: 'model_list', requestId: 'r', groups: {}, currentModel: 'm' },
|
|
497
|
+
],
|
|
498
|
+
['model_list without currentModel', { type: 'model_list', requestId: 'r', groups: [] }],
|
|
499
|
+
[
|
|
500
|
+
'agent_definitions without requestId',
|
|
501
|
+
{ type: 'agent_definitions', agents: [], current: 'general-purpose' },
|
|
502
|
+
],
|
|
503
|
+
[
|
|
504
|
+
'agent_definitions with a non-array agents',
|
|
505
|
+
{ type: 'agent_definitions', requestId: 'r', agents: {}, current: 'general-purpose' },
|
|
506
|
+
],
|
|
507
|
+
[
|
|
508
|
+
'agent_definitions with an agent missing definedIn',
|
|
509
|
+
{
|
|
510
|
+
type: 'agent_definitions',
|
|
511
|
+
requestId: 'r',
|
|
512
|
+
agents: [{ name: 'a', description: 'd' }],
|
|
513
|
+
current: 'a',
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
['agent_definitions without current', { type: 'agent_definitions', requestId: 'r', agents: [] }],
|
|
292
517
|
[
|
|
293
518
|
'session_change_failed with an unknown code',
|
|
294
519
|
{ type: 'session_change_failed', code: 'nope', message: 'm' },
|
|
@@ -355,6 +580,13 @@ const MALFORMED_SERVER: ReadonlyArray<[string, unknown]> = [
|
|
|
355
580
|
'waiting_loop_stop with a prototype-only kind',
|
|
356
581
|
{ type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'toString' } },
|
|
357
582
|
],
|
|
583
|
+
['settings without requestId', { type: 'settings', settings: {} }],
|
|
584
|
+
['settings with an array settings', { type: 'settings', requestId: 'r', settings: [] }],
|
|
585
|
+
[
|
|
586
|
+
'settings_error with an unknown code',
|
|
587
|
+
{ type: 'settings_error', requestId: 'r', code: 'nope', message: 'm' },
|
|
588
|
+
],
|
|
589
|
+
['settings_error without message', { type: 'settings_error', requestId: 'r', code: 'invalid' }],
|
|
358
590
|
];
|
|
359
591
|
|
|
360
592
|
describe('decodeClientMessage (issue #2045)', () => {
|
|
@@ -372,12 +604,42 @@ describe('decodeClientMessage (issue #2045)', () => {
|
|
|
372
604
|
expect(decodeClientMessage(value).ok).toBe(false);
|
|
373
605
|
});
|
|
374
606
|
|
|
607
|
+
// #3282 §4b: "Providers & Models" — Use, Model and Delete each add one `update-settings` patch
|
|
608
|
+
// field; `CLIENT_SAMPLES` above only round-trips one field (`outputStyle`) per variant, so these
|
|
609
|
+
// three get their own explicit round trip.
|
|
610
|
+
it.each([
|
|
611
|
+
{ field: 'providerProfile', profileName: 'backup' },
|
|
612
|
+
{ field: 'providerModel', profileName: 'backup', modelId: 'claude-haiku-4-5' },
|
|
613
|
+
{ field: 'deleteProviderProfile', profileName: 'backup' },
|
|
614
|
+
])('decodes an update-settings patch with field %j', (patch) => {
|
|
615
|
+
const message = { type: 'update-settings', requestId: 'r', patch };
|
|
616
|
+
expect(decodeClientMessage(JSON.parse(JSON.stringify(message)))).toEqual({
|
|
617
|
+
ok: true,
|
|
618
|
+
message,
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
|
|
375
622
|
it('keeps the protocol wording for a bad submit prompt', () => {
|
|
376
623
|
expect(decodeClientMessage({ type: 'submit', prompt: 1 })).toEqual({
|
|
377
624
|
ok: false,
|
|
378
625
|
reason: 'prompt must be a non-empty string',
|
|
379
626
|
});
|
|
380
627
|
});
|
|
628
|
+
|
|
629
|
+
it.each([
|
|
630
|
+
{ field: 'mcpServerEnabled', serverId: 'docs', enabled: true },
|
|
631
|
+
{ field: 'reloadMcpServers' },
|
|
632
|
+
{ field: 'pluginEnabled', pluginId: 'formatter@robota', enabled: false },
|
|
633
|
+
{ field: 'reloadPlugins' },
|
|
634
|
+
{ field: 'installPlugin', pluginId: 'linter@robota' },
|
|
635
|
+
{ field: 'uninstallPlugin', pluginId: 'formatter@robota' },
|
|
636
|
+
] as const)('accepts the $field settings patch (#3282 §4 part b-2)', (patch) => {
|
|
637
|
+
const message = { type: 'update-settings' as const, requestId: 'r', patch };
|
|
638
|
+
expect(decodeClientMessage(JSON.parse(JSON.stringify(message)))).toEqual({
|
|
639
|
+
ok: true,
|
|
640
|
+
message,
|
|
641
|
+
});
|
|
642
|
+
});
|
|
381
643
|
});
|
|
382
644
|
|
|
383
645
|
describe('decodeServerMessage (issue #2045)', () => {
|
|
@@ -424,6 +686,38 @@ describe('decodeServerMessage (issue #2045)', () => {
|
|
|
424
686
|
message: { type: 'pending', pending: null },
|
|
425
687
|
});
|
|
426
688
|
});
|
|
689
|
+
|
|
690
|
+
it('accepts an error frame carrying its classification (#3289 §3), and one without it', () => {
|
|
691
|
+
const classified = {
|
|
692
|
+
type: 'error',
|
|
693
|
+
message: 'Authentication Error: invalid x-api-key',
|
|
694
|
+
code: 'rate_limit',
|
|
695
|
+
provider: 'anthropic',
|
|
696
|
+
retryAfterSeconds: 30,
|
|
697
|
+
};
|
|
698
|
+
expect(decodeServerMessage(classified)).toEqual({ ok: true, message: classified });
|
|
699
|
+
expect(decodeServerMessage({ type: 'error', message: 'boom' })).toEqual({
|
|
700
|
+
ok: true,
|
|
701
|
+
message: { type: 'error', message: 'boom' },
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('accepts an error frame naming the model a model_unavailable failure tried', () => {
|
|
706
|
+
const classified = {
|
|
707
|
+
type: 'error',
|
|
708
|
+
message: 'No model available for provider "anthropic"',
|
|
709
|
+
code: 'model_unavailable',
|
|
710
|
+
provider: 'anthropic',
|
|
711
|
+
model: 'claude-x',
|
|
712
|
+
};
|
|
713
|
+
expect(decodeServerMessage(classified)).toEqual({ ok: true, message: classified });
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('refuses an error frame with an unrecognized code', () => {
|
|
717
|
+
expect(
|
|
718
|
+
decodeServerMessage({ type: 'error', message: 'm', code: 'not-a-real-code' }).ok,
|
|
719
|
+
).toBe(false);
|
|
720
|
+
});
|
|
427
721
|
});
|
|
428
722
|
|
|
429
723
|
describe('decodeFrame — the one carrier path', () => {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3282 §4c — the Project panel's reads dispatch: success answers the matching `project_*` message
|
|
3
|
+
* under the request's `requestId`; a rejected read becomes a `failed`/`unavailable` result (never an
|
|
4
|
+
* uncaught rejection — the exact class of bug the `/memory` red-toast fix addresses at the command
|
|
5
|
+
* layer, guarded again here at the wire boundary); a session that does not implement the capability
|
|
6
|
+
* (`Partial<ISessionProjectRead>` — see `protocol-session.ts`) answers `protocol_error`, never a throw.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
11
|
+
import { handleProjectReadMessage, isProjectReadMessage } from '../project-read-messages.js';
|
|
12
|
+
|
|
13
|
+
import type { TOutboundDeliver } from '../outbound-delivery.js';
|
|
14
|
+
import type { TProjectReadCapableSession } from '../protocol-session.js';
|
|
15
|
+
import type { TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
16
|
+
|
|
17
|
+
function collect(): { sent: TServerMessage[]; deliver: TOutboundDeliver } {
|
|
18
|
+
const sent: TServerMessage[] = [];
|
|
19
|
+
return { sent, deliver: createOutboundDelivery((m) => sent.push(m), vi.fn()) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('isProjectReadMessage', () => {
|
|
23
|
+
it('recognizes exactly the three Project panel read types', () => {
|
|
24
|
+
expect(isProjectReadMessage({ type: 'project-status', requestId: 'r' })).toBe(true);
|
|
25
|
+
expect(isProjectReadMessage({ type: 'project-diff', requestId: 'r', path: 'a.txt' })).toBe(true);
|
|
26
|
+
expect(isProjectReadMessage({ type: 'project-memory', requestId: 'r' })).toBe(true);
|
|
27
|
+
expect(isProjectReadMessage({ type: 'get-status' } as TClientMessage)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('handleProjectReadMessage', () => {
|
|
32
|
+
it('answers project_status with the session result under the same requestId', async () => {
|
|
33
|
+
const { sent, deliver } = collect();
|
|
34
|
+
const session = {
|
|
35
|
+
readProjectStatus: vi.fn().mockResolvedValue({ kind: 'not-a-repository' }),
|
|
36
|
+
} as unknown as TProjectReadCapableSession;
|
|
37
|
+
handleProjectReadMessage(session, deliver, { type: 'project-status', requestId: 'r1' });
|
|
38
|
+
await Promise.resolve();
|
|
39
|
+
expect(sent).toEqual([
|
|
40
|
+
{ type: 'project_status', requestId: 'r1', result: { kind: 'not-a-repository' } },
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('passes the path through to readProjectDiff and answers project_diff', async () => {
|
|
45
|
+
const { sent, deliver } = collect();
|
|
46
|
+
const readProjectDiff = vi.fn().mockResolvedValue({ kind: 'diff', diffLines: [], truncated: false });
|
|
47
|
+
const session = { readProjectDiff } as unknown as TProjectReadCapableSession;
|
|
48
|
+
handleProjectReadMessage(session, deliver, {
|
|
49
|
+
type: 'project-diff',
|
|
50
|
+
requestId: 'r2',
|
|
51
|
+
path: 'src/a.ts',
|
|
52
|
+
});
|
|
53
|
+
await Promise.resolve();
|
|
54
|
+
expect(readProjectDiff).toHaveBeenCalledExactlyOnceWith('src/a.ts');
|
|
55
|
+
expect(sent).toEqual([
|
|
56
|
+
{ type: 'project_diff', requestId: 'r2', result: { kind: 'diff', diffLines: [], truncated: false } },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('answers project_memory with the session result', async () => {
|
|
61
|
+
const { sent, deliver } = collect();
|
|
62
|
+
const session = {
|
|
63
|
+
readProjectMemory: vi
|
|
64
|
+
.fn()
|
|
65
|
+
.mockResolvedValue({ kind: 'memory', content: 'x', path: 'MEMORY.md', truncated: false }),
|
|
66
|
+
} as unknown as TProjectReadCapableSession;
|
|
67
|
+
handleProjectReadMessage(session, deliver, { type: 'project-memory', requestId: 'r3' });
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
expect(sent).toEqual([
|
|
70
|
+
{
|
|
71
|
+
type: 'project_memory',
|
|
72
|
+
requestId: 'r3',
|
|
73
|
+
result: { kind: 'memory', content: 'x', path: 'MEMORY.md', truncated: false },
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('turns a rejected readProjectStatus into a failed result, never an uncaught rejection', async () => {
|
|
79
|
+
const { sent, deliver } = collect();
|
|
80
|
+
const session = {
|
|
81
|
+
readProjectStatus: vi.fn().mockRejectedValue(new Error('git not found')),
|
|
82
|
+
} as unknown as TProjectReadCapableSession;
|
|
83
|
+
handleProjectReadMessage(session, deliver, { type: 'project-status', requestId: 'r4' });
|
|
84
|
+
await Promise.resolve();
|
|
85
|
+
await Promise.resolve();
|
|
86
|
+
expect(sent).toEqual([
|
|
87
|
+
{ type: 'project_status', requestId: 'r4', result: { kind: 'failed', message: 'git not found' } },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('turns a rejected readProjectMemory into an unavailable result', async () => {
|
|
92
|
+
const { sent, deliver } = collect();
|
|
93
|
+
const session = {
|
|
94
|
+
readProjectMemory: vi.fn().mockRejectedValue(new Error('no store')),
|
|
95
|
+
} as unknown as TProjectReadCapableSession;
|
|
96
|
+
handleProjectReadMessage(session, deliver, { type: 'project-memory', requestId: 'r5' });
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
expect(sent).toEqual([
|
|
100
|
+
{ type: 'project_memory', requestId: 'r5', result: { kind: 'unavailable', message: 'no store' } },
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('answers protocol_error, never a throw, when the session has no project-read capability', () => {
|
|
105
|
+
const { sent, deliver } = collect();
|
|
106
|
+
const session = {} as unknown as TProjectReadCapableSession;
|
|
107
|
+
expect(() =>
|
|
108
|
+
handleProjectReadMessage(session, deliver, { type: 'project-status', requestId: 'r6' }),
|
|
109
|
+
).not.toThrow();
|
|
110
|
+
expect(sent).toEqual([
|
|
111
|
+
{
|
|
112
|
+
type: 'protocol_error',
|
|
113
|
+
message: 'This host does not support the Project panel.',
|
|
114
|
+
requestId: 'r6',
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
});
|