@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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @alexkroman1/aai@1.4.3 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 75.94 kB │ gzip: 22.51 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: 100.76 kB
26
- ✔ Build complete in 45ms
25
+ ℹ 14 files, total: 102.30 kB
26
+ ✔ Build complete in 49ms
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
- emitter.emit("event", { type: "speech_started" });
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
- emitter.emit("event", { type: "speech_stopped" });
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 (ctx.turnPromise !== null) ctx.turnPromise.then(sendPending);
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.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", {
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();
@@ -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).toHaveBeenCalledOnce();
225
- expect(handler.mock.calls[0]?.[0]).toEqual({ type: "speech_stopped" });
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
- function dispatchS2sMessage(emitter: Emitter<S2sEvents>, msg: S2sServerMessage): void {
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
- emitter.emit("event", { type: "speech_started" });
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
- emitter.emit("event", { type: "speech_stopped" });
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);
@@ -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 (ctx.turnPromise !== null) {
202
- void ctx.turnPromise.then(sendPending);
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.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
+ }
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {