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

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 (62) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/README.md +80 -118
  3. package/dist/browser/client.d.ts +2 -2
  4. package/dist/browser/client.js +1 -1
  5. package/dist/browser/index.d.ts +87 -13
  6. package/dist/browser/index.d.ts.map +1 -1
  7. package/dist/browser/index.js +1 -1
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/observer-messages-CEeisRaF.js +2 -0
  10. package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
  11. package/dist/browser/observer-messages-Dnff2Tdb.d.ts +471 -0
  12. package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
  13. package/dist/node/index.cjs +1 -1
  14. package/dist/node/index.d.cts +308 -16
  15. package/dist/node/index.d.cts.map +1 -1
  16. package/dist/node/index.d.ts +308 -16
  17. package/dist/node/index.d.ts.map +1 -1
  18. package/dist/node/index.js +1 -1
  19. package/dist/node/index.js.map +1 -1
  20. package/dist/node/node.cjs +1 -1
  21. package/dist/node/node.d.cts +69 -1
  22. package/dist/node/node.d.cts.map +1 -1
  23. package/dist/node/node.d.ts +70 -2
  24. package/dist/node/node.d.ts.map +1 -1
  25. package/dist/node/node.js +1 -1
  26. package/dist/node/node.js.map +1 -1
  27. package/package.json +17 -14
  28. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  29. package/src/__tests__/history-attached-budget.test.ts +216 -0
  30. package/src/__tests__/message-decoders.test.ts +496 -6
  31. package/src/__tests__/project-read-messages.test.ts +118 -0
  32. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  33. package/src/__tests__/session-directory-messages.test.ts +322 -0
  34. package/src/__tests__/session-error-classification.test.ts +113 -0
  35. package/src/__tests__/session-history-wire.test.ts +342 -0
  36. package/src/__tests__/session-message-handler-observe.test.ts +217 -0
  37. package/src/__tests__/session-message-handler.test.ts +139 -4
  38. package/src/__tests__/settings-messages.test.ts +193 -0
  39. package/src/__tests__/ws-broadcast-events.test.ts +10 -0
  40. package/src/client.ts +9 -1
  41. package/src/index.ts +18 -2
  42. package/src/loop-control-messages.ts +34 -0
  43. package/src/message-decoders.ts +238 -6
  44. package/src/node/bearer-resource-gate.ts +291 -0
  45. package/src/node/index.ts +13 -0
  46. package/src/observer-messages.ts +47 -0
  47. package/src/open-prompts.ts +76 -0
  48. package/src/outbound-delivery.ts +11 -2
  49. package/src/project-read-messages.ts +74 -0
  50. package/src/protocol-session.ts +23 -1
  51. package/src/session-directory-messages.ts +162 -0
  52. package/src/session-error-classification.ts +91 -0
  53. package/src/session-events.ts +100 -19
  54. package/src/session-message-handler.ts +110 -15
  55. package/src/session-query-messages.ts +123 -5
  56. package/src/session-resume-bridge.ts +8 -0
  57. package/src/settings-messages.ts +103 -0
  58. package/src/wire-messages.ts +238 -11
  59. package/dist/browser/message-decoders-BB8n-H2X.d.ts +0 -252
  60. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
  61. package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
  62. package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
@@ -0,0 +1,113 @@
1
+ import {
2
+ AuthenticationError,
3
+ ModelNotAvailableError,
4
+ NetworkError,
5
+ ProviderError,
6
+ RateLimitError,
7
+ } from '@robota-sdk/agent-core';
8
+ import { describe, expect, it, vi } from 'vitest';
9
+
10
+ import { createOutboundDelivery } from '../outbound-delivery.js';
11
+ import { classifySessionErrorForWire } from '../session-error-classification.js';
12
+ import { subscribeSessionEvents } from '../session-events.js';
13
+
14
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
15
+
16
+ /**
17
+ * #3289 §3: the `error` wire frame gains a typed `code`/`provider`/`retryAfterSeconds` alongside the
18
+ * raw `message`, so a renderer can say what happened in plain words instead of showing the vendor's
19
+ * raw text. Additive — an error this classification does not recognize carries no `code` at all,
20
+ * which is exactly today's behavior (unchanged).
21
+ */
22
+ describe('classifySessionErrorForWire', () => {
23
+ it('classifies an AuthenticationError', () => {
24
+ expect(classifySessionErrorForWire(new AuthenticationError('bad key', 'anthropic'))).toEqual({
25
+ code: 'auth',
26
+ provider: 'anthropic',
27
+ });
28
+ });
29
+
30
+ it('classifies a RateLimitError, carrying retryAfterSeconds when the provider gave one', () => {
31
+ expect(
32
+ classifySessionErrorForWire(new RateLimitError('slow down', 30, 'openai')),
33
+ ).toEqual({ code: 'rate_limit', provider: 'openai', retryAfterSeconds: 30 });
34
+ expect(classifySessionErrorForWire(new RateLimitError('slow down', undefined, 'openai'))).toEqual(
35
+ { code: 'rate_limit', provider: 'openai' },
36
+ );
37
+ });
38
+
39
+ it('classifies a ModelNotAvailableError, carrying the model when the failure named one', () => {
40
+ expect(
41
+ classifySessionErrorForWire(new ModelNotAvailableError('claude-x', 'anthropic')),
42
+ ).toEqual({ code: 'model_unavailable', provider: 'anthropic', model: 'claude-x' });
43
+ expect(
44
+ classifySessionErrorForWire(new ModelNotAvailableError(undefined, 'anthropic')),
45
+ ).toEqual({ code: 'model_unavailable', provider: 'anthropic' });
46
+ });
47
+
48
+ it('classifies a NetworkError, carrying the provider when the failure named one', () => {
49
+ expect(
50
+ classifySessionErrorForWire(new NetworkError('down', undefined, undefined, 'anthropic')),
51
+ ).toEqual({
52
+ code: 'network',
53
+ provider: 'anthropic',
54
+ });
55
+ expect(classifySessionErrorForWire(new NetworkError('down'))).toEqual({ code: 'network' });
56
+ });
57
+
58
+ it('classifies a generic ProviderError as the fallback "provider" code', () => {
59
+ expect(classifySessionErrorForWire(new ProviderError('failed', 'gemini'))).toEqual({
60
+ code: 'provider',
61
+ provider: 'gemini',
62
+ });
63
+ });
64
+
65
+ it('classifies through a wrapping layer (e.g. a higher-level "Conversation failed")', () => {
66
+ const wrapped = new ProviderError(
67
+ 'Conversation failed',
68
+ 'conversation',
69
+ new AuthenticationError('bad key', 'openai'),
70
+ );
71
+ expect(classifySessionErrorForWire(wrapped)).toEqual({ code: 'auth', provider: 'openai' });
72
+ });
73
+
74
+ it('leaves an unrelated error uncoded, so the caller keeps showing its raw message unchanged', () => {
75
+ expect(classifySessionErrorForWire(new Error('some internal failure'))).toEqual({});
76
+ });
77
+ });
78
+
79
+ describe('the error wire frame carries the classification (#3289 §3)', () => {
80
+ it('adds code/provider/retryAfterSeconds onto the delivered error frame', () => {
81
+ const send = vi.fn();
82
+ const session = createTestInteractiveSession({ on: vi.fn(), off: vi.fn() });
83
+ subscribeSessionEvents(session, createOutboundDelivery(send, vi.fn()));
84
+
85
+ const onError = vi.mocked(session.on).mock.calls.find(([name]) => name === 'error')?.[1] as (
86
+ error: Error,
87
+ ) => void;
88
+ onError(new RateLimitError('slow down', 12, 'anthropic'));
89
+
90
+ expect(send).toHaveBeenCalledWith(
91
+ expect.objectContaining({
92
+ type: 'error',
93
+ message: 'Rate Limit Error: slow down',
94
+ code: 'rate_limit',
95
+ provider: 'anthropic',
96
+ retryAfterSeconds: 12,
97
+ }),
98
+ );
99
+ });
100
+
101
+ it('delivers an unrelated error with no code, exactly as before', () => {
102
+ const send = vi.fn();
103
+ const session = createTestInteractiveSession({ on: vi.fn(), off: vi.fn() });
104
+ subscribeSessionEvents(session, createOutboundDelivery(send, vi.fn()));
105
+
106
+ const onError = vi.mocked(session.on).mock.calls.find(([name]) => name === 'error')?.[1] as (
107
+ error: Error,
108
+ ) => void;
109
+ onError(new Error('boom'));
110
+
111
+ expect(send).toHaveBeenCalledWith({ type: 'error', message: 'boom' });
112
+ });
113
+ });
@@ -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,217 @@
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
+ // #3282 §4c: `Partial<ISessionProjectRead>` — not part of `IInteractiveSession`, so it is assigned
24
+ // ad hoc (below) rather than passed to `createTestInteractiveSession`'s strict literal.
25
+ readProjectStatus: ReturnType<typeof vi.fn>;
26
+ } {
27
+ const listeners = new Map<string, Set<TListener>>();
28
+ const session = createTestInteractiveSession({
29
+ submit: vi.fn(),
30
+ abort: vi.fn(),
31
+ cancelQueue: vi.fn(),
32
+ executeCommand: vi.fn(),
33
+ resolvePermission: vi.fn(),
34
+ resolveAsk: vi.fn(),
35
+ cancelBackgroundTask: vi.fn(),
36
+ closeBackgroundTask: vi.fn(),
37
+ sendBackgroundTask: vi.fn(),
38
+ getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
39
+ isExecuting: vi.fn().mockReturnValue(true),
40
+ on: ((event: string, handler: TListener) => {
41
+ if (!listeners.has(event)) listeners.set(event, new Set());
42
+ listeners.get(event)!.add(handler);
43
+ }) as IInteractiveSession['on'],
44
+ off: ((event: string, handler: TListener) => {
45
+ listeners.get(event)?.delete(handler);
46
+ }) as IInteractiveSession['off'],
47
+ });
48
+ return Object.assign(session, {
49
+ readProjectStatus: vi.fn().mockResolvedValue({ kind: 'not-a-repository' }),
50
+ emit: (event: string, ...args: unknown[]) => {
51
+ listeners.get(event)?.forEach((handler) => handler(...args));
52
+ },
53
+ listenerCount: (event: string) => listeners.get(event)?.size ?? 0,
54
+ });
55
+ }
56
+
57
+ function attach(
58
+ session: IInteractiveSession,
59
+ role?: 'drive' | 'observe',
60
+ ): { sent: TServerMessage[]; send: (message: TClientMessage) => void; cleanup: () => void } {
61
+ const sent: TServerMessage[] = [];
62
+ const { onMessage, cleanup } = createSessionMessageHandler({
63
+ session,
64
+ deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
65
+ driverId: role === 'observe' ? 'attach:2' : 'attach:1',
66
+ ...(role === undefined ? {} : { role }),
67
+ });
68
+ return { sent, send: (message) => onMessage(JSON.stringify(message)), cleanup };
69
+ }
70
+
71
+ const MUTATING: readonly TClientMessage[] = [
72
+ { type: 'submit', prompt: 'hello' },
73
+ { type: 'command', name: 'exit' },
74
+ { type: 'abort' },
75
+ { type: 'cancel-queue' },
76
+ { type: 'cancel-background-task', taskId: 't1' },
77
+ { type: 'close-background-task', taskId: 't1' },
78
+ { type: 'send-background-task', taskId: 't1', input: { prompt: 'x' } },
79
+ { type: 'permission-response', id: 'p1', result: true },
80
+ { type: 'ask-response', id: 'a1', response: { type: 'answer', values: ['React'] } } as TClientMessage,
81
+ { type: 'get-personal-usage-report', requestId: 'r1', period: '7d', timezone: 'UTC' },
82
+ { type: 'get-stored-session-usage-report', requestId: 'r2', sessionId: 'other' },
83
+ ];
84
+
85
+ describe('observe role', () => {
86
+ it('refuses every mutating or cross-session message with protocol_error and touches nothing', () => {
87
+ const session = createSession();
88
+ const observer = attach(session, 'observe');
89
+ for (const message of MUTATING) {
90
+ observer.sent.length = 0;
91
+ observer.send(message);
92
+ expect(observer.sent).toEqual([
93
+ { type: 'protocol_error', message: `Not permitted for an observer: ${message.type}` },
94
+ ]);
95
+ }
96
+ expect(session.submit).not.toHaveBeenCalled();
97
+ expect(session.executeCommand).not.toHaveBeenCalled();
98
+ expect(session.abort).not.toHaveBeenCalled();
99
+ expect(session.cancelQueue).not.toHaveBeenCalled();
100
+ expect(session.cancelBackgroundTask).not.toHaveBeenCalled();
101
+ expect(session.closeBackgroundTask).not.toHaveBeenCalled();
102
+ expect(session.sendBackgroundTask).not.toHaveBeenCalled();
103
+ expect(session.resolvePermission).not.toHaveBeenCalled();
104
+ expect(session.resolveAsk).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it('keeps the read queries for this session', () => {
108
+ const session = createSession();
109
+ const observer = attach(session, 'observe');
110
+ observer.send({ type: 'get-messages' });
111
+ observer.send({ type: 'get-executing' });
112
+ expect(observer.sent).toEqual([
113
+ // #3288 §2: `display` rides the same frame (the test double's default projects to `[]`).
114
+ { type: 'messages', messages: [{ role: 'user', content: 'hi' }], display: [] },
115
+ { type: 'executing', executing: true },
116
+ ]);
117
+ });
118
+
119
+ it('#3282 §4c: reads the Project panel status — read-only, so an observer may too', async () => {
120
+ const session = createSession();
121
+ const observer = attach(session, 'observe');
122
+ observer.send({ type: 'project-status', requestId: 'ps-1' });
123
+ await Promise.resolve();
124
+ expect(observer.sent).toEqual([
125
+ { type: 'project_status', requestId: 'ps-1', result: { kind: 'not-a-repository' } },
126
+ ]);
127
+ });
128
+
129
+ it('#3282 §4: reads the agent switcher roster, a query, never a write', () => {
130
+ const session = createSession();
131
+ const observer = attach(session, 'observe');
132
+ observer.send({ type: 'get-agent-definitions', requestId: 'req-agents-1' });
133
+ expect(observer.sent).toEqual([
134
+ { type: 'agent_definitions', requestId: 'req-agents-1', agents: [], current: 'general-purpose' },
135
+ ]);
136
+ });
137
+
138
+ it('#3186: reads the session status and the command catalog, which every client shows', () => {
139
+ const session = createSession();
140
+ const observer = attach(session, 'observe');
141
+ observer.send({ type: 'get-status' });
142
+ observer.send({ type: 'get-commands' });
143
+ expect(observer.sent).toEqual([
144
+ { type: 'session_status', status: session.getStatusSnapshot() },
145
+ { type: 'commands', commands: [], skills: [] },
146
+ ]);
147
+ });
148
+
149
+ it('never subscribes to prompt events, so it does not count as a surface that can answer', () => {
150
+ const session = createSession();
151
+ const observer = attach(session, 'observe');
152
+ expect(session.listenerCount('permission_request')).toBe(0);
153
+ expect(session.listenerCount('ask_request')).toBe(0);
154
+ expect(session.listenerCount('prompt_resolved')).toBe(1);
155
+ expect(session.listenerCount('text_delta')).toBe(1);
156
+ session.emit('permission_request', { id: 'p1', toolName: 'Bash', toolArgs: {} });
157
+ session.emit('ask_request', { id: 'a1', request: { id: 'r', title: 'Pick' } });
158
+ session.emit('prompt_resolved', { id: 'p1' });
159
+ expect(observer.sent).toEqual([{ type: 'prompt_resolved', event: { id: 'p1' } }]);
160
+ observer.cleanup();
161
+ expect(session.listenerCount('prompt_resolved')).toBe(0);
162
+ });
163
+
164
+ it('cannot answer a prompt that a drive surface holds', () => {
165
+ const session = createSession();
166
+ const drive = attach(session, 'drive');
167
+ const observer = attach(session, 'observe');
168
+ session.emit('permission_request', { id: 'p1', toolName: 'Bash', toolArgs: {} });
169
+ expect(drive.sent).toContainEqual({
170
+ type: 'permission_request',
171
+ event: { id: 'p1', toolName: 'Bash', toolArgs: {} },
172
+ });
173
+ observer.send({ type: 'permission-response', id: 'p1', result: true });
174
+ expect(session.resolvePermission).not.toHaveBeenCalled();
175
+ drive.send({ type: 'permission-response', id: 'p1', result: false });
176
+ expect(session.resolvePermission).toHaveBeenCalledExactlyOnceWith('p1', false, 'attach:1');
177
+ });
178
+
179
+ it('leaves the drive role unchanged', () => {
180
+ const session = createSession();
181
+ const drive = attach(session);
182
+ expect(session.listenerCount('permission_request')).toBe(1);
183
+ expect(session.listenerCount('ask_request')).toBe(1);
184
+ drive.send({ type: 'abort' });
185
+ drive.send({ type: 'permission-response', id: 'p1', result: true });
186
+ expect(session.abort).toHaveBeenCalledTimes(1);
187
+ expect(session.resolvePermission).toHaveBeenCalledExactlyOnceWith('p1', true, 'attach:1');
188
+ expect(drive.sent).toEqual([]);
189
+ });
190
+
191
+ it('disconnects an observer that stops reading while the session keeps streaming to others', () => {
192
+ const session = createSession();
193
+ const drive = attach(session, 'drive');
194
+ let pending = 0;
195
+ const onDeliveryError = vi.fn();
196
+ const received: TServerMessage[] = [];
197
+ createSessionMessageHandler({
198
+ session,
199
+ role: 'observe',
200
+ deliver: createOutboundDelivery(
201
+ (message) => {
202
+ received.push(message);
203
+ pending += 64 * 1024;
204
+ },
205
+ onDeliveryError,
206
+ () => pending,
207
+ ATTACHED_SURFACE_MAX_PENDING_BYTES,
208
+ ),
209
+ });
210
+ for (let index = 0; index < 64; index++) {
211
+ expect(() => session.emit('text_delta', `delta ${index}`)).not.toThrow();
212
+ }
213
+ expect(onDeliveryError).toHaveBeenCalledTimes(1);
214
+ expect(received.length).toBeLessThan(64);
215
+ expect(drive.sent.filter((message) => message.type === 'text_delta')).toHaveLength(64);
216
+ });
217
+ });