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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +84 -26
  2. package/dist/browser/client.d.ts +2 -0
  3. package/dist/browser/client.js +1 -0
  4. package/dist/browser/index.d.ts +520 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +2 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/browser/message-decoders-BB8n-H2X.d.ts +252 -0
  9. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
  10. package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
  11. package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
  12. package/dist/node/index.cjs +1 -1
  13. package/dist/node/index.d.cts +766 -0
  14. package/dist/node/index.d.cts.map +1 -0
  15. package/dist/node/index.d.ts +765 -27
  16. package/dist/node/index.d.ts.map +1 -1
  17. package/dist/node/index.js +1 -1
  18. package/dist/node/index.js.map +1 -1
  19. package/dist/node/node.cjs +1 -0
  20. package/dist/node/node.d.cts +216 -0
  21. package/dist/node/node.d.cts.map +1 -0
  22. package/dist/node/node.d.ts +216 -0
  23. package/dist/node/node.d.ts.map +1 -0
  24. package/dist/node/node.js +2 -0
  25. package/dist/node/node.js.map +1 -0
  26. package/package.json +68 -47
  27. package/src/__tests__/access-token-verifier.test.ts +672 -0
  28. package/src/__tests__/admission.test.ts +130 -0
  29. package/src/__tests__/channel-frames.test.ts +214 -0
  30. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  31. package/src/__tests__/file-transfer.test.ts +281 -0
  32. package/src/__tests__/handoff-chunking.test.ts +215 -0
  33. package/src/__tests__/handoff-manifest.test.ts +52 -0
  34. package/src/__tests__/message-decoders.test.ts +249 -0
  35. package/src/__tests__/outbound-delivery.test.ts +558 -0
  36. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  37. package/src/__tests__/personal-usage-report.test.ts +193 -0
  38. package/src/__tests__/resume-buffer.test.ts +93 -0
  39. package/src/__tests__/session-event-delivery.test.ts +101 -0
  40. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  41. package/src/__tests__/session-message-handler.test.ts +590 -0
  42. package/src/__tests__/session-resume-bridge.test.ts +406 -0
  43. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  44. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  45. package/src/__tests__/ws-broadcast-events.test.ts +84 -0
  46. package/src/background-messages.ts +169 -0
  47. package/src/channel-frames.ts +182 -0
  48. package/src/client.ts +9 -0
  49. package/src/handoff-chunking.ts +235 -0
  50. package/src/index.ts +112 -3
  51. package/src/message-decoders.ts +229 -0
  52. package/src/message-parser.ts +33 -0
  53. package/src/node/access-token-verifier.ts +363 -0
  54. package/src/node/admission.ts +153 -0
  55. package/src/node/file-transfer.ts +530 -0
  56. package/src/node/handoff-manifest.ts +77 -0
  57. package/src/node/index.ts +27 -0
  58. package/src/outbound-delivery.ts +231 -0
  59. package/src/peer-message-ledger.ts +193 -0
  60. package/src/protocol-session.ts +28 -0
  61. package/src/resume-buffer.ts +123 -0
  62. package/src/session-events.ts +198 -0
  63. package/src/session-message-handler.ts +282 -0
  64. package/src/session-query-messages.ts +42 -0
  65. package/src/session-resume-bridge.ts +252 -0
  66. package/src/usage-messages.ts +195 -0
  67. package/src/wire-messages.ts +165 -0
  68. package/dist/node/headless/index.cjs +0 -1
  69. package/dist/node/headless/index.d.ts +0 -2
  70. package/dist/node/headless/index.js +0 -1
  71. package/dist/node/headless-DG_MEbf0.js +0 -19
  72. package/dist/node/headless-DG_MEbf0.js.map +0 -1
  73. package/dist/node/headless-xq77JBq0.cjs +0 -18
  74. package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
  75. package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
  76. package/dist/node/index-JIvzrtaA.d.ts +0 -52
  77. package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
  78. package/dist/node/index-JTD_xtEQ.d.ts +0 -96
  79. package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
  80. package/dist/node/index-y_HBOfzO.d.ts +0 -52
  81. package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
  82. package/dist/node/programmatic/index.cjs +0 -1
  83. package/dist/node/programmatic/index.d.ts +0 -2
  84. package/dist/node/programmatic/index.js +0 -1
  85. package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
  86. package/dist/node/programmatic-70dxIXtS.js +0 -2
  87. package/dist/node/programmatic-70dxIXtS.js.map +0 -1
  88. package/dist/node/testing/index.cjs +0 -1
  89. package/dist/node/testing/index.d.ts +0 -2
  90. package/dist/node/testing/index.js +0 -1
  91. package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
  92. package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
  93. package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
  94. package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
  95. package/src/headless/HeadlessInteractionChannel.ts +0 -129
  96. package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
  97. package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
  98. package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
  99. package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
  100. package/src/headless/__tests__/headless-runner.test.ts +0 -508
  101. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
  102. package/src/headless/__tests__/headless-transport.test.ts +0 -267
  103. package/src/headless/cli-input.ts +0 -50
  104. package/src/headless/headless-runner.ts +0 -230
  105. package/src/headless/headless-stream-json.ts +0 -148
  106. package/src/headless/headless-transport.ts +0 -43
  107. package/src/headless/index.ts +0 -8
  108. package/src/headless/print-terminal.ts +0 -57
  109. package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
  110. package/src/programmatic/createProgrammaticAgent.ts +0 -71
  111. package/src/programmatic/index.ts +0 -5
  112. package/src/testing/index.ts +0 -10
  113. package/src/transport-registry.ts +0 -103
@@ -0,0 +1,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
+ });