@robota-sdk/agent-transport 3.0.0-beta.94 → 3.0.0-beta.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.md +2 -1
  3. package/dist/browser/client.d.ts +1 -1
  4. package/dist/browser/client.js +1 -1
  5. package/dist/browser/index.d.ts +34 -4
  6. package/dist/browser/index.d.ts.map +1 -1
  7. package/dist/browser/index.js +1 -1
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/{observer-messages-Dnff2Tdb.d.ts → observer-messages-DoWb6RNv.d.ts} +23 -1
  10. package/dist/browser/observer-messages-DoWb6RNv.d.ts.map +1 -0
  11. package/dist/browser/observer-messages-UG77sTYK.js +2 -0
  12. package/dist/browser/observer-messages-UG77sTYK.js.map +1 -0
  13. package/dist/node/index.cjs +1 -1
  14. package/dist/node/index.d.cts +54 -3
  15. package/dist/node/index.d.cts.map +1 -1
  16. package/dist/node/index.d.ts +54 -3
  17. package/dist/node/index.d.ts.map +1 -1
  18. package/dist/node/index.js +1 -1
  19. package/dist/node/index.js.map +1 -1
  20. package/dist/node/node.cjs +1 -1
  21. package/dist/node/node.d.cts +19 -1
  22. package/dist/node/node.d.cts.map +1 -1
  23. package/dist/node/node.d.ts +19 -1
  24. package/dist/node/node.d.ts.map +1 -1
  25. package/dist/node/node.js +1 -1
  26. package/dist/node/node.js.map +1 -1
  27. package/package.json +8 -8
  28. package/src/__tests__/app-tool-messages.test.ts +463 -0
  29. package/src/__tests__/message-decoders.test.ts +101 -0
  30. package/src/__tests__/permission-summary.test.ts +18 -0
  31. package/src/__tests__/session-message-handler-observe.test.ts +6 -0
  32. package/src/__tests__/session-message-handler.test.ts +3 -2
  33. package/src/__tests__/session-resume-bridge.test.ts +66 -0
  34. package/src/__tests__/stdio-carrier.test.ts +85 -0
  35. package/src/app-tool-messages.ts +140 -0
  36. package/src/index.ts +7 -0
  37. package/src/message-decoders.ts +16 -0
  38. package/src/node/index.ts +2 -0
  39. package/src/node/stdio-carrier.ts +59 -0
  40. package/src/permission-summary.ts +18 -0
  41. package/src/protocol-session.ts +7 -1
  42. package/src/session-events.ts +1 -0
  43. package/src/session-message-handler.ts +37 -1
  44. package/src/session-resume-bridge.ts +15 -0
  45. package/src/wire-messages.ts +35 -0
  46. package/dist/browser/observer-messages-CEeisRaF.js +0 -2
  47. package/dist/browser/observer-messages-CEeisRaF.js.map +0 -1
  48. 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
+ });
@@ -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
+ });
@@ -35,6 +35,8 @@ function createSession(): IInteractiveSession & {
35
35
  cancelBackgroundTask: vi.fn(),
36
36
  closeBackgroundTask: vi.fn(),
37
37
  sendBackgroundTask: vi.fn(),
38
+ invokeRuntimeTool: vi.fn(),
39
+ readToolAppView: vi.fn(),
38
40
  getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
39
41
  isExecuting: vi.fn().mockReturnValue(true),
40
42
  on: ((event: string, handler: TListener) => {
@@ -80,6 +82,8 @@ const MUTATING: readonly TClientMessage[] = [
80
82
  { type: 'ask-response', id: 'a1', response: { type: 'answer', values: ['React'] } } as TClientMessage,
81
83
  { type: 'get-personal-usage-report', requestId: 'r1', period: '7d', timezone: 'UTC' },
82
84
  { type: 'get-stored-session-usage-report', requestId: 'r2', sessionId: 'other' },
85
+ { type: 'call-app-tool', requestId: 'r3', viewId: 'view-1', name: 'Write', arguments: {} },
86
+ { type: 'read-app-view', requestId: 'r4', viewId: 'view-1' },
83
87
  ];
84
88
 
85
89
  describe('observe role', () => {
@@ -102,6 +106,8 @@ describe('observe role', () => {
102
106
  expect(session.sendBackgroundTask).not.toHaveBeenCalled();
103
107
  expect(session.resolvePermission).not.toHaveBeenCalled();
104
108
  expect(session.resolveAsk).not.toHaveBeenCalled();
109
+ expect(session.invokeRuntimeTool).not.toHaveBeenCalled();
110
+ expect(session.readToolAppView).not.toHaveBeenCalled();
105
111
  });
106
112
 
107
113
  it('keeps the read queries for this session', () => {
@@ -667,17 +667,18 @@ describe('WebSocket Transport Handler', () => {
667
667
 
668
668
  // ── REMOTE-007: transport-neutral permission/ask over the wire (TC-06) ──
669
669
 
670
- it('forwards a permission_request session event to the client', () => {
670
+ it('forwards a permission_request session event to the client, with the call id it names', () => {
671
671
  const { session, sent } = setup();
672
672
  session._emit('permission_request', {
673
673
  id: 'p1',
674
674
  toolName: 'Bash',
675
675
  toolArgs: { command: 'ls' },
676
+ executionId: 'call-1',
676
677
  });
677
678
  const msg = sent.find((m) => m.type === 'permission_request');
678
679
  expect(msg).toEqual({
679
680
  type: 'permission_request',
680
- event: { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' } },
681
+ event: { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' }, executionId: 'call-1' },
681
682
  });
682
683
  });
683
684