@smooai/smooth-operator 0.1.0

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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Runtime validation against the spec JSON Schemas, using ajv.
3
+ *
4
+ * The spec ships draft 2020-12 schemas with internal `$defs` (no cross-file
5
+ * `$ref`s). We register every schema under its `$id` so ajv can resolve any
6
+ * `#/$defs/...` pointer within it, then expose:
7
+ *
8
+ * - `validateAt(schemaRef, instance)` — validate against a spec-relative ref like
9
+ * `events/stream-chunk.schema.json` or `actions/send-message.schema.json#/$defs/Request`
10
+ * (the exact form used by `conformance/fixtures.json`).
11
+ * - `validateEvent` / `validateAction` — convenience validators that pick the
12
+ * right schema from a frame's discriminator and validate it.
13
+ *
14
+ * Schemas are loaded from the spec directory on disk. This module is Node-only
15
+ * (it reads files); it is intended for build/test/server use, not the browser
16
+ * bundle. The wire client (`client.ts`) does not import it — validation is opt-in.
17
+ */
18
+ import _Ajv2020, { Ajv2020 as AjvClass } from 'ajv/dist/2020.js';
19
+ import _addFormats from 'ajv-formats';
20
+ const Ajv2020 = (_Ajv2020.default ?? _Ajv2020);
21
+ const addFormats = (_addFormats.default ?? _addFormats);
22
+ import { readFile, readdir } from 'node:fs/promises';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, join } from 'node:path';
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ /** Default location of the spec dir relative to this source file (../../spec). */
27
+ export const DEFAULT_SPEC_DIR = join(__dirname, '..', '..', 'spec');
28
+ /** Maps an event `type` to its schema file (spec-relative). */
29
+ const EVENT_SCHEMA_FILE = {
30
+ immediate_response: 'events/immediate-response.schema.json',
31
+ eventual_response: 'events/eventual-response.schema.json',
32
+ stream_chunk: 'events/stream-chunk.schema.json',
33
+ stream_token: 'events/stream-token.schema.json',
34
+ keepalive: 'events/keepalive.schema.json',
35
+ write_confirmation_required: 'events/write-confirmation-required.schema.json',
36
+ otp_verification_required: 'events/otp-verification-required.schema.json',
37
+ otp_sent: 'events/otp-sent.schema.json',
38
+ otp_verified: 'events/otp-verified.schema.json',
39
+ otp_invalid: 'events/otp-invalid.schema.json',
40
+ error: 'events/error.schema.json',
41
+ pong: 'events/pong.schema.json',
42
+ };
43
+ /** Maps an action `action` to its request schema ref (spec-relative). */
44
+ const ACTION_SCHEMA_REF = {
45
+ create_conversation_session: 'actions/create-conversation-session.schema.json#/$defs/Request',
46
+ send_message: 'actions/send-message.schema.json#/$defs/Request',
47
+ get_session: 'actions/get-session.schema.json#/$defs/Request',
48
+ get_conversation_messages: 'actions/get-messages.schema.json#/$defs/Request',
49
+ confirm_tool_action: 'actions/confirm-tool-action.schema.json#/$defs/Request',
50
+ verify_otp: 'actions/verify-otp.schema.json#/$defs/Request',
51
+ ping: 'actions/ping.schema.json#/$defs/Request',
52
+ };
53
+ export class ProtocolValidator {
54
+ ajv;
55
+ /** spec-relative file path → the schema's `$id` (used to build `$ref`-able URIs). */
56
+ fileToId = new Map();
57
+ cache = new Map();
58
+ constructor(ajv) {
59
+ this.ajv = ajv;
60
+ }
61
+ /** Load every `*.schema.json` under `specDir` and register it with ajv. */
62
+ static async load(specDir = DEFAULT_SPEC_DIR) {
63
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
64
+ addFormats(ajv);
65
+ const validator = new ProtocolValidator(ajv);
66
+ for (const sub of ['', 'actions', 'events', 'domain']) {
67
+ const dir = sub ? join(specDir, sub) : specDir;
68
+ const entries = await readdir(dir, { withFileTypes: true });
69
+ for (const e of entries) {
70
+ if (!e.isFile() || !e.name.endsWith('.schema.json'))
71
+ continue;
72
+ const rel = sub ? `${sub}/${e.name}` : e.name;
73
+ const schema = JSON.parse(await readFile(join(dir, e.name), 'utf8'));
74
+ const id = schema.$id ?? `urn:smooth-agent:${rel}`;
75
+ // ajv throws if the same $id is added twice; guard against it.
76
+ if (!ajv.getSchema(id))
77
+ ajv.addSchema(schema, id);
78
+ validator.fileToId.set(rel, id);
79
+ }
80
+ }
81
+ return validator;
82
+ }
83
+ /**
84
+ * Validate `instance` against a spec-relative schema ref. The ref is the form
85
+ * used in `fixtures.json`: a file path, optionally with a JSON-pointer fragment
86
+ * into the schema's `$defs` (e.g. `actions/ping.schema.json#/$defs/Request`).
87
+ */
88
+ validateAt(schemaRef, instance) {
89
+ const validate = this.compile(schemaRef);
90
+ const valid = validate(instance);
91
+ return { valid, errors: valid ? [] : (validate.errors ?? []) };
92
+ }
93
+ /** Validate a server event frame by selecting the schema from its `type`. */
94
+ validateEvent(frame) {
95
+ const file = EVENT_SCHEMA_FILE[frame.type];
96
+ if (!file) {
97
+ return { valid: false, errors: [syntheticError(`Unknown event type: ${String(frame.type)}`)] };
98
+ }
99
+ return this.validateAt(file, frame);
100
+ }
101
+ /** Validate a client action frame by selecting the schema from its `action`. */
102
+ validateAction(frame) {
103
+ const ref = ACTION_SCHEMA_REF[frame.action];
104
+ if (!ref) {
105
+ return { valid: false, errors: [syntheticError(`Unknown action: ${String(frame.action)}`)] };
106
+ }
107
+ return this.validateAt(ref, frame);
108
+ }
109
+ compile(schemaRef) {
110
+ const cached = this.cache.get(schemaRef);
111
+ if (cached)
112
+ return cached;
113
+ const [file, pointer] = schemaRef.split('#');
114
+ const id = this.fileToId.get(file);
115
+ if (!id)
116
+ throw new Error(`No schema registered for "${file}" (ref "${schemaRef}")`);
117
+ // Resolve via the registered $id + optional JSON-pointer fragment.
118
+ const uri = pointer ? `${id}#${pointer}` : id;
119
+ const validate = this.ajv.getSchema(uri);
120
+ if (!validate)
121
+ throw new Error(`ajv could not resolve schema ref "${schemaRef}" (uri "${uri}")`);
122
+ this.cache.set(schemaRef, validate);
123
+ return validate;
124
+ }
125
+ }
126
+ function syntheticError(message) {
127
+ return { instancePath: '', schemaPath: '', keyword: 'protocol', params: {}, message };
128
+ }
129
+ /** Render ajv errors into a single human-readable string. */
130
+ export function formatErrors(errors) {
131
+ return errors.map((e) => `${e.instancePath || '<root>'} ${e.message ?? ''}`.trim()).join('; ');
132
+ }
133
+ //# sourceMappingURL=validate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@smooai/smooth-operator",
3
+ "version": "0.1.0",
4
+ "description": "TypeScript protocol types and native WebSocket client for the smooth-operator protocol. Generated from the language-neutral JSON Schemas in spec/.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "main": "./dist/index.js",
11
+ "module": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./validate": {
19
+ "types": "./dist/validate.d.ts",
20
+ "import": "./dist/validate.js"
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/SmooAI/smooth-operator.git",
29
+ "directory": "typescript"
30
+ },
31
+ "homepage": "https://github.com/SmooAI/smooth-operator/tree/main/typescript",
32
+ "keywords": [
33
+ "smooth-operator",
34
+ "websocket",
35
+ "protocol",
36
+ "client",
37
+ "ai",
38
+ "agent",
39
+ "streaming"
40
+ ],
41
+ "files": [
42
+ "dist",
43
+ "src"
44
+ ],
45
+ "dependencies": {
46
+ "ajv": "^8.17.1",
47
+ "ajv-formats": "^3.0.1"
48
+ },
49
+ "devDependencies": {
50
+ "@apidevtools/json-schema-ref-parser": "^11.7.3",
51
+ "@types/node": "^25.9.2",
52
+ "json-schema-to-typescript": "^15.0.4",
53
+ "tsx": "^4.19.2",
54
+ "typescript": "^5.7.3",
55
+ "vitest": "^2.1.8"
56
+ },
57
+ "scripts": {
58
+ "generate": "tsx scripts/generate.ts",
59
+ "build": "tsc -p tsconfig.json",
60
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest",
63
+ "test:e2e": "vitest run --config vitest.e2e.config.ts"
64
+ }
65
+ }
package/src/client.ts ADDED
@@ -0,0 +1,435 @@
1
+ /**
2
+ * SmoothAgentClient — a minimal, idiomatic, transport-agnostic client for the
3
+ * smooth-operator WebSocket protocol.
4
+ *
5
+ * Design goals
6
+ * ------------
7
+ * - **Transport-agnostic.** The client never touches a real socket directly; it
8
+ * talks to an injectable {@link Transport}. The default ({@link WebSocketTransport})
9
+ * uses the global `WebSocket`, but tests inject a mock and Node can inject `ws`.
10
+ * - **Request/response correlation by `requestId`.** Every action gets a generated
11
+ * `requestId`; the client routes incoming events back to the originating call.
12
+ * - **Streaming as an async iterator.** `sendMessage` returns a {@link MessageTurn}
13
+ * that is both awaitable (resolves with the terminal `eventual_response`) and
14
+ * async-iterable (yields each `stream_token` / `stream_chunk` / HITL event in
15
+ * order). This models the `stream_token`/`stream_chunk` → `eventual_response`
16
+ * flow without forcing a callback style on the caller.
17
+ * - **No live server required.** Correctness is fully unit-testable with a mock
18
+ * transport (see `test/client.test.ts`).
19
+ */
20
+ import {
21
+ WebSocketTransport,
22
+ type Transport,
23
+ type WebSocketFactory,
24
+ } from './transport.js';
25
+ import type {
26
+ ClientAction,
27
+ CreateConversationSessionRequest,
28
+ CreateConversationSessionResponse,
29
+ EventualResponse,
30
+ GetMessagesRequest,
31
+ GetMessagesResponse,
32
+ GetSessionRequest,
33
+ GetSessionResponse,
34
+ SendMessageRequest,
35
+ ServerEvent,
36
+ } from './types.js';
37
+ import { isServerEvent } from './types.js';
38
+
39
+ export interface SmoothAgentClientOptions {
40
+ /** WebSocket URL, e.g. `wss://realtime.prod.smooth-agent.dev`. */
41
+ url: string;
42
+ /** Inject a transport (for tests / non-browser runtimes). Defaults to a WebSocket transport. */
43
+ transport?: Transport;
44
+ /** Inject a WebSocket factory used by the default transport (e.g. the `ws` package on Node). */
45
+ webSocketFactory?: WebSocketFactory;
46
+ /** Generate request IDs. Defaults to `crypto.randomUUID()` with a `req-` prefix. */
47
+ generateRequestId?: () => string;
48
+ /** Per-request timeout in ms for non-streaming actions. Default 30000. */
49
+ requestTimeout?: number;
50
+ /**
51
+ * Overall timeout in ms for a streaming `sendMessage` turn: if the server accepts
52
+ * the message but never emits a terminal `eventual_response` / `error`, the turn
53
+ * rejects with a {@link TurnTimeoutError} instead of hanging forever. Default
54
+ * 120000. Set to 0 (or a negative number) to disable.
55
+ */
56
+ turnTimeout?: number;
57
+ }
58
+
59
+ /** Events that terminate a streaming turn (success or error). */
60
+ const TURN_TERMINAL = new Set(['eventual_response', 'error']);
61
+
62
+ /** A timeout that yields no terminal event. */
63
+ class RequestTimeoutError extends Error {
64
+ constructor(requestId: string, ms: number) {
65
+ super(`Request ${requestId} timed out after ${ms}ms`);
66
+ this.name = 'RequestTimeoutError';
67
+ }
68
+ }
69
+
70
+ /**
71
+ * A streaming turn that received no terminal `eventual_response` / `error` within the
72
+ * configured {@link SmoothAgentClientOptions.turnTimeout}. The turn rejects with this
73
+ * and its async iteration throws it, so a stuck server can never hang the caller.
74
+ */
75
+ export class TurnTimeoutError extends Error {
76
+ readonly requestId: string;
77
+ constructor(requestId: string, ms: number) {
78
+ super(`Turn ${requestId} timed out after ${ms}ms without a terminal response`);
79
+ this.name = 'TurnTimeoutError';
80
+ this.requestId = requestId;
81
+ }
82
+ }
83
+
84
+ /** A protocol-level error event surfaced as a throwable. */
85
+ export class ProtocolError extends Error {
86
+ readonly code: string;
87
+ readonly requestId?: string;
88
+ constructor(code: string, message: string, requestId?: string) {
89
+ super(message);
90
+ this.name = 'ProtocolError';
91
+ this.code = code;
92
+ this.requestId = requestId;
93
+ }
94
+ }
95
+
96
+ /** Internal record for an in-flight single-response request. */
97
+ interface PendingRequest {
98
+ resolve: (event: ServerEvent) => void;
99
+ reject: (err: unknown) => void;
100
+ timer: ReturnType<typeof setTimeout> | undefined;
101
+ }
102
+
103
+ /**
104
+ * A streaming message turn. Await it for the terminal {@link EventualResponse},
105
+ * or async-iterate it to receive every intermediate event in arrival order.
106
+ *
107
+ * ```ts
108
+ * const turn = client.sendMessage({ sessionId, message: 'hi' });
109
+ * for await (const ev of turn) {
110
+ * if (ev.type === 'stream_token') process.stdout.write(ev.token ?? '');
111
+ * }
112
+ * const final = await turn; // EventualResponse
113
+ * ```
114
+ */
115
+ export class MessageTurn implements AsyncIterable<ServerEvent>, PromiseLike<EventualResponse> {
116
+ /** The requestId this turn is correlated on. */
117
+ readonly requestId: string;
118
+
119
+ private readonly queue: ServerEvent[] = [];
120
+ private waiter: {
121
+ resolve: (result: IteratorResult<ServerEvent>) => void;
122
+ reject: (err: unknown) => void;
123
+ } | null = null;
124
+ private done = false;
125
+ private finalEvent: EventualResponse | null = null;
126
+ private error: unknown = null;
127
+ private readonly settled: Promise<EventualResponse>;
128
+ private settle!: (value: EventualResponse) => void;
129
+ private fail!: (err: unknown) => void;
130
+ private readonly onClose: () => void;
131
+ private timeoutTimer: ReturnType<typeof setTimeout> | undefined;
132
+
133
+ constructor(requestId: string, onClose: () => void, turnTimeout = 0) {
134
+ this.requestId = requestId;
135
+ this.onClose = onClose;
136
+ this.settled = new Promise<EventualResponse>((resolve, reject) => {
137
+ this.settle = resolve;
138
+ this.fail = reject;
139
+ });
140
+ // Avoid unhandled-rejection noise if the caller only iterates and never awaits.
141
+ this.settled.catch(() => {});
142
+ // Bound the turn: a server that accepts the message but never emits a terminal
143
+ // event must not hang the caller forever.
144
+ if (turnTimeout > 0) {
145
+ this.timeoutTimer = setTimeout(() => {
146
+ this.finish(null, new TurnTimeoutError(this.requestId, turnTimeout));
147
+ }, turnTimeout);
148
+ }
149
+ }
150
+
151
+ /** Feed an event into the turn (called by the client's dispatcher). */
152
+ push(event: ServerEvent): void {
153
+ if (this.done) return;
154
+
155
+ if (event.type === 'error') {
156
+ const code = event.data?.error?.code ?? 'INTERNAL_ERROR';
157
+ const message = event.data?.error?.message ?? 'Unknown protocol error';
158
+ this.deliver(event);
159
+ this.finish(null, new ProtocolError(code, message, this.requestId));
160
+ return;
161
+ }
162
+
163
+ this.deliver(event);
164
+
165
+ if (event.type === 'eventual_response') {
166
+ this.finish(event, null);
167
+ }
168
+ }
169
+
170
+ /** Force-close the turn (e.g. on disconnect) with an error. */
171
+ abort(err: unknown): void {
172
+ if (this.done) return;
173
+ this.finish(null, err);
174
+ }
175
+
176
+ private deliver(event: ServerEvent): void {
177
+ if (this.waiter) {
178
+ const w = this.waiter;
179
+ this.waiter = null;
180
+ w.resolve({ value: event, done: false });
181
+ } else {
182
+ this.queue.push(event);
183
+ }
184
+ }
185
+
186
+ private finish(final: EventualResponse | null, err: unknown): void {
187
+ if (this.done) return;
188
+ this.done = true;
189
+ this.finalEvent = final;
190
+ this.error = err;
191
+ if (this.timeoutTimer) {
192
+ clearTimeout(this.timeoutTimer);
193
+ this.timeoutTimer = undefined;
194
+ }
195
+ this.onClose();
196
+
197
+ if (err) this.fail(err);
198
+ else if (final) this.settle(final);
199
+
200
+ // Release any pending iterator waiter now that the stream has ended. On error
201
+ // the parked next() must *reject* (mirroring the queued-error path in next())
202
+ // so a pure `for await` consumer sees the terminal error thrown instead of a
203
+ // silent, indistinguishable `{ done: true }`.
204
+ if (this.waiter) {
205
+ const w = this.waiter;
206
+ this.waiter = null;
207
+ if (err) {
208
+ w.reject(err);
209
+ } else {
210
+ w.resolve({ value: undefined as never, done: true });
211
+ }
212
+ }
213
+ }
214
+
215
+ [Symbol.asyncIterator](): AsyncIterator<ServerEvent> {
216
+ return {
217
+ next: (): Promise<IteratorResult<ServerEvent>> => {
218
+ if (this.queue.length > 0) {
219
+ return Promise.resolve({ value: this.queue.shift()!, done: false });
220
+ }
221
+ if (this.done) {
222
+ if (this.error) return Promise.reject(this.error);
223
+ return Promise.resolve({ value: undefined as never, done: true });
224
+ }
225
+ return new Promise<IteratorResult<ServerEvent>>((resolve, reject) => {
226
+ this.waiter = { resolve, reject };
227
+ });
228
+ },
229
+ };
230
+ }
231
+
232
+ // PromiseLike — `await turn` resolves with the EventualResponse.
233
+ then<TResult1 = EventualResponse, TResult2 = never>(
234
+ onfulfilled?: ((value: EventualResponse) => TResult1 | PromiseLike<TResult1>) | null,
235
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
236
+ ): PromiseLike<TResult1 | TResult2> {
237
+ return this.settled.then(onfulfilled, onrejected);
238
+ }
239
+ }
240
+
241
+ export class SmoothAgentClient {
242
+ private readonly transport: Transport;
243
+ private readonly generateRequestId: () => string;
244
+ private readonly requestTimeout: number;
245
+ private readonly turnTimeout: number;
246
+
247
+ /** requestId → single-response waiter (create_session, get_session, ping, …). */
248
+ private readonly pending = new Map<string, PendingRequest>();
249
+ /** requestId → active streaming turn (send_message, and HITL resumes). */
250
+ private readonly turns = new Map<string, MessageTurn>();
251
+ /** Unsolicited-event listeners (keepalive, server-push). */
252
+ private readonly listeners = new Set<(event: ServerEvent) => void>();
253
+
254
+ private unsubscribe: Array<() => void> = [];
255
+
256
+ constructor(options: SmoothAgentClientOptions) {
257
+ this.transport = options.transport ?? new WebSocketTransport(options.url, options.webSocketFactory);
258
+ this.requestTimeout = options.requestTimeout ?? 30_000;
259
+ this.turnTimeout = options.turnTimeout ?? 120_000;
260
+ this.generateRequestId =
261
+ options.generateRequestId ??
262
+ (() => `req-${(globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2))}`);
263
+
264
+ this.unsubscribe.push(this.transport.onMessage((data) => this.handleFrame(data)));
265
+ this.unsubscribe.push(
266
+ this.transport.onClose(() => this.failAll(new Error('Transport closed'))),
267
+ );
268
+ }
269
+
270
+ /** Open the underlying transport. */
271
+ async connect(): Promise<void> {
272
+ await this.transport.connect();
273
+ }
274
+
275
+ /** Close the transport and reject all in-flight work. */
276
+ disconnect(reason = 'client disconnect'): void {
277
+ this.failAll(new Error(reason));
278
+ for (const u of this.unsubscribe) u();
279
+ this.unsubscribe = [];
280
+ this.transport.close(1000, reason);
281
+ }
282
+
283
+ /** Subscribe to unsolicited / uncorrelated server events (e.g. keepalive). */
284
+ onEvent(listener: (event: ServerEvent) => void): () => void {
285
+ this.listeners.add(listener);
286
+ return () => this.listeners.delete(listener);
287
+ }
288
+
289
+ // ───────────────────────────── Actions ─────────────────────────────────
290
+
291
+ /** Start a new conversation session. Resolves with the session descriptor. */
292
+ async createConversationSession(
293
+ req: Omit<CreateConversationSessionRequest, 'action' | 'requestId'>,
294
+ ): Promise<CreateConversationSessionResponse> {
295
+ const event = await this.request({ action: 'create_conversation_session', ...req });
296
+ return extractImmediateData<CreateConversationSessionResponse>(event);
297
+ }
298
+
299
+ /** Fetch a session snapshot by ID. */
300
+ async getSession(req: Omit<GetSessionRequest, 'action' | 'requestId'>): Promise<GetSessionResponse> {
301
+ const event = await this.request({ action: 'get_session', ...req });
302
+ return extractImmediateData<GetSessionResponse>(event);
303
+ }
304
+
305
+ /** Fetch a page of conversation messages. */
306
+ async getMessages(req: Omit<GetMessagesRequest, 'action' | 'requestId'>): Promise<GetMessagesResponse> {
307
+ const event = await this.request({ action: 'get_conversation_messages', ...req });
308
+ return extractImmediateData<GetMessagesResponse>(event);
309
+ }
310
+
311
+ /** Keepalive ping. Resolves with the server timestamp from the `pong` event. */
312
+ async ping(): Promise<number> {
313
+ const event = await this.request({ action: 'ping' });
314
+ if (event.type === 'pong') return event.timestamp ?? event.data?.timestamp ?? Date.now();
315
+ return Date.now();
316
+ }
317
+
318
+ /**
319
+ * Submit a user message and return a {@link MessageTurn}: await it for the
320
+ * terminal `eventual_response`, or async-iterate it for the streaming events.
321
+ */
322
+ sendMessage(req: Omit<SendMessageRequest, 'action' | 'requestId'>): MessageTurn {
323
+ const requestId = this.generateRequestId();
324
+ const turn = new MessageTurn(requestId, () => this.turns.delete(requestId), this.turnTimeout);
325
+ this.turns.set(requestId, turn);
326
+ try {
327
+ this.transport.send(JSON.stringify({ action: 'send_message', requestId, ...req }));
328
+ } catch (err) {
329
+ this.turns.delete(requestId);
330
+ turn.abort(err);
331
+ }
332
+ return turn;
333
+ }
334
+
335
+ /**
336
+ * Approve or reject a pending tool write, resuming the paused turn identified
337
+ * by `requestId`. The resumed streaming events flow back into the original
338
+ * {@link MessageTurn} for that `requestId`.
339
+ */
340
+ confirmToolAction(req: { sessionId: string; requestId: string; approved: boolean }): void {
341
+ this.transport.send(JSON.stringify({ action: 'confirm_tool_action', ...req }));
342
+ }
343
+
344
+ /**
345
+ * Submit an OTP code, resuming the paused turn identified by `requestId`.
346
+ * The resumed streaming events flow back into the original {@link MessageTurn}.
347
+ */
348
+ verifyOtp(req: { sessionId: string; requestId: string; code: string }): void {
349
+ this.transport.send(JSON.stringify({ action: 'verify_otp', ...req }));
350
+ }
351
+
352
+ // ─────────────────────────── Internals ─────────────────────────────────
353
+
354
+ /** Send an action that expects a single correlated response event. */
355
+ private request(action: Omit<ClientAction, 'requestId'> & { requestId?: string }): Promise<ServerEvent> {
356
+ const requestId = action.requestId ?? this.generateRequestId();
357
+ const frame = { ...action, requestId } as ClientAction;
358
+
359
+ return new Promise<ServerEvent>((resolve, reject) => {
360
+ const timer =
361
+ this.requestTimeout > 0
362
+ ? setTimeout(() => {
363
+ this.pending.delete(requestId);
364
+ reject(new RequestTimeoutError(requestId, this.requestTimeout));
365
+ }, this.requestTimeout)
366
+ : undefined;
367
+
368
+ this.pending.set(requestId, { resolve, reject, timer });
369
+ try {
370
+ this.transport.send(JSON.stringify(frame));
371
+ } catch (err) {
372
+ if (timer) clearTimeout(timer);
373
+ this.pending.delete(requestId);
374
+ reject(err);
375
+ }
376
+ });
377
+ }
378
+
379
+ /** Parse and route an incoming frame to the right consumer. */
380
+ private handleFrame(data: string): void {
381
+ let frame: unknown;
382
+ try {
383
+ frame = JSON.parse(data);
384
+ } catch {
385
+ return; // ignore malformed frames
386
+ }
387
+ if (!isServerEvent(frame)) return;
388
+ const event = frame;
389
+ const requestId = event.requestId;
390
+
391
+ // 1. Streaming turn? Route every related event into it.
392
+ if (requestId && this.turns.has(requestId)) {
393
+ this.turns.get(requestId)!.push(event);
394
+ return;
395
+ }
396
+
397
+ // 2. Single-response request awaiting resolution?
398
+ if (requestId && this.pending.has(requestId)) {
399
+ const pending = this.pending.get(requestId)!;
400
+ this.pending.delete(requestId);
401
+ if (pending.timer) clearTimeout(pending.timer);
402
+
403
+ if (event.type === 'error') {
404
+ const code = event.data?.error?.code ?? 'INTERNAL_ERROR';
405
+ const message = event.data?.error?.message ?? 'Unknown protocol error';
406
+ pending.reject(new ProtocolError(code, message, requestId));
407
+ } else {
408
+ pending.resolve(event);
409
+ }
410
+ return;
411
+ }
412
+
413
+ // 3. Unsolicited / uncorrelated event (keepalive, server push).
414
+ for (const l of this.listeners) l(event);
415
+ }
416
+
417
+ private failAll(err: unknown): void {
418
+ for (const [, p] of this.pending) {
419
+ if (p.timer) clearTimeout(p.timer);
420
+ p.reject(err);
421
+ }
422
+ this.pending.clear();
423
+ for (const [, turn] of this.turns) turn.abort(err);
424
+ this.turns.clear();
425
+ }
426
+ }
427
+
428
+ /** Pull the typed `data` payload out of an `immediate_response` event. */
429
+ function extractImmediateData<T>(event: ServerEvent): T {
430
+ if (event.type === 'immediate_response') return event.data as T;
431
+ // Some servers may answer a non-streaming action with the payload elsewhere;
432
+ // fall back to `data` if present.
433
+ if ('data' in event && event.data && typeof event.data === 'object') return event.data as T;
434
+ throw new ProtocolError('UNEXPECTED_EVENT', `Expected immediate_response, got "${event.type}"`, event.requestId);
435
+ }