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

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 (55) hide show
  1. package/CHANGELOG.md +752 -0
  2. package/dist/browser/client.d.ts +2 -2
  3. package/dist/browser/client.js +1 -1
  4. package/dist/browser/index.d.ts +31 -11
  5. package/dist/browser/index.d.ts.map +1 -1
  6. package/dist/browser/index.js +1 -1
  7. package/dist/browser/index.js.map +1 -1
  8. package/dist/browser/observer-messages-Bx-xReUQ.js +2 -0
  9. package/dist/browser/observer-messages-Bx-xReUQ.js.map +1 -0
  10. package/dist/browser/{message-decoders-BB8n-H2X.d.ts → observer-messages-DC_8VHk9.d.ts} +104 -7
  11. package/dist/browser/observer-messages-DC_8VHk9.d.ts.map +1 -0
  12. package/dist/node/index.cjs +1 -1
  13. package/dist/node/index.d.cts +131 -14
  14. package/dist/node/index.d.cts.map +1 -1
  15. package/dist/node/index.d.ts +131 -14
  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 -1
  20. package/dist/node/node.d.cts +177 -2
  21. package/dist/node/node.d.cts.map +1 -1
  22. package/dist/node/node.d.ts +178 -3
  23. package/dist/node/node.d.ts.map +1 -1
  24. package/dist/node/node.js +1 -1
  25. package/dist/node/node.js.map +1 -1
  26. package/package.json +13 -11
  27. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  28. package/src/__tests__/file-transfer.test.ts +281 -0
  29. package/src/__tests__/history-attached-budget.test.ts +216 -0
  30. package/src/__tests__/message-decoders.test.ts +201 -5
  31. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  32. package/src/__tests__/session-directory-messages.test.ts +192 -0
  33. package/src/__tests__/session-history-wire.test.ts +342 -0
  34. package/src/__tests__/session-message-handler-observe.test.ts +193 -0
  35. package/src/__tests__/session-message-handler.test.ts +82 -1
  36. package/src/__tests__/ws-broadcast-events.test.ts +10 -0
  37. package/src/client.ts +9 -1
  38. package/src/index.ts +14 -2
  39. package/src/loop-control-messages.ts +34 -0
  40. package/src/message-decoders.ts +82 -4
  41. package/src/node/bearer-resource-gate.ts +291 -0
  42. package/src/node/file-transfer.ts +530 -0
  43. package/src/node/index.ts +30 -0
  44. package/src/observer-messages.ts +36 -0
  45. package/src/open-prompts.ts +76 -0
  46. package/src/outbound-delivery.ts +11 -2
  47. package/src/protocol-session.ts +7 -1
  48. package/src/session-directory-messages.ts +85 -0
  49. package/src/session-events.ts +93 -18
  50. package/src/session-message-handler.ts +53 -8
  51. package/src/session-query-messages.ts +105 -4
  52. package/src/wire-messages.ts +105 -7
  53. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
  54. package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
  55. package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
@@ -0,0 +1,342 @@
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(['compact', '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('delivers where a turn came from', () => {
319
+ const session = createSession();
320
+ const client = attach(session);
321
+
322
+ session.emit('turn_source', 'agent-wakeup');
323
+
324
+ expect(client.sent).toEqual([{ type: 'turn_source', source: 'agent-wakeup' }]);
325
+ });
326
+
327
+ it('pushes the same frames to an observer, which follows the session read-only', () => {
328
+ const session = createSession();
329
+ const observer = attach(session, 'observe');
330
+ const state = { usedTokens: 1, maxTokens: 10, usedPercentage: 10, remainingPercentage: 90 };
331
+
332
+ session.emit('context_update', state);
333
+ session.emit('compact', {});
334
+ session.emit('turn_source', 'user');
335
+
336
+ expect(observer.sent).toEqual([
337
+ { type: 'context', state },
338
+ { type: 'history_changed' },
339
+ { type: 'turn_source', source: 'user' },
340
+ ]);
341
+ });
342
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The read-only `observe` role: an observer reads the session's own conversation and state, never
3
+ * counts as a surface that can answer a prompt, and cannot submit, answer, or control anything.
4
+ */
5
+
6
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
7
+ import { describe, expect, it, vi } from 'vitest';
8
+
9
+ import {
10
+ ATTACHED_SURFACE_MAX_PENDING_BYTES,
11
+ createOutboundDelivery,
12
+ } from '../outbound-delivery.js';
13
+ import { createSessionMessageHandler } from '../session-message-handler.js';
14
+
15
+ import type { TClientMessage, TServerMessage } from '../wire-messages.js';
16
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
17
+
18
+ type TListener = (...args: unknown[]) => void;
19
+
20
+ function createSession(): IInteractiveSession & {
21
+ emit: (event: string, ...args: unknown[]) => void;
22
+ listenerCount: (event: string) => number;
23
+ } {
24
+ const listeners = new Map<string, Set<TListener>>();
25
+ const session = createTestInteractiveSession({
26
+ submit: vi.fn(),
27
+ abort: vi.fn(),
28
+ cancelQueue: vi.fn(),
29
+ executeCommand: vi.fn(),
30
+ resolvePermission: vi.fn(),
31
+ resolveAsk: vi.fn(),
32
+ cancelBackgroundTask: vi.fn(),
33
+ closeBackgroundTask: vi.fn(),
34
+ sendBackgroundTask: vi.fn(),
35
+ getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
36
+ isExecuting: vi.fn().mockReturnValue(true),
37
+ on: ((event: string, handler: TListener) => {
38
+ if (!listeners.has(event)) listeners.set(event, new Set());
39
+ listeners.get(event)!.add(handler);
40
+ }) as IInteractiveSession['on'],
41
+ off: ((event: string, handler: TListener) => {
42
+ listeners.get(event)?.delete(handler);
43
+ }) as IInteractiveSession['off'],
44
+ });
45
+ return Object.assign(session, {
46
+ emit: (event: string, ...args: unknown[]) => {
47
+ listeners.get(event)?.forEach((handler) => handler(...args));
48
+ },
49
+ listenerCount: (event: string) => listeners.get(event)?.size ?? 0,
50
+ });
51
+ }
52
+
53
+ function attach(
54
+ session: IInteractiveSession,
55
+ role?: 'drive' | 'observe',
56
+ ): { sent: TServerMessage[]; send: (message: TClientMessage) => void; cleanup: () => void } {
57
+ const sent: TServerMessage[] = [];
58
+ const { onMessage, cleanup } = createSessionMessageHandler({
59
+ session,
60
+ deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
61
+ driverId: role === 'observe' ? 'attach:2' : 'attach:1',
62
+ ...(role === undefined ? {} : { role }),
63
+ });
64
+ return { sent, send: (message) => onMessage(JSON.stringify(message)), cleanup };
65
+ }
66
+
67
+ const MUTATING: readonly TClientMessage[] = [
68
+ { type: 'submit', prompt: 'hello' },
69
+ { type: 'command', name: 'exit' },
70
+ { type: 'abort' },
71
+ { type: 'cancel-queue' },
72
+ { type: 'cancel-background-task', taskId: 't1' },
73
+ { type: 'close-background-task', taskId: 't1' },
74
+ { type: 'send-background-task', taskId: 't1', input: { prompt: 'x' } },
75
+ { type: 'permission-response', id: 'p1', result: true },
76
+ { type: 'ask-response', id: 'a1', response: { type: 'answer', values: ['React'] } } as TClientMessage,
77
+ { type: 'get-personal-usage-report', requestId: 'r1', period: '7d', timezone: 'UTC' },
78
+ { type: 'get-stored-session-usage-report', requestId: 'r2', sessionId: 'other' },
79
+ ];
80
+
81
+ describe('observe role', () => {
82
+ it('refuses every mutating or cross-session message with protocol_error and touches nothing', () => {
83
+ const session = createSession();
84
+ const observer = attach(session, 'observe');
85
+ for (const message of MUTATING) {
86
+ observer.sent.length = 0;
87
+ observer.send(message);
88
+ expect(observer.sent).toEqual([
89
+ { type: 'protocol_error', message: `Not permitted for an observer: ${message.type}` },
90
+ ]);
91
+ }
92
+ expect(session.submit).not.toHaveBeenCalled();
93
+ expect(session.executeCommand).not.toHaveBeenCalled();
94
+ expect(session.abort).not.toHaveBeenCalled();
95
+ expect(session.cancelQueue).not.toHaveBeenCalled();
96
+ expect(session.cancelBackgroundTask).not.toHaveBeenCalled();
97
+ expect(session.closeBackgroundTask).not.toHaveBeenCalled();
98
+ expect(session.sendBackgroundTask).not.toHaveBeenCalled();
99
+ expect(session.resolvePermission).not.toHaveBeenCalled();
100
+ expect(session.resolveAsk).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it('keeps the read queries for this session', () => {
104
+ const session = createSession();
105
+ const observer = attach(session, 'observe');
106
+ observer.send({ type: 'get-messages' });
107
+ observer.send({ type: 'get-executing' });
108
+ expect(observer.sent).toEqual([
109
+ { type: 'messages', messages: [{ role: 'user', content: 'hi' }] },
110
+ { type: 'executing', executing: true },
111
+ ]);
112
+ });
113
+
114
+ it('#3186: reads the session status and the command catalog, which every client shows', () => {
115
+ const session = createSession();
116
+ const observer = attach(session, 'observe');
117
+ observer.send({ type: 'get-status' });
118
+ observer.send({ type: 'get-commands' });
119
+ expect(observer.sent).toEqual([
120
+ { type: 'session_status', status: session.getStatusSnapshot() },
121
+ { type: 'commands', commands: [], skills: [] },
122
+ ]);
123
+ });
124
+
125
+ it('never subscribes to prompt events, so it does not count as a surface that can answer', () => {
126
+ const session = createSession();
127
+ const observer = attach(session, 'observe');
128
+ expect(session.listenerCount('permission_request')).toBe(0);
129
+ expect(session.listenerCount('ask_request')).toBe(0);
130
+ expect(session.listenerCount('prompt_resolved')).toBe(1);
131
+ expect(session.listenerCount('text_delta')).toBe(1);
132
+ session.emit('permission_request', { id: 'p1', toolName: 'Bash', toolArgs: {} });
133
+ session.emit('ask_request', { id: 'a1', request: { id: 'r', title: 'Pick' } });
134
+ session.emit('prompt_resolved', { id: 'p1' });
135
+ expect(observer.sent).toEqual([{ type: 'prompt_resolved', event: { id: 'p1' } }]);
136
+ observer.cleanup();
137
+ expect(session.listenerCount('prompt_resolved')).toBe(0);
138
+ });
139
+
140
+ it('cannot answer a prompt that a drive surface holds', () => {
141
+ const session = createSession();
142
+ const drive = attach(session, 'drive');
143
+ const observer = attach(session, 'observe');
144
+ session.emit('permission_request', { id: 'p1', toolName: 'Bash', toolArgs: {} });
145
+ expect(drive.sent).toContainEqual({
146
+ type: 'permission_request',
147
+ event: { id: 'p1', toolName: 'Bash', toolArgs: {} },
148
+ });
149
+ observer.send({ type: 'permission-response', id: 'p1', result: true });
150
+ expect(session.resolvePermission).not.toHaveBeenCalled();
151
+ drive.send({ type: 'permission-response', id: 'p1', result: false });
152
+ expect(session.resolvePermission).toHaveBeenCalledExactlyOnceWith('p1', false, 'attach:1');
153
+ });
154
+
155
+ it('leaves the drive role unchanged', () => {
156
+ const session = createSession();
157
+ const drive = attach(session);
158
+ expect(session.listenerCount('permission_request')).toBe(1);
159
+ expect(session.listenerCount('ask_request')).toBe(1);
160
+ drive.send({ type: 'abort' });
161
+ drive.send({ type: 'permission-response', id: 'p1', result: true });
162
+ expect(session.abort).toHaveBeenCalledTimes(1);
163
+ expect(session.resolvePermission).toHaveBeenCalledExactlyOnceWith('p1', true, 'attach:1');
164
+ expect(drive.sent).toEqual([]);
165
+ });
166
+
167
+ it('disconnects an observer that stops reading while the session keeps streaming to others', () => {
168
+ const session = createSession();
169
+ const drive = attach(session, 'drive');
170
+ let pending = 0;
171
+ const onDeliveryError = vi.fn();
172
+ const received: TServerMessage[] = [];
173
+ createSessionMessageHandler({
174
+ session,
175
+ role: 'observe',
176
+ deliver: createOutboundDelivery(
177
+ (message) => {
178
+ received.push(message);
179
+ pending += 64 * 1024;
180
+ },
181
+ onDeliveryError,
182
+ () => pending,
183
+ ATTACHED_SURFACE_MAX_PENDING_BYTES,
184
+ ),
185
+ });
186
+ for (let index = 0; index < 64; index++) {
187
+ expect(() => session.emit('text_delta', `delta ${index}`)).not.toThrow();
188
+ }
189
+ expect(onDeliveryError).toHaveBeenCalledTimes(1);
190
+ expect(received.length).toBeLessThan(64);
191
+ expect(drive.sent.filter((message) => message.type === 'text_delta')).toHaveLength(64);
192
+ });
193
+ });
@@ -10,6 +10,7 @@ import { createSessionMessageHandler } from '../session-message-handler.js';
10
10
  import { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from '../session-events.js';
11
11
  import type { TServerMessage } from '../wire-messages.js';
12
12
  import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
13
+ import type { ICommandListEntry } from '@robota-sdk/agent-interface-command';
13
14
  import type {
14
15
  IBackgroundJobGroupState,
15
16
  IExecutionWorkspaceEvent,
@@ -157,6 +158,35 @@ describe('WebSocket Transport Handler', () => {
157
158
  expect(sent[0]!.type).toBe('context');
158
159
  });
159
160
 
161
+ it('#3186: get-commands sends the commands and skills a client can offer', () => {
162
+ const { onMessage, session, sent } = setup();
163
+ const command: ICommandListEntry = {
164
+ name: 'help',
165
+ description: 'Show commands',
166
+ modelInvocable: false,
167
+ runner: 'runtime',
168
+ };
169
+ const skill = {
170
+ name: 'parity-demo',
171
+ description: 'Demo',
172
+ source: 'project',
173
+ modelInvocable: true,
174
+ userInvocable: true,
175
+ };
176
+ Object.assign(session, {
177
+ listCommands: vi.fn().mockReturnValue([command]),
178
+ listSkills: vi.fn().mockReturnValue([skill]),
179
+ });
180
+ onMessage(JSON.stringify({ type: 'get-commands' }));
181
+ expect(sent).toEqual([{ type: 'commands', commands: [command], skills: [skill] }]);
182
+ });
183
+
184
+ it('#3186: get-status sends the session status snapshot', () => {
185
+ const { onMessage, session, sent } = setup();
186
+ onMessage(JSON.stringify({ type: 'get-status' }));
187
+ expect(sent).toEqual([{ type: 'session_status', status: session.getStatusSnapshot() }]);
188
+ });
189
+
160
190
  it('get-executing sends executing status', () => {
161
191
  const { onMessage, sent } = setup();
162
192
  onMessage(JSON.stringify({ type: 'get-executing' }));
@@ -166,7 +196,35 @@ describe('WebSocket Transport Handler', () => {
166
196
  it('get-pending sends pending prompt', () => {
167
197
  const { onMessage, sent } = setup();
168
198
  onMessage(JSON.stringify({ type: 'get-pending' }));
169
- expect(sent[0]).toEqual({ type: 'pending', pending: null });
199
+ expect(sent[0]).toEqual({ type: 'pending', pending: null, pendingCount: 0 });
200
+ });
201
+
202
+ it('#3189: answers a submit with the queue once the prompt is taken, so a queued prompt shows', async () => {
203
+ const { onMessage, sent, session } = setup();
204
+ const mock = session as unknown as {
205
+ submit: ReturnType<typeof vi.fn>;
206
+ getPendingPrompt: ReturnType<typeof vi.fn>;
207
+ getPendingCount: ReturnType<typeof vi.fn>;
208
+ };
209
+ let queue: () => void = () => undefined;
210
+ // A turn is running: the session queues the prompt only after its own initialization yields.
211
+ mock.submit.mockImplementationOnce(
212
+ () =>
213
+ new Promise<void>((resolve) => {
214
+ queue = () => {
215
+ mock.getPendingPrompt.mockReturnValue('follow-up');
216
+ mock.getPendingCount.mockReturnValue(1);
217
+ resolve();
218
+ };
219
+ }),
220
+ );
221
+ onMessage(JSON.stringify({ type: 'submit', prompt: 'follow-up' }));
222
+ // A `get-pending` sent beside the submit is answered before the prompt is queued.
223
+ onMessage(JSON.stringify({ type: 'get-pending' }));
224
+ expect(sent).toEqual([{ type: 'pending', pending: null, pendingCount: 0 }]);
225
+ queue();
226
+ await new Promise((r) => setTimeout(r, 0));
227
+ expect(sent.at(-1)).toEqual({ type: 'pending', pending: 'follow-up', pendingCount: 1 });
170
228
  });
171
229
 
172
230
  it('get-background-tasks sends current background task list', () => {
@@ -447,6 +505,29 @@ describe('WebSocket Transport Handler', () => {
447
505
  expect(sent).toEqual([{ type: 'protocol_error', message: 'command failed' }]);
448
506
  });
449
507
 
508
+ it("#3189: echoes a command's requestId on its result and on its failure, and on nothing else", async () => {
509
+ const { onMessage, sent, session } = setup();
510
+ const { executeCommand, submit } = session as unknown as {
511
+ executeCommand: ReturnType<typeof vi.fn>;
512
+ submit: ReturnType<typeof vi.fn>;
513
+ };
514
+ executeCommand.mockResolvedValueOnce({ message: 'done', success: true });
515
+ executeCommand.mockRejectedValueOnce(new Error('command failed'));
516
+ submit.mockRejectedValueOnce(new Error('A turn is running.'));
517
+ onMessage(JSON.stringify({ type: 'command', name: 'clear', requestId: 'c1' }));
518
+ onMessage(JSON.stringify({ type: 'command', name: 'broken', requestId: 'c2' }));
519
+ onMessage(JSON.stringify({ type: 'submit', prompt: 'hello' }));
520
+ await new Promise((r) => setTimeout(r, 10));
521
+ expect(sent).toEqual(
522
+ expect.arrayContaining([
523
+ expect.objectContaining({ type: 'command_result', name: 'clear', requestId: 'c1' }),
524
+ { type: 'protocol_error', message: 'command failed', requestId: 'c2' },
525
+ { type: 'protocol_error', message: 'A turn is running.' },
526
+ ]),
527
+ );
528
+ expect(sent).toHaveLength(3);
529
+ });
530
+
450
531
  it('forwards InteractiveSession events to client', () => {
451
532
  const { session, sent } = setup();
452
533
  (session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
@@ -81,4 +81,14 @@ describe('CMD-004 Stage E — session_renamed / history_cleared broadcast to EVE
81
81
  expect(sentA).toEqual([{ type: 'history_cleared' }]);
82
82
  expect(sentB).toEqual([{ type: 'history_cleared' }]);
83
83
  });
84
+
85
+ it('#3189: session_switched reaches both attached surfaces (each re-reads the new session)', () => {
86
+ const { session, sentA, sentB } = setupTwoSurfaces();
87
+
88
+ session._emit('session_switched', { sessionId: 'session-2' });
89
+
90
+ const expected = { type: 'session_switched', event: { sessionId: 'session-2' } };
91
+ expect(sentA).toEqual([expected]);
92
+ expect(sentB).toEqual([expected]);
93
+ });
84
94
  });
package/src/client.ts CHANGED
@@ -6,4 +6,12 @@ export {
6
6
  decodeServerMessage,
7
7
  } from './message-decoders.js';
8
8
  export type { TMessageDecodeResult } from './message-decoders.js';
9
- export type { TClientMessage, TServerMessage, TSeqServerMessage } from './wire-messages.js';
9
+ // The one list of messages an observer may send: the host refuses the rest, a client sends no others.
10
+ export { isObserverMessageType } from './observer-messages.js';
11
+ export type {
12
+ IWireHistoryEntry,
13
+ TClientMessage,
14
+ TServerMessage,
15
+ TSeqServerMessage,
16
+ TWireExecutionResult,
17
+ } from './wire-messages.js';