@robota-sdk/agent-transport 3.0.0-beta.82 → 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 (62) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/README.md +80 -118
  3. package/dist/browser/client.d.ts +2 -2
  4. package/dist/browser/client.js +1 -1
  5. package/dist/browser/index.d.ts +87 -13
  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-Dnff2Tdb.d.ts +471 -0
  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 +308 -16
  15. package/dist/node/index.d.cts.map +1 -1
  16. package/dist/node/index.d.ts +308 -16
  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/dist/node/node.cjs +1 -1
  21. package/dist/node/node.d.cts +69 -1
  22. package/dist/node/node.d.cts.map +1 -1
  23. package/dist/node/node.d.ts +70 -2
  24. package/dist/node/node.d.ts.map +1 -1
  25. package/dist/node/node.js +1 -1
  26. package/dist/node/node.js.map +1 -1
  27. package/package.json +17 -14
  28. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  29. package/src/__tests__/history-attached-budget.test.ts +216 -0
  30. package/src/__tests__/message-decoders.test.ts +496 -6
  31. package/src/__tests__/project-read-messages.test.ts +118 -0
  32. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  33. package/src/__tests__/session-directory-messages.test.ts +322 -0
  34. package/src/__tests__/session-error-classification.test.ts +113 -0
  35. package/src/__tests__/session-history-wire.test.ts +342 -0
  36. package/src/__tests__/session-message-handler-observe.test.ts +217 -0
  37. package/src/__tests__/session-message-handler.test.ts +139 -4
  38. package/src/__tests__/settings-messages.test.ts +193 -0
  39. package/src/__tests__/ws-broadcast-events.test.ts +10 -0
  40. package/src/client.ts +9 -1
  41. package/src/index.ts +18 -2
  42. package/src/loop-control-messages.ts +34 -0
  43. package/src/message-decoders.ts +238 -6
  44. package/src/node/bearer-resource-gate.ts +291 -0
  45. package/src/node/index.ts +13 -0
  46. package/src/observer-messages.ts +47 -0
  47. package/src/open-prompts.ts +76 -0
  48. package/src/outbound-delivery.ts +11 -2
  49. package/src/project-read-messages.ts +74 -0
  50. package/src/protocol-session.ts +23 -1
  51. package/src/session-directory-messages.ts +162 -0
  52. package/src/session-error-classification.ts +91 -0
  53. package/src/session-events.ts +100 -19
  54. package/src/session-message-handler.ts +110 -15
  55. package/src/session-query-messages.ts +123 -5
  56. package/src/session-resume-bridge.ts +8 -0
  57. package/src/settings-messages.ts +103 -0
  58. package/src/wire-messages.ts +238 -11
  59. package/dist/browser/message-decoders-BB8n-H2X.d.ts +0 -252
  60. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
  61. package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
  62. package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { SESSION_CHANGE_REFUSAL_CODES } from '@robota-sdk/agent-interface-session';
1
2
  import { describe, expect, it } from 'vitest';
2
3
 
3
4
  import {
@@ -18,11 +19,27 @@ import type { TClientMessage, TServerMessage } from '../wire-messages.js';
18
19
  */
19
20
  const CLIENT_SAMPLES: Readonly<Record<TClientMessage['type'], TClientMessage>> = {
20
21
  submit: { type: 'submit', prompt: 'hi' },
21
- command: { type: 'command', name: 'help', args: 'x' },
22
+ command: { type: 'command', name: 'help', args: 'x', requestId: 'command-1' },
22
23
  abort: { type: 'abort' },
23
24
  'cancel-queue': { type: 'cancel-queue' },
24
25
  'get-messages': { type: 'get-messages' },
26
+ 'get-history': { type: 'get-history', fromIndex: 40 },
27
+ 'get-prompts': { type: 'get-prompts' },
25
28
  'get-context': { type: 'get-context' },
29
+ 'get-commands': { type: 'get-commands' },
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' },
33
+ 'list-sessions': { type: 'list-sessions', requestId: 'request-3' },
34
+ 'new-session': { type: 'new-session', requestId: 'request-4' },
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' },
26
43
  'get-usage-report': { type: 'get-usage-report' },
27
44
  'get-personal-usage-report': {
28
45
  type: 'get-personal-usage-report',
@@ -38,6 +55,16 @@ const CLIENT_SAMPLES: Readonly<Record<TClientMessage['type'], TClientMessage>> =
38
55
  'get-executing': { type: 'get-executing' },
39
56
  'get-pending': { type: 'get-pending' },
40
57
  'get-execution-workspace': { type: 'get-execution-workspace' },
58
+ 'read-execution-detail': {
59
+ type: 'read-execution-detail',
60
+ requestId: 'detail-1',
61
+ entryId: 'main',
62
+ cursor: { offset: 20 },
63
+ },
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' },
41
68
  'get-background-tasks': {
42
69
  type: 'get-background-tasks',
43
70
  filter: { kind: 'agent', includeClosed: true },
@@ -58,6 +85,12 @@ const CLIENT_SAMPLES: Readonly<Record<TClientMessage['type'], TClientMessage>> =
58
85
  'ask-response': { type: 'ask-response', id: 'a1', response: { type: 'answer', values: ['y'] } },
59
86
  resume: { type: 'resume', lastSeq: 4 },
60
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
+ },
61
94
  };
62
95
 
63
96
  const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> = {
@@ -69,9 +102,93 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
69
102
  complete: { type: 'complete', result: {} as never },
70
103
  interrupted: { type: 'interrupted', result: {} as never },
71
104
  error: { type: 'error', message: 'm' },
72
- command_result: { type: 'command_result', name: 'n', message: 'm', success: true },
73
- messages: { type: 'messages', messages: [] },
105
+ command_result: {
106
+ type: 'command_result',
107
+ name: 'n',
108
+ message: 'm',
109
+ success: true,
110
+ requestId: 'command-1',
111
+ },
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
+ },
123
+ history: {
124
+ type: 'history',
125
+ startIndex: 3,
126
+ total: 4,
127
+ entries: [
128
+ {
129
+ id: 'e1',
130
+ timestamp: '2026-09-26T01:02:03.004Z',
131
+ category: 'event',
132
+ type: 'skill-activation',
133
+ data: { name: 'review' },
134
+ },
135
+ ],
136
+ },
74
137
  context: { type: 'context', state: {} as never },
138
+ history_changed: { type: 'history_changed' },
139
+ turn_source: { type: 'turn_source', source: 'peer' },
140
+ commands: { type: 'commands', commands: [], skills: [] },
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
+ },
160
+ sessions: { type: 'sessions', requestId: 'request-3', listing: {} as never },
161
+ sessions_error: {
162
+ type: 'sessions_error',
163
+ requestId: 'request-3',
164
+ code: 'list_failed',
165
+ message: 'unreadable store',
166
+ },
167
+ session_switched: { type: 'session_switched', event: { sessionId: 'session-2' } },
168
+ session_change_failed: {
169
+ type: 'session_change_failed',
170
+ code: 'prompt_pending',
171
+ message: 'Answer the pending prompt first.',
172
+ requestId: 'request-5',
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
+ },
75
192
  usage_report: { type: 'usage_report', report: {} as never },
76
193
  personal_usage_report: {
77
194
  type: 'personal_usage_report',
@@ -98,8 +215,48 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
98
215
  message: 'not found',
99
216
  },
100
217
  executing: { type: 'executing', executing: false },
101
- pending: { type: 'pending', pending: null },
218
+ pending: { type: 'pending', pending: null, pendingCount: 0 },
102
219
  execution_workspace_event: { type: 'execution_workspace_event', snapshot: {} as never },
220
+ execution_detail: {
221
+ type: 'execution_detail',
222
+ requestId: 'detail-1',
223
+ page: { entryId: 'main', records: [], nextCursor: { offset: 20 } },
224
+ },
225
+ execution_detail_error: {
226
+ type: 'execution_detail_error',
227
+ requestId: 'detail-1',
228
+ message: 'Unknown entry: main',
229
+ },
230
+ waiting_loop_stop: {
231
+ type: 'waiting_loop_stop',
232
+ requestId: 'loop-1',
233
+ outcome: { kind: 'stopped', loopId: 'loop-a', message: 'Stopped loop-a.' },
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
+ },
103
260
  background_task_event: { type: 'background_task_event', event: {} as never },
104
261
  background_job_group_event: { type: 'background_job_group_event', event: {} as never },
105
262
  plan_event: { type: 'plan_event', event: {} as never },
@@ -122,8 +279,15 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
122
279
  taskId: 't',
123
280
  success: true,
124
281
  },
125
- protocol_error: { type: 'protocol_error', message: 'm' },
282
+ protocol_error: { type: 'protocol_error', message: 'm', requestId: 'command-1' },
126
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
+ },
127
291
  };
128
292
 
129
293
  const MALFORMED_CLIENT: ReadonlyArray<[string, unknown]> = [
@@ -170,6 +334,133 @@ const MALFORMED_CLIENT: ReadonlyArray<[string, unknown]> = [
170
334
  ['resume with a string lastSeq', { type: 'resume', lastSeq: '4' }],
171
335
  ['ack with NaN', { type: 'ack', seq: Number.NaN }],
172
336
  ['cancel-background-task with an empty taskId', { type: 'cancel-background-task', taskId: '' }],
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
+ ],
345
+ ['get-history from a negative index', { type: 'get-history', fromIndex: -1 }],
346
+ ['get-history from a fractional index', { type: 'get-history', fromIndex: 1.5 }],
347
+ ['command with an empty requestId', { type: 'command', name: 'n', requestId: '' }],
348
+ ['switch-session with an empty sessionId', { type: 'switch-session', sessionId: '' }],
349
+ [
350
+ 'switch-session with a numeric requestId',
351
+ { type: 'switch-session', sessionId: 's', requestId: 1 },
352
+ ],
353
+ ['new-session with an empty requestId', { type: 'new-session', requestId: '' }],
354
+ ['read-execution-detail without requestId', { type: 'read-execution-detail', entryId: 'main' }],
355
+ [
356
+ 'read-execution-detail with an empty entryId',
357
+ { type: 'read-execution-detail', requestId: 'r', entryId: '' },
358
+ ],
359
+ [
360
+ 'read-execution-detail with a negative offset',
361
+ { type: 'read-execution-detail', requestId: 'r', entryId: 'main', cursor: { offset: -1 } },
362
+ ],
363
+ [
364
+ 'read-execution-detail with a string offset',
365
+ { type: 'read-execution-detail', requestId: 'r', entryId: 'main', cursor: { offset: '2' } },
366
+ ],
367
+ ['stop-waiting-loop without requestId', { type: 'stop-waiting-loop' }],
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
+ ],
173
464
  ];
174
465
 
175
466
  const MALFORMED_SERVER: ReadonlyArray<[string, unknown]> = [
@@ -182,13 +473,120 @@ const MALFORMED_SERVER: ReadonlyArray<[string, unknown]> = [
182
473
  ['thinking with a string flag', { type: 'thinking', isThinking: 'yes' }],
183
474
  ['messages with a non-array', { type: 'messages', messages: {} }],
184
475
  ['messages with a primitive entry', { type: 'messages', messages: ['x'] }],
185
- ['pending with a number', { type: 'pending', pending: 1 }],
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'] }],
479
+ ['pending with a number', { type: 'pending', pending: 1, pendingCount: 1 }],
480
+ ['pending with a negative count', { type: 'pending', pending: null, pendingCount: -1 }],
481
+ ['pending with a fractional count', { type: 'pending', pending: 'p', pendingCount: 1.5 }],
186
482
  ['background_task with an array task', { type: 'background_task', taskId: 't', task: [] }],
187
483
  [
188
484
  'control result with an unknown action',
189
485
  { type: 'background_task_control_result', action: 'nuke', taskId: 't', success: true },
190
486
  ],
191
487
  ['command_result without success', { type: 'command_result', name: 'n', message: 'm' }],
488
+ [
489
+ 'sessions_error with an unknown code',
490
+ { type: 'sessions_error', requestId: 'r', code: 'nope', message: 'm' },
491
+ ],
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: [] }],
517
+ [
518
+ 'session_change_failed with an unknown code',
519
+ { type: 'session_change_failed', code: 'nope', message: 'm' },
520
+ ],
521
+ ['session_change_failed without message', { type: 'session_change_failed', code: 'limit' }],
522
+ [
523
+ 'session_change_failed with a numeric requestId',
524
+ { type: 'session_change_failed', code: 'limit', message: 'm', requestId: 1 },
525
+ ],
526
+ ['history without entries', { type: 'history', startIndex: 0, total: 0 }],
527
+ ['history without its place', { type: 'history', entries: [] }],
528
+ ['history with a negative total', { type: 'history', startIndex: 0, total: -1, entries: [] }],
529
+ ['history with a primitive entry', { type: 'history', startIndex: 0, total: 1, entries: ['x'] }],
530
+ [
531
+ 'history with a numeric timestamp',
532
+ {
533
+ type: 'history',
534
+ startIndex: 0,
535
+ total: 1,
536
+ entries: [{ id: 'e', timestamp: 1, category: 'chat', type: 'user' }],
537
+ },
538
+ ],
539
+ [
540
+ 'history with an entry missing its category',
541
+ {
542
+ type: 'history',
543
+ startIndex: 0,
544
+ total: 1,
545
+ entries: [{ id: 'e', timestamp: 't', type: 'user' }],
546
+ },
547
+ ],
548
+ [
549
+ 'protocol_error with a numeric requestId',
550
+ { type: 'protocol_error', message: 'm', requestId: 1 },
551
+ ],
552
+ ['turn_source with an unknown source', { type: 'turn_source', source: 'robot' }],
553
+ ['turn_source without source', { type: 'turn_source' }],
554
+ ['execution_detail without page', { type: 'execution_detail', requestId: 'r' }],
555
+ ['execution_detail with an array page', { type: 'execution_detail', requestId: 'r', page: [] }],
556
+ ['execution_detail without requestId', { type: 'execution_detail', page: {} }],
557
+ ['execution_detail_error without message', { type: 'execution_detail_error', requestId: 'r' }],
558
+ ['waiting_loop_stop without outcome', { type: 'waiting_loop_stop', requestId: 'r' }],
559
+ [
560
+ 'waiting_loop_stop with an unknown kind',
561
+ { type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'paused' } },
562
+ ],
563
+ [
564
+ 'waiting_loop_stop stopped without loopId',
565
+ { type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'stopped' } },
566
+ ],
567
+ [
568
+ 'waiting_loop_stop several without message',
569
+ { type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'several' } },
570
+ ],
571
+ [
572
+ 'waiting_loop_stop failed with a numeric loopId',
573
+ {
574
+ type: 'waiting_loop_stop',
575
+ requestId: 'r',
576
+ outcome: { kind: 'failed', loopId: 1, message: 'm' },
577
+ },
578
+ ],
579
+ [
580
+ 'waiting_loop_stop with a prototype-only kind',
581
+ { type: 'waiting_loop_stop', requestId: 'r', outcome: { kind: 'toString' } },
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' }],
192
590
  ];
193
591
 
194
592
  describe('decodeClientMessage (issue #2045)', () => {
@@ -206,12 +604,42 @@ describe('decodeClientMessage (issue #2045)', () => {
206
604
  expect(decodeClientMessage(value).ok).toBe(false);
207
605
  });
208
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
+
209
622
  it('keeps the protocol wording for a bad submit prompt', () => {
210
623
  expect(decodeClientMessage({ type: 'submit', prompt: 1 })).toEqual({
211
624
  ok: false,
212
625
  reason: 'prompt must be a non-empty string',
213
626
  });
214
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
+ });
215
643
  });
216
644
 
217
645
  describe('decodeServerMessage (issue #2045)', () => {
@@ -228,6 +656,68 @@ describe('decodeServerMessage (issue #2045)', () => {
228
656
  it.each(MALFORMED_SERVER)('refuses %s', (_label, value) => {
229
657
  expect(decodeServerMessage(value).ok).toBe(false);
230
658
  });
659
+
660
+ it('accepts every refusal code the session contracts declare, and a refusal without requestId', () => {
661
+ for (const code of SESSION_CHANGE_REFUSAL_CODES) {
662
+ expect(decodeServerMessage({ type: 'session_change_failed', code, message: 'm' }).ok).toBe(
663
+ true,
664
+ );
665
+ }
666
+ });
667
+
668
+ it('accepts every waiting-loop stop outcome', () => {
669
+ for (const outcome of [
670
+ { kind: 'none' },
671
+ { kind: 'several', message: 'Use /loop stop <id>.' },
672
+ { kind: 'stopped', loopId: 'loop-a' },
673
+ { kind: 'failed', message: 'store unavailable' },
674
+ { kind: 'failed', loopId: 'loop-a', message: 'store unavailable' },
675
+ ]) {
676
+ expect(decodeServerMessage({ type: 'waiting_loop_stop', requestId: 'r', outcome }).ok).toBe(
677
+ true,
678
+ );
679
+ }
680
+ });
681
+
682
+ it('accepts a pending frame without its count, as a host from before the count sends it', () => {
683
+ // Refusing it would cut a newer client off from an older daemon on every prompt it queues.
684
+ expect(decodeFrame('{"type":"pending","pending":null}', decodeServerMessage)).toEqual({
685
+ ok: true,
686
+ message: { type: 'pending', pending: null },
687
+ });
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
+ });
231
721
  });
232
722
 
233
723
  describe('decodeFrame — the one carrier path', () => {