@alexkroman1/aai 1.4.4 → 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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @alexkroman1/aai@1.4.4 build /home/runner/work/agent/agent/packages/aai
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
  ℹ tsdown v0.21.7 powered by rolldown v1.0.0-rc.12
@@ -8,7 +8,7 @@
8
8
  ℹ target: node22
9
9
  ℹ tsconfig: tsconfig.json
10
10
  ℹ Build start
11
- ℹ dist/host/runtime-barrel.js 76.29 kB │ gzip: 22.68 kB
11
+ ℹ dist/host/runtime-barrel.js 77.48 kB │ gzip: 23.08 kB
12
12
  ℹ dist/sdk/protocol.js  4.75 kB │ gzip: 1.76 kB
13
13
  ℹ dist/index.js  2.88 kB │ gzip: 1.24 kB
14
14
  ℹ dist/sdk/manifest-barrel.js  0.36 kB │ gzip: 0.20 kB
@@ -22,5 +22,5 @@
22
22
  ℹ dist/assemblyai-Cxg9eobY.js  0.53 kB │ gzip: 0.35 kB
23
23
  ℹ dist/anthropic-BrUCPKUc.js  0.23 kB │ gzip: 0.18 kB
24
24
  ℹ dist/cartesia-DwDk2tEu.js  0.22 kB │ gzip: 0.17 kB
25
- ℹ 14 files, total: 101.11 kB
26
- ✔ Build complete in 41ms
25
+ ℹ 14 files, total: 102.30 kB
26
+ ✔ Build complete in 49ms
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
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
+
3
10
  ## 1.4.4
4
11
 
5
12
  ### Patch Changes
@@ -1326,7 +1326,7 @@ 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, state) {
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 });
@@ -1369,6 +1369,10 @@ function dispatchS2sMessage(emitter, msg, state) {
1369
1369
  });
1370
1370
  break;
1371
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
+ });
1372
1376
  if (msg.status === "interrupted") emitter.emit("event", { type: "cancelled" });
1373
1377
  else emitter.emit("event", { type: "reply_done" });
1374
1378
  break;
@@ -1383,12 +1387,16 @@ function dispatchS2sMessage(emitter, msg, state) {
1383
1387
  }
1384
1388
  }
1385
1389
  function connectS2s(opts) {
1386
- const { apiKey, config, createWebSocket, logger: log = consoleLogger } = opts;
1390
+ const { apiKey, config, createWebSocket, logger: log = consoleLogger, sid } = opts;
1387
1391
  return new Promise((resolve, reject) => {
1388
1392
  log.info("S2S connecting", { url: config.wssUrl });
1389
1393
  const ws = createWebSocket(config.wssUrl, { headers: { Authorization: `Bearer ${apiKey}` } });
1390
1394
  const emitter = createNanoEvents();
1391
1395
  const dispatchState = { speechActive: false };
1396
+ const dispatchCtx = sid !== void 0 ? {
1397
+ log,
1398
+ sid
1399
+ } : { log };
1392
1400
  let opened = false;
1393
1401
  function send(msg) {
1394
1402
  if (ws.readyState !== 1) {
@@ -1468,6 +1476,7 @@ function connectS2s(opts) {
1468
1476
  }
1469
1477
  function logIncoming(obj) {
1470
1478
  if (obj.type === "reply.audio" || obj.type === "input.audio") return;
1479
+ if (obj.type === "reply.done") return;
1471
1480
  log.info(`S2S << ${obj.type}`);
1472
1481
  }
1473
1482
  function handleS2sMessage(ev) {
@@ -1485,7 +1494,7 @@ function connectS2s(opts) {
1485
1494
  log.warn(`S2S << unrecognised message type: ${obj.type ?? JSON.stringify(raw).slice(0, 200)}`);
1486
1495
  return;
1487
1496
  }
1488
- dispatchS2sMessage(emitter, parsed, dispatchState);
1497
+ dispatchS2sMessage(emitter, parsed, dispatchState, dispatchCtx);
1489
1498
  }
1490
1499
  ws.addEventListener("message", handleS2sMessage);
1491
1500
  ws.addEventListener("close", (ev) => {
@@ -1621,12 +1630,22 @@ function handleReplyCancelled(ctx) {
1621
1630
  ctx.cancelReply();
1622
1631
  ctx.client.event({ type: "cancelled" });
1623
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;
1624
1641
  function handleReplyDone(ctx) {
1642
+ const startMs = Date.now();
1625
1643
  const doneReplyId = ctx.reply.currentReplyId;
1626
1644
  if (doneReplyId === null) {
1627
1645
  ctx.log.debug("Dropping duplicate reply.done (no active reply)");
1628
1646
  return;
1629
1647
  }
1648
+ const hadTurnPromise = ctx.turnPromise !== null;
1630
1649
  const sendPending = () => {
1631
1650
  if (ctx.reply.currentReplyId !== doneReplyId) {
1632
1651
  ctx.reply.pendingTools = [];
@@ -1644,9 +1663,16 @@ function handleReplyDone(ctx) {
1644
1663
  ctx.client.playAudioDone();
1645
1664
  ctx.client.event({ type: "reply_done" });
1646
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
+ });
1647
1673
  }
1648
1674
  };
1649
- if (ctx.turnPromise !== null) ctx.turnPromise.then(sendPending);
1675
+ if (hadTurnPromise) ctx.turnPromise?.then(sendPending);
1650
1676
  else sendPending();
1651
1677
  }
1652
1678
  function setupListeners(ctx, handle) {
@@ -1669,7 +1695,15 @@ function setupListeners(ctx, handle) {
1669
1695
  handle.close();
1670
1696
  });
1671
1697
  handle.on("close", (code, reason) => {
1672
- ctx.log.info("S2S closed", {
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", {
1673
1707
  code,
1674
1708
  reason
1675
1709
  });
@@ -1749,7 +1783,8 @@ function createS2sSession(opts) {
1749
1783
  apiKey,
1750
1784
  config: s2sConfig,
1751
1785
  createWebSocket,
1752
- logger: log
1786
+ logger: log,
1787
+ sid: id
1753
1788
  });
1754
1789
  if (sessionAbort.signal.aborted || generation !== connectGeneration) {
1755
1790
  handle.close();
@@ -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
@@ -402,6 +402,26 @@ describe("connectS2s", () => {
402
402
  expect(handler.mock.calls[0]?.[0]).toEqual({ type: "cancelled" });
403
403
  });
404
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
+
405
425
  test("session.error with session_not_found dispatches 'sessionExpired'", async () => {
406
426
  const { raw, handle } = await setupHandle();
407
427
  const handler = vi.fn();
package/host/s2s.ts CHANGED
@@ -86,10 +86,18 @@ export type S2sEvent = ClientEvent & { _interrupted?: boolean };
86
86
  */
87
87
  type DispatchState = { speechActive: boolean };
88
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
+
89
96
  function dispatchS2sMessage(
90
97
  emitter: Emitter<S2sEvents>,
91
98
  msg: S2sServerMessage,
92
99
  state: DispatchState,
100
+ dispatchCtx: DispatchContext,
93
101
  ): void {
94
102
  switch (msg.type) {
95
103
  case "session.ready":
@@ -131,6 +139,13 @@ function dispatchS2sMessage(
131
139
  });
132
140
  break;
133
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
+ });
134
149
  if (msg.status === "interrupted") {
135
150
  emitter.emit("event", { type: "cancelled" });
136
151
  } else {
@@ -192,10 +207,16 @@ export type ConnectS2sOptions = {
192
207
  config: S2SConfig;
193
208
  createWebSocket: CreateS2sWebSocket;
194
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;
195
216
  };
196
217
 
197
218
  export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
198
- const { apiKey, config, createWebSocket, logger: log = consoleLogger } = opts;
219
+ const { apiKey, config, createWebSocket, logger: log = consoleLogger, sid } = opts;
199
220
 
200
221
  return new Promise((resolve, reject) => {
201
222
  log.info("S2S connecting", { url: config.wssUrl });
@@ -206,6 +227,7 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
206
227
 
207
228
  const emitter = createNanoEvents<S2sEvents>();
208
229
  const dispatchState: DispatchState = { speechActive: false };
230
+ const dispatchCtx: DispatchContext = sid !== undefined ? { log, sid } : { log };
209
231
  let opened = false;
210
232
 
211
233
  function send(msg: { type: string; [key: string]: unknown }): void {
@@ -287,6 +309,9 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
287
309
  function logIncoming(obj: { type?: unknown }): void {
288
310
  // reply.audio and input.audio are ~95% of traffic — skip logging.
289
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;
290
315
  log.info(`S2S << ${obj.type}`);
291
316
  }
292
317
 
@@ -309,7 +334,7 @@ export function connectS2s(opts: ConnectS2sOptions): Promise<S2sHandle> {
309
334
  );
310
335
  return;
311
336
  }
312
- dispatchS2sMessage(emitter, parsed, dispatchState);
337
+ dispatchS2sMessage(emitter, parsed, dispatchState, dispatchCtx);
313
338
  }
314
339
 
315
340
  ws.addEventListener("message", handleS2sMessage);
@@ -191,6 +191,55 @@ describe("createS2sSession", () => {
191
191
  expect(client.audioDoneCount).toBe(1);
192
192
  });
193
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
+
194
243
  test("cancelled event emits cancelled", async () => {
195
244
  const { session, client, mockHandle } = setup();
196
245
  await session.start();
package/host/session.ts CHANGED
@@ -179,7 +179,17 @@ 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;
184
194
  // Dedup duplicate reply.done events from the S2S service: once the reply
185
195
  // has been fully dispatched (or was never started), currentReplyId is null.
@@ -187,6 +197,7 @@ function handleReplyDone(ctx: S2sSessionCtx): void {
187
197
  ctx.log.debug("Dropping duplicate reply.done (no active reply)");
188
198
  return;
189
199
  }
200
+ const hadTurnPromise = ctx.turnPromise !== null;
190
201
  const sendPending = () => {
191
202
  if (ctx.reply.currentReplyId !== doneReplyId) {
192
203
  ctx.reply.pendingTools = [];
@@ -204,10 +215,19 @@ function handleReplyDone(ctx: S2sSessionCtx): void {
204
215
  ctx.client.event({ type: "reply_done" });
205
216
  // Mark reply as finished so any repeated reply.done is dropped above.
206
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
+ }
207
227
  }
208
228
  };
209
- if (ctx.turnPromise !== null) {
210
- void ctx.turnPromise.then(sendPending);
229
+ if (hadTurnPromise) {
230
+ void ctx.turnPromise?.then(sendPending);
211
231
  } else {
212
232
  sendPending();
213
233
  }
@@ -229,7 +249,20 @@ function setupListeners(ctx: S2sSessionCtx, handle: S2sHandle): void {
229
249
  handle.close();
230
250
  });
231
251
  handle.on("close", (code, reason) => {
232
- ctx.log.info("S2S closed", { code, reason });
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
+ }
233
266
  ctx.s2s = null;
234
267
  ctx.cancelReply();
235
268
  });
@@ -319,6 +352,7 @@ export function createS2sSession(opts: S2sSessionOptions): Session {
319
352
  config: s2sConfig,
320
353
  createWebSocket,
321
354
  logger: log,
355
+ sid: id,
322
356
  });
323
357
  if (sessionAbort.signal.aborted || generation !== connectGeneration) {
324
358
  handle.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {