@coder/ai-sdk-agent 0.9.0 → 0.9.2

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/dist/index.js CHANGED
@@ -824,6 +824,23 @@ var CoderChatClient = class {
824
824
  opts?.signal
825
825
  );
826
826
  }
827
+ /**
828
+ * Withdraw a QUEUED (not yet materialized) message from the chat's
829
+ * submission queue (`DELETE /queue/{id}`). `queuedMessageId` is the
830
+ * queue-entry id from `CreateChatMessageResponse.queued_message` — a
831
+ * different id space from committed chat message ids. Rejects with a
832
+ * {@link CoderApiError}: 404 when the entry no longer exists (already
833
+ * promoted into history, or deleted), 409 when the chat has no queue.
834
+ */
835
+ deleteQueuedMessage(chatId, queuedMessageId, signal) {
836
+ return this.#request(
837
+ "deleteQueuedMessage",
838
+ "DELETE",
839
+ `${API_PREFIX}/${chatId}/queue/${queuedMessageId}`,
840
+ void 0,
841
+ signal
842
+ );
843
+ }
827
844
  updateChat(chatId, req, signal) {
828
845
  return this.#request("updateChat", "PATCH", `${API_PREFIX}/${chatId}`, req, signal);
829
846
  }
@@ -1348,6 +1365,16 @@ function joinContent(content, kind) {
1348
1365
  for (const p of content) if (p.type === kind) full += p.text ?? "";
1349
1366
  return full;
1350
1367
  }
1368
+ function canonicalJson(value) {
1369
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
1370
+ if (value !== null && typeof value === "object") {
1371
+ const entries = Object.entries(value);
1372
+ entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1373
+ const body = entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
1374
+ return `{${body.join(",")}}`;
1375
+ }
1376
+ return JSON.stringify(value) ?? "null";
1377
+ }
1351
1378
  var TurnTranslator = class {
1352
1379
  #dynamicToolNames;
1353
1380
  #submittedToolCallIds;
@@ -1398,13 +1425,30 @@ var TurnTranslator = class {
1398
1425
  * to a segment that already streamed) — they arrive only as replays: the
1399
1426
  * initial sync when resuming a chat, or a mid-turn `history_reset` re-send
1400
1427
  * of the full history. Ingest skips them entirely so a replay can neither
1401
- * re-emit earlier turns' content nor inflate this turn's usage.
1428
+ * re-emit earlier turns' content nor inflate this turn's usage. Mutable for
1429
+ * exactly one transition: a QUEUED submission starts at the pre-turn cursor
1430
+ * and advances to the materialized user message's id when the gate anchors
1431
+ * (see {@link QueuedSubmissionGate}) — replayed pre-anchor tail messages
1432
+ * then stay filtered on every redial, which reuses the turn's ORIGINAL
1433
+ * `after_id` and therefore re-delivers them.
1402
1434
  */
1403
1435
  #turnCursor;
1436
+ /** The queued-submission attribution gate; undefined once anchored (#114). */
1437
+ #queuedGate;
1404
1438
  constructor(opts) {
1405
1439
  this.#dynamicToolNames = opts.dynamicToolNames;
1406
1440
  this.#turnCursor = opts.turnCursor ?? 0;
1407
1441
  this.#submittedToolCallIds = opts.submittedToolCallIds ?? /* @__PURE__ */ new Set();
1442
+ const queued = opts.queuedSubmission;
1443
+ if (queued) {
1444
+ this.#queuedGate = {
1445
+ queuedId: queued.id,
1446
+ expected: queued.content !== void 0 ? canonicalJson(queued.content) : void 0,
1447
+ candidateId: void 0,
1448
+ buffered: [],
1449
+ deadEnd: void 0
1450
+ };
1451
+ }
1408
1452
  }
1409
1453
  get terminalStatus() {
1410
1454
  return this.#terminalStatus;
@@ -1419,6 +1463,32 @@ var TurnTranslator = class {
1419
1463
  get error() {
1420
1464
  return this.#error;
1421
1465
  }
1466
+ /**
1467
+ * The turn's EFFECTIVE attribution cursor: the constructor's `turnCursor`
1468
+ * until a queued submission anchors, the materialized user message's id
1469
+ * after. Distinct from the stream's redial cursor, which deliberately stays
1470
+ * the turn's original `after_id` (see `streamChatEvents`).
1471
+ */
1472
+ get turnCursor() {
1473
+ return this.#turnCursor;
1474
+ }
1475
+ /**
1476
+ * Whether a queued submission is still awaiting its own user-message
1477
+ * snapshot (#114). While true, nothing is attributed to this turn — no
1478
+ * content, usage, tool calls, or settle statuses — because the stream
1479
+ * window still belongs to the chat's concurrent run.
1480
+ */
1481
+ get awaitingQueuedSubmission() {
1482
+ return this.#queuedGate !== void 0;
1483
+ }
1484
+ /**
1485
+ * Set when a queued submission's wait can never complete (see
1486
+ * {@link QueuedSubmissionGate}); the model layer throws it so the turn
1487
+ * fails fast instead of hanging until timeout/abort.
1488
+ */
1489
+ get queuedSubmissionDeadEnd() {
1490
+ return this.#queuedGate?.deadEnd;
1491
+ }
1422
1492
  // --- block helpers --------------------------------------------------------
1423
1493
  #openText(out) {
1424
1494
  if (this.#reasoning.id) this.#closeReasoning(out);
@@ -1561,6 +1631,8 @@ var TurnTranslator = class {
1561
1631
  }
1562
1632
  // --- ingest ---------------------------------------------------------------
1563
1633
  ingest(ev) {
1634
+ const gate = this.#queuedGate;
1635
+ if (gate) return this.#ingestGated(gate, ev);
1564
1636
  const out = [];
1565
1637
  switch (ev.type) {
1566
1638
  case "message_part":
@@ -1609,6 +1681,82 @@ var TurnTranslator = class {
1609
1681
  }
1610
1682
  return out;
1611
1683
  }
1684
+ /**
1685
+ * Ingest while the queued-submission gate is up (#114): the stream window
1686
+ * still belongs to the chat's concurrent run, so nothing is attributed —
1687
+ * this only advances cursor bookkeeping, runs the anchor correlation
1688
+ * protocol (see {@link QueuedSubmissionGate}), and detects dead ends.
1689
+ * Returns parts only at the anchoring transition, when buffered snapshots
1690
+ * of this turn's own already-committed messages are replayed through the
1691
+ * normal path.
1692
+ */
1693
+ #ingestGated(gate, ev) {
1694
+ switch (ev.type) {
1695
+ case "message": {
1696
+ const message = ev.message;
1697
+ if (!message) break;
1698
+ if (message.id > this.#maxMessageId) this.#maxMessageId = message.id;
1699
+ if (message.id <= this.#turnCursor) break;
1700
+ if (message.role === "user" && this.#matchesQueuedContent(gate, message)) {
1701
+ if (gate.queuedId === void 0) return this.#anchorQueuedTurn(message.id);
1702
+ gate.candidateId = message.id;
1703
+ gate.buffered = [];
1704
+ } else if (gate.candidateId !== void 0) {
1705
+ gate.buffered.push(message);
1706
+ }
1707
+ break;
1708
+ }
1709
+ case "queue_update": {
1710
+ if (gate.queuedId === void 0) break;
1711
+ const queue = ev.queued_messages ?? [];
1712
+ if (queue.some((entry) => entry.id === gate.queuedId)) {
1713
+ gate.candidateId = void 0;
1714
+ gate.buffered = [];
1715
+ break;
1716
+ }
1717
+ if (gate.candidateId !== void 0) {
1718
+ const buffered = gate.buffered;
1719
+ const out = this.#anchorQueuedTurn(gate.candidateId);
1720
+ for (const message of buffered) this.#ingestMessage(out, message);
1721
+ return out;
1722
+ }
1723
+ gate.deadEnd = {
1724
+ message: "The queued submission left the chat's queue without materializing as this turn's user message (deleted externally, or its materialized form could not be correlated); this turn never started.",
1725
+ kind: "queued_submission_lost",
1726
+ retryable: false
1727
+ };
1728
+ break;
1729
+ }
1730
+ case "error": {
1731
+ if (!ev.error) break;
1732
+ gate.deadEnd = {
1733
+ message: "The chat's active run failed while this turn's submission was queued behind it; the queued message never materialized, so this turn never started.",
1734
+ detail: ev.error.detail ? `${ev.error.message}: ${ev.error.detail}` : ev.error.message,
1735
+ kind: "queued_submission_lost",
1736
+ retryable: false
1737
+ };
1738
+ break;
1739
+ }
1740
+ default:
1741
+ break;
1742
+ }
1743
+ return [];
1744
+ }
1745
+ /** Whether a user-message snapshot's content matches the queued entry's. */
1746
+ #matchesQueuedContent(gate, message) {
1747
+ if (gate.expected === void 0) return true;
1748
+ return canonicalJson(message.content ?? []) === gate.expected;
1749
+ }
1750
+ /**
1751
+ * Opens the turn's attribution window at `anchorId` — this turn's own user
1752
+ * message — and drops the gate. Replays of the pre-anchor tail (redials
1753
+ * reuse the turn's ORIGINAL cursor) stay filtered by the advanced cursor.
1754
+ */
1755
+ #anchorQueuedTurn(anchorId) {
1756
+ this.#turnCursor = anchorId;
1757
+ this.#queuedGate = void 0;
1758
+ return [];
1759
+ }
1612
1760
  #ingestMessagePart(out, ev) {
1613
1761
  const mp = ev.message_part;
1614
1762
  if (!mp || mp.role !== "assistant" && mp.role !== "tool") return;
@@ -1823,11 +1971,36 @@ var CoderLanguageModel = class {
1823
1971
  this.modelId = config.model ?? "chatd";
1824
1972
  this.#chatId = config.chatId;
1825
1973
  this.#lastKnownChatId = config.chatId;
1974
+ if (config.lastSeenMessageId !== void 0) {
1975
+ if (!Number.isSafeInteger(config.lastSeenMessageId) || config.lastSeenMessageId <= 0) {
1976
+ throw new CoderAgentError(
1977
+ `lastSeenMessageId must be a positive integer (got ${config.lastSeenMessageId}); persist the value of the lastSeenMessageId getter and supply it verbatim.`
1978
+ );
1979
+ }
1980
+ if (!config.chatId) {
1981
+ throw new CoderAgentError(
1982
+ "lastSeenMessageId requires chatId: a resume cursor is meaningless without the chat it belongs to."
1983
+ );
1984
+ }
1985
+ this.#lastSeenMessageId = config.lastSeenMessageId;
1986
+ }
1826
1987
  this.#emitTransportEvent = safeTransportEmitter(config.onTransportEvent);
1827
1988
  }
1828
1989
  get chatId() {
1829
1990
  return this.#chatId;
1830
1991
  }
1992
+ /**
1993
+ * The resume cursor: the newest chat message id this session has observed,
1994
+ * or `undefined` before any turn has streamed (nothing to persist then —
1995
+ * a fresh instance seeds from history instead). Persist it alongside
1996
+ * {@link chatId} once a turn settles and supply both to a future instance
1997
+ * ({@link CoderLanguageModelConfig.lastSeenMessageId}) to skip the resumed
1998
+ * turn's pre-prompt seed GET. Read it between turns, not mid-generation:
1999
+ * the cursor advances when a segment ends.
2000
+ */
2001
+ get lastSeenMessageId() {
2002
+ return this.#lastSeenMessageId > 0 ? this.#lastSeenMessageId : void 0;
2003
+ }
1831
2004
  /**
1832
2005
  * The last chat id this model created or attached to, for CLEANUP targeting
1833
2006
  * only — generation always targets {@link chatId}. It survives
@@ -1981,9 +2154,9 @@ var CoderLanguageModel = class {
1981
2154
  * pending calls — nothing to recover, the caller keeps waiting on the
1982
2155
  * stream, bounded by `requestTimeoutMs`/abort.
1983
2156
  */
1984
- async #recoverRequiresAction(chatId, dynamicNames, afterId, signal) {
2157
+ async #recoverRequiresAction(chatId, dynamicNames, turnCursor, signal) {
1985
2158
  const { messages } = await this.#config.client.getMessages(chatId, { limit: 200 }, signal);
1986
- const turnMessages = messages.filter((m) => m.id > (afterId ?? 0)).sort((a, b) => a.id - b.id);
2159
+ const turnMessages = messages.filter((m) => m.id > turnCursor).sort((a, b) => a.id - b.id);
1987
2160
  const lastAssistant = turnMessages.findLast((m) => m.role === "assistant");
1988
2161
  if (!lastAssistant) return [];
1989
2162
  const handled = /* @__PURE__ */ new Set();
@@ -2137,10 +2310,20 @@ var CoderLanguageModel = class {
2137
2310
  let discardSession = false;
2138
2311
  let interruptSent = false;
2139
2312
  let turnChatId;
2313
+ let queuedTurn = false;
2314
+ let queuedEntry;
2140
2315
  const interrupt = () => {
2141
2316
  const id = turnChatId ?? this.#chatId;
2142
2317
  if (interruptSent || !id) return;
2143
2318
  interruptSent = true;
2319
+ if (queuedTurn && (translator === void 0 || translator.awaitingQueuedSubmission)) {
2320
+ const entryId = queuedEntry?.id;
2321
+ if (entryId !== void 0) {
2322
+ void this.#config.client.deleteQueuedMessage(id, entryId).catch(() => {
2323
+ });
2324
+ }
2325
+ return;
2326
+ }
2144
2327
  void this.#config.client.interruptChat(id).catch(() => {
2145
2328
  });
2146
2329
  };
@@ -2224,7 +2407,15 @@ var CoderLanguageModel = class {
2224
2407
  },
2225
2408
  signal
2226
2409
  );
2227
- afterId = resp.message?.id ?? this.#lastSeenMessageId;
2410
+ if (resp.message) {
2411
+ afterId = resp.message.id;
2412
+ } else if (resp.queued) {
2413
+ afterId = this.#lastSeenMessageId;
2414
+ queuedTurn = true;
2415
+ queuedEntry = resp.queued_message;
2416
+ } else {
2417
+ afterId = this.#lastSeenMessageId;
2418
+ }
2228
2419
  }
2229
2420
  } else {
2230
2421
  if (!this.#chatId)
@@ -2243,14 +2434,21 @@ var CoderLanguageModel = class {
2243
2434
  this.#lastKnownChatId = chatId;
2244
2435
  if (segmentChat) segmentChat.chatId = chatId;
2245
2436
  const dynamicNames = dynamicToolNames(options.tools);
2246
- translator = new TurnTranslator({
2437
+ const translatorOptions = {
2247
2438
  dynamicToolNames: dynamicNames,
2248
2439
  turnCursor: afterId ?? 0,
2249
2440
  // The retained stream redials with the TURN's original cursor, so a
2250
2441
  // reconnect can replay an earlier segment's `action_required`
2251
2442
  // snapshot; calls this session already answered must not run again.
2252
2443
  submittedToolCallIds: this.#submittedToolCallIds
2253
- });
2444
+ };
2445
+ if (queuedTurn) {
2446
+ const queuedSubmission = {};
2447
+ if (queuedEntry?.id !== void 0) queuedSubmission.id = queuedEntry.id;
2448
+ if (queuedEntry?.content !== void 0) queuedSubmission.content = queuedEntry.content;
2449
+ translatorOptions.queuedSubmission = queuedSubmission;
2450
+ }
2451
+ translator = new TurnTranslator(translatorOptions);
2254
2452
  let sinceRequiresAction = 0;
2255
2453
  let grace;
2256
2454
  let recovery;
@@ -2291,7 +2489,11 @@ var CoderLanguageModel = class {
2291
2489
  recovery = this.#recoverRequiresAction(
2292
2490
  chatId,
2293
2491
  dynamicNames,
2294
- afterId,
2492
+ // The turn's EFFECTIVE cursor: for a queued submission it has
2493
+ // advanced past the materialized user message (a pause can
2494
+ // only arm after the gate anchored), so the derivation never
2495
+ // sees the concurrent predecessor run's tail (#114).
2496
+ translator.turnCursor,
2295
2497
  signal ? AbortSignal.any([signal, recoveryAbort.signal]) : recoveryAbort.signal
2296
2498
  ).catch(() => []);
2297
2499
  continue;
@@ -2315,6 +2517,8 @@ var CoderLanguageModel = class {
2315
2517
  stream.consumed(next);
2316
2518
  if (raced.done) break;
2317
2519
  const parts = translator.ingest(raced.value);
2520
+ const deadEnd = translator.queuedSubmissionDeadEnd;
2521
+ if (deadEnd) throw new CoderChatError(deadEnd);
2318
2522
  if (raced.value.type === "error" && translator.error && !translator.terminalStatus) {
2319
2523
  const peek = stream.read();
2320
2524
  const bound = startGraceTimer(0);
@@ -2604,6 +2808,7 @@ var CoderAgent = class {
2604
2808
  mcpServerIds: settings.mcpServerIds,
2605
2809
  planMode: settings.planMode,
2606
2810
  chatId: settings.chatId,
2811
+ lastSeenMessageId: settings.lastSeenMessageId,
2607
2812
  requestTimeoutMs: settings.requestTimeoutMs,
2608
2813
  onTransportEvent: settings.onTransportEvent
2609
2814
  });
@@ -2632,6 +2837,16 @@ var CoderAgent = class {
2632
2837
  get chatId() {
2633
2838
  return this.#model.chatId;
2634
2839
  }
2840
+ /**
2841
+ * The session's resume cursor: the newest chat message id observed, or
2842
+ * `undefined` before any turn has streamed. Persist it alongside
2843
+ * {@link chatId} when a turn ends and supply both to a future instance
2844
+ * ({@link CoderAgentSettings.lastSeenMessageId}) to skip the resumed turn's
2845
+ * pre-prompt seed GET. Read it between turns, not mid-generation.
2846
+ */
2847
+ get lastSeenMessageId() {
2848
+ return this.#model.lastSeenMessageId;
2849
+ }
2635
2850
  /**
2636
2851
  * The last chat id this agent created or attached to, retained for CLEANUP
2637
2852
  * even after the session was dropped — a fresh-chat stream failure discards