@robota-sdk/agent-transport 3.0.0-beta.83 → 3.0.0-beta.84

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 (43) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +80 -118
  3. package/dist/browser/client.d.ts +1 -1
  4. package/dist/browser/client.js +1 -1
  5. package/dist/browser/index.d.ts +60 -6
  6. package/dist/browser/index.d.ts.map +1 -1
  7. package/dist/browser/index.js +1 -1
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/observer-messages-CEeisRaF.js +2 -0
  10. package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
  11. package/dist/browser/{observer-messages-DC_8VHk9.d.ts → observer-messages-Dnff2Tdb.d.ts} +125 -3
  12. package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
  13. package/dist/node/index.cjs +1 -1
  14. package/dist/node/index.d.cts +181 -6
  15. package/dist/node/index.d.cts.map +1 -1
  16. package/dist/node/index.d.ts +181 -6
  17. package/dist/node/index.d.ts.map +1 -1
  18. package/dist/node/index.js +1 -1
  19. package/dist/node/index.js.map +1 -1
  20. package/package.json +12 -11
  21. package/src/__tests__/message-decoders.test.ts +295 -1
  22. package/src/__tests__/project-read-messages.test.ts +118 -0
  23. package/src/__tests__/session-directory-messages.test.ts +130 -0
  24. package/src/__tests__/session-error-classification.test.ts +113 -0
  25. package/src/__tests__/session-message-handler-observe.test.ts +25 -1
  26. package/src/__tests__/session-message-handler.test.ts +57 -3
  27. package/src/__tests__/settings-messages.test.ts +193 -0
  28. package/src/index.ts +4 -0
  29. package/src/message-decoders.ts +157 -3
  30. package/src/observer-messages.ts +11 -0
  31. package/src/project-read-messages.ts +74 -0
  32. package/src/protocol-session.ts +17 -1
  33. package/src/session-directory-messages.ts +78 -1
  34. package/src/session-error-classification.ts +91 -0
  35. package/src/session-events.ts +7 -1
  36. package/src/session-message-handler.ts +58 -8
  37. package/src/session-query-messages.ts +18 -1
  38. package/src/session-resume-bridge.ts +8 -0
  39. package/src/settings-messages.ts +103 -0
  40. package/src/wire-messages.ts +133 -4
  41. package/dist/browser/observer-messages-Bx-xReUQ.js +0 -2
  42. package/dist/browser/observer-messages-Bx-xReUQ.js.map +0 -1
  43. 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.83",
4
- "description": "Transport package boundary for Robota SDK — runtime hosts are owned by agent-framework",
3
+ "version": "3.0.0-beta.84",
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-core": "3.0.0-beta.83",
92
- "@robota-sdk/agent-interface-analytics": "3.0.0-beta.83",
93
- "@robota-sdk/agent-interface-command": "3.0.0-beta.83",
94
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.83",
95
- "@robota-sdk/agent-interface-session": "3.0.0-beta.83",
96
- "@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.83",
97
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.83"
92
+ "@robota-sdk/agent-core": "3.0.0-beta.84",
93
+ "@robota-sdk/agent-interface-command": "3.0.0-beta.84",
94
+ "@robota-sdk/agent-interface-analytics": "3.0.0-beta.84",
95
+ "@robota-sdk/agent-interface-execution": "3.0.0-beta.84",
96
+ "@robota-sdk/agent-interface-session": "3.0.0-beta.84",
97
+ "@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.84",
98
+ "@robota-sdk/agent-interface-transport": "3.0.0-beta.84"
98
99
  },
99
100
  "devDependencies": {
100
101
  "rimraf": "^5.0.10",
@@ -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
- messages: { type: 'messages', messages: [] },
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
+ });