@robota-sdk/agent-transport 3.0.0-beta.81 → 3.0.0-beta.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +752 -0
  2. package/dist/browser/client.d.ts +2 -2
  3. package/dist/browser/client.js +1 -1
  4. package/dist/browser/index.d.ts +31 -11
  5. package/dist/browser/index.d.ts.map +1 -1
  6. package/dist/browser/index.js +1 -1
  7. package/dist/browser/index.js.map +1 -1
  8. package/dist/browser/observer-messages-Bx-xReUQ.js +2 -0
  9. package/dist/browser/observer-messages-Bx-xReUQ.js.map +1 -0
  10. package/dist/browser/{message-decoders-BB8n-H2X.d.ts → observer-messages-DC_8VHk9.d.ts} +104 -7
  11. package/dist/browser/observer-messages-DC_8VHk9.d.ts.map +1 -0
  12. package/dist/node/index.cjs +1 -1
  13. package/dist/node/index.d.cts +131 -14
  14. package/dist/node/index.d.cts.map +1 -1
  15. package/dist/node/index.d.ts +131 -14
  16. package/dist/node/index.d.ts.map +1 -1
  17. package/dist/node/index.js +1 -1
  18. package/dist/node/index.js.map +1 -1
  19. package/dist/node/node.cjs +1 -1
  20. package/dist/node/node.d.cts +177 -2
  21. package/dist/node/node.d.cts.map +1 -1
  22. package/dist/node/node.d.ts +178 -3
  23. package/dist/node/node.d.ts.map +1 -1
  24. package/dist/node/node.js +1 -1
  25. package/dist/node/node.js.map +1 -1
  26. package/package.json +13 -11
  27. package/src/__tests__/bearer-resource-gate.test.ts +120 -0
  28. package/src/__tests__/file-transfer.test.ts +281 -0
  29. package/src/__tests__/history-attached-budget.test.ts +216 -0
  30. package/src/__tests__/message-decoders.test.ts +201 -5
  31. package/src/__tests__/session-detail-loop-status.test.ts +169 -0
  32. package/src/__tests__/session-directory-messages.test.ts +192 -0
  33. package/src/__tests__/session-history-wire.test.ts +342 -0
  34. package/src/__tests__/session-message-handler-observe.test.ts +193 -0
  35. package/src/__tests__/session-message-handler.test.ts +82 -1
  36. package/src/__tests__/ws-broadcast-events.test.ts +10 -0
  37. package/src/client.ts +9 -1
  38. package/src/index.ts +14 -2
  39. package/src/loop-control-messages.ts +34 -0
  40. package/src/message-decoders.ts +82 -4
  41. package/src/node/bearer-resource-gate.ts +291 -0
  42. package/src/node/file-transfer.ts +530 -0
  43. package/src/node/index.ts +30 -0
  44. package/src/observer-messages.ts +36 -0
  45. package/src/open-prompts.ts +76 -0
  46. package/src/outbound-delivery.ts +11 -2
  47. package/src/protocol-session.ts +7 -1
  48. package/src/session-directory-messages.ts +85 -0
  49. package/src/session-events.ts +93 -18
  50. package/src/session-message-handler.ts +53 -8
  51. package/src/session-query-messages.ts +105 -4
  52. package/src/wire-messages.ts +105 -7
  53. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +0 -1
  54. package/dist/browser/message-decoders-CDmDoZPR.js +0 -2
  55. package/dist/browser/message-decoders-CDmDoZPR.js.map +0 -1
@@ -0,0 +1,281 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ FILE_CREDIT_WINDOW,
7
+ receiveFileOverChannel,
8
+ sendFileOverChannel,
9
+ type IFileSink,
10
+ type IFileSource,
11
+ type TFileAdmission,
12
+ } from '../node/file-transfer.js';
13
+
14
+ import type { IFileFrameChannel, IFileOffer } from '@robota-sdk/agent-interface-session-mobility';
15
+
16
+ /** Two ends of an in-memory channel. Frames arrive asynchronously, in order; `sent` records one side. */
17
+ function channelPair(): { a: IFileFrameChannel & { sent: string[] }; b: IFileFrameChannel } {
18
+ type TEnd = IFileFrameChannel & {
19
+ sent: string[];
20
+ frames: Set<(frame: string) => void>;
21
+ closes: Set<() => void>;
22
+ closed: boolean;
23
+ other?: TEnd;
24
+ };
25
+ const make = (): TEnd => {
26
+ const end: TEnd = {
27
+ sent: [],
28
+ frames: new Set(),
29
+ closes: new Set(),
30
+ closed: false,
31
+ send(frame) {
32
+ if (end.closed) throw new Error('closed');
33
+ end.sent.push(frame);
34
+ const other = end.other!;
35
+ setImmediate(() => {
36
+ if (!other.closed) for (const handler of other.frames) handler(frame);
37
+ });
38
+ },
39
+ onFrame(handler) {
40
+ end.frames.add(handler);
41
+ return () => end.frames.delete(handler);
42
+ },
43
+ onClose(handler) {
44
+ end.closes.add(handler);
45
+ return () => end.closes.delete(handler);
46
+ },
47
+ close() {
48
+ for (const side of [end, end.other!]) {
49
+ if (side.closed) continue;
50
+ side.closed = true;
51
+ setImmediate(() => {
52
+ for (const handler of side.closes) handler();
53
+ });
54
+ }
55
+ },
56
+ };
57
+ return end;
58
+ };
59
+ const a = make();
60
+ const b = make();
61
+ a.other = b;
62
+ b.other = a;
63
+ return { a, b };
64
+ }
65
+
66
+ function sha256(bytes: Uint8Array): string {
67
+ return createHash('sha256').update(bytes).digest('hex');
68
+ }
69
+
70
+ function sourceOf(bytes: Buffer, pieces = 7_000): IFileSource {
71
+ return {
72
+ size: bytes.length,
73
+ async *read() {
74
+ for (let at = 0; at < bytes.length; at += pieces) yield bytes.subarray(at, at + pieces);
75
+ },
76
+ };
77
+ }
78
+
79
+ function memorySink(): IFileSink & { written: Buffer[]; state: 'open' | 'kept' | 'discarded' } {
80
+ const sink = {
81
+ written: [] as Buffer[],
82
+ state: 'open' as 'open' | 'kept' | 'discarded',
83
+ write: async (chunk: Uint8Array) => {
84
+ sink.written.push(Buffer.from(chunk));
85
+ },
86
+ commit: async () => {
87
+ sink.state = 'kept';
88
+ return '/quarantine/file';
89
+ },
90
+ discard: async () => {
91
+ sink.state = 'discarded';
92
+ },
93
+ };
94
+ return sink;
95
+ }
96
+
97
+ function offerFor(bytes: Buffer, over: Partial<IFileOffer> = {}): IFileOffer {
98
+ return { transferId: 't1', name: 'data.bin', size: bytes.length, sha256: sha256(bytes), ...over };
99
+ }
100
+
101
+ describe('file carrier', () => {
102
+ it('moves a file the receiver admits, verified whole', async () => {
103
+ const bytes = Buffer.from(Array.from({ length: 100_000 }, (_, i) => i % 251));
104
+ const { a, b } = channelPair();
105
+ const sink = memorySink();
106
+ const offers: IFileOffer[] = [];
107
+ const [sent, received] = await Promise.all([
108
+ sendFileOverChannel({ channel: a, offer: offerFor(bytes), source: sourceOf(bytes) }),
109
+ receiveFileOverChannel({
110
+ channel: b,
111
+ admit: async (offer) => {
112
+ offers.push(offer);
113
+ return { sink };
114
+ },
115
+ }),
116
+ ]);
117
+ expect(sent).toEqual({ ok: true });
118
+ expect(received).toMatchObject({ ok: true, location: '/quarantine/file' });
119
+ expect(Buffer.concat(sink.written).equals(bytes)).toBe(true);
120
+ expect(sink.state).toBe('kept');
121
+ expect(offers).toEqual([offerFor(bytes)]);
122
+ });
123
+
124
+ it('sends no content before the receiver answers, and nothing at all when it refuses', async () => {
125
+ const bytes = Buffer.from('secret-ish');
126
+ const { a, b } = channelPair();
127
+ const [sent, received] = await Promise.all([
128
+ sendFileOverChannel({ channel: a, offer: offerFor(bytes), source: sourceOf(bytes) }),
129
+ receiveFileOverChannel({
130
+ channel: b,
131
+ admit: async (): Promise<TFileAdmission> => ({ refused: 'declined' }),
132
+ }),
133
+ ]);
134
+ expect(sent).toMatchObject({ ok: false, reason: 'declined' });
135
+ expect(received).toMatchObject({ ok: false, reason: 'declined' });
136
+ expect(a.sent.map((frame) => (JSON.parse(frame) as { t: string }).t)).toEqual(['file-offer']);
137
+ });
138
+
139
+ it('refuses an offer over the size limit without asking anyone', async () => {
140
+ const bytes = Buffer.alloc(2_000);
141
+ const { a, b } = channelPair();
142
+ let asked = false;
143
+ const [sent, received] = await Promise.all([
144
+ sendFileOverChannel({ channel: a, offer: offerFor(bytes), source: sourceOf(bytes) }),
145
+ receiveFileOverChannel({
146
+ channel: b,
147
+ maxBytes: 1_000,
148
+ admit: async () => {
149
+ asked = true;
150
+ return { sink: memorySink() };
151
+ },
152
+ }),
153
+ ]);
154
+ expect(asked).toBe(false);
155
+ expect(sent).toMatchObject({ ok: false, reason: 'too-large' });
156
+ expect(received).toMatchObject({ ok: false, reason: 'too-large' });
157
+ });
158
+
159
+ it('discards content that does not match the offered hash', async () => {
160
+ const bytes = Buffer.from('the real content');
161
+ const { a, b } = channelPair();
162
+ const sink = memorySink();
163
+ const [sent, received] = await Promise.all([
164
+ sendFileOverChannel({
165
+ channel: a,
166
+ offer: offerFor(bytes, { sha256: sha256(Buffer.from('something else')) }),
167
+ source: sourceOf(bytes),
168
+ }),
169
+ receiveFileOverChannel({ channel: b, admit: async () => ({ sink }) }),
170
+ ]);
171
+ expect(received).toMatchObject({ ok: false, reason: 'integrity' });
172
+ expect(sent).toMatchObject({ ok: false, reason: 'integrity' });
173
+ expect(sink.state).toBe('discarded');
174
+ });
175
+
176
+ it('discards a transfer the sender abandons midway', async () => {
177
+ const bytes = Buffer.alloc(200_000, 1);
178
+ const { a, b } = channelPair();
179
+ const sink = memorySink();
180
+ const source: IFileSource = {
181
+ size: bytes.length,
182
+ async *read() {
183
+ yield bytes.subarray(0, 50_000);
184
+ throw new Error('disk went away');
185
+ },
186
+ };
187
+ const [sent, received] = await Promise.all([
188
+ sendFileOverChannel({ channel: a, offer: offerFor(bytes), source }),
189
+ receiveFileOverChannel({ channel: b, admit: async () => ({ sink }) }),
190
+ ]);
191
+ expect(sent.ok).toBe(false);
192
+ expect(received).toMatchObject({ ok: false, reason: 'closed' });
193
+ expect(sink.state).toBe('discarded');
194
+ });
195
+
196
+ it('keeps no more than the credit window in flight', async () => {
197
+ const bytes = Buffer.alloc(40 * 1024 * 16, 7);
198
+ const { a, b } = channelPair();
199
+ let release: () => void = () => undefined;
200
+ const stalled = new Promise<void>((resolve) => {
201
+ release = resolve;
202
+ });
203
+ let writes = 0;
204
+ const sink: IFileSink = {
205
+ write: async () => {
206
+ writes += 1;
207
+ // The receiver's disk stalls on the first write.
208
+ if (writes === 1) await stalled;
209
+ },
210
+ commit: async () => '/q',
211
+ discard: async () => undefined,
212
+ };
213
+ const sending = sendFileOverChannel({
214
+ channel: a,
215
+ offer: offerFor(bytes),
216
+ source: sourceOf(bytes),
217
+ });
218
+ const receiving = receiveFileOverChannel({ channel: b, admit: async () => ({ sink }) });
219
+ await new Promise((resolve) => setTimeout(resolve, 100));
220
+ const chunks = a.sent.filter((frame) => frame.includes('"file-chunk"')).length;
221
+ expect(chunks).toBe(FILE_CREDIT_WINDOW);
222
+ release();
223
+ await expect(sending).resolves.toEqual({ ok: true });
224
+ await expect(receiving).resolves.toMatchObject({ ok: true });
225
+ });
226
+
227
+ it('ends a transfer whose sender ignores its credit', async () => {
228
+ const { a, b } = channelPair();
229
+ const bytes = Buffer.alloc(10);
230
+ const receiving = receiveFileOverChannel({
231
+ channel: b,
232
+ admit: async () => ({
233
+ sink: {
234
+ write: () => new Promise<void>((resolve) => setTimeout(resolve, 50)),
235
+ commit: async () => '',
236
+ discard: async () => undefined,
237
+ },
238
+ }),
239
+ });
240
+ a.onFrame(() => undefined);
241
+ a.send(JSON.stringify({ t: 'file-offer', ...offerFor(bytes, { size: 10 * 1024 * 1024 }) }));
242
+ await new Promise((resolve) => setTimeout(resolve, 20));
243
+ for (let seq = 0; seq < FILE_CREDIT_WINDOW + 5; seq += 1) {
244
+ a.send(JSON.stringify({ t: 'file-chunk', seq, data: Buffer.alloc(16).toString('base64') }));
245
+ }
246
+ await expect(receiving).resolves.toMatchObject({ ok: false, reason: 'protocol' });
247
+ });
248
+
249
+ it('passes on a refusal detail only as printable text', async () => {
250
+ const bytes = Buffer.from('x');
251
+ const { a, b } = channelPair();
252
+ b.onFrame(() => {
253
+ b.send(
254
+ JSON.stringify({ t: 'file-refuse', reason: 'declined', detail: 'no\u001b[2J‮thanks' }),
255
+ );
256
+ });
257
+ const sent = await sendFileOverChannel({
258
+ channel: a,
259
+ offer: offerFor(bytes),
260
+ source: sourceOf(bytes),
261
+ });
262
+ expect(sent).toMatchObject({ ok: false, reason: 'declined' });
263
+ const detail = sent.ok ? '' : (sent.detail ?? '');
264
+ expect(detail).toContain('thanks');
265
+ expect([...detail].some((char) => (char.codePointAt(0) ?? 0) < 0x20)).toBe(false);
266
+ expect(detail).not.toContain('‮');
267
+ });
268
+
269
+ it('ends a transfer that sends an empty chunk', async () => {
270
+ const { a, b } = channelPair();
271
+ const bytes = Buffer.alloc(10);
272
+ const sink = memorySink();
273
+ const receiving = receiveFileOverChannel({ channel: b, admit: async () => ({ sink }) });
274
+ a.onFrame(() => undefined);
275
+ a.send(JSON.stringify({ t: 'file-offer', ...offerFor(bytes) }));
276
+ await new Promise((resolve) => setTimeout(resolve, 20));
277
+ a.send(JSON.stringify({ t: 'file-chunk', seq: 0, data: '' }));
278
+ await expect(receiving).resolves.toMatchObject({ ok: false, reason: 'protocol' });
279
+ expect(sink.state).toBe('discarded');
280
+ });
281
+ });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * #3189: a history larger than an attached terminal's backpressure budget reaches that terminal
3
+ * whole, over a real socket, without the connection being cut.
4
+ *
5
+ * The carrier here is wired as the supervised attach carrier is: every reply goes through
6
+ * `createOutboundDelivery` measuring the socket's `writableLength` against
7
+ * `ATTACHED_SURFACE_MAX_PENDING_BYTES`, and a failure destroys the socket. The client sends what an
8
+ * attached terminal sends when it starts, all at once, and pages the history as it arrives.
9
+ */
10
+
11
+ import { mkdtempSync, rmSync } from 'node:fs';
12
+ import { createConnection, createServer } from 'node:net';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
17
+ import { afterEach, describe, expect, it } from 'vitest';
18
+
19
+ import {
20
+ ATTACHED_SURFACE_MAX_PENDING_BYTES,
21
+ createOutboundDelivery,
22
+ } from '../outbound-delivery.js';
23
+ import { createSessionMessageHandler } from '../session-message-handler.js';
24
+
25
+ import type { TClientMessage, TServerMessage } from '../wire-messages.js';
26
+ import type { IHistoryEntry } from '@robota-sdk/agent-core';
27
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
28
+ import type { Server, Socket } from 'node:net';
29
+
30
+ type TListener = (...args: unknown[]) => void;
31
+
32
+ const ENTRY_BYTES = 20_000;
33
+ /** Half as much again as the budget, so one frame holding it all would trip it. */
34
+ const HISTORY_BYTES = ATTACHED_SURFACE_MAX_PENDING_BYTES * 1.5;
35
+
36
+ const STARTUP: readonly TClientMessage[] = [
37
+ { type: 'get-history' },
38
+ { type: 'get-context' },
39
+ { type: 'get-commands' },
40
+ { type: 'get-status' },
41
+ { type: 'get-executing' },
42
+ { type: 'get-pending' },
43
+ { type: 'get-execution-workspace' },
44
+ ];
45
+
46
+ function longHistory(): IHistoryEntry[] {
47
+ return Array.from({ length: Math.ceil(HISTORY_BYTES / ENTRY_BYTES) }, (_, index) => ({
48
+ id: `e${index}`,
49
+ timestamp: new Date('2026-09-26T00:00:00.000Z'),
50
+ category: 'chat',
51
+ type: 'assistant',
52
+ data: { role: 'assistant', content: 'x'.repeat(ENTRY_BYTES) },
53
+ }));
54
+ }
55
+
56
+ function emittingSession(
57
+ history: IHistoryEntry[],
58
+ ): IInteractiveSession & { emit: (event: string, payload: unknown) => void } {
59
+ const listeners = new Map<string, Set<TListener>>();
60
+ const session = createTestInteractiveSession({
61
+ getFullHistory: () => history,
62
+ on: ((event: string, handler: TListener) => {
63
+ if (!listeners.has(event)) listeners.set(event, new Set());
64
+ listeners.get(event)!.add(handler);
65
+ }) as IInteractiveSession['on'],
66
+ off: ((event: string, handler: TListener) => {
67
+ listeners.get(event)?.delete(handler);
68
+ }) as IInteractiveSession['off'],
69
+ });
70
+ return Object.assign(session, {
71
+ emit: (event: string, payload: unknown) => {
72
+ for (const handler of listeners.get(event) ?? []) handler(payload);
73
+ },
74
+ });
75
+ }
76
+
77
+ /** One connection's carrier, as the supervised attach carrier builds it. */
78
+ function serve(session: IInteractiveSession, socket: Socket, onCut: (error: Error) => void): void {
79
+ const deliver = createOutboundDelivery(
80
+ (message) => {
81
+ socket.write(`${JSON.stringify(message)}\n`);
82
+ },
83
+ (error) => {
84
+ onCut(error);
85
+ socket.destroy();
86
+ },
87
+ () => socket.writableLength,
88
+ ATTACHED_SURFACE_MAX_PENDING_BYTES,
89
+ );
90
+ const { onMessage, cleanup } = createSessionMessageHandler({ session, deliver });
91
+ let buffered = '';
92
+ socket.setEncoding('utf8');
93
+ socket.on('data', (chunk: string) => {
94
+ buffered += chunk;
95
+ // Every complete line of a chunk is handled in one pass, as the carrier's drain loop does.
96
+ for (let end = buffered.indexOf('\n'); end !== -1; end = buffered.indexOf('\n')) {
97
+ const line = buffered.slice(0, end);
98
+ buffered = buffered.slice(end + 1);
99
+ onMessage(line);
100
+ }
101
+ });
102
+ socket.on('close', cleanup);
103
+ }
104
+
105
+ interface IClient {
106
+ readonly frames: TServerMessage[];
107
+ readonly closed: Promise<void>;
108
+ send(message: TClientMessage): void;
109
+ next(predicate: (frame: TServerMessage) => boolean): Promise<TServerMessage>;
110
+ end(): void;
111
+ }
112
+
113
+ function connect(path: string): Promise<IClient> {
114
+ return new Promise((resolve, reject) => {
115
+ const socket = createConnection(path);
116
+ const frames: TServerMessage[] = [];
117
+ const waiters: {
118
+ predicate: (frame: TServerMessage) => boolean;
119
+ found: (f: TServerMessage) => void;
120
+ lost: (error: Error) => void;
121
+ }[] = [];
122
+ let buffered = '';
123
+ let markClosed: () => void = () => undefined;
124
+ const closed = new Promise<void>((done) => {
125
+ markClosed = done;
126
+ });
127
+ socket.setEncoding('utf8');
128
+ socket.on('data', (chunk: string) => {
129
+ buffered += chunk;
130
+ for (let end = buffered.indexOf('\n'); end !== -1; end = buffered.indexOf('\n')) {
131
+ const frame = JSON.parse(buffered.slice(0, end)) as TServerMessage;
132
+ buffered = buffered.slice(end + 1);
133
+ frames.push(frame);
134
+ for (const waiter of waiters.splice(0)) {
135
+ if (waiter.predicate(frame)) waiter.found(frame);
136
+ else waiters.push(waiter);
137
+ }
138
+ }
139
+ });
140
+ socket.on('close', () => {
141
+ markClosed();
142
+ for (const waiter of waiters.splice(0))
143
+ waiter.lost(new Error('the host closed the connection'));
144
+ });
145
+ socket.on('error', reject);
146
+ socket.on('connect', () =>
147
+ resolve({
148
+ frames,
149
+ closed,
150
+ send: (message) => socket.write(`${JSON.stringify(message)}\n`),
151
+ next: (predicate) =>
152
+ new Promise((found, lost) => {
153
+ waiters.push({ predicate, found, lost });
154
+ }),
155
+ end: () => socket.end(),
156
+ }),
157
+ );
158
+ });
159
+ }
160
+
161
+ function isHistory(frame: TServerMessage): frame is Extract<TServerMessage, { type: 'history' }> {
162
+ return frame.type === 'history';
163
+ }
164
+
165
+ describe('a history larger than the attached-surface budget (#3189)', () => {
166
+ let server: Server | undefined;
167
+ let dir: string | undefined;
168
+
169
+ afterEach(async () => {
170
+ await new Promise<void>((done) => (server ? server.close(() => done()) : done()));
171
+ if (dir) rmSync(dir, { recursive: true, force: true });
172
+ });
173
+
174
+ it('reaches an attached terminal whole, and the turn after it does not cut the terminal off', async () => {
175
+ const history = longHistory();
176
+ const session = emittingSession(history);
177
+ const cuts: Error[] = [];
178
+ dir = mkdtempSync(join(tmpdir(), 'rh-'));
179
+ const path = join(dir, 's.sock');
180
+ server = createServer((socket) => serve(session, socket, (error) => cuts.push(error)));
181
+ await new Promise<void>((done) => server!.listen(path, done));
182
+ const client = await connect(path);
183
+
184
+ // What an attached terminal sends when it starts, in one burst.
185
+ for (const message of STARTUP) client.send(message);
186
+ const received: string[] = [];
187
+ let page = (await client.next(isHistory)) as Extract<TServerMessage, { type: 'history' }>;
188
+ for (;;) {
189
+ received.push(...page.entries.map((entry) => entry.id));
190
+ const next = page.startIndex + page.entries.length;
191
+ if (next >= page.total) break;
192
+ const arriving = client.next(isHistory);
193
+ client.send({ type: 'get-history', fromIndex: next });
194
+ page = (await arriving) as Extract<TServerMessage, { type: 'history' }>;
195
+ }
196
+ expect(received).toEqual(history.map((entry) => entry.id));
197
+
198
+ // A turn ends: its result, then more frames at once, as the host sends them.
199
+ const thinking = client.next((frame) => frame.type === 'thinking');
200
+ session.emit('complete', {
201
+ response: 'done',
202
+ history,
203
+ toolSummaries: [],
204
+ contextState: { usedTokens: 1, maxTokens: 10, usedPercentage: 10 },
205
+ });
206
+ session.emit('thinking', false);
207
+ await thinking;
208
+
209
+ expect(cuts).toEqual([]);
210
+ expect(client.frames.map((frame) => frame.type)).toEqual(
211
+ expect.arrayContaining(['context', 'commands', 'session_status', 'pending', 'complete']),
212
+ );
213
+ client.end();
214
+ await client.closed;
215
+ }, 20_000);
216
+ });