@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,726 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for WebSocket transport handler.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
8
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
9
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
10
|
+
import { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from '../session-events.js';
|
|
11
|
+
import type { TServerMessage } from '../wire-messages.js';
|
|
12
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
13
|
+
import type { ICommandListEntry } from '@robota-sdk/agent-interface-command';
|
|
14
|
+
import type {
|
|
15
|
+
IBackgroundJobGroupState,
|
|
16
|
+
IExecutionWorkspaceEvent,
|
|
17
|
+
IExecutionWorkspaceSnapshot,
|
|
18
|
+
TBackgroundJobGroupEvent,
|
|
19
|
+
TExecutionWorkspaceUpdateCause,
|
|
20
|
+
} from '@robota-sdk/agent-interface-execution';
|
|
21
|
+
import type {
|
|
22
|
+
IBackgroundTaskLogPage,
|
|
23
|
+
IBackgroundTaskState,
|
|
24
|
+
TBackgroundTaskEvent,
|
|
25
|
+
} from '@robota-sdk/agent-interface-execution';
|
|
26
|
+
|
|
27
|
+
const backgroundTask: IBackgroundTaskState = {
|
|
28
|
+
id: 'task_1',
|
|
29
|
+
kind: 'agent',
|
|
30
|
+
label: 'Explore',
|
|
31
|
+
agentType: 'Explore',
|
|
32
|
+
status: 'running',
|
|
33
|
+
mode: 'background',
|
|
34
|
+
parentSessionId: 'session_1',
|
|
35
|
+
depth: 0,
|
|
36
|
+
cwd: '/repo',
|
|
37
|
+
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
38
|
+
unread: false,
|
|
39
|
+
promptPreview: 'inspect code',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const backgroundTaskLogPage: IBackgroundTaskLogPage = {
|
|
43
|
+
taskId: 'task_1',
|
|
44
|
+
cursor: { offset: 0 },
|
|
45
|
+
lines: ['line one'],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const executionWorkspaceSnapshot: IExecutionWorkspaceSnapshot = {
|
|
49
|
+
sessionId: 'session_1',
|
|
50
|
+
entries: [],
|
|
51
|
+
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const backgroundJobGroup: IBackgroundJobGroupState = {
|
|
55
|
+
id: 'group_1',
|
|
56
|
+
parentSessionId: 'session_1',
|
|
57
|
+
waitPolicy: 'wait_all',
|
|
58
|
+
taskIds: ['task_1'],
|
|
59
|
+
status: 'running',
|
|
60
|
+
createdAt: '2026-05-01T00:00:00.000Z',
|
|
61
|
+
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
62
|
+
results: [],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function createMockSession() {
|
|
66
|
+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
|
67
|
+
return Object.assign(createTestInteractiveSession(), {
|
|
68
|
+
submit: vi.fn().mockResolvedValue(undefined),
|
|
69
|
+
abort: vi.fn(),
|
|
70
|
+
cancelQueue: vi.fn(),
|
|
71
|
+
getMessages: vi.fn().mockReturnValue([{ role: 'user', content: 'hi' }]),
|
|
72
|
+
getContextState: vi
|
|
73
|
+
.fn()
|
|
74
|
+
.mockReturnValue({ usedTokens: 500, maxTokens: 100000, usedPercentage: 0.5 }),
|
|
75
|
+
isExecuting: vi.fn().mockReturnValue(false),
|
|
76
|
+
getPendingPrompt: vi.fn().mockReturnValue(null),
|
|
77
|
+
listBackgroundTasks: vi.fn().mockReturnValue([backgroundTask]),
|
|
78
|
+
getBackgroundTask: vi.fn().mockReturnValue(backgroundTask),
|
|
79
|
+
listBackgroundJobGroups: vi.fn().mockReturnValue([backgroundJobGroup]),
|
|
80
|
+
getBackgroundJobGroup: vi.fn().mockReturnValue(backgroundJobGroup),
|
|
81
|
+
waitBackgroundJobGroup: vi.fn().mockResolvedValue({
|
|
82
|
+
...backgroundJobGroup,
|
|
83
|
+
status: 'completed',
|
|
84
|
+
completedAt: '2026-05-01T00:00:01.000Z',
|
|
85
|
+
results: [{ taskId: 'task_1', label: 'Explore', status: 'completed', summary: 'done' }],
|
|
86
|
+
}),
|
|
87
|
+
cancelBackgroundTask: vi.fn().mockResolvedValue(undefined),
|
|
88
|
+
closeBackgroundTask: vi.fn().mockResolvedValue(undefined),
|
|
89
|
+
sendBackgroundTask: vi.fn().mockResolvedValue(undefined),
|
|
90
|
+
readBackgroundTaskLog: vi.fn().mockResolvedValue(backgroundTaskLogPage),
|
|
91
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue(executionWorkspaceSnapshot),
|
|
92
|
+
executeCommand: vi.fn().mockResolvedValue({ message: 'done', success: true, data: {} }),
|
|
93
|
+
resolvePermission: vi.fn(),
|
|
94
|
+
resolveAsk: vi.fn(),
|
|
95
|
+
listCommands: vi.fn().mockReturnValue([]),
|
|
96
|
+
// ARCH-012: required members — the cast below used to hide their absence, so this
|
|
97
|
+
// double was accepted while the real contract would have rejected it.
|
|
98
|
+
isInitialized: true,
|
|
99
|
+
getPendingCount: vi.fn().mockReturnValue(0),
|
|
100
|
+
getActiveDriverId: vi.fn().mockReturnValue(null),
|
|
101
|
+
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
102
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
103
|
+
listeners.get(event)!.add(handler);
|
|
104
|
+
}),
|
|
105
|
+
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
106
|
+
listeners.get(event)?.delete(handler);
|
|
107
|
+
}),
|
|
108
|
+
_emit: (event: string, ...args: unknown[]) => {
|
|
109
|
+
listeners.get(event)?.forEach((h) => h(...args));
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe('WebSocket Transport Handler', () => {
|
|
115
|
+
function setup() {
|
|
116
|
+
const session = createMockSession();
|
|
117
|
+
const sent: TServerMessage[] = [];
|
|
118
|
+
const { onMessage, cleanup } = createSessionMessageHandler({
|
|
119
|
+
session,
|
|
120
|
+
deliver: createOutboundDelivery((msg) => sent.push(msg), vi.fn()),
|
|
121
|
+
});
|
|
122
|
+
return { session, sent, onMessage, cleanup };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it('submit calls session.submit()', () => {
|
|
126
|
+
const { onMessage, session } = setup();
|
|
127
|
+
onMessage(JSON.stringify({ type: 'submit', prompt: 'hello' }));
|
|
128
|
+
expect(
|
|
129
|
+
(session as unknown as { submit: ReturnType<typeof vi.fn> }).submit,
|
|
130
|
+
).toHaveBeenCalledWith('hello', undefined, undefined, undefined);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('abort calls session.abort()', () => {
|
|
134
|
+
const { onMessage, session } = setup();
|
|
135
|
+
onMessage(JSON.stringify({ type: 'abort' }));
|
|
136
|
+
expect((session as unknown as { abort: ReturnType<typeof vi.fn> }).abort).toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('cancel-queue calls session.cancelQueue()', () => {
|
|
140
|
+
const { onMessage, session } = setup();
|
|
141
|
+
onMessage(JSON.stringify({ type: 'cancel-queue' }));
|
|
142
|
+
expect(
|
|
143
|
+
(session as unknown as { cancelQueue: ReturnType<typeof vi.fn> }).cancelQueue,
|
|
144
|
+
).toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('get-messages sends messages back', () => {
|
|
148
|
+
const { onMessage, sent } = setup();
|
|
149
|
+
onMessage(JSON.stringify({ type: 'get-messages' }));
|
|
150
|
+
expect(sent).toHaveLength(1);
|
|
151
|
+
expect(sent[0]!.type).toBe('messages');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('get-context sends context state back', () => {
|
|
155
|
+
const { onMessage, sent } = setup();
|
|
156
|
+
onMessage(JSON.stringify({ type: 'get-context' }));
|
|
157
|
+
expect(sent).toHaveLength(1);
|
|
158
|
+
expect(sent[0]!.type).toBe('context');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('#3186: get-commands sends the commands and skills a client can offer', () => {
|
|
162
|
+
const { onMessage, session, sent } = setup();
|
|
163
|
+
const command: ICommandListEntry = {
|
|
164
|
+
name: 'help',
|
|
165
|
+
description: 'Show commands',
|
|
166
|
+
modelInvocable: false,
|
|
167
|
+
runner: 'runtime',
|
|
168
|
+
};
|
|
169
|
+
const skill = {
|
|
170
|
+
name: 'parity-demo',
|
|
171
|
+
description: 'Demo',
|
|
172
|
+
source: 'project',
|
|
173
|
+
modelInvocable: true,
|
|
174
|
+
userInvocable: true,
|
|
175
|
+
};
|
|
176
|
+
Object.assign(session, {
|
|
177
|
+
listCommands: vi.fn().mockReturnValue([command]),
|
|
178
|
+
listSkills: vi.fn().mockReturnValue([skill]),
|
|
179
|
+
});
|
|
180
|
+
onMessage(JSON.stringify({ type: 'get-commands' }));
|
|
181
|
+
expect(sent).toEqual([{ type: 'commands', commands: [command], skills: [skill] }]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('#3186: get-status sends the session status snapshot', () => {
|
|
185
|
+
const { onMessage, session, sent } = setup();
|
|
186
|
+
onMessage(JSON.stringify({ type: 'get-status' }));
|
|
187
|
+
expect(sent).toEqual([{ type: 'session_status', status: session.getStatusSnapshot() }]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('#3282 §2: list-models sends the model list, echoing requestId', () => {
|
|
191
|
+
const { onMessage, session, sent } = setup();
|
|
192
|
+
const snapshot = {
|
|
193
|
+
groups: [
|
|
194
|
+
{
|
|
195
|
+
profileName: 'anthropic',
|
|
196
|
+
providerLabel: 'Anthropic',
|
|
197
|
+
models: [{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }],
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
currentProfile: 'anthropic',
|
|
201
|
+
currentModel: 'claude-sonnet-4-6',
|
|
202
|
+
};
|
|
203
|
+
Object.assign(session, { listModels: vi.fn().mockReturnValue(snapshot) });
|
|
204
|
+
|
|
205
|
+
onMessage(JSON.stringify({ type: 'list-models', requestId: 'req-models-1' }));
|
|
206
|
+
|
|
207
|
+
expect(sent).toEqual([{ type: 'model_list', requestId: 'req-models-1', ...snapshot }]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('#3282 §4: get-agent-definitions sends the roster and the current default, echoing requestId', () => {
|
|
211
|
+
const { onMessage, session, sent } = setup();
|
|
212
|
+
const agents = [
|
|
213
|
+
{ name: 'general-purpose', description: 'General-purpose task execution agent.', definedIn: 'Built-in' },
|
|
214
|
+
{ name: 'Explore', description: 'Read-only codebase exploration agent.', definedIn: 'Built-in' },
|
|
215
|
+
];
|
|
216
|
+
Object.assign(session, {
|
|
217
|
+
listAgentDefinitions: vi.fn().mockReturnValue(agents),
|
|
218
|
+
getDefaultAgentType: vi.fn().mockReturnValue('Explore'),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
onMessage(JSON.stringify({ type: 'get-agent-definitions', requestId: 'req-agents-1' }));
|
|
222
|
+
|
|
223
|
+
expect(sent).toEqual([
|
|
224
|
+
{ type: 'agent_definitions', requestId: 'req-agents-1', agents, current: 'Explore' },
|
|
225
|
+
]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('get-executing sends executing status', () => {
|
|
229
|
+
const { onMessage, sent } = setup();
|
|
230
|
+
onMessage(JSON.stringify({ type: 'get-executing' }));
|
|
231
|
+
expect(sent[0]).toEqual({ type: 'executing', executing: false });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('get-pending sends pending prompt', () => {
|
|
235
|
+
const { onMessage, sent } = setup();
|
|
236
|
+
onMessage(JSON.stringify({ type: 'get-pending' }));
|
|
237
|
+
expect(sent[0]).toEqual({ type: 'pending', pending: null, pendingCount: 0 });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('#3189: answers a submit with the queue once the prompt is taken, so a queued prompt shows', async () => {
|
|
241
|
+
const { onMessage, sent, session } = setup();
|
|
242
|
+
const mock = session as unknown as {
|
|
243
|
+
submit: ReturnType<typeof vi.fn>;
|
|
244
|
+
getPendingPrompt: ReturnType<typeof vi.fn>;
|
|
245
|
+
getPendingCount: ReturnType<typeof vi.fn>;
|
|
246
|
+
};
|
|
247
|
+
let queue: () => void = () => undefined;
|
|
248
|
+
// A turn is running: the session queues the prompt only after its own initialization yields.
|
|
249
|
+
mock.submit.mockImplementationOnce(
|
|
250
|
+
() =>
|
|
251
|
+
new Promise<void>((resolve) => {
|
|
252
|
+
queue = () => {
|
|
253
|
+
mock.getPendingPrompt.mockReturnValue('follow-up');
|
|
254
|
+
mock.getPendingCount.mockReturnValue(1);
|
|
255
|
+
resolve();
|
|
256
|
+
};
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
onMessage(JSON.stringify({ type: 'submit', prompt: 'follow-up' }));
|
|
260
|
+
// A `get-pending` sent beside the submit is answered before the prompt is queued.
|
|
261
|
+
onMessage(JSON.stringify({ type: 'get-pending' }));
|
|
262
|
+
expect(sent).toEqual([{ type: 'pending', pending: null, pendingCount: 0 }]);
|
|
263
|
+
queue();
|
|
264
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
265
|
+
expect(sent.at(-1)).toEqual({ type: 'pending', pending: 'follow-up', pendingCount: 1 });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('get-background-tasks sends current background task list', () => {
|
|
269
|
+
const { onMessage, sent, session } = setup();
|
|
270
|
+
onMessage(JSON.stringify({ type: 'get-background-tasks', filter: { kind: 'agent' } }));
|
|
271
|
+
|
|
272
|
+
expect(sent[0]).toEqual({ type: 'background_tasks', tasks: [backgroundTask] });
|
|
273
|
+
expect(
|
|
274
|
+
(session as unknown as { listBackgroundTasks: ReturnType<typeof vi.fn> }).listBackgroundTasks,
|
|
275
|
+
).toHaveBeenCalledWith({ kind: 'agent' });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('get-background-task sends one background task snapshot', () => {
|
|
279
|
+
const { onMessage, sent, session } = setup();
|
|
280
|
+
onMessage(JSON.stringify({ type: 'get-background-task', taskId: 'task_1' }));
|
|
281
|
+
|
|
282
|
+
expect(sent[0]).toEqual({
|
|
283
|
+
type: 'background_task',
|
|
284
|
+
taskId: 'task_1',
|
|
285
|
+
task: backgroundTask,
|
|
286
|
+
});
|
|
287
|
+
expect(
|
|
288
|
+
(session as unknown as { getBackgroundTask: ReturnType<typeof vi.fn> }).getBackgroundTask,
|
|
289
|
+
).toHaveBeenCalledWith('task_1');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('get-background-job-groups sends group snapshots', () => {
|
|
293
|
+
const { onMessage, sent, session } = setup();
|
|
294
|
+
onMessage(JSON.stringify({ type: 'get-background-job-groups' }));
|
|
295
|
+
|
|
296
|
+
expect(sent[0]).toEqual({ type: 'background_job_groups', groups: [backgroundJobGroup] });
|
|
297
|
+
expect(
|
|
298
|
+
(session as unknown as { listBackgroundJobGroups: ReturnType<typeof vi.fn> })
|
|
299
|
+
.listBackgroundJobGroups,
|
|
300
|
+
).toHaveBeenCalled();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('get-background-job-group sends one group snapshot', () => {
|
|
304
|
+
const { onMessage, sent, session } = setup();
|
|
305
|
+
onMessage(JSON.stringify({ type: 'get-background-job-group', groupId: 'group_1' }));
|
|
306
|
+
|
|
307
|
+
expect(sent[0]).toEqual({
|
|
308
|
+
type: 'background_job_group',
|
|
309
|
+
groupId: 'group_1',
|
|
310
|
+
group: backgroundJobGroup,
|
|
311
|
+
});
|
|
312
|
+
expect(
|
|
313
|
+
(session as unknown as { getBackgroundJobGroup: ReturnType<typeof vi.fn> })
|
|
314
|
+
.getBackgroundJobGroup,
|
|
315
|
+
).toHaveBeenCalledWith('group_1');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('wait-background-job-group waits and sends the completed group', async () => {
|
|
319
|
+
const { onMessage, sent, session } = setup();
|
|
320
|
+
onMessage(JSON.stringify({ type: 'wait-background-job-group', groupId: 'group_1' }));
|
|
321
|
+
|
|
322
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
323
|
+
|
|
324
|
+
expect(
|
|
325
|
+
(session as unknown as { waitBackgroundJobGroup: ReturnType<typeof vi.fn> })
|
|
326
|
+
.waitBackgroundJobGroup,
|
|
327
|
+
).toHaveBeenCalledWith('group_1');
|
|
328
|
+
expect(sent[0]).toMatchObject({
|
|
329
|
+
type: 'background_job_group',
|
|
330
|
+
groupId: 'group_1',
|
|
331
|
+
group: { id: 'group_1', status: 'completed' },
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('cancel-background-task maps to session control and emits control result', async () => {
|
|
336
|
+
const { onMessage, sent, session } = setup();
|
|
337
|
+
onMessage(JSON.stringify({ type: 'cancel-background-task', taskId: 'task_1', reason: 'stop' }));
|
|
338
|
+
|
|
339
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
340
|
+
|
|
341
|
+
expect(
|
|
342
|
+
(session as unknown as { cancelBackgroundTask: ReturnType<typeof vi.fn> })
|
|
343
|
+
.cancelBackgroundTask,
|
|
344
|
+
).toHaveBeenCalledWith('task_1', 'stop');
|
|
345
|
+
expect(sent[0]).toEqual({
|
|
346
|
+
type: 'background_task_control_result',
|
|
347
|
+
action: 'cancel',
|
|
348
|
+
taskId: 'task_1',
|
|
349
|
+
success: true,
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('send-background-task maps prompt input to session control', async () => {
|
|
354
|
+
const { onMessage, sent, session } = setup();
|
|
355
|
+
onMessage(
|
|
356
|
+
JSON.stringify({
|
|
357
|
+
type: 'send-background-task',
|
|
358
|
+
taskId: 'task_1',
|
|
359
|
+
input: { prompt: 'continue' },
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
364
|
+
|
|
365
|
+
expect(
|
|
366
|
+
(session as unknown as { sendBackgroundTask: ReturnType<typeof vi.fn> }).sendBackgroundTask,
|
|
367
|
+
).toHaveBeenCalledWith('task_1', { prompt: 'continue' });
|
|
368
|
+
expect(sent[0]).toEqual({
|
|
369
|
+
type: 'background_task_control_result',
|
|
370
|
+
action: 'send',
|
|
371
|
+
taskId: 'task_1',
|
|
372
|
+
success: true,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('read-background-task-log sends log page', async () => {
|
|
377
|
+
const { onMessage, sent, session } = setup();
|
|
378
|
+
onMessage(
|
|
379
|
+
JSON.stringify({
|
|
380
|
+
type: 'read-background-task-log',
|
|
381
|
+
taskId: 'task_1',
|
|
382
|
+
cursor: { offset: 0 },
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
387
|
+
|
|
388
|
+
expect(
|
|
389
|
+
(session as unknown as { readBackgroundTaskLog: ReturnType<typeof vi.fn> })
|
|
390
|
+
.readBackgroundTaskLog,
|
|
391
|
+
).toHaveBeenCalledWith('task_1', { offset: 0 });
|
|
392
|
+
expect(sent[0]).toEqual({
|
|
393
|
+
type: 'background_task_log',
|
|
394
|
+
taskId: 'task_1',
|
|
395
|
+
page: backgroundTaskLogPage,
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('command executes via session.executeCommand() tagged as a remote origin (REMOTE-003)', async () => {
|
|
400
|
+
const { onMessage, sent, session } = setup();
|
|
401
|
+
onMessage(JSON.stringify({ type: 'command', name: 'clear' }));
|
|
402
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
403
|
+
expect(sent).toHaveLength(1);
|
|
404
|
+
expect(sent[0]!.type).toBe('command_result');
|
|
405
|
+
// A transport-origin command is an untrusted remote origin — the handler tags it `'remote'` so the session
|
|
406
|
+
// applies its (optional, allow-by-default) remote-command policy.
|
|
407
|
+
// CMD-004 Phase 2: the 4th arg is the command-origin driver id — undefined when the handler has
|
|
408
|
+
// no server-assigned id (unattributed; a client-sent id is NEVER trusted). The 5th (#3282 §4
|
|
409
|
+
// part b-2) is this carrier's locality — undefined here since this handler was not given one.
|
|
410
|
+
expect(
|
|
411
|
+
(session as unknown as { executeCommand: ReturnType<typeof vi.fn> }).executeCommand,
|
|
412
|
+
).toHaveBeenCalledWith('clear', '', 'remote', undefined, undefined);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('command carries the SERVER-ASSIGNED driver id as the command origin (CMD-004 Phase 2)', async () => {
|
|
416
|
+
const session = createMockSession();
|
|
417
|
+
const sent: TServerMessage[] = [];
|
|
418
|
+
const { onMessage } = createSessionMessageHandler({
|
|
419
|
+
session,
|
|
420
|
+
deliver: createOutboundDelivery((msg) => sent.push(msg), vi.fn()),
|
|
421
|
+
driverId: 'device-7',
|
|
422
|
+
});
|
|
423
|
+
onMessage(JSON.stringify({ type: 'command', name: 'settings' }));
|
|
424
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
425
|
+
expect(
|
|
426
|
+
(session as unknown as { executeCommand: ReturnType<typeof vi.fn> }).executeCommand,
|
|
427
|
+
).toHaveBeenCalledWith('settings', '', 'remote', 'device-7', undefined);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("forwards this carrier's commandSurfaceLocality to executeCommand (#3282 §4 part b-2)", async () => {
|
|
431
|
+
const session = createMockSession();
|
|
432
|
+
const sent: TServerMessage[] = [];
|
|
433
|
+
const { onMessage } = createSessionMessageHandler({
|
|
434
|
+
session,
|
|
435
|
+
deliver: createOutboundDelivery((msg) => sent.push(msg), vi.fn()),
|
|
436
|
+
commandSurfaceLocality: 'remote',
|
|
437
|
+
});
|
|
438
|
+
onMessage(JSON.stringify({ type: 'command', name: 'plugin', args: 'install demo@community' }));
|
|
439
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
440
|
+
expect(
|
|
441
|
+
(session as unknown as { executeCommand: ReturnType<typeof vi.fn> }).executeCommand,
|
|
442
|
+
).toHaveBeenCalledWith('plugin', 'install demo@community', 'remote', undefined, 'remote');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('forwards ui_intent session events to the requesting client (CMD-004 Phase 2)', () => {
|
|
446
|
+
const session = createMockSession();
|
|
447
|
+
const sent: TServerMessage[] = [];
|
|
448
|
+
createSessionMessageHandler({
|
|
449
|
+
session,
|
|
450
|
+
deliver: createOutboundDelivery((msg) => sent.push(msg), vi.fn()),
|
|
451
|
+
driverId: 'device-7',
|
|
452
|
+
});
|
|
453
|
+
session._emit('ui_intent', {
|
|
454
|
+
intent: { type: 'show-settings' },
|
|
455
|
+
requesterDriverId: 'device-7',
|
|
456
|
+
});
|
|
457
|
+
expect(sent).toEqual([
|
|
458
|
+
{
|
|
459
|
+
type: 'ui_intent',
|
|
460
|
+
event: { intent: { type: 'show-settings' }, requesterDriverId: 'device-7' },
|
|
461
|
+
},
|
|
462
|
+
]);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// CMD-004 Stage D — `ui_intent` is REQUESTER-ROUTED (the spec's Key mechanics): the surface that
|
|
466
|
+
// issued the command renders the intent; other surfaces never see it. Only an UNATTRIBUTED intent
|
|
467
|
+
// (no requester id — e.g. an idle model-invoked command) reaches every surface, because it cannot
|
|
468
|
+
// be routed and a silent drop is banned.
|
|
469
|
+
describe('ui_intent requester routing (CMD-004 Stage D)', () => {
|
|
470
|
+
function twoSurfaces() {
|
|
471
|
+
const session = createMockSession();
|
|
472
|
+
const sentA: TServerMessage[] = [];
|
|
473
|
+
const sentB: TServerMessage[] = [];
|
|
474
|
+
createSessionMessageHandler({
|
|
475
|
+
session,
|
|
476
|
+
deliver: createOutboundDelivery((msg) => sentA.push(msg), vi.fn()),
|
|
477
|
+
driverId: 'device-A',
|
|
478
|
+
});
|
|
479
|
+
createSessionMessageHandler({
|
|
480
|
+
session,
|
|
481
|
+
deliver: createOutboundDelivery((msg) => sentB.push(msg), vi.fn()),
|
|
482
|
+
driverId: 'device-B',
|
|
483
|
+
});
|
|
484
|
+
return { session, sentA, sentB };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
it('routes a requester-stamped intent ONLY to the invoking surface — not broadcast', () => {
|
|
488
|
+
const { session, sentA, sentB } = twoSurfaces();
|
|
489
|
+
session._emit('ui_intent', {
|
|
490
|
+
intent: { type: 'show-session-picker' },
|
|
491
|
+
requesterDriverId: 'device-A',
|
|
492
|
+
});
|
|
493
|
+
expect(sentA).toEqual([
|
|
494
|
+
{
|
|
495
|
+
type: 'ui_intent',
|
|
496
|
+
event: { intent: { type: 'show-session-picker' }, requesterDriverId: 'device-A' },
|
|
497
|
+
},
|
|
498
|
+
]);
|
|
499
|
+
expect(sentB).toEqual([]);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('an unattributed intent reaches every surface (unroutable — never silently dropped)', () => {
|
|
503
|
+
const { session, sentA, sentB } = twoSurfaces();
|
|
504
|
+
session._emit('ui_intent', { intent: { type: 'show-settings' } });
|
|
505
|
+
expect(sentA).toEqual([{ type: 'ui_intent', event: { intent: { type: 'show-settings' } } }]);
|
|
506
|
+
expect(sentB).toEqual(sentA);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("a surface with NO server-assigned id never receives another surface's intent", () => {
|
|
510
|
+
const { sent, session } = setup(); // setup() builds a handler without a driverId
|
|
511
|
+
session._emit('ui_intent', {
|
|
512
|
+
intent: { type: 'show-agent-switcher' },
|
|
513
|
+
requesterDriverId: 'device-elsewhere',
|
|
514
|
+
});
|
|
515
|
+
expect(sent).toEqual([]);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('invalid JSON sends protocol_error', () => {
|
|
520
|
+
const { onMessage, sent } = setup();
|
|
521
|
+
onMessage('not json');
|
|
522
|
+
expect(sent[0]).toEqual({ type: 'protocol_error', message: 'Invalid JSON' });
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('unknown type sends protocol_error', () => {
|
|
526
|
+
const { onMessage, sent } = setup();
|
|
527
|
+
onMessage(JSON.stringify({ type: 'unknown_type' }));
|
|
528
|
+
expect(sent[0]!.type).toBe('protocol_error');
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('submit without prompt sends protocol_error', () => {
|
|
532
|
+
const { onMessage, sent } = setup();
|
|
533
|
+
onMessage(JSON.stringify({ type: 'submit' }));
|
|
534
|
+
// TRANS-008: the wording changed with the guard. "required" was false for a prompt that WAS
|
|
535
|
+
// provided and had the wrong type; the refusal is about shape and the message now says so.
|
|
536
|
+
expect(sent[0]).toEqual({
|
|
537
|
+
type: 'protocol_error',
|
|
538
|
+
message: 'prompt must be a non-empty string',
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('submit rejection surfaces a protocol_error (WS-001)', async () => {
|
|
543
|
+
const { onMessage, sent, session } = setup();
|
|
544
|
+
(session as unknown as { submit: ReturnType<typeof vi.fn> }).submit.mockRejectedValueOnce(
|
|
545
|
+
new Error('submit failed'),
|
|
546
|
+
);
|
|
547
|
+
onMessage(JSON.stringify({ type: 'submit', prompt: 'hello' }));
|
|
548
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
549
|
+
expect(sent).toEqual([{ type: 'protocol_error', message: 'submit failed' }]);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('command rejection surfaces a protocol_error (WS-001)', async () => {
|
|
553
|
+
const { onMessage, sent, session } = setup();
|
|
554
|
+
(
|
|
555
|
+
session as unknown as { executeCommand: ReturnType<typeof vi.fn> }
|
|
556
|
+
).executeCommand.mockRejectedValueOnce(new Error('command failed'));
|
|
557
|
+
onMessage(JSON.stringify({ type: 'command', name: 'clear' }));
|
|
558
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
559
|
+
expect(sent).toEqual([{ type: 'protocol_error', message: 'command failed' }]);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("#3189: echoes a command's requestId on its result and on its failure, and on nothing else", async () => {
|
|
563
|
+
const { onMessage, sent, session } = setup();
|
|
564
|
+
const { executeCommand, submit } = session as unknown as {
|
|
565
|
+
executeCommand: ReturnType<typeof vi.fn>;
|
|
566
|
+
submit: ReturnType<typeof vi.fn>;
|
|
567
|
+
};
|
|
568
|
+
executeCommand.mockResolvedValueOnce({ message: 'done', success: true });
|
|
569
|
+
executeCommand.mockRejectedValueOnce(new Error('command failed'));
|
|
570
|
+
submit.mockRejectedValueOnce(new Error('A turn is running.'));
|
|
571
|
+
onMessage(JSON.stringify({ type: 'command', name: 'clear', requestId: 'c1' }));
|
|
572
|
+
onMessage(JSON.stringify({ type: 'command', name: 'broken', requestId: 'c2' }));
|
|
573
|
+
onMessage(JSON.stringify({ type: 'submit', prompt: 'hello' }));
|
|
574
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
575
|
+
expect(sent).toEqual(
|
|
576
|
+
expect.arrayContaining([
|
|
577
|
+
expect.objectContaining({ type: 'command_result', name: 'clear', requestId: 'c1' }),
|
|
578
|
+
{ type: 'protocol_error', message: 'command failed', requestId: 'c2' },
|
|
579
|
+
{ type: 'protocol_error', message: 'A turn is running.' },
|
|
580
|
+
]),
|
|
581
|
+
);
|
|
582
|
+
expect(sent).toHaveLength(3);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('forwards InteractiveSession events to client', () => {
|
|
586
|
+
const { session, sent } = setup();
|
|
587
|
+
(session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
|
|
588
|
+
'text_delta',
|
|
589
|
+
'hello',
|
|
590
|
+
);
|
|
591
|
+
expect(sent).toHaveLength(1);
|
|
592
|
+
expect(sent[0]).toEqual({ type: 'text_delta', delta: 'hello' });
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('forwards thinking event', () => {
|
|
596
|
+
const { session, sent } = setup();
|
|
597
|
+
(session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
|
|
598
|
+
'thinking',
|
|
599
|
+
true,
|
|
600
|
+
);
|
|
601
|
+
expect(sent[0]).toEqual({ type: 'thinking', isThinking: true });
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('forwards background task events to the client', () => {
|
|
605
|
+
const { session, sent } = setup();
|
|
606
|
+
const event: TBackgroundTaskEvent = {
|
|
607
|
+
type: 'background_task_text_delta',
|
|
608
|
+
taskId: 'task_1',
|
|
609
|
+
delta: 'partial',
|
|
610
|
+
};
|
|
611
|
+
(session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
|
|
612
|
+
'background_task_event',
|
|
613
|
+
event,
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
expect(sent[0]).toEqual({ type: 'background_task_event', event });
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('forwards background job group events to the client', () => {
|
|
620
|
+
const { session, sent } = setup();
|
|
621
|
+
const event: TBackgroundJobGroupEvent = {
|
|
622
|
+
type: 'background_job_group_completed',
|
|
623
|
+
group: { ...backgroundJobGroup, status: 'completed' },
|
|
624
|
+
};
|
|
625
|
+
(session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
|
|
626
|
+
'background_job_group_event',
|
|
627
|
+
event,
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
expect(sent[0]).toEqual({ type: 'background_job_group_event', event });
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('forwards execution_workspace_event to the client', () => {
|
|
634
|
+
const { session, sent } = setup();
|
|
635
|
+
const event: IExecutionWorkspaceEvent = {
|
|
636
|
+
type: 'execution_workspace_updated',
|
|
637
|
+
cause: 'main_thread',
|
|
638
|
+
snapshot: executionWorkspaceSnapshot,
|
|
639
|
+
};
|
|
640
|
+
(session as unknown as { _emit: (e: string, ...args: unknown[]) => void })._emit(
|
|
641
|
+
'execution_workspace_event',
|
|
642
|
+
event,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
expect(sent[0]).toEqual({
|
|
646
|
+
type: 'execution_workspace_event',
|
|
647
|
+
snapshot: executionWorkspaceSnapshot,
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('get-execution-workspace sends current snapshot', () => {
|
|
652
|
+
const { onMessage, sent, session } = setup();
|
|
653
|
+
onMessage(JSON.stringify({ type: 'get-execution-workspace' }));
|
|
654
|
+
|
|
655
|
+
expect(sent[0]).toEqual({
|
|
656
|
+
type: 'execution_workspace_event',
|
|
657
|
+
snapshot: executionWorkspaceSnapshot,
|
|
658
|
+
});
|
|
659
|
+
expect(
|
|
660
|
+
(
|
|
661
|
+
session as unknown as {
|
|
662
|
+
getExecutionWorkspaceSnapshot: ReturnType<typeof vi.fn>;
|
|
663
|
+
}
|
|
664
|
+
).getExecutionWorkspaceSnapshot,
|
|
665
|
+
).toHaveBeenCalled();
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// ── REMOTE-007: transport-neutral permission/ask over the wire (TC-06) ──
|
|
669
|
+
|
|
670
|
+
it('forwards a permission_request session event to the client, with the call id it names', () => {
|
|
671
|
+
const { session, sent } = setup();
|
|
672
|
+
session._emit('permission_request', {
|
|
673
|
+
id: 'p1',
|
|
674
|
+
toolName: 'Bash',
|
|
675
|
+
toolArgs: { command: 'ls' },
|
|
676
|
+
executionId: 'call-1',
|
|
677
|
+
});
|
|
678
|
+
const msg = sent.find((m) => m.type === 'permission_request');
|
|
679
|
+
expect(msg).toEqual({
|
|
680
|
+
type: 'permission_request',
|
|
681
|
+
event: { id: 'p1', toolName: 'Bash', toolArgs: { command: 'ls' }, executionId: 'call-1' },
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('forwards ask_request and prompt_resolved session events to the client', () => {
|
|
686
|
+
const { session, sent } = setup();
|
|
687
|
+
session._emit('ask_request', { id: 'a1', request: { id: 'r', title: 'Pick' } });
|
|
688
|
+
session._emit('prompt_resolved', { id: 'a1' });
|
|
689
|
+
expect(sent.find((m) => m.type === 'ask_request')).toEqual({
|
|
690
|
+
type: 'ask_request',
|
|
691
|
+
event: { id: 'a1', request: { id: 'r', title: 'Pick' } },
|
|
692
|
+
});
|
|
693
|
+
expect(sent.find((m) => m.type === 'prompt_resolved')).toEqual({
|
|
694
|
+
type: 'prompt_resolved',
|
|
695
|
+
event: { id: 'a1' },
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('a permission-response from the client resolves the pending permission by id', () => {
|
|
700
|
+
const { onMessage, session } = setup();
|
|
701
|
+
onMessage(JSON.stringify({ type: 'permission-response', id: 'p1', result: true }));
|
|
702
|
+
expect(
|
|
703
|
+
(session as unknown as { resolvePermission: ReturnType<typeof vi.fn> }).resolvePermission,
|
|
704
|
+
).toHaveBeenCalledWith('p1', true, undefined);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('an ask-response from the client resolves the pending ask by id', () => {
|
|
708
|
+
const { onMessage, session } = setup();
|
|
709
|
+
const response = { type: 'answer', values: ['React'] };
|
|
710
|
+
onMessage(JSON.stringify({ type: 'ask-response', id: 'a1', response }));
|
|
711
|
+
expect(
|
|
712
|
+
(session as unknown as { resolveAsk: ReturnType<typeof vi.fn> }).resolveAsk,
|
|
713
|
+
).toHaveBeenCalledWith('a1', response, undefined);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('cleanup unsubscribes from all events', () => {
|
|
717
|
+
const { session, cleanup } = setup();
|
|
718
|
+
cleanup();
|
|
719
|
+
const subscribedEventCount = Object.values(PROTOCOL_SESSION_EVENT_CLASSIFICATION).filter(
|
|
720
|
+
(classification) => classification !== 'non-surface',
|
|
721
|
+
).length;
|
|
722
|
+
expect((session as unknown as { off: ReturnType<typeof vi.fn> }).off).toHaveBeenCalledTimes(
|
|
723
|
+
subscribedEventCount,
|
|
724
|
+
);
|
|
725
|
+
});
|
|
726
|
+
});
|