@robota-sdk/agent-transport 3.0.0-beta.83 → 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 (43) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +80 -118
  3. package/dist/browser/client.d.ts +1 -1
  4. package/dist/browser/client.js +1 -1
  5. package/dist/browser/index.d.ts +60 -6
  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-DC_8VHk9.d.ts → observer-messages-Dnff2Tdb.d.ts} +125 -3
  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 +181 -6
  15. package/dist/node/index.d.cts.map +1 -1
  16. package/dist/node/index.d.ts +181 -6
  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/package.json +12 -11
  21. package/src/__tests__/message-decoders.test.ts +295 -1
  22. package/src/__tests__/project-read-messages.test.ts +118 -0
  23. package/src/__tests__/session-directory-messages.test.ts +130 -0
  24. package/src/__tests__/session-error-classification.test.ts +113 -0
  25. package/src/__tests__/session-message-handler-observe.test.ts +25 -1
  26. package/src/__tests__/session-message-handler.test.ts +57 -3
  27. package/src/__tests__/settings-messages.test.ts +193 -0
  28. package/src/index.ts +4 -0
  29. package/src/message-decoders.ts +157 -3
  30. package/src/observer-messages.ts +11 -0
  31. package/src/project-read-messages.ts +74 -0
  32. package/src/protocol-session.ts +17 -1
  33. package/src/session-directory-messages.ts +78 -1
  34. package/src/session-error-classification.ts +91 -0
  35. package/src/session-events.ts +7 -1
  36. package/src/session-message-handler.ts +58 -8
  37. package/src/session-query-messages.ts +18 -1
  38. package/src/session-resume-bridge.ts +8 -0
  39. package/src/settings-messages.ts +103 -0
  40. package/src/wire-messages.ts +133 -4
  41. package/dist/browser/observer-messages-Bx-xReUQ.js +0 -2
  42. package/dist/browser/observer-messages-Bx-xReUQ.js.map +0 -1
  43. package/dist/browser/observer-messages-DC_8VHk9.d.ts.map +0 -1
@@ -14,6 +14,7 @@ import type {
14
14
  ISessionDirectory,
15
15
  ISessionListing,
16
16
  TSessionChangeRefusalCode,
17
+ TSessionDeleteRefusalCode,
17
18
  } from '@robota-sdk/agent-interface-session';
18
19
 
19
20
  const listing: ISessionListing = {
@@ -35,6 +36,8 @@ function createDirectory(overrides: Partial<ISessionDirectory> = {}): ISessionDi
35
36
  listSessions: vi.fn(() => listing),
36
37
  switchSession: vi.fn(async () => undefined),
37
38
  newSession: vi.fn(async () => undefined),
39
+ renameSession: vi.fn(async () => undefined),
40
+ deleteSession: vi.fn(async () => undefined),
38
41
  ...overrides,
39
42
  };
40
43
  }
@@ -58,6 +61,11 @@ function refusal(code: TSessionChangeRefusalCode, message: string): Error {
58
61
  return Object.assign(new Error(message), { name: 'SessionChangeRefusal', code });
59
62
  }
60
63
 
64
+ /** The shape `SessionDeleteRefusal` (agent-framework) has; this package cannot import the class. */
65
+ function deleteRefusal(code: TSessionDeleteRefusalCode, message: string): Error {
66
+ return Object.assign(new Error(message), { name: 'SessionDeleteRefusal', code });
67
+ }
68
+
61
69
  const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
62
70
 
63
71
  describe('session directory messages (#3189)', () => {
@@ -190,3 +198,125 @@ describe('session directory messages (#3189)', () => {
190
198
  expect(directory.newSession).not.toHaveBeenCalled();
191
199
  });
192
200
  });
201
+
202
+ describe('rename-session (#3289 §1)', () => {
203
+ it('renames and answers session_renamed_in_list, correlated by requestId', async () => {
204
+ const directory = createDirectory();
205
+ const client = attach({ directory });
206
+ client.send({ type: 'rename-session', sessionId: 'session-2', name: 'Renamed', requestId: 'r1' });
207
+ await flush();
208
+ expect(directory.renameSession).toHaveBeenCalledExactlyOnceWith('session-2', 'Renamed');
209
+ expect(client.sent).toEqual([
210
+ { type: 'session_renamed_in_list', requestId: 'r1', sessionId: 'session-2', name: 'Renamed' },
211
+ ]);
212
+ });
213
+
214
+ it('answers session_rename_failed with the reason when the directory refuses', async () => {
215
+ const directory = createDirectory({
216
+ renameSession: vi.fn(async () => {
217
+ throw new Error('No session session-2 in this workspace.');
218
+ }),
219
+ });
220
+ const client = attach({ directory });
221
+ client.send({ type: 'rename-session', sessionId: 'session-2', name: 'x', requestId: 'r1' });
222
+ await flush();
223
+ expect(client.sent).toEqual([
224
+ {
225
+ type: 'session_rename_failed',
226
+ requestId: 'r1',
227
+ message: 'No session session-2 in this workspace.',
228
+ },
229
+ ]);
230
+ });
231
+
232
+ it('answers not_available on a host without a directory', () => {
233
+ const client = attach({});
234
+ client.send({ type: 'rename-session', sessionId: 'session-2', name: 'x', requestId: 'r1' });
235
+ expect(client.sent).toEqual([
236
+ {
237
+ type: 'session_rename_failed',
238
+ requestId: 'r1',
239
+ message: 'Sessions cannot be renamed on this host.',
240
+ },
241
+ ]);
242
+ });
243
+
244
+ it('refuses an observer', async () => {
245
+ const directory = createDirectory();
246
+ const observer = attach({ directory, role: 'observe' });
247
+ observer.send({ type: 'rename-session', sessionId: 'session-2', name: 'x', requestId: 'r1' });
248
+ expect(observer.sent).toEqual([
249
+ { type: 'protocol_error', message: 'Not permitted for an observer: rename-session' },
250
+ ]);
251
+ expect(directory.renameSession).not.toHaveBeenCalled();
252
+ });
253
+ });
254
+
255
+ describe('delete-session round-trip (#3289 §1)', () => {
256
+ it('deletes and answers session_deleted, correlated by requestId', async () => {
257
+ const directory = createDirectory();
258
+ const client = attach({ directory });
259
+ client.send({ type: 'delete-session', sessionId: 'session-2', requestId: 'r1' });
260
+ await flush();
261
+ expect(directory.deleteSession).toHaveBeenCalledExactlyOnceWith('session-2');
262
+ expect(client.sent).toEqual([
263
+ { type: 'session_deleted', requestId: 'r1', sessionId: 'session-2' },
264
+ ]);
265
+ });
266
+
267
+ it('answers session_delete_failed with the refusal code and reason', async () => {
268
+ const directory = createDirectory({
269
+ deleteSession: vi.fn(async () => {
270
+ throw deleteRefusal('live_elsewhere', 'Another client is on this session.');
271
+ }),
272
+ });
273
+ const client = attach({ directory });
274
+ client.send({ type: 'delete-session', sessionId: 'session-2', requestId: 'r1' });
275
+ await flush();
276
+ expect(client.sent).toEqual([
277
+ {
278
+ type: 'session_delete_failed',
279
+ requestId: 'r1',
280
+ code: 'live_elsewhere',
281
+ message: 'Another client is on this session.',
282
+ },
283
+ ]);
284
+ });
285
+
286
+ it('reports code failed for an error that is not a declared delete refusal', async () => {
287
+ const directory = createDirectory({
288
+ deleteSession: vi.fn(async () => {
289
+ throw new Error('disk full');
290
+ }),
291
+ });
292
+ const client = attach({ directory });
293
+ client.send({ type: 'delete-session', sessionId: 'session-2', requestId: 'r1' });
294
+ await flush();
295
+ expect(client.sent).toEqual([
296
+ { type: 'session_delete_failed', requestId: 'r1', code: 'failed', message: 'disk full' },
297
+ ]);
298
+ });
299
+
300
+ it('answers not_available on a host without a directory', () => {
301
+ const client = attach({});
302
+ client.send({ type: 'delete-session', sessionId: 'session-2', requestId: 'r1' });
303
+ expect(client.sent).toEqual([
304
+ {
305
+ type: 'session_delete_failed',
306
+ requestId: 'r1',
307
+ code: 'not_available',
308
+ message: 'Sessions cannot be deleted on this host.',
309
+ },
310
+ ]);
311
+ });
312
+
313
+ it('refuses an observer', async () => {
314
+ const directory = createDirectory();
315
+ const observer = attach({ directory, role: 'observe' });
316
+ observer.send({ type: 'delete-session', sessionId: 'session-2', requestId: 'r1' });
317
+ expect(observer.sent).toEqual([
318
+ { type: 'protocol_error', message: 'Not permitted for an observer: delete-session' },
319
+ ]);
320
+ expect(directory.deleteSession).not.toHaveBeenCalled();
321
+ });
322
+ });
@@ -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
+ });
@@ -20,6 +20,9 @@ type TListener = (...args: unknown[]) => void;
20
20
  function createSession(): IInteractiveSession & {
21
21
  emit: (event: string, ...args: unknown[]) => void;
22
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>;
23
26
  } {
24
27
  const listeners = new Map<string, Set<TListener>>();
25
28
  const session = createTestInteractiveSession({
@@ -43,6 +46,7 @@ function createSession(): IInteractiveSession & {
43
46
  }) as IInteractiveSession['off'],
44
47
  });
45
48
  return Object.assign(session, {
49
+ readProjectStatus: vi.fn().mockResolvedValue({ kind: 'not-a-repository' }),
46
50
  emit: (event: string, ...args: unknown[]) => {
47
51
  listeners.get(event)?.forEach((handler) => handler(...args));
48
52
  },
@@ -106,11 +110,31 @@ describe('observe role', () => {
106
110
  observer.send({ type: 'get-messages' });
107
111
  observer.send({ type: 'get-executing' });
108
112
  expect(observer.sent).toEqual([
109
- { type: 'messages', messages: [{ role: 'user', content: 'hi' }] },
113
+ // #3288 §2: `display` rides the same frame (the test double's default projects to `[]`).
114
+ { type: 'messages', messages: [{ role: 'user', content: 'hi' }], display: [] },
110
115
  { type: 'executing', executing: true },
111
116
  ]);
112
117
  });
113
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
+
114
138
  it('#3186: reads the session status and the command catalog, which every client shows', () => {
115
139
  const session = createSession();
116
140
  const observer = attach(session, 'observe');
@@ -187,6 +187,44 @@ describe('WebSocket Transport Handler', () => {
187
187
  expect(sent).toEqual([{ type: 'session_status', status: session.getStatusSnapshot() }]);
188
188
  });
189
189
 
190
+ it('#3282 §2: list-models sends the model list, echoing requestId', () => {
191
+ const { onMessage, session, sent } = setup();
192
+ const snapshot = {
193
+ groups: [
194
+ {
195
+ profileName: 'anthropic',
196
+ providerLabel: 'Anthropic',
197
+ models: [{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }],
198
+ },
199
+ ],
200
+ currentProfile: 'anthropic',
201
+ currentModel: 'claude-sonnet-4-6',
202
+ };
203
+ Object.assign(session, { listModels: vi.fn().mockReturnValue(snapshot) });
204
+
205
+ onMessage(JSON.stringify({ type: 'list-models', requestId: 'req-models-1' }));
206
+
207
+ expect(sent).toEqual([{ type: 'model_list', requestId: 'req-models-1', ...snapshot }]);
208
+ });
209
+
210
+ it('#3282 §4: get-agent-definitions sends the roster and the current default, echoing requestId', () => {
211
+ const { onMessage, session, sent } = setup();
212
+ const agents = [
213
+ { name: 'general-purpose', description: 'General-purpose task execution agent.', definedIn: 'Built-in' },
214
+ { name: 'Explore', description: 'Read-only codebase exploration agent.', definedIn: 'Built-in' },
215
+ ];
216
+ Object.assign(session, {
217
+ listAgentDefinitions: vi.fn().mockReturnValue(agents),
218
+ getDefaultAgentType: vi.fn().mockReturnValue('Explore'),
219
+ });
220
+
221
+ onMessage(JSON.stringify({ type: 'get-agent-definitions', requestId: 'req-agents-1' }));
222
+
223
+ expect(sent).toEqual([
224
+ { type: 'agent_definitions', requestId: 'req-agents-1', agents, current: 'Explore' },
225
+ ]);
226
+ });
227
+
190
228
  it('get-executing sends executing status', () => {
191
229
  const { onMessage, sent } = setup();
192
230
  onMessage(JSON.stringify({ type: 'get-executing' }));
@@ -367,10 +405,11 @@ describe('WebSocket Transport Handler', () => {
367
405
  // A transport-origin command is an untrusted remote origin — the handler tags it `'remote'` so the session
368
406
  // applies its (optional, allow-by-default) remote-command policy.
369
407
  // CMD-004 Phase 2: the 4th arg is the command-origin driver id — undefined when the handler has
370
- // no server-assigned id (unattributed; a client-sent id is NEVER trusted).
408
+ // no server-assigned id (unattributed; a client-sent id is NEVER trusted). The 5th (#3282 §4
409
+ // part b-2) is this carrier's locality — undefined here since this handler was not given one.
371
410
  expect(
372
411
  (session as unknown as { executeCommand: ReturnType<typeof vi.fn> }).executeCommand,
373
- ).toHaveBeenCalledWith('clear', '', 'remote', undefined);
412
+ ).toHaveBeenCalledWith('clear', '', 'remote', undefined, undefined);
374
413
  });
375
414
 
376
415
  it('command carries the SERVER-ASSIGNED driver id as the command origin (CMD-004 Phase 2)', async () => {
@@ -385,7 +424,22 @@ describe('WebSocket Transport Handler', () => {
385
424
  await new Promise((r) => setTimeout(r, 10));
386
425
  expect(
387
426
  (session as unknown as { executeCommand: ReturnType<typeof vi.fn> }).executeCommand,
388
- ).toHaveBeenCalledWith('settings', '', 'remote', 'device-7');
427
+ ).toHaveBeenCalledWith('settings', '', 'remote', 'device-7', undefined);
428
+ });
429
+
430
+ it("forwards this carrier's commandSurfaceLocality to executeCommand (#3282 §4 part b-2)", async () => {
431
+ const session = createMockSession();
432
+ const sent: TServerMessage[] = [];
433
+ const { onMessage } = createSessionMessageHandler({
434
+ session,
435
+ deliver: createOutboundDelivery((msg) => sent.push(msg), vi.fn()),
436
+ commandSurfaceLocality: 'remote',
437
+ });
438
+ onMessage(JSON.stringify({ type: 'command', name: 'plugin', args: 'install demo@community' }));
439
+ await new Promise((r) => setTimeout(r, 10));
440
+ expect(
441
+ (session as unknown as { executeCommand: ReturnType<typeof vi.fn> }).executeCommand,
442
+ ).toHaveBeenCalledWith('plugin', 'install demo@community', 'remote', undefined, 'remote');
389
443
  });
390
444
 
391
445
  it('forwards ui_intent session events to the requesting client (CMD-004 Phase 2)', () => {
@@ -0,0 +1,193 @@
1
+ /**
2
+ * #3282 §4a: the GUI Settings screen reads and writes through `get-settings`/`update-settings`,
3
+ * correlated by `requestId` and answered by `settings` or `settings_error` — the same request/error
4
+ * shape as `get-personal-usage-report`. The host-owned reporter decides what a read or write means;
5
+ * this layer only correlates and carries the result.
6
+ */
7
+
8
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+
11
+ import { createOutboundDelivery } from '../outbound-delivery.js';
12
+ import { createSessionMessageHandler } from '../session-message-handler.js';
13
+
14
+ import type { ISettingsReporter, TSettingsUpdateOutcome } from '../settings-messages.js';
15
+ import type { TClientMessage, TServerMessage } from '../wire-messages.js';
16
+ import type { ISettingsSnapshot } from '@robota-sdk/agent-interface-session';
17
+
18
+ const snapshot: ISettingsSnapshot = {
19
+ language: { current: 'en', recommended: [], appliesNote: 'Takes effect after a restart.' },
20
+ outputStyle: { current: 'default', choices: [] },
21
+ preset: { current: 'default', choices: [], skipsAllChecksPresetIds: [] },
22
+ permissionMode: { current: 'default', choices: [], skipsAllChecksMode: 'bypassPermissions' },
23
+ permissionRules: [],
24
+ sandbox: { enabled: true, available: true, description: 'Confines shell commands.' },
25
+ mcp: { servers: [] },
26
+ plugins: { plugins: [], canInstall: true },
27
+ providers: { profiles: [] },
28
+ };
29
+
30
+ function createReporter(overrides: Partial<ISettingsReporter> = {}): ISettingsReporter {
31
+ return {
32
+ getSettings: vi.fn(() => snapshot),
33
+ updateSettings: vi.fn(
34
+ (): TSettingsUpdateOutcome => ({ ok: true, settings: snapshot }),
35
+ ),
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ function attach(
41
+ options: {
42
+ reporter?: ISettingsReporter;
43
+ role?: 'drive' | 'observe';
44
+ commandSurfaceLocality?: 'local' | 'remote';
45
+ } = {},
46
+ ): {
47
+ sent: TServerMessage[];
48
+ send: (message: TClientMessage) => void;
49
+ } {
50
+ const sent: TServerMessage[] = [];
51
+ const { onMessage } = createSessionMessageHandler({
52
+ session: createTestInteractiveSession(),
53
+ deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
54
+ ...(options.reporter ? { settingsReporter: options.reporter } : {}),
55
+ ...(options.role ? { role: options.role } : {}),
56
+ ...(options.commandSurfaceLocality
57
+ ? { commandSurfaceLocality: options.commandSurfaceLocality }
58
+ : {}),
59
+ });
60
+ return { sent, send: (message) => onMessage(JSON.stringify(message)) };
61
+ }
62
+
63
+ const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
64
+
65
+ describe('settings messages (#3282 §4a)', () => {
66
+ it('answers get-settings with the reporter snapshot, correlated by requestId', async () => {
67
+ const reporter = createReporter();
68
+ const client = attach({ reporter });
69
+ client.send({ type: 'get-settings', requestId: 'r1' });
70
+ await flush();
71
+ expect(client.sent).toEqual([{ type: 'settings', requestId: 'r1', settings: snapshot }]);
72
+ });
73
+
74
+ it('answers not_available when the host has no settings reporter', async () => {
75
+ const client = attach({});
76
+ client.send({ type: 'get-settings', requestId: 'r1' });
77
+ await flush();
78
+ expect(client.sent).toEqual([
79
+ {
80
+ type: 'settings_error',
81
+ requestId: 'r1',
82
+ code: 'not_available',
83
+ message: 'Settings are not available on this host.',
84
+ },
85
+ ]);
86
+ });
87
+
88
+ it('applies a patch through the reporter and answers with the fresh snapshot', async () => {
89
+ const reporter = createReporter();
90
+ const client = attach({ reporter });
91
+ const patch = { field: 'outputStyle' as const, styleId: 'concise' };
92
+ client.send({ type: 'update-settings', requestId: 'r2', patch });
93
+ await flush();
94
+ expect(reporter.updateSettings).toHaveBeenCalledOnce();
95
+ expect(vi.mocked(reporter.updateSettings).mock.calls[0]?.[1]).toEqual(patch);
96
+ expect(client.sent).toEqual([{ type: 'settings', requestId: 'r2', settings: snapshot }]);
97
+ });
98
+
99
+ it('reports a refused patch as settings_error with the reporter code and message, writing nothing', async () => {
100
+ const reporter = createReporter({
101
+ updateSettings: vi.fn(
102
+ (): TSettingsUpdateOutcome => ({
103
+ ok: false,
104
+ code: 'refused',
105
+ message: 'Entering "bypassPermissions" is blocked for this workspace.',
106
+ }),
107
+ ),
108
+ });
109
+ const client = attach({ reporter });
110
+ client.send({
111
+ type: 'update-settings',
112
+ requestId: 'r3',
113
+ patch: { field: 'permissionMode', mode: 'bypassPermissions' },
114
+ });
115
+ await flush();
116
+ expect(client.sent).toEqual([
117
+ {
118
+ type: 'settings_error',
119
+ requestId: 'r3',
120
+ code: 'refused',
121
+ message: 'Entering "bypassPermissions" is blocked for this workspace.',
122
+ },
123
+ ]);
124
+ });
125
+
126
+ it('reports a thrown error from the reporter as update_failed', async () => {
127
+ const reporter = createReporter({
128
+ updateSettings: vi.fn(() => {
129
+ throw new Error('disk full');
130
+ }),
131
+ });
132
+ const client = attach({ reporter });
133
+ client.send({
134
+ type: 'update-settings',
135
+ requestId: 'r4',
136
+ patch: { field: 'language', language: 'ko' },
137
+ });
138
+ await flush();
139
+ expect(client.sent).toEqual([
140
+ { type: 'settings_error', requestId: 'r4', code: 'update_failed', message: 'disk full' },
141
+ ]);
142
+ });
143
+
144
+ it('lets an observer read settings but never write them', async () => {
145
+ const reporter = createReporter();
146
+ const observer = attach({ reporter, role: 'observe' });
147
+ observer.send({ type: 'get-settings', requestId: 'r5' });
148
+ observer.send({
149
+ type: 'update-settings',
150
+ requestId: 'r6',
151
+ patch: { field: 'sandbox', enabled: false },
152
+ });
153
+ await flush();
154
+ // The observer-role refusal is synchronous; the reporter's (possibly async) snapshot reply is
155
+ // not, so it lands second even though `get-settings` was sent first.
156
+ expect(observer.sent).toEqual([
157
+ { type: 'protocol_error', message: 'Not permitted for an observer: update-settings' },
158
+ { type: 'settings', requestId: 'r5', settings: snapshot },
159
+ ]);
160
+ expect(reporter.updateSettings).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it('forwards this carrier\'s commandSurfaceLocality to both a read and a write (#3282 §4 part b-2)', async () => {
164
+ const reporter = createReporter();
165
+ const client = attach({ reporter, commandSurfaceLocality: 'remote' });
166
+ client.send({ type: 'get-settings', requestId: 'r7' });
167
+ client.send({
168
+ type: 'update-settings',
169
+ requestId: 'r8',
170
+ patch: { field: 'reloadMcpServers' },
171
+ });
172
+ await flush();
173
+ expect(vi.mocked(reporter.getSettings).mock.calls[0]?.[1]).toBe('remote');
174
+ expect(vi.mocked(reporter.updateSettings).mock.calls[0]?.[2]).toBe('remote');
175
+ });
176
+
177
+ it.each([
178
+ { field: 'mcpServerEnabled', serverId: 'docs', enabled: false },
179
+ { field: 'reloadMcpServers' },
180
+ { field: 'pluginEnabled', pluginId: 'formatter@robota', enabled: true },
181
+ { field: 'reloadPlugins' },
182
+ { field: 'installPlugin', pluginId: 'linter@robota' },
183
+ { field: 'uninstallPlugin', pluginId: 'formatter@robota' },
184
+ ] as const)('round-trips the $field patch through the reporter', async (patch) => {
185
+ const reporter = createReporter();
186
+ const client = attach({ reporter });
187
+ client.send({ type: 'update-settings', requestId: 'rt', patch });
188
+ await flush();
189
+ expect(reporter.updateSettings).toHaveBeenCalledOnce();
190
+ expect(vi.mocked(reporter.updateSettings).mock.calls[0]?.[1]).toEqual(patch);
191
+ expect(client.sent).toEqual([{ type: 'settings', requestId: 'rt', settings: snapshot }]);
192
+ });
193
+ });
package/src/index.ts CHANGED
@@ -57,6 +57,7 @@ export { isObserverMessageType } from './observer-messages.js';
57
57
  export type { TProtocolSessionEventClassification } from './session-events.js';
58
58
  export type { IProtocolSession } from './protocol-session.js';
59
59
  export type {
60
+ IWireAgentDefinitionSummary,
60
61
  IWireHistoryEntry,
61
62
  TClientMessage,
62
63
  TServerMessage,
@@ -88,6 +89,9 @@ export type {
88
89
  TResumeSink,
89
90
  IAttachOptions,
90
91
  } from './session-resume-bridge.js';
92
+ // #3282 §4a: the Settings screen's host-owned read/write seam (parallels `IUsageQueryReporters`).
93
+ export { handleSettingsMessage, isSettingsMessage } from './settings-messages.js';
94
+ export type { ISettingsReporter, TSettingsUpdateOutcome } from './settings-messages.js';
91
95
 
92
96
  // SEC-008 admission is NOT here: `src/node/admission.ts` mints and compares transport tokens with
93
97
  // `node:crypto`. A browser never mints one — it presents one it was handed — so the exclusion costs