@alexkroman1/aai 1.4.3 → 1.4.5
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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +13 -0
- package/dist/host/runtime-barrel.js +55 -8
- package/dist/host/s2s.d.ts +6 -0
- package/host/s2s.test.ts +37 -2
- package/host/s2s.ts +48 -5
- package/host/session.test.ts +66 -0
- package/host/session.ts +45 -3
- package/package.json +1 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
> @alexkroman1/aai@1.4.
|
|
2
|
+
> @alexkroman1/aai@1.4.5 build /home/runner/work/agent/agent/packages/aai
|
|
3
3
|
> tsdown && tsc -p tsconfig.build.json
|
|
4
4
|
|
|
5
5
|
[34mℹ[39m [34mtsdown v0.21.7[39m powered by [38;2;255;126;23mrolldown v1.0.0-rc.12[39m
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[34mℹ[39m target: [34mnode22[39m
|
|
9
9
|
[34mℹ[39m tsconfig: [34mtsconfig.json[39m
|
|
10
10
|
[34mℹ[39m Build start
|
|
11
|
-
[34mℹ[39m [2mdist/[22m[1mhost/runtime-barrel.js[22m [
|
|
11
|
+
[34mℹ[39m [2mdist/[22m[1mhost/runtime-barrel.js[22m [2m77.48 kB[22m [2m│ gzip: 23.08 kB[22m
|
|
12
12
|
[34mℹ[39m [2mdist/[22m[1msdk/protocol.js[22m [2m 4.75 kB[22m [2m│ gzip: 1.76 kB[22m
|
|
13
13
|
[34mℹ[39m [2mdist/[22m[1mindex.js[22m [2m 2.88 kB[22m [2m│ gzip: 1.24 kB[22m
|
|
14
14
|
[34mℹ[39m [2mdist/[22m[1msdk/manifest-barrel.js[22m [2m 0.36 kB[22m [2m│ gzip: 0.20 kB[22m
|
|
@@ -22,5 +22,5 @@
|
|
|
22
22
|
[34mℹ[39m [2mdist/[22massemblyai-Cxg9eobY.js [2m 0.53 kB[22m [2m│ gzip: 0.35 kB[22m
|
|
23
23
|
[34mℹ[39m [2mdist/[22manthropic-BrUCPKUc.js [2m 0.23 kB[22m [2m│ gzip: 0.18 kB[22m
|
|
24
24
|
[34mℹ[39m [2mdist/[22mcartesia-DwDk2tEu.js [2m 0.22 kB[22m [2m│ gzip: 0.17 kB[22m
|
|
25
|
-
[34mℹ[39m 14 files, total:
|
|
26
|
-
[32m✔[39m Build complete in [
|
|
25
|
+
[34mℹ[39m 14 files, total: 102.30 kB
|
|
26
|
+
[32m✔[39m Build complete in [32m49ms[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @alexkroman1/aai
|
|
2
2
|
|
|
3
|
+
## 1.4.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 07dc8fb: Log raw reply.done arrivals from the S2S service (sid, status) and warn when the S2S socket closes while a reply is still active, so silent drops are visible server-side.
|
|
8
|
+
- 2ca5d1f: Instrument slow reply_done dispatches with warn-level logs (session id, duration, hadTurnPromise) to help diagnose event-loop starvation under load.
|
|
9
|
+
|
|
10
|
+
## 1.4.4
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 74341a4: fix(aai): dedup duplicate S2S reply.done and speech.stopped events to prevent client-side cascades in the voice session wire protocol
|
|
15
|
+
|
|
3
16
|
## 1.4.3
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
|
@@ -1326,17 +1326,23 @@ function parseS2sMessage(obj) {
|
|
|
1326
1326
|
const result = S2sMessageSchema.safeParse(obj);
|
|
1327
1327
|
return result.success ? result.data : void 0;
|
|
1328
1328
|
}
|
|
1329
|
-
function dispatchS2sMessage(emitter, msg) {
|
|
1329
|
+
function dispatchS2sMessage(emitter, msg, state, dispatchCtx) {
|
|
1330
1330
|
switch (msg.type) {
|
|
1331
1331
|
case "session.ready":
|
|
1332
1332
|
emitter.emit("ready", { sessionId: msg.session_id });
|
|
1333
1333
|
break;
|
|
1334
1334
|
case "session.updated": break;
|
|
1335
1335
|
case "input.speech.started":
|
|
1336
|
-
|
|
1336
|
+
if (!state.speechActive) {
|
|
1337
|
+
state.speechActive = true;
|
|
1338
|
+
emitter.emit("event", { type: "speech_started" });
|
|
1339
|
+
}
|
|
1337
1340
|
break;
|
|
1338
1341
|
case "input.speech.stopped":
|
|
1339
|
-
|
|
1342
|
+
if (state.speechActive) {
|
|
1343
|
+
state.speechActive = false;
|
|
1344
|
+
emitter.emit("event", { type: "speech_stopped" });
|
|
1345
|
+
}
|
|
1340
1346
|
break;
|
|
1341
1347
|
case "transcript.user":
|
|
1342
1348
|
emitter.emit("event", {
|
|
@@ -1363,6 +1369,10 @@ function dispatchS2sMessage(emitter, msg) {
|
|
|
1363
1369
|
});
|
|
1364
1370
|
break;
|
|
1365
1371
|
case "reply.done":
|
|
1372
|
+
dispatchCtx.log.info("S2S << reply.done", {
|
|
1373
|
+
...dispatchCtx.sid !== void 0 ? { sid: dispatchCtx.sid } : {},
|
|
1374
|
+
status: msg.status ?? "completed"
|
|
1375
|
+
});
|
|
1366
1376
|
if (msg.status === "interrupted") emitter.emit("event", { type: "cancelled" });
|
|
1367
1377
|
else emitter.emit("event", { type: "reply_done" });
|
|
1368
1378
|
break;
|
|
@@ -1377,11 +1387,16 @@ function dispatchS2sMessage(emitter, msg) {
|
|
|
1377
1387
|
}
|
|
1378
1388
|
}
|
|
1379
1389
|
function connectS2s(opts) {
|
|
1380
|
-
const { apiKey, config, createWebSocket, logger: log = consoleLogger } = opts;
|
|
1390
|
+
const { apiKey, config, createWebSocket, logger: log = consoleLogger, sid } = opts;
|
|
1381
1391
|
return new Promise((resolve, reject) => {
|
|
1382
1392
|
log.info("S2S connecting", { url: config.wssUrl });
|
|
1383
1393
|
const ws = createWebSocket(config.wssUrl, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
1384
1394
|
const emitter = createNanoEvents();
|
|
1395
|
+
const dispatchState = { speechActive: false };
|
|
1396
|
+
const dispatchCtx = sid !== void 0 ? {
|
|
1397
|
+
log,
|
|
1398
|
+
sid
|
|
1399
|
+
} : { log };
|
|
1385
1400
|
let opened = false;
|
|
1386
1401
|
function send(msg) {
|
|
1387
1402
|
if (ws.readyState !== 1) {
|
|
@@ -1461,6 +1476,7 @@ function connectS2s(opts) {
|
|
|
1461
1476
|
}
|
|
1462
1477
|
function logIncoming(obj) {
|
|
1463
1478
|
if (obj.type === "reply.audio" || obj.type === "input.audio") return;
|
|
1479
|
+
if (obj.type === "reply.done") return;
|
|
1464
1480
|
log.info(`S2S << ${obj.type}`);
|
|
1465
1481
|
}
|
|
1466
1482
|
function handleS2sMessage(ev) {
|
|
@@ -1478,7 +1494,7 @@ function connectS2s(opts) {
|
|
|
1478
1494
|
log.warn(`S2S << unrecognised message type: ${obj.type ?? JSON.stringify(raw).slice(0, 200)}`);
|
|
1479
1495
|
return;
|
|
1480
1496
|
}
|
|
1481
|
-
dispatchS2sMessage(emitter, parsed);
|
|
1497
|
+
dispatchS2sMessage(emitter, parsed, dispatchState, dispatchCtx);
|
|
1482
1498
|
}
|
|
1483
1499
|
ws.addEventListener("message", handleS2sMessage);
|
|
1484
1500
|
ws.addEventListener("close", (ev) => {
|
|
@@ -1614,8 +1630,22 @@ function handleReplyCancelled(ctx) {
|
|
|
1614
1630
|
ctx.cancelReply();
|
|
1615
1631
|
ctx.client.event({ type: "cancelled" });
|
|
1616
1632
|
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Warn when the entry-to-emit time for a reply_done dispatch exceeds this.
|
|
1635
|
+
* Tool-less sessions should be sub-millisecond; sessions with pending tools
|
|
1636
|
+
* will legitimately spend time awaiting ctx.turnPromise. We log both (with
|
|
1637
|
+
* `hadTurnPromise`) so event-loop starvation is distinguishable from
|
|
1638
|
+
* genuine tool-call latency.
|
|
1639
|
+
*/
|
|
1640
|
+
const REPLY_DONE_SLOW_THRESHOLD_MS = 50;
|
|
1617
1641
|
function handleReplyDone(ctx) {
|
|
1642
|
+
const startMs = Date.now();
|
|
1618
1643
|
const doneReplyId = ctx.reply.currentReplyId;
|
|
1644
|
+
if (doneReplyId === null) {
|
|
1645
|
+
ctx.log.debug("Dropping duplicate reply.done (no active reply)");
|
|
1646
|
+
return;
|
|
1647
|
+
}
|
|
1648
|
+
const hadTurnPromise = ctx.turnPromise !== null;
|
|
1619
1649
|
const sendPending = () => {
|
|
1620
1650
|
if (ctx.reply.currentReplyId !== doneReplyId) {
|
|
1621
1651
|
ctx.reply.pendingTools = [];
|
|
@@ -1632,9 +1662,17 @@ function handleReplyDone(ctx) {
|
|
|
1632
1662
|
});
|
|
1633
1663
|
ctx.client.playAudioDone();
|
|
1634
1664
|
ctx.client.event({ type: "reply_done" });
|
|
1665
|
+
ctx.reply.currentReplyId = null;
|
|
1666
|
+
const durationMs = Date.now() - startMs;
|
|
1667
|
+
if (durationMs >= REPLY_DONE_SLOW_THRESHOLD_MS) ctx.log.warn("slow reply_done dispatch", {
|
|
1668
|
+
sid: ctx.id,
|
|
1669
|
+
agent: ctx.agent,
|
|
1670
|
+
durationMs,
|
|
1671
|
+
hadTurnPromise
|
|
1672
|
+
});
|
|
1635
1673
|
}
|
|
1636
1674
|
};
|
|
1637
|
-
if (
|
|
1675
|
+
if (hadTurnPromise) ctx.turnPromise?.then(sendPending);
|
|
1638
1676
|
else sendPending();
|
|
1639
1677
|
}
|
|
1640
1678
|
function setupListeners(ctx, handle) {
|
|
@@ -1657,7 +1695,15 @@ function setupListeners(ctx, handle) {
|
|
|
1657
1695
|
handle.close();
|
|
1658
1696
|
});
|
|
1659
1697
|
handle.on("close", (code, reason) => {
|
|
1660
|
-
ctx.
|
|
1698
|
+
const activeReplyId = ctx.reply.currentReplyId;
|
|
1699
|
+
if (activeReplyId !== null) ctx.log.warn("S2S closed with active reply", {
|
|
1700
|
+
sid: ctx.id,
|
|
1701
|
+
agent: ctx.agent,
|
|
1702
|
+
activeReplyId,
|
|
1703
|
+
code,
|
|
1704
|
+
reason
|
|
1705
|
+
});
|
|
1706
|
+
else ctx.log.info("S2S closed", {
|
|
1661
1707
|
code,
|
|
1662
1708
|
reason
|
|
1663
1709
|
});
|
|
@@ -1737,7 +1783,8 @@ function createS2sSession(opts) {
|
|
|
1737
1783
|
apiKey,
|
|
1738
1784
|
config: s2sConfig,
|
|
1739
1785
|
createWebSocket,
|
|
1740
|
-
logger: log
|
|
1786
|
+
logger: log,
|
|
1787
|
+
sid: id
|
|
1741
1788
|
});
|
|
1742
1789
|
if (sessionAbort.signal.aborted || generation !== connectGeneration) {
|
|
1743
1790
|
handle.close();
|
package/dist/host/s2s.d.ts
CHANGED
|
@@ -77,5 +77,11 @@ export type ConnectS2sOptions = {
|
|
|
77
77
|
config: S2SConfig;
|
|
78
78
|
createWebSocket: CreateS2sWebSocket;
|
|
79
79
|
logger?: Logger;
|
|
80
|
+
/**
|
|
81
|
+
* Session id attached to diagnostic log lines (e.g. raw `reply.done`
|
|
82
|
+
* arrivals from the S2S service). Optional; logs omit the field when
|
|
83
|
+
* not provided.
|
|
84
|
+
*/
|
|
85
|
+
sid?: string;
|
|
80
86
|
};
|
|
81
87
|
export declare function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle>;
|
package/host/s2s.test.ts
CHANGED
|
@@ -219,10 +219,25 @@ describe("connectS2s", () => {
|
|
|
219
219
|
const handler = vi.fn();
|
|
220
220
|
handle.on("event", handler);
|
|
221
221
|
|
|
222
|
+
// Prime VAD state — speech_stopped is only forwarded after a speech_started.
|
|
223
|
+
raw.emit("message", Buffer.from(JSON.stringify({ type: "input.speech.started" })));
|
|
222
224
|
raw.emit("message", Buffer.from(JSON.stringify({ type: "input.speech.stopped" })));
|
|
223
225
|
|
|
224
|
-
expect(handler).
|
|
225
|
-
expect(handler.mock.calls[0]?.[0]).toEqual({ type: "
|
|
226
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
227
|
+
expect(handler.mock.calls[0]?.[0]).toEqual({ type: "speech_started" });
|
|
228
|
+
expect(handler.mock.calls[1]?.[0]).toEqual({ type: "speech_stopped" });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("duplicate input.speech.stopped is suppressed", async () => {
|
|
232
|
+
const { raw, handle } = await setupHandle();
|
|
233
|
+
const handler = vi.fn();
|
|
234
|
+
handle.on("event", handler);
|
|
235
|
+
|
|
236
|
+
raw.emit("message", Buffer.from(JSON.stringify({ type: "input.speech.started" })));
|
|
237
|
+
raw.emit("message", Buffer.from(JSON.stringify({ type: "input.speech.stopped" })));
|
|
238
|
+
raw.emit("message", Buffer.from(JSON.stringify({ type: "input.speech.stopped" })));
|
|
239
|
+
|
|
240
|
+
expect(handler.mock.calls.filter((c) => c[0].type === "speech_stopped")).toHaveLength(1);
|
|
226
241
|
});
|
|
227
242
|
|
|
228
243
|
test("transcript.user dispatches 'event' with user_transcript", async () => {
|
|
@@ -387,6 +402,26 @@ describe("connectS2s", () => {
|
|
|
387
402
|
expect(handler.mock.calls[0]?.[0]).toEqual({ type: "cancelled" });
|
|
388
403
|
});
|
|
389
404
|
|
|
405
|
+
test("reply.done arrival is logged with sid and status", async () => {
|
|
406
|
+
const { raw, createWebSocket, logger } = createTestS2s();
|
|
407
|
+
const infoSpy = vi.fn();
|
|
408
|
+
logger.info = infoSpy;
|
|
409
|
+
const handle = await connectS2s({
|
|
410
|
+
apiKey: "test-key",
|
|
411
|
+
config: s2sConfig,
|
|
412
|
+
createWebSocket,
|
|
413
|
+
logger,
|
|
414
|
+
sid: "sess-abc",
|
|
415
|
+
});
|
|
416
|
+
handle.on("event", vi.fn());
|
|
417
|
+
|
|
418
|
+
raw.emit("message", Buffer.from(JSON.stringify({ type: "reply.done", status: "completed" })));
|
|
419
|
+
|
|
420
|
+
const arrivalCall = infoSpy.mock.calls.find((c) => c[0] === "S2S << reply.done");
|
|
421
|
+
expect(arrivalCall).toBeDefined();
|
|
422
|
+
expect(arrivalCall?.[1]).toEqual({ sid: "sess-abc", status: "completed" });
|
|
423
|
+
});
|
|
424
|
+
|
|
390
425
|
test("session.error with session_not_found dispatches 'sessionExpired'", async () => {
|
|
391
426
|
const { raw, handle } = await setupHandle();
|
|
392
427
|
const handler = vi.fn();
|
package/host/s2s.ts
CHANGED
|
@@ -79,7 +79,26 @@ function parseS2sMessage(obj: Record<string, unknown>): S2sServerMessage | undef
|
|
|
79
79
|
*/
|
|
80
80
|
export type S2sEvent = ClientEvent & { _interrupted?: boolean };
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Per-connection dispatch state. Used to dedup events that the upstream S2S
|
|
84
|
+
* service may emit more than once for a single logical turn (e.g. repeated
|
|
85
|
+
* `input.speech.stopped` after the VAD flips).
|
|
86
|
+
*/
|
|
87
|
+
type DispatchState = { speechActive: boolean };
|
|
88
|
+
|
|
89
|
+
type DispatchContext = {
|
|
90
|
+
/** Logger used for diagnostic `S2S <<` arrival logs. */
|
|
91
|
+
log: Logger;
|
|
92
|
+
/** Session id threaded through diagnostic logs; omitted when undefined. */
|
|
93
|
+
sid?: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function dispatchS2sMessage(
|
|
97
|
+
emitter: Emitter<S2sEvents>,
|
|
98
|
+
msg: S2sServerMessage,
|
|
99
|
+
state: DispatchState,
|
|
100
|
+
dispatchCtx: DispatchContext,
|
|
101
|
+
): void {
|
|
83
102
|
switch (msg.type) {
|
|
84
103
|
case "session.ready":
|
|
85
104
|
emitter.emit("ready", { sessionId: msg.session_id });
|
|
@@ -87,10 +106,16 @@ function dispatchS2sMessage(emitter: Emitter<S2sEvents>, msg: S2sServerMessage):
|
|
|
87
106
|
case "session.updated":
|
|
88
107
|
break;
|
|
89
108
|
case "input.speech.started":
|
|
90
|
-
|
|
109
|
+
if (!state.speechActive) {
|
|
110
|
+
state.speechActive = true;
|
|
111
|
+
emitter.emit("event", { type: "speech_started" });
|
|
112
|
+
}
|
|
91
113
|
break;
|
|
92
114
|
case "input.speech.stopped":
|
|
93
|
-
|
|
115
|
+
if (state.speechActive) {
|
|
116
|
+
state.speechActive = false;
|
|
117
|
+
emitter.emit("event", { type: "speech_stopped" });
|
|
118
|
+
}
|
|
94
119
|
break;
|
|
95
120
|
case "transcript.user":
|
|
96
121
|
emitter.emit("event", { type: "user_transcript", text: msg.text });
|
|
@@ -114,6 +139,13 @@ function dispatchS2sMessage(emitter: Emitter<S2sEvents>, msg: S2sServerMessage):
|
|
|
114
139
|
});
|
|
115
140
|
break;
|
|
116
141
|
case "reply.done":
|
|
142
|
+
// Log every raw reply.done arrival from the S2S service — one line per
|
|
143
|
+
// event, before any client-facing dedup — so we can cross-check which
|
|
144
|
+
// stalled sessions actually received reply.done for their turn.
|
|
145
|
+
dispatchCtx.log.info("S2S << reply.done", {
|
|
146
|
+
...(dispatchCtx.sid !== undefined ? { sid: dispatchCtx.sid } : {}),
|
|
147
|
+
status: msg.status ?? "completed",
|
|
148
|
+
});
|
|
117
149
|
if (msg.status === "interrupted") {
|
|
118
150
|
emitter.emit("event", { type: "cancelled" });
|
|
119
151
|
} else {
|
|
@@ -175,10 +207,16 @@ export type ConnectS2sOptions = {
|
|
|
175
207
|
config: S2SConfig;
|
|
176
208
|
createWebSocket: CreateS2sWebSocket;
|
|
177
209
|
logger?: Logger;
|
|
210
|
+
/**
|
|
211
|
+
* Session id attached to diagnostic log lines (e.g. raw `reply.done`
|
|
212
|
+
* arrivals from the S2S service). Optional; logs omit the field when
|
|
213
|
+
* not provided.
|
|
214
|
+
*/
|
|
215
|
+
sid?: string;
|
|
178
216
|
};
|
|
179
217
|
|
|
180
218
|
export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
181
|
-
const { apiKey, config, createWebSocket, logger: log = consoleLogger } = opts;
|
|
219
|
+
const { apiKey, config, createWebSocket, logger: log = consoleLogger, sid } = opts;
|
|
182
220
|
|
|
183
221
|
return new Promise((resolve, reject) => {
|
|
184
222
|
log.info("S2S connecting", { url: config.wssUrl });
|
|
@@ -188,6 +226,8 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
188
226
|
});
|
|
189
227
|
|
|
190
228
|
const emitter = createNanoEvents<S2sEvents>();
|
|
229
|
+
const dispatchState: DispatchState = { speechActive: false };
|
|
230
|
+
const dispatchCtx: DispatchContext = sid !== undefined ? { log, sid } : { log };
|
|
191
231
|
let opened = false;
|
|
192
232
|
|
|
193
233
|
function send(msg: { type: string; [key: string]: unknown }): void {
|
|
@@ -269,6 +309,9 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
269
309
|
function logIncoming(obj: { type?: unknown }): void {
|
|
270
310
|
// reply.audio and input.audio are ~95% of traffic — skip logging.
|
|
271
311
|
if (obj.type === "reply.audio" || obj.type === "input.audio") return;
|
|
312
|
+
// reply.done gets a richer log (sid + status) inside dispatchS2sMessage;
|
|
313
|
+
// skip the generic line here to avoid a duplicate.
|
|
314
|
+
if (obj.type === "reply.done") return;
|
|
272
315
|
log.info(`S2S << ${obj.type}`);
|
|
273
316
|
}
|
|
274
317
|
|
|
@@ -291,7 +334,7 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
|
|
|
291
334
|
);
|
|
292
335
|
return;
|
|
293
336
|
}
|
|
294
|
-
dispatchS2sMessage(emitter, parsed);
|
|
337
|
+
dispatchS2sMessage(emitter, parsed, dispatchState, dispatchCtx);
|
|
295
338
|
}
|
|
296
339
|
|
|
297
340
|
ws.addEventListener("message", handleS2sMessage);
|
package/host/session.test.ts
CHANGED
|
@@ -168,12 +168,78 @@ describe("createS2sSession", () => {
|
|
|
168
168
|
const { session, client, mockHandle } = setup();
|
|
169
169
|
await session.start();
|
|
170
170
|
|
|
171
|
+
mockHandle._fire("replyStarted", { replyId: "r1" });
|
|
171
172
|
mockHandle._fire("event", { type: "reply_done" });
|
|
172
173
|
|
|
173
174
|
expect(client.audioDoneCount).toBe(1);
|
|
174
175
|
expect(client.events).toContainEvent("reply_done");
|
|
175
176
|
});
|
|
176
177
|
|
|
178
|
+
test("duplicate reply_done is suppressed after reply completes", async () => {
|
|
179
|
+
const { session, client, mockHandle } = setup();
|
|
180
|
+
await session.start();
|
|
181
|
+
|
|
182
|
+
mockHandle._fire("replyStarted", { replyId: "r1" });
|
|
183
|
+
mockHandle._fire("event", { type: "reply_done" });
|
|
184
|
+
mockHandle._fire("event", { type: "reply_done" });
|
|
185
|
+
|
|
186
|
+
const replyDones = client.events.filter(
|
|
187
|
+
(e): e is { type: string } =>
|
|
188
|
+
typeof e === "object" && e !== null && "type" in e && e.type === "reply_done",
|
|
189
|
+
);
|
|
190
|
+
expect(replyDones).toHaveLength(1);
|
|
191
|
+
expect(client.audioDoneCount).toBe(1);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("S2S close with active reply logs a warn", async () => {
|
|
195
|
+
const warn = vi.fn();
|
|
196
|
+
const info = vi.fn();
|
|
197
|
+
const logger = { debug: vi.fn(), info, warn, error: vi.fn() };
|
|
198
|
+
const { session, mockHandle } = setup({ logger });
|
|
199
|
+
await session.start();
|
|
200
|
+
|
|
201
|
+
// reply started but not yet done → currentReplyId is non-null
|
|
202
|
+
mockHandle._fire("replyStarted", { replyId: "r-active" });
|
|
203
|
+
mockHandle._fire("close", 1006, "abnormal");
|
|
204
|
+
|
|
205
|
+
expect(warn).toHaveBeenCalledWith(
|
|
206
|
+
"S2S closed with active reply",
|
|
207
|
+
expect.objectContaining({ activeReplyId: "r-active", code: 1006, reason: "abnormal" }),
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("S2S clean close (no active reply) logs at info, not warn", async () => {
|
|
212
|
+
const warn = vi.fn();
|
|
213
|
+
const info = vi.fn();
|
|
214
|
+
const logger = { debug: vi.fn(), info, warn, error: vi.fn() };
|
|
215
|
+
const { session, mockHandle } = setup({ logger });
|
|
216
|
+
await session.start();
|
|
217
|
+
|
|
218
|
+
mockHandle._fire("replyStarted", { replyId: "r1" });
|
|
219
|
+
mockHandle._fire("event", { type: "reply_done" });
|
|
220
|
+
mockHandle._fire("close", 1000, "ok");
|
|
221
|
+
|
|
222
|
+
expect(warn).not.toHaveBeenCalledWith("S2S closed with active reply", expect.any(Object));
|
|
223
|
+
expect(info).toHaveBeenCalledWith("S2S closed", expect.objectContaining({ code: 1000 }));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("fast reply_done dispatch does not warn", async () => {
|
|
227
|
+
const warn = vi.fn();
|
|
228
|
+
const logger = {
|
|
229
|
+
debug: vi.fn(),
|
|
230
|
+
info: vi.fn(),
|
|
231
|
+
warn,
|
|
232
|
+
error: vi.fn(),
|
|
233
|
+
};
|
|
234
|
+
const { session, mockHandle } = setup({ logger });
|
|
235
|
+
await session.start();
|
|
236
|
+
|
|
237
|
+
mockHandle._fire("replyStarted", { replyId: "r1" });
|
|
238
|
+
mockHandle._fire("event", { type: "reply_done" });
|
|
239
|
+
|
|
240
|
+
expect(warn).not.toHaveBeenCalledWith("slow reply_done dispatch", expect.any(Object));
|
|
241
|
+
});
|
|
242
|
+
|
|
177
243
|
test("cancelled event emits cancelled", async () => {
|
|
178
244
|
const { session, client, mockHandle } = setup();
|
|
179
245
|
await session.start();
|
package/host/session.ts
CHANGED
|
@@ -179,8 +179,25 @@ function handleReplyCancelled(ctx: S2sSessionCtx): void {
|
|
|
179
179
|
ctx.client.event({ type: "cancelled" });
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Warn when the entry-to-emit time for a reply_done dispatch exceeds this.
|
|
184
|
+
* Tool-less sessions should be sub-millisecond; sessions with pending tools
|
|
185
|
+
* will legitimately spend time awaiting ctx.turnPromise. We log both (with
|
|
186
|
+
* `hadTurnPromise`) so event-loop starvation is distinguishable from
|
|
187
|
+
* genuine tool-call latency.
|
|
188
|
+
*/
|
|
189
|
+
const REPLY_DONE_SLOW_THRESHOLD_MS = 50;
|
|
190
|
+
|
|
182
191
|
function handleReplyDone(ctx: S2sSessionCtx): void {
|
|
192
|
+
const startMs = Date.now();
|
|
183
193
|
const doneReplyId = ctx.reply.currentReplyId;
|
|
194
|
+
// Dedup duplicate reply.done events from the S2S service: once the reply
|
|
195
|
+
// has been fully dispatched (or was never started), currentReplyId is null.
|
|
196
|
+
if (doneReplyId === null) {
|
|
197
|
+
ctx.log.debug("Dropping duplicate reply.done (no active reply)");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const hadTurnPromise = ctx.turnPromise !== null;
|
|
184
201
|
const sendPending = () => {
|
|
185
202
|
if (ctx.reply.currentReplyId !== doneReplyId) {
|
|
186
203
|
ctx.reply.pendingTools = [];
|
|
@@ -196,10 +213,21 @@ function handleReplyDone(ctx: S2sSessionCtx): void {
|
|
|
196
213
|
}
|
|
197
214
|
ctx.client.playAudioDone();
|
|
198
215
|
ctx.client.event({ type: "reply_done" });
|
|
216
|
+
// Mark reply as finished so any repeated reply.done is dropped above.
|
|
217
|
+
ctx.reply.currentReplyId = null;
|
|
218
|
+
const durationMs = Date.now() - startMs;
|
|
219
|
+
if (durationMs >= REPLY_DONE_SLOW_THRESHOLD_MS) {
|
|
220
|
+
ctx.log.warn("slow reply_done dispatch", {
|
|
221
|
+
sid: ctx.id,
|
|
222
|
+
agent: ctx.agent,
|
|
223
|
+
durationMs,
|
|
224
|
+
hadTurnPromise,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
199
227
|
}
|
|
200
228
|
};
|
|
201
|
-
if (
|
|
202
|
-
void ctx.turnPromise
|
|
229
|
+
if (hadTurnPromise) {
|
|
230
|
+
void ctx.turnPromise?.then(sendPending);
|
|
203
231
|
} else {
|
|
204
232
|
sendPending();
|
|
205
233
|
}
|
|
@@ -221,7 +249,20 @@ function setupListeners(ctx: S2sSessionCtx, handle: S2sHandle): void {
|
|
|
221
249
|
handle.close();
|
|
222
250
|
});
|
|
223
251
|
handle.on("close", (code, reason) => {
|
|
224
|
-
ctx.
|
|
252
|
+
const activeReplyId = ctx.reply.currentReplyId;
|
|
253
|
+
if (activeReplyId !== null) {
|
|
254
|
+
// Silent drop — S2S socket closed while the server was still owed a
|
|
255
|
+
// reply. Client stays in waitingForReply=true until a session timeout.
|
|
256
|
+
ctx.log.warn("S2S closed with active reply", {
|
|
257
|
+
sid: ctx.id,
|
|
258
|
+
agent: ctx.agent,
|
|
259
|
+
activeReplyId,
|
|
260
|
+
code,
|
|
261
|
+
reason,
|
|
262
|
+
});
|
|
263
|
+
} else {
|
|
264
|
+
ctx.log.info("S2S closed", { code, reason });
|
|
265
|
+
}
|
|
225
266
|
ctx.s2s = null;
|
|
226
267
|
ctx.cancelReply();
|
|
227
268
|
});
|
|
@@ -311,6 +352,7 @@ export function createS2sSession(opts: S2sSessionOptions): Session {
|
|
|
311
352
|
config: s2sConfig,
|
|
312
353
|
createWebSocket,
|
|
313
354
|
logger: log,
|
|
355
|
+
sid: id,
|
|
314
356
|
});
|
|
315
357
|
if (sessionAbort.signal.aborted || generation !== connectGeneration) {
|
|
316
358
|
handle.close();
|