@moxxy/plugin-channel-http 0.26.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.
- package/LICENSE +21 -0
- package/dist/channel.d.ts +50 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +164 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/router.d.ts +95 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +354 -0
- package/dist/router.js.map +1 -0
- package/package.json +67 -0
- package/src/attach.test.ts +85 -0
- package/src/channel.test.ts +165 -0
- package/src/channel.ts +211 -0
- package/src/index.ts +58 -0
- package/src/integration.test.ts +254 -0
- package/src/router.test.ts +665 -0
- package/src/router.ts +424 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { Readable } from 'node:stream';
|
|
4
|
+
import { Socket } from 'node:net';
|
|
5
|
+
import { Session, silentLogger } from '@moxxy/core';
|
|
6
|
+
import { defineTranscriber } from '@moxxy/sdk';
|
|
7
|
+
import type { ClientSession } from '@moxxy/sdk';
|
|
8
|
+
import {
|
|
9
|
+
routeRequest,
|
|
10
|
+
handleHealth,
|
|
11
|
+
handleTurn,
|
|
12
|
+
handleTurnStream,
|
|
13
|
+
handleTurnAudio,
|
|
14
|
+
driveTurn,
|
|
15
|
+
turnRequestSchema,
|
|
16
|
+
TurnLimiter,
|
|
17
|
+
MAX_BUFFERED_EVENTS,
|
|
18
|
+
} from './router.js';
|
|
19
|
+
import type { MoxxyEvent } from '@moxxy/sdk';
|
|
20
|
+
|
|
21
|
+
function makeIncoming(opts: { method: string; url: string; headers?: Record<string, string>; body?: string }): IncomingMessage {
|
|
22
|
+
const readable = Readable.from(opts.body ? [Buffer.from(opts.body)] : []);
|
|
23
|
+
const socket = new Socket();
|
|
24
|
+
const req = readable as unknown as IncomingMessage;
|
|
25
|
+
Object.assign(req, {
|
|
26
|
+
method: opts.method,
|
|
27
|
+
url: opts.url,
|
|
28
|
+
headers: opts.headers ?? {},
|
|
29
|
+
socket,
|
|
30
|
+
});
|
|
31
|
+
return req;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeResponse(): ServerResponse & {
|
|
35
|
+
_status: number;
|
|
36
|
+
_headers: Record<string, string | number | string[]>;
|
|
37
|
+
_body: string;
|
|
38
|
+
_emit(event: string, ...args: unknown[]): void;
|
|
39
|
+
_writeReturns: boolean;
|
|
40
|
+
_fireTimeout(): void;
|
|
41
|
+
_timeoutMs: number;
|
|
42
|
+
} {
|
|
43
|
+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
|
44
|
+
const res = {
|
|
45
|
+
_status: 0,
|
|
46
|
+
_headers: {} as Record<string, string | number | string[]>,
|
|
47
|
+
_body: '',
|
|
48
|
+
_writeReturns: true,
|
|
49
|
+
_timeoutMs: -1,
|
|
50
|
+
_timeoutCb: undefined as undefined | (() => void),
|
|
51
|
+
headersSent: false,
|
|
52
|
+
writableEnded: false,
|
|
53
|
+
destroyed: false,
|
|
54
|
+
writeHead(status: number, headers: Record<string, string | number | string[]>) {
|
|
55
|
+
this._status = status;
|
|
56
|
+
this._headers = headers;
|
|
57
|
+
this.headersSent = true;
|
|
58
|
+
return this;
|
|
59
|
+
},
|
|
60
|
+
end(body?: string) {
|
|
61
|
+
if (this.writableEnded) throw new Error('write after end');
|
|
62
|
+
if (body !== undefined) this._body += body;
|
|
63
|
+
this.writableEnded = true;
|
|
64
|
+
return this;
|
|
65
|
+
},
|
|
66
|
+
write(chunk: string) {
|
|
67
|
+
if (this.writableEnded || this.destroyed) return false;
|
|
68
|
+
this._body += chunk;
|
|
69
|
+
return this._writeReturns;
|
|
70
|
+
},
|
|
71
|
+
// Mirror http.ServerResponse#setTimeout: setTimeout(0) disarms.
|
|
72
|
+
setTimeout(ms: number, cb?: () => void) {
|
|
73
|
+
this._timeoutMs = ms;
|
|
74
|
+
this._timeoutCb = ms > 0 ? cb : undefined;
|
|
75
|
+
return this;
|
|
76
|
+
},
|
|
77
|
+
// Test helper: simulate the socket-inactivity timeout firing.
|
|
78
|
+
_fireTimeout() {
|
|
79
|
+
this._timeoutCb?.();
|
|
80
|
+
},
|
|
81
|
+
on(event: string, fn: (...args: unknown[]) => void) {
|
|
82
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
83
|
+
listeners.get(event)!.add(fn);
|
|
84
|
+
return this;
|
|
85
|
+
},
|
|
86
|
+
once(event: string, fn: (...args: unknown[]) => void) {
|
|
87
|
+
const wrap = (...args: unknown[]): void => {
|
|
88
|
+
listeners.get(event)?.delete(wrap);
|
|
89
|
+
fn(...args);
|
|
90
|
+
};
|
|
91
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
92
|
+
listeners.get(event)!.add(wrap);
|
|
93
|
+
return this;
|
|
94
|
+
},
|
|
95
|
+
off(event: string, fn: (...args: unknown[]) => void) {
|
|
96
|
+
listeners.get(event)?.delete(fn);
|
|
97
|
+
return this;
|
|
98
|
+
},
|
|
99
|
+
_emit(event: string, ...args: unknown[]) {
|
|
100
|
+
for (const fn of [...(listeners.get(event) ?? [])]) fn(...args);
|
|
101
|
+
},
|
|
102
|
+
} as unknown as ServerResponse & {
|
|
103
|
+
_status: number;
|
|
104
|
+
_headers: Record<string, string | number | string[]>;
|
|
105
|
+
_body: string;
|
|
106
|
+
_emit(event: string, ...args: unknown[]): void;
|
|
107
|
+
_writeReturns: boolean;
|
|
108
|
+
_fireTimeout(): void;
|
|
109
|
+
_timeoutMs: number;
|
|
110
|
+
};
|
|
111
|
+
return res;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe('routeRequest', () => {
|
|
115
|
+
it('matches GET /v1/health', () => {
|
|
116
|
+
expect(routeRequest(makeIncoming({ method: 'GET', url: '/v1/health' }))).toBe(handleHealth);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns null for unknown routes', () => {
|
|
120
|
+
expect(routeRequest(makeIncoming({ method: 'GET', url: '/unknown' }))).toBeNull();
|
|
121
|
+
expect(routeRequest(makeIncoming({ method: 'PUT', url: '/v1/turn' }))).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('matches POST /v1/turn', () => {
|
|
125
|
+
expect(routeRequest(makeIncoming({ method: 'POST', url: '/v1/turn' }))).not.toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('matches POST /v1/turn/stream', () => {
|
|
129
|
+
expect(routeRequest(makeIncoming({ method: 'POST', url: '/v1/turn/stream' }))).not.toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('matches POST /v1/turn/audio (with or without query string)', () => {
|
|
133
|
+
expect(routeRequest(makeIncoming({ method: 'POST', url: '/v1/turn/audio' }))).toBe(
|
|
134
|
+
handleTurnAudio,
|
|
135
|
+
);
|
|
136
|
+
expect(
|
|
137
|
+
routeRequest(makeIncoming({ method: 'POST', url: '/v1/turn/audio?model=sonnet' })),
|
|
138
|
+
).toBe(handleTurnAudio);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('handleTurnAudio', () => {
|
|
143
|
+
const ctx = (session: Session) => ({ session, authToken: 'x', logger: silentLogger });
|
|
144
|
+
|
|
145
|
+
it('rejects requests without Bearer auth with 401', async () => {
|
|
146
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
147
|
+
const res = makeResponse();
|
|
148
|
+
await handleTurnAudio(
|
|
149
|
+
makeIncoming({ method: 'POST', url: '/v1/turn/audio', headers: { 'content-type': 'audio/ogg' } }),
|
|
150
|
+
res,
|
|
151
|
+
ctx(session),
|
|
152
|
+
);
|
|
153
|
+
expect(res._status).toBe(401);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('returns 503 when no transcriber is active on the session', async () => {
|
|
157
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
158
|
+
const res = makeResponse();
|
|
159
|
+
await handleTurnAudio(
|
|
160
|
+
makeIncoming({
|
|
161
|
+
method: 'POST',
|
|
162
|
+
url: '/v1/turn/audio',
|
|
163
|
+
headers: { 'content-type': 'audio/ogg', authorization: 'Bearer x' },
|
|
164
|
+
body: 'oggbytes',
|
|
165
|
+
}),
|
|
166
|
+
res,
|
|
167
|
+
ctx(session),
|
|
168
|
+
);
|
|
169
|
+
expect(res._status).toBe(503);
|
|
170
|
+
expect(JSON.parse(res._body).error).toBe('no_transcriber');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects non-audio Content-Type with 415', async () => {
|
|
174
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
175
|
+
session.transcribers.register(
|
|
176
|
+
defineTranscriber({
|
|
177
|
+
name: 't',
|
|
178
|
+
createClient: () => ({ name: 't', transcribe: async () => ({ text: 'x' }) }),
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
session.transcribers.setActive('t');
|
|
182
|
+
const res = makeResponse();
|
|
183
|
+
await handleTurnAudio(
|
|
184
|
+
makeIncoming({
|
|
185
|
+
method: 'POST',
|
|
186
|
+
url: '/v1/turn/audio',
|
|
187
|
+
headers: { 'content-type': 'application/octet-stream', authorization: 'Bearer x' },
|
|
188
|
+
body: 'bytes',
|
|
189
|
+
}),
|
|
190
|
+
res,
|
|
191
|
+
ctx(session),
|
|
192
|
+
);
|
|
193
|
+
expect(res._status).toBe(415);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('returns 400 on empty body', async () => {
|
|
197
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
198
|
+
session.transcribers.register(
|
|
199
|
+
defineTranscriber({
|
|
200
|
+
name: 't',
|
|
201
|
+
createClient: () => ({ name: 't', transcribe: async () => ({ text: 'x' }) }),
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
session.transcribers.setActive('t');
|
|
205
|
+
const res = makeResponse();
|
|
206
|
+
await handleTurnAudio(
|
|
207
|
+
makeIncoming({
|
|
208
|
+
method: 'POST',
|
|
209
|
+
url: '/v1/turn/audio',
|
|
210
|
+
headers: { 'content-type': 'audio/ogg', authorization: 'Bearer x' },
|
|
211
|
+
body: '',
|
|
212
|
+
}),
|
|
213
|
+
res,
|
|
214
|
+
ctx(session),
|
|
215
|
+
);
|
|
216
|
+
expect(res._status).toBe(400);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('returns 422 when the transcriber yields an empty transcript', async () => {
|
|
220
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
221
|
+
session.transcribers.register(
|
|
222
|
+
defineTranscriber({
|
|
223
|
+
name: 't',
|
|
224
|
+
createClient: () => ({ name: 't', transcribe: async () => ({ text: ' ' }) }),
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
session.transcribers.setActive('t');
|
|
228
|
+
const res = makeResponse();
|
|
229
|
+
await handleTurnAudio(
|
|
230
|
+
makeIncoming({
|
|
231
|
+
method: 'POST',
|
|
232
|
+
url: '/v1/turn/audio',
|
|
233
|
+
headers: { 'content-type': 'audio/ogg', authorization: 'Bearer x' },
|
|
234
|
+
body: 'oggbytes',
|
|
235
|
+
}),
|
|
236
|
+
res,
|
|
237
|
+
ctx(session),
|
|
238
|
+
);
|
|
239
|
+
expect(res._status).toBe(422);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('handleTurn — client-disconnect abort (u70-1)', () => {
|
|
244
|
+
it('aborts the turn signal when the client hangs up mid-run', async () => {
|
|
245
|
+
let capturedSignal: AbortSignal | undefined;
|
|
246
|
+
let resolveClosed!: () => void;
|
|
247
|
+
const sawSignal = new Promise<void>((r) => {
|
|
248
|
+
resolveClosed = r;
|
|
249
|
+
});
|
|
250
|
+
// A session whose runTurn blocks until aborted, recording the signal it
|
|
251
|
+
// received so the test can assert it was wired through.
|
|
252
|
+
const session = {
|
|
253
|
+
runTurn: (_prompt: string, opts?: { signal?: AbortSignal }) => {
|
|
254
|
+
capturedSignal = opts?.signal;
|
|
255
|
+
resolveClosed();
|
|
256
|
+
return (async function* () {
|
|
257
|
+
await new Promise<void>((resolve) => {
|
|
258
|
+
opts?.signal?.addEventListener('abort', () => resolve());
|
|
259
|
+
});
|
|
260
|
+
})();
|
|
261
|
+
},
|
|
262
|
+
} as unknown as ClientSession;
|
|
263
|
+
|
|
264
|
+
const res = makeResponse();
|
|
265
|
+
const handlerDone = handleTurn(
|
|
266
|
+
makeIncoming({
|
|
267
|
+
method: 'POST',
|
|
268
|
+
url: '/v1/turn',
|
|
269
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer x' },
|
|
270
|
+
body: JSON.stringify({ prompt: 'hello' }),
|
|
271
|
+
}),
|
|
272
|
+
res,
|
|
273
|
+
{ session, authToken: 'x', logger: silentLogger },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
// Wait until runTurn is in-flight, then simulate the client disconnect.
|
|
277
|
+
await sawSignal;
|
|
278
|
+
expect(capturedSignal).toBeDefined();
|
|
279
|
+
expect(capturedSignal!.aborted).toBe(false);
|
|
280
|
+
res._emit('close');
|
|
281
|
+
expect(capturedSignal!.aborted).toBe(true);
|
|
282
|
+
|
|
283
|
+
await handlerDone;
|
|
284
|
+
// The close listener is detached after the turn settles (no leak).
|
|
285
|
+
res._emit('close'); // would throw if listener double-fired on a dead res
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('driveTurn', () => {
|
|
290
|
+
const fakeSession = (gen: () => AsyncGenerator<MoxxyEvent>): ClientSession =>
|
|
291
|
+
({ runTurn: () => gen() }) as unknown as ClientSession;
|
|
292
|
+
|
|
293
|
+
const asst = (content: string): MoxxyEvent =>
|
|
294
|
+
({ type: 'assistant_message', content }) as unknown as MoxxyEvent;
|
|
295
|
+
|
|
296
|
+
it('drains all events and extracts the LAST assistant message', async () => {
|
|
297
|
+
const session = fakeSession(async function* () {
|
|
298
|
+
yield asst('first');
|
|
299
|
+
yield { type: 'tool_call_requested' } as unknown as MoxxyEvent;
|
|
300
|
+
yield asst('final');
|
|
301
|
+
});
|
|
302
|
+
const res = makeResponse();
|
|
303
|
+
const { events, assistant } = await driveTurn(session, 'hi', {}, res);
|
|
304
|
+
expect(events).toHaveLength(3);
|
|
305
|
+
expect(assistant).toBe('final');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('returns empty assistant when no assistant_message is emitted', async () => {
|
|
309
|
+
const session = fakeSession(async function* () {
|
|
310
|
+
yield { type: 'tool_call_requested' } as unknown as MoxxyEvent;
|
|
311
|
+
});
|
|
312
|
+
const res = makeResponse();
|
|
313
|
+
const { assistant } = await driveTurn(session, 'hi', {}, res);
|
|
314
|
+
expect(assistant).toBe('');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('forwards an abort signal and aborts it when the client disconnects', async () => {
|
|
318
|
+
let signal: AbortSignal | undefined;
|
|
319
|
+
let started!: () => void;
|
|
320
|
+
const inFlight = new Promise<void>((r) => (started = r));
|
|
321
|
+
const session = {
|
|
322
|
+
runTurn: (_p: string, opts?: { signal?: AbortSignal }) => {
|
|
323
|
+
signal = opts?.signal;
|
|
324
|
+
started();
|
|
325
|
+
return (async function* () {
|
|
326
|
+
await new Promise<void>((resolve) => opts?.signal?.addEventListener('abort', () => resolve()));
|
|
327
|
+
})();
|
|
328
|
+
},
|
|
329
|
+
} as unknown as ClientSession;
|
|
330
|
+
|
|
331
|
+
const res = makeResponse();
|
|
332
|
+
const done = driveTurn(session, 'hi', {}, res);
|
|
333
|
+
await inFlight;
|
|
334
|
+
expect(signal!.aborted).toBe(false);
|
|
335
|
+
res._emit('close');
|
|
336
|
+
expect(signal!.aborted).toBe(true);
|
|
337
|
+
await done;
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('detaches the close listener after the turn settles', async () => {
|
|
341
|
+
const session = fakeSession(async function* () {
|
|
342
|
+
yield asst('ok');
|
|
343
|
+
});
|
|
344
|
+
const res = makeResponse();
|
|
345
|
+
await driveTurn(session, 'hi', {}, res);
|
|
346
|
+
// Listener removed -> a late close does not abort anything / throw.
|
|
347
|
+
expect(() => res._emit('close')).not.toThrow();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('re-throws when the turn fails (and still detaches the listener)', async () => {
|
|
351
|
+
const session = fakeSession(async function* () {
|
|
352
|
+
yield asst('partial');
|
|
353
|
+
throw new Error('boom');
|
|
354
|
+
});
|
|
355
|
+
const res = makeResponse();
|
|
356
|
+
await expect(driveTurn(session, 'hi', {}, res)).rejects.toThrow('boom');
|
|
357
|
+
expect(() => res._emit('close')).not.toThrow();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe('handleHealth', () => {
|
|
362
|
+
it('replies 200 ok', async () => {
|
|
363
|
+
const res = makeResponse();
|
|
364
|
+
await handleHealth(makeIncoming({ method: 'GET', url: '/v1/health' }), res);
|
|
365
|
+
expect(res._status).toBe(200);
|
|
366
|
+
expect(JSON.parse(res._body)).toEqual({ status: 'ok' });
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe('turnRequestSchema', () => {
|
|
371
|
+
it('accepts minimal {prompt}', () => {
|
|
372
|
+
expect(turnRequestSchema.parse({ prompt: 'hi' })).toEqual({ prompt: 'hi' });
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('accepts optional model + systemPrompt', () => {
|
|
376
|
+
const out = turnRequestSchema.parse({ prompt: 'hi', model: 'sonnet', systemPrompt: 'be terse' });
|
|
377
|
+
expect(out.model).toBe('sonnet');
|
|
378
|
+
expect(out.systemPrompt).toBe('be terse');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('rejects empty prompt', () => {
|
|
382
|
+
expect(() => turnRequestSchema.parse({ prompt: '' })).toThrow();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('rejects non-string fields', () => {
|
|
386
|
+
expect(() => turnRequestSchema.parse({ prompt: 123 })).toThrow();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('TurnLimiter', () => {
|
|
391
|
+
it('admits up to max and refuses beyond it, recovering on release', () => {
|
|
392
|
+
const limiter = new TurnLimiter(2);
|
|
393
|
+
expect(limiter.tryAcquire()).toBe(true);
|
|
394
|
+
expect(limiter.tryAcquire()).toBe(true);
|
|
395
|
+
expect(limiter.tryAcquire()).toBe(false);
|
|
396
|
+
limiter.release();
|
|
397
|
+
expect(limiter.tryAcquire()).toBe(true);
|
|
398
|
+
expect(limiter.active).toBe(2);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('release() never underflows below zero', () => {
|
|
402
|
+
const limiter = new TurnLimiter(1);
|
|
403
|
+
limiter.release();
|
|
404
|
+
limiter.release();
|
|
405
|
+
expect(limiter.active).toBe(0);
|
|
406
|
+
expect(limiter.tryAcquire()).toBe(true);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
describe('handleTurn — auth precedes body read', () => {
|
|
411
|
+
it('returns 401 without consuming the request body', async () => {
|
|
412
|
+
// A body stream that records whether anything read it. Auth must short-
|
|
413
|
+
// circuit before any byte is pulled, so an unauthenticated client can never
|
|
414
|
+
// force the (bounded) body buffering path.
|
|
415
|
+
let consumed = false;
|
|
416
|
+
const body = new Readable({
|
|
417
|
+
read() {
|
|
418
|
+
consumed = true;
|
|
419
|
+
this.push(null);
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
const socket = new Socket();
|
|
423
|
+
const req = body as unknown as IncomingMessage;
|
|
424
|
+
Object.assign(req, { method: 'POST', url: '/v1/turn', headers: {}, socket });
|
|
425
|
+
|
|
426
|
+
const session = { runTurn: () => (async function* () {})() } as unknown as ClientSession;
|
|
427
|
+
const res = makeResponse();
|
|
428
|
+
await handleTurn(req, res, { session, authToken: 'secret', logger: silentLogger });
|
|
429
|
+
expect(res._status).toBe(401);
|
|
430
|
+
expect(consumed).toBe(false);
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
describe('handleTurn — concurrency cap', () => {
|
|
435
|
+
const blockingSession = (): ClientSession =>
|
|
436
|
+
({
|
|
437
|
+
runTurn: (_p: string, opts?: { signal?: AbortSignal }) =>
|
|
438
|
+
(async function* () {
|
|
439
|
+
await new Promise<void>((resolve) => opts?.signal?.addEventListener('abort', () => resolve()));
|
|
440
|
+
})(),
|
|
441
|
+
}) as unknown as ClientSession;
|
|
442
|
+
|
|
443
|
+
it('returns 429 once the turn limiter is saturated', async () => {
|
|
444
|
+
const session = blockingSession();
|
|
445
|
+
const limiter = new TurnLimiter(1);
|
|
446
|
+
const ctx = { session, authToken: 'x', logger: silentLogger, turnLimiter: limiter };
|
|
447
|
+
|
|
448
|
+
// First turn occupies the only slot (stays in-flight until aborted).
|
|
449
|
+
const res1 = makeResponse();
|
|
450
|
+
const first = handleTurn(
|
|
451
|
+
makeIncoming({ method: 'POST', url: '/v1/turn', headers: { authorization: 'Bearer x' }, body: JSON.stringify({ prompt: 'hi' }) }),
|
|
452
|
+
res1,
|
|
453
|
+
ctx,
|
|
454
|
+
);
|
|
455
|
+
// Give the first request time to read its body and acquire the slot.
|
|
456
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
457
|
+
|
|
458
|
+
const res2 = makeResponse();
|
|
459
|
+
await handleTurn(
|
|
460
|
+
makeIncoming({ method: 'POST', url: '/v1/turn', headers: { authorization: 'Bearer x' }, body: JSON.stringify({ prompt: 'hi' }) }),
|
|
461
|
+
res2,
|
|
462
|
+
ctx,
|
|
463
|
+
);
|
|
464
|
+
expect(res2._status).toBe(429);
|
|
465
|
+
expect(JSON.parse(res2._body).error).toBe('too_many_turns');
|
|
466
|
+
|
|
467
|
+
// Unblock the first turn; its slot is released so a third now succeeds.
|
|
468
|
+
res1._emit('close');
|
|
469
|
+
await first;
|
|
470
|
+
expect(limiter.active).toBe(0);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe('error shaping — internal errors not echoed verbatim', () => {
|
|
475
|
+
it('500 turn_failed returns a generic message, not the raw provider error', async () => {
|
|
476
|
+
const session = {
|
|
477
|
+
runTurn: () =>
|
|
478
|
+
(async function* () {
|
|
479
|
+
throw new Error('SECRET /Users/x/.moxxy/vault.json provider key sk-leak');
|
|
480
|
+
})(),
|
|
481
|
+
} as unknown as ClientSession;
|
|
482
|
+
const warn = vi.fn();
|
|
483
|
+
const res = makeResponse();
|
|
484
|
+
await handleTurn(
|
|
485
|
+
makeIncoming({ method: 'POST', url: '/v1/turn', headers: { authorization: 'Bearer x' }, body: JSON.stringify({ prompt: 'hi' }) }),
|
|
486
|
+
res,
|
|
487
|
+
{ session, authToken: 'x', logger: { warn } },
|
|
488
|
+
);
|
|
489
|
+
expect(res._status).toBe(500);
|
|
490
|
+
const body = JSON.parse(res._body);
|
|
491
|
+
expect(body.error).toBe('turn_failed');
|
|
492
|
+
expect(body.message).not.toContain('SECRET');
|
|
493
|
+
expect(body.message).not.toContain('vault.json');
|
|
494
|
+
expect(body.message).not.toContain('sk-leak');
|
|
495
|
+
// Full detail still reaches the server-side log.
|
|
496
|
+
expect(warn).toHaveBeenCalledWith('http turn failed', expect.objectContaining({ err: expect.stringContaining('SECRET') }));
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
describe('driveTurn — buffered-events bound', () => {
|
|
501
|
+
it('stops collecting and aborts once MAX_BUFFERED_EVENTS is hit', async () => {
|
|
502
|
+
let aborted = false;
|
|
503
|
+
const session = {
|
|
504
|
+
runTurn: (_p: string, opts?: { signal?: AbortSignal }) => {
|
|
505
|
+
opts?.signal?.addEventListener('abort', () => {
|
|
506
|
+
aborted = true;
|
|
507
|
+
});
|
|
508
|
+
return (async function* () {
|
|
509
|
+
// Emit far more than the cap; the loop must break, not OOM.
|
|
510
|
+
for (let i = 0; i < MAX_BUFFERED_EVENTS + 1000; i++) {
|
|
511
|
+
yield { type: 'tool_call_requested' } as unknown as MoxxyEvent;
|
|
512
|
+
}
|
|
513
|
+
})();
|
|
514
|
+
},
|
|
515
|
+
} as unknown as ClientSession;
|
|
516
|
+
const res = makeResponse();
|
|
517
|
+
const { events } = await driveTurn(session, 'hi', {}, res);
|
|
518
|
+
expect(events.length).toBe(MAX_BUFFERED_EVENTS);
|
|
519
|
+
expect(aborted).toBe(true);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe('handleTurnStream — backpressure + closed-socket safety', () => {
|
|
524
|
+
it('pauses on a false write() and resumes on drain', async () => {
|
|
525
|
+
const session = {
|
|
526
|
+
runTurn: () =>
|
|
527
|
+
(async function* () {
|
|
528
|
+
yield { type: 'assistant_chunk', delta: 'a' } as unknown as MoxxyEvent;
|
|
529
|
+
yield { type: 'assistant_chunk', delta: 'b' } as unknown as MoxxyEvent;
|
|
530
|
+
})(),
|
|
531
|
+
} as unknown as ClientSession;
|
|
532
|
+
const res = makeResponse();
|
|
533
|
+
res._writeReturns = false; // every write reports a full buffer -> must await 'drain'
|
|
534
|
+
|
|
535
|
+
let finished = false;
|
|
536
|
+
const done = handleTurnStream(
|
|
537
|
+
makeIncoming({ method: 'POST', url: '/v1/turn/stream', headers: { authorization: 'Bearer x' }, body: JSON.stringify({ prompt: 'hi' }) }),
|
|
538
|
+
res,
|
|
539
|
+
{ session, authToken: 'x', logger: silentLogger },
|
|
540
|
+
).then(() => {
|
|
541
|
+
finished = true;
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// The handler parks awaiting 'drain' after the first write returns false.
|
|
545
|
+
// Pump 'drain' until it completes — proving the loop actually waited.
|
|
546
|
+
while (!finished) {
|
|
547
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
548
|
+
res._emit('drain');
|
|
549
|
+
}
|
|
550
|
+
await done;
|
|
551
|
+
expect(res._body).toContain('[DONE]');
|
|
552
|
+
expect(res.writableEnded).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('does not throw when the client disconnected before terminal writes', async () => {
|
|
556
|
+
const session = {
|
|
557
|
+
runTurn: (_p: string, opts?: { signal?: AbortSignal }) =>
|
|
558
|
+
(async function* () {
|
|
559
|
+
yield { type: 'assistant_chunk', delta: 'a' } as unknown as MoxxyEvent;
|
|
560
|
+
// Simulate the client hanging up mid-stream: the generator ends here
|
|
561
|
+
// (after one chunk) while the consumer is still attached.
|
|
562
|
+
void opts;
|
|
563
|
+
})(),
|
|
564
|
+
} as unknown as ClientSession;
|
|
565
|
+
const res = makeResponse();
|
|
566
|
+
|
|
567
|
+
const done = handleTurnStream(
|
|
568
|
+
makeIncoming({ method: 'POST', url: '/v1/turn/stream', headers: { authorization: 'Bearer x' }, body: JSON.stringify({ prompt: 'hi' }) }),
|
|
569
|
+
res,
|
|
570
|
+
{ session, authToken: 'x', logger: silentLogger },
|
|
571
|
+
);
|
|
572
|
+
// Destroy the response (client gone) — terminal res.end() must be guarded.
|
|
573
|
+
(res as unknown as { destroyed: boolean }).destroyed = true;
|
|
574
|
+
(res as unknown as { writableEnded: boolean }).writableEnded = true;
|
|
575
|
+
res._emit('close');
|
|
576
|
+
await expect(done).resolves.toBeUndefined();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('aborts the turn when a stalled consumer trips the write-side timeout', async () => {
|
|
580
|
+
// A consumer that never reads: every write reports backpressure and 'drain'
|
|
581
|
+
// never fires. Without the stall timeout the handler would park forever,
|
|
582
|
+
// keeping the provider stream (and billing) alive.
|
|
583
|
+
let signal: AbortSignal | undefined;
|
|
584
|
+
const session = {
|
|
585
|
+
runTurn: (_p: string, opts?: { signal?: AbortSignal }) => {
|
|
586
|
+
signal = opts?.signal;
|
|
587
|
+
return (async function* () {
|
|
588
|
+
for (let i = 0; i < 100; i++) {
|
|
589
|
+
yield { type: 'assistant_chunk', delta: String(i) } as unknown as MoxxyEvent;
|
|
590
|
+
}
|
|
591
|
+
})();
|
|
592
|
+
},
|
|
593
|
+
} as unknown as ClientSession;
|
|
594
|
+
const res = makeResponse();
|
|
595
|
+
res._writeReturns = false; // permanent backpressure: the handler parks on 'drain'
|
|
596
|
+
|
|
597
|
+
let settled = false;
|
|
598
|
+
const done = handleTurnStream(
|
|
599
|
+
makeIncoming({ method: 'POST', url: '/v1/turn/stream', headers: { authorization: 'Bearer x' }, body: JSON.stringify({ prompt: 'hi' }) }),
|
|
600
|
+
res,
|
|
601
|
+
{ session, authToken: 'x', logger: silentLogger, streamStallMs: 1000 },
|
|
602
|
+
).then(() => { settled = true; });
|
|
603
|
+
|
|
604
|
+
// Let the handler reach the first parked write.
|
|
605
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
606
|
+
expect(settled).toBe(false);
|
|
607
|
+
expect(signal!.aborted).toBe(false);
|
|
608
|
+
|
|
609
|
+
// Fire the inactivity timeout — must abort the turn and let the handler exit.
|
|
610
|
+
res._fireTimeout();
|
|
611
|
+
await done;
|
|
612
|
+
expect(settled).toBe(true);
|
|
613
|
+
expect(signal!.aborted).toBe(true);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('disarms the stall timer after the stream settles cleanly', async () => {
|
|
617
|
+
const session = {
|
|
618
|
+
runTurn: () =>
|
|
619
|
+
(async function* () {
|
|
620
|
+
yield { type: 'assistant_chunk', delta: 'a' } as unknown as MoxxyEvent;
|
|
621
|
+
})(),
|
|
622
|
+
} as unknown as ClientSession;
|
|
623
|
+
const res = makeResponse();
|
|
624
|
+
await handleTurnStream(
|
|
625
|
+
makeIncoming({ method: 'POST', url: '/v1/turn/stream', headers: { authorization: 'Bearer x' }, body: JSON.stringify({ prompt: 'hi' }) }),
|
|
626
|
+
res,
|
|
627
|
+
{ session, authToken: 'x', logger: silentLogger, streamStallMs: 1000 },
|
|
628
|
+
);
|
|
629
|
+
// The finally block calls res.setTimeout(0) to disarm; firing now is a no-op
|
|
630
|
+
// (the callback was cleared) and must not throw.
|
|
631
|
+
expect(res._timeoutMs).toBe(0);
|
|
632
|
+
expect(() => res._fireTimeout()).not.toThrow();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('treats a response-socket error as a disconnect (aborts, no crash, no rethrow)', async () => {
|
|
636
|
+
// An unhandled 'error' on a ServerResponse is a fatal uncaughtException in
|
|
637
|
+
// Node. The handler must absorb it: abort the turn and resolve, never throw.
|
|
638
|
+
let signal: AbortSignal | undefined;
|
|
639
|
+
const session = {
|
|
640
|
+
runTurn: (_p: string, opts?: { signal?: AbortSignal }) => {
|
|
641
|
+
signal = opts?.signal;
|
|
642
|
+
return (async function* () {
|
|
643
|
+
await new Promise<void>((resolve) => opts?.signal?.addEventListener('abort', () => resolve()));
|
|
644
|
+
})();
|
|
645
|
+
},
|
|
646
|
+
} as unknown as ClientSession;
|
|
647
|
+
const warn = vi.fn();
|
|
648
|
+
const res = makeResponse();
|
|
649
|
+
|
|
650
|
+
const done = handleTurnStream(
|
|
651
|
+
makeIncoming({ method: 'POST', url: '/v1/turn/stream', headers: { authorization: 'Bearer x' }, body: JSON.stringify({ prompt: 'hi' }) }),
|
|
652
|
+
res,
|
|
653
|
+
{ session, authToken: 'x', logger: { warn } },
|
|
654
|
+
);
|
|
655
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
656
|
+
// Emit a socket error mid-stream — must not propagate.
|
|
657
|
+
expect(() => res._emit('error', new Error('ECONNRESET'))).not.toThrow();
|
|
658
|
+
await expect(done).resolves.toBeUndefined();
|
|
659
|
+
expect(signal!.aborted).toBe(true);
|
|
660
|
+
expect(warn).toHaveBeenCalledWith('http stream response error', expect.objectContaining({}));
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// keep `vi` reachable so the import isn't pruned by some bundlers in CI
|
|
665
|
+
void vi;
|