@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,130 @@
1
+ /**
2
+ * SEC-008 — the admission seam's own cases.
3
+ *
4
+ * `resolveAdmission` had none. It was exercised only THROUGH the four transports that call it, and
5
+ * both defects review found here are ones that indirection hides: a transport passing a well-formed
6
+ * config never exhibits either.
7
+ *
8
+ * The seam's whole claim is "one place to read and one place to change". A seam nobody tests
9
+ * directly is one place to read and four places to find out you were wrong.
10
+ */
11
+
12
+ import { describe, expect, it } from 'vitest';
13
+
14
+ import { bearerCredential, resolveAdmission } from '../node/admission.js';
15
+
16
+ const OPEN_REASON = 'loopback only, the port is bound to 127.0.0.1';
17
+
18
+ describe('resolveAdmission is secure by default', () => {
19
+ it('mints when nothing is asked for', () => {
20
+ const admission = resolveAdmission();
21
+
22
+ expect(admission.token).toMatch(/^[0-9a-f]{64}$/);
23
+ expect(admission.openReason).toBeUndefined();
24
+ });
25
+
26
+ it('keeps an explicit token', () => {
27
+ expect(resolveAdmission({ token: 'chosen-by-the-host' })).toEqual({
28
+ token: 'chosen-by-the-host',
29
+ });
30
+ });
31
+
32
+ it('opens only with a reason', () => {
33
+ expect(resolveAdmission({ open: true, openReason: OPEN_REASON })).toEqual({
34
+ token: null,
35
+ openReason: OPEN_REASON,
36
+ });
37
+ expect(() => resolveAdmission({ open: true })).toThrow(/requires `openReason`/);
38
+ expect(() => resolveAdmission({ open: true, openReason: ' ' })).toThrow(
39
+ /requires `openReason`/,
40
+ );
41
+ });
42
+
43
+ it('MINTS for an empty-string token rather than requiring the empty string', () => {
44
+ // The value behind the discriminator defect. `{ token: '' }` is documented as "mint a fresh
45
+ // one", and the HTTP route's `'token' in options.admission` read it as ALREADY RESOLVED and
46
+ // installed the empty string as the required credential — which a peer sending an empty bearer
47
+ // then matches. The route no longer discriminates; this pins the value it depends on.
48
+ expect(resolveAdmission({ token: '' }).token).toMatch(/^[0-9a-f]{64}$/);
49
+ });
50
+ });
51
+
52
+ describe('resolveAdmission is idempotent', () => {
53
+ // Why it must be: the HTTP route accepts either shape, and BOTH interfaces declare a `token`, so
54
+ // they cannot be told apart structurally. Making the function accept its own output deletes that
55
+ // question instead of answering it wrongly.
56
+ it('returns an already-resolved token admission unchanged', () => {
57
+ const resolved = resolveAdmission({ token: 'chosen-by-the-host' });
58
+
59
+ expect(resolveAdmission(resolved)).toEqual(resolved);
60
+ });
61
+
62
+ it('returns an already-resolved OPEN admission unchanged, reason and all', () => {
63
+ // The case that made the old code wrong in the dangerous direction: re-resolving an open
64
+ // admission through a config-shaped path either minted a token nobody holds or dropped the
65
+ // reason and left an unexplained open transport.
66
+ const resolved = resolveAdmission({ open: true, openReason: OPEN_REASON });
67
+
68
+ expect(resolved).toEqual({ token: null, openReason: OPEN_REASON });
69
+ expect(resolveAdmission(resolved)).toEqual(resolved);
70
+ });
71
+
72
+ it('REFUSES an open admission that arrives without its reason', () => {
73
+ // `token: null` is the recorded outcome of a decision. One reaching here with no reason was
74
+ // hand-built or corrupted, and both are the unexplained-open state the reason exists to
75
+ // prevent — so it is refused rather than passed through.
76
+ expect(() => resolveAdmission({ token: null })).toThrow(/requires `openReason`/);
77
+ });
78
+ });
79
+
80
+ describe('resolveAdmission refuses a contradiction', () => {
81
+ it('throws when a token and `open: true` are asked for together', () => {
82
+ // Review: `WebRtcTransport` already refused the analogous `secret` + `open` pair in its own
83
+ // constructor, so only WebRTC callers were protected. The old seam gave silent precedence to
84
+ // the token — the safe DIRECTION, but the wrong ANSWER: a caller that wrote both does not know
85
+ // what it is asking for, and one of the two things it believes is false.
86
+ expect(() =>
87
+ resolveAdmission({ token: 'a-credential', open: true, openReason: OPEN_REASON }),
88
+ ).toThrow(/contradictory/);
89
+ });
90
+
91
+ it('still accepts `open: false` beside a token, which is not a contradiction', () => {
92
+ // `open: false` says the same thing the token does. Refusing it would turn a redundant but
93
+ // coherent config into an error, which is over-blocking for nothing.
94
+ expect(resolveAdmission({ token: 'a-credential', open: false })).toEqual({
95
+ token: 'a-credential',
96
+ });
97
+ });
98
+ });
99
+
100
+ describe('bearerCredential reads the header the way RFC 7235 defines it', () => {
101
+ it('matches the SCHEME without regard to case', () => {
102
+ // `auth-scheme` is a token, and tokens compare case-insensitively. The exact-case match refused
103
+ // a conformant client with a 401 it could not act on — a refusal that reads as a server bug and
104
+ // gets worked around rather than fixed. Review found it.
105
+ expect(bearerCredential('bearer abc123')).toBe('abc123');
106
+ expect(bearerCredential('BEARER abc123')).toBe('abc123');
107
+ expect(bearerCredential('BeArEr abc123')).toBe('abc123');
108
+ });
109
+
110
+ it('leaves the CREDENTIAL exactly as sent', () => {
111
+ // Only the scheme is case-insensitive. The credential is compared byte for byte, so folding it
112
+ // would make two different secrets the same secret.
113
+ expect(bearerCredential('Bearer AbC')).toBe('AbC');
114
+ });
115
+
116
+ it('accepts more than one space between the two', () => {
117
+ expect(bearerCredential('Bearer abc123')).toBe('abc123');
118
+ expect(bearerCredential('Bearer\tabc123')).toBe('abc123');
119
+ });
120
+
121
+ it('refuses a header that merely STARTS with the word', () => {
122
+ // `Bearerness xyz` is a different scheme, and reading it as a bearer token would admit a peer
123
+ // that never claimed to be one.
124
+ expect(bearerCredential('Bearerness xyz')).toBeUndefined();
125
+ expect(bearerCredential('Basic abc123')).toBeUndefined();
126
+ expect(bearerCredential('Bearer')).toBeUndefined();
127
+ expect(bearerCredential('Bearer ')).toBeUndefined();
128
+ expect(bearerCredential(undefined)).toBeUndefined();
129
+ });
130
+ });
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ import {
4
+ CHANNEL_FRAME_MAGIC,
5
+ decodeChannelFrame,
6
+ encodeBinaryFrame,
7
+ encodeChannelEventFrame,
8
+ } from '../channel-frames.js';
9
+
10
+ import type { IBinaryFrame, IChannelEventFrame } from '@robota-sdk/agent-interface-transport';
11
+
12
+ /**
13
+ * TRANS-001 — payload-agnostic frame codec.
14
+ *
15
+ * The codec is the ONLY thing that knows the carrier envelope; it never interprets the opaque body.
16
+ * These tests pin round-trip integrity, ordering metadata (`seq`), and explicit malformed-input
17
+ * results (a result union, never a swallowed default).
18
+ */
19
+
20
+ function bytes(...values: number[]): Uint8Array {
21
+ return Uint8Array.from(values);
22
+ }
23
+
24
+ describe('channel frame codec (TRANS-001)', () => {
25
+ it('round-trips an opaque binary payload byte-identically', () => {
26
+ // Deliberately includes bytes that are invalid UTF-8 and JSON-hostile — the carrier must not care.
27
+ const payload = bytes(0x00, 0xff, 0xfe, 0x7b, 0x22, 0x80, 0x0a, 0xc3, 0x28);
28
+ const encoded = encodeBinaryFrame({ kind: 'binary', channel: 'audio', seq: 7, payload });
29
+
30
+ const decoded = decodeChannelFrame(encoded);
31
+ expect(decoded.ok).toBe(true);
32
+ if (!decoded.ok) return;
33
+ expect(decoded.frame.kind).toBe('binary');
34
+ const frame = decoded.frame as IBinaryFrame;
35
+ expect(frame.channel).toBe('audio');
36
+ expect(frame.seq).toBe(7);
37
+ expect(Array.from(frame.payload)).toEqual(Array.from(payload));
38
+ });
39
+
40
+ it('round-trips an empty binary payload', () => {
41
+ const encoded = encodeBinaryFrame({
42
+ kind: 'binary',
43
+ channel: 'blob',
44
+ seq: 0,
45
+ payload: new Uint8Array(0),
46
+ });
47
+ const decoded = decodeChannelFrame(encoded);
48
+ expect(decoded.ok).toBe(true);
49
+ if (!decoded.ok) return;
50
+ expect((decoded.frame as IBinaryFrame).payload.byteLength).toBe(0);
51
+ });
52
+
53
+ it('preserves per-channel ordering through seq on a chunked stream', () => {
54
+ const source = new Uint8Array(1024);
55
+ for (let i = 0; i < source.length; i += 1) source[i] = (i * 31) % 256;
56
+
57
+ const chunkSize = 100;
58
+ const wire: Uint8Array[] = [];
59
+ for (let offset = 0, seq = 0; offset < source.length; offset += chunkSize, seq += 1) {
60
+ wire.push(
61
+ encodeBinaryFrame({
62
+ kind: 'binary',
63
+ channel: 'file',
64
+ seq,
65
+ payload: source.subarray(offset, Math.min(offset + chunkSize, source.length)),
66
+ }),
67
+ );
68
+ }
69
+
70
+ // Deliver out of order; the receiver reassembles by `seq`, not by arrival order.
71
+ const shuffled = [...wire].reverse();
72
+ const received: IBinaryFrame[] = [];
73
+ for (const w of shuffled) {
74
+ const d = decodeChannelFrame(w);
75
+ expect(d.ok).toBe(true);
76
+ if (d.ok && d.frame.kind === 'binary') received.push(d.frame);
77
+ }
78
+
79
+ received.sort((a, b) => a.seq - b.seq);
80
+ const reassembled = new Uint8Array(source.length);
81
+ let cursor = 0;
82
+ for (const frame of received) {
83
+ reassembled.set(frame.payload, cursor);
84
+ cursor += frame.payload.byteLength;
85
+ }
86
+ expect(cursor).toBe(source.length);
87
+ expect(Array.from(reassembled)).toEqual(Array.from(source));
88
+ });
89
+
90
+ it('round-trips a consumer-declared structured event frame', () => {
91
+ const encoded = encodeChannelEventFrame({
92
+ kind: 'event',
93
+ channel: 'captions',
94
+ seq: 3,
95
+ event: 'caption',
96
+ payload: { text: 'hello', final: true },
97
+ });
98
+
99
+ const decoded = decodeChannelFrame(encoded);
100
+ expect(decoded.ok).toBe(true);
101
+ if (!decoded.ok) return;
102
+ expect(decoded.frame.kind).toBe('event');
103
+ const frame = decoded.frame as IChannelEventFrame;
104
+ expect(frame.channel).toBe('captions');
105
+ expect(frame.seq).toBe(3);
106
+ expect(frame.event).toBe('caption');
107
+ expect(frame.payload).toEqual({ text: 'hello', final: true });
108
+ });
109
+
110
+ it('supports multi-byte UTF-8 channel names', () => {
111
+ const encoded = encodeBinaryFrame({
112
+ kind: 'binary',
113
+ channel: '음성-채널',
114
+ seq: 1,
115
+ payload: bytes(1, 2, 3),
116
+ });
117
+ const decoded = decodeChannelFrame(encoded);
118
+ expect(decoded.ok).toBe(true);
119
+ if (!decoded.ok) return;
120
+ expect(decoded.frame.channel).toBe('음성-채널');
121
+ });
122
+
123
+ it('rejects a channel name that does not fit the 255-byte header field', () => {
124
+ expect(() =>
125
+ encodeBinaryFrame({
126
+ kind: 'binary',
127
+ channel: 'x'.repeat(256),
128
+ seq: 0,
129
+ payload: new Uint8Array(0),
130
+ }),
131
+ ).toThrow(/channel name/i);
132
+ });
133
+
134
+ it('rejects an empty channel name', () => {
135
+ expect(() =>
136
+ encodeBinaryFrame({ kind: 'binary', channel: '', seq: 0, payload: new Uint8Array(0) }),
137
+ ).toThrow(/channel name/i);
138
+ });
139
+
140
+ it('rejects a seq outside the uint32 range', () => {
141
+ expect(() =>
142
+ encodeBinaryFrame({
143
+ kind: 'binary',
144
+ channel: 'c',
145
+ seq: 2 ** 32,
146
+ payload: new Uint8Array(0),
147
+ }),
148
+ ).toThrow(/seq/i);
149
+ });
150
+
151
+ it('reports a non-channel frame as a typed error result rather than throwing', () => {
152
+ const decoded = decodeChannelFrame(bytes(0x01, 0x02, 0x03, 0x04, 0x05));
153
+ expect(decoded.ok).toBe(false);
154
+ if (decoded.ok) return;
155
+ expect(decoded.error).toMatch(/magic|channel frame/i);
156
+ });
157
+
158
+ it('reports a truncated frame as a typed error result', () => {
159
+ const full = encodeBinaryFrame({
160
+ kind: 'binary',
161
+ channel: 'audio',
162
+ seq: 1,
163
+ payload: bytes(9, 9, 9),
164
+ });
165
+ const decoded = decodeChannelFrame(full.subarray(0, 6));
166
+ expect(decoded.ok).toBe(false);
167
+ if (decoded.ok) return;
168
+ expect(decoded.error).toMatch(/truncated/i);
169
+ });
170
+
171
+ it('reports an unknown frame kind as a typed error result', () => {
172
+ const full = encodeBinaryFrame({
173
+ kind: 'binary',
174
+ channel: 'audio',
175
+ seq: 1,
176
+ payload: bytes(9),
177
+ });
178
+ const tampered = Uint8Array.from(full);
179
+ tampered[4] = 0x7f; // unknown kind byte
180
+ const decoded = decodeChannelFrame(tampered);
181
+ expect(decoded.ok).toBe(false);
182
+ if (decoded.ok) return;
183
+ expect(decoded.error).toMatch(/kind/i);
184
+ });
185
+
186
+ it('reports a malformed event body as a typed error result', () => {
187
+ const good = encodeChannelEventFrame({
188
+ kind: 'event',
189
+ channel: 'c',
190
+ seq: 0,
191
+ event: 'e',
192
+ payload: null,
193
+ });
194
+ const tampered = Uint8Array.from(good);
195
+ // Corrupt the JSON body (everything after the 10 + channelNameLength header bytes).
196
+ tampered[tampered.length - 1] = 0x7b; // '{'
197
+ const decoded = decodeChannelFrame(tampered);
198
+ expect(decoded.ok).toBe(false);
199
+ if (decoded.ok) return;
200
+ expect(decoded.error).toMatch(/body|json/i);
201
+ });
202
+
203
+ it('stamps the shared magic so a carrier can route frames without parsing them', () => {
204
+ const encoded = encodeBinaryFrame({
205
+ kind: 'binary',
206
+ channel: 'c',
207
+ seq: 0,
208
+ payload: new Uint8Array(0),
209
+ });
210
+ expect(Array.from(encoded.subarray(0, CHANNEL_FRAME_MAGIC.length))).toEqual(
211
+ Array.from(CHANNEL_FRAME_MAGIC),
212
+ );
213
+ });
214
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createOutboundDelivery } from '../outbound-delivery.js';
4
+ import { subscribeSessionEvents } from '../session-events.js';
5
+
6
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
7
+
8
+ import type { IInteractiveSession, TDriverId } from '@robota-sdk/agent-interface-session';
9
+
10
+ /**
11
+ * ARCH-012 — the sharp edge of an optional contract member.
12
+ *
13
+ * `IInteractiveSession` declared `getActiveDriverId?()`, and the one consumer read it as
14
+ * `session.getActiveDriverId?.() ?? undefined`. Two entirely different situations arrived as the same
15
+ * `undefined`:
16
+ *
17
+ * - the host DOES attribute turns and no driver is active right now, and
18
+ * - the host does not implement attribution at all.
19
+ *
20
+ * The second silently loses every co-drive attribution, with no error, no log, and nothing a reader
21
+ * or a test could use to tell it from the first. The shipped `InteractiveSession` happens to
22
+ * implement all three optional members, so the defect was latent for it and live for the 51 casts
23
+ * that stand in for other implementations.
24
+ *
25
+ * The fix is not a better fallback. It is removing the optionality: a host either provides the
26
+ * capability or does not claim the contract. These cases pin that the member is REQUIRED, which is
27
+ * what makes "no active driver" the only thing `null` can mean.
28
+ */
29
+ function hostWithAttribution(driverId: TDriverId | null): IInteractiveSession {
30
+ // The conformant double, not another hand-rolled partial — the cast ratchet objected to the first
31
+ // draft of this very file, which is the behaviour it exists for.
32
+ return createTestInteractiveSession({
33
+ getActiveDriverId: () => driverId,
34
+ on: vi.fn(),
35
+ off: vi.fn(),
36
+ });
37
+ }
38
+
39
+ describe('driver attribution is a declared capability, not an optional call (ARCH-012)', () => {
40
+ it('TYPE-LEVEL: a host that omits the attribution members does not satisfy the contract', () => {
41
+ // Judged by `pnpm typecheck`, not by this assertion — `@ts-expect-error` fails the BUILD when the
42
+ // error it expects does not occur.
43
+ //
44
+ // `Omit` rather than a hand-written object literal, deliberately. A literal missing the three
45
+ // members is also missing the forty required ones, so `@ts-expect-error` would be satisfied by
46
+ // the wrong error and the case would pass whether or not the optionality was ever removed. That
47
+ // is precisely the accidental-green shape; the first draft of this test had it. This type carries
48
+ // every other member and differs ONLY in the three.
49
+ type OmitsAttribution = Omit<
50
+ IInteractiveSession,
51
+ 'getActiveDriverId' | 'getPendingCount' | 'isInitialized'
52
+ >;
53
+ const host = {} as OmitsAttribution;
54
+ // @ts-expect-error ARCH-012: the three are REQUIRED. If this ever compiles again the optionality
55
+ // is back, and `undefined` means both "no active driver" and "cannot answer" once more.
56
+ const asSession: IInteractiveSession = host;
57
+ expect(asSession).toBeDefined();
58
+ });
59
+
60
+ it('stamps the active driver onto a turn-authored event', () => {
61
+ const send = vi.fn();
62
+ const session = hostWithAttribution('driver-7');
63
+ subscribeSessionEvents(session, createOutboundDelivery(send, vi.fn()));
64
+
65
+ const handler = vi.mocked(session.on).mock.calls.find(([name]) => name === 'user_message')?.[1];
66
+ (handler as (content: string) => void)('hello');
67
+
68
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({ driverId: 'driver-7' }));
69
+ });
70
+
71
+ it('omits the driver when the host attributes turns and none is active', () => {
72
+ // `null` now means exactly one thing: nobody is driving. It used to be indistinguishable from
73
+ // "this host cannot answer the question".
74
+ const send = vi.fn();
75
+ const session = hostWithAttribution(null);
76
+ subscribeSessionEvents(session, createOutboundDelivery(send, vi.fn()));
77
+
78
+ const handler = vi.mocked(session.on).mock.calls.find(([name]) => name === 'user_message')?.[1];
79
+ (handler as (content: string) => void)('hello');
80
+
81
+ expect(send).toHaveBeenCalledWith(expect.not.objectContaining({ driverId: expect.anything() }));
82
+ });
83
+ });
@@ -0,0 +1,215 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ chunkCountFor,
5
+ chunkHandoffPayload,
6
+ HandoffChunkAssembler,
7
+ type IHandoffChunk,
8
+ } from '../handoff-chunking.js';
9
+ import { sealHandoffRecord, verifyHandoffPayload } from '../node/handoff-manifest.js';
10
+
11
+ import type { IInteractiveSessionRecord } from '@robota-sdk/agent-interface-session';
12
+
13
+ const ID = 'handoff_1';
14
+
15
+ function assemble(chunks: readonly IHandoffChunk[], assembler = new HandoffChunkAssembler(ID)) {
16
+ let last;
17
+ for (const chunk of chunks) last = assembler.accept(chunk);
18
+ return { assembler, last };
19
+ }
20
+
21
+ describe('HANDOFF-001 — a payload larger than one frame travels in pieces', () => {
22
+ it('every chunk fits the byte budget', () => {
23
+ // The channel's limit is in bytes, so the budget has to be honoured in bytes. A payload of
24
+ // multi-byte characters is where a character-based cut overflows the frame.
25
+ const payload = '한글'.repeat(500);
26
+
27
+ const chunks = chunkHandoffPayload(ID, payload, 64);
28
+
29
+ for (const chunk of chunks) {
30
+ expect(Buffer.from(chunk.data, 'base64').byteLength).toBeLessThanOrEqual(64);
31
+ }
32
+ });
33
+
34
+ it('round-trips a payload whose characters do not align to the cut', () => {
35
+ // The cut lands mid-character by construction here. Reassembly is on bytes, so the boundary
36
+ // is invisible to the result — which is the property the byte-based design buys.
37
+ const payload = `${'a'.repeat(30)}😀${'b'.repeat(30)}`;
38
+
39
+ const { last } = assemble(chunkHandoffPayload(ID, payload, 16));
40
+
41
+ expect(last?.outcome).toBe('complete');
42
+ expect(last?.serialized).toBe(payload);
43
+ });
44
+
45
+ it('an empty payload is one chunk, not zero', () => {
46
+ // Zero chunks would leave a receiver unable to tell a completed empty transfer from one that
47
+ // never started.
48
+ const chunks = chunkHandoffPayload(ID, '', 64);
49
+
50
+ expect(chunks).toHaveLength(1);
51
+ expect(assemble(chunks).last?.serialized).toBe('');
52
+ });
53
+
54
+ it('every chunk carries the total, so no single lost frame hides the count', () => {
55
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
56
+
57
+ expect(chunks.every((c) => c.total === chunks.length)).toBe(true);
58
+ });
59
+
60
+ it('refuses a budget that would produce an endless sequence of empty chunks', () => {
61
+ expect(() => chunkHandoffPayload(ID, 'x', 0)).toThrow(/positive integer/);
62
+ });
63
+
64
+ it('predicts the chunk count from the manifest without building them', () => {
65
+ const { serialized, integrity } = sealHandoffRecord({
66
+ id: 's',
67
+ cwd: '/w',
68
+ createdAt: 'a',
69
+ updatedAt: 'b',
70
+ messages: [],
71
+ } as IInteractiveSessionRecord);
72
+
73
+ expect(chunkCountFor(integrity, 16)).toBe(chunkHandoffPayload(ID, serialized, 16).length);
74
+ });
75
+ });
76
+
77
+ describe('HANDOFF-001 — reordering and retries are normal; inconsistency is not', () => {
78
+ it('accepts chunks out of order', () => {
79
+ const chunks = [...chunkHandoffPayload(ID, 'x'.repeat(100), 10)].reverse();
80
+
81
+ const { last } = assemble(chunks);
82
+
83
+ expect(last?.outcome).toBe('complete');
84
+ expect(last?.serialized).toBe('x'.repeat(100));
85
+ });
86
+
87
+ it('a re-sent chunk is a duplicate, not a problem', () => {
88
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
89
+ const assembler = new HandoffChunkAssembler(ID);
90
+ assembler.accept(chunks[0]);
91
+
92
+ const again = assembler.accept(chunks[0]);
93
+
94
+ expect(again.outcome).toBe('duplicate');
95
+ expect(again.received).toBe(1);
96
+ });
97
+
98
+ it('different bytes for the same index is refused, not overwritten', () => {
99
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
100
+ const assembler = new HandoffChunkAssembler(ID);
101
+ assembler.accept(chunks[0]);
102
+
103
+ const conflicting = assembler.accept({
104
+ ...chunks[0],
105
+ data: Buffer.from('zz').toString('base64'),
106
+ });
107
+
108
+ expect(conflicting.outcome).toBe('refused');
109
+ });
110
+
111
+ it('a peer that changes its mind about the size is refused', () => {
112
+ // Either it restarted the transfer under the same id, or it is not the peer we started with.
113
+ // Both mean the pieces held so far can no longer be assumed to belong together.
114
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
115
+ const assembler = new HandoffChunkAssembler(ID);
116
+ assembler.accept(chunks[0]);
117
+
118
+ const result = assembler.accept({ ...chunks[1], total: 3 });
119
+
120
+ expect(result.rejection).toBe('inconsistent-total');
121
+ });
122
+
123
+ it('refuses a chunk belonging to a different transfer rather than routing it', () => {
124
+ const [chunk] = chunkHandoffPayload('other_handoff', 'x', 10);
125
+
126
+ expect(new HandoffChunkAssembler(ID).accept(chunk).rejection).toBe('wrong-handoff');
127
+ });
128
+
129
+ it.each([
130
+ ['a negative index', -1],
131
+ ['an index beyond the total', 99],
132
+ ])('refuses %s', (_label, index) => {
133
+ const [chunk] = chunkHandoffPayload(ID, 'x'.repeat(30), 10);
134
+
135
+ expect(new HandoffChunkAssembler(ID).accept({ ...chunk, index }).rejection).toBe(
136
+ 'out-of-range',
137
+ );
138
+ });
139
+
140
+ it('reports which indices are still missing, so a caller can ask rather than restart', () => {
141
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
142
+ const assembler = new HandoffChunkAssembler(ID);
143
+ assembler.accept(chunks[0]);
144
+ assembler.accept(chunks[3]);
145
+
146
+ expect(assembler.missing()).toEqual([1, 2, 4, 5, 6, 7, 8, 9]);
147
+ });
148
+
149
+ it('knows nothing is missing before anything has declared a total', () => {
150
+ expect(new HandoffChunkAssembler(ID).missing()).toEqual([]);
151
+ });
152
+
153
+ it('discard drops what it held', () => {
154
+ const chunks = chunkHandoffPayload(ID, 'x'.repeat(100), 10);
155
+ const assembler = new HandoffChunkAssembler(ID);
156
+ assembler.accept(chunks[0]);
157
+
158
+ assembler.discard();
159
+
160
+ expect(assembler.received).toBe(0);
161
+ expect(assembler.expected).toBeUndefined();
162
+ });
163
+ });
164
+
165
+ describe('HANDOFF-001 TC-06 — a mangled chunk is caught where it arrives', () => {
166
+ it('refuses data that is not decodable base64', () => {
167
+ // Buffer.from is lenient: it drops what it cannot parse rather than throwing, so a mangled
168
+ // chunk would silently become a shorter one and corrupt the payload in a way only the final
169
+ // digest catches — after the whole transfer completed.
170
+ const [chunk] = chunkHandoffPayload(ID, 'x'.repeat(30), 10);
171
+
172
+ const result = new HandoffChunkAssembler(ID).accept({ ...chunk, data: 'not!valid!base64!' });
173
+
174
+ expect(result.rejection).toBe('undecodable');
175
+ });
176
+
177
+ it('a corrupt-but-decodable chunk survives here and is caught by the digest', () => {
178
+ // The two checks are deliberately different questions. This one asks "did the pieces arrive";
179
+ // the digest asks "are they the pieces that were sent". Asking the second one per chunk would
180
+ // report each piece intact while the set is not the set that was sent.
181
+ const record = {
182
+ id: 's',
183
+ cwd: '/w',
184
+ createdAt: 'a',
185
+ updatedAt: 'b',
186
+ messages: [],
187
+ } as IInteractiveSessionRecord;
188
+ const { serialized, integrity } = sealHandoffRecord(record);
189
+ const chunks = chunkHandoffPayload(ID, serialized, 16);
190
+ const swapped = chunks.map((c, i) =>
191
+ i === 0 ? { ...c, data: Buffer.from('0'.repeat(16)).toString('base64') } : c,
192
+ );
193
+
194
+ const { last } = assemble(swapped);
195
+
196
+ expect(last?.outcome).toBe('complete');
197
+ expect(verifyHandoffPayload(last?.serialized ?? '', integrity).intact).toBe(false);
198
+ });
199
+
200
+ it('the whole path holds: seal, chunk, reassemble, verify', () => {
201
+ const record = {
202
+ id: 'session_1',
203
+ cwd: '/home/alice/project',
204
+ createdAt: '2026-08-17T00:00:00.000Z',
205
+ updatedAt: '2026-08-17T01:00:00.000Z',
206
+ messages: [{ role: 'user', content: '안녕하세요 😀' }],
207
+ } as unknown as IInteractiveSessionRecord;
208
+ const { serialized, integrity } = sealHandoffRecord(record);
209
+
210
+ const { last } = assemble(chunkHandoffPayload(ID, serialized, 24));
211
+
212
+ expect(verifyHandoffPayload(last?.serialized ?? '', integrity).intact).toBe(true);
213
+ expect(JSON.parse(last?.serialized ?? '{}')).toEqual(record);
214
+ });
215
+ });