@coder/ai-sdk-agent 0.6.3 → 0.7.1

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
@@ -1118,15 +1128,31 @@ checkpoint:
1118
1128
  // to interrupting — and journals each action write-ahead, so a recovery
1119
1129
  // pass that dies mid-recovery is visible — and classifiable — to the next.
1120
1130
  declare function reconcileClientTools(): Promise<boolean>;
1121
- // Entries are per-ATTEMPT: clear/supersede them transactionally with
1122
- // recording the attempt's result, or a stale verdict misclassifies the
1123
- // next attempt on this chat — attempt-scoped journal keys close the
1124
- // remaining double-crash window (see
1125
- // https://github.com/coder/ai-sdk/issues/92).
1131
+ // Journal entries are per-ATTEMPT, so the chat id alone cannot scope them:
1132
+ // a verdict from attempt N must not classify attempt N+1 (double crash),
1133
+ // and a later step's recovery on the same chat must not inherit this
1134
+ // one's. Every entry pins the step marker and an attempt count at write
1135
+ // time; a consult applies an entry only while both still match, so each
1136
+ // resubmission retires every earlier verdict by itself — there is no
1137
+ // clear step to forget.
1138
+ type JournalEntry = {
1139
+ // "submitting" is written ahead of every send on a checkpointed chat;
1140
+ // "resumed" / "cut-short" are the ledger reconcile's verdict (both in
1141
+ // the next sections).
1142
+ state: "submitting" | "resumed" | "cut-short";
1143
+ marker: string; // the step-scoped marker ("Recover the result…", below)
1144
+ attempt: number; // markerAttempts() at write time
1145
+ at: number; // write time — bounds the in-flight-send wait below
1146
+ };
1126
1147
  declare const journal: {
1127
- get(chatId: string): Promise<"resumed" | "cut-short" | undefined>;
1128
- set(chatId: string, state: "resumed" | "cut-short"): Promise<void>;
1148
+ get(chatId: string): Promise<JournalEntry | undefined>;
1149
+ set(chatId: string, entry: JournalEntry): Promise<void>;
1129
1150
  };
1151
+ // This step's attempts committed so far: user messages in the converted
1152
+ // transcript carrying the marker. Steps run sequentially, so they sit
1153
+ // contiguous at the tail of history — page newest-first (as the recovery
1154
+ // scan below already does) and stop at the first user message without it.
1155
+ declare function markerAttempts(): Promise<number>;
1130
1156
 
1131
1157
  // Bounded: a stalled deployment must fail the step, not hang it.
1132
1158
  const deadline = AbortSignal.timeout(15_000);
@@ -1134,7 +1160,11 @@ checkpoint:
1134
1160
  // below: a 409 means there was nothing to stop.
1135
1161
  let cutShort = true;
1136
1162
  let { status } = await agent.client.getChat(agent.chatId!, deadline);
1137
- const journaled = await journal.get(agent.chatId!);
1163
+ // Recompute what the entry pinned at write time; a mismatch on either
1164
+ // means it classifies an earlier attempt (or another step's) — dead, skip.
1165
+ const entry = await journal.get(agent.chatId!);
1166
+ const attempts = await markerAttempts();
1167
+ const journaled = entry?.marker === marker && entry.attempt === attempts ? entry : undefined;
1138
1168
  if (status === "requires_action") {
1139
1169
  // The crash hit a client tool mid-execution: this pause is the only
1140
1170
  // pending record of its committed effects. Never interrupt it away —
@@ -1142,8 +1172,8 @@ checkpoint:
1142
1172
  // (reviving the run — nothing was cut short) or interrupts itself only
1143
1173
  // when nothing committed.
1144
1174
  cutShort = await reconcileClientTools();
1145
- } else if (journaled) {
1146
- // A previous recovery pass acted on this chat, then died before the
1175
+ } else if (journaled && journaled.state !== "submitting") {
1176
+ // A previous recovery pass acted on this attempt, then died before the
1147
1177
  // step's result was recorded. Its journaled state is the verdict:
1148
1178
  // "resumed" — it revived the run by submitting recorded results;
1149
1179
  // interrupting now would cut down a run doing the step's work, and the
@@ -1151,7 +1181,7 @@ checkpoint:
1151
1181
  // idempotency keys. Let it finish; nothing was cut short.
1152
1182
  // "cut-short" — it went on to interrupt (an unstarted sibling in the
1153
1183
  // batch); the attempt is truncated, so the check below must resubmit.
1154
- cutShort = journaled === "cut-short";
1184
+ cutShort = journaled.state === "cut-short";
1155
1185
  if (cutShort) {
1156
1186
  // The journal records write-ahead INTENT, not a landed interrupt: the
1157
1187
  // pass may have died in between, leaving the revived run working.
@@ -1172,6 +1202,24 @@ checkpoint:
1172
1202
  else throw err;
1173
1203
  });
1174
1204
  }
1205
+ // One thing can still be in flight here: the crashed attempt's own SEND. A
1206
+ // matching "submitting" entry — its attempt count has not grown — means the
1207
+ // process died with `createChatMessage` possibly en route, and the server
1208
+ // can commit it AFTER every read above (the 409 only said nothing was
1209
+ // running THEN). Sleep out what remains of the entry's commit window —
1210
+ // requestTimeoutMs past the write is the conservative horizon, and a
1211
+ // restart has usually consumed it already — so a late-landing turn starts
1212
+ // before the settle poll below and the next section's negative history
1213
+ // read is past the window. Then recount: a send that never landed OVER AN
1214
+ // EXISTING attempt pins cutShort — the step only ever plans a resubmission
1215
+ // over a cut-short, errored, or unsubmitted attempt, so the write-ahead
1216
+ // that superseded a "cut-short" verdict still carries its consequence, and
1217
+ // the scan below cannot mistake the old attempt's truncated tail for a
1218
+ // finished result.
1219
+ if (journaled?.state === "submitting") {
1220
+ await new Promise((r) => setTimeout(r, Math.max(0, journaled.at + 300_000 - Date.now())));
1221
+ if ((await markerAttempts()) === journaled.attempt && journaled.attempt > 0) cutShort = true;
1222
+ }
1175
1223
  // The 15 s interrupt bound must not cap what follows: a reconciled pause
1176
1224
  // revives a full model/tool continuation. Budget the settle poll — and the
1177
1225
  // history reads after it — on the turn scale, like the step itself.
@@ -1266,7 +1314,8 @@ event — and the ledger holds the verdict on each:
1266
1314
  // The step's configured ToolSet names: history does not record which calls
1267
1315
  // were client tools (see [Rehydrating chat history]), so — like the SDK's
1268
1316
  // own derivation — restrict to the names this step registers; a server/MCP
1269
- // call must never reach the ledger as "unstarted".
1317
+ // call must never reach the ledger as "unstarted". (The recovery scan's
1318
+ // final-text cut reuses this set — next section.)
1270
1319
  const clientTools = new Set(["charge_card"]);
1271
1320
 
1272
1321
  // Pending = the last assistant message's client (non-provider-executed,
@@ -1321,9 +1370,14 @@ if (results.length > 0 || unstarted) {
1321
1370
  // call — a mixed batch journals "cut-short" before even its submission,
1322
1371
  // so no crash point can leave a stale "resumed" masking the pending
1323
1372
  // interrupt. Every crash then either re-reconciles the stable pause or
1324
- // re-drives the journaled intent. Clear the entry only once the step's
1325
- // result is finally recorded.
1326
- await journal.set(agent.chatId!, unstarted ? "cut-short" : "resumed");
1373
+ // re-drives the journaled intent. Scoped to THIS attempt: a resubmission
1374
+ // grows the marker count and retires the verdict on its own.
1375
+ await journal.set(agent.chatId!, {
1376
+ state: unstarted ? "cut-short" : "resumed",
1377
+ marker,
1378
+ attempt: await markerAttempts(),
1379
+ at: Date.now(),
1380
+ });
1327
1381
  }
1328
1382
  if (results.length > 0) await agent.client.submitToolResults(agent.chatId!, { results }, deadline);
1329
1383
  if (unstarted) await agent.client.interruptChat(agent.chatId!, deadline);
@@ -1358,11 +1412,29 @@ step's return value was recorded.** History now holds a perfectly good
1358
1412
  result; a retried step that blindly resubmits runs the whole turn again —
1359
1413
  duplicated output, repeated workspace/MCP/tool effects. This is the
1360
1414
  at‑least‑once boundary every durable engine has around external effects, and
1361
- the chat itself is the record that closes it. Make attempts matchable first —
1362
- the server stores the prompt verbatim, so embed a step‑scoped marker:
1415
+ the chat itself is the record that narrows it to one honest window (below).
1416
+ Make attempts matchable first — the server stores the prompt verbatim, so
1417
+ embed a step‑scoped marker — and journal every send before it leaves the
1418
+ process:
1363
1419
 
1364
1420
  ```ts
1365
1421
  const marker = `[${workflowId}/step-2]`; // stable across this step's retries
1422
+ // Write-ahead, BEFORE the send — first attempt and recovery resubmit alike.
1423
+ // A crash before this write proves no send ever started; a crash after it
1424
+ // leaves a dated entry whose commit window the settle-wait sleeps out. On a
1425
+ // resubmission this write supersedes the "cut-short" verdict it acts on —
1426
+ // safely: an unlanded send over an existing attempt makes the settle-wait
1427
+ // pin cutShort right back. A first turn has no chat id to journal under: its
1428
+ // send-crash window belongs to the empty-checkpoint sweep
1429
+ // ("Recover after a crash").
1430
+ if (agent.chatId) {
1431
+ await journal.set(agent.chatId, {
1432
+ state: "submitting",
1433
+ marker,
1434
+ attempt: await markerAttempts(),
1435
+ at: Date.now(),
1436
+ });
1437
+ }
1366
1438
  const { text } = await agent.generate({ prompt: `${marker} ${prompt}` });
1367
1439
  ```
1368
1440
 
@@ -1391,25 +1463,27 @@ const submitted = lastUser?.parts.some((p) => p.type === "text" && p.text.includ
1391
1463
 
1392
1464
  if (submitted && !cutShort && (status === "waiting" || status === "completed")) {
1393
1465
  // The attempt finished; only the recording was lost. Recover what
1394
- // `generate().text` would have returned — the FINAL step's text. A turn
1395
- // that crossed tool calls commits narration between them too, so cut the
1396
- // turn's parts after the last tool part and join the text that follows.
1466
+ // `generate().text` would have returned — the FINAL step's text. Only a
1467
+ // CLIENT tool ends an AI SDK step, and narration lands on both sides of a
1468
+ // server-side call, so cut after the last part named in `clientTools`
1469
+ // (shared with the reconcile above) and join the text that follows.
1397
1470
  const parts = transcript
1398
1471
  .slice(transcript.indexOf(lastUser!) + 1)
1399
1472
  .filter((m) => m.role === "assistant")
1400
1473
  .flatMap((m) => m.parts);
1401
1474
  return parts
1402
- .slice(parts.findLastIndex((p) => p.type === "dynamic-tool") + 1)
1475
+ .slice(parts.findLastIndex((p) => p.type === "dynamic-tool" && clientTools.has(p.toolName)) + 1)
1403
1476
  .filter((p) => p.type === "text")
1404
1477
  .map((p) => p.text)
1405
1478
  .join("");
1406
1479
  }
1407
1480
  // Resubmit only what never finished: the prompt never reached the server
1408
- // (!submitted), the turn failed (status "error" — normal retry judgment
1409
- // applies), or the interrupt cut a live run short (cutShort — the model sees
1410
- // its own aborted attempt in history and continues from it). One exit is
1411
- // neither: a requires_action settle goes back to ledger reconciliation
1412
- // (previous section) — never to recovery or resubmission.
1481
+ // (!submitted — sound only behind the write-ahead; see below), the turn
1482
+ // failed (status "error" — normal retry judgment applies), or the interrupt
1483
+ // cut a live run short (cutShort — the model sees its own aborted attempt in
1484
+ // history and continues from it). One exit is neither: a requires_action
1485
+ // settle goes back to ledger reconciliation (previous section) — never to
1486
+ // recovery or resubmission.
1413
1487
  ```
1414
1488
 
1415
1489
  - **`cutShort` is the completed/interrupted discriminator — but a 409 only
@@ -1421,22 +1495,39 @@ if (submitted && !cutShort && (status === "waiting" || status === "completed"))
1421
1495
  hard crash, false after a `requestTimeoutMs` expiry — the expiry already
1422
1496
  fired the SDK's own best‑effort interrupt, which may have stopped the run
1423
1497
  before recovery ever looked. Carry the reason recovery ran: when it follows
1424
- a known timeout or abort — or a journaled earlier interrupt (write‑ahead,
1425
- like the tool ledger, if the recovery pass can itself crash between its
1426
- interrupt and the resubmit) — pin `cutShort` to `true` and let only the
1427
- hard‑crash path trust the 409.
1428
- - **The cut lands after the last tool part of _either_ kind.** History does
1429
- not mark which tool calls were client tools
1430
- ([Rehydrating chat history](#rehydrating-chat-history)), so when a final
1431
- step narrates _before_ a server‑side tool call, that preamble is dropped
1432
- too — the recovered text is the answer following the turn's last tool
1433
- activity.
1498
+ a known timeout or abort — or a matching journaled `"cut-short"`, or an
1499
+ unlanded `"submitting"` over an existing attempt (the settle‑wait already
1500
+ pins both) — pin `cutShort` to `true` and let only the hard‑crash path
1501
+ trust the 409.
1502
+ - **`!submitted` races the crashed attempt's own send.** A crash kills the
1503
+ process, not necessarily its in‑flight `createChatMessage` — the server can
1504
+ commit that request _after_ an interrupt 409'd and a history read saw no
1505
+ marker, and a resubmit on that evidence queues the turn twice, duplicating
1506
+ its workspace/MCP/tool effects. The write‑ahead is what makes the negative
1507
+ read honest: no matching `"submitting"` entry proves no send ever started,
1508
+ and a matching one held recovery at the settle‑wait until the entry's
1509
+ commit horizon passed. That narrows the window; only server‑enforced
1510
+ submission idempotency could close it, and `POST /chats/{id}/messages`
1511
+ offers none today. A commit that outlasts even the horizon still lands
1512
+ under the same marker, so the duplicate is at least visible — in history,
1513
+ and to the next consult's attempt count.
1514
+ - **The cut lands at the last _configured client‑tool_ boundary.** History
1515
+ does not mark which calls were client tools
1516
+ ([Rehydrating chat history](#rehydrating-chat-history)), so the cut
1517
+ classifies by name against the reconcile's `clientTools` set — narration
1518
+ before a trailing _server‑side_ call survives, matching what
1519
+ `generate().text` aggregates. What name‑matching cannot see: a call
1520
+ recorded under a name the step no longer configures (a tool renamed
1521
+ between deploys) reads as server‑side and leaks earlier‑step narration
1522
+ into the result, and a server tool sharing a configured name still cuts
1523
+ final‑step narration away.
1434
1524
  - **Steps with structured results recover the call's input, not the text.**
1435
1525
  A [structured output](#structured-output) step's answer is the
1436
1526
  `structured_output` call's typed input, which rehydrates as a
1437
1527
  `dynamic-tool` part on the recovered turn
1438
- ([Rehydrating chat history](#rehydrating-chat-history)) — the text join
1439
- above would slice it away and return only the ack prose that follows.
1528
+ ([Rehydrating chat history](#rehydrating-chat-history)) — and
1529
+ `structured_output` is one of the step's configured client tools, so the
1530
+ text join above cuts at it and returns only the ack prose that follows.
1440
1531
  Recover the filed call instead — this scan replaces the text join
1441
1532
  _inside_ the recovery branch above, reusing its `parts` — validated
1442
1533
  client‑side exactly like the live path (rule 2 there — the schema is the
@@ -1566,7 +1657,8 @@ export async function archiveWorkflowChat(workflowId: string): Promise<void> {
1566
1657
  - Let redials self‑heal; own every retry decision — a re‑run step resubmits
1567
1658
  its prompt as a new user turn.
1568
1659
  - Before resubmitting after a crash: reconcile the tool ledger before any
1569
- interrupt, and recover a finished attempt's result from history.
1660
+ interrupt, recover a finished attempt's result from history, and trust
1661
+ "never submitted" only past the journaled send's commit window.
1570
1662
  - Keep concurrent steps' fan‑out within workspace quota —
1571
1663
  [Workspaces & quota](#workspaces--quota).
1572
1664
  - Steps that don't need server‑side tools (plan / extract / synthesize) are
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);