@robota-sdk/agent-transport 3.0.0-beta.79 → 3.0.0-beta.82

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 (113) hide show
  1. package/README.md +84 -26
  2. package/dist/browser/client.d.ts +2 -0
  3. package/dist/browser/client.js +1 -0
  4. package/dist/browser/index.d.ts +520 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +2 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/browser/message-decoders-BB8n-H2X.d.ts +252 -0
  9. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
  10. package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
  11. package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
  12. package/dist/node/index.cjs +1 -1
  13. package/dist/node/index.d.cts +766 -0
  14. package/dist/node/index.d.cts.map +1 -0
  15. package/dist/node/index.d.ts +765 -27
  16. package/dist/node/index.d.ts.map +1 -1
  17. package/dist/node/index.js +1 -1
  18. package/dist/node/index.js.map +1 -1
  19. package/dist/node/node.cjs +1 -0
  20. package/dist/node/node.d.cts +216 -0
  21. package/dist/node/node.d.cts.map +1 -0
  22. package/dist/node/node.d.ts +216 -0
  23. package/dist/node/node.d.ts.map +1 -0
  24. package/dist/node/node.js +2 -0
  25. package/dist/node/node.js.map +1 -0
  26. package/package.json +68 -47
  27. package/src/__tests__/access-token-verifier.test.ts +672 -0
  28. package/src/__tests__/admission.test.ts +130 -0
  29. package/src/__tests__/channel-frames.test.ts +214 -0
  30. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  31. package/src/__tests__/file-transfer.test.ts +281 -0
  32. package/src/__tests__/handoff-chunking.test.ts +215 -0
  33. package/src/__tests__/handoff-manifest.test.ts +52 -0
  34. package/src/__tests__/message-decoders.test.ts +249 -0
  35. package/src/__tests__/outbound-delivery.test.ts +558 -0
  36. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  37. package/src/__tests__/personal-usage-report.test.ts +193 -0
  38. package/src/__tests__/resume-buffer.test.ts +93 -0
  39. package/src/__tests__/session-event-delivery.test.ts +101 -0
  40. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  41. package/src/__tests__/session-message-handler.test.ts +590 -0
  42. package/src/__tests__/session-resume-bridge.test.ts +406 -0
  43. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  44. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  45. package/src/__tests__/ws-broadcast-events.test.ts +84 -0
  46. package/src/background-messages.ts +169 -0
  47. package/src/channel-frames.ts +182 -0
  48. package/src/client.ts +9 -0
  49. package/src/handoff-chunking.ts +235 -0
  50. package/src/index.ts +112 -3
  51. package/src/message-decoders.ts +229 -0
  52. package/src/message-parser.ts +33 -0
  53. package/src/node/access-token-verifier.ts +363 -0
  54. package/src/node/admission.ts +153 -0
  55. package/src/node/file-transfer.ts +530 -0
  56. package/src/node/handoff-manifest.ts +77 -0
  57. package/src/node/index.ts +27 -0
  58. package/src/outbound-delivery.ts +231 -0
  59. package/src/peer-message-ledger.ts +193 -0
  60. package/src/protocol-session.ts +28 -0
  61. package/src/resume-buffer.ts +123 -0
  62. package/src/session-events.ts +198 -0
  63. package/src/session-message-handler.ts +282 -0
  64. package/src/session-query-messages.ts +42 -0
  65. package/src/session-resume-bridge.ts +252 -0
  66. package/src/usage-messages.ts +195 -0
  67. package/src/wire-messages.ts +165 -0
  68. package/dist/node/headless/index.cjs +0 -1
  69. package/dist/node/headless/index.d.ts +0 -2
  70. package/dist/node/headless/index.js +0 -1
  71. package/dist/node/headless-DG_MEbf0.js +0 -19
  72. package/dist/node/headless-DG_MEbf0.js.map +0 -1
  73. package/dist/node/headless-xq77JBq0.cjs +0 -18
  74. package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
  75. package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
  76. package/dist/node/index-JIvzrtaA.d.ts +0 -52
  77. package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
  78. package/dist/node/index-JTD_xtEQ.d.ts +0 -96
  79. package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
  80. package/dist/node/index-y_HBOfzO.d.ts +0 -52
  81. package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
  82. package/dist/node/programmatic/index.cjs +0 -1
  83. package/dist/node/programmatic/index.d.ts +0 -2
  84. package/dist/node/programmatic/index.js +0 -1
  85. package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
  86. package/dist/node/programmatic-70dxIXtS.js +0 -2
  87. package/dist/node/programmatic-70dxIXtS.js.map +0 -1
  88. package/dist/node/testing/index.cjs +0 -1
  89. package/dist/node/testing/index.d.ts +0 -2
  90. package/dist/node/testing/index.js +0 -1
  91. package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
  92. package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
  93. package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
  94. package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
  95. package/src/headless/HeadlessInteractionChannel.ts +0 -129
  96. package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
  97. package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
  98. package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
  99. package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
  100. package/src/headless/__tests__/headless-runner.test.ts +0 -508
  101. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
  102. package/src/headless/__tests__/headless-transport.test.ts +0 -267
  103. package/src/headless/cli-input.ts +0 -50
  104. package/src/headless/headless-runner.ts +0 -230
  105. package/src/headless/headless-stream-json.ts +0 -148
  106. package/src/headless/headless-transport.ts +0 -43
  107. package/src/headless/index.ts +0 -8
  108. package/src/headless/print-terminal.ts +0 -57
  109. package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
  110. package/src/programmatic/createProgrammaticAgent.ts +0 -71
  111. package/src/programmatic/index.ts +0 -5
  112. package/src/testing/index.ts +0 -10
  113. package/src/transport-registry.ts +0 -103
@@ -0,0 +1,590 @@
1
+ /**
2
+ * Tests for WebSocket transport handler.
3
+ */
4
+
5
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
6
+
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import { createOutboundDelivery } from '../outbound-delivery.js';
9
+ import { createSessionMessageHandler } from '../session-message-handler.js';
10
+ import { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from '../session-events.js';
11
+ import type { TServerMessage } from '../wire-messages.js';
12
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
13
+ import type {
14
+ IBackgroundJobGroupState,
15
+ IExecutionWorkspaceEvent,
16
+ IExecutionWorkspaceSnapshot,
17
+ TBackgroundJobGroupEvent,
18
+ TExecutionWorkspaceUpdateCause,
19
+ } from '@robota-sdk/agent-interface-execution';
20
+ import type {
21
+ IBackgroundTaskLogPage,
22
+ IBackgroundTaskState,
23
+ TBackgroundTaskEvent,
24
+ } from '@robota-sdk/agent-interface-execution';
25
+
26
+ const backgroundTask: IBackgroundTaskState = {
27
+ id: 'task_1',
28
+ kind: 'agent',
29
+ label: 'Explore',
30
+ agentType: 'Explore',
31
+ status: 'running',
32
+ mode: 'background',
33
+ parentSessionId: 'session_1',
34
+ depth: 0,
35
+ cwd: '/repo',
36
+ updatedAt: '2026-05-01T00:00:00.000Z',
37
+ unread: false,
38
+ promptPreview: 'inspect code',
39
+ };
40
+
41
+ const backgroundTaskLogPage: IBackgroundTaskLogPage = {
42
+ taskId: 'task_1',
43
+ cursor: { offset: 0 },
44
+ lines: ['line one'],
45
+ };
46
+
47
+ const executionWorkspaceSnapshot: IExecutionWorkspaceSnapshot = {
48
+ sessionId: 'session_1',
49
+ entries: [],
50
+ updatedAt: '2026-05-01T00:00:00.000Z',
51
+ };
52
+
53
+ const backgroundJobGroup: IBackgroundJobGroupState = {
54
+ id: 'group_1',
55
+ parentSessionId: 'session_1',
56
+ waitPolicy: 'wait_all',
57
+ taskIds: ['task_1'],
58
+ status: 'running',
59
+ createdAt: '2026-05-01T00:00:00.000Z',
60
+ updatedAt: '2026-05-01T00:00:00.000Z',
61
+ results: [],
62
+ };
63
+
64
+ function createMockSession() {
65
+ const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
66
+ return Object.assign(createTestInteractiveSession(), {
67
+ submit: vi.fn().mockResolvedValue(undefined),
68
+ abort: vi.fn(),
69
+ cancelQueue: vi.fn(),
70
+ getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
71
+ getContextState: vi
72
+ .fn()
73
+ .mockReturnValue({ usedTokens: 500, maxTokens: 100000, usedPercentage: 0.5 }),
74
+ isExecuting: vi.fn().mockReturnValue(false),
75
+ getPendingPrompt: vi.fn().mockReturnValue(null),
76
+ listBackgroundTasks: vi.fn().mockReturnValue([backgroundTask]),
77
+ getBackgroundTask: vi.fn().mockReturnValue(backgroundTask),
78
+ listBackgroundJobGroups: vi.fn().mockReturnValue([backgroundJobGroup]),
79
+ getBackgroundJobGroup: vi.fn().mockReturnValue(backgroundJobGroup),
80
+ waitBackgroundJobGroup: vi.fn().mockResolvedValue({
81
+ ...backgroundJobGroup,
82
+ status: 'completed',
83
+ completedAt: '2026-05-01T00:00:01.000Z',
84
+ results: [{ taskId: 'task_1', label: 'Explore', status: 'completed', summary: 'done' }],
85
+ }),
86
+ cancelBackgroundTask: vi.fn().mockResolvedValue(undefined),
87
+ closeBackgroundTask: vi.fn().mockResolvedValue(undefined),
88
+ sendBackgroundTask: vi.fn().mockResolvedValue(undefined),
89
+ readBackgroundTaskLog: vi.fn().mockResolvedValue(backgroundTaskLogPage),
90
+ getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue(executionWorkspaceSnapshot),
91
+ executeCommand: vi.fn().mockResolvedValue({ message: 'done', success: true, data: {} }),
92
+ resolvePermission: vi.fn(),
93
+ resolveAsk: vi.fn(),
94
+ listCommands: vi.fn().mockReturnValue([]),
95
+ // ARCH-012: required members — the cast below used to hide their absence, so this
96
+ // double was accepted while the real contract would have rejected it.
97
+ isInitialized: true,
98
+ getPendingCount: vi.fn().mockReturnValue(0),
99
+ getActiveDriverId: vi.fn().mockReturnValue(null),
100
+ on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
101
+ if (!listeners.has(event)) listeners.set(event, new Set());
102
+ listeners.get(event)!.add(handler);
103
+ }),
104
+ off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
105
+ listeners.get(event)?.delete(handler);
106
+ }),
107
+ _emit: (event: string, ...args: unknown[]) => {
108
+ listeners.get(event)?.forEach((h) => h(...args));
109
+ },
110
+ });
111
+ }
112
+
113
+ describe('WebSocket Transport Handler', () => {
114
+ function setup() {
115
+ const session = createMockSession();
116
+ const sent: TServerMessage[] = [];
117
+ const { onMessage, cleanup } = createSessionMessageHandler({
118
+ session,
119
+ deliver: createOutboundDelivery((msg) => sent.push(msg), vi.fn()),
120
+ });
121
+ return { session, sent, onMessage, cleanup };
122
+ }
123
+
124
+ it('submit calls session.submit()', () => {
125
+ const { onMessage, session } = setup();
126
+ onMessage(JSON.stringify({ type: 'submit', prompt: 'hello' }));
127
+ expect(
128
+ (session as unknown as { submit: ReturnType<typeof vi.fn> }).submit,
129
+ ).toHaveBeenCalledWith('hello', undefined, undefined, undefined);
130
+ });
131
+
132
+ it('abort calls session.abort()', () => {
133
+ const { onMessage, session } = setup();
134
+ onMessage(JSON.stringify({ type: 'abort' }));
135
+ expect((session as unknown as { abort: ReturnType<typeof vi.fn> }).abort).toHaveBeenCalled();
136
+ });
137
+
138
+ it('cancel-queue calls session.cancelQueue()', () => {
139
+ const { onMessage, session } = setup();
140
+ onMessage(JSON.stringify({ type: 'cancel-queue' }));
141
+ expect(
142
+ (session as unknown as { cancelQueue: ReturnType<typeof vi.fn> }).cancelQueue,
143
+ ).toHaveBeenCalled();
144
+ });
145
+
146
+ it('get-messages sends messages back', () => {
147
+ const { onMessage, sent } = setup();
148
+ onMessage(JSON.stringify({ type: 'get-messages' }));
149
+ expect(sent).toHaveLength(1);
150
+ expect(sent[0]!.type).toBe('messages');
151
+ });
152
+
153
+ it('get-context sends context state back', () => {
154
+ const { onMessage, sent } = setup();
155
+ onMessage(JSON.stringify({ type: 'get-context' }));
156
+ expect(sent).toHaveLength(1);
157
+ expect(sent[0]!.type).toBe('context');
158
+ });
159
+
160
+ it('get-executing sends executing status', () => {
161
+ const { onMessage, sent } = setup();
162
+ onMessage(JSON.stringify({ type: 'get-executing' }));
163
+ expect(sent[0]).toEqual({ type: 'executing', executing: false });
164
+ });
165
+
166
+ it('get-pending sends pending prompt', () => {
167
+ const { onMessage, sent } = setup();
168
+ onMessage(JSON.stringify({ type: 'get-pending' }));
169
+ expect(sent[0]).toEqual({ type: 'pending', pending: null });
170
+ });
171
+
172
+ it('get-background-tasks sends current background task list', () => {
173
+ const { onMessage, sent, session } = setup();
174
+ onMessage(JSON.stringify({ type: 'get-background-tasks', filter: { kind: 'agent' } }));
175
+
176
+ expect(sent[0]).toEqual({ type: 'background_tasks', tasks: [backgroundTask] });
177
+ expect(
178
+ (session as unknown as { listBackgroundTasks: ReturnType<typeof vi.fn> }).listBackgroundTasks,
179
+ ).toHaveBeenCalledWith({ kind: 'agent' });
180
+ });
181
+
182
+ it('get-background-task sends one background task snapshot', () => {
183
+ const { onMessage, sent, session } = setup();
184
+ onMessage(JSON.stringify({ type: 'get-background-task', taskId: 'task_1' }));
185
+
186
+ expect(sent[0]).toEqual({
187
+ type: 'background_task',
188
+ taskId: 'task_1',
189
+ task: backgroundTask,
190
+ });
191
+ expect(
192
+ (session as unknown as { getBackgroundTask: ReturnType<typeof vi.fn> }).getBackgroundTask,
193
+ ).toHaveBeenCalledWith('task_1');
194
+ });
195
+
196
+ it('get-background-job-groups sends group snapshots', () => {
197
+ const { onMessage, sent, session } = setup();
198
+ onMessage(JSON.stringify({ type: 'get-background-job-groups' }));
199
+
200
+ expect(sent[0]).toEqual({ type: 'background_job_groups', groups: [backgroundJobGroup] });
201
+ expect(
202
+ (session as unknown as { listBackgroundJobGroups: ReturnType<typeof vi.fn> })
203
+ .listBackgroundJobGroups,
204
+ ).toHaveBeenCalled();
205
+ });
206
+
207
+ it('get-background-job-group sends one group snapshot', () => {
208
+ const { onMessage, sent, session } = setup();
209
+ onMessage(JSON.stringify({ type: 'get-background-job-group', groupId: 'group_1' }));
210
+
211
+ expect(sent[0]).toEqual({
212
+ type: 'background_job_group',
213
+ groupId: 'group_1',
214
+ group: backgroundJobGroup,
215
+ });
216
+ expect(
217
+ (session as unknown as { getBackgroundJobGroup: ReturnType<typeof vi.fn> })
218
+ .getBackgroundJobGroup,
219
+ ).toHaveBeenCalledWith('group_1');
220
+ });
221
+
222
+ it('wait-background-job-group waits and sends the completed group', async () => {
223
+ const { onMessage, sent, session } = setup();
224
+ onMessage(JSON.stringify({ type: 'wait-background-job-group', groupId: 'group_1' }));
225
+
226
+ await new Promise((r) => setTimeout(r, 10));
227
+
228
+ expect(
229
+ (session as unknown as { waitBackgroundJobGroup: ReturnType<typeof vi.fn> })
230
+ .waitBackgroundJobGroup,
231
+ ).toHaveBeenCalledWith('group_1');
232
+ expect(sent[0]).toMatchObject({
233
+ type: 'background_job_group',
234
+ groupId: 'group_1',
235
+ group: { id: 'group_1', status: 'completed' },
236
+ });
237
+ });
238
+
239
+ it('cancel-background-task maps to session control and emits control result', async () => {
240
+ const { onMessage, sent, session } = setup();
241
+ onMessage(JSON.stringify({ type: 'cancel-background-task', taskId: 'task_1', reason: 'stop' }));
242
+
243
+ await new Promise((r) => setTimeout(r, 10));
244
+
245
+ expect(
246
+ (session as unknown as { cancelBackgroundTask: ReturnType<typeof vi.fn> })
247
+ .cancelBackgroundTask,
248
+ ).toHaveBeenCalledWith('task_1', 'stop');
249
+ expect(sent[0]).toEqual({
250
+ type: 'background_task_control_result',
251
+ action: 'cancel',
252
+ taskId: 'task_1',
253
+ success: true,
254
+ });
255
+ });
256
+
257
+ it('send-background-task maps prompt input to session control', async () => {
258
+ const { onMessage, sent, session } = setup();
259
+ onMessage(
260
+ JSON.stringify({
261
+ type: 'send-background-task',
262
+ taskId: 'task_1',
263
+ input: { prompt: 'continue' },
264
+ }),
265
+ );
266
+
267
+ await new Promise((r) => setTimeout(r, 10));
268
+
269
+ expect(
270
+ (session as unknown as { sendBackgroundTask: ReturnType<typeof vi.fn> }).sendBackgroundTask,
271
+ ).toHaveBeenCalledWith('task_1', { prompt: 'continue' });
272
+ expect(sent[0]).toEqual({
273
+ type: 'background_task_control_result',
274
+ action: 'send',
275
+ taskId: 'task_1',
276
+ success: true,
277
+ });
278
+ });
279
+
280
+ it('read-background-task-log sends log page', async () => {
281
+ const { onMessage, sent, session } = setup();
282
+ onMessage(
283
+ JSON.stringify({
284
+ type: 'read-background-task-log',
285
+ taskId: 'task_1',
286
+ cursor: { offset: 0 },
287
+ }),
288
+ );
289
+
290
+ await new Promise((r) => setTimeout(r, 10));
291
+
292
+ expect(
293
+ (session as unknown as { readBackgroundTaskLog: ReturnType<typeof vi.fn> })
294
+ .readBackgroundTaskLog,
295
+ ).toHaveBeenCalledWith('task_1', { offset: 0 });
296
+ expect(sent[0]).toEqual({
297
+ type: 'background_task_log',
298
+ taskId: 'task_1',
299
+ page: backgroundTaskLogPage,
300
+ });
301
+ });
302
+
303
+ it('command executes via session.executeCommand() tagged as a remote origin (REMOTE-003)', async () => {
304
+ const { onMessage, sent, session } = setup();
305
+ onMessage(JSON.stringify({ type: 'command', name: 'clear' }));
306
+ await new Promise((r) => setTimeout(r, 10));
307
+ expect(sent).toHaveLength(1);
308
+ expect(sent[0]!.type).toBe('command_result');
309
+ // A transport-origin command is an untrusted remote origin — the handler tags it `'remote'` so the session
310
+ // applies its (optional, allow-by-default) remote-command policy.
311
+ // CMD-004 Phase 2: the 4th arg is the command-origin driver id — undefined when the handler has
312
+ // no server-assigned id (unattributed; a client-sent id is NEVER trusted).
313
+ expect(
314
+ (session as unknown as { executeCommand: ReturnType<typeof vi.fn> }).executeCommand,
315
+ ).toHaveBeenCalledWith('clear', '', 'remote', undefined);
316
+ });
317
+
318
+ it('command carries the SERVER-ASSIGNED driver id as the command origin (CMD-004 Phase 2)', async () => {
319
+ const session = createMockSession();
320
+ const sent: TServerMessage[] = [];
321
+ const { onMessage } = createSessionMessageHandler({
322
+ session,
323
+ deliver: createOutboundDelivery((msg) => sent.push(msg), vi.fn()),
324
+ driverId: 'device-7',
325
+ });
326
+ onMessage(JSON.stringify({ type: 'command', name: 'settings' }));
327
+ await new Promise((r) => setTimeout(r, 10));
328
+ expect(
329
+ (session as unknown as { executeCommand: ReturnType<typeof vi.fn> }).executeCommand,
330
+ ).toHaveBeenCalledWith('settings', '', 'remote', 'device-7');
331
+ });
332
+
333
+ it('forwards ui_intent session events to the requesting client (CMD-004 Phase 2)', () => {
334
+ const session = createMockSession();
335
+ const sent: TServerMessage[] = [];
336
+ createSessionMessageHandler({
337
+ session,
338
+ deliver: createOutboundDelivery((msg) => sent.push(msg), vi.fn()),
339
+ driverId: 'device-7',
340
+ });
341
+ session._emit('ui_intent', {
342
+ intent: { type: 'show-settings' },
343
+ requesterDriverId: 'device-7',
344
+ });
345
+ expect(sent).toEqual([
346
+ {
347
+ type: 'ui_intent',
348
+ event: { intent: { type: 'show-settings' }, requesterDriverId: 'device-7' },
349
+ },
350
+ ]);
351
+ });
352
+
353
+ // CMD-004 Stage D — `ui_intent` is REQUESTER-ROUTED (the spec's Key mechanics): the surface that
354
+ // issued the command renders the intent; other surfaces never see it. Only an UNATTRIBUTED intent
355
+ // (no requester id — e.g. an idle model-invoked command) reaches every surface, because it cannot
356
+ // be routed and a silent drop is banned.
357
+ describe('ui_intent requester routing (CMD-004 Stage D)', () => {
358
+ function twoSurfaces() {
359
+ const session = createMockSession();
360
+ const sentA: TServerMessage[] = [];
361
+ const sentB: TServerMessage[] = [];
362
+ createSessionMessageHandler({
363
+ session,
364
+ deliver: createOutboundDelivery((msg) => sentA.push(msg), vi.fn()),
365
+ driverId: 'device-A',
366
+ });
367
+ createSessionMessageHandler({
368
+ session,
369
+ deliver: createOutboundDelivery((msg) => sentB.push(msg), vi.fn()),
370
+ driverId: 'device-B',
371
+ });
372
+ return { session, sentA, sentB };
373
+ }
374
+
375
+ it('routes a requester-stamped intent ONLY to the invoking surface — not broadcast', () => {
376
+ const { session, sentA, sentB } = twoSurfaces();
377
+ session._emit('ui_intent', {
378
+ intent: { type: 'show-session-picker' },
379
+ requesterDriverId: 'device-A',
380
+ });
381
+ expect(sentA).toEqual([
382
+ {
383
+ type: 'ui_intent',
384
+ event: { intent: { type: 'show-session-picker' }, requesterDriverId: 'device-A' },
385
+ },
386
+ ]);
387
+ expect(sentB).toEqual([]);
388
+ });
389
+
390
+ it('an unattributed intent reaches every surface (unroutable — never silently dropped)', () => {
391
+ const { session, sentA, sentB } = twoSurfaces();
392
+ session._emit('ui_intent', { intent: { type: 'show-settings' } });
393
+ expect(sentA).toEqual([{ type: 'ui_intent', event: { intent: { type: 'show-settings' } } }]);
394
+ expect(sentB).toEqual(sentA);
395
+ });
396
+
397
+ it("a surface with NO server-assigned id never receives another surface's intent", () => {
398
+ const { sent, session } = setup(); // setup() builds a handler without a driverId
399
+ session._emit('ui_intent', {
400
+ intent: { type: 'show-agent-switcher' },
401
+ requesterDriverId: 'device-elsewhere',
402
+ });
403
+ expect(sent).toEqual([]);
404
+ });
405
+ });
406
+
407
+ it('invalid JSON sends protocol_error', () => {
408
+ const { onMessage, sent } = setup();
409
+ onMessage('not json');
410
+ expect(sent[0]).toEqual({ type: 'protocol_error', message: 'Invalid JSON' });
411
+ });
412
+
413
+ it('unknown type sends protocol_error', () => {
414
+ const { onMessage, sent } = setup();
415
+ onMessage(JSON.stringify({ type: 'unknown_type' }));
416
+ expect(sent[0]!.type).toBe('protocol_error');
417
+ });
418
+
419
+ it('submit without prompt sends protocol_error', () => {
420
+ const { onMessage, sent } = setup();
421
+ onMessage(JSON.stringify({ type: 'submit' }));
422
+ // TRANS-008: the wording changed with the guard. "required" was false for a prompt that WAS
423
+ // provided and had the wrong type; the refusal is about shape and the message now says so.
424
+ expect(sent[0]).toEqual({
425
+ type: 'protocol_error',
426
+ message: 'prompt must be a non-empty string',
427
+ });
428
+ });
429
+
430
+ it('submit rejection surfaces a protocol_error (WS-001)', async () => {
431
+ const { onMessage, sent, session } = setup();
432
+ (session as unknown as { submit: ReturnType<typeof vi.fn> }).submit.mockRejectedValueOnce(
433
+ new Error('submit failed'),
434
+ );
435
+ onMessage(JSON.stringify({ type: 'submit', prompt: 'hello' }));
436
+ await new Promise((r) => setTimeout(r, 10));
437
+ expect(sent).toEqual([{ type: 'protocol_error', message: 'submit failed' }]);
438
+ });
439
+
440
+ it('command rejection surfaces a protocol_error (WS-001)', async () => {
441
+ const { onMessage, sent, session } = setup();
442
+ (
443
+ session as unknown as { executeCommand: ReturnType<typeof vi.fn> }
444
+ ).executeCommand.mockRejectedValueOnce(new Error('command failed'));
445
+ onMessage(JSON.stringify({ type: 'command', name: 'clear' }));
446
+ await new Promise((r) => setTimeout(r, 10));
447
+ expect(sent).toEqual([{ type: 'protocol_error', message: 'command failed' }]);
448
+ });
449
+
450
+ it('forwards InteractiveSession events to client', () => {
451
+ const { session, sent } = setup();
452
+ (session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
453
+ 'text_delta',
454
+ 'hello',
455
+ );
456
+ expect(sent).toHaveLength(1);
457
+ expect(sent[0]).toEqual({ type: 'text_delta', delta: 'hello' });
458
+ });
459
+
460
+ it('forwards thinking event', () => {
461
+ const { session, sent } = setup();
462
+ (session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
463
+ 'thinking',
464
+ true,
465
+ );
466
+ expect(sent[0]).toEqual({ type: 'thinking', isThinking: true });
467
+ });
468
+
469
+ it('forwards background task events to the client', () => {
470
+ const { session, sent } = setup();
471
+ const event: TBackgroundTaskEvent = {
472
+ type: 'background_task_text_delta',
473
+ taskId: 'task_1',
474
+ delta: 'partial',
475
+ };
476
+ (session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
477
+ 'background_task_event',
478
+ event,
479
+ );
480
+
481
+ expect(sent[0]).toEqual({ type: 'background_task_event', event });
482
+ });
483
+
484
+ it('forwards background job group events to the client', () => {
485
+ const { session, sent } = setup();
486
+ const event: TBackgroundJobGroupEvent = {
487
+ type: 'background_job_group_completed',
488
+ group: { ...backgroundJobGroup, status: 'completed' },
489
+ };
490
+ (session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
491
+ 'background_job_group_event',
492
+ event,
493
+ );
494
+
495
+ expect(sent[0]).toEqual({ type: 'background_job_group_event', event });
496
+ });
497
+
498
+ it('forwards execution_workspace_event to the client', () => {
499
+ const { session, sent } = setup();
500
+ const event: IExecutionWorkspaceEvent = {
501
+ type: 'execution_workspace_updated',
502
+ cause: 'main_thread',
503
+ snapshot: executionWorkspaceSnapshot,
504
+ };
505
+ (session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
506
+ 'execution_workspace_event',
507
+ event,
508
+ );
509
+
510
+ expect(sent[0]).toEqual({
511
+ type: 'execution_workspace_event',
512
+ snapshot: executionWorkspaceSnapshot,
513
+ });
514
+ });
515
+
516
+ it('get-execution-workspace sends current snapshot', () => {
517
+ const { onMessage, sent, session } = setup();
518
+ onMessage(JSON.stringify({ type: 'get-execution-workspace' }));
519
+
520
+ expect(sent[0]).toEqual({
521
+ type: 'execution_workspace_event',
522
+ snapshot: executionWorkspaceSnapshot,
523
+ });
524
+ expect(
525
+ (
526
+ session as unknown as {
527
+ getExecutionWorkspaceSnapshot: ReturnType<typeof vi.fn>;
528
+ }
529
+ ).getExecutionWorkspaceSnapshot,
530
+ ).toHaveBeenCalled();
531
+ });
532
+
533
+ // ── REMOTE-007: transport-neutral permission/ask over the wire (TC-06) ──
534
+
535
+ it('forwards a permission_request session event to the client', () => {
536
+ const { session, sent } = setup();
537
+ session._emit('permission_request', {
538
+ id: 'p1',
539
+ toolName: 'Bash',
540
+ toolArgs: { command: 'ls' },
541
+ });
542
+ const msg = sent.find((m) => m.type === 'permission_request');
543
+ expect(msg).toEqual({
544
+ type: 'permission_request',
545
+ event: { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' } },
546
+ });
547
+ });
548
+
549
+ it('forwards ask_request and prompt_resolved session events to the client', () => {
550
+ const { session, sent } = setup();
551
+ session._emit('ask_request', { id: 'a1', request: { id: 'r', title: 'Pick' } });
552
+ session._emit('prompt_resolved', { id: 'a1' });
553
+ expect(sent.find((m) => m.type === 'ask_request')).toEqual({
554
+ type: 'ask_request',
555
+ event: { id: 'a1', request: { id: 'r', title: 'Pick' } },
556
+ });
557
+ expect(sent.find((m) => m.type === 'prompt_resolved')).toEqual({
558
+ type: 'prompt_resolved',
559
+ event: { id: 'a1' },
560
+ });
561
+ });
562
+
563
+ it('a permission-response from the client resolves the pending permission by id', () => {
564
+ const { onMessage, session } = setup();
565
+ onMessage(JSON.stringify({ type: 'permission-response', id: 'p1', result: true }));
566
+ expect(
567
+ (session as unknown as { resolvePermission: ReturnType<typeof vi.fn> }).resolvePermission,
568
+ ).toHaveBeenCalledWith('p1', true, undefined);
569
+ });
570
+
571
+ it('an ask-response from the client resolves the pending ask by id', () => {
572
+ const { onMessage, session } = setup();
573
+ const response = { type: 'answer', values: ['React'] };
574
+ onMessage(JSON.stringify({ type: 'ask-response', id: 'a1', response }));
575
+ expect(
576
+ (session as unknown as { resolveAsk: ReturnType<typeof vi.fn> }).resolveAsk,
577
+ ).toHaveBeenCalledWith('a1', response, undefined);
578
+ });
579
+
580
+ it('cleanup unsubscribes from all events', () => {
581
+ const { session, cleanup } = setup();
582
+ cleanup();
583
+ const subscribedEventCount = Object.values(PROTOCOL_SESSION_EVENT_CLASSIFICATION).filter(
584
+ (classification) => classification !== 'non-surface',
585
+ ).length;
586
+ expect((session as unknown as { off: ReturnType<typeof vi.fn> }).off).toHaveBeenCalledTimes(
587
+ subscribedEventCount,
588
+ );
589
+ });
590
+ });