@coder/ai-sdk-agent 0.6.3 → 0.7.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/README.md CHANGED
@@ -324,19 +324,19 @@ for (const ev of events) {
324
324
  `timestamp` (`Date.now()` at observation — comparable to server‑side timestamps
325
325
  such as a message's `created_at`, for delivery‑lag measurements):
326
326
 
327
- | event | when | payload (besides `timestamp`) |
328
- | ---------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
329
- | `http:request` | a REST request is sent | `id` (correlates the pair), `method`, `path` |
330
- | `http:response` | response headers arrive (incl. non‑2xx, `ok:false`) | `id`, `method`, `path`, `status`, `ok`, `durationMs` |
331
- | `http:error` | the fetch itself rejects (network failure, abort) | `id`, `method`, `path`, `message`, `durationMs` |
332
- | `ws:dial` | a stream connection attempt starts | `chatId`, `attempt` (1‑based, increments per redial), `url` |
333
- | `ws:open` | the WebSocket handshake completes | `chatId`, `attempt` |
334
- | `ws:event` | a decoded stream event arrives | `chatId`, `attempt`, `event` (the decoded `ChatStreamEvent`, by reference — don't mutate) |
335
- | `ws:close` | the connection ends (exactly one per dial) | `chatId`, `attempt`, `code`/`reason` when the server/network closed it; absent when the reader closed it (settle, teardown, redial) |
336
- | `ws:error` | a socket error or unparseable frame | `chatId`, `attempt`, `message` |
337
- | `ws:redial` | a dropped connection is about to be redialed | `chatId`, `attempt` (the ended connection), `consecutiveFailures`, `maxConsecutiveFailures`, `backoffMs` |
338
- | `segment:start` | a turn segment (one model round‑trip) starts | `segment` (1‑based per model instance), `chatId` (absent before the first turn creates the chat) |
339
- | `segment:settle` | the segment ends (exactly one per start) | `segment`, `chatId`, `durationMs`, and: `status` + `finishReason` on a clean settle, `error` (`{name, message}`, plus `status` if the run still settled terminally) on failure, neither on a teardown |
327
+ | event | when | payload (besides `timestamp`) |
328
+ | ---------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
329
+ | `http:request` | a REST request is sent | `id` (correlates the pair), `method`, `path` |
330
+ | `http:response` | response headers arrive (incl. non‑2xx, `ok:false`) | `id`, `method`, `path`, `status`, `ok`, `durationMs` |
331
+ | `http:error` | the fetch itself rejects (network failure, abort) | `id`, `method`, `path`, `message`, `durationMs` |
332
+ | `ws:dial` | a stream connection attempt starts | `chatId`, `reader` (identifies the `streamChatEvents` call), `attempt` (1‑based per reader, increments per redial), `url` |
333
+ | `ws:open` | the WebSocket handshake completes | `chatId`, `reader`, `attempt` |
334
+ | `ws:event` | a decoded stream event arrives | `chatId`, `reader`, `attempt`, `event` (the decoded `ChatStreamEvent`, by reference — don't mutate) |
335
+ | `ws:close` | the connection ends (exactly one per dial) | `chatId`, `reader`, `attempt`, `code`/`reason` when the server/network closed it; absent when the reader closed it (settle, teardown, redial) |
336
+ | `ws:error` | a socket error or unparseable frame | `chatId`, `reader`, `attempt`, `message` |
337
+ | `ws:redial` | a dropped connection is about to be redialed | `chatId`, `reader`, `attempt` (the ended connection), `consecutiveFailures`, `maxConsecutiveFailures`, `backoffMs` |
338
+ | `segment:start` | a turn segment (one model round‑trip) starts | `segment` (1‑based per model instance), `chatId` (absent before the first turn creates the chat) |
339
+ | `segment:settle` | the segment ends (exactly one per start) | `segment`, `chatId`, `reader` (the reader that served the segment; absent if none was acquired), `durationMs`, and: `status` + `finishReason` on a clean settle, `error` (`{name, message}`, plus `status` if the run still settled terminally) on failure, neither on a teardown |
340
340
 
341
341
  Semantics worth knowing:
342
342
 
@@ -348,8 +348,18 @@ Semantics worth knowing:
348
348
  `Coder-Session-Token` header, which is deliberately excluded); `path`/`url`
349
349
  never contain credentials.
350
350
  - `ws:event` fires at arrival, **before** replay dedup: after a redial, chatd's
351
- replay of the in‑progress episode is visible here (correlate with `attempt`)
351
+ replay of the in‑progress episode is visible here (correlate with `reader`/`attempt`)
352
352
  even though the reader suppresses the duplicates it forwards to the turn.
353
+ - Every `ws:*` event carries `reader` — a monotonic id for the
354
+ `streamChatEvents` call (reader) behind the connection, allocated from one
355
+ process‑wide counter so it stays unique across model and client instances.
356
+ `attempt` restarts at 1 per reader, so identify a connection as
357
+ `(chatId, reader, attempt)`, never `(chatId, attempt)` alone: a client‑tool
358
+ pause the caller abandons is closed fire‑and‑forget when the next turn dials
359
+ its replacement, and the superseded reader's late `ws:close` (or a raced‑in
360
+ frame) can emit after the new reader's `ws:dial` — the reader id is what
361
+ tells them apart. `segment:settle` names the reader that served the segment;
362
+ `segment:start` predates stream acquisition and carries none.
353
363
  - A multi‑step turn that drives client tools emits one `segment:start`/
354
364
  `segment:settle` pair per round‑trip, all riding **one** `ws:dial`ed
355
365
  connection — the stream is retained across `requires_action` pauses. A pause
package/dist/index.d.ts CHANGED
@@ -382,9 +382,24 @@ interface HttpErrorTransportEvent extends HttpTransportEventBase {
382
382
  }
383
383
  interface StreamTransportEventBase extends TransportEventBase {
384
384
  chatId: string;
385
+ /**
386
+ * Identifies the reader (one `streamChatEvents` call) this event belongs
387
+ * to: monotonically increasing, allocated per call from one module-global
388
+ * (process-wide) counter — NOT per model or client instance, so
389
+ * `(chatId, reader, attempt)` stays unique for the lifetime of any
390
+ * subscriber, even one shared across instances (issue #94). Readers on
391
+ * other chats/clients advance the same counter, so ids are ordered but not
392
+ * dense per chat. Use it to tell a superseded reader's late events apart
393
+ * from the live reader's: a client-tool pause abandoned by the caller is
394
+ * closed fire-and-forget when the next turn dials its replacement, and the
395
+ * old reader's `ws:close` (or a raced-in frame) can emit after the new
396
+ * reader's `ws:dial` — both would otherwise be ambiguous at `attempt` 1.
397
+ */
398
+ reader: number;
385
399
  /**
386
400
  * 1-based connection attempt within one reader (`streamChatEvents` call —
387
- * with stream retention (#44), one per turn). Increments on every redial.
401
+ * with stream retention (#44), one per turn). Increments on every redial
402
+ * and restarts at 1 for each new reader.
388
403
  */
389
404
  attempt: number;
390
405
  }
@@ -446,6 +461,17 @@ interface SegmentTransportEventBase extends TransportEventBase {
446
461
  segment: number;
447
462
  /** Undefined on a first turn's start until the chat is created. */
448
463
  chatId?: string;
464
+ /**
465
+ * The reader (`streamChatEvents` call — the `reader` on `ws:*` events) that
466
+ * served this segment, correlating it with the connection's `ws:*` events.
467
+ * Absent on `segment:start`, which is emitted before the segment acquires
468
+ * its stream (a fresh turn dials only once the chat exists, and even a
469
+ * stream retained by a client-tool pause can still be replaced — stamping a
470
+ * predicted reader could lie), and on a settle when the segment failed
471
+ * before acquiring one. Segments of one turn share the reader: the stream
472
+ * is retained across `requires_action` pauses (#44).
473
+ */
474
+ reader?: number;
449
475
  }
450
476
  /**
451
477
  * A segment (one model round-trip: submission plus the server run until it
@@ -531,6 +557,13 @@ interface StreamChatEventsOptions {
531
557
  * listeners are registered. See {@link CoderTransportEvent}.
532
558
  */
533
559
  onTransportEvent?: TransportEventHandler;
560
+ /**
561
+ * Reader id stamped on this call's `ws:*` events, for callers that need to
562
+ * know it (the model records it to stamp `segment:settle` with the reader
563
+ * that served the segment). Must come from {@link nextStreamReaderId};
564
+ * allocated automatically when absent.
565
+ */
566
+ reader?: number;
534
567
  }
535
568
  /**
536
569
  * Opens the chatd `/stream` WebSocket and yields decoded {@link ChatStreamEvent}s
@@ -678,6 +711,8 @@ declare class CoderChatClient {
678
711
  streamEvents(chatId: string, opts?: {
679
712
  afterId?: number;
680
713
  signal?: AbortSignal;
714
+ /** Pre-allocated reader id for `ws:*` events — see {@link streamChatEvents}. */
715
+ reader?: number;
681
716
  }): AsyncGenerator<ChatStreamEvent, void, void>;
682
717
  /**
683
718
  * Watch lifecycle events (status/title changes, creation, deletion, …) for
package/dist/index.js CHANGED
@@ -185,6 +185,11 @@ function httpToWs(baseUrl) {
185
185
  if (baseUrl.startsWith("http://")) return `ws://${baseUrl.slice("http://".length)}`;
186
186
  return baseUrl;
187
187
  }
188
+ var readerSeq = 0;
189
+ function nextStreamReaderId() {
190
+ readerSeq += 1;
191
+ return readerSeq;
192
+ }
188
193
  var STREAM_BACKOFF_INITIAL_MS = 1e3;
189
194
  var STREAM_BACKOFF_CAP_MS = 3e4;
190
195
  var STREAM_MAX_CONSECUTIVE_FAILURES = 5;
@@ -197,7 +202,8 @@ function streamChatEvents(options) {
197
202
  if (external.aborted) chain();
198
203
  else external.addEventListener("abort", chain, { once: true });
199
204
  }
200
- const inner = streamChatEventsLoop({ ...options, signal: controller.signal });
205
+ const reader = options.reader ?? nextStreamReaderId();
206
+ const inner = streamChatEventsLoop({ ...options, reader, signal: controller.signal });
201
207
  const detach = () => external?.removeEventListener("abort", chain);
202
208
  const finish = async () => {
203
209
  controller.abort();
@@ -240,7 +246,7 @@ function streamChatEvents(options) {
240
246
  };
241
247
  }
242
248
  async function* streamChatEventsLoop(options) {
243
- const { baseUrl, token, chatId, signal } = options;
249
+ const { baseUrl, token, chatId, signal, reader } = options;
244
250
  const factory = options.webSocketFactory ?? defaultFactory;
245
251
  const emit = safeTransportEmitter(options.onTransportEvent);
246
252
  let attempt = 0;
@@ -269,15 +275,15 @@ async function* streamChatEventsLoop(options) {
269
275
  resolveNext = void 0;
270
276
  };
271
277
  attempt += 1;
272
- emit?.({ type: "ws:dial", chatId, attempt, url, timestamp: Date.now() });
278
+ emit?.({ type: "ws:dial", chatId, reader, attempt, url, timestamp: Date.now() });
273
279
  let ws;
274
280
  try {
275
281
  ws = factory(url, { headers: { "Coder-Session-Token": token } });
276
282
  } catch (err) {
277
283
  if (emit) {
278
284
  const message = err instanceof Error ? err.message : String(err);
279
- emit({ type: "ws:error", chatId, attempt, message, timestamp: Date.now() });
280
- emit({ type: "ws:close", chatId, attempt, timestamp: Date.now() });
285
+ emit({ type: "ws:error", chatId, reader, attempt, message, timestamp: Date.now() });
286
+ emit({ type: "ws:close", chatId, reader, attempt, timestamp: Date.now() });
281
287
  }
282
288
  throw err;
283
289
  }
@@ -307,6 +313,7 @@ async function* streamChatEventsLoop(options) {
307
313
  emit?.({
308
314
  type: "ws:error",
309
315
  chatId,
316
+ reader,
310
317
  attempt,
311
318
  message: "failed to parse chat stream frame",
312
319
  timestamp: Date.now()
@@ -323,6 +330,7 @@ async function* streamChatEventsLoop(options) {
323
330
  emit?.({
324
331
  type: "ws:event",
325
332
  chatId,
333
+ reader,
326
334
  attempt,
327
335
  event: e,
328
336
  timestamp: Date.now()
@@ -333,6 +341,7 @@ async function* streamChatEventsLoop(options) {
333
341
  emit?.({
334
342
  type: "ws:event",
335
343
  chatId,
344
+ reader,
336
345
  attempt,
337
346
  event: batch,
338
347
  timestamp: Date.now()
@@ -343,7 +352,7 @@ async function* streamChatEventsLoop(options) {
343
352
  const onError = (ev) => {
344
353
  if (finished) return;
345
354
  const message = ev && typeof ev === "object" && "message" in ev ? String(ev.message) : "chat stream socket error";
346
- emit?.({ type: "ws:error", chatId, attempt, message, timestamp: Date.now() });
355
+ emit?.({ type: "ws:error", chatId, reader, attempt, message, timestamp: Date.now() });
347
356
  const status = upgradeStatus(message);
348
357
  failure = status !== void 0 && status >= 400 && status < 500 && !TRANSIENT_UPGRADE_STATUSES.has(status) ? new CoderApiError({ status, method: "GET", path, message }) : new CoderAgentError(message);
349
358
  finished = true;
@@ -355,6 +364,7 @@ async function* streamChatEventsLoop(options) {
355
364
  const closeEvent = {
356
365
  type: "ws:close",
357
366
  chatId,
367
+ reader,
358
368
  attempt,
359
369
  timestamp: Date.now()
360
370
  };
@@ -365,7 +375,7 @@ async function* streamChatEventsLoop(options) {
365
375
  finished = true;
366
376
  wake();
367
377
  };
368
- const onOpen = emit ? () => emit({ type: "ws:open", chatId, attempt, timestamp: Date.now() }) : void 0;
378
+ const onOpen = emit ? () => emit({ type: "ws:open", chatId, reader, attempt, timestamp: Date.now() }) : void 0;
369
379
  if (onOpen) ws.addEventListener("open", onOpen);
370
380
  ws.addEventListener("message", onMessage);
371
381
  ws.addEventListener("error", onError);
@@ -426,7 +436,7 @@ async function* streamChatEventsLoop(options) {
426
436
  }
427
437
  if (emit && !closeEmitted) {
428
438
  closeEmitted = true;
429
- emit({ type: "ws:close", chatId, attempt, timestamp: Date.now() });
439
+ emit({ type: "ws:close", chatId, reader, attempt, timestamp: Date.now() });
430
440
  }
431
441
  }
432
442
  if (signal?.aborted) return;
@@ -451,6 +461,7 @@ async function* streamChatEventsLoop(options) {
451
461
  emit?.({
452
462
  type: "ws:redial",
453
463
  chatId,
464
+ reader,
454
465
  attempt,
455
466
  consecutiveFailures: failures,
456
467
  maxConsecutiveFailures: STREAM_MAX_CONSECUTIVE_FAILURES,
@@ -867,6 +878,7 @@ var CoderChatClient = class {
867
878
  chatId,
868
879
  afterId: opts?.afterId,
869
880
  signal: opts?.signal,
881
+ reader: opts?.reader,
870
882
  webSocketFactory: this.#webSocketFactory,
871
883
  onTransportEvent: this.#onTransportEvent
872
884
  });
@@ -1145,6 +1157,12 @@ function dynamicToolNames(tools) {
1145
1157
  // src/model/session-stream.ts
1146
1158
  var SessionChatStream = class {
1147
1159
  chatId;
1160
+ /**
1161
+ * The reader id stamped on the wrapped reader's `ws:*` transport events —
1162
+ * the model stamps it on `segment:settle` so segments correlate with the
1163
+ * connection that served them (issue #94).
1164
+ */
1165
+ reader;
1148
1166
  #events;
1149
1167
  #controller = new AbortController();
1150
1168
  #pending;
@@ -1153,6 +1171,7 @@ var SessionChatStream = class {
1153
1171
  #closePromise;
1154
1172
  constructor(opts) {
1155
1173
  this.chatId = opts.chatId;
1174
+ this.reader = opts.reader;
1156
1175
  this.#events = opts.open(this.#controller.signal);
1157
1176
  }
1158
1177
  get closed() {
@@ -1811,9 +1830,11 @@ var CoderLanguageModel = class {
1811
1830
  }
1812
1831
  this.#retainedStream = void 0;
1813
1832
  if (retained) void retained.close();
1833
+ const reader = nextStreamReaderId();
1814
1834
  const created = new SessionChatStream({
1815
1835
  chatId,
1816
- open: (streamSignal) => this.#config.client.streamEvents(chatId, { afterId, signal: streamSignal })
1836
+ reader,
1837
+ open: (streamSignal) => this.#config.client.streamEvents(chatId, { afterId, signal: streamSignal, reader })
1817
1838
  });
1818
1839
  this.#retainedStream = created;
1819
1840
  return created;
@@ -1977,6 +1998,7 @@ var CoderLanguageModel = class {
1977
1998
  };
1978
1999
  const chatId = segmentChat.chatId ?? this.#chatId;
1979
2000
  if (chatId !== void 0) settleEvent.chatId = chatId;
2001
+ if (segmentChat.reader !== void 0) settleEvent.reader = segmentChat.reader;
1980
2002
  if (!failed && finishReason === void 0) {
1981
2003
  if (segmentChat.timeoutError) {
1982
2004
  failure = segmentChat.timeoutError;
@@ -2140,6 +2162,7 @@ var CoderLanguageModel = class {
2140
2162
  let recoveryAttempted = false;
2141
2163
  const stream = this.#acquireStream(action.kind === "resume", chatId, afterId);
2142
2164
  streamAcquired = true;
2165
+ if (segmentChat) segmentChat.reader = stream.reader;
2143
2166
  let onSegmentAbort;
2144
2167
  const segmentAborted = signal ? new Promise((resolve) => {
2145
2168
  onSegmentAbort = () => resolve(SEGMENT_ABORTED);