@coder/ai-sdk-agent 0.6.2 → 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
@@ -816,7 +826,7 @@ sequence pinpoints _where_ a turn died:
816
826
  | `segment:settle` carries `error: { name: "CoderChatError", message: "…requestTimeoutMs budget…" }` | the per‑segment bound expired — wedged server, slow model, or an unschedulable workspace | inspect the workspace via the v2 API/UI: a `pending`/`failed` build means the row above; `running` means the turn was genuinely slow — raise `requestTimeoutMs` for long tool work |
817
827
  | repeated `ws:redial` with `consecutiveFailures` climbing toward `maxConsecutiveFailures` (5; backoff 1 s → 2 s → 4 s → 8 s, ≈15 s of redialing without forward progress), then a `CoderStreamError` | the network path to the deployment is failing — not workspace scheduling (the server keeps generating through short gaps) | fix connectivity; mind retry ownership above — on workspace‑backed chats the error is `isRetryable: false`, so the replay decision is yours |
818
828
  | `segment:settle` with `status: "error"` and an `error` payload | the turn failed server‑side — a provider/model error, a tool failure, or a scheduling/build failure that terminated the turn instead of leaving it pending | the settle event's `error` carries only `{ name, message }` — branch on `kind` / `retryable` / `statusCode` by catching the thrown `CoderChatError`: at the `generate()` call site, or — for `stream()` — around stream consumption, since mid‑stream failures surface on the stream, not from `await agent.stream()` ([Handling errors](#handling-errors)); check the workspace build state to rule scheduling in or out |
819
- | turn settled `status: "requires_action"`, follow‑up messages queue forever | the loop ended on an unanswered client tool call | submit the stranded results or interrupt — see rule 4 under [Structured output](#structured-output) |
829
+ | turn settled `status: "requires_action"`, follow‑up messages queue forever | the loop ended on an unanswered client tool call | submit the stranded results or interrupt — see rule 4 under [Structured output](#structured-output); if a crash left the pause behind, reconcile effects first ([Make client tools crash-safe](#make-client-tools-crash-safe)) |
820
830
  | `archive()` keeps returning 409 and rethrows after ~15 s | the chat never settled server‑side — usually a stuck run still holding its workspace | `interrupt()` with a bounded signal, then re‑archive; if the run stays wedged, stop the workspace itself |
821
831
 
822
832
  ## Configuration
@@ -1031,7 +1041,11 @@ try {
1031
1041
  server interrupt is asynchronous and best‑effort, so the timed‑out run may
1032
1042
  still be committing — run the same interrupt‑and‑wait‑for‑terminal‑status
1033
1043
  recovery as after a crash (below) before the retry, or its trailing output
1034
- is absorbed into the retried turn.
1044
+ is absorbed into the retried turn. A timed‑out run can also have _finished_
1045
+ before that interrupt landed — but because the interrupt was already fired,
1046
+ the recovery's [result check](#recover-the-result-before-resubmitting)
1047
+ must treat the attempt as cut short (pin `cutShort` there) rather than
1048
+ trusting its own 409.
1035
1049
  - **`CoderStreamError`** (an AI SDK `APICallError`) — the stream could not be
1036
1050
  re‑established. `isRetryable: true` only when the failed turn had just
1037
1051
  created its chat _and_ had no external effects a replay would repeat (no
@@ -1096,27 +1110,365 @@ checkpoint:
1096
1110
  resumed turn seeds its message cursor _before_ submitting — whatever the
1097
1111
  old run commits after that point would be absorbed into the resumed turn's
1098
1112
  output and usage. A crash _inside one of your client tools_ is worse: the
1099
- chat is paused in `requires_action`, where new messages wait forever (and
1100
- see [#86](https://github.com/coder/ai-sdk/issues/86) if that tool's effect
1101
- was non‑idempotent). Both cases have one remedy — after a crash, interrupt
1102
- the chat and **wait until it actually settles** before resuming:
1113
+ chat is paused in `requires_action`, where new messages wait forever — and
1114
+ if the tool's external effect committed before the crash, that pause is the
1115
+ only pending record of it, so reconcile the tool ledger **before** any
1116
+ interrupt ([Make client tools crash-safe](#make-client-tools-crash-safe)).
1117
+ Both cases have one remedy — stop what the crash left behind and **wait
1118
+ until the chat actually settles** before deciding: a client‑tool pause is
1119
+ reconciled, never interrupted away; anything else is interrupted:
1103
1120
  `interrupt()` resolves on acknowledgment while the run keeps winding down
1104
1121
  (and committing) for a few more seconds, so poll the chat's status to a
1105
1122
  terminal one first. On a chat with no live run the interrupt rejects with a
1106
1123
  409 `CoderApiError`; that's the good case — ignore it:
1107
1124
 
1108
1125
  ```ts
1126
+ // Both shared with "Make client tools crash-safe" (below): the ledger
1127
+ // reconciliation resolves to its `unstarted` — true only when it fell back
1128
+ // to interrupting — and journals each action write-ahead, so a recovery
1129
+ // pass that dies mid-recovery is visible — and classifiable — to the next.
1130
+ declare function reconcileClientTools(): Promise<boolean>;
1131
+ // Entries are per-ATTEMPT: clear/supersede them transactionally with
1132
+ // recording the attempt's result, or a stale verdict misclassifies the
1133
+ // next attempt on this chat — attempt-scoped journal keys close the
1134
+ // remaining double-crash window (see
1135
+ // https://github.com/coder/ai-sdk/issues/92).
1136
+ declare const journal: {
1137
+ get(chatId: string): Promise<"resumed" | "cut-short" | undefined>;
1138
+ set(chatId: string, state: "resumed" | "cut-short"): Promise<void>;
1139
+ };
1140
+
1109
1141
  // Bounded: a stalled deployment must fail the step, not hang it.
1110
1142
  const deadline = AbortSignal.timeout(15_000);
1111
- await agent.interrupt({ signal: deadline }).catch(() => {}); // 409 = nothing to stop
1112
- let status;
1143
+ // Whether an interrupt stopped a live run drives the resubmit decision
1144
+ // below: a 409 means there was nothing to stop.
1145
+ let cutShort = true;
1146
+ let { status } = await agent.client.getChat(agent.chatId!, deadline);
1147
+ const journaled = await journal.get(agent.chatId!);
1148
+ if (status === "requires_action") {
1149
+ // The crash hit a client tool mid-execution: this pause is the only
1150
+ // pending record of its committed effects. Never interrupt it away —
1151
+ // reconcile the ledger instead (below): it submits recorded results
1152
+ // (reviving the run — nothing was cut short) or interrupts itself only
1153
+ // when nothing committed.
1154
+ cutShort = await reconcileClientTools();
1155
+ } else if (journaled) {
1156
+ // A previous recovery pass acted on this chat, then died before the
1157
+ // step's result was recorded. Its journaled state is the verdict:
1158
+ // "resumed" — it revived the run by submitting recorded results;
1159
+ // interrupting now would cut down a run doing the step's work, and the
1160
+ // resubmitted prompt would mint NEW tool-call ids, sidestepping the
1161
+ // idempotency keys. Let it finish; nothing was cut short.
1162
+ // "cut-short" — it went on to interrupt (an unstarted sibling in the
1163
+ // batch); the attempt is truncated, so the check below must resubmit.
1164
+ cutShort = journaled === "cut-short";
1165
+ if (cutShort) {
1166
+ // The journal records write-ahead INTENT, not a landed interrupt: the
1167
+ // pass may have died in between, leaving the revived run working.
1168
+ // Re-drive the interrupt to make the intent true — a 409 means the
1169
+ // chat already settled; the cut-short verdict stands either way.
1170
+ await agent.interrupt({ signal: deadline }).catch((err) => {
1171
+ if (!(err instanceof CoderApiError && err.status === 409)) throw err;
1172
+ });
1173
+ }
1174
+ } else {
1175
+ await agent.interrupt({ signal: deadline }).catch((err) => {
1176
+ // A 409 proves nothing is stoppable NOW — that means "never cut short"
1177
+ // only if the probe saw no interrupt already in flight. A probed
1178
+ // "interrupting" is an earlier interrupt (a timeout's best-effort one,
1179
+ // a sweeper) already truncating the attempt; this 409 just says it
1180
+ // landed. Keep cutShort = true then.
1181
+ if (err instanceof CoderApiError && err.status === 409) cutShort = status === "interrupting";
1182
+ else throw err;
1183
+ });
1184
+ }
1185
+ // The 15 s interrupt bound must not cap what follows: a reconciled pause
1186
+ // revives a full model/tool continuation. Budget the settle poll — and the
1187
+ // history reads after it — on the turn scale, like the step itself.
1188
+ const settle = AbortSignal.timeout(600_000);
1113
1189
  do {
1114
- ({ status } = await agent.client.getChat(agent.chatId!, deadline));
1190
+ ({ status } = await agent.client.getChat(agent.chatId!, settle));
1115
1191
  if (status === "waiting" || status === "completed" || status === "error") break;
1192
+ // With no accepted interrupt racing to clear it, requires_action is a
1193
+ // STABLE pause: a run resumed by submitted tool results called a NEW
1194
+ // client tool. Exit and reconcile the ledger again (below) instead of
1195
+ // polling out the deadline.
1196
+ if (status === "requires_action" && !cutShort) break;
1116
1197
  await new Promise((r) => setTimeout(r, 1_000)); // acknowledged ≠ settled — wait it out
1117
1198
  } while (true);
1118
1199
  ```
1119
1200
 
1201
+ Settled is necessary, not sufficient: a terminal status doesn't say whether
1202
+ the crashed attempt already _finished the step's work_ — check history
1203
+ before resubmitting
1204
+ ([Recover the result before resubmitting](#recover-the-result-before-resubmitting)).
1205
+
1206
+ ### Make client tools crash-safe
1207
+
1208
+ Interrupting un‑strands a `requires_action` pause — but if the crashed
1209
+ process died _between_ a non‑idempotent client tool committing its external
1210
+ effect (a payment, a deploy, an email) and that result reaching the server,
1211
+ the pause is the **only pending record of the execution**. Interrupt it away
1212
+ and the resubmitted prompt is free to call the tool again — and duplicate the
1213
+ effect. Close the window with a tool‑invocation ledger in the same durable
1214
+ store as the `chatId` checkpoint, keyed by **chat id + `toolCallId`** — the
1215
+ server assigns the call id and commits it to history, so both sides of the
1216
+ reconciliation survive the crash. The chat id must be part of the key (and
1217
+ of the idempotency key handed to the external system): call ids are only
1218
+ unique _within_ a chat, and a store shared across workflows must never let
1219
+ one chat's `done` entry answer — or deduplicate — another chat's call. In
1220
+ multi‑tenant stores, fold the tenant in too:
1221
+
1222
+ ```ts
1223
+ import { tool } from "ai";
1224
+ import { z } from "zod";
1225
+ import { CoderAgent } from "@coder/ai-sdk-agent";
1226
+
1227
+ type LedgerEntry = { state: "committing" } | { state: "done"; output: string };
1228
+ declare const ledger: {
1229
+ get(key: string): Promise<LedgerEntry | undefined>;
1230
+ set(key: string, entry: LedgerEntry): Promise<void>;
1231
+ };
1232
+ declare const payments: {
1233
+ charge(amountCents: number, opts: { idempotencyKey: string }): Promise<{ receiptId: string }>;
1234
+ };
1235
+
1236
+ const ledgerKey = (chatId: string, toolCallId: string) => `${chatId}/${toolCallId}`;
1237
+ const ChargeArgs = z.object({ amountCents: z.number().int() });
1238
+
1239
+ // A factory taking a getter, not an id: the chat may not exist until the
1240
+ // turn creates it, so the id must be read lazily — it is set before any
1241
+ // tool runs.
1242
+ const chargeCard = (chatId: () => string) =>
1243
+ tool({
1244
+ description: "Charge the customer's card.",
1245
+ inputSchema: ChargeArgs, // shared with recovery's re-drive dispatch below
1246
+ execute: async ({ amountCents }, { toolCallId }) => {
1247
+ const key = ledgerKey(chatId(), toolCallId);
1248
+ // Write-ahead, BEFORE the effect: a crash between the two writes leaves
1249
+ // "committing" (ambiguous, but resolvable below); no entry at all proves
1250
+ // the effect never started.
1251
+ await ledger.set(key, { state: "committing" });
1252
+ // Hand the external system the SAME key as its idempotency key, so
1253
+ // re-driving this exact call can never double-charge.
1254
+ const { receiptId } = await payments.charge(amountCents, { idempotencyKey: key });
1255
+ await ledger.set(key, { state: "done", output: `charged: receipt ${receiptId}` });
1256
+ return `charged: receipt ${receiptId}`;
1257
+ },
1258
+ });
1259
+
1260
+ // Wire-up: a direct self-reference in the constructor call is circular for
1261
+ // the type checker — defer it through a box assigned right after.
1262
+ let self: { chatId?: string } | undefined;
1263
+ const agent = new CoderAgent({
1264
+ /* …base options as in runTurn… */
1265
+ tools: { charge_card: chargeCard(() => self!.chatId!) },
1266
+ });
1267
+ self = agent;
1268
+ ```
1269
+
1270
+ On crash recovery, when the checkpointed chat is paused in `requires_action`,
1271
+ reconcile _before_ any interrupt. The pending calls are in committed history —
1272
+ the same derivation the SDK itself uses to recover a lost `action_required`
1273
+ event — and the ledger holds the verdict on each:
1274
+
1275
+ ```ts
1276
+ // The step's configured ToolSet names: history does not record which calls
1277
+ // were client tools (see [Rehydrating chat history]), so — like the SDK's
1278
+ // own derivation — restrict to the names this step registers; a server/MCP
1279
+ // call must never reach the ledger as "unstarted".
1280
+ const clientTools = new Set(["charge_card"]);
1281
+
1282
+ // Pending = the last assistant message's client (non-provider-executed,
1283
+ // ToolSet-named) tool calls, minus ids answered by a later message's tool
1284
+ // result.
1285
+ const { messages } = await agent.client.getMessages(agent.chatId!, { limit: 200 }, deadline);
1286
+ const ordered = [...messages].sort((a, b) => a.id - b.id);
1287
+ const lastAssistant = ordered.findLast((m) => m.role === "assistant");
1288
+ const handled = new Set(
1289
+ ordered
1290
+ .filter((m) => m.id > (lastAssistant?.id ?? 0))
1291
+ .flatMap((m) => m.content ?? [])
1292
+ .filter((p) => p.type === "tool-result")
1293
+ .map((p) => p.tool_call_id),
1294
+ );
1295
+ const pending = (lastAssistant?.content ?? []).filter(
1296
+ (p) =>
1297
+ p.type === "tool-call" &&
1298
+ !p.provider_executed &&
1299
+ clientTools.has(p.tool_name!) &&
1300
+ !handled.has(p.tool_call_id),
1301
+ );
1302
+
1303
+ const results: { tool_call_id: string; output: unknown }[] = [];
1304
+ let unstarted = false;
1305
+ for (const call of pending) {
1306
+ const key = ledgerKey(agent.chatId!, call.tool_call_id!);
1307
+ const entry = await ledger.get(key);
1308
+ if (entry?.state === "done") {
1309
+ // Effect committed, result stranded: submit the record — the pause is
1310
+ // answered and nothing re-executes.
1311
+ results.push({ tool_call_id: call.tool_call_id!, output: entry.output });
1312
+ } else if (entry) {
1313
+ // "committing" is ambiguous — the crash hit between the write-ahead and
1314
+ // "done". Safe to re-drive: the SAME idempotency key makes a committed
1315
+ // effect replay as a no-op, and the call's args are committed in history.
1316
+ // Dispatch by tool name — every ledgered tool needs its own re-drive
1317
+ // path, its args validated exactly as the live loop would have.
1318
+ if (call.tool_name !== "charge_card")
1319
+ throw new Error(`no re-drive path for tool ${call.tool_name}`);
1320
+ const { amountCents } = ChargeArgs.parse(call.args);
1321
+ const { receiptId } = await payments.charge(amountCents, { idempotencyKey: key });
1322
+ const output = `charged: receipt ${receiptId}`;
1323
+ await ledger.set(key, { state: "done", output });
1324
+ results.push({ tool_call_id: call.tool_call_id!, output });
1325
+ } else {
1326
+ unstarted = true; // the tool never ran — interrupting loses nothing
1327
+ }
1328
+ }
1329
+ if (results.length > 0 || unstarted) {
1330
+ // ONE write-ahead with the batch's FINAL verdict, before any network
1331
+ // call — a mixed batch journals "cut-short" before even its submission,
1332
+ // so no crash point can leave a stale "resumed" masking the pending
1333
+ // interrupt. Every crash then either re-reconciles the stable pause or
1334
+ // re-drives the journaled intent. Clear the entry only once the step's
1335
+ // result is finally recorded.
1336
+ await journal.set(agent.chatId!, unstarted ? "cut-short" : "resumed");
1337
+ }
1338
+ if (results.length > 0) await agent.client.submitToolResults(agent.chatId!, { results }, deadline);
1339
+ if (unstarted) await agent.client.interruptChat(agent.chatId!, deadline);
1340
+ // As `reconcileClientTools()` in the settle-wait snippet above: resolve to
1341
+ // `unstarted` — cutShort is true only when this fell back to interrupting.
1342
+ ```
1343
+
1344
+ - **Only interrupt when the ledger shows no committed effect.** Submitted
1345
+ results are committed to chat history, so even when a mixed batch (one call
1346
+ done, a sibling never started) still ends in an interrupt, the effect's
1347
+ recorded outcome survives for the resubmitted turn to see.
1348
+ - **Submitting answers the pause; it does not revive your tool loop.** Once
1349
+ every pending call is answered, the turn resumes **server‑side** with no
1350
+ process streaming it. That is why the settle‑wait's reconcile branch never
1351
+ interrupts a pause — nothing stranded is left to stop, and an interrupt
1352
+ now would cut down the very run the submission revived — and why the
1353
+ resolved `unstarted` leaves **`cutShort = false`** after a submission:
1354
+ nothing was cut short, and the next section's `!cutShort` check must
1355
+ classify the resumed run's finish as a completed attempt, not resubmit
1356
+ it. Give the poll a turn‑scale deadline rather than the 15 s interrupt
1357
+ bound; it exits on `requires_action` (stable here, with no accepted
1358
+ interrupt clearing it), which means the resumed run called a _new_ tool —
1359
+ reconcile again; a call with no ledger entry is safe to interrupt.
1360
+ - **The finished run's output lands only in history** — recover it like any
1361
+ completed‑but‑unrecorded attempt (next section) instead of resubmitting.
1362
+
1363
+ ### Recover the result before resubmitting
1364
+
1365
+ The settle‑wait resolves overlap with a still‑live run, but not the last
1366
+ crash window: **the server finished the turn, and the process died before the
1367
+ step's return value was recorded.** History now holds a perfectly good
1368
+ result; a retried step that blindly resubmits runs the whole turn again —
1369
+ duplicated output, repeated workspace/MCP/tool effects. This is the
1370
+ at‑least‑once boundary every durable engine has around external effects, and
1371
+ the chat itself is the record that closes it. Make attempts matchable first —
1372
+ the server stores the prompt verbatim, so embed a step‑scoped marker:
1373
+
1374
+ ```ts
1375
+ const marker = `[${workflowId}/step-2]`; // stable across this step's retries
1376
+ const { text } = await agent.generate({ prompt: `${marker} ${prompt}` });
1377
+ ```
1378
+
1379
+ Then, after the settle‑wait, three states are distinguishable — and only one
1380
+ of them resubmits:
1381
+
1382
+ ```ts
1383
+ import { chatMessagesToUIMessages } from "@coder/ai-sdk-agent";
1384
+
1385
+ // A long turn can commit more than a page of messages after the prompt —
1386
+ // page newest-first (the endpoint's default order) until the turn's user
1387
+ // message is on hand; "never submitted" must never be concluded from a
1388
+ // truncated read, or this recovery resubmits exactly the turn it was meant
1389
+ // to deduplicate.
1390
+ const fetched = [];
1391
+ let page = await agent.client.getMessages(agent.chatId!, { limit: 200 }, settle);
1392
+ fetched.push(...page.messages);
1393
+ while (!fetched.some((m) => m.role === "user") && page.has_more) {
1394
+ const oldestId = Math.min(...fetched.map((m) => m.id));
1395
+ page = await agent.client.getMessages(agent.chatId!, { before_id: oldestId, limit: 200 }, settle);
1396
+ fetched.push(...page.messages);
1397
+ }
1398
+ const transcript = chatMessagesToUIMessages(fetched); // chronological
1399
+ const lastUser = transcript.findLast((m) => m.role === "user");
1400
+ const submitted = lastUser?.parts.some((p) => p.type === "text" && p.text.includes(marker));
1401
+
1402
+ if (submitted && !cutShort && (status === "waiting" || status === "completed")) {
1403
+ // The attempt finished; only the recording was lost. Recover what
1404
+ // `generate().text` would have returned — the FINAL step's text. A turn
1405
+ // that crossed tool calls commits narration between them too, so cut the
1406
+ // turn's parts after the last tool part and join the text that follows.
1407
+ const parts = transcript
1408
+ .slice(transcript.indexOf(lastUser!) + 1)
1409
+ .filter((m) => m.role === "assistant")
1410
+ .flatMap((m) => m.parts);
1411
+ return parts
1412
+ .slice(parts.findLastIndex((p) => p.type === "dynamic-tool") + 1)
1413
+ .filter((p) => p.type === "text")
1414
+ .map((p) => p.text)
1415
+ .join("");
1416
+ }
1417
+ // Resubmit only what never finished: the prompt never reached the server
1418
+ // (!submitted), the turn failed (status "error" — normal retry judgment
1419
+ // applies), or the interrupt cut a live run short (cutShort — the model sees
1420
+ // its own aborted attempt in history and continues from it). One exit is
1421
+ // neither: a requires_action settle goes back to ledger reconciliation
1422
+ // (previous section) — never to recovery or resubmission.
1423
+ ```
1424
+
1425
+ - **`cutShort` is the completed/interrupted discriminator — but a 409 only
1426
+ proves nothing is running _now_.** An interrupted attempt leaves the same
1427
+ marker and a truncated answer in history, so history alone can't tell
1428
+ "finished" from "stopped"; what can is whether any interrupt stopped the
1429
+ run. The inference "409 ⇒ the attempt finished" is therefore sound only
1430
+ when recovery's interrupt is the _first_ one that could have: true after a
1431
+ hard crash, false after a `requestTimeoutMs` expiry — the expiry already
1432
+ fired the SDK's own best‑effort interrupt, which may have stopped the run
1433
+ before recovery ever looked. Carry the reason recovery ran: when it follows
1434
+ a known timeout or abort — or a journaled earlier interrupt (write‑ahead,
1435
+ like the tool ledger, if the recovery pass can itself crash between its
1436
+ interrupt and the resubmit) — pin `cutShort` to `true` and let only the
1437
+ hard‑crash path trust the 409.
1438
+ - **The cut lands after the last tool part of _either_ kind.** History does
1439
+ not mark which tool calls were client tools
1440
+ ([Rehydrating chat history](#rehydrating-chat-history)), so when a final
1441
+ step narrates _before_ a server‑side tool call, that preamble is dropped
1442
+ too — the recovered text is the answer following the turn's last tool
1443
+ activity.
1444
+ - **Steps with structured results recover the call's input, not the text.**
1445
+ A [structured output](#structured-output) step's answer is the
1446
+ `structured_output` call's typed input, which rehydrates as a
1447
+ `dynamic-tool` part on the recovered turn
1448
+ ([Rehydrating chat history](#rehydrating-chat-history)) — the text join
1449
+ above would slice it away and return only the ack prose that follows.
1450
+ Recover the filed call instead — this scan replaces the text join
1451
+ _inside_ the recovery branch above, reusing its `parts` — validated
1452
+ client‑side exactly like the live path (rule 2 there — the schema is the
1453
+ real gate):
1454
+
1455
+ ```ts
1456
+ // Scan backward to the last call that VALIDATES, as in the live path — a
1457
+ // schema-invalid re-file must not shadow a valid answer.
1458
+ const filed = parts
1459
+ .filter((p) => p.type === "dynamic-tool")
1460
+ .filter((p) => p.toolName === "structured_output");
1461
+ let answer: z.infer<typeof Answer> | undefined;
1462
+ for (const call of filed.reverse()) {
1463
+ const parsed = Answer.safeParse(call.input);
1464
+ if (parsed.success) {
1465
+ answer = parsed.data;
1466
+ break;
1467
+ }
1468
+ }
1469
+ // recovered ⇔ answer !== undefined
1470
+ ```
1471
+
1120
1472
  ### Watch turn health from inside the step
1121
1473
 
1122
1474
  [Transport events](#observability) classify how each segment ended without
@@ -1223,6 +1575,8 @@ export async function archiveWorkflowChat(workflowId: string): Promise<void> {
1223
1575
  total wall‑clock.
1224
1576
  - Let redials self‑heal; own every retry decision — a re‑run step resubmits
1225
1577
  its prompt as a new user turn.
1578
+ - Before resubmitting after a crash: reconcile the tool ledger before any
1579
+ interrupt, and recover a finished attempt's result from history.
1226
1580
  - Keep concurrent steps' fan‑out within workspace quota —
1227
1581
  [Workspaces & quota](#workspaces--quota).
1228
1582
  - 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