@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +151 -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__/__fixtures__/korean-text.json +6 -0
  29. package/src/__tests__/app-tool-messages.test.ts +463 -0
  30. package/src/__tests__/channel-frames.test.ts +3 -2
  31. package/src/__tests__/handoff-chunking.test.ts +6 -3
  32. package/src/__tests__/message-decoders.test.ts +101 -0
  33. package/src/__tests__/permission-summary.test.ts +18 -0
  34. package/src/__tests__/session-message-handler-observe.test.ts +6 -0
  35. package/src/__tests__/session-message-handler.test.ts +3 -2
  36. package/src/__tests__/session-resume-bridge.test.ts +66 -0
  37. package/src/__tests__/stdio-carrier.test.ts +85 -0
  38. package/src/app-tool-messages.ts +140 -0
  39. package/src/index.ts +7 -0
  40. package/src/message-decoders.ts +16 -0
  41. package/src/node/index.ts +2 -0
  42. package/src/node/stdio-carrier.ts +59 -0
  43. package/src/permission-summary.ts +18 -0
  44. package/src/protocol-session.ts +7 -1
  45. package/src/session-events.ts +1 -0
  46. package/src/session-message-handler.ts +37 -1
  47. package/src/session-resume-bridge.ts +15 -0
  48. package/src/wire-messages.ts +35 -0
  49. package/dist/browser/observer-messages-CEeisRaF.js +0 -2
  50. package/dist/browser/observer-messages-CEeisRaF.js.map +0 -1
  51. 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 = '한글'.repeat(500);
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({ text: '핸드오프 ✓ '.repeat(200) });
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
+ });