@robota-sdk/agent-transport 3.0.0-beta.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1137 -0
- package/LICENSE +661 -0
- package/README.md +121 -0
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +624 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
- package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
- package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1115 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1115 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +317 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +317 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +122 -0
- package/src/__tests__/__fixtures__/korean-text.json +6 -0
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/app-tool-messages.test.ts +463 -0
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/channel-frames.test.ts +215 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/file-transfer.test.ts +281 -0
- package/src/__tests__/handoff-chunking.test.ts +261 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +870 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/permission-summary.test.ts +18 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +322 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-history-wire.test.ts +364 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler-observe.test.ts +223 -0
- package/src/__tests__/session-message-handler.test.ts +726 -0
- package/src/__tests__/session-resume-bridge.test.ts +472 -0
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/stdio-carrier.test.ts +85 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +94 -0
- package/src/app-tool-messages.ts +140 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +17 -0
- package/src/handoff-chunking.ts +275 -0
- package/src/index.ts +135 -0
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +485 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/bearer-resource-gate.ts +331 -0
- package/src/node/file-transfer.ts +530 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +45 -0
- package/src/node/stdio-carrier.ts +59 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +240 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/permission-summary.ts +18 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +56 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +290 -0
- package/src/session-message-handler.ts +413 -0
- package/src/session-query-messages.ts +160 -0
- package/src/session-resume-bridge.ts +275 -0
- package/src/settings-messages.ts +103 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +436 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-008 — the admission seam's own cases.
|
|
3
|
+
*
|
|
4
|
+
* `resolveAdmission` had none. It was exercised only THROUGH the four transports that call it, and
|
|
5
|
+
* both defects review found here are ones that indirection hides: a transport passing a well-formed
|
|
6
|
+
* config never exhibits either.
|
|
7
|
+
*
|
|
8
|
+
* The seam's whole claim is "one place to read and one place to change". A seam nobody tests
|
|
9
|
+
* directly is one place to read and four places to find out you were wrong.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { bearerCredential, resolveAdmission } from '../node/admission.js';
|
|
15
|
+
|
|
16
|
+
const OPEN_REASON = 'loopback only, the port is bound to 127.0.0.1';
|
|
17
|
+
|
|
18
|
+
describe('resolveAdmission is secure by default', () => {
|
|
19
|
+
it('mints when nothing is asked for', () => {
|
|
20
|
+
const admission = resolveAdmission();
|
|
21
|
+
|
|
22
|
+
expect(admission.token).toMatch(/^[0-9a-f]{64}$/);
|
|
23
|
+
expect(admission.openReason).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps an explicit token', () => {
|
|
27
|
+
expect(resolveAdmission({ token: 'chosen-by-the-host' })).toEqual({
|
|
28
|
+
token: 'chosen-by-the-host',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('opens only with a reason', () => {
|
|
33
|
+
expect(resolveAdmission({ open: true, openReason: OPEN_REASON })).toEqual({
|
|
34
|
+
token: null,
|
|
35
|
+
openReason: OPEN_REASON,
|
|
36
|
+
});
|
|
37
|
+
expect(() => resolveAdmission({ open: true })).toThrow(/requires `openReason`/);
|
|
38
|
+
expect(() => resolveAdmission({ open: true, openReason: ' ' })).toThrow(
|
|
39
|
+
/requires `openReason`/,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('MINTS for an empty-string token rather than requiring the empty string', () => {
|
|
44
|
+
// The value behind the discriminator defect. `{ token: '' }` is documented as "mint a fresh
|
|
45
|
+
// one", and the HTTP route's `'token' in options.admission` read it as ALREADY RESOLVED and
|
|
46
|
+
// installed the empty string as the required credential — which a peer sending an empty bearer
|
|
47
|
+
// then matches. The route no longer discriminates; this pins the value it depends on.
|
|
48
|
+
expect(resolveAdmission({ token: '' }).token).toMatch(/^[0-9a-f]{64}$/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('resolveAdmission is idempotent', () => {
|
|
53
|
+
// Why it must be: the HTTP route accepts either shape, and BOTH interfaces declare a `token`, so
|
|
54
|
+
// they cannot be told apart structurally. Making the function accept its own output deletes that
|
|
55
|
+
// question instead of answering it wrongly.
|
|
56
|
+
it('returns an already-resolved token admission unchanged', () => {
|
|
57
|
+
const resolved = resolveAdmission({ token: 'chosen-by-the-host' });
|
|
58
|
+
|
|
59
|
+
expect(resolveAdmission(resolved)).toEqual(resolved);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns an already-resolved OPEN admission unchanged, reason and all', () => {
|
|
63
|
+
// The case that made the old code wrong in the dangerous direction: re-resolving an open
|
|
64
|
+
// admission through a config-shaped path either minted a token nobody holds or dropped the
|
|
65
|
+
// reason and left an unexplained open transport.
|
|
66
|
+
const resolved = resolveAdmission({ open: true, openReason: OPEN_REASON });
|
|
67
|
+
|
|
68
|
+
expect(resolved).toEqual({ token: null, openReason: OPEN_REASON });
|
|
69
|
+
expect(resolveAdmission(resolved)).toEqual(resolved);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('REFUSES an open admission that arrives without its reason', () => {
|
|
73
|
+
// `token: null` is the recorded outcome of a decision. One reaching here with no reason was
|
|
74
|
+
// hand-built or corrupted, and both are the unexplained-open state the reason exists to
|
|
75
|
+
// prevent — so it is refused rather than passed through.
|
|
76
|
+
expect(() => resolveAdmission({ token: null })).toThrow(/requires `openReason`/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('resolveAdmission refuses a contradiction', () => {
|
|
81
|
+
it('throws when a token and `open: true` are asked for together', () => {
|
|
82
|
+
// Review: `WebRtcTransport` already refused the analogous `secret` + `open` pair in its own
|
|
83
|
+
// constructor, so only WebRTC callers were protected. The old seam gave silent precedence to
|
|
84
|
+
// the token — the safe DIRECTION, but the wrong ANSWER: a caller that wrote both does not know
|
|
85
|
+
// what it is asking for, and one of the two things it believes is false.
|
|
86
|
+
expect(() =>
|
|
87
|
+
resolveAdmission({ token: 'a-credential', open: true, openReason: OPEN_REASON }),
|
|
88
|
+
).toThrow(/contradictory/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('still accepts `open: false` beside a token, which is not a contradiction', () => {
|
|
92
|
+
// `open: false` says the same thing the token does. Refusing it would turn a redundant but
|
|
93
|
+
// coherent config into an error, which is over-blocking for nothing.
|
|
94
|
+
expect(resolveAdmission({ token: 'a-credential', open: false })).toEqual({
|
|
95
|
+
token: 'a-credential',
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('bearerCredential reads the header the way RFC 7235 defines it', () => {
|
|
101
|
+
it('matches the SCHEME without regard to case', () => {
|
|
102
|
+
// `auth-scheme` is a token, and tokens compare case-insensitively. The exact-case match refused
|
|
103
|
+
// a conformant client with a 401 it could not act on — a refusal that reads as a server bug and
|
|
104
|
+
// gets worked around rather than fixed. Review found it.
|
|
105
|
+
expect(bearerCredential('bearer abc123')).toBe('abc123');
|
|
106
|
+
expect(bearerCredential('BEARER abc123')).toBe('abc123');
|
|
107
|
+
expect(bearerCredential('BeArEr abc123')).toBe('abc123');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('leaves the CREDENTIAL exactly as sent', () => {
|
|
111
|
+
// Only the scheme is case-insensitive. The credential is compared byte for byte, so folding it
|
|
112
|
+
// would make two different secrets the same secret.
|
|
113
|
+
expect(bearerCredential('Bearer AbC')).toBe('AbC');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('accepts more than one space between the two', () => {
|
|
117
|
+
expect(bearerCredential('Bearer abc123')).toBe('abc123');
|
|
118
|
+
expect(bearerCredential('Bearer\tabc123')).toBe('abc123');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('refuses a header that merely STARTS with the word', () => {
|
|
122
|
+
// `Bearerness xyz` is a different scheme, and reading it as a bearer token would admit a peer
|
|
123
|
+
// that never claimed to be one.
|
|
124
|
+
expect(bearerCredential('Bearerness xyz')).toBeUndefined();
|
|
125
|
+
expect(bearerCredential('Basic abc123')).toBeUndefined();
|
|
126
|
+
expect(bearerCredential('Bearer')).toBeUndefined();
|
|
127
|
+
expect(bearerCredential('Bearer ')).toBeUndefined();
|
|
128
|
+
expect(bearerCredential(undefined)).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -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
|
+
});
|