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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +1137 -0
  2. package/LICENSE +661 -0
  3. package/README.md +121 -0
  4. package/dist/browser/client.d.ts +2 -0
  5. package/dist/browser/client.js +1 -0
  6. package/dist/browser/index.d.ts +624 -0
  7. package/dist/browser/index.d.ts.map +1 -0
  8. package/dist/browser/index.js +2 -0
  9. package/dist/browser/index.js.map +1 -0
  10. package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
  11. package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
  12. package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
  13. package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
  14. package/dist/node/index.cjs +1 -0
  15. package/dist/node/index.d.cts +1115 -0
  16. package/dist/node/index.d.cts.map +1 -0
  17. package/dist/node/index.d.ts +1115 -0
  18. package/dist/node/index.d.ts.map +1 -0
  19. package/dist/node/index.js +2 -0
  20. package/dist/node/index.js.map +1 -0
  21. package/dist/node/node.cjs +1 -0
  22. package/dist/node/node.d.cts +317 -0
  23. package/dist/node/node.d.cts.map +1 -0
  24. package/dist/node/node.d.ts +317 -0
  25. package/dist/node/node.d.ts.map +1 -0
  26. package/dist/node/node.js +2 -0
  27. package/dist/node/node.js.map +1 -0
  28. package/package.json +122 -0
  29. package/src/__tests__/__fixtures__/korean-text.json +6 -0
  30. package/src/__tests__/access-token-verifier.test.ts +672 -0
  31. package/src/__tests__/admission.test.ts +130 -0
  32. package/src/__tests__/app-tool-messages.test.ts +463 -0
  33. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  34. package/src/__tests__/channel-frames.test.ts +215 -0
  35. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  36. package/src/__tests__/file-transfer.test.ts +281 -0
  37. package/src/__tests__/handoff-chunking.test.ts +261 -0
  38. package/src/__tests__/handoff-manifest.test.ts +52 -0
  39. package/src/__tests__/history-attached-budget.test.ts +216 -0
  40. package/src/__tests__/message-decoders.test.ts +870 -0
  41. package/src/__tests__/outbound-delivery.test.ts +558 -0
  42. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  43. package/src/__tests__/permission-summary.test.ts +18 -0
  44. package/src/__tests__/personal-usage-report.test.ts +193 -0
  45. package/src/__tests__/project-read-messages.test.ts +118 -0
  46. package/src/__tests__/resume-buffer.test.ts +93 -0
  47. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  48. package/src/__tests__/session-directory-messages.test.ts +322 -0
  49. package/src/__tests__/session-error-classification.test.ts +113 -0
  50. package/src/__tests__/session-event-delivery.test.ts +101 -0
  51. package/src/__tests__/session-history-wire.test.ts +364 -0
  52. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  53. package/src/__tests__/session-message-handler-observe.test.ts +223 -0
  54. package/src/__tests__/session-message-handler.test.ts +726 -0
  55. package/src/__tests__/session-resume-bridge.test.ts +472 -0
  56. package/src/__tests__/settings-messages.test.ts +193 -0
  57. package/src/__tests__/stdio-carrier.test.ts +85 -0
  58. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  59. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  60. package/src/__tests__/ws-broadcast-events.test.ts +94 -0
  61. package/src/app-tool-messages.ts +140 -0
  62. package/src/background-messages.ts +169 -0
  63. package/src/channel-frames.ts +182 -0
  64. package/src/client.ts +17 -0
  65. package/src/handoff-chunking.ts +275 -0
  66. package/src/index.ts +135 -0
  67. package/src/loop-control-messages.ts +34 -0
  68. package/src/message-decoders.ts +485 -0
  69. package/src/message-parser.ts +33 -0
  70. package/src/node/access-token-verifier.ts +363 -0
  71. package/src/node/admission.ts +153 -0
  72. package/src/node/bearer-resource-gate.ts +331 -0
  73. package/src/node/file-transfer.ts +530 -0
  74. package/src/node/handoff-manifest.ts +77 -0
  75. package/src/node/index.ts +45 -0
  76. package/src/node/stdio-carrier.ts +59 -0
  77. package/src/observer-messages.ts +47 -0
  78. package/src/open-prompts.ts +76 -0
  79. package/src/outbound-delivery.ts +240 -0
  80. package/src/peer-message-ledger.ts +193 -0
  81. package/src/permission-summary.ts +18 -0
  82. package/src/project-read-messages.ts +74 -0
  83. package/src/protocol-session.ts +56 -0
  84. package/src/resume-buffer.ts +123 -0
  85. package/src/session-directory-messages.ts +162 -0
  86. package/src/session-error-classification.ts +91 -0
  87. package/src/session-events.ts +290 -0
  88. package/src/session-message-handler.ts +413 -0
  89. package/src/session-query-messages.ts +160 -0
  90. package/src/session-resume-bridge.ts +275 -0
  91. package/src/settings-messages.ts +103 -0
  92. package/src/usage-messages.ts +195 -0
  93. package/src/wire-messages.ts +436 -0
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createOutboundDelivery } from '../outbound-delivery.js';
4
+ import { createSessionMessageHandler } from '../session-message-handler.js';
5
+ import { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from '../session-events.js';
6
+
7
+ import type { TServerMessage } from '../wire-messages.js';
8
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
9
+
10
+ type TEmittingSession = IInteractiveSession & {
11
+ emitForTest: (event: string, payload: unknown) => void;
12
+ };
13
+
14
+ function createSession(): TEmittingSession {
15
+ const listeners = new Map<string, Set<(payload: unknown) => void>>();
16
+ return {
17
+ getActiveDriverId: () => null,
18
+ on: vi.fn((event: string, handler: (payload: unknown) => void) => {
19
+ if (!listeners.has(event)) listeners.set(event, new Set());
20
+ listeners.get(event)!.add(handler);
21
+ }),
22
+ off: vi.fn((event: string, handler: (payload: unknown) => void) => {
23
+ listeners.get(event)?.delete(handler);
24
+ }),
25
+ emitForTest: (event: string, payload: unknown) => {
26
+ for (const handler of listeners.get(event) ?? []) handler(payload);
27
+ },
28
+ } as unknown as TEmittingSession;
29
+ }
30
+
31
+ describe('protocol session-event delivery policy (ARCH-020/ARCH-028)', () => {
32
+ it('mechanically matches every surface classification to an actual subscription', () => {
33
+ const session = createSession();
34
+ const { cleanup } = createSessionMessageHandler({
35
+ session,
36
+ deliver: createOutboundDelivery(vi.fn(), vi.fn()),
37
+ });
38
+ const classifiedForSubscription = Object.entries(PROTOCOL_SESSION_EVENT_CLASSIFICATION)
39
+ .filter(([, classification]) => classification !== 'non-surface')
40
+ .map(([event]) => event)
41
+ .sort();
42
+ const onCalls = vi.mocked(session.on).mock.calls;
43
+ expect(onCalls.map(([event]) => event).sort()).toEqual(classifiedForSubscription);
44
+
45
+ cleanup();
46
+ const offCalls = vi.mocked(session.off).mock.calls;
47
+ expect(offCalls.map(([event]) => event).sort()).toEqual(classifiedForSubscription);
48
+ for (const [event, handler] of onCalls) {
49
+ expect(offCalls).toContainEqual([event, handler]);
50
+ }
51
+ });
52
+
53
+ it('forwards plan, context refresh, and branch events as typed protocol frames', () => {
54
+ const session = createSession();
55
+ const sent: TServerMessage[] = [];
56
+ createSessionMessageHandler({
57
+ session,
58
+ deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
59
+ });
60
+
61
+ session.emitForTest('plan_event', { type: 'plan_created', plan: { id: 'plan-1' } });
62
+ session.emitForTest('context_file_refreshed', { filePath: '/repo/AGENTS.md' });
63
+ session.emitForTest('branch_event', {
64
+ kind: 'branch_switched',
65
+ checkpointId: 'turn-0002',
66
+ branchId: 'branch-2',
67
+ });
68
+
69
+ expect(sent.map((message) => message.type)).toEqual([
70
+ 'plan_event',
71
+ 'context_file_refreshed',
72
+ 'branch_event',
73
+ ]);
74
+ });
75
+
76
+ it('isolates a carrier send failure and reports its owning session event', () => {
77
+ const session = createSession();
78
+ const failures: Array<{ message: string; event: string }> = [];
79
+ createSessionMessageHandler({
80
+ session,
81
+ deliver: createOutboundDelivery(
82
+ () => {
83
+ throw new Error('socket closed');
84
+ },
85
+ (error, event) => {
86
+ failures.push({ message: error.message, event });
87
+ throw new Error('diagnostic callback failed');
88
+ },
89
+ ),
90
+ });
91
+
92
+ expect(() =>
93
+ session.emitForTest('branch_event', {
94
+ kind: 'checkpoint_created',
95
+ checkpointId: 'turn-0001',
96
+ branchId: 'main',
97
+ }),
98
+ ).not.toThrow();
99
+ expect(failures).toEqual([{ message: 'socket closed', event: 'branch_event' }]);
100
+ });
101
+ });
@@ -0,0 +1,364 @@
1
+ /**
2
+ * #3189: what a client that renders the whole session (the TUI) needs over the wire, beside the
3
+ * streamed turn — the full history, the context window as it changes, a signal that the history
4
+ * changed, and where each turn came from.
5
+ */
6
+
7
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+
10
+ import { createOutboundDelivery } from '../outbound-delivery.js';
11
+ import { createSessionMessageHandler } from '../session-message-handler.js';
12
+ import { HISTORY_PAGE_MAX_BYTES } from '../session-query-messages.js';
13
+
14
+ import type { IWireHistoryEntry, TClientMessage, TServerMessage } from '../wire-messages.js';
15
+ import type { IHistoryEntry } from '@robota-sdk/agent-core';
16
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
17
+
18
+ type TListener = (...args: unknown[]) => void;
19
+ type TEmittingSession = IInteractiveSession & { emit: (event: string, payload?: unknown) => void };
20
+
21
+ const RECORDED_AT = new Date('2026-09-26T01:02:03.004Z');
22
+
23
+ function createSession(overrides: Partial<IInteractiveSession> = {}): TEmittingSession {
24
+ const listeners = new Map<string, Set<TListener>>();
25
+ const session = createTestInteractiveSession({
26
+ on: ((event: string, handler: TListener) => {
27
+ if (!listeners.has(event)) listeners.set(event, new Set());
28
+ listeners.get(event)!.add(handler);
29
+ }) as IInteractiveSession['on'],
30
+ off: ((event: string, handler: TListener) => {
31
+ listeners.get(event)?.delete(handler);
32
+ }) as IInteractiveSession['off'],
33
+ ...overrides,
34
+ });
35
+ return Object.assign(session, {
36
+ emit: (event: string, payload?: unknown) => {
37
+ listeners.get(event)?.forEach((handler) => handler(payload));
38
+ },
39
+ });
40
+ }
41
+
42
+ function attach(
43
+ session: IInteractiveSession,
44
+ role: 'drive' | 'observe' = 'drive',
45
+ ): { sent: TServerMessage[]; send: (message: TClientMessage) => void; leave: () => void } {
46
+ const sent: TServerMessage[] = [];
47
+ const { onMessage, cleanup } = createSessionMessageHandler({
48
+ session,
49
+ role,
50
+ deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
51
+ });
52
+ return { sent, send: (message) => onMessage(JSON.stringify(message)), leave: cleanup };
53
+ }
54
+
55
+ type THistoryFrame = Extract<TServerMessage, { type: 'history' }>;
56
+
57
+ function lastHistory(sent: readonly TServerMessage[]): THistoryFrame {
58
+ const frame = sent.at(-1);
59
+ if (frame?.type !== 'history') throw new Error(`expected a history frame, got ${frame?.type}`);
60
+ return frame;
61
+ }
62
+
63
+ /** Entries of about `bytes` serialized bytes each. */
64
+ function historyOf(count: number, bytes: number): IHistoryEntry[] {
65
+ return Array.from({ length: count }, (_, index) => ({
66
+ id: `e${index}`,
67
+ timestamp: RECORDED_AT,
68
+ category: 'chat',
69
+ type: 'assistant',
70
+ data: { role: 'assistant', content: 'x'.repeat(bytes) },
71
+ }));
72
+ }
73
+
74
+ /** Read the whole history the way a client does: the next page only after the previous one. */
75
+ function readAll(client: ReturnType<typeof attach>): {
76
+ pages: THistoryFrame[];
77
+ entries: IWireHistoryEntry[];
78
+ } {
79
+ const pages: THistoryFrame[] = [];
80
+ const entries: IWireHistoryEntry[] = [];
81
+ client.send({ type: 'get-history' });
82
+ for (;;) {
83
+ const page = lastHistory(client.sent);
84
+ pages.push(page);
85
+ entries.push(...page.entries);
86
+ const next = page.startIndex + page.entries.length;
87
+ if (next >= page.total) return { pages, entries };
88
+ client.send({ type: 'get-history', fromIndex: next });
89
+ }
90
+ }
91
+
92
+ describe('get-history (#3189)', () => {
93
+ it('answers with the full history, each timestamp an ISO 8601 string', () => {
94
+ const session = createSession({
95
+ getFullHistory: () => [
96
+ {
97
+ id: 'm1',
98
+ timestamp: RECORDED_AT,
99
+ category: 'chat',
100
+ type: 'user',
101
+ data: { role: 'user', content: 'hi' },
102
+ },
103
+ { id: 'e1', timestamp: RECORDED_AT, category: 'event', type: 'skill-activation' },
104
+ ],
105
+ });
106
+ const client = attach(session);
107
+
108
+ client.send({ type: 'get-history' });
109
+
110
+ expect(client.sent).toEqual([
111
+ {
112
+ type: 'history',
113
+ startIndex: 0,
114
+ total: 2,
115
+ entries: [
116
+ {
117
+ id: 'm1',
118
+ timestamp: '2026-09-26T01:02:03.004Z',
119
+ category: 'chat',
120
+ type: 'user',
121
+ data: { role: 'user', content: 'hi' },
122
+ },
123
+ {
124
+ id: 'e1',
125
+ timestamp: '2026-09-26T01:02:03.004Z',
126
+ category: 'event',
127
+ type: 'skill-activation',
128
+ },
129
+ ],
130
+ },
131
+ ]);
132
+ });
133
+
134
+ it('carries a resumed entry, whose timestamp came back from JSON as a string, unchanged', () => {
135
+ const resumed = JSON.parse(
136
+ JSON.stringify([{ id: 'm1', timestamp: RECORDED_AT, category: 'chat', type: 'user' }]),
137
+ ) as ReturnType<IInteractiveSession['getFullHistory']>;
138
+ const client = attach(createSession({ getFullHistory: () => resumed }));
139
+
140
+ client.send({ type: 'get-history' });
141
+
142
+ expect(client.sent).toEqual([
143
+ {
144
+ type: 'history',
145
+ startIndex: 0,
146
+ total: 1,
147
+ entries: [
148
+ { id: 'm1', timestamp: '2026-09-26T01:02:03.004Z', category: 'chat', type: 'user' },
149
+ ],
150
+ },
151
+ ]);
152
+ });
153
+
154
+ it('is a read, so an observer may send it', () => {
155
+ const session = createSession({
156
+ getFullHistory: () => [{ id: 'm1', timestamp: RECORDED_AT, category: 'chat', type: 'user' }],
157
+ });
158
+ const observer = attach(session, 'observe');
159
+
160
+ observer.send({ type: 'get-history' });
161
+
162
+ expect(observer.sent).toEqual([
163
+ {
164
+ type: 'history',
165
+ startIndex: 0,
166
+ total: 1,
167
+ entries: [
168
+ { id: 'm1', timestamp: '2026-09-26T01:02:03.004Z', category: 'chat', type: 'user' },
169
+ ],
170
+ },
171
+ ]);
172
+ });
173
+
174
+ it('sends a long history in pages under the page budget, which read back to the whole of it', () => {
175
+ const history = historyOf(40, 20_000);
176
+ const client = attach(createSession({ getFullHistory: () => history }));
177
+
178
+ const { pages, entries } = readAll(client);
179
+
180
+ expect(pages.length).toBeGreaterThan(1);
181
+ for (const page of pages) {
182
+ expect(new TextEncoder().encode(JSON.stringify(page.entries)).byteLength).toBeLessThanOrEqual(
183
+ HISTORY_PAGE_MAX_BYTES,
184
+ );
185
+ expect(page.total).toBe(40);
186
+ }
187
+ expect(entries.map((entry) => entry.id)).toEqual(history.map((entry) => entry.id));
188
+ });
189
+
190
+ it('sends an entry larger than the page budget alone, so a reader still moves forward', () => {
191
+ const history = [...historyOf(1, 10), ...historyOf(1, HISTORY_PAGE_MAX_BYTES + 1)];
192
+ history[1] = { ...history[1]!, id: 'big' };
193
+ const client = attach(createSession({ getFullHistory: () => history }));
194
+
195
+ const { pages } = readAll(client);
196
+
197
+ expect(pages.map((page) => page.entries.map((entry) => entry.id))).toEqual([['e0'], ['big']]);
198
+ });
199
+
200
+ it('answers from the index asked, and says where the history ends when that is past it', () => {
201
+ const history = historyOf(3, 10);
202
+ const client = attach(createSession({ getFullHistory: () => history }));
203
+
204
+ client.send({ type: 'get-history', fromIndex: 2 });
205
+ expect(lastHistory(client.sent)).toMatchObject({ startIndex: 2, total: 3 });
206
+ expect(lastHistory(client.sent).entries.map((entry) => entry.id)).toEqual(['e2']);
207
+
208
+ // A client that knows more than the session holds learns it from `total`, and reads again.
209
+ client.send({ type: 'get-history', fromIndex: 7 });
210
+ expect(lastHistory(client.sent)).toEqual({
211
+ type: 'history',
212
+ startIndex: 3,
213
+ total: 3,
214
+ entries: [],
215
+ });
216
+ });
217
+ });
218
+
219
+ describe('turn results on the wire (#3189)', () => {
220
+ it.each(['complete', 'interrupted'] as const)(
221
+ 'sends %s without the session history, which only grows',
222
+ (event) => {
223
+ const session = createSession();
224
+ const client = attach(session);
225
+ const contextState = { usedTokens: 1, maxTokens: 10, usedPercentage: 10 };
226
+
227
+ session.emit(event, {
228
+ response: 'done',
229
+ history: historyOf(50, 50_000),
230
+ toolSummaries: [],
231
+ contextState,
232
+ });
233
+
234
+ expect(client.sent).toEqual([
235
+ { type: event, result: { response: 'done', toolSummaries: [], contextState } },
236
+ ]);
237
+ },
238
+ );
239
+ });
240
+
241
+ describe('get-prompts (#3189)', () => {
242
+ const permission = { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' } };
243
+ const ask = { id: 'a1', request: { kind: 'text', title: 'Name?' } };
244
+
245
+ it('sends a client that attached later the prompts still open, as the frames that asked them', () => {
246
+ const session = createSession();
247
+ const first = attach(session);
248
+ session.emit('permission_request', permission);
249
+ session.emit('ask_request', ask);
250
+ session.emit('prompt_resolved', { id: 'a1' });
251
+
252
+ const later = attach(session);
253
+ later.send({ type: 'get-prompts' });
254
+
255
+ expect(first.sent.map((frame) => frame.type)).toEqual([
256
+ 'permission_request',
257
+ 'ask_request',
258
+ 'prompt_resolved',
259
+ ]);
260
+ expect(later.sent).toEqual([{ type: 'permission_request', event: permission }]);
261
+ });
262
+
263
+ it("forgets the previous session's prompts on a switch, and all of them once nobody can answer", () => {
264
+ const session = createSession();
265
+ const first = attach(session);
266
+ session.emit('permission_request', permission);
267
+ session.emit('session_switched', { sessionId: 'session-2' });
268
+ first.send({ type: 'get-prompts' });
269
+ expect(first.sent.at(-1)).toEqual({
270
+ type: 'session_switched',
271
+ event: { sessionId: 'session-2' },
272
+ });
273
+
274
+ session.emit('permission_request', { ...permission, id: 'p2' });
275
+ first.leave();
276
+ const next = attach(session);
277
+ next.send({ type: 'get-prompts' });
278
+ expect(next.sent).toEqual([]);
279
+ });
280
+
281
+ it('is not a read an observer may send: an observer never receives prompts', () => {
282
+ const session = createSession();
283
+ attach(session);
284
+ session.emit('permission_request', permission);
285
+ const observer = attach(session, 'observe');
286
+
287
+ observer.send({ type: 'get-prompts' });
288
+
289
+ expect(observer.sent).toEqual([
290
+ { type: 'protocol_error', message: 'Not permitted for an observer: get-prompts' },
291
+ ]);
292
+ });
293
+ });
294
+
295
+ describe('pushed session state (#3189)', () => {
296
+ it('delivers a context_update as the context frame get-context answers with', () => {
297
+ const session = createSession();
298
+ const client = attach(session);
299
+ const state = { usedTokens: 900, maxTokens: 1000, usedPercentage: 90, remainingPercentage: 10 };
300
+
301
+ session.emit('context_update', state);
302
+
303
+ expect(client.sent).toEqual([{ type: 'context', state }]);
304
+ });
305
+
306
+ it.each(['skill_activation', 'memory_event'])(
307
+ 'tells the client the history changed on %s, without sending the entries',
308
+ (event) => {
309
+ const session = createSession();
310
+ const client = attach(session);
311
+
312
+ session.emit(event, {});
313
+
314
+ expect(client.sent).toEqual([{ type: 'history_changed' }]);
315
+ },
316
+ );
317
+
318
+ it('preserves completed compaction metadata without sending or replacing history entries', () => {
319
+ const session = createSession();
320
+ const client = attach(session);
321
+ session.emit('compact', {
322
+ trigger: 'auto',
323
+ before: { usedTokens: 900, maxTokens: 1000, usedPercentage: 90, remainingPercentage: 10 },
324
+ after: { usedTokens: 200, maxTokens: 1000, usedPercentage: 20, remainingPercentage: 80 },
325
+ });
326
+ expect(client.sent).toEqual([
327
+ {
328
+ type: 'history_changed',
329
+ compact: { trigger: 'auto', beforeUsedPercentage: 90, afterUsedPercentage: 20 },
330
+ },
331
+ ]);
332
+ client.leave();
333
+ session.emit('compact', { trigger: 'auto' });
334
+ expect(client.sent).toHaveLength(1);
335
+ });
336
+
337
+ it('delivers where a turn came from', () => {
338
+ const session = createSession();
339
+ const client = attach(session);
340
+
341
+ session.emit('turn_source', 'agent-wakeup');
342
+
343
+ expect(client.sent).toEqual([{ type: 'turn_source', source: 'agent-wakeup' }]);
344
+ });
345
+
346
+ it('pushes the same frames to an observer, which follows the session read-only', () => {
347
+ const session = createSession();
348
+ const observer = attach(session, 'observe');
349
+ const state = { usedTokens: 1, maxTokens: 10, usedPercentage: 10, remainingPercentage: 90 };
350
+
351
+ session.emit('context_update', state);
352
+ session.emit('compact', { trigger: 'auto', before: state, after: state });
353
+ session.emit('turn_source', 'user');
354
+
355
+ expect(observer.sent).toEqual([
356
+ { type: 'context', state },
357
+ {
358
+ type: 'history_changed',
359
+ compact: { trigger: 'auto', beforeUsedPercentage: 10, afterUsedPercentage: 10 },
360
+ },
361
+ { type: 'turn_source', source: 'user' },
362
+ ]);
363
+ });
364
+ });
@@ -0,0 +1,99 @@
1
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
2
+
3
+ import { describe, it, expect, vi } from 'vitest';
4
+
5
+ import { createOutboundDelivery } from '../outbound-delivery.js';
6
+ import { createSessionMessageHandler } from '../session-message-handler.js';
7
+ import type { TServerMessage } from '../wire-messages.js';
8
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
9
+ import type { TBackgroundTaskEvent } from '@robota-sdk/agent-interface-execution';
10
+
11
+ /**
12
+ * REMOTE-014 E5 TC-03/05 — the protocol layer: the SERVER-ASSIGNED driver id is injected into inbound
13
+ * submit/command/prompt-response (a client cannot send its own), and turn-authored events are SELECTIVELY
14
+ * stamped with the active turn's driver (background events are never stamped).
15
+ */
16
+
17
+ function mockSession(activeDriverId: string | null) {
18
+ const listeners = new Map<string, Set<(...a: unknown[]) => void>>();
19
+ return Object.assign(createTestInteractiveSession(), {
20
+ submit: vi.fn().mockResolvedValue(undefined),
21
+ executeCommand: vi.fn().mockResolvedValue({ message: 'ok', success: true }),
22
+ resolvePermission: vi.fn(),
23
+ resolveAsk: vi.fn(),
24
+ getActiveDriverId: vi.fn().mockReturnValue(activeDriverId),
25
+ on: (e: string, h: (...a: unknown[]) => void) => {
26
+ if (!listeners.has(e)) listeners.set(e, new Set());
27
+ listeners.get(e)!.add(h);
28
+ },
29
+ off: (e: string, h: (...a: unknown[]) => void) => listeners.get(e)?.delete(h),
30
+ _emit: (e: string, ...a: unknown[]) => listeners.get(e)?.forEach((h) => h(...a)),
31
+ });
32
+ }
33
+
34
+ describe('session-message-handler E5 driver attribution (REMOTE-014)', () => {
35
+ it('TC-05: injects the SERVER-ASSIGNED driver id into submit + prompt-response (client cannot forge it)', () => {
36
+ const session = mockSession(null);
37
+ const { onMessage } = createSessionMessageHandler({
38
+ session,
39
+ deliver: createOutboundDelivery(() => {}, vi.fn()),
40
+ driverId: 'device-42',
41
+ });
42
+ // A client submit frame carries NO driverId field (structurally — TClientMessage has none); the handler
43
+ // injects the bound server id.
44
+ onMessage(JSON.stringify({ type: 'submit', prompt: 'hi', driverId: 'spoofed' }));
45
+ expect(session.submit as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
46
+ 'hi',
47
+ undefined,
48
+ undefined,
49
+ {
50
+ driverId: 'device-42',
51
+ },
52
+ );
53
+ onMessage(JSON.stringify({ type: 'permission-response', id: 'p1', result: true }));
54
+ expect(session.resolvePermission).toHaveBeenCalledWith('p1', true, 'device-42');
55
+ onMessage(
56
+ JSON.stringify({
57
+ type: 'ask-response',
58
+ id: 'a1',
59
+ response: { type: 'answer', values: ['y'] },
60
+ }),
61
+ );
62
+ expect(session.resolveAsk).toHaveBeenCalledWith(
63
+ 'a1',
64
+ { type: 'answer', values: ['y'] },
65
+ 'device-42',
66
+ );
67
+ });
68
+
69
+ it('TC-03: SELECTIVELY stamps turn-authored events with the active driver; background events carry none', () => {
70
+ const session = mockSession('driver-A');
71
+ const sent: TServerMessage[] = [];
72
+ createSessionMessageHandler({
73
+ session,
74
+ deliver: createOutboundDelivery((m) => sent.push(m), vi.fn()),
75
+ });
76
+
77
+ session._emit('user_message', 'hello');
78
+ session._emit('text_delta', 'chunk');
79
+ session._emit('background_task_event', { type: 'noop' } as unknown as TBackgroundTaskEvent);
80
+
81
+ const userMsg = sent.find((m) => m.type === 'user_message') as { driverId?: string };
82
+ const textDelta = sent.find((m) => m.type === 'text_delta') as { driverId?: string };
83
+ const bgEvent = sent.find((m) => m.type === 'background_task_event') as { driverId?: string };
84
+ expect(userMsg.driverId).toBe('driver-A');
85
+ expect(textDelta.driverId).toBe('driver-A');
86
+ expect(bgEvent.driverId).toBeUndefined(); // background events are NOT turn-authored
87
+ });
88
+
89
+ it('TC-03: an unattributed (idle) turn stamps no driverId', () => {
90
+ const session = mockSession(null); // getActiveDriverId → null
91
+ const sent: TServerMessage[] = [];
92
+ createSessionMessageHandler({
93
+ session,
94
+ deliver: createOutboundDelivery((m) => sent.push(m), vi.fn()),
95
+ });
96
+ session._emit('user_message', 'hi');
97
+ expect((sent[0] as { driverId?: string }).driverId).toBeUndefined();
98
+ });
99
+ });