@robota-sdk/agent-transport 3.0.0-beta.78 → 3.0.0-beta.81

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 (111) 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 +109 -0
  21. package/dist/node/node.d.cts.map +1 -0
  22. package/dist/node/node.d.ts +109 -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__/handoff-chunking.test.ts +215 -0
  32. package/src/__tests__/handoff-manifest.test.ts +52 -0
  33. package/src/__tests__/message-decoders.test.ts +249 -0
  34. package/src/__tests__/outbound-delivery.test.ts +558 -0
  35. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  36. package/src/__tests__/personal-usage-report.test.ts +193 -0
  37. package/src/__tests__/resume-buffer.test.ts +93 -0
  38. package/src/__tests__/session-event-delivery.test.ts +101 -0
  39. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  40. package/src/__tests__/session-message-handler.test.ts +590 -0
  41. package/src/__tests__/session-resume-bridge.test.ts +406 -0
  42. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  43. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  44. package/src/__tests__/ws-broadcast-events.test.ts +84 -0
  45. package/src/background-messages.ts +169 -0
  46. package/src/channel-frames.ts +182 -0
  47. package/src/client.ts +9 -0
  48. package/src/handoff-chunking.ts +235 -0
  49. package/src/index.ts +112 -3
  50. package/src/message-decoders.ts +229 -0
  51. package/src/message-parser.ts +33 -0
  52. package/src/node/access-token-verifier.ts +363 -0
  53. package/src/node/admission.ts +153 -0
  54. package/src/node/handoff-manifest.ts +77 -0
  55. package/src/node/index.ts +10 -0
  56. package/src/outbound-delivery.ts +231 -0
  57. package/src/peer-message-ledger.ts +193 -0
  58. package/src/protocol-session.ts +28 -0
  59. package/src/resume-buffer.ts +123 -0
  60. package/src/session-events.ts +198 -0
  61. package/src/session-message-handler.ts +282 -0
  62. package/src/session-query-messages.ts +42 -0
  63. package/src/session-resume-bridge.ts +252 -0
  64. package/src/usage-messages.ts +195 -0
  65. package/src/wire-messages.ts +165 -0
  66. package/dist/node/headless/index.cjs +0 -1
  67. package/dist/node/headless/index.d.ts +0 -2
  68. package/dist/node/headless/index.js +0 -1
  69. package/dist/node/headless-DG_MEbf0.js +0 -19
  70. package/dist/node/headless-DG_MEbf0.js.map +0 -1
  71. package/dist/node/headless-xq77JBq0.cjs +0 -18
  72. package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
  73. package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
  74. package/dist/node/index-JIvzrtaA.d.ts +0 -52
  75. package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
  76. package/dist/node/index-JTD_xtEQ.d.ts +0 -96
  77. package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
  78. package/dist/node/index-y_HBOfzO.d.ts +0 -52
  79. package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
  80. package/dist/node/programmatic/index.cjs +0 -1
  81. package/dist/node/programmatic/index.d.ts +0 -2
  82. package/dist/node/programmatic/index.js +0 -1
  83. package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
  84. package/dist/node/programmatic-70dxIXtS.js +0 -2
  85. package/dist/node/programmatic-70dxIXtS.js.map +0 -1
  86. package/dist/node/testing/index.cjs +0 -1
  87. package/dist/node/testing/index.d.ts +0 -2
  88. package/dist/node/testing/index.js +0 -1
  89. package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
  90. package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
  91. package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
  92. package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
  93. package/src/headless/HeadlessInteractionChannel.ts +0 -129
  94. package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
  95. package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
  96. package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
  97. package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
  98. package/src/headless/__tests__/headless-runner.test.ts +0 -508
  99. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
  100. package/src/headless/__tests__/headless-transport.test.ts +0 -267
  101. package/src/headless/cli-input.ts +0 -50
  102. package/src/headless/headless-runner.ts +0 -230
  103. package/src/headless/headless-stream-json.ts +0 -148
  104. package/src/headless/headless-transport.ts +0 -43
  105. package/src/headless/index.ts +0 -8
  106. package/src/headless/print-terminal.ts +0 -57
  107. package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
  108. package/src/programmatic/createProgrammaticAgent.ts +0 -71
  109. package/src/programmatic/index.ts +0 -5
  110. package/src/testing/index.ts +0 -10
  111. package/src/transport-registry.ts +0 -103
@@ -0,0 +1,406 @@
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
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * TRANS-008 (issue #2045) — a `submit` prompt that is not a string must not reach the session.
3
+ *
4
+ * `parseClientMessage` does `JSON.parse(data) as TClientMessage`, and the only guard in the submit
5
+ * handler is `if (!msg.prompt)`. That is a FALSY check, not a type check: `{}`, `[]` and `42` are
6
+ * truthy, so they pass it and reach `session.submit(input: string)`.
7
+ *
8
+ * Why this one field and not the other thirteen with payloads: the value does not stop at the
9
+ * session. `interactive-session-execution-controller.ts:249` does
10
+ * `emit('user_message', displayInput ?? input)`, `session-events.ts` classifies `user_message` as
11
+ * `'forwarded'` and delivers `{ type: 'user_message', content, … }` — declared `content: string` —
12
+ * to EVERY attached client, not only the sender. Both client cast sites (`ws-session-client.ts:66`,
13
+ * `rtc-responder-gate.ts:118`) accept it with `as TServerMessage`, so nothing on the receiving side
14
+ * rejects it either.
15
+ *
16
+ * So one unguarded field lets a client put a shape-invalid value into a typed frame delivered to its
17
+ * peers. The permission and resume paths were measured too and do NOT cross that way: `interpretApproval`
18
+ * compares by strict equality against a closed set and defaults to deny, and a malformed `ack`/`resume`
19
+ * seq degrades only the sender's own attachment.
20
+ */
21
+
22
+ import { describe, expect, it, vi } from 'vitest';
23
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
24
+
25
+ import { createOutboundDelivery } from '../outbound-delivery.js';
26
+ import { createSessionMessageHandler } from '../session-message-handler.js';
27
+
28
+ import type { TServerMessage } from '../wire-messages.js';
29
+
30
+ function setup() {
31
+ const submit = vi.fn().mockResolvedValue({ turnId: 't1', completed: Promise.resolve() });
32
+ // The published conformant double, not a cast: a hand-rolled object asserted to be an
33
+ // `IInteractiveSession` is a partial re-implementation nothing checks against the real contract.
34
+ const session = Object.assign(createTestInteractiveSession(), { submit });
35
+ const sent: TServerMessage[] = [];
36
+ const { onMessage } = createSessionMessageHandler({
37
+ session,
38
+ deliver: createOutboundDelivery((msg) => sent.push(msg), vi.fn()),
39
+ });
40
+ return { submit, sent, onMessage };
41
+ }
42
+
43
+ describe('TRANS-008: a non-string submit prompt is refused at the ingress', () => {
44
+ it.each([
45
+ ['an object', {}],
46
+ ['an array', []],
47
+ ['a number', 42],
48
+ ['a boolean', true],
49
+ ])('refuses %s — it is truthy, so the falsy guard passes it', (_label, prompt) => {
50
+ const { submit, sent, onMessage } = setup();
51
+
52
+ onMessage(JSON.stringify({ type: 'submit', prompt }));
53
+
54
+ // The session must never see it: once it does, the value is re-emitted as `user_message` to
55
+ // every attached client inside a frame whose `content` is declared `string`.
56
+ expect(submit).not.toHaveBeenCalled();
57
+ expect(sent.some((msg) => msg.type === 'protocol_error')).toBe(true);
58
+ });
59
+
60
+ it('still refuses an EMPTY prompt, which the falsy guard already caught', () => {
61
+ // The control for the guard that exists. A type check that replaced it would otherwise let
62
+ // `''` through — a regression hidden by the new assertions above.
63
+ const { submit, sent, onMessage } = setup();
64
+
65
+ onMessage(JSON.stringify({ type: 'submit', prompt: '' }));
66
+
67
+ expect(submit).not.toHaveBeenCalled();
68
+ expect(sent.some((msg) => msg.type === 'protocol_error')).toBe(true);
69
+ });
70
+
71
+ it('still accepts a real string prompt, so the refusal is about shape and not about submitting', () => {
72
+ const { submit, onMessage } = setup();
73
+
74
+ onMessage(JSON.stringify({ type: 'submit', prompt: 'hello' }));
75
+
76
+ expect(submit).toHaveBeenCalledTimes(1);
77
+ });
78
+ });
@@ -0,0 +1,98 @@
1
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+
4
+ import { createOutboundDelivery } from '../outbound-delivery.js';
5
+ import { createSessionMessageHandler } from '../session-message-handler.js';
6
+ import type { TServerMessage } from '../wire-messages.js';
7
+ import type { IUsageBySourceReport } from '@robota-sdk/agent-interface-analytics';
8
+
9
+ /**
10
+ * SELFHOST-004 P5 (TC-08): the new `usage_report` TServerMessage variant carries the assembled
11
+ * trace/cost read-model (per-operation span timeline + cost-by-source) across the sidecar boundary.
12
+ * No pre-existing variant carries per-op `durationMs` or per-source `costUsd`; this test proves the
13
+ * carrier is well-typed AND survives JSON serialization (the WS stream is JSON) so the timeline/cost
14
+ * actually reaches the GUI — not assumed "free".
15
+ */
16
+ describe('SELFHOST-004 TC-08 — usage_report server-message carrier', () => {
17
+ const report: IUsageBySourceReport = {
18
+ sessionId: 'sess-1',
19
+ totalTokens: 300,
20
+ promptTokens: 200,
21
+ completionTokens: 100,
22
+ costUsd: 0.0015,
23
+ costExact: true,
24
+ bySource: [
25
+ {
26
+ key: 'main:',
27
+ source: { scope: 'main', label: 'main thread' },
28
+ label: 'main thread',
29
+ promptTokens: 200,
30
+ completionTokens: 100,
31
+ totalTokens: 300,
32
+ turns: 1,
33
+ percentage: 100,
34
+ costUsd: 0.0015,
35
+ costExact: true,
36
+ },
37
+ ],
38
+ timeline: [
39
+ {
40
+ turnIndex: 0,
41
+ source: { scope: 'main', label: 'main thread' },
42
+ label: 'main thread',
43
+ spans: [
44
+ { spanId: 'span_a', op: 'read', durationMs: 5 },
45
+ { spanId: 'span_b', op: 'grep', durationMs: 7 },
46
+ ],
47
+ totalDurationMs: 12,
48
+ },
49
+ ],
50
+ };
51
+
52
+ it('is assignable to TServerMessage and carries per-op durationMs + per-source costUsd together', () => {
53
+ const message: TServerMessage = { type: 'usage_report', report };
54
+
55
+ // narrow by discriminant — the carrier’s payload is the assembled read-model
56
+ expect(message.type).toBe('usage_report');
57
+ if (message.type !== 'usage_report') throw new Error('unreachable');
58
+
59
+ // per-op timing rides the carrier (the datum no prior variant carried)
60
+ expect(message.report.timeline[0]?.spans.map((s) => s.durationMs)).toEqual([5, 7]);
61
+ // per-source cost rides the carrier
62
+ expect(message.report.bySource[0]?.costUsd).toBeCloseTo(0.0015, 6);
63
+ expect(message.report.costUsd).toBeCloseTo(0.0015, 6);
64
+ });
65
+
66
+ it('survives JSON round-trip (the WS stream) with the timeline + cost intact', () => {
67
+ const message: TServerMessage = { type: 'usage_report', report };
68
+
69
+ const wire = JSON.parse(JSON.stringify(message)) as TServerMessage;
70
+ expect(wire).toEqual(message);
71
+ if (wire.type !== 'usage_report') throw new Error('unreachable');
72
+ expect(wire.report.timeline[0]?.totalDurationMs).toBe(12);
73
+ expect(wire.report.bySource[0]?.costUsd).toBeCloseTo(0.0015, 6);
74
+ });
75
+
76
+ it('requests are expressible via the get-usage-report client message', () => {
77
+ // compile-time proof the request variant exists (the GUI asks for the report)
78
+ const req = { type: 'get-usage-report' } as const;
79
+ expect(req.type).toBe('get-usage-report');
80
+ });
81
+
82
+ it('routes the request through the host producer and delivers the report', async () => {
83
+ const sent: TServerMessage[] = [];
84
+ const reporter = vi.fn().mockReturnValue(report);
85
+ const session = createTestInteractiveSession();
86
+ const { onMessage } = createSessionMessageHandler({
87
+ session,
88
+ deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
89
+ usageReporter: reporter,
90
+ });
91
+
92
+ onMessage(JSON.stringify({ type: 'get-usage-report' }));
93
+ await Promise.resolve();
94
+
95
+ expect(reporter).toHaveBeenCalledWith(session);
96
+ expect(sent).toContainEqual({ type: 'usage_report', report });
97
+ });
98
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * CMD-004 Phase 2 Stage E — broadcast session events over the WS transport.
3
+ *
4
+ * The spec's final carriers: `session_renamed` and `history_cleared` are BROADCAST session events —
5
+ * every attached surface (including co-driving ones) receives them, so titles update and transcripts
6
+ * refresh everywhere. Unlike `ui_intent` (requester-routed), these are never filtered by driver id.
7
+ *
8
+ * Red-first evidence: against pre-Stage-E code neither event is forwarded by
9
+ * `subscribeSessionEvents` at all — a co-driving surface's title/transcript went stale.
10
+ */
11
+
12
+ import { describe, expect, it, vi } from 'vitest';
13
+
14
+ import { createOutboundDelivery } from '../outbound-delivery.js';
15
+ import { createSessionMessageHandler } from '../session-message-handler.js';
16
+
17
+ import type { TServerMessage } from '../wire-messages.js';
18
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
19
+
20
+ type TEmittingSession = IInteractiveSession & {
21
+ _emit: (event: string, ...args: unknown[]) => void;
22
+ };
23
+
24
+ function createMockSession(): TEmittingSession {
25
+ const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
26
+ return {
27
+ // ARCH-012: required. This double feeds `subscribeSessionEvents`, which calls it on every
28
+ // turn-authored event — omitting it throws the moment a case emits one.
29
+ getActiveDriverId: () => null,
30
+ on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
31
+ if (!listeners.has(event)) listeners.set(event, new Set());
32
+ listeners.get(event)!.add(handler);
33
+ }),
34
+ off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
35
+ listeners.get(event)?.delete(handler);
36
+ }),
37
+ _emit: (event: string, ...args: unknown[]) => {
38
+ listeners.get(event)?.forEach((h) => h(...args));
39
+ },
40
+ } as unknown as TEmittingSession;
41
+ }
42
+
43
+ /** Two real WS handlers (surfaces) over ONE session — the co-driving topology. */
44
+ function setupTwoSurfaces(): {
45
+ session: TEmittingSession;
46
+ sentA: TServerMessage[];
47
+ sentB: TServerMessage[];
48
+ } {
49
+ const session = createMockSession();
50
+ const sentA: TServerMessage[] = [];
51
+ const sentB: TServerMessage[] = [];
52
+ createSessionMessageHandler({
53
+ session,
54
+ deliver: createOutboundDelivery((m) => sentA.push(m), vi.fn()),
55
+ driverId: 'device-A',
56
+ });
57
+ createSessionMessageHandler({
58
+ session,
59
+ deliver: createOutboundDelivery((m) => sentB.push(m), vi.fn()),
60
+ driverId: 'device-B',
61
+ });
62
+ return { session, sentA, sentB };
63
+ }
64
+
65
+ describe('CMD-004 Stage E — session_renamed / history_cleared broadcast to EVERY surface', () => {
66
+ it('session_renamed reaches both attached surfaces (titles update everywhere)', () => {
67
+ const { session, sentA, sentB } = setupTwoSurfaces();
68
+
69
+ session._emit('session_renamed', { name: 'Renamed Everywhere' });
70
+
71
+ const expected = { type: 'session_renamed', event: { name: 'Renamed Everywhere' } };
72
+ expect(sentA).toEqual([expected]);
73
+ expect(sentB).toEqual([expected]);
74
+ });
75
+
76
+ it('history_cleared reaches both attached surfaces (transcripts refresh everywhere)', () => {
77
+ const { session, sentA, sentB } = setupTwoSurfaces();
78
+
79
+ session._emit('history_cleared');
80
+
81
+ expect(sentA).toEqual([{ type: 'history_cleared' }]);
82
+ expect(sentB).toEqual([{ type: 'history_cleared' }]);
83
+ });
84
+ });