@robota-sdk/agent-transport 3.0.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1137 -0
- package/LICENSE +661 -0
- package/README.md +121 -0
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +624 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
- package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
- package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1115 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1115 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +317 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +317 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +122 -0
- package/src/__tests__/__fixtures__/korean-text.json +6 -0
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/app-tool-messages.test.ts +463 -0
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/channel-frames.test.ts +215 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/file-transfer.test.ts +281 -0
- package/src/__tests__/handoff-chunking.test.ts +261 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +870 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/permission-summary.test.ts +18 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +322 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-history-wire.test.ts +364 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler-observe.test.ts +223 -0
- package/src/__tests__/session-message-handler.test.ts +726 -0
- package/src/__tests__/session-resume-bridge.test.ts +472 -0
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/stdio-carrier.test.ts +85 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +94 -0
- package/src/app-tool-messages.ts +140 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +17 -0
- package/src/handoff-chunking.ts +275 -0
- package/src/index.ts +135 -0
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +485 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/bearer-resource-gate.ts +331 -0
- package/src/node/file-transfer.ts +530 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +45 -0
- package/src/node/stdio-carrier.ts +59 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +240 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/permission-summary.ts +18 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +56 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +290 -0
- package/src/session-message-handler.ts +413 -0
- package/src/session-query-messages.ts +160 -0
- package/src/session-resume-bridge.ts +275 -0
- package/src/settings-messages.ts +103 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +436 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createBearerResourceServer,
|
|
5
|
+
describeProtectedResource,
|
|
6
|
+
} from '../node/bearer-resource-gate.js';
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
9
|
+
|
|
10
|
+
function request(headers: Record<string, string>, remoteAddress = '127.0.0.1'): IncomingMessage {
|
|
11
|
+
return { headers, socket: { remoteAddress } } as unknown as IncomingMessage;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function response() {
|
|
15
|
+
const written: { status?: number; headers?: Record<string, string> } = {};
|
|
16
|
+
const res = {
|
|
17
|
+
writeHead(status: number, headers?: Record<string, string>) {
|
|
18
|
+
written.status = status;
|
|
19
|
+
if (headers !== undefined) written.headers = headers;
|
|
20
|
+
return { end: () => undefined };
|
|
21
|
+
},
|
|
22
|
+
} as unknown as ServerResponse;
|
|
23
|
+
return { res, written };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('shared bearer resource-server gate', () => {
|
|
27
|
+
it('places the RFC 9728 metadata before the resource path and points every challenge at it', () => {
|
|
28
|
+
const resource = describeProtectedResource({
|
|
29
|
+
resource: 'https://agent.example.invalid/hooks/events/ci',
|
|
30
|
+
issuer: 'https://auth.example',
|
|
31
|
+
scopes: ['a', 'b'],
|
|
32
|
+
label: 'Test',
|
|
33
|
+
});
|
|
34
|
+
expect(resource.wellKnownPath).toBe('/.well-known/oauth-protected-resource/hooks/events/ci');
|
|
35
|
+
const metadata = 'https://agent.example.invalid/.well-known/oauth-protected-resource/hooks/events/ci';
|
|
36
|
+
expect(resource.challenges).toEqual({
|
|
37
|
+
missing: `Bearer resource_metadata="${metadata}"`,
|
|
38
|
+
invalid: `Bearer error="invalid_token", resource_metadata="${metadata}"`,
|
|
39
|
+
scope: `Bearer error="insufficient_scope", scope="a b", resource_metadata="${metadata}"`,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('believes X-Forwarded-For only from a trusted proxy, and only its rightmost untrusted hop', () => {
|
|
44
|
+
const server = createBearerResourceServer({
|
|
45
|
+
publicUrl: 'https://agent.example.invalid',
|
|
46
|
+
trustedProxies: ['127.0.0.1', '10.0.0.2'],
|
|
47
|
+
label: 'Test',
|
|
48
|
+
});
|
|
49
|
+
const budgetAfter = (count: number, req: IncomingMessage) => {
|
|
50
|
+
let last = server.fail(req);
|
|
51
|
+
for (let index = 1; index < count; index += 1) last = server.fail(req);
|
|
52
|
+
return last;
|
|
53
|
+
};
|
|
54
|
+
// From an untrusted peer the header is ignored: the peer itself is counted and classed.
|
|
55
|
+
const untrusted = request({ 'x-forwarded-for': '198.51.100.9' }, '203.0.113.5');
|
|
56
|
+
expect(server.remote(untrusted)).toBe('public');
|
|
57
|
+
expect(budgetAfter(21, untrusted).throttled).toBe(true);
|
|
58
|
+
expect(server.fail(request({}, '198.51.100.9')).throttled).toBe(false);
|
|
59
|
+
// From a trusted proxy: the nearest untrusted hop, skipping further trusted proxies.
|
|
60
|
+
const viaProxies = request({ 'x-forwarded-for': '6.6.6.6, 198.51.100.20, 10.0.0.2' });
|
|
61
|
+
expect(budgetAfter(21, viaProxies).throttled).toBe(true);
|
|
62
|
+
// A forged hop further left does not move the counted address.
|
|
63
|
+
const forged = request({ 'x-forwarded-for': '1.1.1.1, 198.51.100.20' });
|
|
64
|
+
expect(server.fail(forged).throttled).toBe(true);
|
|
65
|
+
// A garbage hop falls back to the proxy itself.
|
|
66
|
+
expect(server.remote(request({ 'x-forwarded-for': 'not-an-ip' }))).toBe('loopback');
|
|
67
|
+
// A different client behind the proxy has its own budget.
|
|
68
|
+
expect(server.fail(request({ 'x-forwarded-for': '198.51.100.21' })).throttled).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('refuses an unsafe configuration, naming the carrier', () => {
|
|
72
|
+
const base = {
|
|
73
|
+
resource: 'https://r.example/x',
|
|
74
|
+
issuer: 'https://i.example',
|
|
75
|
+
scopes: ['s'],
|
|
76
|
+
label: 'Carrier',
|
|
77
|
+
};
|
|
78
|
+
expect(() => describeProtectedResource({ ...base, resource: 'http://r.example/x' })).toThrow(
|
|
79
|
+
/^Carrier public URL must use https/,
|
|
80
|
+
);
|
|
81
|
+
expect(() => describeProtectedResource({ ...base, issuer: 'http://i.example' })).toThrow(
|
|
82
|
+
/^Carrier authorization issuer/,
|
|
83
|
+
);
|
|
84
|
+
expect(() => describeProtectedResource({ ...base, scopes: ['a b'] })).toThrow(/valid scope/);
|
|
85
|
+
expect(() =>
|
|
86
|
+
createBearerResourceServer({
|
|
87
|
+
publicUrl: 'https://r.example',
|
|
88
|
+
trustedProxies: ['proxy'],
|
|
89
|
+
label: 'Carrier',
|
|
90
|
+
}),
|
|
91
|
+
).toThrow(/^Carrier trusted proxy must be a literal IP address/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('checks Host and Origin against the public URL and counts only the failures it is told of', () => {
|
|
95
|
+
let clock = 0;
|
|
96
|
+
const server = createBearerResourceServer({
|
|
97
|
+
publicUrl: 'https://agent.example.invalid/hooks',
|
|
98
|
+
label: 'Test',
|
|
99
|
+
now: () => clock,
|
|
100
|
+
});
|
|
101
|
+
const ok = response();
|
|
102
|
+
expect(server.checkNames(request({ host: 'agent.example.invalid' }), ok.res)).toBe(true);
|
|
103
|
+
expect(
|
|
104
|
+
server.checkNames(
|
|
105
|
+
request({ host: 'agent.example.invalid:443', origin: 'https://agent.example.invalid' }),
|
|
106
|
+
ok.res,
|
|
107
|
+
),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
const bad = response();
|
|
110
|
+
expect(server.checkNames(request({ host: '127.0.0.1' }), bad.res)).toBe(false);
|
|
111
|
+
expect(bad.written.status).toBe(403);
|
|
112
|
+
const peer = request({ host: 'agent.example.invalid' }, '203.0.113.9');
|
|
113
|
+
let last = server.fail(peer);
|
|
114
|
+
for (let index = 1; index < 21; index += 1) last = server.fail(peer);
|
|
115
|
+
expect(last).toMatchObject({ remote: 'public', throttled: true });
|
|
116
|
+
expect(server.remote(request({}, '10.0.0.4'))).toBe('private');
|
|
117
|
+
clock = 60_000;
|
|
118
|
+
expect(server.fail(peer).throttled).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
import koreanText from './__fixtures__/korean-text.json';
|
|
10
|
+
|
|
11
|
+
import type { IBinaryFrame, IChannelEventFrame } from '@robota-sdk/agent-interface-transport';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* TRANS-001 — payload-agnostic frame codec.
|
|
15
|
+
*
|
|
16
|
+
* The codec is the ONLY thing that knows the carrier envelope; it never interprets the opaque body.
|
|
17
|
+
* These tests pin round-trip integrity, ordering metadata (`seq`), and explicit malformed-input
|
|
18
|
+
* results (a result union, never a swallowed default).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function bytes(...values: number[]): Uint8Array {
|
|
22
|
+
return Uint8Array.from(values);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('channel frame codec (TRANS-001)', () => {
|
|
26
|
+
it('round-trips an opaque binary payload byte-identically', () => {
|
|
27
|
+
// Deliberately includes bytes that are invalid UTF-8 and JSON-hostile — the carrier must not care.
|
|
28
|
+
const payload = bytes(0x00, 0xff, 0xfe, 0x7b, 0x22, 0x80, 0x0a, 0xc3, 0x28);
|
|
29
|
+
const encoded = encodeBinaryFrame({ kind: 'binary', channel: 'audio', seq: 7, payload });
|
|
30
|
+
|
|
31
|
+
const decoded = decodeChannelFrame(encoded);
|
|
32
|
+
expect(decoded.ok).toBe(true);
|
|
33
|
+
if (!decoded.ok) return;
|
|
34
|
+
expect(decoded.frame.kind).toBe('binary');
|
|
35
|
+
const frame = decoded.frame as IBinaryFrame;
|
|
36
|
+
expect(frame.channel).toBe('audio');
|
|
37
|
+
expect(frame.seq).toBe(7);
|
|
38
|
+
expect(Array.from(frame.payload)).toEqual(Array.from(payload));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('round-trips an empty binary payload', () => {
|
|
42
|
+
const encoded = encodeBinaryFrame({
|
|
43
|
+
kind: 'binary',
|
|
44
|
+
channel: 'blob',
|
|
45
|
+
seq: 0,
|
|
46
|
+
payload: new Uint8Array(0),
|
|
47
|
+
});
|
|
48
|
+
const decoded = decodeChannelFrame(encoded);
|
|
49
|
+
expect(decoded.ok).toBe(true);
|
|
50
|
+
if (!decoded.ok) return;
|
|
51
|
+
expect((decoded.frame as IBinaryFrame).payload.byteLength).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('preserves per-channel ordering through seq on a chunked stream', () => {
|
|
55
|
+
const source = new Uint8Array(1024);
|
|
56
|
+
for (let i = 0; i < source.length; i += 1) source[i] = (i * 31) % 256;
|
|
57
|
+
|
|
58
|
+
const chunkSize = 100;
|
|
59
|
+
const wire: Uint8Array[] = [];
|
|
60
|
+
for (let offset = 0, seq = 0; offset < source.length; offset += chunkSize, seq += 1) {
|
|
61
|
+
wire.push(
|
|
62
|
+
encodeBinaryFrame({
|
|
63
|
+
kind: 'binary',
|
|
64
|
+
channel: 'file',
|
|
65
|
+
seq,
|
|
66
|
+
payload: source.subarray(offset, Math.min(offset + chunkSize, source.length)),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Deliver out of order; the receiver reassembles by `seq`, not by arrival order.
|
|
72
|
+
const shuffled = [...wire].reverse();
|
|
73
|
+
const received: IBinaryFrame[] = [];
|
|
74
|
+
for (const w of shuffled) {
|
|
75
|
+
const d = decodeChannelFrame(w);
|
|
76
|
+
expect(d.ok).toBe(true);
|
|
77
|
+
if (d.ok && d.frame.kind === 'binary') received.push(d.frame);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
received.sort((a, b) => a.seq - b.seq);
|
|
81
|
+
const reassembled = new Uint8Array(source.length);
|
|
82
|
+
let cursor = 0;
|
|
83
|
+
for (const frame of received) {
|
|
84
|
+
reassembled.set(frame.payload, cursor);
|
|
85
|
+
cursor += frame.payload.byteLength;
|
|
86
|
+
}
|
|
87
|
+
expect(cursor).toBe(source.length);
|
|
88
|
+
expect(Array.from(reassembled)).toEqual(Array.from(source));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('round-trips a consumer-declared structured event frame', () => {
|
|
92
|
+
const encoded = encodeChannelEventFrame({
|
|
93
|
+
kind: 'event',
|
|
94
|
+
channel: 'captions',
|
|
95
|
+
seq: 3,
|
|
96
|
+
event: 'caption',
|
|
97
|
+
payload: { text: 'hello', final: true },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const decoded = decodeChannelFrame(encoded);
|
|
101
|
+
expect(decoded.ok).toBe(true);
|
|
102
|
+
if (!decoded.ok) return;
|
|
103
|
+
expect(decoded.frame.kind).toBe('event');
|
|
104
|
+
const frame = decoded.frame as IChannelEventFrame;
|
|
105
|
+
expect(frame.channel).toBe('captions');
|
|
106
|
+
expect(frame.seq).toBe(3);
|
|
107
|
+
expect(frame.event).toBe('caption');
|
|
108
|
+
expect(frame.payload).toEqual({ text: 'hello', final: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('supports multi-byte UTF-8 channel names', () => {
|
|
112
|
+
const encoded = encodeBinaryFrame({
|
|
113
|
+
kind: 'binary',
|
|
114
|
+
channel: koreanText.voiceChannelName,
|
|
115
|
+
seq: 1,
|
|
116
|
+
payload: bytes(1, 2, 3),
|
|
117
|
+
});
|
|
118
|
+
const decoded = decodeChannelFrame(encoded);
|
|
119
|
+
expect(decoded.ok).toBe(true);
|
|
120
|
+
if (!decoded.ok) return;
|
|
121
|
+
expect(decoded.frame.channel).toBe(koreanText.voiceChannelName);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('rejects a channel name that does not fit the 255-byte header field', () => {
|
|
125
|
+
expect(() =>
|
|
126
|
+
encodeBinaryFrame({
|
|
127
|
+
kind: 'binary',
|
|
128
|
+
channel: 'x'.repeat(256),
|
|
129
|
+
seq: 0,
|
|
130
|
+
payload: new Uint8Array(0),
|
|
131
|
+
}),
|
|
132
|
+
).toThrow(/channel name/i);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects an empty channel name', () => {
|
|
136
|
+
expect(() =>
|
|
137
|
+
encodeBinaryFrame({ kind: 'binary', channel: '', seq: 0, payload: new Uint8Array(0) }),
|
|
138
|
+
).toThrow(/channel name/i);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('rejects a seq outside the uint32 range', () => {
|
|
142
|
+
expect(() =>
|
|
143
|
+
encodeBinaryFrame({
|
|
144
|
+
kind: 'binary',
|
|
145
|
+
channel: 'c',
|
|
146
|
+
seq: 2 ** 32,
|
|
147
|
+
payload: new Uint8Array(0),
|
|
148
|
+
}),
|
|
149
|
+
).toThrow(/seq/i);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('reports a non-channel frame as a typed error result rather than throwing', () => {
|
|
153
|
+
const decoded = decodeChannelFrame(bytes(0x01, 0x02, 0x03, 0x04, 0x05));
|
|
154
|
+
expect(decoded.ok).toBe(false);
|
|
155
|
+
if (decoded.ok) return;
|
|
156
|
+
expect(decoded.error).toMatch(/magic|channel frame/i);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('reports a truncated frame as a typed error result', () => {
|
|
160
|
+
const full = encodeBinaryFrame({
|
|
161
|
+
kind: 'binary',
|
|
162
|
+
channel: 'audio',
|
|
163
|
+
seq: 1,
|
|
164
|
+
payload: bytes(9, 9, 9),
|
|
165
|
+
});
|
|
166
|
+
const decoded = decodeChannelFrame(full.subarray(0, 6));
|
|
167
|
+
expect(decoded.ok).toBe(false);
|
|
168
|
+
if (decoded.ok) return;
|
|
169
|
+
expect(decoded.error).toMatch(/truncated/i);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('reports an unknown frame kind as a typed error result', () => {
|
|
173
|
+
const full = encodeBinaryFrame({
|
|
174
|
+
kind: 'binary',
|
|
175
|
+
channel: 'audio',
|
|
176
|
+
seq: 1,
|
|
177
|
+
payload: bytes(9),
|
|
178
|
+
});
|
|
179
|
+
const tampered = Uint8Array.from(full);
|
|
180
|
+
tampered[4] = 0x7f; // unknown kind byte
|
|
181
|
+
const decoded = decodeChannelFrame(tampered);
|
|
182
|
+
expect(decoded.ok).toBe(false);
|
|
183
|
+
if (decoded.ok) return;
|
|
184
|
+
expect(decoded.error).toMatch(/kind/i);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('reports a malformed event body as a typed error result', () => {
|
|
188
|
+
const good = encodeChannelEventFrame({
|
|
189
|
+
kind: 'event',
|
|
190
|
+
channel: 'c',
|
|
191
|
+
seq: 0,
|
|
192
|
+
event: 'e',
|
|
193
|
+
payload: null,
|
|
194
|
+
});
|
|
195
|
+
const tampered = Uint8Array.from(good);
|
|
196
|
+
// Corrupt the JSON body (everything after the 10 + channelNameLength header bytes).
|
|
197
|
+
tampered[tampered.length - 1] = 0x7b; // '{'
|
|
198
|
+
const decoded = decodeChannelFrame(tampered);
|
|
199
|
+
expect(decoded.ok).toBe(false);
|
|
200
|
+
if (decoded.ok) return;
|
|
201
|
+
expect(decoded.error).toMatch(/body|json/i);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('stamps the shared magic so a carrier can route frames without parsing them', () => {
|
|
205
|
+
const encoded = encodeBinaryFrame({
|
|
206
|
+
kind: 'binary',
|
|
207
|
+
channel: 'c',
|
|
208
|
+
seq: 0,
|
|
209
|
+
payload: new Uint8Array(0),
|
|
210
|
+
});
|
|
211
|
+
expect(Array.from(encoded.subarray(0, CHANNEL_FRAME_MAGIC.length))).toEqual(
|
|
212
|
+
Array.from(CHANNEL_FRAME_MAGIC),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -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
|
+
});
|