@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.
- package/CHANGELOG.md +795 -0
- package/README.md +80 -118
- package/dist/browser/client.d.ts +2 -2
- package/dist/browser/client.js +1 -1
- package/dist/browser/index.d.ts +87 -13
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/observer-messages-CEeisRaF.js +2 -0
- package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts +471 -0
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +308 -16
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +308 -16
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.cjs +1 -1
- package/dist/node/node.d.cts +69 -1
- package/dist/node/node.d.cts.map +1 -1
- package/dist/node/node.d.ts +70 -2
- package/dist/node/node.d.ts.map +1 -1
- package/dist/node/node.js +1 -1
- package/dist/node/node.js.map +1 -1
- package/package.json +17 -14
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +496 -6
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +322 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-history-wire.test.ts +342 -0
- package/src/__tests__/session-message-handler-observe.test.ts +217 -0
- package/src/__tests__/session-message-handler.test.ts +139 -4
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/ws-broadcast-events.test.ts +10 -0
- package/src/client.ts +9 -1
- package/src/index.ts +18 -2
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +238 -6
- package/src/node/bearer-resource-gate.ts +291 -0
- package/src/node/index.ts +13 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +11 -2
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +23 -1
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +100 -19
- package/src/session-message-handler.ts +110 -15
- package/src/session-query-messages.ts +123 -5
- package/src/session-resume-bridge.ts +8 -0
- package/src/settings-messages.ts +103 -0
- package/src/wire-messages.ts +238 -11
- package/dist/browser/message-decoders-BB8n-H2X.d.ts +0 -252
- package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
- package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
- package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
|
@@ -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,73 @@ 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
|
+
|
|
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
|
+
|
|
160
228
|
it('get-executing sends executing status', () => {
|
|
161
229
|
const { onMessage, sent } = setup();
|
|
162
230
|
onMessage(JSON.stringify({ type: 'get-executing' }));
|
|
@@ -166,7 +234,35 @@ describe('WebSocket Transport Handler', () => {
|
|
|
166
234
|
it('get-pending sends pending prompt', () => {
|
|
167
235
|
const { onMessage, sent } = setup();
|
|
168
236
|
onMessage(JSON.stringify({ type: 'get-pending' }));
|
|
169
|
-
expect(sent[0]).toEqual({ type: 'pending', pending: null });
|
|
237
|
+
expect(sent[0]).toEqual({ type: 'pending', pending: null, pendingCount: 0 });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('#3189: answers a submit with the queue once the prompt is taken, so a queued prompt shows', async () => {
|
|
241
|
+
const { onMessage, sent, session } = setup();
|
|
242
|
+
const mock = session as unknown as {
|
|
243
|
+
submit: ReturnType<typeof vi.fn>;
|
|
244
|
+
getPendingPrompt: ReturnType<typeof vi.fn>;
|
|
245
|
+
getPendingCount: ReturnType<typeof vi.fn>;
|
|
246
|
+
};
|
|
247
|
+
let queue: () => void = () => undefined;
|
|
248
|
+
// A turn is running: the session queues the prompt only after its own initialization yields.
|
|
249
|
+
mock.submit.mockImplementationOnce(
|
|
250
|
+
() =>
|
|
251
|
+
new Promise<void>((resolve) => {
|
|
252
|
+
queue = () => {
|
|
253
|
+
mock.getPendingPrompt.mockReturnValue('follow-up');
|
|
254
|
+
mock.getPendingCount.mockReturnValue(1);
|
|
255
|
+
resolve();
|
|
256
|
+
};
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
onMessage(JSON.stringify({ type: 'submit', prompt: 'follow-up' }));
|
|
260
|
+
// A `get-pending` sent beside the submit is answered before the prompt is queued.
|
|
261
|
+
onMessage(JSON.stringify({ type: 'get-pending' }));
|
|
262
|
+
expect(sent).toEqual([{ type: 'pending', pending: null, pendingCount: 0 }]);
|
|
263
|
+
queue();
|
|
264
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
265
|
+
expect(sent.at(-1)).toEqual({ type: 'pending', pending: 'follow-up', pendingCount: 1 });
|
|
170
266
|
});
|
|
171
267
|
|
|
172
268
|
it('get-background-tasks sends current background task list', () => {
|
|
@@ -309,10 +405,11 @@ describe('WebSocket Transport Handler', () => {
|
|
|
309
405
|
// A transport-origin command is an untrusted remote origin — the handler tags it `'remote'` so the session
|
|
310
406
|
// applies its (optional, allow-by-default) remote-command policy.
|
|
311
407
|
// CMD-004 Phase 2: the 4th arg is the command-origin driver id — undefined when the handler has
|
|
312
|
-
// 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.
|
|
313
410
|
expect(
|
|
314
411
|
(session as unknown as { executeCommand: ReturnType<typeof vi.fn> }).executeCommand,
|
|
315
|
-
).toHaveBeenCalledWith('clear', '', 'remote', undefined);
|
|
412
|
+
).toHaveBeenCalledWith('clear', '', 'remote', undefined, undefined);
|
|
316
413
|
});
|
|
317
414
|
|
|
318
415
|
it('command carries the SERVER-ASSIGNED driver id as the command origin (CMD-004 Phase 2)', async () => {
|
|
@@ -327,7 +424,22 @@ describe('WebSocket Transport Handler', () => {
|
|
|
327
424
|
await new Promise((r) => setTimeout(r, 10));
|
|
328
425
|
expect(
|
|
329
426
|
(session as unknown as { executeCommand: ReturnType<typeof vi.fn> }).executeCommand,
|
|
330
|
-
).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');
|
|
331
443
|
});
|
|
332
444
|
|
|
333
445
|
it('forwards ui_intent session events to the requesting client (CMD-004 Phase 2)', () => {
|
|
@@ -447,6 +559,29 @@ describe('WebSocket Transport Handler', () => {
|
|
|
447
559
|
expect(sent).toEqual([{ type: 'protocol_error', message: 'command failed' }]);
|
|
448
560
|
});
|
|
449
561
|
|
|
562
|
+
it("#3189: echoes a command's requestId on its result and on its failure, and on nothing else", async () => {
|
|
563
|
+
const { onMessage, sent, session } = setup();
|
|
564
|
+
const { executeCommand, submit } = session as unknown as {
|
|
565
|
+
executeCommand: ReturnType<typeof vi.fn>;
|
|
566
|
+
submit: ReturnType<typeof vi.fn>;
|
|
567
|
+
};
|
|
568
|
+
executeCommand.mockResolvedValueOnce({ message: 'done', success: true });
|
|
569
|
+
executeCommand.mockRejectedValueOnce(new Error('command failed'));
|
|
570
|
+
submit.mockRejectedValueOnce(new Error('A turn is running.'));
|
|
571
|
+
onMessage(JSON.stringify({ type: 'command', name: 'clear', requestId: 'c1' }));
|
|
572
|
+
onMessage(JSON.stringify({ type: 'command', name: 'broken', requestId: 'c2' }));
|
|
573
|
+
onMessage(JSON.stringify({ type: 'submit', prompt: 'hello' }));
|
|
574
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
575
|
+
expect(sent).toEqual(
|
|
576
|
+
expect.arrayContaining([
|
|
577
|
+
expect.objectContaining({ type: 'command_result', name: 'clear', requestId: 'c1' }),
|
|
578
|
+
{ type: 'protocol_error', message: 'command failed', requestId: 'c2' },
|
|
579
|
+
{ type: 'protocol_error', message: 'A turn is running.' },
|
|
580
|
+
]),
|
|
581
|
+
);
|
|
582
|
+
expect(sent).toHaveLength(3);
|
|
583
|
+
});
|
|
584
|
+
|
|
450
585
|
it('forwards InteractiveSession events to client', () => {
|
|
451
586
|
const { session, sent } = setup();
|
|
452
587
|
(session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
|
|
@@ -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
|
+
});
|
|
@@ -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
|
-
|
|
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';
|
package/src/index.ts
CHANGED
|
@@ -33,9 +33,13 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
export { createSessionMessageHandler } from './session-message-handler.js';
|
|
36
|
-
export type {
|
|
36
|
+
export type {
|
|
37
|
+
ISessionMessageHandlerOptions,
|
|
38
|
+
TSessionSurfaceRole,
|
|
39
|
+
} from './session-message-handler.js';
|
|
37
40
|
// ARCH-030: the connection-scoped outbound delivery boundary every carrier builds and passes down.
|
|
38
41
|
export {
|
|
42
|
+
ATTACHED_SURFACE_MAX_PENDING_BYTES,
|
|
39
43
|
createOutboundDelivery,
|
|
40
44
|
createPendingStallClock,
|
|
41
45
|
isOverPendingBudget,
|
|
@@ -48,9 +52,18 @@ export type {
|
|
|
48
52
|
TOutboundDeliver,
|
|
49
53
|
} from './outbound-delivery.js';
|
|
50
54
|
export { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from './session-events.js';
|
|
55
|
+
// The one list of messages an observer may send: the host refuses the rest, a client sends no others.
|
|
56
|
+
export { isObserverMessageType } from './observer-messages.js';
|
|
51
57
|
export type { TProtocolSessionEventClassification } from './session-events.js';
|
|
52
58
|
export type { IProtocolSession } from './protocol-session.js';
|
|
53
|
-
export type {
|
|
59
|
+
export type {
|
|
60
|
+
IWireAgentDefinitionSummary,
|
|
61
|
+
IWireHistoryEntry,
|
|
62
|
+
TClientMessage,
|
|
63
|
+
TServerMessage,
|
|
64
|
+
TSeqServerMessage,
|
|
65
|
+
TWireExecutionResult,
|
|
66
|
+
} from './wire-messages.js';
|
|
54
67
|
// Issue #2045: the owner-side runtime decoders; carriers implement `raw → decodeFrame → typed`.
|
|
55
68
|
export {
|
|
56
69
|
MAX_INBOUND_FRAME_BYTES,
|
|
@@ -76,6 +89,9 @@ export type {
|
|
|
76
89
|
TResumeSink,
|
|
77
90
|
IAttachOptions,
|
|
78
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';
|
|
79
95
|
|
|
80
96
|
// SEC-008 admission is NOT here: `src/node/admission.ts` mints and compares transport tokens with
|
|
81
97
|
// `node:crypto`. A browser never mints one — it presents one it was handed — so the exclusion costs
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
2
|
+
import type { IProtocolSession } from './protocol-session.js';
|
|
3
|
+
import type { TClientMessage } from './wire-messages.js';
|
|
4
|
+
|
|
5
|
+
type TLoopControlMessage = Extract<TClientMessage, { type: 'stop-waiting-loop' }>;
|
|
6
|
+
|
|
7
|
+
export function isLoopControlMessage(msg: TClientMessage): msg is TLoopControlMessage {
|
|
8
|
+
return msg.type === 'stop-waiting-loop';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stop the self-paced loop that is waiting for its next wake. The session decides which loop, if
|
|
13
|
+
* any; the reply carries its outcome, and a session that throws is reported as a failed stop.
|
|
14
|
+
*/
|
|
15
|
+
export function handleLoopControlMessage(
|
|
16
|
+
session: IProtocolSession,
|
|
17
|
+
deliver: TOutboundDeliver,
|
|
18
|
+
msg: TLoopControlMessage,
|
|
19
|
+
): void {
|
|
20
|
+
const { requestId } = msg;
|
|
21
|
+
session.stopWaitingSelfPacedLoop().then(
|
|
22
|
+
(outcome) => deliver({ type: 'waiting_loop_stop', requestId, outcome }),
|
|
23
|
+
(error: unknown) =>
|
|
24
|
+
deliver({
|
|
25
|
+
type: 'waiting_loop_stop',
|
|
26
|
+
requestId,
|
|
27
|
+
outcome: { kind: 'failed', message: errorMessage(error) },
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function errorMessage(error: unknown): string {
|
|
33
|
+
return error instanceof Error ? error.message : String(error);
|
|
34
|
+
}
|