@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.
- package/CHANGELOG.md +43 -0
- package/README.md +80 -118
- package/dist/browser/client.d.ts +1 -1
- package/dist/browser/client.js +1 -1
- package/dist/browser/index.d.ts +60 -6
- 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-DC_8VHk9.d.ts → observer-messages-Dnff2Tdb.d.ts} +125 -3
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +181 -6
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +181 -6
- 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/package.json +12 -11
- package/src/__tests__/message-decoders.test.ts +295 -1
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/session-directory-messages.test.ts +130 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-message-handler-observe.test.ts +25 -1
- package/src/__tests__/session-message-handler.test.ts +57 -3
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/index.ts +4 -0
- package/src/message-decoders.ts +157 -3
- package/src/observer-messages.ts +11 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +17 -1
- package/src/session-directory-messages.ts +78 -1
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +7 -1
- package/src/session-message-handler.ts +58 -8
- package/src/session-query-messages.ts +18 -1
- package/src/session-resume-bridge.ts +8 -0
- package/src/settings-messages.ts +103 -0
- package/src/wire-messages.ts +133 -4
- package/dist/browser/observer-messages-Bx-xReUQ.js +0 -2
- package/dist/browser/observer-messages-Bx-xReUQ.js.map +0 -1
- 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
|
-
|
|
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
|