@robota-sdk/agent-transport 3.0.0-beta.100

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 (93) hide show
  1. package/CHANGELOG.md +1137 -0
  2. package/LICENSE +661 -0
  3. package/README.md +121 -0
  4. package/dist/browser/client.d.ts +2 -0
  5. package/dist/browser/client.js +1 -0
  6. package/dist/browser/index.d.ts +624 -0
  7. package/dist/browser/index.d.ts.map +1 -0
  8. package/dist/browser/index.js +2 -0
  9. package/dist/browser/index.js.map +1 -0
  10. package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
  11. package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
  12. package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
  13. package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
  14. package/dist/node/index.cjs +1 -0
  15. package/dist/node/index.d.cts +1115 -0
  16. package/dist/node/index.d.cts.map +1 -0
  17. package/dist/node/index.d.ts +1115 -0
  18. package/dist/node/index.d.ts.map +1 -0
  19. package/dist/node/index.js +2 -0
  20. package/dist/node/index.js.map +1 -0
  21. package/dist/node/node.cjs +1 -0
  22. package/dist/node/node.d.cts +317 -0
  23. package/dist/node/node.d.cts.map +1 -0
  24. package/dist/node/node.d.ts +317 -0
  25. package/dist/node/node.d.ts.map +1 -0
  26. package/dist/node/node.js +2 -0
  27. package/dist/node/node.js.map +1 -0
  28. package/package.json +122 -0
  29. package/src/__tests__/__fixtures__/korean-text.json +6 -0
  30. package/src/__tests__/access-token-verifier.test.ts +672 -0
  31. package/src/__tests__/admission.test.ts +130 -0
  32. package/src/__tests__/app-tool-messages.test.ts +463 -0
  33. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  34. package/src/__tests__/channel-frames.test.ts +215 -0
  35. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  36. package/src/__tests__/file-transfer.test.ts +281 -0
  37. package/src/__tests__/handoff-chunking.test.ts +261 -0
  38. package/src/__tests__/handoff-manifest.test.ts +52 -0
  39. package/src/__tests__/history-attached-budget.test.ts +216 -0
  40. package/src/__tests__/message-decoders.test.ts +870 -0
  41. package/src/__tests__/outbound-delivery.test.ts +558 -0
  42. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  43. package/src/__tests__/permission-summary.test.ts +18 -0
  44. package/src/__tests__/personal-usage-report.test.ts +193 -0
  45. package/src/__tests__/project-read-messages.test.ts +118 -0
  46. package/src/__tests__/resume-buffer.test.ts +93 -0
  47. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  48. package/src/__tests__/session-directory-messages.test.ts +322 -0
  49. package/src/__tests__/session-error-classification.test.ts +113 -0
  50. package/src/__tests__/session-event-delivery.test.ts +101 -0
  51. package/src/__tests__/session-history-wire.test.ts +364 -0
  52. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  53. package/src/__tests__/session-message-handler-observe.test.ts +223 -0
  54. package/src/__tests__/session-message-handler.test.ts +726 -0
  55. package/src/__tests__/session-resume-bridge.test.ts +472 -0
  56. package/src/__tests__/settings-messages.test.ts +193 -0
  57. package/src/__tests__/stdio-carrier.test.ts +85 -0
  58. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  59. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  60. package/src/__tests__/ws-broadcast-events.test.ts +94 -0
  61. package/src/app-tool-messages.ts +140 -0
  62. package/src/background-messages.ts +169 -0
  63. package/src/channel-frames.ts +182 -0
  64. package/src/client.ts +17 -0
  65. package/src/handoff-chunking.ts +275 -0
  66. package/src/index.ts +135 -0
  67. package/src/loop-control-messages.ts +34 -0
  68. package/src/message-decoders.ts +485 -0
  69. package/src/message-parser.ts +33 -0
  70. package/src/node/access-token-verifier.ts +363 -0
  71. package/src/node/admission.ts +153 -0
  72. package/src/node/bearer-resource-gate.ts +331 -0
  73. package/src/node/file-transfer.ts +530 -0
  74. package/src/node/handoff-manifest.ts +77 -0
  75. package/src/node/index.ts +45 -0
  76. package/src/node/stdio-carrier.ts +59 -0
  77. package/src/observer-messages.ts +47 -0
  78. package/src/open-prompts.ts +76 -0
  79. package/src/outbound-delivery.ts +240 -0
  80. package/src/peer-message-ledger.ts +193 -0
  81. package/src/permission-summary.ts +18 -0
  82. package/src/project-read-messages.ts +74 -0
  83. package/src/protocol-session.ts +56 -0
  84. package/src/resume-buffer.ts +123 -0
  85. package/src/session-directory-messages.ts +162 -0
  86. package/src/session-error-classification.ts +91 -0
  87. package/src/session-events.ts +290 -0
  88. package/src/session-message-handler.ts +413 -0
  89. package/src/session-query-messages.ts +160 -0
  90. package/src/session-resume-bridge.ts +275 -0
  91. package/src/settings-messages.ts +103 -0
  92. package/src/usage-messages.ts +195 -0
  93. package/src/wire-messages.ts +436 -0
@@ -0,0 +1,472 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createOutboundDelivery, DEFAULT_MAX_PENDING_BYTES } from '../outbound-delivery.js';
4
+ import { createSessionMessageHandler } from '../session-message-handler.js';
5
+ import { SessionResumeBridge } from '../session-resume-bridge.js';
6
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
7
+
8
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
9
+ import type { IPersonalUsageReport } from '@robota-sdk/agent-interface-analytics';
10
+
11
+ /**
12
+ * REMOTE-013 E4 TC-02 — the persistent SessionResumeBridge: a monotonic seq CONTINUOUS across a
13
+ * detach/attach (channel drop), gap output captured while detached and replayed on `resume`, `ack` freeing the
14
+ * buffer, and — the regression guard — the WS `createSessionMessageHandler` path stamping NO seq.
15
+ */
16
+
17
+ function fakeSession(): {
18
+ session: IInteractiveSession;
19
+ fire: (event: string, arg: unknown) => void;
20
+ } {
21
+ const handlers = new Map<string, (arg: unknown) => void>();
22
+ // ARCH-012: the published conformant double, overridden where this case needs behaviour. The
23
+ // hand-rolled partial it replaces was `as unknown as IInteractiveSession` — a cast that hid four
24
+ // missing members, so this suite proved things the real contract does not permit.
25
+ const session = createTestInteractiveSession({
26
+ on: ((event: string, handler: (arg: unknown) => void) =>
27
+ handlers.set(event, handler)) as IInteractiveSession['on'],
28
+ off: ((event: string) => {
29
+ handlers.delete(event);
30
+ }) as IInteractiveSession['off'],
31
+ });
32
+ return { session, fire: (event, arg) => handlers.get(event)?.(arg) };
33
+ }
34
+
35
+ function frames(sink: { calls: string[] }): { type: string; seq?: number; delta?: string }[] {
36
+ return sink.calls.map((c) => JSON.parse(c));
37
+ }
38
+
39
+ function sink(): TResumeSinkStub {
40
+ const calls: string[] = [];
41
+ return Object.assign((data: string) => void calls.push(data), { calls });
42
+ }
43
+ type TResumeSinkStub = ((data: string) => void) & { calls: string[] };
44
+ const TEST_ATTACH_OPTIONS = { onDeliveryError: vi.fn() };
45
+
46
+ describe('SessionResumeBridge (REMOTE-013 TC-02)', () => {
47
+ it('keeps remote surface attribution separate from the paired device id', async () => {
48
+ const base = fakeSession().session;
49
+ const submit = vi.fn().mockResolvedValue(undefined);
50
+ const session = Object.assign(base, { submit });
51
+ const bridge = new SessionResumeBridge({
52
+ session,
53
+ driverId: 'device-sha256',
54
+ surface: 'remote',
55
+ });
56
+ bridge.attach(sink(), TEST_ATTACH_OPTIONS);
57
+
58
+ bridge.onClientMessage(JSON.stringify({ type: 'submit', prompt: 'paired turn' }));
59
+ await vi.waitFor(() => expect(submit).toHaveBeenCalled());
60
+
61
+ expect(submit).toHaveBeenCalledWith('paired turn', undefined, undefined, {
62
+ driverId: 'device-sha256',
63
+ surface: 'remote',
64
+ });
65
+ bridge.dispose();
66
+ });
67
+
68
+ it('keeps host usage reporting reachable across the resume bridge', async () => {
69
+ const { session } = fakeSession();
70
+ const report = { schemaVersion: 1 } as IPersonalUsageReport;
71
+ const personalUsageReporter = vi.fn().mockResolvedValue(report);
72
+ const bridge = new SessionResumeBridge({ session, personalUsageReporter });
73
+ const output = sink();
74
+ bridge.attach(output, TEST_ATTACH_OPTIONS);
75
+
76
+ bridge.onClientMessage(
77
+ JSON.stringify({
78
+ type: 'get-personal-usage-report',
79
+ requestId: 'resume-usage',
80
+ period: '7d',
81
+ timezone: 'UTC',
82
+ }),
83
+ );
84
+ await vi.waitFor(() => expect(output.calls).toHaveLength(1));
85
+
86
+ expect(personalUsageReporter).toHaveBeenCalledWith({ period: '7d', timezone: 'UTC' });
87
+ expect(frames(output)[0]).toMatchObject({
88
+ type: 'personal_usage_report',
89
+ requestId: 'resume-usage',
90
+ report,
91
+ });
92
+ bridge.dispose();
93
+ });
94
+
95
+ it('stamps a monotonic seq and forwards live to the attached sink', () => {
96
+ const { session, fire } = fakeSession();
97
+ const bridge = new SessionResumeBridge({ session });
98
+ const s = sink();
99
+ bridge.attach(s, TEST_ATTACH_OPTIONS);
100
+ fire('text_delta', 'a');
101
+ fire('text_delta', 'b');
102
+ expect(frames(s).map((f) => f.seq)).toEqual([1, 2]);
103
+ expect(frames(s).map((f) => f.delta)).toEqual(['a', 'b']);
104
+ bridge.dispose();
105
+ });
106
+
107
+ it('the Issue-A guard: seq is CONTINUOUS across detach/attach; gap output is captured + replayed on resume', () => {
108
+ const { session, fire } = fakeSession();
109
+ const bridge = new SessionResumeBridge({ session });
110
+
111
+ const s1 = sink();
112
+ bridge.attach(s1, TEST_ATTACH_OPTIONS);
113
+ fire('text_delta', 'a'); // seq 1 → s1
114
+ expect(frames(s1).map((f) => f.seq)).toEqual([1]);
115
+
116
+ // Channel drops.
117
+ bridge.detach();
118
+ fire('text_delta', 'b'); // seq 2 — buffered, NOT sent (no sink)
119
+ fire('text_delta', 'c'); // seq 3 — buffered
120
+ expect(s1.calls).toHaveLength(1); // s1 got nothing during the gap
121
+
122
+ // New channel after reconnect. The client last applied seq 1 → asks for the tail.
123
+ const s2 = sink();
124
+ bridge.attach(s2, TEST_ATTACH_OPTIONS);
125
+ expect(s2.calls).toHaveLength(0); // attach does NOT auto-replay
126
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 1 }));
127
+
128
+ const replayed = frames(s2);
129
+ // seq did NOT reset to 1 on the new channel — the gap frames keep their original 2,3.
130
+ expect(replayed.map((f) => f.seq)).toEqual([2, 3]);
131
+ expect(replayed.map((f) => f.delta)).toEqual(['b', 'c']);
132
+ bridge.dispose();
133
+ });
134
+
135
+ it('reconnect hold (Issue-B): a LIVE frame emitted between attach and resume does not leapfrog the gap', () => {
136
+ const { session, fire } = fakeSession();
137
+ const bridge = new SessionResumeBridge({ session });
138
+ const s1 = sink();
139
+ bridge.attach(s1, TEST_ATTACH_OPTIONS);
140
+ fire('text_delta', 'a'); // seq 1 → client applies through 1
141
+
142
+ // Drop; gap frames buffered.
143
+ bridge.detach();
144
+ fire('text_delta', 'b'); // seq 2 (gap)
145
+ fire('text_delta', 'c'); // seq 3 (gap)
146
+
147
+ // Reconnect attach with awaitResume → live forwarding is HELD until resume flushes the tail.
148
+ const s2 = sink();
149
+ bridge.attach(s2, { ...TEST_ATTACH_OPTIONS, awaitResume: true });
150
+ fire('text_delta', 'd'); // seq 4 — emitted BEFORE the client's resume arrives; must NOT be sent yet
151
+ expect(s2.calls).toHaveLength(0); // held
152
+
153
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 1 }));
154
+ // The client receives 2,3,4 IN ORDER (gap then the held live frame) — none lost, none reordered.
155
+ expect(frames(s2).map((f) => f.seq)).toEqual([2, 3, 4]);
156
+ expect(frames(s2).map((f) => f.delta)).toEqual(['b', 'c', 'd']);
157
+
158
+ // Hold released — a subsequent live frame flows immediately behind the flushed tail.
159
+ fire('text_delta', 'e'); // seq 5
160
+ expect(frames(s2).map((f) => f.seq)).toEqual([2, 3, 4, 5]);
161
+ bridge.dispose();
162
+ });
163
+
164
+ it('ack frees the buffer up to seq; a later resume replays only the newer tail', () => {
165
+ const { session, fire } = fakeSession();
166
+ const bridge = new SessionResumeBridge({ session });
167
+ const s = sink();
168
+ bridge.attach(s, TEST_ATTACH_OPTIONS);
169
+ fire('text_delta', 'a'); // 1
170
+ fire('text_delta', 'b'); // 2
171
+ fire('text_delta', 'c'); // 3
172
+ bridge.onClientMessage(JSON.stringify({ type: 'ack', seq: 2 })); // free 1,2
173
+
174
+ const s2 = sink();
175
+ bridge.detach();
176
+ bridge.attach(s2, TEST_ATTACH_OPTIONS);
177
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 2 }));
178
+ expect(frames(s2).map((f) => f.seq)).toEqual([3]); // only the un-acked tail
179
+ bridge.dispose();
180
+ });
181
+
182
+ it('resume with a lastSeq older than the retained buffer sends resume_gap (no silent gap)', () => {
183
+ const { session, fire } = fakeSession();
184
+ const bridge = new SessionResumeBridge({ session, buffer: { maxFrames: 2 } });
185
+ const s = sink();
186
+ bridge.attach(s, TEST_ATTACH_OPTIONS);
187
+ fire('text_delta', 'a'); // 1 (evicted)
188
+ fire('text_delta', 'b'); // 2
189
+ fire('text_delta', 'c'); // 3 → buffer holds [2,3]
190
+ const s2 = sink();
191
+ bridge.detach();
192
+ bridge.attach(s2, TEST_ATTACH_OPTIONS);
193
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 })); // saw nothing, 1 is gone
194
+ expect(frames(s2)).toEqual([{ type: 'resume_gap' }]);
195
+ bridge.dispose();
196
+ });
197
+
198
+ // CMD-004 Stage D — the bridge's single subscription requester-routes `ui_intent` with the
199
+ // LATE-BOUND driver id (`setDriverId` binds after pairing), so a skipped intent is never
200
+ // buffered and can never leak to this surface through a later `resume` replay.
201
+ it('requester-routes ui_intent by the late-bound driver id; foreign intents are never buffered', () => {
202
+ const { session, fire } = fakeSession();
203
+ const bridge = new SessionResumeBridge({ session });
204
+ const s = sink();
205
+ bridge.attach(s, TEST_ATTACH_OPTIONS);
206
+
207
+ // Not paired yet (no driver id): an intent attributed to another surface is skipped.
208
+ fire('ui_intent', { intent: { type: 'show-settings' }, requesterDriverId: 'device-9' });
209
+ expect(s.calls).toHaveLength(0);
210
+
211
+ bridge.setDriverId('device-9');
212
+ fire('ui_intent', { intent: { type: 'show-settings' }, requesterDriverId: 'device-9' });
213
+ expect(frames(s)).toEqual([
214
+ {
215
+ type: 'ui_intent',
216
+ event: { intent: { type: 'show-settings' }, requesterDriverId: 'device-9' },
217
+ seq: 1, // the skipped foreign intent consumed NO seq — it was routed out BEFORE buffering
218
+ },
219
+ ]);
220
+
221
+ // Another surface's intent stays invisible even across a reconnect replay.
222
+ fire('ui_intent', { intent: { type: 'show-plugin-manager' }, requesterDriverId: 'device-2' });
223
+ const s2 = sink();
224
+ bridge.detach();
225
+ bridge.attach(s2, TEST_ATTACH_OPTIONS);
226
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
227
+ expect(frames(s2).filter((f) => f.type === 'ui_intent')).toHaveLength(1); // only device-9's own
228
+
229
+ // Unattributed intents are unroutable → delivered (never silently dropped).
230
+ fire('ui_intent', { intent: { type: 'show-agent-switcher' } });
231
+ expect(frames(s2).at(-1)).toEqual({
232
+ type: 'ui_intent',
233
+ event: { intent: { type: 'show-agent-switcher' } },
234
+ seq: 2, // the device-2 intent consumed no seq either — routing precedes the buffer
235
+ });
236
+ bridge.dispose();
237
+ });
238
+
239
+ it('regression: the WS createSessionMessageHandler path stamps NO seq', () => {
240
+ const { session, fire } = fakeSession();
241
+ const sent: unknown[] = [];
242
+ const { cleanup } = createSessionMessageHandler({
243
+ session,
244
+ deliver: createOutboundDelivery((m) => sent.push(m), vi.fn()),
245
+ });
246
+ fire('text_delta', 'a');
247
+ expect(sent).toEqual([{ type: 'text_delta', delta: 'a' }]); // no `seq` field
248
+ cleanup();
249
+ });
250
+
251
+ it('reports a WebRTC sink failure and detaches instead of swallowing it', () => {
252
+ const { session, fire } = fakeSession();
253
+ const failures: Array<{ message: string; event: string }> = [];
254
+ const bridge = new SessionResumeBridge({ session });
255
+ bridge.attach(
256
+ () => {
257
+ throw new Error('data channel closed');
258
+ },
259
+ {
260
+ onDeliveryError: (error, event) => {
261
+ failures.push({ message: error.message, event });
262
+ throw new Error('diagnostic callback failed');
263
+ },
264
+ },
265
+ );
266
+
267
+ fire('branch_event', {
268
+ kind: 'checkpoint_created',
269
+ checkpointId: 'turn-0001',
270
+ branchId: 'main',
271
+ });
272
+
273
+ expect(failures).toEqual([{ message: 'data channel closed', event: 'branch_event' }]);
274
+ expect(bridge.lastSeq).toBe(1); // retained for replay after the carrier reconnects
275
+ bridge.dispose();
276
+ });
277
+
278
+ // ARCH-030: the bridge's own string-level try/catch is gone; its only guard is the per-attachment
279
+ // outbound boundary. These three pin the semantics that used to be side effects of `detach()`.
280
+ describe('the attachment boundary (ARCH-030)', () => {
281
+ it('reports ONCE across a failing multi-frame replay, and the dropped frames survive', () => {
282
+ const { session, fire } = fakeSession();
283
+ const failures: Array<{ message: string; event: string }> = [];
284
+ const bridge = new SessionResumeBridge({ session });
285
+
286
+ // Three frames buffered while no channel is attached — the reconnect gap.
287
+ fire('text_delta', 'a');
288
+ fire('text_delta', 'b');
289
+ fire('text_delta', 'c');
290
+ expect(bridge.lastSeq).toBe(3);
291
+
292
+ bridge.attach(
293
+ () => {
294
+ throw new Error('data channel closed');
295
+ },
296
+ {
297
+ awaitResume: true,
298
+ onDeliveryError: (error, event) => failures.push({ message: error.message, event }),
299
+ },
300
+ );
301
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
302
+
303
+ // One report for the whole tail. Before the boundary this was one only because
304
+ // `reportDeliveryError` happened to `detach()` mid-loop; now it is the stated contract.
305
+ expect(failures).toEqual([{ message: 'data channel closed', event: 'text_delta' }]);
306
+
307
+ // Nothing was acked, so every frame is still replayable on the NEXT sink.
308
+ const healthy = sink();
309
+ bridge.attach(healthy, { awaitResume: true, onDeliveryError: vi.fn() });
310
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
311
+ expect(frames(healthy).map((f) => f.seq)).toEqual([1, 2, 3]);
312
+ expect(frames(healthy).map((f) => f.delta)).toEqual(['a', 'b', 'c']);
313
+ bridge.dispose();
314
+ });
315
+
316
+ it('ARCH-030: a replay burst stops when the budget closes the boundary, and the rest survives', () => {
317
+ // The replay-burst contract case. A reconnecting peer is handed the whole retained tail at
318
+ // once; if it stops reading part-way, the loop must stop rather than push the remainder into a
319
+ // carrier that is already over budget.
320
+ const { session, fire } = fakeSession();
321
+ const failures: Array<{ message: string; event: string }> = [];
322
+ const bridge = new SessionResumeBridge({ session });
323
+
324
+ for (const delta of ['a', 'b', 'c', 'd']) fire('text_delta', delta);
325
+ expect(bridge.lastSeq).toBe(4);
326
+
327
+ const written: string[] = [];
328
+ let pending = 0;
329
+ bridge.attach(
330
+ (data) => {
331
+ written.push(data);
332
+ // The peer reads nothing. This carrier is already holding a large backlog, so the second
333
+ // frame of the replay finds it past DEFAULT_MAX_PENDING_BYTES — the real default, not a
334
+ // budget invented for the test.
335
+ pending += DEFAULT_MAX_PENDING_BYTES;
336
+ },
337
+ {
338
+ awaitResume: true,
339
+ onDeliveryError: (error, event) => failures.push({ message: error.message, event }),
340
+ pendingBytes: () => pending,
341
+ },
342
+ );
343
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
344
+
345
+ // It stopped short rather than draining the tail into a carrier over its limit.
346
+ expect(written.length).toBeLessThan(4);
347
+ expect(failures).toHaveLength(1);
348
+ expect(failures[0]?.message).toContain('pending');
349
+
350
+ // And nothing was lost: the frames it did not push are still replayable on the next sink.
351
+ const healthy = sink();
352
+ bridge.attach(healthy, { awaitResume: true, onDeliveryError: vi.fn() });
353
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
354
+ expect(frames(healthy).map((f) => f.seq)).toEqual([1, 2, 3, 4]);
355
+ bridge.dispose();
356
+ });
357
+
358
+ it('a fresh attach un-latches the connection, and seq stays continuous across the failure', () => {
359
+ const { session, fire } = fakeSession();
360
+ const bridge = new SessionResumeBridge({ session });
361
+ bridge.attach(
362
+ () => {
363
+ throw new Error('data channel closed');
364
+ },
365
+ { onDeliveryError: vi.fn() },
366
+ );
367
+ fire('text_delta', 'a');
368
+
369
+ const second = sink();
370
+ const secondFailures: string[] = [];
371
+ bridge.attach(second, { onDeliveryError: (error) => secondFailures.push(error.message) });
372
+ fire('text_delta', 'b');
373
+
374
+ // The new attachment delivers: a latched boundary is not carried across `attach`.
375
+ expect(frames(second).map((f) => f.seq)).toEqual([2]);
376
+ expect(secondFailures).toEqual([]);
377
+ expect(bridge.lastSeq).toBe(2); // the failed frame still consumed its seq
378
+ bridge.dispose();
379
+ });
380
+
381
+ it('routes a reply to an inbound frame through the same boundary as the event fan-out', () => {
382
+ const handlers = new Map<string, (arg: unknown) => void>();
383
+ const session = createTestInteractiveSession({
384
+ on: ((event: string, handler: (arg: unknown) => void) =>
385
+ handlers.set(event, handler)) as IInteractiveSession['on'],
386
+ off: ((event: string) => {
387
+ handlers.delete(event);
388
+ }) as IInteractiveSession['off'],
389
+ });
390
+ const failures: Array<{ message: string; event: string }> = [];
391
+ const bridge = new SessionResumeBridge({ session });
392
+ bridge.attach(
393
+ () => {
394
+ throw new Error('data channel closed');
395
+ },
396
+ { onDeliveryError: (error, event) => failures.push({ message: error.message, event }) },
397
+ );
398
+
399
+ // A synchronous query reply — before ARCH-030 the bridge guarded this at the string level, and
400
+ // the handler path next to it did not guard it at all.
401
+ expect(() => bridge.onClientMessage(JSON.stringify({ type: 'get-executing' }))).not.toThrow();
402
+ expect(failures).toEqual([{ message: 'data channel closed', event: 'executing' }]);
403
+ bridge.dispose();
404
+ });
405
+ });
406
+ });
407
+
408
+ describe('app view messages through the resume bridge', () => {
409
+ const view = {
410
+ sourceId: 'files',
411
+ resourceUri: 'ui://files/list',
412
+ mimeType: 'text/html;profile=mcp-app',
413
+ html: '<main>rows</main>',
414
+ callableTools: { list_files: 'mcp__files__list_files' },
415
+ };
416
+ const call = {
417
+ type: 'call-app-tool',
418
+ requestId: 'c1',
419
+ viewId: 'view-1',
420
+ name: 'mcp__files__list_files',
421
+ arguments: {},
422
+ };
423
+
424
+ it('cancels a call waiting on its prompt when the host gives up on the bridge, not when a channel drops', async () => {
425
+ let signal: AbortSignal | undefined;
426
+ const base = fakeSession().session;
427
+ const session = Object.assign(base, {
428
+ readToolAppView: vi.fn().mockReturnValue(view),
429
+ invokeRuntimeTool: vi.fn(
430
+ (_name: string, _arguments: unknown, options?: { signal?: AbortSignal }) => {
431
+ signal = options?.signal;
432
+ return new Promise(() => undefined);
433
+ },
434
+ ),
435
+ });
436
+ const bridge = new SessionResumeBridge({ session });
437
+ bridge.attach(sink(), TEST_ATTACH_OPTIONS);
438
+
439
+ bridge.onClientMessage(JSON.stringify(call));
440
+ await vi.waitFor(() => expect(signal).toBeDefined());
441
+
442
+ // A channel drop leaves the session reconnectable: the call keeps waiting for its prompt.
443
+ bridge.detach();
444
+ expect(signal?.aborted).toBe(false);
445
+
446
+ bridge.dispose();
447
+ expect(signal?.aborted).toBe(true);
448
+ });
449
+
450
+ it('holds an app view to the channel limit before the frame reaches the buffer', () => {
451
+ const base = fakeSession().session;
452
+ const session = Object.assign(base, {
453
+ readToolAppView: vi.fn().mockReturnValue({ ...view, html: 'x'.repeat(5_000) }),
454
+ });
455
+ const bridge = new SessionResumeBridge({ session, maxFrameBytes: 2_000 });
456
+ const output = sink();
457
+ bridge.attach(output, TEST_ATTACH_OPTIONS);
458
+
459
+ bridge.onClientMessage(JSON.stringify({ type: 'read-app-view', requestId: 'v1', viewId: 'view-1' }));
460
+
461
+ expect(frames(output).map((frame) => frame.type)).toEqual(['protocol_error']);
462
+ // Nothing large was retained for a later `resume` either.
463
+ const replay = sink();
464
+ bridge.detach();
465
+ bridge.attach(replay, TEST_ATTACH_OPTIONS);
466
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
467
+ expect(frames(replay).map((frame) => frame.type)).toEqual(['protocol_error']);
468
+ expect(replay.calls.join('').length).toBeLessThan(2_000);
469
+ bridge.dispose();
470
+ });
471
+ });
472
+
@@ -0,0 +1,193 @@
1
+ /**
2
+ * #3282 §4a: the GUI Settings screen reads and writes through `get-settings`/`update-settings`,
3
+ * correlated by `requestId` and answered by `settings` or `settings_error` — the same request/error
4
+ * shape as `get-personal-usage-report`. The host-owned reporter decides what a read or write means;
5
+ * this layer only correlates and carries the result.
6
+ */
7
+
8
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+
11
+ import { createOutboundDelivery } from '../outbound-delivery.js';
12
+ import { createSessionMessageHandler } from '../session-message-handler.js';
13
+
14
+ import type { ISettingsReporter, TSettingsUpdateOutcome } from '../settings-messages.js';
15
+ import type { TClientMessage, TServerMessage } from '../wire-messages.js';
16
+ import type { ISettingsSnapshot } from '@robota-sdk/agent-interface-session';
17
+
18
+ const snapshot: ISettingsSnapshot = {
19
+ language: { current: 'en', recommended: [], appliesNote: 'Takes effect after a restart.' },
20
+ outputStyle: { current: 'default', choices: [] },
21
+ preset: { current: 'default', choices: [], skipsAllChecksPresetIds: [] },
22
+ permissionMode: { current: 'default', choices: [], skipsAllChecksMode: 'bypassPermissions' },
23
+ permissionRules: [],
24
+ sandbox: { enabled: true, available: true, description: 'Confines shell commands.' },
25
+ mcp: { servers: [] },
26
+ plugins: { plugins: [], canInstall: true },
27
+ providers: { profiles: [] },
28
+ };
29
+
30
+ function createReporter(overrides: Partial<ISettingsReporter> = {}): ISettingsReporter {
31
+ return {
32
+ getSettings: vi.fn(() => snapshot),
33
+ updateSettings: vi.fn(
34
+ (): TSettingsUpdateOutcome => ({ ok: true, settings: snapshot }),
35
+ ),
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ function attach(
41
+ options: {
42
+ reporter?: ISettingsReporter;
43
+ role?: 'drive' | 'observe';
44
+ commandSurfaceLocality?: 'local' | 'remote';
45
+ } = {},
46
+ ): {
47
+ sent: TServerMessage[];
48
+ send: (message: TClientMessage) => void;
49
+ } {
50
+ const sent: TServerMessage[] = [];
51
+ const { onMessage } = createSessionMessageHandler({
52
+ session: createTestInteractiveSession(),
53
+ deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
54
+ ...(options.reporter ? { settingsReporter: options.reporter } : {}),
55
+ ...(options.role ? { role: options.role } : {}),
56
+ ...(options.commandSurfaceLocality
57
+ ? { commandSurfaceLocality: options.commandSurfaceLocality }
58
+ : {}),
59
+ });
60
+ return { sent, send: (message) => onMessage(JSON.stringify(message)) };
61
+ }
62
+
63
+ const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
64
+
65
+ describe('settings messages (#3282 §4a)', () => {
66
+ it('answers get-settings with the reporter snapshot, correlated by requestId', async () => {
67
+ const reporter = createReporter();
68
+ const client = attach({ reporter });
69
+ client.send({ type: 'get-settings', requestId: 'r1' });
70
+ await flush();
71
+ expect(client.sent).toEqual([{ type: 'settings', requestId: 'r1', settings: snapshot }]);
72
+ });
73
+
74
+ it('answers not_available when the host has no settings reporter', async () => {
75
+ const client = attach({});
76
+ client.send({ type: 'get-settings', requestId: 'r1' });
77
+ await flush();
78
+ expect(client.sent).toEqual([
79
+ {
80
+ type: 'settings_error',
81
+ requestId: 'r1',
82
+ code: 'not_available',
83
+ message: 'Settings are not available on this host.',
84
+ },
85
+ ]);
86
+ });
87
+
88
+ it('applies a patch through the reporter and answers with the fresh snapshot', async () => {
89
+ const reporter = createReporter();
90
+ const client = attach({ reporter });
91
+ const patch = { field: 'outputStyle' as const, styleId: 'concise' };
92
+ client.send({ type: 'update-settings', requestId: 'r2', patch });
93
+ await flush();
94
+ expect(reporter.updateSettings).toHaveBeenCalledOnce();
95
+ expect(vi.mocked(reporter.updateSettings).mock.calls[0]?.[1]).toEqual(patch);
96
+ expect(client.sent).toEqual([{ type: 'settings', requestId: 'r2', settings: snapshot }]);
97
+ });
98
+
99
+ it('reports a refused patch as settings_error with the reporter code and message, writing nothing', async () => {
100
+ const reporter = createReporter({
101
+ updateSettings: vi.fn(
102
+ (): TSettingsUpdateOutcome => ({
103
+ ok: false,
104
+ code: 'refused',
105
+ message: 'Entering "bypassPermissions" is blocked for this workspace.',
106
+ }),
107
+ ),
108
+ });
109
+ const client = attach({ reporter });
110
+ client.send({
111
+ type: 'update-settings',
112
+ requestId: 'r3',
113
+ patch: { field: 'permissionMode', mode: 'bypassPermissions' },
114
+ });
115
+ await flush();
116
+ expect(client.sent).toEqual([
117
+ {
118
+ type: 'settings_error',
119
+ requestId: 'r3',
120
+ code: 'refused',
121
+ message: 'Entering "bypassPermissions" is blocked for this workspace.',
122
+ },
123
+ ]);
124
+ });
125
+
126
+ it('reports a thrown error from the reporter as update_failed', async () => {
127
+ const reporter = createReporter({
128
+ updateSettings: vi.fn(() => {
129
+ throw new Error('disk full');
130
+ }),
131
+ });
132
+ const client = attach({ reporter });
133
+ client.send({
134
+ type: 'update-settings',
135
+ requestId: 'r4',
136
+ patch: { field: 'language', language: 'ko' },
137
+ });
138
+ await flush();
139
+ expect(client.sent).toEqual([
140
+ { type: 'settings_error', requestId: 'r4', code: 'update_failed', message: 'disk full' },
141
+ ]);
142
+ });
143
+
144
+ it('lets an observer read settings but never write them', async () => {
145
+ const reporter = createReporter();
146
+ const observer = attach({ reporter, role: 'observe' });
147
+ observer.send({ type: 'get-settings', requestId: 'r5' });
148
+ observer.send({
149
+ type: 'update-settings',
150
+ requestId: 'r6',
151
+ patch: { field: 'sandbox', enabled: false },
152
+ });
153
+ await flush();
154
+ // The observer-role refusal is synchronous; the reporter's (possibly async) snapshot reply is
155
+ // not, so it lands second even though `get-settings` was sent first.
156
+ expect(observer.sent).toEqual([
157
+ { type: 'protocol_error', message: 'Not permitted for an observer: update-settings' },
158
+ { type: 'settings', requestId: 'r5', settings: snapshot },
159
+ ]);
160
+ expect(reporter.updateSettings).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it('forwards this carrier\'s commandSurfaceLocality to both a read and a write (#3282 §4 part b-2)', async () => {
164
+ const reporter = createReporter();
165
+ const client = attach({ reporter, commandSurfaceLocality: 'remote' });
166
+ client.send({ type: 'get-settings', requestId: 'r7' });
167
+ client.send({
168
+ type: 'update-settings',
169
+ requestId: 'r8',
170
+ patch: { field: 'reloadMcpServers' },
171
+ });
172
+ await flush();
173
+ expect(vi.mocked(reporter.getSettings).mock.calls[0]?.[1]).toBe('remote');
174
+ expect(vi.mocked(reporter.updateSettings).mock.calls[0]?.[2]).toBe('remote');
175
+ });
176
+
177
+ it.each([
178
+ { field: 'mcpServerEnabled', serverId: 'docs', enabled: false },
179
+ { field: 'reloadMcpServers' },
180
+ { field: 'pluginEnabled', pluginId: 'formatter@fixture-agent', enabled: true },
181
+ { field: 'reloadPlugins' },
182
+ { field: 'installPlugin', pluginId: 'linter@fixture-agent' },
183
+ { field: 'uninstallPlugin', pluginId: 'formatter@fixture-agent' },
184
+ ] as const)('round-trips the $field patch through the reporter', async (patch) => {
185
+ const reporter = createReporter();
186
+ const client = attach({ reporter });
187
+ client.send({ type: 'update-settings', requestId: 'rt', patch });
188
+ await flush();
189
+ expect(reporter.updateSettings).toHaveBeenCalledOnce();
190
+ expect(vi.mocked(reporter.updateSettings).mock.calls[0]?.[1]).toEqual(patch);
191
+ expect(client.sent).toEqual([{ type: 'settings', requestId: 'rt', settings: snapshot }]);
192
+ });
193
+ });