@robota-sdk/agent-transport 3.0.0-beta.78 → 3.0.0-beta.81
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/README.md +84 -26
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +520 -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/message-decoders-BB8n-H2X.d.ts +252 -0
- package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
- package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
- package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +766 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +765 -27
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +109 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +109 -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 +68 -47
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/channel-frames.test.ts +214 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/handoff-chunking.test.ts +215 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/message-decoders.test.ts +249 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler.test.ts +590 -0
- package/src/__tests__/session-resume-bridge.test.ts +406 -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 +84 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +9 -0
- package/src/handoff-chunking.ts +235 -0
- package/src/index.ts +112 -3
- package/src/message-decoders.ts +229 -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/handoff-manifest.ts +77 -0
- package/src/node/index.ts +10 -0
- package/src/outbound-delivery.ts +231 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/protocol-session.ts +28 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-events.ts +198 -0
- package/src/session-message-handler.ts +282 -0
- package/src/session-query-messages.ts +42 -0
- package/src/session-resume-bridge.ts +252 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +165 -0
- package/dist/node/headless/index.cjs +0 -1
- package/dist/node/headless/index.d.ts +0 -2
- package/dist/node/headless/index.js +0 -1
- package/dist/node/headless-DG_MEbf0.js +0 -19
- package/dist/node/headless-DG_MEbf0.js.map +0 -1
- package/dist/node/headless-xq77JBq0.cjs +0 -18
- package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
- package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
- package/dist/node/index-JIvzrtaA.d.ts +0 -52
- package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
- package/dist/node/index-JTD_xtEQ.d.ts +0 -96
- package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
- package/dist/node/index-y_HBOfzO.d.ts +0 -52
- package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
- package/dist/node/programmatic/index.cjs +0 -1
- package/dist/node/programmatic/index.d.ts +0 -2
- package/dist/node/programmatic/index.js +0 -1
- package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
- package/dist/node/programmatic-70dxIXtS.js +0 -2
- package/dist/node/programmatic-70dxIXtS.js.map +0 -1
- package/dist/node/testing/index.cjs +0 -1
- package/dist/node/testing/index.d.ts +0 -2
- package/dist/node/testing/index.js +0 -1
- package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
- package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
- package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
- package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
- package/src/headless/HeadlessInteractionChannel.ts +0 -129
- package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
- package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
- package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
- package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
- package/src/headless/__tests__/headless-runner.test.ts +0 -508
- package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
- package/src/headless/__tests__/headless-transport.test.ts +0 -267
- package/src/headless/cli-input.ts +0 -50
- package/src/headless/headless-runner.ts +0 -230
- package/src/headless/headless-stream-json.ts +0 -148
- package/src/headless/headless-transport.ts +0 -43
- package/src/headless/index.ts +0 -8
- package/src/headless/print-terminal.ts +0 -57
- package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
- package/src/programmatic/createProgrammaticAgent.ts +0 -71
- package/src/programmatic/index.ts +0 -5
- package/src/testing/index.ts +0 -10
- package/src/transport-registry.ts +0 -103
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createOutboundDelivery, DEFAULT_MAX_PENDING_BYTES } from '../outbound-delivery.js';
|
|
4
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
5
|
+
import { SessionResumeBridge } from '../session-resume-bridge.js';
|
|
6
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
7
|
+
|
|
8
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
9
|
+
import type { IPersonalUsageReport } from '@robota-sdk/agent-interface-analytics';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* REMOTE-013 E4 TC-02 — the persistent SessionResumeBridge: a monotonic seq CONTINUOUS across a
|
|
13
|
+
* detach/attach (channel drop), gap output captured while detached and replayed on `resume`, `ack` freeing the
|
|
14
|
+
* buffer, and — the regression guard — the WS `createSessionMessageHandler` path stamping NO seq.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function fakeSession(): {
|
|
18
|
+
session: IInteractiveSession;
|
|
19
|
+
fire: (event: string, arg: unknown) => void;
|
|
20
|
+
} {
|
|
21
|
+
const handlers = new Map<string, (arg: unknown) => void>();
|
|
22
|
+
// ARCH-012: the published conformant double, overridden where this case needs behaviour. The
|
|
23
|
+
// hand-rolled partial it replaces was `as unknown as IInteractiveSession` — a cast that hid four
|
|
24
|
+
// missing members, so this suite proved things the real contract does not permit.
|
|
25
|
+
const session = createTestInteractiveSession({
|
|
26
|
+
on: ((event: string, handler: (arg: unknown) => void) =>
|
|
27
|
+
handlers.set(event, handler)) as IInteractiveSession['on'],
|
|
28
|
+
off: ((event: string) => {
|
|
29
|
+
handlers.delete(event);
|
|
30
|
+
}) as IInteractiveSession['off'],
|
|
31
|
+
});
|
|
32
|
+
return { session, fire: (event, arg) => handlers.get(event)?.(arg) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function frames(sink: { calls: string[] }): { type: string; seq?: number; delta?: string }[] {
|
|
36
|
+
return sink.calls.map((c) => JSON.parse(c));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sink(): TResumeSinkStub {
|
|
40
|
+
const calls: string[] = [];
|
|
41
|
+
return Object.assign((data: string) => void calls.push(data), { calls });
|
|
42
|
+
}
|
|
43
|
+
type TResumeSinkStub = ((data: string) => void) & { calls: string[] };
|
|
44
|
+
const TEST_ATTACH_OPTIONS = { onDeliveryError: vi.fn() };
|
|
45
|
+
|
|
46
|
+
describe('SessionResumeBridge (REMOTE-013 TC-02)', () => {
|
|
47
|
+
it('keeps remote surface attribution separate from the paired device id', async () => {
|
|
48
|
+
const base = fakeSession().session;
|
|
49
|
+
const submit = vi.fn().mockResolvedValue(undefined);
|
|
50
|
+
const session = Object.assign(base, { submit });
|
|
51
|
+
const bridge = new SessionResumeBridge({
|
|
52
|
+
session,
|
|
53
|
+
driverId: 'device-sha256',
|
|
54
|
+
surface: 'remote',
|
|
55
|
+
});
|
|
56
|
+
bridge.attach(sink(), TEST_ATTACH_OPTIONS);
|
|
57
|
+
|
|
58
|
+
bridge.onClientMessage(JSON.stringify({ type: 'submit', prompt: 'paired turn' }));
|
|
59
|
+
await vi.waitFor(() => expect(submit).toHaveBeenCalled());
|
|
60
|
+
|
|
61
|
+
expect(submit).toHaveBeenCalledWith('paired turn', undefined, undefined, {
|
|
62
|
+
driverId: 'device-sha256',
|
|
63
|
+
surface: 'remote',
|
|
64
|
+
});
|
|
65
|
+
bridge.dispose();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps host usage reporting reachable across the resume bridge', async () => {
|
|
69
|
+
const { session } = fakeSession();
|
|
70
|
+
const report = { schemaVersion: 1 } as IPersonalUsageReport;
|
|
71
|
+
const personalUsageReporter = vi.fn().mockResolvedValue(report);
|
|
72
|
+
const bridge = new SessionResumeBridge({ session, personalUsageReporter });
|
|
73
|
+
const output = sink();
|
|
74
|
+
bridge.attach(output, TEST_ATTACH_OPTIONS);
|
|
75
|
+
|
|
76
|
+
bridge.onClientMessage(
|
|
77
|
+
JSON.stringify({
|
|
78
|
+
type: 'get-personal-usage-report',
|
|
79
|
+
requestId: 'resume-usage',
|
|
80
|
+
period: '7d',
|
|
81
|
+
timezone: 'UTC',
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
await vi.waitFor(() => expect(output.calls).toHaveLength(1));
|
|
85
|
+
|
|
86
|
+
expect(personalUsageReporter).toHaveBeenCalledWith({ period: '7d', timezone: 'UTC' });
|
|
87
|
+
expect(frames(output)[0]).toMatchObject({
|
|
88
|
+
type: 'personal_usage_report',
|
|
89
|
+
requestId: 'resume-usage',
|
|
90
|
+
report,
|
|
91
|
+
});
|
|
92
|
+
bridge.dispose();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('stamps a monotonic seq and forwards live to the attached sink', () => {
|
|
96
|
+
const { session, fire } = fakeSession();
|
|
97
|
+
const bridge = new SessionResumeBridge({ session });
|
|
98
|
+
const s = sink();
|
|
99
|
+
bridge.attach(s, TEST_ATTACH_OPTIONS);
|
|
100
|
+
fire('text_delta', 'a');
|
|
101
|
+
fire('text_delta', 'b');
|
|
102
|
+
expect(frames(s).map((f) => f.seq)).toEqual([1, 2]);
|
|
103
|
+
expect(frames(s).map((f) => f.delta)).toEqual(['a', 'b']);
|
|
104
|
+
bridge.dispose();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('the Issue-A guard: seq is CONTINUOUS across detach/attach; gap output is captured + replayed on resume', () => {
|
|
108
|
+
const { session, fire } = fakeSession();
|
|
109
|
+
const bridge = new SessionResumeBridge({ session });
|
|
110
|
+
|
|
111
|
+
const s1 = sink();
|
|
112
|
+
bridge.attach(s1, TEST_ATTACH_OPTIONS);
|
|
113
|
+
fire('text_delta', 'a'); // seq 1 → s1
|
|
114
|
+
expect(frames(s1).map((f) => f.seq)).toEqual([1]);
|
|
115
|
+
|
|
116
|
+
// Channel drops.
|
|
117
|
+
bridge.detach();
|
|
118
|
+
fire('text_delta', 'b'); // seq 2 — buffered, NOT sent (no sink)
|
|
119
|
+
fire('text_delta', 'c'); // seq 3 — buffered
|
|
120
|
+
expect(s1.calls).toHaveLength(1); // s1 got nothing during the gap
|
|
121
|
+
|
|
122
|
+
// New channel after reconnect. The client last applied seq 1 → asks for the tail.
|
|
123
|
+
const s2 = sink();
|
|
124
|
+
bridge.attach(s2, TEST_ATTACH_OPTIONS);
|
|
125
|
+
expect(s2.calls).toHaveLength(0); // attach does NOT auto-replay
|
|
126
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 1 }));
|
|
127
|
+
|
|
128
|
+
const replayed = frames(s2);
|
|
129
|
+
// seq did NOT reset to 1 on the new channel — the gap frames keep their original 2,3.
|
|
130
|
+
expect(replayed.map((f) => f.seq)).toEqual([2, 3]);
|
|
131
|
+
expect(replayed.map((f) => f.delta)).toEqual(['b', 'c']);
|
|
132
|
+
bridge.dispose();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('reconnect hold (Issue-B): a LIVE frame emitted between attach and resume does not leapfrog the gap', () => {
|
|
136
|
+
const { session, fire } = fakeSession();
|
|
137
|
+
const bridge = new SessionResumeBridge({ session });
|
|
138
|
+
const s1 = sink();
|
|
139
|
+
bridge.attach(s1, TEST_ATTACH_OPTIONS);
|
|
140
|
+
fire('text_delta', 'a'); // seq 1 → client applies through 1
|
|
141
|
+
|
|
142
|
+
// Drop; gap frames buffered.
|
|
143
|
+
bridge.detach();
|
|
144
|
+
fire('text_delta', 'b'); // seq 2 (gap)
|
|
145
|
+
fire('text_delta', 'c'); // seq 3 (gap)
|
|
146
|
+
|
|
147
|
+
// Reconnect attach with awaitResume → live forwarding is HELD until resume flushes the tail.
|
|
148
|
+
const s2 = sink();
|
|
149
|
+
bridge.attach(s2, { ...TEST_ATTACH_OPTIONS, awaitResume: true });
|
|
150
|
+
fire('text_delta', 'd'); // seq 4 — emitted BEFORE the client's resume arrives; must NOT be sent yet
|
|
151
|
+
expect(s2.calls).toHaveLength(0); // held
|
|
152
|
+
|
|
153
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 1 }));
|
|
154
|
+
// The client receives 2,3,4 IN ORDER (gap then the held live frame) — none lost, none reordered.
|
|
155
|
+
expect(frames(s2).map((f) => f.seq)).toEqual([2, 3, 4]);
|
|
156
|
+
expect(frames(s2).map((f) => f.delta)).toEqual(['b', 'c', 'd']);
|
|
157
|
+
|
|
158
|
+
// Hold released — a subsequent live frame flows immediately behind the flushed tail.
|
|
159
|
+
fire('text_delta', 'e'); // seq 5
|
|
160
|
+
expect(frames(s2).map((f) => f.seq)).toEqual([2, 3, 4, 5]);
|
|
161
|
+
bridge.dispose();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('ack frees the buffer up to seq; a later resume replays only the newer tail', () => {
|
|
165
|
+
const { session, fire } = fakeSession();
|
|
166
|
+
const bridge = new SessionResumeBridge({ session });
|
|
167
|
+
const s = sink();
|
|
168
|
+
bridge.attach(s, TEST_ATTACH_OPTIONS);
|
|
169
|
+
fire('text_delta', 'a'); // 1
|
|
170
|
+
fire('text_delta', 'b'); // 2
|
|
171
|
+
fire('text_delta', 'c'); // 3
|
|
172
|
+
bridge.onClientMessage(JSON.stringify({ type: 'ack', seq: 2 })); // free 1,2
|
|
173
|
+
|
|
174
|
+
const s2 = sink();
|
|
175
|
+
bridge.detach();
|
|
176
|
+
bridge.attach(s2, TEST_ATTACH_OPTIONS);
|
|
177
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 2 }));
|
|
178
|
+
expect(frames(s2).map((f) => f.seq)).toEqual([3]); // only the un-acked tail
|
|
179
|
+
bridge.dispose();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('resume with a lastSeq older than the retained buffer sends resume_gap (no silent gap)', () => {
|
|
183
|
+
const { session, fire } = fakeSession();
|
|
184
|
+
const bridge = new SessionResumeBridge({ session, buffer: { maxFrames: 2 } });
|
|
185
|
+
const s = sink();
|
|
186
|
+
bridge.attach(s, TEST_ATTACH_OPTIONS);
|
|
187
|
+
fire('text_delta', 'a'); // 1 (evicted)
|
|
188
|
+
fire('text_delta', 'b'); // 2
|
|
189
|
+
fire('text_delta', 'c'); // 3 → buffer holds [2,3]
|
|
190
|
+
const s2 = sink();
|
|
191
|
+
bridge.detach();
|
|
192
|
+
bridge.attach(s2, TEST_ATTACH_OPTIONS);
|
|
193
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 })); // saw nothing, 1 is gone
|
|
194
|
+
expect(frames(s2)).toEqual([{ type: 'resume_gap' }]);
|
|
195
|
+
bridge.dispose();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// CMD-004 Stage D — the bridge's single subscription requester-routes `ui_intent` with the
|
|
199
|
+
// LATE-BOUND driver id (`setDriverId` binds after pairing), so a skipped intent is never
|
|
200
|
+
// buffered and can never leak to this surface through a later `resume` replay.
|
|
201
|
+
it('requester-routes ui_intent by the late-bound driver id; foreign intents are never buffered', () => {
|
|
202
|
+
const { session, fire } = fakeSession();
|
|
203
|
+
const bridge = new SessionResumeBridge({ session });
|
|
204
|
+
const s = sink();
|
|
205
|
+
bridge.attach(s, TEST_ATTACH_OPTIONS);
|
|
206
|
+
|
|
207
|
+
// Not paired yet (no driver id): an intent attributed to another surface is skipped.
|
|
208
|
+
fire('ui_intent', { intent: { type: 'show-settings' }, requesterDriverId: 'device-9' });
|
|
209
|
+
expect(s.calls).toHaveLength(0);
|
|
210
|
+
|
|
211
|
+
bridge.setDriverId('device-9');
|
|
212
|
+
fire('ui_intent', { intent: { type: 'show-settings' }, requesterDriverId: 'device-9' });
|
|
213
|
+
expect(frames(s)).toEqual([
|
|
214
|
+
{
|
|
215
|
+
type: 'ui_intent',
|
|
216
|
+
event: { intent: { type: 'show-settings' }, requesterDriverId: 'device-9' },
|
|
217
|
+
seq: 1, // the skipped foreign intent consumed NO seq — it was routed out BEFORE buffering
|
|
218
|
+
},
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
// Another surface's intent stays invisible even across a reconnect replay.
|
|
222
|
+
fire('ui_intent', { intent: { type: 'show-plugin-manager' }, requesterDriverId: 'device-2' });
|
|
223
|
+
const s2 = sink();
|
|
224
|
+
bridge.detach();
|
|
225
|
+
bridge.attach(s2, TEST_ATTACH_OPTIONS);
|
|
226
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
|
|
227
|
+
expect(frames(s2).filter((f) => f.type === 'ui_intent')).toHaveLength(1); // only device-9's own
|
|
228
|
+
|
|
229
|
+
// Unattributed intents are unroutable → delivered (never silently dropped).
|
|
230
|
+
fire('ui_intent', { intent: { type: 'show-agent-switcher' } });
|
|
231
|
+
expect(frames(s2).at(-1)).toEqual({
|
|
232
|
+
type: 'ui_intent',
|
|
233
|
+
event: { intent: { type: 'show-agent-switcher' } },
|
|
234
|
+
seq: 2, // the device-2 intent consumed no seq either — routing precedes the buffer
|
|
235
|
+
});
|
|
236
|
+
bridge.dispose();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('regression: the WS createSessionMessageHandler path stamps NO seq', () => {
|
|
240
|
+
const { session, fire } = fakeSession();
|
|
241
|
+
const sent: unknown[] = [];
|
|
242
|
+
const { cleanup } = createSessionMessageHandler({
|
|
243
|
+
session,
|
|
244
|
+
deliver: createOutboundDelivery((m) => sent.push(m), vi.fn()),
|
|
245
|
+
});
|
|
246
|
+
fire('text_delta', 'a');
|
|
247
|
+
expect(sent).toEqual([{ type: 'text_delta', delta: 'a' }]); // no `seq` field
|
|
248
|
+
cleanup();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('reports a WebRTC sink failure and detaches instead of swallowing it', () => {
|
|
252
|
+
const { session, fire } = fakeSession();
|
|
253
|
+
const failures: Array<{ message: string; event: string }> = [];
|
|
254
|
+
const bridge = new SessionResumeBridge({ session });
|
|
255
|
+
bridge.attach(
|
|
256
|
+
() => {
|
|
257
|
+
throw new Error('data channel closed');
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
onDeliveryError: (error, event) => {
|
|
261
|
+
failures.push({ message: error.message, event });
|
|
262
|
+
throw new Error('diagnostic callback failed');
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
fire('branch_event', {
|
|
268
|
+
kind: 'checkpoint_created',
|
|
269
|
+
checkpointId: 'turn-0001',
|
|
270
|
+
branchId: 'main',
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(failures).toEqual([{ message: 'data channel closed', event: 'branch_event' }]);
|
|
274
|
+
expect(bridge.lastSeq).toBe(1); // retained for replay after the carrier reconnects
|
|
275
|
+
bridge.dispose();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ARCH-030: the bridge's own string-level try/catch is gone; its only guard is the per-attachment
|
|
279
|
+
// outbound boundary. These three pin the semantics that used to be side effects of `detach()`.
|
|
280
|
+
describe('the attachment boundary (ARCH-030)', () => {
|
|
281
|
+
it('reports ONCE across a failing multi-frame replay, and the dropped frames survive', () => {
|
|
282
|
+
const { session, fire } = fakeSession();
|
|
283
|
+
const failures: Array<{ message: string; event: string }> = [];
|
|
284
|
+
const bridge = new SessionResumeBridge({ session });
|
|
285
|
+
|
|
286
|
+
// Three frames buffered while no channel is attached — the reconnect gap.
|
|
287
|
+
fire('text_delta', 'a');
|
|
288
|
+
fire('text_delta', 'b');
|
|
289
|
+
fire('text_delta', 'c');
|
|
290
|
+
expect(bridge.lastSeq).toBe(3);
|
|
291
|
+
|
|
292
|
+
bridge.attach(
|
|
293
|
+
() => {
|
|
294
|
+
throw new Error('data channel closed');
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
awaitResume: true,
|
|
298
|
+
onDeliveryError: (error, event) => failures.push({ message: error.message, event }),
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
|
|
302
|
+
|
|
303
|
+
// One report for the whole tail. Before the boundary this was one only because
|
|
304
|
+
// `reportDeliveryError` happened to `detach()` mid-loop; now it is the stated contract.
|
|
305
|
+
expect(failures).toEqual([{ message: 'data channel closed', event: 'text_delta' }]);
|
|
306
|
+
|
|
307
|
+
// Nothing was acked, so every frame is still replayable on the NEXT sink.
|
|
308
|
+
const healthy = sink();
|
|
309
|
+
bridge.attach(healthy, { awaitResume: true, onDeliveryError: vi.fn() });
|
|
310
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
|
|
311
|
+
expect(frames(healthy).map((f) => f.seq)).toEqual([1, 2, 3]);
|
|
312
|
+
expect(frames(healthy).map((f) => f.delta)).toEqual(['a', 'b', 'c']);
|
|
313
|
+
bridge.dispose();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('ARCH-030: a replay burst stops when the budget closes the boundary, and the rest survives', () => {
|
|
317
|
+
// The replay-burst contract case. A reconnecting peer is handed the whole retained tail at
|
|
318
|
+
// once; if it stops reading part-way, the loop must stop rather than push the remainder into a
|
|
319
|
+
// carrier that is already over budget.
|
|
320
|
+
const { session, fire } = fakeSession();
|
|
321
|
+
const failures: Array<{ message: string; event: string }> = [];
|
|
322
|
+
const bridge = new SessionResumeBridge({ session });
|
|
323
|
+
|
|
324
|
+
for (const delta of ['a', 'b', 'c', 'd']) fire('text_delta', delta);
|
|
325
|
+
expect(bridge.lastSeq).toBe(4);
|
|
326
|
+
|
|
327
|
+
const written: string[] = [];
|
|
328
|
+
let pending = 0;
|
|
329
|
+
bridge.attach(
|
|
330
|
+
(data) => {
|
|
331
|
+
written.push(data);
|
|
332
|
+
// The peer reads nothing. This carrier is already holding a large backlog, so the second
|
|
333
|
+
// frame of the replay finds it past DEFAULT_MAX_PENDING_BYTES — the real default, not a
|
|
334
|
+
// budget invented for the test.
|
|
335
|
+
pending += DEFAULT_MAX_PENDING_BYTES;
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
awaitResume: true,
|
|
339
|
+
onDeliveryError: (error, event) => failures.push({ message: error.message, event }),
|
|
340
|
+
pendingBytes: () => pending,
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
|
|
344
|
+
|
|
345
|
+
// It stopped short rather than draining the tail into a carrier over its limit.
|
|
346
|
+
expect(written.length).toBeLessThan(4);
|
|
347
|
+
expect(failures).toHaveLength(1);
|
|
348
|
+
expect(failures[0]?.message).toContain('pending');
|
|
349
|
+
|
|
350
|
+
// And nothing was lost: the frames it did not push are still replayable on the next sink.
|
|
351
|
+
const healthy = sink();
|
|
352
|
+
bridge.attach(healthy, { awaitResume: true, onDeliveryError: vi.fn() });
|
|
353
|
+
bridge.onClientMessage(JSON.stringify({ type: 'resume', lastSeq: 0 }));
|
|
354
|
+
expect(frames(healthy).map((f) => f.seq)).toEqual([1, 2, 3, 4]);
|
|
355
|
+
bridge.dispose();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('a fresh attach un-latches the connection, and seq stays continuous across the failure', () => {
|
|
359
|
+
const { session, fire } = fakeSession();
|
|
360
|
+
const bridge = new SessionResumeBridge({ session });
|
|
361
|
+
bridge.attach(
|
|
362
|
+
() => {
|
|
363
|
+
throw new Error('data channel closed');
|
|
364
|
+
},
|
|
365
|
+
{ onDeliveryError: vi.fn() },
|
|
366
|
+
);
|
|
367
|
+
fire('text_delta', 'a');
|
|
368
|
+
|
|
369
|
+
const second = sink();
|
|
370
|
+
const secondFailures: string[] = [];
|
|
371
|
+
bridge.attach(second, { onDeliveryError: (error) => secondFailures.push(error.message) });
|
|
372
|
+
fire('text_delta', 'b');
|
|
373
|
+
|
|
374
|
+
// The new attachment delivers: a latched boundary is not carried across `attach`.
|
|
375
|
+
expect(frames(second).map((f) => f.seq)).toEqual([2]);
|
|
376
|
+
expect(secondFailures).toEqual([]);
|
|
377
|
+
expect(bridge.lastSeq).toBe(2); // the failed frame still consumed its seq
|
|
378
|
+
bridge.dispose();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('routes a reply to an inbound frame through the same boundary as the event fan-out', () => {
|
|
382
|
+
const handlers = new Map<string, (arg: unknown) => void>();
|
|
383
|
+
const session = createTestInteractiveSession({
|
|
384
|
+
on: ((event: string, handler: (arg: unknown) => void) =>
|
|
385
|
+
handlers.set(event, handler)) as IInteractiveSession['on'],
|
|
386
|
+
off: ((event: string) => {
|
|
387
|
+
handlers.delete(event);
|
|
388
|
+
}) as IInteractiveSession['off'],
|
|
389
|
+
});
|
|
390
|
+
const failures: Array<{ message: string; event: string }> = [];
|
|
391
|
+
const bridge = new SessionResumeBridge({ session });
|
|
392
|
+
bridge.attach(
|
|
393
|
+
() => {
|
|
394
|
+
throw new Error('data channel closed');
|
|
395
|
+
},
|
|
396
|
+
{ onDeliveryError: (error, event) => failures.push({ message: error.message, event }) },
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
// A synchronous query reply — before ARCH-030 the bridge guarded this at the string level, and
|
|
400
|
+
// the handler path next to it did not guard it at all.
|
|
401
|
+
expect(() => bridge.onClientMessage(JSON.stringify({ type: 'get-executing' }))).not.toThrow();
|
|
402
|
+
expect(failures).toEqual([{ message: 'data channel closed', event: 'executing' }]);
|
|
403
|
+
bridge.dispose();
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
});
|
|
@@ -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,84 @@
|
|
|
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
|
+
});
|