@robota-sdk/agent-transport 3.0.0-beta.94 → 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 (48) hide show
  1. package/CHANGELOG.md +139 -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
@@ -404,3 +404,69 @@ describe('SessionResumeBridge (REMOTE-013 TC-02)', () => {
404
404
  });
405
405
  });
406
406
  });
407
+
408
+ describe('app view messages through the resume bridge', () => {
409
+ const view = {
410
+ sourceId: 'files',
411
+ resourceUri: 'ui://files/list',
412
+ mimeType: 'text/html;profile=mcp-app',
413
+ html: '<main>rows</main>',
414
+ callableTools: { list_files: 'mcp__files__list_files' },
415
+ };
416
+ const call = {
417
+ type: 'call-app-tool',
418
+ requestId: 'c1',
419
+ viewId: 'view-1',
420
+ name: 'mcp__files__list_files',
421
+ arguments: {},
422
+ };
423
+
424
+ it('cancels a call waiting on its prompt when the host gives up on the bridge, not when a channel drops', async () => {
425
+ let signal: AbortSignal | undefined;
426
+ const base = fakeSession().session;
427
+ const session = Object.assign(base, {
428
+ readToolAppView: vi.fn().mockReturnValue(view),
429
+ invokeRuntimeTool: vi.fn(
430
+ (_name: string, _arguments: unknown, options?: { signal?: AbortSignal }) => {
431
+ signal = options?.signal;
432
+ return new Promise(() => undefined);
433
+ },
434
+ ),
435
+ });
436
+ const bridge = new SessionResumeBridge({ session });
437
+ bridge.attach(sink(), TEST_ATTACH_OPTIONS);
438
+
439
+ bridge.onClientMessage(JSON.stringify(call));
440
+ await vi.waitFor(() => expect(signal).toBeDefined());
441
+
442
+ // A channel drop leaves the session reconnectable: the call keeps waiting for its prompt.
443
+ bridge.detach();
444
+ expect(signal?.aborted).toBe(false);
445
+
446
+ bridge.dispose();
447
+ expect(signal?.aborted).toBe(true);
448
+ });
449
+
450
+ it('holds an app view to the channel limit before the frame reaches the buffer', () => {
451
+ const base = fakeSession().session;
452
+ const session = Object.assign(base, {
453
+ readToolAppView: vi.fn().mockReturnValue({ ...view, html: 'x'.repeat(5_000) }),
454
+ });
455
+ const bridge = new SessionResumeBridge({ session, maxFrameBytes: 2_000 });
456
+ const output = sink();
457
+ bridge.attach(output, TEST_ATTACH_OPTIONS);
458
+
459
+ bridge.onClientMessage(JSON.stringify({ type: 'read-app-view', requestId: 'v1', viewId: 'view-1' }));
460
+
461
+ expect(frames(output).map((frame) => frame.type)).toEqual(['protocol_error']);
462
+ // Nothing large was retained for a later `resume` either.
463
+ const replay = sink();
464
+ bridge.detach();
465
+ bridge.attach(replay, TEST_ATTACH_OPTIONS);
466
+ bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
467
+ expect(frames(replay).map((frame) => frame.type)).toEqual(['protocol_error']);
468
+ expect(replay.calls.join('').length).toBeLessThan(2_000);
469
+ bridge.dispose();
470
+ });
471
+ });
472
+
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The stdio carrier bridge: what a protocol carrier hands its SDK in place of the process input, and
3
+ * how it learns that either end went away. Two carriers share it, so the cases here are the contract
4
+ * both rely on rather than either carrier's own behavior.
5
+ */
6
+
7
+ import { PassThrough } from 'node:stream';
8
+
9
+ import { describe, expect, it, vi } from 'vitest';
10
+
11
+ import { attachStdioCarrier } from '../node/stdio-carrier.js';
12
+
13
+ function handlers() {
14
+ return { onInputClose: vi.fn(), onOutputClose: vi.fn(), onOutputError: vi.fn() };
15
+ }
16
+
17
+ describe('attachStdioCarrier', () => {
18
+ it('forwards input to the bridge and keeps bytes written before anyone reads', async () => {
19
+ const input = new PassThrough();
20
+ const carrier = attachStdioCarrier(input, new PassThrough(), handlers());
21
+ input.write('early\n');
22
+ const chunks: string[] = [];
23
+ carrier.bridge.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
24
+ await new Promise((resolve) => setImmediate(resolve));
25
+ expect(chunks.join('')).toBe('early\n');
26
+ carrier.release();
27
+ });
28
+
29
+ it('reports the input ending, and the bridge ends with it', async () => {
30
+ const input = new PassThrough();
31
+ const h = handlers();
32
+ const carrier = attachStdioCarrier(input, new PassThrough(), h);
33
+ const ended = new Promise<void>((resolve) => carrier.bridge.once('end', resolve));
34
+ carrier.bridge.resume();
35
+ input.end();
36
+ await ended;
37
+ expect(h.onInputClose).toHaveBeenCalled();
38
+ carrier.release();
39
+ });
40
+
41
+ it('reports the input closing without an end', () => {
42
+ const input = new PassThrough();
43
+ const h = handlers();
44
+ const carrier = attachStdioCarrier(input, new PassThrough(), h);
45
+ input.emit('close');
46
+ expect(h.onInputClose).toHaveBeenCalledTimes(1);
47
+ carrier.release();
48
+ });
49
+
50
+ it('reports the output closing and failing', () => {
51
+ const output = new PassThrough();
52
+ const h = handlers();
53
+ const carrier = attachStdioCarrier(new PassThrough(), output, h);
54
+ const failure = new Error('EPIPE');
55
+ output.emit('error', failure);
56
+ output.emit('close');
57
+ expect(h.onOutputError).toHaveBeenCalledWith(failure);
58
+ expect(h.onOutputClose).toHaveBeenCalledTimes(1);
59
+ carrier.release();
60
+ });
61
+
62
+ it('destroys the bridge on release but leaves the real input alone', () => {
63
+ const input = new PassThrough();
64
+ const carrier = attachStdioCarrier(input, new PassThrough(), handlers());
65
+ carrier.release();
66
+ expect(carrier.bridge.destroyed).toBe(true);
67
+ expect(input.destroyed).toBe(false);
68
+ expect(input.listenerCount('end')).toBe(0);
69
+ expect(input.listenerCount('close')).toBe(0);
70
+ });
71
+
72
+ it('stops reporting after release, and release is idempotent', () => {
73
+ const input = new PassThrough();
74
+ const output = new PassThrough();
75
+ const h = handlers();
76
+ const carrier = attachStdioCarrier(input, output, h);
77
+ carrier.release();
78
+ carrier.release();
79
+ input.emit('close');
80
+ output.emit('close');
81
+ expect(h.onInputClose).not.toHaveBeenCalled();
82
+ expect(h.onOutputClose).not.toHaveBeenCalled();
83
+ expect(h.onOutputError).not.toHaveBeenCalled();
84
+ });
85
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * A driving surface that hosts an MCP app view acts for it here: it invokes one runtime tool
3
+ * through the session's own permission gate, and it reads a finished call's view on demand.
4
+ *
5
+ * Both are drive-only (neither is in `OBSERVER_MESSAGES`): an observer neither runs a tool nor
6
+ * receives a view's document. A message that decoded but cannot be served answers `protocol_error`
7
+ * echoing `requestId`, so the view that asked can tell which call it was.
8
+ */
9
+ import { MAX_INBOUND_FRAME_BYTES } from './message-decoders.js';
10
+
11
+ import type { TOutboundDeliver } from './outbound-delivery.js';
12
+ import type { TClientMessage, TServerMessage } from './wire-messages.js';
13
+ import type {
14
+ ISessionRuntimeTools,
15
+ ISessionToolAppViews,
16
+ TDriverId,
17
+ } from '@robota-sdk/agent-interface-session';
18
+
19
+ type TCallAppToolMessage = Extract<TClientMessage, { type: 'call-app-tool' }>;
20
+ type TReadAppViewMessage = Extract<TClientMessage, { type: 'read-app-view' }>;
21
+
22
+ /**
23
+ * Room a carrier that stamps frames after they are produced (the resume bridge adds `seq`) needs
24
+ * beneath the inbound limit, so a frame sent within the limit is still received within it.
25
+ */
26
+ const FRAME_STAMP_MARGIN_BYTES = 1024;
27
+
28
+ /** Where a frame answering an app message is measured, and what ends the work behind it. */
29
+ export interface IAppMessageOptions {
30
+ /** The most bytes one answering frame may take; defaults to the inbound limit less a margin. */
31
+ readonly maxFrameBytes?: number;
32
+ /** Ends the connection's work: aborting it cancels a call still waiting on its prompt. */
33
+ readonly signal?: AbortSignal;
34
+ }
35
+
36
+ export function isAppToolMessage(msg: TClientMessage): msg is TCallAppToolMessage {
37
+ return msg.type === 'call-app-tool';
38
+ }
39
+
40
+ export function isAppViewMessage(msg: TClientMessage): msg is TReadAppViewMessage {
41
+ return msg.type === 'read-app-view';
42
+ }
43
+
44
+ function frameLimit(options: IAppMessageOptions | undefined): number {
45
+ return options?.maxFrameBytes ?? MAX_INBOUND_FRAME_BYTES - FRAME_STAMP_MARGIN_BYTES;
46
+ }
47
+
48
+ /** Checked before `deliver`, so a frame the channel cannot carry never enters a buffer. */
49
+ function fitsFrame(frame: TServerMessage, limit: number): boolean {
50
+ return new TextEncoder().encode(JSON.stringify(frame)).byteLength <= limit;
51
+ }
52
+
53
+ /**
54
+ * Runs the tool as an interactive call acting for this connection's driver, so a permission prompt
55
+ * it raises is shown to the surfaces that can answer and is attributed to that driver. The call is
56
+ * limited to the tools the named view's own source declared: the surface says which view it acts
57
+ * for, and the session, not the surface, says what that view may reach.
58
+ */
59
+ export function handleAppToolMessage(
60
+ session: ISessionRuntimeTools & ISessionToolAppViews,
61
+ deliver: TOutboundDeliver,
62
+ msg: TCallAppToolMessage,
63
+ driverId?: TDriverId,
64
+ options?: IAppMessageOptions,
65
+ ): void {
66
+ const { requestId } = msg;
67
+ const view = session.readToolAppView(msg.viewId);
68
+ if (view === undefined) {
69
+ deliver({ type: 'protocol_error', message: 'This app view is no longer available.', requestId });
70
+ return;
71
+ }
72
+ if (!Object.values(view.callableTools).includes(msg.name)) {
73
+ deliver({
74
+ type: 'protocol_error',
75
+ message: 'This tool is not available to this app view.',
76
+ requestId,
77
+ });
78
+ return;
79
+ }
80
+ const { signal } = options ?? {};
81
+ const limit = frameLimit(options);
82
+ session
83
+ .invokeRuntimeTool(msg.name, msg.arguments, {
84
+ permissionInteraction: 'interactive',
85
+ ...(driverId ? { driverId } : {}),
86
+ ...(signal ? { signal } : {}),
87
+ })
88
+ .then(
89
+ (result) => {
90
+ // The connection that asked is gone: there is nobody to answer.
91
+ if (signal?.aborted) return;
92
+ const frame: TServerMessage = {
93
+ type: 'app_tool_result',
94
+ requestId,
95
+ success: result.success,
96
+ ...(result.result !== undefined ? { data: result.result } : {}),
97
+ ...(result.parts !== undefined ? { parts: result.parts } : {}),
98
+ ...(result.error !== undefined ? { error: result.error } : {}),
99
+ };
100
+ if (fitsFrame(frame, limit)) {
101
+ deliver(frame);
102
+ } else {
103
+ deliver({
104
+ type: 'protocol_error',
105
+ message: 'This app tool result is too large to send.',
106
+ requestId,
107
+ });
108
+ }
109
+ },
110
+ (error: unknown) => {
111
+ if (signal?.aborted) return;
112
+ deliver({
113
+ type: 'protocol_error',
114
+ message: error instanceof Error ? error.message : String(error),
115
+ requestId,
116
+ });
117
+ },
118
+ );
119
+ }
120
+
121
+ /** Answers with the whole document, or says why not: gone, or too large for a frame. */
122
+ export function handleAppViewMessage(
123
+ session: ISessionToolAppViews,
124
+ deliver: TOutboundDeliver,
125
+ msg: TReadAppViewMessage,
126
+ options?: IAppMessageOptions,
127
+ ): void {
128
+ const { requestId } = msg;
129
+ const view = session.readToolAppView(msg.viewId);
130
+ if (view === undefined) {
131
+ deliver({ type: 'protocol_error', message: 'This app view is no longer available.', requestId });
132
+ return;
133
+ }
134
+ const frame: TServerMessage = { type: 'app_view', requestId, view };
135
+ if (!fitsFrame(frame, frameLimit(options))) {
136
+ deliver({ type: 'protocol_error', message: 'This app view is too large to send.', requestId });
137
+ return;
138
+ }
139
+ deliver(frame);
140
+ }
package/src/index.ts CHANGED
@@ -54,6 +54,9 @@ export type {
54
54
  export { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from './session-events.js';
55
55
  // The one list of messages an observer may send: the host refuses the rest, a client sends no others.
56
56
  export { isObserverMessageType } from './observer-messages.js';
57
+ // The most history one frame carries, sized for the tightest channel; a carrier whose channel is
58
+ // small (a WebRTC data channel) bounds an app view's frame by the same number.
59
+ export { HISTORY_PAGE_MAX_BYTES } from './session-query-messages.js';
57
60
  export type { TProtocolSessionEventClassification } from './session-events.js';
58
61
  export type { IProtocolSession } from './protocol-session.js';
59
62
  export type {
@@ -112,6 +115,10 @@ export type {
112
115
  IPeerMessageVerdict,
113
116
  } from './peer-message-ledger.js';
114
117
 
118
+ // The argument a person recognises a tool call by, shared by every transport that asks a person
119
+ // about a permission in its own wire form, so they name the same call the same way.
120
+ export { permissionSummaryArgument } from './permission-summary.js';
121
+
115
122
  // The handoff integrity helpers (`sealHandoffRecord`, `verifyHandoffPayload`) are NOT
116
123
  // here: it hashes with `node:crypto`. Chunking is, because it never hashes anything.
117
124
  export {
@@ -269,6 +269,13 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
269
269
  'project-status': { requestId: isNonEmptyString },
270
270
  'project-diff': { requestId: isNonEmptyString, path: isNonEmptyString },
271
271
  'project-memory': { requestId: isNonEmptyString },
272
+ 'call-app-tool': {
273
+ requestId: isNonEmptyString,
274
+ viewId: isNonEmptyString,
275
+ name: isNonEmptyString,
276
+ arguments: isRecord,
277
+ },
278
+ 'read-app-view': { requestId: isNonEmptyString, viewId: isNonEmptyString },
272
279
  'get-background-tasks': { filter: isOptional(isBackgroundTaskListFilter) },
273
280
  'get-background-task': { taskId: isNonEmptyString },
274
281
  'get-background-job-groups': {},
@@ -380,6 +387,15 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
380
387
  project_status: { requestId: isNonEmptyString, result: isProjectStatusRead },
381
388
  project_diff: { requestId: isNonEmptyString, result: isProjectDiffRead },
382
389
  project_memory: { requestId: isNonEmptyString, result: isProjectMemoryRead },
390
+ // `data` is whatever JSON the tool returned, so any value passes, null included; absent is fine.
391
+ app_tool_result: {
392
+ requestId: isNonEmptyString,
393
+ success: isBoolean,
394
+ data: () => true,
395
+ parts: isOptional(isRecordArray),
396
+ error: isOptional(isString),
397
+ },
398
+ app_view: { requestId: isNonEmptyString, view: isRecord },
383
399
  background_task_event: { event: isRecord },
384
400
  background_job_group_event: { event: isRecord },
385
401
  plan_event: { event: isRecord },
package/src/node/index.ts CHANGED
@@ -41,3 +41,5 @@ export type {
41
41
  TFileSendOutcome,
42
42
  TFileTransferRefusal,
43
43
  } from './file-transfer.js';
44
+ export { attachStdioCarrier } from './stdio-carrier.js';
45
+ export type { IStdioCarrier, IStdioCarrierHandlers } from './stdio-carrier.js';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The input bridge and close observation a stdio protocol carrier shares.
3
+ *
4
+ * A protocol SDK that reads a stream owns what it reads: closing the SDK connection destroys its
5
+ * source. Handing it the process input directly would let one carrier's `stop()` destroy `process.stdin`
6
+ * for the whole process, so the carrier reads a `PassThrough` bridge fed by the input instead. Piping
7
+ * also makes EOF observable before the SDK starts reading and keeps early frames, with backpressure,
8
+ * until it does.
9
+ *
10
+ * This module only attaches and detaches. What a close or failure means — settle a promise, cancel a
11
+ * startup, end the process — stays with each carrier.
12
+ */
13
+
14
+ import { PassThrough } from 'node:stream';
15
+
16
+ import type { Readable, Writable } from 'node:stream';
17
+
18
+ export interface IStdioCarrierHandlers {
19
+ /** The peer ended its side: `end` or `close` on the input. May fire more than once. */
20
+ readonly onInputClose: () => void;
21
+ /** The output closed. */
22
+ readonly onOutputClose: () => void;
23
+ /** The output failed to accept a write. */
24
+ readonly onOutputError: (error: Error) => void;
25
+ }
26
+
27
+ export interface IStdioCarrier {
28
+ /** Hand this to the protocol SDK in place of the input; destroying it never destroys the input. */
29
+ readonly bridge: PassThrough;
30
+ /** Remove the listeners, unpipe and destroy the bridge. Safe to call again; the streams stay open. */
31
+ release(): void;
32
+ }
33
+
34
+ export function attachStdioCarrier(
35
+ input: Readable,
36
+ output: Writable,
37
+ handlers: IStdioCarrierHandlers,
38
+ ): IStdioCarrier {
39
+ const bridge = new PassThrough();
40
+ let released = false;
41
+ input.once('end', handlers.onInputClose);
42
+ input.once('close', handlers.onInputClose);
43
+ output.once('close', handlers.onOutputClose);
44
+ output.on('error', handlers.onOutputError);
45
+ input.pipe(bridge);
46
+ return {
47
+ bridge,
48
+ release() {
49
+ if (released) return;
50
+ released = true;
51
+ input.off('end', handlers.onInputClose);
52
+ input.off('close', handlers.onInputClose);
53
+ output.off('close', handlers.onOutputClose);
54
+ output.off('error', handlers.onOutputError);
55
+ input.unpipe(bridge);
56
+ bridge.destroy();
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The one argument a person recognises a tool call by, for every transport that puts a permission
3
+ * request to a person in its own wire form. Shared so the transports name the same call the same
4
+ * way; the built-in file tools take `filePath`, other tools `file_path` or `path`.
5
+ */
6
+ import type { TToolArgs } from '@robota-sdk/agent-core';
7
+
8
+ /** In the order the tools put them first. */
9
+ const SUMMARY_ARGUMENTS = ['command', 'filePath', 'file_path', 'path', 'url', 'pattern', 'query'];
10
+
11
+ /** The call's identifying argument, or `undefined` when it has none worth showing. */
12
+ export function permissionSummaryArgument(toolArgs: TToolArgs): string | undefined {
13
+ for (const key of SUMMARY_ARGUMENTS) {
14
+ const value = toolArgs[key];
15
+ if (typeof value === 'string' && value !== '') return value;
16
+ }
17
+ return undefined;
18
+ }
@@ -11,8 +11,10 @@ import type {
11
11
  ISessionExecutionWorkspace,
12
12
  ISessionProjectRead,
13
13
  ISessionPromptResolution,
14
+ ISessionRuntimeTools,
14
15
  ISessionSelfPacedLoopControl,
15
16
  ISessionStatusRead,
17
+ ISessionToolAppViews,
16
18
  ISessionTurnControl,
17
19
  ISessionTurnSubmission,
18
20
  } from '@robota-sdk/agent-interface-session';
@@ -37,7 +39,11 @@ export interface IProtocolSession
37
39
  // #3282 §4: the agent switcher's read (`listAgentDefinitions`/`getDefaultAgentType`) — the
38
40
  // group's write members (`spawnAgentJob` and friends) stay unused by any wire handler; they run
39
41
  // only through `executeCommand`, same as every other command-only write.
40
- ISessionAgentJobs {}
42
+ ISessionAgentJobs,
43
+ // A driving surface that hosts an app view runs one runtime tool for it (`call-app-tool`) and
44
+ // reads a finished call's view (`read-app-view`).
45
+ ISessionRuntimeTools,
46
+ ISessionToolAppViews {}
41
47
 
42
48
  /**
43
49
  * #3282 §4c: a protocol session that ALSO answers the Project panel's reads. `Partial` (not a fourth
@@ -45,6 +45,7 @@ export const PROTOCOL_SESSION_EVENT_CLASSIFICATION = {
45
45
  tool_start: 'forwarded',
46
46
  tool_end: 'forwarded',
47
47
  thinking: 'forwarded',
48
+ thinking_delta: 'non-surface',
48
49
  complete: 'forwarded',
49
50
  error: 'forwarded',
50
51
  context_update: 'forwarded',
@@ -8,6 +8,13 @@
8
8
  * Server pushes IProtocolSession events to client in real-time.
9
9
  */
10
10
 
11
+ import {
12
+ handleAppToolMessage,
13
+ handleAppViewMessage,
14
+ isAppToolMessage,
15
+ isAppViewMessage,
16
+ type IAppMessageOptions,
17
+ } from './app-tool-messages.js';
11
18
  import {
12
19
  handleBackgroundControlMessage,
13
20
  handleBackgroundQueryMessage,
@@ -93,6 +100,13 @@ export interface ISessionMessageHandlerOptions {
93
100
  * command that runs code from outside the session (installing a plugin) can refuse a remote device.
94
101
  */
95
102
  commandSurfaceLocality?: TCommandSurfaceLocality;
103
+ /**
104
+ * The most bytes one frame answering an app tool call or view read may take, for a carrier whose
105
+ * channel carries less than the inbound limit (a WebRTC data channel). An answer over it is
106
+ * replaced by a `protocol_error` before it reaches any buffer. Defaults to the inbound limit less
107
+ * a margin for a carrier that stamps frames afterwards.
108
+ */
109
+ maxFrameBytes?: number;
96
110
  }
97
111
 
98
112
  /**
@@ -119,10 +133,17 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
119
133
  cleanup: () => void;
120
134
  } {
121
135
  const role = options.role ?? 'drive';
122
- const cleanup = subscribeSessionEvents(options.session, options.deliver, {
136
+ const unsubscribe = subscribeSessionEvents(options.session, options.deliver, {
123
137
  getSurfaceDriverId: () => options.driverId,
124
138
  receivePrompts: role === 'drive',
125
139
  });
140
+ // One per connection: when the connection goes, work it started for a hosted app view (a tool call
141
+ // waiting on its permission prompt) is cancelled, so the prompt is withdrawn instead of left open.
142
+ const connection = new AbortController();
143
+ const cleanup = (): void => {
144
+ connection.abort();
145
+ unsubscribe();
146
+ };
126
147
  const onMessage = createMessageHandler(
127
148
  options.session,
128
149
  options.deliver,
@@ -137,6 +158,10 @@ export function createSessionMessageHandler(options: ISessionMessageHandlerOptio
137
158
  options.sessionDirectory,
138
159
  options.settingsReporter,
139
160
  options.commandSurfaceLocality,
161
+ {
162
+ signal: connection.signal,
163
+ ...(options.maxFrameBytes !== undefined ? { maxFrameBytes: options.maxFrameBytes } : {}),
164
+ },
140
165
  );
141
166
 
142
167
  return { onMessage, cleanup };
@@ -152,6 +177,7 @@ function createMessageHandler(
152
177
  sessionDirectory?: ISessionDirectory,
153
178
  settingsReporter?: ISettingsReporter,
154
179
  commandSurfaceLocality?: TCommandSurfaceLocality,
180
+ appMessages?: IAppMessageOptions,
155
181
  ): (data: string) => void {
156
182
  return (data: string): void => {
157
183
  const msg = parseClientMessage(data, deliver);
@@ -170,6 +196,7 @@ function createMessageHandler(
170
196
  sessionDirectory,
171
197
  settingsReporter,
172
198
  commandSurfaceLocality,
199
+ appMessages,
173
200
  );
174
201
  };
175
202
  }
@@ -194,6 +221,7 @@ export function handleClientMessage(
194
221
  sessionDirectory?: ISessionDirectory,
195
222
  settingsReporter?: ISettingsReporter,
196
223
  commandSurfaceLocality?: TCommandSurfaceLocality,
224
+ appMessages?: IAppMessageOptions,
197
225
  ): void {
198
226
  if (handleUsageQueryMessage(session, deliver, msg, reporters)) {
199
227
  return;
@@ -230,6 +258,14 @@ export function handleClientMessage(
230
258
  handleProjectReadMessage(session, deliver, msg);
231
259
  return;
232
260
  }
261
+ if (isAppToolMessage(msg)) {
262
+ handleAppToolMessage(session, deliver, msg, driverId, appMessages);
263
+ return;
264
+ }
265
+ if (isAppViewMessage(msg)) {
266
+ handleAppViewMessage(session, deliver, msg, appMessages);
267
+ return;
268
+ }
233
269
  if (isBackgroundQueryMessage(msg)) {
234
270
  handleBackgroundQueryMessage(session, deliver, msg);
235
271
  return;
@@ -68,6 +68,8 @@ export interface ISessionResumeBridgeOptions {
68
68
  readonly storedSessionUsageReporter?: IUsageQueryReporters['storedSessionUsageReporter'];
69
69
  /** #3282 §4 part b-2: this bridge is the reconnectable WebRTC path, so it is always `'remote'`. */
70
70
  readonly commandSurfaceLocality?: TCommandSurfaceLocality;
71
+ /** See {@link ISessionMessageHandlerOptions.maxFrameBytes}: the channel's limit, not the inbound one. */
72
+ readonly maxFrameBytes?: number;
71
73
  }
72
74
 
73
75
  export class SessionResumeBridge {
@@ -77,6 +79,13 @@ export class SessionResumeBridge {
77
79
  private readonly reporters: IUsageQueryReporters;
78
80
  private readonly surface?: TUsageSurface;
79
81
  private readonly commandSurfaceLocality?: TCommandSurfaceLocality;
82
+ private readonly maxFrameBytes?: number;
83
+ /**
84
+ * Ends with the bridge, not with a channel: a channel drop keeps the session reconnectable, so work
85
+ * started for a hosted app view (a call waiting on its prompt) survives it and is cancelled only when
86
+ * the host gives up on the session.
87
+ */
88
+ private readonly lifetime = new AbortController();
80
89
  private readonly unsubscribe: () => void;
81
90
  private onDeliveryError?: IAttachOptions['onDeliveryError'];
82
91
  /**
@@ -110,6 +119,7 @@ export class SessionResumeBridge {
110
119
  this.driverId = options.driverId;
111
120
  this.surface = options.surface;
112
121
  this.commandSurfaceLocality = options.commandSurfaceLocality;
122
+ this.maxFrameBytes = options.maxFrameBytes;
113
123
  this.reporters = {
114
124
  personalUsageReporter: options.personalUsageReporter,
115
125
  usageReporter: options.usageReporter,
@@ -186,6 +196,10 @@ export class SessionResumeBridge {
186
196
  undefined,
187
197
  undefined,
188
198
  this.commandSurfaceLocality,
199
+ {
200
+ signal: this.lifetime.signal,
201
+ ...(this.maxFrameBytes !== undefined ? { maxFrameBytes: this.maxFrameBytes } : {}),
202
+ },
189
203
  );
190
204
  }
191
205
 
@@ -193,6 +207,7 @@ export class SessionResumeBridge {
193
207
  public dispose(): void {
194
208
  if (this.disposed) return;
195
209
  this.disposed = true;
210
+ this.lifetime.abort();
196
211
  // Through `detach`, not a field assignment: the boundary's closure is what holds the channel, so
197
212
  // clearing anything else released nothing and left teardown documented but not performed.
198
213
  this.detach();
@@ -1,3 +1,9 @@
1
+ import type {
2
+ IToolAppView,
3
+ IToolExecutionResult,
4
+ TToolParameters,
5
+ TUniversalMessagePart,
6
+ } from '@robota-sdk/agent-core';
1
7
  import type {
2
8
  IPersonalUsageReport,
3
9
  IPersonalUsageRequest,
@@ -157,6 +163,23 @@ export type TClientMessage =
157
163
  // The Project panel's "Memory" section — project memory as readable text. Answered by
158
164
  // `project_memory`.
159
165
  | { type: 'project-memory'; requestId: string }
166
+ // A driving surface invokes one runtime tool for a hosted app view, through the session's
167
+ // permission gate; a prompt it raises is attributed to that surface's driver. `viewId` names the
168
+ // view (from a `tool_end`'s `appView`) and limits the call to the tools that view's own source
169
+ // declared. Answered by `app_tool_result`; a call that decoded but is refused (the view is gone,
170
+ // the tool is not the view's, the invocation is refused) is answered by `protocol_error` echoing
171
+ // `requestId`. A frame that does not decode, or one an observer sends, carries no `requestId`.
172
+ | {
173
+ type: 'call-app-tool';
174
+ requestId: string;
175
+ viewId: string;
176
+ name: string;
177
+ arguments: TToolParameters;
178
+ }
179
+ // A driving surface reads the view a finished model call named in its `tool_end` (`appView`).
180
+ // Answered by `app_view`; a read that decoded but cannot be served (gone, or too large to send)
181
+ // is answered by `protocol_error` echoing `requestId`.
182
+ | { type: 'read-app-view'; requestId: string; viewId: string }
160
183
  | { type: 'get-background-tasks'; filter?: IBackgroundTaskListFilter }
161
184
  | { type: 'get-background-task'; taskId: string }
162
185
  | { type: 'get-background-job-groups' }
@@ -338,6 +361,18 @@ export type TServerMessage =
338
361
  | { type: 'project_status'; requestId: string; result: TProjectStatusRead }
339
362
  | { type: 'project_diff'; requestId: string; result: TProjectDiffRead }
340
363
  | { type: 'project_memory'; requestId: string; result: TProjectMemoryRead }
364
+ // The outcome of a `call-app-tool`, correlated by `requestId`: the tool's own result, as the
365
+ // session returned it.
366
+ | {
367
+ type: 'app_tool_result';
368
+ requestId: string;
369
+ success: boolean;
370
+ data?: IToolExecutionResult['result'];
371
+ parts?: TUniversalMessagePart[];
372
+ error?: string;
373
+ }
374
+ // The answer to a `read-app-view`: the whole document, to the surface that asked and no other.
375
+ | { type: 'app_view'; requestId: string; view: IToolAppView }
341
376
  | { type: 'background_task_event'; event: TBackgroundTaskEvent }
342
377
  | { type: 'background_job_group_event'; event: TBackgroundJobGroupEvent }
343
378
  | { type: 'plan_event'; event: IPlanApprovalEvent }