@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,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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRANS-008 (issue #2045) — a `submit` prompt that is not a string must not reach the session.
|
|
3
|
+
*
|
|
4
|
+
* `parseClientMessage` does `JSON.parse(data) as TClientMessage`, and the only guard in the submit
|
|
5
|
+
* handler is `if (!msg.prompt)`. That is a FALSY check, not a type check: `{}`, `[]` and `42` are
|
|
6
|
+
* truthy, so they pass it and reach `session.submit(input: string)`.
|
|
7
|
+
*
|
|
8
|
+
* Why this one field and not the other thirteen with payloads: the value does not stop at the
|
|
9
|
+
* session. `interactive-session-execution-controller.ts:249` does
|
|
10
|
+
* `emit('user_message', displayInput ?? input)`, `session-events.ts` classifies `user_message` as
|
|
11
|
+
* `'forwarded'` and delivers `{ type: 'user_message', content, … }` — declared `content: string` —
|
|
12
|
+
* to EVERY attached client, not only the sender. Both client cast sites (`ws-session-client.ts:66`,
|
|
13
|
+
* `rtc-responder-gate.ts:118`) accept it with `as TServerMessage`, so nothing on the receiving side
|
|
14
|
+
* rejects it either.
|
|
15
|
+
*
|
|
16
|
+
* So one unguarded field lets a client put a shape-invalid value into a typed frame delivered to its
|
|
17
|
+
* peers. The permission and resume paths were measured too and do NOT cross that way: `interpretApproval`
|
|
18
|
+
* compares by strict equality against a closed set and defaults to deny, and a malformed `ack`/`resume`
|
|
19
|
+
* seq degrades only the sender's own attachment.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
23
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
24
|
+
|
|
25
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
26
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
27
|
+
|
|
28
|
+
import type { TServerMessage } from '../wire-messages.js';
|
|
29
|
+
|
|
30
|
+
function setup() {
|
|
31
|
+
const submit = vi.fn().mockResolvedValue({ turnId: 't1', completed: Promise.resolve() });
|
|
32
|
+
// The published conformant double, not a cast: a hand-rolled object asserted to be an
|
|
33
|
+
// `IInteractiveSession` is a partial re-implementation nothing checks against the real contract.
|
|
34
|
+
const session = Object.assign(createTestInteractiveSession(), { submit });
|
|
35
|
+
const sent: TServerMessage[] = [];
|
|
36
|
+
const { onMessage } = createSessionMessageHandler({
|
|
37
|
+
session,
|
|
38
|
+
deliver: createOutboundDelivery((msg) => sent.push(msg), vi.fn()),
|
|
39
|
+
});
|
|
40
|
+
return { submit, sent, onMessage };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('TRANS-008: a non-string submit prompt is refused at the ingress', () => {
|
|
44
|
+
it.each([
|
|
45
|
+
['an object', {}],
|
|
46
|
+
['an array', []],
|
|
47
|
+
['a number', 42],
|
|
48
|
+
['a boolean', true],
|
|
49
|
+
])('refuses %s — it is truthy, so the falsy guard passes it', (_label, prompt) => {
|
|
50
|
+
const { submit, sent, onMessage } = setup();
|
|
51
|
+
|
|
52
|
+
onMessage(JSON.stringify({ type: 'submit', prompt }));
|
|
53
|
+
|
|
54
|
+
// The session must never see it: once it does, the value is re-emitted as `user_message` to
|
|
55
|
+
// every attached client inside a frame whose `content` is declared `string`.
|
|
56
|
+
expect(submit).not.toHaveBeenCalled();
|
|
57
|
+
expect(sent.some((msg) => msg.type === 'protocol_error')).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('still refuses an EMPTY prompt, which the falsy guard already caught', () => {
|
|
61
|
+
// The control for the guard that exists. A type check that replaced it would otherwise let
|
|
62
|
+
// `''` through — a regression hidden by the new assertions above.
|
|
63
|
+
const { submit, sent, onMessage } = setup();
|
|
64
|
+
|
|
65
|
+
onMessage(JSON.stringify({ type: 'submit', prompt: '' }));
|
|
66
|
+
|
|
67
|
+
expect(submit).not.toHaveBeenCalled();
|
|
68
|
+
expect(sent.some((msg) => msg.type === 'protocol_error')).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('still accepts a real string prompt, so the refusal is about shape and not about submitting', () => {
|
|
72
|
+
const { submit, onMessage } = setup();
|
|
73
|
+
|
|
74
|
+
onMessage(JSON.stringify({ type: 'submit', prompt: 'hello' }));
|
|
75
|
+
|
|
76
|
+
expect(submit).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
5
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
6
|
+
import type { TServerMessage } from '../wire-messages.js';
|
|
7
|
+
import type { IUsageBySourceReport } from '@robota-sdk/agent-interface-analytics';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SELFHOST-004 P5 (TC-08): the new `usage_report` TServerMessage variant carries the assembled
|
|
11
|
+
* trace/cost read-model (per-operation span timeline + cost-by-source) across the sidecar boundary.
|
|
12
|
+
* No pre-existing variant carries per-op `durationMs` or per-source `costUsd`; this test proves the
|
|
13
|
+
* carrier is well-typed AND survives JSON serialization (the WS stream is JSON) so the timeline/cost
|
|
14
|
+
* actually reaches the GUI — not assumed "free".
|
|
15
|
+
*/
|
|
16
|
+
describe('SELFHOST-004 TC-08 — usage_report server-message carrier', () => {
|
|
17
|
+
const report: IUsageBySourceReport = {
|
|
18
|
+
sessionId: 'sess-1',
|
|
19
|
+
totalTokens: 300,
|
|
20
|
+
promptTokens: 200,
|
|
21
|
+
completionTokens: 100,
|
|
22
|
+
costUsd: 0.0015,
|
|
23
|
+
costExact: true,
|
|
24
|
+
bySource: [
|
|
25
|
+
{
|
|
26
|
+
key: 'main:',
|
|
27
|
+
source: { scope: 'main', label: 'main thread' },
|
|
28
|
+
label: 'main thread',
|
|
29
|
+
promptTokens: 200,
|
|
30
|
+
completionTokens: 100,
|
|
31
|
+
totalTokens: 300,
|
|
32
|
+
turns: 1,
|
|
33
|
+
percentage: 100,
|
|
34
|
+
costUsd: 0.0015,
|
|
35
|
+
costExact: true,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
timeline: [
|
|
39
|
+
{
|
|
40
|
+
turnIndex: 0,
|
|
41
|
+
source: { scope: 'main', label: 'main thread' },
|
|
42
|
+
label: 'main thread',
|
|
43
|
+
spans: [
|
|
44
|
+
{ spanId: 'span_a', op: 'read', durationMs: 5 },
|
|
45
|
+
{ spanId: 'span_b', op: 'grep', durationMs: 7 },
|
|
46
|
+
],
|
|
47
|
+
totalDurationMs: 12,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
it('is assignable to TServerMessage and carries per-op durationMs + per-source costUsd together', () => {
|
|
53
|
+
const message: TServerMessage = { type: 'usage_report', report };
|
|
54
|
+
|
|
55
|
+
// narrow by discriminant — the carrier’s payload is the assembled read-model
|
|
56
|
+
expect(message.type).toBe('usage_report');
|
|
57
|
+
if (message.type !== 'usage_report') throw new Error('unreachable');
|
|
58
|
+
|
|
59
|
+
// per-op timing rides the carrier (the datum no prior variant carried)
|
|
60
|
+
expect(message.report.timeline[0]?.spans.map((s) => s.durationMs)).toEqual([5, 7]);
|
|
61
|
+
// per-source cost rides the carrier
|
|
62
|
+
expect(message.report.bySource[0]?.costUsd).toBeCloseTo(0.0015, 6);
|
|
63
|
+
expect(message.report.costUsd).toBeCloseTo(0.0015, 6);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('survives JSON round-trip (the WS stream) with the timeline + cost intact', () => {
|
|
67
|
+
const message: TServerMessage = { type: 'usage_report', report };
|
|
68
|
+
|
|
69
|
+
const wire = JSON.parse(JSON.stringify(message)) as TServerMessage;
|
|
70
|
+
expect(wire).toEqual(message);
|
|
71
|
+
if (wire.type !== 'usage_report') throw new Error('unreachable');
|
|
72
|
+
expect(wire.report.timeline[0]?.totalDurationMs).toBe(12);
|
|
73
|
+
expect(wire.report.bySource[0]?.costUsd).toBeCloseTo(0.0015, 6);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('requests are expressible via the get-usage-report client message', () => {
|
|
77
|
+
// compile-time proof the request variant exists (the GUI asks for the report)
|
|
78
|
+
const req = { type: 'get-usage-report' } as const;
|
|
79
|
+
expect(req.type).toBe('get-usage-report');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('routes the request through the host producer and delivers the report', async () => {
|
|
83
|
+
const sent: TServerMessage[] = [];
|
|
84
|
+
const reporter = vi.fn().mockReturnValue(report);
|
|
85
|
+
const session = createTestInteractiveSession();
|
|
86
|
+
const { onMessage } = createSessionMessageHandler({
|
|
87
|
+
session,
|
|
88
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
89
|
+
usageReporter: reporter,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
onMessage(JSON.stringify({ type: 'get-usage-report' }));
|
|
93
|
+
await Promise.resolve();
|
|
94
|
+
|
|
95
|
+
expect(reporter).toHaveBeenCalledWith(session);
|
|
96
|
+
expect(sent).toContainEqual({ type: 'usage_report', report });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMD-004 Phase 2 Stage E — broadcast session events over the WS transport.
|
|
3
|
+
*
|
|
4
|
+
* The spec's final carriers: `session_renamed` and `history_cleared` are BROADCAST session events —
|
|
5
|
+
* every attached surface (including co-driving ones) receives them, so titles update and transcripts
|
|
6
|
+
* refresh everywhere. Unlike `ui_intent` (requester-routed), these are never filtered by driver id.
|
|
7
|
+
*
|
|
8
|
+
* Red-first evidence: against pre-Stage-E code neither event is forwarded by
|
|
9
|
+
* `subscribeSessionEvents` at all — a co-driving surface's title/transcript went stale.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
15
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
16
|
+
|
|
17
|
+
import type { TServerMessage } from '../wire-messages.js';
|
|
18
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
19
|
+
|
|
20
|
+
type TEmittingSession = IInteractiveSession & {
|
|
21
|
+
_emit: (event: string, ...args: unknown[]) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function createMockSession(): TEmittingSession {
|
|
25
|
+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
|
26
|
+
return {
|
|
27
|
+
// ARCH-012: required. This double feeds `subscribeSessionEvents`, which calls it on every
|
|
28
|
+
// turn-authored event — omitting it throws the moment a case emits one.
|
|
29
|
+
getActiveDriverId: () => null,
|
|
30
|
+
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
31
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
32
|
+
listeners.get(event)!.add(handler);
|
|
33
|
+
}),
|
|
34
|
+
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
35
|
+
listeners.get(event)?.delete(handler);
|
|
36
|
+
}),
|
|
37
|
+
_emit: (event: string, ...args: unknown[]) => {
|
|
38
|
+
listeners.get(event)?.forEach((h) => h(...args));
|
|
39
|
+
},
|
|
40
|
+
} as unknown as TEmittingSession;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Two real WS handlers (surfaces) over ONE session — the co-driving topology. */
|
|
44
|
+
function setupTwoSurfaces(): {
|
|
45
|
+
session: TEmittingSession;
|
|
46
|
+
sentA: TServerMessage[];
|
|
47
|
+
sentB: TServerMessage[];
|
|
48
|
+
} {
|
|
49
|
+
const session = createMockSession();
|
|
50
|
+
const sentA: TServerMessage[] = [];
|
|
51
|
+
const sentB: TServerMessage[] = [];
|
|
52
|
+
createSessionMessageHandler({
|
|
53
|
+
session,
|
|
54
|
+
deliver: createOutboundDelivery((m) => sentA.push(m), vi.fn()),
|
|
55
|
+
driverId: 'device-A',
|
|
56
|
+
});
|
|
57
|
+
createSessionMessageHandler({
|
|
58
|
+
session,
|
|
59
|
+
deliver: createOutboundDelivery((m) => sentB.push(m), vi.fn()),
|
|
60
|
+
driverId: 'device-B',
|
|
61
|
+
});
|
|
62
|
+
return { session, sentA, sentB };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('CMD-004 Stage E — session_renamed / history_cleared broadcast to EVERY surface', () => {
|
|
66
|
+
it('session_renamed reaches both attached surfaces (titles update everywhere)', () => {
|
|
67
|
+
const { session, sentA, sentB } = setupTwoSurfaces();
|
|
68
|
+
|
|
69
|
+
session._emit('session_renamed', { name: 'Renamed Everywhere' });
|
|
70
|
+
|
|
71
|
+
const expected = { type: 'session_renamed', event: { name: 'Renamed Everywhere' } };
|
|
72
|
+
expect(sentA).toEqual([expected]);
|
|
73
|
+
expect(sentB).toEqual([expected]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('history_cleared reaches both attached surfaces (transcripts refresh everywhere)', () => {
|
|
77
|
+
const { session, sentA, sentB } = setupTwoSurfaces();
|
|
78
|
+
|
|
79
|
+
session._emit('history_cleared');
|
|
80
|
+
|
|
81
|
+
expect(sentA).toEqual([{ type: 'history_cleared' }]);
|
|
82
|
+
expect(sentB).toEqual([{ type: 'history_cleared' }]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('#3189: session_switched reaches both attached surfaces (each re-reads the new session)', () => {
|
|
86
|
+
const { session, sentA, sentB } = setupTwoSurfaces();
|
|
87
|
+
|
|
88
|
+
session._emit('session_switched', { sessionId: 'session-2' });
|
|
89
|
+
|
|
90
|
+
const expected = { type: 'session_switched', event: { sessionId: 'session-2' } };
|
|
91
|
+
expect(sentA).toEqual([expected]);
|
|
92
|
+
expect(sentB).toEqual([expected]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
2
|
+
import type { IProtocolSession } from './protocol-session.js';
|
|
3
|
+
import type { TBackgroundControlAction, TClientMessage } from './wire-messages.js';
|
|
4
|
+
|
|
5
|
+
export function handleBackgroundQueryMessage(
|
|
6
|
+
session: IProtocolSession,
|
|
7
|
+
deliver: TOutboundDeliver,
|
|
8
|
+
msg: Extract<
|
|
9
|
+
TClientMessage,
|
|
10
|
+
| { type: 'get-background-tasks' | 'get-background-task' | 'read-background-task-log' }
|
|
11
|
+
| {
|
|
12
|
+
type:
|
|
13
|
+
'get-background-job-groups' | 'get-background-job-group' | 'wait-background-job-group';
|
|
14
|
+
}
|
|
15
|
+
>,
|
|
16
|
+
): void {
|
|
17
|
+
if (msg.type === 'get-background-tasks') {
|
|
18
|
+
deliver({ type: 'background_tasks', tasks: session.listBackgroundTasks(msg.filter) });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (msg.type === 'get-background-task') {
|
|
22
|
+
sendBackgroundTaskSnapshot(session, deliver, msg);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (msg.type === 'get-background-job-groups') {
|
|
26
|
+
deliver({ type: 'background_job_groups', groups: session.listBackgroundJobGroups() });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (msg.type === 'get-background-job-group') {
|
|
30
|
+
sendBackgroundJobGroupSnapshot(session, deliver, msg);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (msg.type === 'wait-background-job-group') {
|
|
34
|
+
sendBackgroundJobGroupWaitResult(session, deliver, msg);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
sendBackgroundTaskLogPage(session, deliver, msg);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function handleBackgroundControlMessage(
|
|
41
|
+
session: IProtocolSession,
|
|
42
|
+
deliver: TOutboundDeliver,
|
|
43
|
+
msg: Extract<
|
|
44
|
+
TClientMessage,
|
|
45
|
+
{ type: 'cancel-background-task' | 'close-background-task' | 'send-background-task' }
|
|
46
|
+
>,
|
|
47
|
+
): void {
|
|
48
|
+
if (!msg.taskId) {
|
|
49
|
+
deliver({ type: 'protocol_error', message: 'taskId is required' });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (msg.type === 'cancel-background-task') {
|
|
53
|
+
sendBackgroundTaskControlResult(
|
|
54
|
+
deliver,
|
|
55
|
+
'cancel',
|
|
56
|
+
msg.taskId,
|
|
57
|
+
session.cancelBackgroundTask(msg.taskId, msg.reason),
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (msg.type === 'close-background-task') {
|
|
62
|
+
sendBackgroundTaskControlResult(
|
|
63
|
+
deliver,
|
|
64
|
+
'close',
|
|
65
|
+
msg.taskId,
|
|
66
|
+
session.closeBackgroundTask(msg.taskId),
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
sendBackgroundTaskInput(session, deliver, msg);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sendBackgroundTaskSnapshot(
|
|
74
|
+
session: IProtocolSession,
|
|
75
|
+
deliver: TOutboundDeliver,
|
|
76
|
+
msg: Extract<TClientMessage, { type: 'get-background-task' }>,
|
|
77
|
+
): void {
|
|
78
|
+
if (!msg.taskId) {
|
|
79
|
+
deliver({ type: 'protocol_error', message: 'taskId is required' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
deliver({
|
|
83
|
+
type: 'background_task',
|
|
84
|
+
taskId: msg.taskId,
|
|
85
|
+
task: session.getBackgroundTask(msg.taskId) ?? null,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sendBackgroundTaskLogPage(
|
|
90
|
+
session: IProtocolSession,
|
|
91
|
+
deliver: TOutboundDeliver,
|
|
92
|
+
msg: Extract<TClientMessage, { type: 'read-background-task-log' }>,
|
|
93
|
+
): void {
|
|
94
|
+
if (!msg.taskId) {
|
|
95
|
+
deliver({ type: 'protocol_error', message: 'taskId is required' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
session.readBackgroundTaskLog(msg.taskId, msg.cursor).then(
|
|
99
|
+
(page) => deliver({ type: 'background_task_log', taskId: msg.taskId, page }),
|
|
100
|
+
(error: Error) => deliver({ type: 'protocol_error', message: error.message }),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sendBackgroundJobGroupSnapshot(
|
|
105
|
+
session: IProtocolSession,
|
|
106
|
+
deliver: TOutboundDeliver,
|
|
107
|
+
msg: Extract<TClientMessage, { type: 'get-background-job-group' }>,
|
|
108
|
+
): void {
|
|
109
|
+
if (!msg.groupId) {
|
|
110
|
+
deliver({ type: 'protocol_error', message: 'groupId is required' });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
deliver({
|
|
114
|
+
type: 'background_job_group',
|
|
115
|
+
groupId: msg.groupId,
|
|
116
|
+
group: session.getBackgroundJobGroup(msg.groupId) ?? null,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sendBackgroundJobGroupWaitResult(
|
|
121
|
+
session: IProtocolSession,
|
|
122
|
+
deliver: TOutboundDeliver,
|
|
123
|
+
msg: Extract<TClientMessage, { type: 'wait-background-job-group' }>,
|
|
124
|
+
): void {
|
|
125
|
+
if (!msg.groupId) {
|
|
126
|
+
deliver({ type: 'protocol_error', message: 'groupId is required' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
session.waitBackgroundJobGroup(msg.groupId).then(
|
|
130
|
+
(group) => deliver({ type: 'background_job_group', groupId: msg.groupId, group }),
|
|
131
|
+
(error: Error) => deliver({ type: 'protocol_error', message: error.message }),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sendBackgroundTaskInput(
|
|
136
|
+
session: IProtocolSession,
|
|
137
|
+
deliver: TOutboundDeliver,
|
|
138
|
+
msg: Extract<TClientMessage, { type: 'send-background-task' }>,
|
|
139
|
+
): void {
|
|
140
|
+
if (!msg.input) {
|
|
141
|
+
deliver({ type: 'protocol_error', message: 'input is required' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
sendBackgroundTaskControlResult(
|
|
145
|
+
deliver,
|
|
146
|
+
'send',
|
|
147
|
+
msg.taskId,
|
|
148
|
+
session.sendBackgroundTask(msg.taskId, msg.input),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sendBackgroundTaskControlResult(
|
|
153
|
+
deliver: TOutboundDeliver,
|
|
154
|
+
action: TBackgroundControlAction,
|
|
155
|
+
taskId: string,
|
|
156
|
+
operation: Promise<void>,
|
|
157
|
+
): void {
|
|
158
|
+
operation.then(
|
|
159
|
+
() => deliver({ type: 'background_task_control_result', action, taskId, success: true }),
|
|
160
|
+
(error: Error) =>
|
|
161
|
+
deliver({
|
|
162
|
+
type: 'background_task_control_result',
|
|
163
|
+
action,
|
|
164
|
+
taskId,
|
|
165
|
+
success: false,
|
|
166
|
+
message: error.message,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|