@robota-sdk/agent-transport 3.0.0-beta.93 → 3.0.0-beta.95
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 +151 -0
- package/README.md +2 -1
- package/dist/browser/client.d.ts +1 -1
- package/dist/browser/client.js +1 -1
- package/dist/browser/index.d.ts +34 -4
- 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-Dnff2Tdb.d.ts → observer-messages-DoWb6RNv.d.ts} +23 -1
- package/dist/browser/observer-messages-DoWb6RNv.d.ts.map +1 -0
- package/dist/browser/observer-messages-UG77sTYK.js +2 -0
- package/dist/browser/observer-messages-UG77sTYK.js.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +54 -3
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +54 -3
- 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 +19 -1
- package/dist/node/node.d.cts.map +1 -1
- package/dist/node/node.d.ts +19 -1
- 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 +8 -8
- package/src/__tests__/__fixtures__/korean-text.json +6 -0
- package/src/__tests__/app-tool-messages.test.ts +463 -0
- package/src/__tests__/channel-frames.test.ts +3 -2
- package/src/__tests__/handoff-chunking.test.ts +6 -3
- package/src/__tests__/message-decoders.test.ts +101 -0
- package/src/__tests__/permission-summary.test.ts +18 -0
- package/src/__tests__/session-message-handler-observe.test.ts +6 -0
- package/src/__tests__/session-message-handler.test.ts +3 -2
- package/src/__tests__/session-resume-bridge.test.ts +66 -0
- package/src/__tests__/stdio-carrier.test.ts +85 -0
- package/src/app-tool-messages.ts +140 -0
- package/src/index.ts +7 -0
- package/src/message-decoders.ts +16 -0
- package/src/node/index.ts +2 -0
- package/src/node/stdio-carrier.ts +59 -0
- package/src/permission-summary.ts +18 -0
- package/src/protocol-session.ts +7 -1
- package/src/session-events.ts +1 -0
- package/src/session-message-handler.ts +37 -1
- package/src/session-resume-bridge.ts +15 -0
- package/src/wire-messages.ts +35 -0
- package/dist/browser/observer-messages-CEeisRaF.js +0 -2
- package/dist/browser/observer-messages-CEeisRaF.js.map +0 -1
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +0 -1
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A driving surface hosting an MCP app view invokes one runtime tool through the session's
|
|
3
|
+
* permission gate and reads a finished call's view on demand. Drive-only: the observer allowlist
|
|
4
|
+
* does not list either message. A call is limited to the tools of the view it names, a connection
|
|
5
|
+
* that goes away cancels its calls, and no answering frame exceeds what the channel carries.
|
|
6
|
+
*/
|
|
7
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
handleAppToolMessage,
|
|
12
|
+
handleAppViewMessage,
|
|
13
|
+
isAppToolMessage,
|
|
14
|
+
isAppViewMessage,
|
|
15
|
+
} from '../app-tool-messages.js';
|
|
16
|
+
import { MAX_INBOUND_FRAME_BYTES } from '../message-decoders.js';
|
|
17
|
+
import { isObserverMessageType } from '../observer-messages.js';
|
|
18
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
19
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
20
|
+
|
|
21
|
+
import type { TOutboundDeliver } from '../outbound-delivery.js';
|
|
22
|
+
import type { TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
23
|
+
import type { IToolAppView, IToolExecutionResult } from '@robota-sdk/agent-core';
|
|
24
|
+
import type {
|
|
25
|
+
IInteractiveSession,
|
|
26
|
+
IRuntimeToolInvokeOptions,
|
|
27
|
+
ISessionRuntimeTools,
|
|
28
|
+
ISessionToolAppViews,
|
|
29
|
+
} from '@robota-sdk/agent-interface-session';
|
|
30
|
+
|
|
31
|
+
function collect(): { sent: TServerMessage[]; deliver: TOutboundDeliver } {
|
|
32
|
+
const sent: TServerMessage[] = [];
|
|
33
|
+
return { sent, deliver: createOutboundDelivery((m) => sent.push(m), vi.fn()) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const view: IToolAppView = {
|
|
37
|
+
sourceId: 'files',
|
|
38
|
+
resourceUri: 'ui://files/list',
|
|
39
|
+
mimeType: 'text/html;profile=mcp-app',
|
|
40
|
+
html: '<main>rows</main>',
|
|
41
|
+
callableTools: { list_files: 'mcp__files__list_files' },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type TAppSession = ISessionRuntimeTools & ISessionToolAppViews;
|
|
45
|
+
|
|
46
|
+
function appSession(overrides: Partial<TAppSession> = {}): TAppSession {
|
|
47
|
+
return {
|
|
48
|
+
listRuntimeTools: vi.fn(),
|
|
49
|
+
invokeRuntimeTool: vi.fn(),
|
|
50
|
+
readToolAppView: vi.fn().mockReturnValue(view),
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function result(overrides: Partial<IToolExecutionResult> = {}): IToolExecutionResult {
|
|
56
|
+
return { success: true, toolName: 'mcp__files__list_files', ...overrides };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('call-app-tool', () => {
|
|
60
|
+
const call: Extract<TClientMessage, { type: 'call-app-tool' }> = {
|
|
61
|
+
type: 'call-app-tool',
|
|
62
|
+
requestId: 'app-tool-1',
|
|
63
|
+
viewId: 'view-1',
|
|
64
|
+
name: 'mcp__files__list_files',
|
|
65
|
+
arguments: { path: 'src' },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
it('is recognised, and is not a message an observer may send', () => {
|
|
69
|
+
expect(isAppToolMessage(call)).toBe(true);
|
|
70
|
+
expect(isAppToolMessage({ type: 'get-status' })).toBe(false);
|
|
71
|
+
expect(isObserverMessageType('call-app-tool')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('invokes interactively for the connection driver and answers the correlated result', async () => {
|
|
75
|
+
const { sent, deliver } = collect();
|
|
76
|
+
const session = appSession({
|
|
77
|
+
invokeRuntimeTool: vi.fn().mockResolvedValue(
|
|
78
|
+
result({ result: { rows: 3 }, parts: [{ type: 'text', text: 'three rows' }] }),
|
|
79
|
+
),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
handleAppToolMessage(session, deliver, call, 'attach:7');
|
|
83
|
+
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
|
84
|
+
|
|
85
|
+
expect(session.readToolAppView).toHaveBeenCalledExactlyOnceWith('view-1');
|
|
86
|
+
expect(session.invokeRuntimeTool).toHaveBeenCalledExactlyOnceWith(
|
|
87
|
+
'mcp__files__list_files',
|
|
88
|
+
{ path: 'src' },
|
|
89
|
+
{ permissionInteraction: 'interactive', driverId: 'attach:7' },
|
|
90
|
+
);
|
|
91
|
+
expect(sent).toEqual([
|
|
92
|
+
{
|
|
93
|
+
type: 'app_tool_result',
|
|
94
|
+
requestId: 'app-tool-1',
|
|
95
|
+
success: true,
|
|
96
|
+
data: { rows: 3 },
|
|
97
|
+
parts: [{ type: 'text', text: 'three rows' }],
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('names no driver for a connection that has none, and answers a failure without data', async () => {
|
|
103
|
+
const { sent, deliver } = collect();
|
|
104
|
+
const session = appSession({
|
|
105
|
+
invokeRuntimeTool: vi.fn().mockResolvedValue(result({ success: false, error: 'Permission denied' })),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
handleAppToolMessage(session, deliver, call);
|
|
109
|
+
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
|
110
|
+
|
|
111
|
+
expect(session.invokeRuntimeTool).toHaveBeenCalledExactlyOnceWith(
|
|
112
|
+
'mcp__files__list_files',
|
|
113
|
+
{ path: 'src' },
|
|
114
|
+
{ permissionInteraction: 'interactive' },
|
|
115
|
+
);
|
|
116
|
+
expect(sent).toEqual([
|
|
117
|
+
{ type: 'app_tool_result', requestId: 'app-tool-1', success: false, error: 'Permission denied' },
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('answers a refused invocation with a protocol_error carrying the requestId', async () => {
|
|
122
|
+
const { sent, deliver } = collect();
|
|
123
|
+
const session = appSession({
|
|
124
|
+
invokeRuntimeTool: vi
|
|
125
|
+
.fn()
|
|
126
|
+
.mockRejectedValue(new Error('Another prompt or command is already running.')),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
handleAppToolMessage(session, deliver, call, 'attach:7');
|
|
130
|
+
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
|
131
|
+
|
|
132
|
+
expect(sent).toEqual([
|
|
133
|
+
{
|
|
134
|
+
type: 'protocol_error',
|
|
135
|
+
message: 'Another prompt or command is already running.',
|
|
136
|
+
requestId: 'app-tool-1',
|
|
137
|
+
},
|
|
138
|
+
]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('refuses a call for a view the session no longer holds, without invoking anything', () => {
|
|
142
|
+
const { sent, deliver } = collect();
|
|
143
|
+
const session = appSession({ readToolAppView: vi.fn().mockReturnValue(undefined) });
|
|
144
|
+
|
|
145
|
+
handleAppToolMessage(session, deliver, call, 'attach:7');
|
|
146
|
+
|
|
147
|
+
expect(sent).toEqual([
|
|
148
|
+
{
|
|
149
|
+
type: 'protocol_error',
|
|
150
|
+
message: 'This app view is no longer available.',
|
|
151
|
+
requestId: 'app-tool-1',
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
expect(session.invokeRuntimeTool).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it.each([
|
|
158
|
+
['a tool of another source', 'mcp__other__delete_everything'],
|
|
159
|
+
['the source-side name rather than the runtime name', 'list_files'],
|
|
160
|
+
['a built-in tool', 'Bash'],
|
|
161
|
+
['a property of the map rather than a tool', 'toString'],
|
|
162
|
+
])('refuses %s: the view reaches only the tools its own source declared', (_label, name) => {
|
|
163
|
+
const { sent, deliver } = collect();
|
|
164
|
+
const session = appSession();
|
|
165
|
+
|
|
166
|
+
handleAppToolMessage(session, deliver, { ...call, name }, 'attach:7');
|
|
167
|
+
|
|
168
|
+
expect(sent).toEqual([
|
|
169
|
+
{
|
|
170
|
+
type: 'protocol_error',
|
|
171
|
+
message: 'This tool is not available to this app view.',
|
|
172
|
+
requestId: 'app-tool-1',
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
expect(session.invokeRuntimeTool).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('answers a tool result too large for the channel with a protocol_error instead of the frame', async () => {
|
|
179
|
+
const { sent, deliver } = collect();
|
|
180
|
+
const session = appSession({
|
|
181
|
+
invokeRuntimeTool: vi.fn().mockResolvedValue(result({ result: 'x'.repeat(2_000) })),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
handleAppToolMessage(session, deliver, call, 'attach:7', { maxFrameBytes: 1_000 });
|
|
185
|
+
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
|
186
|
+
|
|
187
|
+
expect(sent).toEqual([
|
|
188
|
+
{
|
|
189
|
+
type: 'protocol_error',
|
|
190
|
+
message: 'This app tool result is too large to send.',
|
|
191
|
+
requestId: 'app-tool-1',
|
|
192
|
+
},
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('sends a tool result that fits the limit', async () => {
|
|
197
|
+
const { sent, deliver } = collect();
|
|
198
|
+
const session = appSession({
|
|
199
|
+
invokeRuntimeTool: vi.fn().mockResolvedValue(result({ result: 'small' })),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
handleAppToolMessage(session, deliver, call, 'attach:7', { maxFrameBytes: 1_000 });
|
|
203
|
+
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
|
204
|
+
|
|
205
|
+
expect(sent[0]).toMatchObject({ type: 'app_tool_result', data: 'small' });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('keeps the margin the resume bridge needs beneath the inbound limit by default', async () => {
|
|
209
|
+
const { sent, deliver } = collect();
|
|
210
|
+
const nearLimit = 'x'.repeat(MAX_INBOUND_FRAME_BYTES - 500);
|
|
211
|
+
const session = appSession({
|
|
212
|
+
invokeRuntimeTool: vi.fn().mockResolvedValue(result({ result: nearLimit })),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
handleAppToolMessage(session, deliver, call, 'attach:7');
|
|
216
|
+
await vi.waitFor(() => expect(sent).toHaveLength(1));
|
|
217
|
+
|
|
218
|
+
// Within the inbound limit, but a `seq` stamp added afterwards would carry it past.
|
|
219
|
+
expect(sent[0]).toMatchObject({ type: 'protocol_error' });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('passes the connection signal to the invocation and answers nobody once it aborts', async () => {
|
|
223
|
+
const { sent, deliver } = collect();
|
|
224
|
+
const connection = new AbortController();
|
|
225
|
+
let seen: IRuntimeToolInvokeOptions | undefined;
|
|
226
|
+
let settled = false;
|
|
227
|
+
const session = appSession({
|
|
228
|
+
invokeRuntimeTool: vi.fn(
|
|
229
|
+
(_name: string, _arguments: unknown, options?: IRuntimeToolInvokeOptions) =>
|
|
230
|
+
new Promise<IToolExecutionResult>((resolve) => {
|
|
231
|
+
seen = options;
|
|
232
|
+
options?.signal?.addEventListener('abort', () => {
|
|
233
|
+
settled = true;
|
|
234
|
+
resolve(result({ success: false, error: 'cancelled' }));
|
|
235
|
+
});
|
|
236
|
+
}),
|
|
237
|
+
),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
handleAppToolMessage(session, deliver, call, 'attach:7', { signal: connection.signal });
|
|
241
|
+
await vi.waitFor(() => expect(seen).toBeDefined());
|
|
242
|
+
expect(seen?.signal).toBe(connection.signal);
|
|
243
|
+
connection.abort();
|
|
244
|
+
await vi.waitFor(() => expect(settled).toBe(true));
|
|
245
|
+
// The handler's continuation runs as a microtask of that settlement; a macrotask is past it.
|
|
246
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
247
|
+
|
|
248
|
+
expect(sent).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('call-app-tool on a connection that is already gone', () => {
|
|
253
|
+
it('answers nobody when the invocation is refused because its signal was already aborted', async () => {
|
|
254
|
+
const { sent, deliver } = collect();
|
|
255
|
+
const connection = new AbortController();
|
|
256
|
+
connection.abort();
|
|
257
|
+
let refused = false;
|
|
258
|
+
const session = appSession({
|
|
259
|
+
invokeRuntimeTool: vi.fn((_name: string, _arguments: unknown, options?: IRuntimeToolInvokeOptions) => {
|
|
260
|
+
refused = true;
|
|
261
|
+
return Promise.reject(options?.signal?.reason ?? new Error('aborted'));
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
handleAppToolMessage(
|
|
266
|
+
session,
|
|
267
|
+
deliver,
|
|
268
|
+
{ type: 'call-app-tool', requestId: 'c1', viewId: 'view-1', name: 'mcp__files__list_files', arguments: {} },
|
|
269
|
+
'attach:7',
|
|
270
|
+
{ signal: connection.signal },
|
|
271
|
+
);
|
|
272
|
+
await vi.waitFor(() => expect(refused).toBe(true));
|
|
273
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
274
|
+
|
|
275
|
+
expect(sent).toEqual([]);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('read-app-view', () => {
|
|
280
|
+
const read: Extract<TClientMessage, { type: 'read-app-view' }> = {
|
|
281
|
+
type: 'read-app-view',
|
|
282
|
+
requestId: 'app-view-1',
|
|
283
|
+
viewId: 'view-1',
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
it('is recognised, and is not a message an observer may send', () => {
|
|
287
|
+
expect(isAppViewMessage(read)).toBe(true);
|
|
288
|
+
expect(isAppViewMessage({ type: 'get-status' })).toBe(false);
|
|
289
|
+
expect(isObserverMessageType('read-app-view')).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('answers the view the session holds under that id', () => {
|
|
293
|
+
const { sent, deliver } = collect();
|
|
294
|
+
const readToolAppView = vi.fn().mockReturnValue(view);
|
|
295
|
+
handleAppViewMessage({ readToolAppView }, deliver, read);
|
|
296
|
+
expect(readToolAppView).toHaveBeenCalledExactlyOnceWith('view-1');
|
|
297
|
+
expect(sent).toEqual([{ type: 'app_view', requestId: 'app-view-1', view }]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('says the view is gone when the session no longer holds it', () => {
|
|
301
|
+
const { sent, deliver } = collect();
|
|
302
|
+
handleAppViewMessage({ readToolAppView: () => undefined }, deliver, read);
|
|
303
|
+
expect(sent).toEqual([
|
|
304
|
+
{
|
|
305
|
+
type: 'protocol_error',
|
|
306
|
+
message: 'This app view is no longer available.',
|
|
307
|
+
requestId: 'app-view-1',
|
|
308
|
+
},
|
|
309
|
+
]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('refuses a view whose frame would pass the limit instead of sending it', () => {
|
|
313
|
+
const { sent, deliver } = collect();
|
|
314
|
+
const large = { ...view, html: 'x'.repeat(MAX_INBOUND_FRAME_BYTES) };
|
|
315
|
+
handleAppViewMessage({ readToolAppView: () => large }, deliver, read);
|
|
316
|
+
expect(sent).toEqual([
|
|
317
|
+
{
|
|
318
|
+
type: 'protocol_error',
|
|
319
|
+
message: 'This app view is too large to send.',
|
|
320
|
+
requestId: 'app-view-1',
|
|
321
|
+
},
|
|
322
|
+
]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('refuses a frame in the margin the resume bridge needs beneath the inbound limit', () => {
|
|
326
|
+
const { sent, deliver } = collect();
|
|
327
|
+
const fixed = JSON.stringify({ type: 'app_view', requestId: 'app-view-1', view: { ...view, html: '' } });
|
|
328
|
+
const nearLimit = {
|
|
329
|
+
...view,
|
|
330
|
+
html: 'x'.repeat(MAX_INBOUND_FRAME_BYTES - Buffer.byteLength(fixed) - 100),
|
|
331
|
+
};
|
|
332
|
+
handleAppViewMessage({ readToolAppView: () => nearLimit }, deliver, read);
|
|
333
|
+
expect(sent).toMatchObject([{ type: 'protocol_error' }]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('counts bytes, not characters, against the limit', () => {
|
|
337
|
+
const { sent, deliver } = collect();
|
|
338
|
+
// Half the limit in characters, but three bytes each: past the limit once encoded.
|
|
339
|
+
const wide = { ...view, html: '€'.repeat(Math.ceil(MAX_INBOUND_FRAME_BYTES / 2)) };
|
|
340
|
+
handleAppViewMessage({ readToolAppView: () => wide }, deliver, read);
|
|
341
|
+
expect(sent).toMatchObject([{ type: 'protocol_error', message: 'This app view is too large to send.' }]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('holds a channel with a smaller limit to that limit, and still sends what fits', () => {
|
|
345
|
+
const { sent, deliver } = collect();
|
|
346
|
+
const medium = { ...view, html: 'x'.repeat(3_000) };
|
|
347
|
+
handleAppViewMessage({ readToolAppView: () => medium }, deliver, read, { maxFrameBytes: 2_000 });
|
|
348
|
+
expect(sent).toMatchObject([{ type: 'protocol_error', message: 'This app view is too large to send.' }]);
|
|
349
|
+
|
|
350
|
+
handleAppViewMessage({ readToolAppView: () => medium }, deliver, read, { maxFrameBytes: 4_000 });
|
|
351
|
+
expect(sent[1]).toMatchObject({ type: 'app_view', requestId: 'app-view-1' });
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('through the session message handler', () => {
|
|
356
|
+
function attach(
|
|
357
|
+
session: IInteractiveSession,
|
|
358
|
+
extra: { role?: 'drive' | 'observe'; maxFrameBytes?: number } = {},
|
|
359
|
+
) {
|
|
360
|
+
const sent: TServerMessage[] = [];
|
|
361
|
+
const { onMessage, cleanup } = createSessionMessageHandler({
|
|
362
|
+
session,
|
|
363
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
364
|
+
driverId: 'attach:3',
|
|
365
|
+
...(extra.role === undefined ? {} : { role: extra.role }),
|
|
366
|
+
...(extra.maxFrameBytes === undefined ? {} : { maxFrameBytes: extra.maxFrameBytes }),
|
|
367
|
+
});
|
|
368
|
+
return { sent, send: (message: TClientMessage) => onMessage(JSON.stringify(message)), cleanup };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const call: TClientMessage = {
|
|
372
|
+
type: 'call-app-tool',
|
|
373
|
+
requestId: 'c1',
|
|
374
|
+
viewId: 'view-1',
|
|
375
|
+
name: 'mcp__files__list_files',
|
|
376
|
+
arguments: {},
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
it("runs a driving connection's call for its driver and reads the view for it", async () => {
|
|
380
|
+
const invokeRuntimeTool = vi.fn().mockResolvedValue(result({ result: 'ok' }));
|
|
381
|
+
const readToolAppView = vi.fn().mockReturnValue(view);
|
|
382
|
+
const session = createTestInteractiveSession({ invokeRuntimeTool, readToolAppView });
|
|
383
|
+
const drive = attach(session);
|
|
384
|
+
|
|
385
|
+
drive.send(call);
|
|
386
|
+
drive.send({ type: 'read-app-view', requestId: 'v1', viewId: 'view-1' });
|
|
387
|
+
await vi.waitFor(() => expect(drive.sent).toHaveLength(2));
|
|
388
|
+
|
|
389
|
+
expect(invokeRuntimeTool).toHaveBeenCalledExactlyOnceWith(
|
|
390
|
+
'mcp__files__list_files',
|
|
391
|
+
{},
|
|
392
|
+
expect.objectContaining({ permissionInteraction: 'interactive', driverId: 'attach:3' }),
|
|
393
|
+
);
|
|
394
|
+
expect(drive.sent).toContainEqual({ type: 'app_view', requestId: 'v1', view });
|
|
395
|
+
expect(drive.sent).toContainEqual({
|
|
396
|
+
type: 'app_tool_result',
|
|
397
|
+
requestId: 'c1',
|
|
398
|
+
success: true,
|
|
399
|
+
data: 'ok',
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('refuses an observer both messages and touches neither the tools nor the views', () => {
|
|
404
|
+
const invokeRuntimeTool = vi.fn();
|
|
405
|
+
const readToolAppView = vi.fn();
|
|
406
|
+
const session = createTestInteractiveSession({ invokeRuntimeTool, readToolAppView });
|
|
407
|
+
const observer = attach(session, { role: 'observe' });
|
|
408
|
+
|
|
409
|
+
observer.send(call);
|
|
410
|
+
observer.send({ type: 'read-app-view', requestId: 'v1', viewId: 'view-1' });
|
|
411
|
+
|
|
412
|
+
expect(observer.sent).toEqual([
|
|
413
|
+
{ type: 'protocol_error', message: 'Not permitted for an observer: call-app-tool' },
|
|
414
|
+
{ type: 'protocol_error', message: 'Not permitted for an observer: read-app-view' },
|
|
415
|
+
]);
|
|
416
|
+
expect(invokeRuntimeTool).not.toHaveBeenCalled();
|
|
417
|
+
expect(readToolAppView).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('cancels a call still waiting on its prompt when the connection goes away', async () => {
|
|
421
|
+
let seen: IRuntimeToolInvokeOptions | undefined;
|
|
422
|
+
let settled = false;
|
|
423
|
+
const invokeRuntimeTool = vi.fn(
|
|
424
|
+
(_name: string, _arguments: unknown, options?: IRuntimeToolInvokeOptions) =>
|
|
425
|
+
new Promise<IToolExecutionResult>((resolve) => {
|
|
426
|
+
seen = options;
|
|
427
|
+
options?.signal?.addEventListener('abort', () => {
|
|
428
|
+
settled = true;
|
|
429
|
+
resolve(result({ success: false, error: 'denied' }));
|
|
430
|
+
});
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
const session = createTestInteractiveSession({
|
|
434
|
+
invokeRuntimeTool,
|
|
435
|
+
readToolAppView: vi.fn().mockReturnValue(view),
|
|
436
|
+
});
|
|
437
|
+
const drive = attach(session);
|
|
438
|
+
|
|
439
|
+
drive.send(call);
|
|
440
|
+
await vi.waitFor(() => expect(seen?.signal).toBeDefined());
|
|
441
|
+
expect(seen?.signal?.aborted).toBe(false);
|
|
442
|
+
|
|
443
|
+
drive.cleanup();
|
|
444
|
+
|
|
445
|
+
expect(seen?.signal?.aborted).toBe(true);
|
|
446
|
+
// The connection is gone: nothing is sent to it for the call it abandoned.
|
|
447
|
+
await vi.waitFor(() => expect(settled).toBe(true));
|
|
448
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
449
|
+
expect(drive.sent.filter((frame) => frame.type === 'app_tool_result')).toEqual([]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('holds app answers to the carrier limit it was given', async () => {
|
|
453
|
+
const readToolAppView = vi.fn().mockReturnValue({ ...view, html: 'x'.repeat(3_000) });
|
|
454
|
+
const session = createTestInteractiveSession({ readToolAppView });
|
|
455
|
+
const drive = attach(session, { maxFrameBytes: 2_000 });
|
|
456
|
+
|
|
457
|
+
drive.send({ type: 'read-app-view', requestId: 'v1', viewId: 'view-1' });
|
|
458
|
+
|
|
459
|
+
expect(drive.sent).toEqual([
|
|
460
|
+
{ type: 'protocol_error', message: 'This app view is too large to send.', requestId: 'v1' },
|
|
461
|
+
]);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
encodeBinaryFrame,
|
|
7
7
|
encodeChannelEventFrame,
|
|
8
8
|
} from '../channel-frames.js';
|
|
9
|
+
import koreanText from './__fixtures__/korean-text.json';
|
|
9
10
|
|
|
10
11
|
import type { IBinaryFrame, IChannelEventFrame } from '@robota-sdk/agent-interface-transport';
|
|
11
12
|
|
|
@@ -110,14 +111,14 @@ describe('channel frame codec (TRANS-001)', () => {
|
|
|
110
111
|
it('supports multi-byte UTF-8 channel names', () => {
|
|
111
112
|
const encoded = encodeBinaryFrame({
|
|
112
113
|
kind: 'binary',
|
|
113
|
-
channel:
|
|
114
|
+
channel: koreanText.voiceChannelName,
|
|
114
115
|
seq: 1,
|
|
115
116
|
payload: bytes(1, 2, 3),
|
|
116
117
|
});
|
|
117
118
|
const decoded = decodeChannelFrame(encoded);
|
|
118
119
|
expect(decoded.ok).toBe(true);
|
|
119
120
|
if (!decoded.ok) return;
|
|
120
|
-
expect(decoded.frame.channel).toBe(
|
|
121
|
+
expect(decoded.frame.channel).toBe(koreanText.voiceChannelName);
|
|
121
122
|
});
|
|
122
123
|
|
|
123
124
|
it('rejects a channel name that does not fit the 255-byte header field', () => {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type IHandoffChunk,
|
|
8
8
|
} from '../handoff-chunking.js';
|
|
9
9
|
import { sealHandoffRecord, verifyHandoffPayload } from '../node/handoff-manifest.js';
|
|
10
|
+
import koreanText from './__fixtures__/korean-text.json';
|
|
10
11
|
|
|
11
12
|
import type { IInteractiveSessionRecord } from '@robota-sdk/agent-interface-session';
|
|
12
13
|
|
|
@@ -22,7 +23,7 @@ describe('HANDOFF-001 — a payload larger than one frame travels in pieces', ()
|
|
|
22
23
|
it('every chunk fits the byte budget', () => {
|
|
23
24
|
// The channel's limit is in bytes, so the budget has to be honoured in bytes. A payload of
|
|
24
25
|
// multi-byte characters is where a character-based cut overflows the frame.
|
|
25
|
-
const payload =
|
|
26
|
+
const payload = koreanText.hangulWord.repeat(500);
|
|
26
27
|
|
|
27
28
|
const chunks = chunkHandoffPayload(ID, payload, 64);
|
|
28
29
|
|
|
@@ -203,7 +204,7 @@ describe('HANDOFF-001 TC-06 — a mangled chunk is caught where it arrives', ()
|
|
|
203
204
|
cwd: '/home/alice/project',
|
|
204
205
|
createdAt: '2026-08-17T00:00:00.000Z',
|
|
205
206
|
updatedAt: '2026-08-17T01:00:00.000Z',
|
|
206
|
-
messages: [{ role: 'user', content:
|
|
207
|
+
messages: [{ role: 'user', content: koreanText.greetingWithSmileEmoji }],
|
|
207
208
|
} as unknown as IInteractiveSessionRecord;
|
|
208
209
|
const { serialized, integrity } = sealHandoffRecord(record);
|
|
209
210
|
|
|
@@ -221,7 +222,9 @@ describe('without Node’s Buffer, as in the browser build', () => {
|
|
|
221
222
|
|
|
222
223
|
it('chunks and reassembles a multi-byte payload', () => {
|
|
223
224
|
vi.stubGlobal('Buffer', undefined);
|
|
224
|
-
const serialized = JSON.stringify({
|
|
225
|
+
const serialized = JSON.stringify({
|
|
226
|
+
text: koreanText.handoffLabelWithCheckmarkAndTrailingSpace.repeat(200),
|
|
227
|
+
});
|
|
225
228
|
const assembler = new HandoffChunkAssembler('h-browser');
|
|
226
229
|
let result: ReturnType<HandoffChunkAssembler['accept']> | undefined;
|
|
227
230
|
|
|
@@ -65,6 +65,14 @@ const CLIENT_SAMPLES: Readonly<Record<TClientMessage['type'], TClientMessage>> =
|
|
|
65
65
|
'project-status': { type: 'project-status', requestId: 'project-status-1' },
|
|
66
66
|
'project-diff': { type: 'project-diff', requestId: 'project-diff-1', path: 'a.txt' },
|
|
67
67
|
'project-memory': { type: 'project-memory', requestId: 'project-memory-1' },
|
|
68
|
+
'call-app-tool': {
|
|
69
|
+
type: 'call-app-tool',
|
|
70
|
+
requestId: 'app-tool-1',
|
|
71
|
+
viewId: 'view-1',
|
|
72
|
+
name: 'mcp__files__list_files',
|
|
73
|
+
arguments: { path: 'src' },
|
|
74
|
+
},
|
|
75
|
+
'read-app-view': { type: 'read-app-view', requestId: 'app-view-1', viewId: 'view-1' },
|
|
68
76
|
'get-background-tasks': {
|
|
69
77
|
type: 'get-background-tasks',
|
|
70
78
|
filter: { kind: 'agent', includeClosed: true },
|
|
@@ -257,6 +265,25 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
|
|
|
257
265
|
requestId: 'project-memory-1',
|
|
258
266
|
result: { kind: 'memory', content: '# Memory', path: '.fixture-state/memory/MEMORY.md', truncated: false },
|
|
259
267
|
},
|
|
268
|
+
app_tool_result: {
|
|
269
|
+
type: 'app_tool_result',
|
|
270
|
+
requestId: 'app-tool-1',
|
|
271
|
+
success: true,
|
|
272
|
+
data: { rows: 3 },
|
|
273
|
+
parts: [{ type: 'text', text: 'three rows' }],
|
|
274
|
+
error: 'ignored on success',
|
|
275
|
+
},
|
|
276
|
+
app_view: {
|
|
277
|
+
type: 'app_view',
|
|
278
|
+
requestId: 'app-view-1',
|
|
279
|
+
view: {
|
|
280
|
+
sourceId: 'files',
|
|
281
|
+
resourceUri: 'ui://files/list',
|
|
282
|
+
mimeType: 'text/html;profile=mcp-app',
|
|
283
|
+
html: '<main>rows</main>',
|
|
284
|
+
callableTools: { list_files: 'mcp__files__list_files' },
|
|
285
|
+
},
|
|
286
|
+
},
|
|
260
287
|
background_task_event: { type: 'background_task_event', event: {} as never },
|
|
261
288
|
background_job_group_event: { type: 'background_job_group_event', event: {} as never },
|
|
262
289
|
plan_event: { type: 'plan_event', event: {} as never },
|
|
@@ -292,6 +319,41 @@ const SERVER_SAMPLES: Readonly<Record<TServerMessage['type'], TServerMessage>> =
|
|
|
292
319
|
|
|
293
320
|
const MALFORMED_CLIENT: ReadonlyArray<[string, unknown]> = [
|
|
294
321
|
['null', null],
|
|
322
|
+
[
|
|
323
|
+
'call-app-tool without requestId',
|
|
324
|
+
{ type: 'call-app-tool', viewId: 'v', name: 'n', arguments: {} },
|
|
325
|
+
],
|
|
326
|
+
[
|
|
327
|
+
'call-app-tool with an empty requestId',
|
|
328
|
+
{ type: 'call-app-tool', requestId: '', viewId: 'v', name: 'n', arguments: {} },
|
|
329
|
+
],
|
|
330
|
+
[
|
|
331
|
+
'call-app-tool without a viewId',
|
|
332
|
+
{ type: 'call-app-tool', requestId: 'r', name: 'n', arguments: {} },
|
|
333
|
+
],
|
|
334
|
+
[
|
|
335
|
+
'call-app-tool with an empty viewId',
|
|
336
|
+
{ type: 'call-app-tool', requestId: 'r', viewId: '', name: 'n', arguments: {} },
|
|
337
|
+
],
|
|
338
|
+
[
|
|
339
|
+
'call-app-tool without a name',
|
|
340
|
+
{ type: 'call-app-tool', requestId: 'r', viewId: 'v', arguments: {} },
|
|
341
|
+
],
|
|
342
|
+
[
|
|
343
|
+
'call-app-tool with a string argument bag',
|
|
344
|
+
{ type: 'call-app-tool', requestId: 'r', viewId: 'v', name: 'n', arguments: 'x' },
|
|
345
|
+
],
|
|
346
|
+
[
|
|
347
|
+
'call-app-tool with an array argument bag',
|
|
348
|
+
{ type: 'call-app-tool', requestId: 'r', viewId: 'v', name: 'n', arguments: [] },
|
|
349
|
+
],
|
|
350
|
+
[
|
|
351
|
+
'call-app-tool without arguments',
|
|
352
|
+
{ type: 'call-app-tool', requestId: 'r', viewId: 'v', name: 'n' },
|
|
353
|
+
],
|
|
354
|
+
['read-app-view without requestId', { type: 'read-app-view', viewId: 'v' }],
|
|
355
|
+
['read-app-view without viewId', { type: 'read-app-view', requestId: 'r' }],
|
|
356
|
+
['read-app-view with a numeric viewId', { type: 'read-app-view', requestId: 'r', viewId: 1 }],
|
|
295
357
|
['an array', [{ type: 'abort' }]],
|
|
296
358
|
['a string', 'abort'],
|
|
297
359
|
['no type', { prompt: 'x' }],
|
|
@@ -465,6 +527,20 @@ const MALFORMED_CLIENT: ReadonlyArray<[string, unknown]> = [
|
|
|
465
527
|
|
|
466
528
|
const MALFORMED_SERVER: ReadonlyArray<[string, unknown]> = [
|
|
467
529
|
['null', null],
|
|
530
|
+
['app_tool_result without requestId', { type: 'app_tool_result', success: true }],
|
|
531
|
+
['app_tool_result with a string flag', { type: 'app_tool_result', requestId: 'r', success: 'yes' }],
|
|
532
|
+
['app_tool_result without a flag', { type: 'app_tool_result', requestId: 'r' }],
|
|
533
|
+
[
|
|
534
|
+
'app_tool_result with non-record parts',
|
|
535
|
+
{ type: 'app_tool_result', requestId: 'r', success: true, parts: ['x'] },
|
|
536
|
+
],
|
|
537
|
+
[
|
|
538
|
+
'app_tool_result with a numeric error',
|
|
539
|
+
{ type: 'app_tool_result', requestId: 'r', success: false, error: 1 },
|
|
540
|
+
],
|
|
541
|
+
['app_view without requestId', { type: 'app_view', view: {} }],
|
|
542
|
+
['app_view with a string view', { type: 'app_view', requestId: 'r', view: '<main/>' }],
|
|
543
|
+
['app_view without a view', { type: 'app_view', requestId: 'r' }],
|
|
468
544
|
['an array', [{ type: 'resume_gap' }]],
|
|
469
545
|
['an unknown type', { type: 'nope' }],
|
|
470
546
|
['text_delta without delta', { type: 'text_delta' }],
|
|
@@ -642,6 +718,31 @@ describe('decodeClientMessage (issue #2045)', () => {
|
|
|
642
718
|
});
|
|
643
719
|
});
|
|
644
720
|
|
|
721
|
+
describe('the app tool and app view frames', () => {
|
|
722
|
+
it('accepts an app tool result whatever JSON the tool returned, and one without data', () => {
|
|
723
|
+
for (const data of [null, 0, 'text', [1, 2], { nested: { ok: true } }]) {
|
|
724
|
+
expect(
|
|
725
|
+
decodeServerMessage({ type: 'app_tool_result', requestId: 'r', success: true, data }).ok,
|
|
726
|
+
).toBe(true);
|
|
727
|
+
}
|
|
728
|
+
expect(decodeServerMessage({ type: 'app_tool_result', requestId: 'r', success: false }).ok).toBe(
|
|
729
|
+
true,
|
|
730
|
+
);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it('keeps an app tool call with no arguments as an empty bag, not as a missing one', () => {
|
|
734
|
+
expect(
|
|
735
|
+
decodeClientMessage({
|
|
736
|
+
type: 'call-app-tool',
|
|
737
|
+
requestId: 'r',
|
|
738
|
+
viewId: 'v',
|
|
739
|
+
name: 'n',
|
|
740
|
+
arguments: {},
|
|
741
|
+
}).ok,
|
|
742
|
+
).toBe(true);
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
|
|
645
746
|
describe('decodeServerMessage (issue #2045)', () => {
|
|
646
747
|
it('has a shape for every union variant and every sample decodes through JSON', () => {
|
|
647
748
|
for (const [type, sample] of Object.entries(SERVER_SAMPLES)) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { permissionSummaryArgument } from '../permission-summary.js';
|
|
4
|
+
|
|
5
|
+
describe('permissionSummaryArgument', () => {
|
|
6
|
+
it('names a call by the argument a person recognises, file tools included', () => {
|
|
7
|
+
expect(permissionSummaryArgument({ filePath: '/w/a.txt', content: 'x' })).toBe('/w/a.txt');
|
|
8
|
+
expect(permissionSummaryArgument({ command: 'make', filePath: '/w/a.txt' })).toBe('make');
|
|
9
|
+
expect(permissionSummaryArgument({ file_path: 'b.txt' })).toBe('b.txt');
|
|
10
|
+
expect(permissionSummaryArgument({ url: 'https://x.test' })).toBe('https://x.test');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('has nothing to show for empty or non-text arguments', () => {
|
|
14
|
+
expect(permissionSummaryArgument({ command: '' })).toBeUndefined();
|
|
15
|
+
expect(permissionSummaryArgument({ path: 3 })).toBeUndefined();
|
|
16
|
+
expect(permissionSummaryArgument({})).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
});
|