@astralform/js 4.9.0 → 5.0.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/dist/index.js CHANGED
@@ -1291,6 +1291,7 @@ function translateWireEvent(wire) {
1291
1291
 
1292
1292
  // src/session.ts
1293
1293
  var SSE_MAX_RECONNECTS = 6;
1294
+ var SSE_STALL_TIMEOUT_MS = 45e3;
1294
1295
  var TOOL_RESULT_MAX_RETRIES = 3;
1295
1296
  var CONVERSATION_PAGE_SIZE = 50;
1296
1297
  function sseReconnectDelayMs(attempt) {
@@ -1328,6 +1329,17 @@ var ChatSession = class {
1328
1329
  /** True while ``loadMoreConversations`` is in flight. */
1329
1330
  this.isLoadingConversations = false;
1330
1331
  this.messages = [];
1332
+ /**
1333
+ * Which conversation ``messages`` currently holds.
1334
+ *
1335
+ * Distinct from ``conversationId``, and the distinction is the point:
1336
+ * ``loadConversation`` moves the POINTER synchronously and installs the LIST
1337
+ * an await later, so for the whole duration of every load the two disagree.
1338
+ * Anything pairing a message with a conversation — ``regenerate`` above all —
1339
+ * has to read this one, or it will pair the previous conversation's last
1340
+ * message with the new conversation's id.
1341
+ */
1342
+ this.messagesConversationId = null;
1331
1343
  this.isStreaming = false;
1332
1344
  this.agentStatus = null;
1333
1345
  this.agents = [];
@@ -1360,6 +1372,43 @@ var ChatSession = class {
1360
1372
  * is discarded instead.
1361
1373
  */
1362
1374
  this.conversationsGeneration = 0;
1375
+ /**
1376
+ * Bumped by every ``loadConversation`` call, so an out-of-order fetch can
1377
+ * tell it is no longer the newest one and drop its result. Separate from
1378
+ * ``conversationsGeneration``, which guards the conversation LIST.
1379
+ */
1380
+ this.loadGeneration = 0;
1381
+ /**
1382
+ * Ids of locally-created user messages the server has not acknowledged yet.
1383
+ *
1384
+ * Arrival time cannot answer "could the reply have included this?" on its
1385
+ * own. Every `loadConversation` in `StreamManager` sits behind the
1386
+ * active-job probe, so the ORDINARY ordering is that a send lands BEFORE the
1387
+ * load starts — and an arrival-time rule drops exactly those, losing the
1388
+ * prompt the user just sent while its stream is still running. Membership
1389
+ * here is set by `send` and cleared when a server row turns up carrying the
1390
+ * same turn, so the keep-decision no longer depends on which side of the
1391
+ * fetch the push landed on.
1392
+ *
1393
+ * The value is `serverRowsKnown` as of the `message_stop` that proved the
1394
+ * row committed, or 0 until then — including after the job response, which
1395
+ * hands back the id but starts the loop as a background task and so proves
1396
+ * nothing about the row. That stamp is what separates "the snapshot predates
1397
+ * the row" from "the server does not have this row" — see
1398
+ * `loadConversation`.
1399
+ *
1400
+ * It is an ANNOTATION ON `this.messages`: reconciliation only ever consults
1401
+ * entries of that array, so an id whose message has left it is dead weight.
1402
+ * `setMessages` is the single place the array is replaced, and it prunes.
1403
+ */
1404
+ this.pendingUserMessages = /* @__PURE__ */ new Map();
1405
+ /**
1406
+ * Bumped once per completed turn, at `message_stop`, so a fetch can record
1407
+ * what was proven when it was ISSUED. A row proven committed before the
1408
+ * fetch went out must appear in its snapshot; one proven after may
1409
+ * legitimately be missing.
1410
+ */
1411
+ this.serverRowsKnown = 0;
1363
1412
  // Minimal in-session accumulation for the assistant message record.
1364
1413
  // Only top-level ``text`` blocks contribute; subagent / tool output
1365
1414
  // is tracked by the consumer's own block store.
@@ -1382,6 +1431,19 @@ var ChatSession = class {
1382
1431
  this.toolRegistry = new ToolRegistry();
1383
1432
  this.storage = storage ?? new InMemoryStorage();
1384
1433
  }
1434
+ /**
1435
+ * Replace the message list, keeping `pendingUserMessages` an annotation on
1436
+ * it. Every removal from the array goes through here — `push` is the only
1437
+ * other mutation and it cannot orphan an id.
1438
+ */
1439
+ setMessages(next) {
1440
+ this.messages = next;
1441
+ if (this.pendingUserMessages.size === 0) return;
1442
+ const present = new Set(next.map((m) => m.id));
1443
+ for (const id of this.pendingUserMessages.keys()) {
1444
+ if (!present.has(id)) this.pendingUserMessages.delete(id);
1445
+ }
1446
+ }
1385
1447
  on(handler) {
1386
1448
  this.handlers.add(handler);
1387
1449
  return () => {
@@ -1430,6 +1492,22 @@ var ChatSession = class {
1430
1492
  }
1431
1493
  if (this.isStreaming) return;
1432
1494
  const conversationId = options?.conversationId ?? this.conversationId ?? void 0;
1495
+ let relocatedFrom = null;
1496
+ if (conversationId) {
1497
+ if (conversationId !== this.conversationId) {
1498
+ this.loadGeneration++;
1499
+ relocatedFrom = {
1500
+ messages: this.messages,
1501
+ messagesId: this.messagesConversationId,
1502
+ conversationId: this.conversationId,
1503
+ generation: this.loadGeneration,
1504
+ target: conversationId
1505
+ };
1506
+ this.setMessages([]);
1507
+ this.messagesConversationId = null;
1508
+ }
1509
+ this.conversationId = conversationId;
1510
+ }
1433
1511
  const userMessage = {
1434
1512
  id: generateId(),
1435
1513
  conversationId: conversationId ?? "",
@@ -1439,9 +1517,11 @@ var ChatSession = class {
1439
1517
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1440
1518
  };
1441
1519
  if (conversationId) {
1442
- await this.storage.addMessage(userMessage, conversationId);
1520
+ await this.storage.addMessage(userMessage, conversationId).catch(() => {
1521
+ });
1443
1522
  }
1444
1523
  this.messages.push(userMessage);
1524
+ this.pendingUserMessages.set(userMessage.id, 0);
1445
1525
  const request = {
1446
1526
  message: content,
1447
1527
  conversation_id: conversationId,
@@ -1461,7 +1541,24 @@ var ChatSession = class {
1461
1541
  reasoning_effort: options?.reasoningEffort,
1462
1542
  temperature: options?.temperature
1463
1543
  };
1464
- await this.processStream(request);
1544
+ const wire = { reached: false };
1545
+ await this.processStream(request, wire);
1546
+ if (!wire.reached) {
1547
+ this.pendingUserMessages.delete(userMessage.id);
1548
+ const at = this.messages.indexOf(userMessage);
1549
+ if (at !== -1) this.messages.splice(at, 1);
1550
+ if (conversationId) {
1551
+ await this.storage.deleteMessage(userMessage.id).catch(() => {
1552
+ });
1553
+ }
1554
+ }
1555
+ if (relocatedFrom && wire.reached && this.loadGeneration === relocatedFrom.generation) {
1556
+ this.messagesConversationId = relocatedFrom.target;
1557
+ } else if (relocatedFrom && !wire.reached && this.loadGeneration === relocatedFrom.generation) {
1558
+ this.setMessages(relocatedFrom.messages);
1559
+ this.messagesConversationId = relocatedFrom.messagesId;
1560
+ this.conversationId = relocatedFrom.conversationId;
1561
+ }
1465
1562
  }
1466
1563
  async resendFromCheckpoint(messageId, newContent) {
1467
1564
  if (this.isStreaming) return;
@@ -1478,12 +1575,13 @@ var ChatSession = class {
1478
1575
  this.accumulatedText = "";
1479
1576
  this.currentTextPath = null;
1480
1577
  }
1481
- async processStream(request) {
1578
+ async processStream(request, wire) {
1482
1579
  this.isStreaming = true;
1483
1580
  this.resetStreamingState();
1484
- this.abortController = new AbortController();
1581
+ const controller = new AbortController();
1582
+ this.abortController = controller;
1485
1583
  try {
1486
- await this.consumeJobStream(request);
1584
+ await this.consumeJobStream(request, wire);
1487
1585
  } catch (err) {
1488
1586
  if (!(err instanceof DOMException && err.name === "AbortError")) {
1489
1587
  this.emit({
@@ -1494,16 +1592,20 @@ var ChatSession = class {
1494
1592
  });
1495
1593
  }
1496
1594
  } finally {
1497
- this.isStreaming = false;
1498
- this.abortController = null;
1595
+ if (this.abortController === controller) {
1596
+ this.isStreaming = false;
1597
+ this.abortController = null;
1598
+ }
1499
1599
  }
1500
1600
  }
1501
- async consumeJobStream(request) {
1601
+ async consumeJobStream(request, wire) {
1502
1602
  const job = await this.client.createJob(request);
1603
+ if (wire) wire.reached = true;
1503
1604
  this.currentJobId = job.job_id;
1504
1605
  const conversationId = job.conversation_id;
1505
1606
  if (!this.conversationId) {
1506
1607
  this.conversationId = conversationId;
1608
+ this.messagesConversationId = conversationId;
1507
1609
  }
1508
1610
  if (!this.conversations.some((c) => c.id === conversationId)) {
1509
1611
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1524,13 +1626,29 @@ var ChatSession = class {
1524
1626
  await this.storage.addMessage(lastMsg, conversationId).catch(() => {
1525
1627
  });
1526
1628
  }
1527
- const messageId = job.message_id;
1629
+ const promptMessageId = job.message_id;
1630
+ if (promptMessageId && lastMsg?.role === "user" && this.pendingUserMessages.has(lastMsg.id)) {
1631
+ this.pendingUserMessages.delete(lastMsg.id);
1632
+ const clientMintedId = lastMsg.id;
1633
+ lastMsg.id = promptMessageId;
1634
+ this.pendingUserMessages.set(promptMessageId, 0);
1635
+ if (conversationId && clientMintedId !== promptMessageId) {
1636
+ await this.storage.deleteMessage(clientMintedId).catch(() => {
1637
+ });
1638
+ await this.storage.addMessage(lastMsg, conversationId).catch(() => {
1639
+ });
1640
+ }
1641
+ const dupe = this.messages.findIndex(
1642
+ (m) => m !== lastMsg && m.id === promptMessageId
1643
+ );
1644
+ if (dupe !== -1) this.messages.splice(dupe, 1);
1645
+ }
1528
1646
  this.lastSeq = -1;
1529
1647
  this.submittedToolCallIds.clear();
1530
1648
  await this.consumeEventStream(
1531
1649
  job.job_id,
1532
1650
  conversationId,
1533
- messageId,
1651
+ promptMessageId,
1534
1652
  true
1535
1653
  // executeClientTools
1536
1654
  );
@@ -1539,17 +1657,26 @@ var ChatSession = class {
1539
1657
  * Shared event consumption loop. Parses each wire event, updates
1540
1658
  * minimal session state, and emits typed ChatEvents to consumers.
1541
1659
  */
1542
- async consumeEventStream(jobId, conversationId, messageId, executeClientTools) {
1660
+ async consumeEventStream(jobId, conversationId, promptMessageId, executeClientTools) {
1543
1661
  const signal = this.abortController?.signal;
1544
1662
  for (let attempt = 0; ; attempt++) {
1545
- const stream = this.client.streamJobEvents(jobId, this.lastSeq, signal);
1663
+ if (signal?.aborted) return;
1664
+ const attemptController = new AbortController();
1665
+ const linkAbort = () => attemptController.abort();
1666
+ signal?.addEventListener("abort", linkAbort);
1546
1667
  let sawTerminal;
1547
1668
  try {
1669
+ const stream = this.client.streamJobEvents(
1670
+ jobId,
1671
+ this.lastSeq,
1672
+ attemptController.signal
1673
+ );
1548
1674
  sawTerminal = await this.pumpStream(
1549
1675
  stream,
1550
1676
  conversationId,
1551
- messageId,
1552
- executeClientTools
1677
+ promptMessageId,
1678
+ executeClientTools,
1679
+ () => attemptController.abort()
1553
1680
  );
1554
1681
  } catch (err) {
1555
1682
  if (signal?.aborted) return;
@@ -1559,6 +1686,8 @@ var ChatSession = class {
1559
1686
  if (attempt >= SSE_MAX_RECONNECTS) throw err;
1560
1687
  await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1561
1688
  continue;
1689
+ } finally {
1690
+ signal?.removeEventListener("abort", linkAbort);
1562
1691
  }
1563
1692
  if (sawTerminal || signal?.aborted) return;
1564
1693
  if (attempt >= SSE_MAX_RECONNECTS) {
@@ -1571,35 +1700,66 @@ var ChatSession = class {
1571
1700
  * Consume a single SSE stream to exhaustion. Returns whether a terminal
1572
1701
  * event (``message_stop`` / ``error``) was seen, so the caller can decide
1573
1702
  * whether an ended stream means "turn done" vs "dropped, reconnect".
1703
+ *
1704
+ * ``onStall`` aborts the per-attempt connection: if no event arrives within
1705
+ * SSE_STALL_TIMEOUT_MS (backend keepalives land every 15s), the stream is a
1706
+ * zombie — ``reader.read()`` will never settle — so we kill the fetch and
1707
+ * throw a ConnectionError, feeding the caller's reconnect-from-lastSeq loop.
1574
1708
  */
1575
- async pumpStream(stream, conversationId, messageId, executeClientTools) {
1709
+ async pumpStream(stream, conversationId, promptMessageId, executeClientTools, onStall) {
1576
1710
  let sawTerminal = false;
1577
- for await (const raw of stream) {
1578
- let parsed;
1579
- try {
1580
- const data = JSON.parse(raw.data);
1581
- if (typeof data !== "object" || data === null || typeof data.type !== "string") {
1582
- if (typeof data?.seq === "number") {
1711
+ const iterator = stream[Symbol.asyncIterator]();
1712
+ try {
1713
+ while (true) {
1714
+ const next = iterator.next();
1715
+ let stallTimer;
1716
+ const stall = new Promise((_, reject) => {
1717
+ stallTimer = setTimeout(() => {
1718
+ reject(
1719
+ new ConnectionError(
1720
+ `Stream stalled: no events for ${SSE_STALL_TIMEOUT_MS}ms`
1721
+ )
1722
+ );
1723
+ onStall?.();
1724
+ }, SSE_STALL_TIMEOUT_MS);
1725
+ });
1726
+ let result;
1727
+ try {
1728
+ result = await Promise.race([next, stall]);
1729
+ } finally {
1730
+ clearTimeout(stallTimer);
1731
+ }
1732
+ if (result.done) break;
1733
+ const raw = result.value;
1734
+ let parsed;
1735
+ try {
1736
+ const data = JSON.parse(raw.data);
1737
+ if (typeof data !== "object" || data === null || typeof data.type !== "string") {
1738
+ if (typeof data?.seq === "number") {
1739
+ this.lastSeq = data.seq;
1740
+ }
1741
+ continue;
1742
+ }
1743
+ parsed = data;
1744
+ if (typeof data.seq === "number") {
1583
1745
  this.lastSeq = data.seq;
1584
1746
  }
1747
+ } catch {
1585
1748
  continue;
1586
1749
  }
1587
- parsed = data;
1588
- if (typeof data.seq === "number") {
1589
- this.lastSeq = data.seq;
1750
+ if (parsed.type === "message_stop" || parsed.type === "error") {
1751
+ sawTerminal = true;
1590
1752
  }
1591
- } catch {
1592
- continue;
1593
- }
1594
- if (parsed.type === "message_stop" || parsed.type === "error") {
1595
- sawTerminal = true;
1753
+ await this.dispatchWireEvent(
1754
+ parsed,
1755
+ conversationId,
1756
+ promptMessageId,
1757
+ executeClientTools
1758
+ );
1596
1759
  }
1597
- await this.dispatchWireEvent(
1598
- parsed,
1599
- conversationId,
1600
- messageId,
1601
- executeClientTools
1602
- );
1760
+ } finally {
1761
+ void iterator.return?.(void 0).catch(() => {
1762
+ });
1603
1763
  }
1604
1764
  return sawTerminal;
1605
1765
  }
@@ -1635,8 +1795,8 @@ var ChatSession = class {
1635
1795
  }
1636
1796
  }
1637
1797
  }
1638
- async dispatchWireEvent(wire, conversationId, messageId, executeClientTools) {
1639
- this.applyWireSideEffects(wire, conversationId, messageId);
1798
+ async dispatchWireEvent(wire, conversationId, promptMessageId, executeClientTools) {
1799
+ this.applyWireSideEffects(wire, conversationId, promptMessageId, true);
1640
1800
  const event = translateWireEvent(wire);
1641
1801
  if (event) {
1642
1802
  this.emit(event);
@@ -1654,7 +1814,9 @@ var ChatSession = class {
1654
1814
  const results = await this.executeClientTools([request]);
1655
1815
  await this.submitToolResultWithRetry({
1656
1816
  conversation_id: conversationId,
1657
- message_id: messageId,
1817
+ // The message that TRIGGERED the tool calls, which is what the
1818
+ // backend stores it against — the prompt id, correctly.
1819
+ message_id: promptMessageId,
1658
1820
  tool_results: results
1659
1821
  });
1660
1822
  this.submittedToolCallIds.add(callId);
@@ -1669,7 +1831,7 @@ var ChatSession = class {
1669
1831
  * instead of re-typing the whole conversation event by event.
1670
1832
  */
1671
1833
  replayWireEvent(wire, conversationId) {
1672
- this.applyWireSideEffects(wire, conversationId, "");
1834
+ this.applyWireSideEffects(wire, conversationId, "", false);
1673
1835
  const event = translateWireEvent(wire);
1674
1836
  if (event) {
1675
1837
  this.emit(event);
@@ -1680,37 +1842,52 @@ var ChatSession = class {
1680
1842
  * the pure wire → ChatEvent mapping can live in translate.ts and be reused
1681
1843
  * by the replay path.
1682
1844
  *
1683
- * ``messageId`` is the server-assigned assistant message id for the current
1684
- * turn; empty in the reconnect and conversation-switch replay paths where
1685
- * messages have already been loaded from REST and shouldn't be re-pushed.
1845
+ * ``promptMessageId`` is the id of the USER turn that started this job —
1846
+ * `POST /v1/jobs` returns it and the backend tags the prompt with it
1847
+ * (`HumanMessage(content=..., id=message_id)`), which is what lets a restore
1848
+ * pair a job with its prompt by id instead of by position. It was previously
1849
+ * documented here as the ASSISTANT's id and used as one; there is no
1850
+ * server-assigned assistant id on the wire, so that row gets a local one.
1851
+ * Empty in the reconnect and conversation-switch replay paths, where the
1852
+ * messages have already been loaded from REST and must not be re-pushed —
1853
+ * so it doubles as the "is this a live send?" gate.
1686
1854
  */
1687
- applyWireSideEffects(wire, conversationId, messageId) {
1855
+ applyWireSideEffects(wire, conversationId, promptMessageId, live) {
1856
+ const ownsTurnState = live || !this.isStreaming;
1688
1857
  switch (wire.type) {
1689
1858
  case "message_start":
1690
- this.resetStreamingState();
1859
+ if (ownsTurnState) this.resetStreamingState();
1691
1860
  if (wire.model) {
1692
1861
  this.modelDisplayName = wire.model;
1693
1862
  }
1694
1863
  return;
1695
1864
  case "block_start":
1696
- if (wire.kind === "text" && (!wire.parent_path || wire.parent_path.length === 0)) {
1865
+ if (ownsTurnState && wire.kind === "text" && (!wire.parent_path || wire.parent_path.length === 0)) {
1697
1866
  this.currentTextPath = wire.path;
1698
1867
  }
1699
1868
  return;
1700
1869
  case "block_delta":
1701
- if (wire.delta.channel === "text" && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1870
+ if (ownsTurnState && wire.delta.channel === "text" && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1702
1871
  this.accumulatedText += wire.delta.text;
1703
1872
  }
1704
1873
  return;
1705
1874
  case "block_stop":
1706
- if (this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1875
+ if (ownsTurnState && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1707
1876
  this.currentTextPath = null;
1708
1877
  }
1709
1878
  return;
1710
1879
  case "message_stop":
1711
- if (messageId) {
1880
+ if (promptMessageId && this.pendingUserMessages.has(promptMessageId)) {
1881
+ this.pendingUserMessages.set(promptMessageId, ++this.serverRowsKnown);
1882
+ }
1883
+ if (promptMessageId) {
1712
1884
  const assistantMessage = {
1713
- id: messageId,
1885
+ // NOT `promptMessageId` — that is the USER turn's id, which
1886
+ // `consumeJobStream` stamps onto the user row. Sharing it puts two
1887
+ // rows under one id, and `pendingUserMessages` is id-keyed. No
1888
+ // server-assigned assistant id exists on the wire, so this row is
1889
+ // local until a REST load replaces it.
1890
+ id: generateId(),
1714
1891
  conversationId,
1715
1892
  role: "assistant",
1716
1893
  content: this.accumulatedText,
@@ -1721,8 +1898,10 @@ var ChatSession = class {
1721
1898
  this.storage.addMessage(assistantMessage, conversationId).catch(() => {
1722
1899
  });
1723
1900
  }
1724
- this.isStreaming = false;
1725
- this.currentJobId = null;
1901
+ if (ownsTurnState) {
1902
+ this.isStreaming = false;
1903
+ this.currentJobId = null;
1904
+ }
1726
1905
  return;
1727
1906
  case "custom":
1728
1907
  if (wire.name === "title_generated") {
@@ -1756,9 +1935,27 @@ var ChatSession = class {
1756
1935
  * Used before reconnectToJob — SSE replay handles event replay.
1757
1936
  */
1758
1937
  async loadConversation(id) {
1938
+ const load = ++this.loadGeneration;
1759
1939
  this.conversationId = id;
1760
- this.resetStreamingState();
1761
- this.messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
1940
+ if (!this.isStreaming) this.resetStreamingState();
1941
+ const rowsKnownAtIssue = this.serverRowsKnown;
1942
+ const messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
1943
+ if (load !== this.loadGeneration) return;
1944
+ const pending = this.messages.filter(
1945
+ (m) => this.pendingUserMessages.has(m.id) && m.conversationId === id
1946
+ );
1947
+ const stillPending = pending.filter((m) => {
1948
+ if (messages.some((f) => f.id === m.id)) return false;
1949
+ const knownAt = this.pendingUserMessages.get(m.id) ?? 0;
1950
+ return knownAt === 0 || knownAt > rowsKnownAtIssue;
1951
+ });
1952
+ for (const m of pending) {
1953
+ if (!stillPending.includes(m)) this.pendingUserMessages.delete(m.id);
1954
+ }
1955
+ this.setMessages(
1956
+ stillPending.length ? [...messages, ...stillPending] : messages
1957
+ );
1958
+ this.messagesConversationId = id;
1762
1959
  }
1763
1960
  /**
1764
1961
  * Reconnect to a running job's SSE stream (e.g. after page reload).
@@ -1771,7 +1968,8 @@ var ChatSession = class {
1771
1968
  this.lastSeq = -1;
1772
1969
  this.submittedToolCallIds.clear();
1773
1970
  this.resetStreamingState();
1774
- this.abortController = new AbortController();
1971
+ const controller = new AbortController();
1972
+ this.abortController = controller;
1775
1973
  try {
1776
1974
  await this.consumeEventStream(
1777
1975
  jobId,
@@ -1788,8 +1986,10 @@ var ChatSession = class {
1788
1986
  blockPath: null
1789
1987
  });
1790
1988
  } finally {
1791
- this.isStreaming = false;
1792
- this.abortController = null;
1989
+ if (this.abortController === controller) {
1990
+ this.isStreaming = false;
1991
+ this.abortController = null;
1992
+ }
1793
1993
  }
1794
1994
  }
1795
1995
  /** Detach from the SSE stream without cancelling the job. */
@@ -1800,25 +2000,54 @@ var ChatSession = class {
1800
2000
  this.resetStreamingState();
1801
2001
  this.emit({ type: "disconnected" });
1802
2002
  }
1803
- /** Stop the job and disconnect (explicit user action). */
1804
- disconnect() {
2003
+ /**
2004
+ * Cancel the running turn: stop the job server-side and tear the stream
2005
+ * down, WITHOUT ending the session. A turn-level cancel is not a
2006
+ * session-level teardown, so unlike `disconnect()` this leaves the protocol
2007
+ * registry alone — the SDK never auto-registers adapters, so clearing them
2008
+ * on a Stop press would silently kill embedded-resource rendering for the
2009
+ * rest of the session with nothing to re-register it.
2010
+ */
2011
+ cancelTurn() {
1805
2012
  if (this.currentJobId) {
1806
2013
  this.client.cancelJob(this.currentJobId).catch(() => {
1807
2014
  });
1808
2015
  }
1809
2016
  this.detach();
1810
2017
  this.currentJobId = null;
2018
+ for (const [id, knownAt] of this.pendingUserMessages) {
2019
+ if (knownAt === 0) this.pendingUserMessages.delete(id);
2020
+ }
2021
+ }
2022
+ /** Stop the job and end the session's activity (explicit user action). */
2023
+ disconnect() {
2024
+ this.cancelTurn();
1811
2025
  this.protocols.clear();
1812
2026
  }
2027
+ /**
2028
+ * A pointer move happened above this layer, so any `loadConversation` in
2029
+ * flight must lose to it. `StreamManager` owns a pointer of its own and
2030
+ * moves it before this one; without this the two halves would gate on
2031
+ * counters that bump at different instants — `generation` synchronously in
2032
+ * `setActiveConversation`, `loadGeneration` only once the switch's own load
2033
+ * actually runs, which is behind the active-job probe.
2034
+ */
2035
+ invalidateLoadsInFlight() {
2036
+ this.loadGeneration++;
2037
+ }
1813
2038
  async createNewConversation() {
1814
2039
  const id = generateId();
2040
+ const load = this.loadGeneration;
1815
2041
  const conversation = await this.storage.createConversation(
1816
2042
  id,
1817
2043
  "New Conversation"
1818
2044
  );
1819
2045
  this.conversations.unshift(conversation);
2046
+ if (load !== this.loadGeneration) return id;
2047
+ this.loadGeneration++;
1820
2048
  this.conversationId = id;
1821
- this.messages = [];
2049
+ this.setMessages([]);
2050
+ this.messagesConversationId = id;
1822
2051
  return id;
1823
2052
  }
1824
2053
  /**
@@ -1837,7 +2066,7 @@ var ChatSession = class {
1837
2066
  */
1838
2067
  replayTurn(id, events, userMessageContent, userMessageId, isSteer = false) {
1839
2068
  this.conversationId = id;
1840
- this.resetStreamingState();
2069
+ if (!this.isStreaming) this.resetStreamingState();
1841
2070
  if (userMessageContent) {
1842
2071
  this.emit({
1843
2072
  type: "user_message",
@@ -1868,11 +2097,17 @@ var ChatSession = class {
1868
2097
  * job's events.
1869
2098
  */
1870
2099
  async switchConversation(id, jobId) {
1871
- const [messagesResult, eventsResult] = await Promise.allSettled([
1872
- this.client.getMessages(id).catch(() => this.storage.fetchMessages(id)),
2100
+ const loading = this.loadConversation(id);
2101
+ const token = this.loadGeneration;
2102
+ const [loadResult, eventsResult] = await Promise.allSettled([
2103
+ loading,
1873
2104
  this.client.getConversationEvents(id, jobId)
1874
2105
  ]);
1875
- this.messages = messagesResult.status === "fulfilled" ? messagesResult.value : [];
2106
+ if (token !== this.loadGeneration) return;
2107
+ if (loadResult.status === "rejected") {
2108
+ this.setMessages([]);
2109
+ this.messagesConversationId = id;
2110
+ }
1876
2111
  this.replayTurn(
1877
2112
  id,
1878
2113
  eventsResult.status === "fulfilled" ? eventsResult.value : []
@@ -1963,8 +2198,10 @@ var ChatSession = class {
1963
2198
  }
1964
2199
  this.conversations = this.conversations.filter((c) => c.id !== id);
1965
2200
  if (this.conversationId === id) {
2201
+ this.loadGeneration++;
1966
2202
  this.conversationId = null;
1967
- this.messages = [];
2203
+ this.setMessages([]);
2204
+ this.messagesConversationId = null;
1968
2205
  }
1969
2206
  }
1970
2207
  toggleClientTool(name) {
@@ -1980,6 +2217,11 @@ var ChatSession = class {
1980
2217
  // src/restore-plan.ts
1981
2218
  function planRestore(args) {
1982
2219
  const { completedJobs, userMessages } = args;
2220
+ const claimed = new Set(
2221
+ (args.claimedMessageIds ?? completedJobs.map((j) => j.message_id)).filter(
2222
+ (id) => !!id
2223
+ )
2224
+ );
1983
2225
  const byId = /* @__PURE__ */ new Map();
1984
2226
  userMessages.forEach((m, i) => {
1985
2227
  if (m.id) byId.set(m.id, i);
@@ -2001,7 +2243,7 @@ function planRestore(args) {
2001
2243
  userMessages.slice(0, cutover)
2002
2244
  );
2003
2245
  let cursor = cutover;
2004
- const isSteer = (m) => !!m?.id && !completedJobs.some((j) => j.message_id === m.id);
2246
+ const isSteer = (m) => !!m?.id && !claimed.has(m.id);
2005
2247
  const drainTo = (stopAt) => {
2006
2248
  while (cursor < stopAt) {
2007
2249
  const m = userMessages[cursor++];
@@ -2087,6 +2329,12 @@ var StreamManager = class {
2087
2329
  this._backgroundJobs = /* @__PURE__ */ new Map();
2088
2330
  this.handlers = [];
2089
2331
  this.unsub = null;
2332
+ /**
2333
+ * Bumped every time the active conversation moves. An async sequence that
2334
+ * captures it can then tell, at each await boundary, whether it is still the
2335
+ * one the user is waiting on — see ``restore``.
2336
+ */
2337
+ this.generation = 0;
2090
2338
  this.session = session;
2091
2339
  this.attach();
2092
2340
  }
@@ -2137,7 +2385,7 @@ var StreamManager = class {
2137
2385
  event
2138
2386
  });
2139
2387
  if (event.type === ChatEventType.MessageStop) {
2140
- if (this._state === "streaming") {
2388
+ if (this._state === "streaming" && !this.session.isStreaming) {
2141
2389
  this.setState("idle");
2142
2390
  }
2143
2391
  }
@@ -2150,13 +2398,21 @@ var StreamManager = class {
2150
2398
  );
2151
2399
  }
2152
2400
  if (this._state === "streaming") return;
2153
- if (!this._activeConversationId) {
2154
- const id = await this.session.createNewConversation();
2155
- this.setActiveConversation(id);
2401
+ let target = this._activeConversationId;
2402
+ if (!target) {
2403
+ target = await this.session.createNewConversation();
2404
+ this.setActiveConversation(target);
2156
2405
  }
2157
2406
  this.setState("streaming");
2158
2407
  try {
2159
2408
  await this.session.send(content, {
2409
+ // Address the send explicitly. `ChatSession.send` otherwise falls back
2410
+ // to `session.conversationId`, which LAGS this pointer: a restore
2411
+ // assigns it synchronously but only reaches the next switch's own
2412
+ // `loadConversation` an await later, so between the two the session
2413
+ // still names the conversation the user left. The manager's pointer
2414
+ // moved the moment the user clicked; it is the authority.
2415
+ conversationId: target ?? void 0,
2160
2416
  agentName: options?.agentName,
2161
2417
  uploadIds: options?.uploadIds,
2162
2418
  planMode: options?.planMode,
@@ -2175,6 +2431,9 @@ var StreamManager = class {
2175
2431
  // ── Regenerate ────────────────────────────────────────────────
2176
2432
  async regenerate() {
2177
2433
  if (this._state === "streaming") return;
2434
+ if (this.session.messagesConversationId !== this._activeConversationId) {
2435
+ return;
2436
+ }
2178
2437
  const userMsgs = this.session.messages.filter(
2179
2438
  (m) => m.role === "user"
2180
2439
  );
@@ -2211,44 +2470,78 @@ var StreamManager = class {
2211
2470
  async switchTo(conversationId, opts) {
2212
2471
  if (conversationId === this._activeConversationId) return;
2213
2472
  const targetHadBackgroundJob = this._backgroundJobs.has(conversationId);
2214
- if (this._state === "streaming") {
2215
- const oldConvId = this._activeConversationId;
2216
- const jobId = this.session.currentJobId;
2217
- if (oldConvId && jobId) {
2218
- this._backgroundJobs.set(oldConvId, jobId);
2219
- this.emit({
2220
- type: "backgroundJobsChanged",
2221
- jobs: this._backgroundJobs
2222
- });
2223
- }
2224
- this.session.detach();
2225
- }
2226
- if (this._backgroundJobs.has(conversationId)) {
2473
+ this.detachStreamingTurn();
2474
+ const parkedJobId = this._backgroundJobs.get(conversationId);
2475
+ if (parkedJobId !== void 0) {
2227
2476
  this._backgroundJobs.delete(conversationId);
2228
2477
  this.emit({
2229
2478
  type: "backgroundJobsChanged",
2230
2479
  jobs: this._backgroundJobs
2231
2480
  });
2232
2481
  }
2233
- this.setActiveConversation(conversationId);
2482
+ const gen = this.setActiveConversation(conversationId);
2234
2483
  if (opts?.skipHistoryReplay && !targetHadBackgroundJob) {
2235
2484
  let activeJobId = null;
2236
2485
  try {
2237
2486
  activeJobId = (await this.session.client.getActiveJob(conversationId)).jobId;
2238
2487
  } catch {
2239
2488
  }
2489
+ if (gen !== this.generation) return;
2240
2490
  if (!activeJobId) {
2241
- await this.session.loadConversation(conversationId);
2242
- this.setState("idle");
2491
+ try {
2492
+ await this.session.loadConversation(conversationId);
2493
+ } finally {
2494
+ if (gen === this.generation) this.settleIdle();
2495
+ }
2243
2496
  return;
2244
2497
  }
2245
2498
  }
2246
- await this.restore(conversationId);
2499
+ let tookOver = false;
2500
+ try {
2501
+ await this.restore(conversationId, gen);
2502
+ tookOver = gen === this.generation;
2503
+ } finally {
2504
+ const supersededOntoSame = gen !== this.generation && this._activeConversationId === conversationId;
2505
+ const stillExists = this.session.conversations.some(
2506
+ (c) => c.id === conversationId
2507
+ );
2508
+ if (parkedJobId !== void 0 && !tookOver && !supersededOntoSame && stillExists) {
2509
+ this._backgroundJobs.set(conversationId, parkedJobId);
2510
+ this.emit({
2511
+ type: "backgroundJobsChanged",
2512
+ jobs: this._backgroundJobs
2513
+ });
2514
+ }
2515
+ if (!tookOver && gen === this.generation && this._state === "restoring") {
2516
+ this.settleIdle();
2517
+ }
2518
+ }
2247
2519
  }
2248
2520
  // ── Create / rename / delete conversation ─────────────────────
2521
+ /**
2522
+ * Create a conversation and make it active.
2523
+ *
2524
+ * The returned id is NOT guaranteed to be the active conversation: if a
2525
+ * switch lands inside the storage round-trip, this declines the pointer move
2526
+ * so the newer one wins, and no `conversationChanged` fires for the new id.
2527
+ * A caller that routes on the return value should `switchTo(id)` rather than
2528
+ * assume it is current — that call is not a no-op in the declined case.
2529
+ */
2249
2530
  async createConversation() {
2250
- const id = await this.session.createNewConversation();
2531
+ this.detachStreamingTurn();
2532
+ let id;
2533
+ try {
2534
+ id = await this.session.createNewConversation();
2535
+ } catch (err) {
2536
+ this.settleIdle();
2537
+ throw err;
2538
+ }
2539
+ if (this.session.conversationId !== id) {
2540
+ this.settleIdle();
2541
+ return id;
2542
+ }
2251
2543
  this.setActiveConversation(id);
2544
+ this.settleIdle();
2252
2545
  return id;
2253
2546
  }
2254
2547
  /**
@@ -2259,16 +2552,34 @@ var StreamManager = class {
2259
2552
  await this.session.renameConversation(id, title);
2260
2553
  }
2261
2554
  async deleteConversation(id) {
2262
- await this.session.deleteConversation(id);
2263
- this._backgroundJobs.delete(id);
2555
+ const wasActive = this._activeConversationId === id;
2556
+ const cancelled = wasActive && this._state === "streaming";
2557
+ if (cancelled) {
2558
+ this.session.cancelTurn();
2559
+ this._state = "idle";
2560
+ }
2561
+ try {
2562
+ await this.session.deleteConversation(id);
2563
+ } catch (err) {
2564
+ if (cancelled) this.setState("idle");
2565
+ throw err;
2566
+ }
2264
2567
  if (this._activeConversationId === id) {
2265
- this._activeConversationId = null;
2266
- this.emit({ type: "conversationChanged", conversationId: null });
2568
+ this.setActiveConversation(null);
2569
+ this.settleIdle();
2570
+ }
2571
+ const parkedJobId = this._backgroundJobs.get(id);
2572
+ if (this._backgroundJobs.delete(id)) {
2573
+ if (parkedJobId) {
2574
+ this.session.client.cancelJob(parkedJobId).catch(() => {
2575
+ });
2576
+ }
2577
+ this.emit({ type: "backgroundJobsChanged", jobs: this._backgroundJobs });
2267
2578
  }
2268
2579
  }
2269
2580
  // ── Stop (explicit cancel) ────────────────────────────────────
2270
2581
  stop() {
2271
- this.session.disconnect();
2582
+ this.session.cancelTurn();
2272
2583
  this.setState("idle");
2273
2584
  }
2274
2585
  // ── Cleanup ───────────────────────────────────────────────────
@@ -2280,34 +2591,80 @@ var StreamManager = class {
2280
2591
  this.handlers = [];
2281
2592
  }
2282
2593
  // ── Internal: helpers ──────────────────────────────────────────
2594
+ /**
2595
+ * Park a streaming turn as a background job and detach from its SSE stream.
2596
+ *
2597
+ * Every method that relocates the active conversation has to do this before
2598
+ * announcing a new state. Announcing `idle` while `session.isStreaming` is
2599
+ * still true is worse than announcing nothing: `manager.send` no longer bails
2600
+ * on the streaming state, calls `session.send`, and THAT bails on its own
2601
+ * `isStreaming` — so the message is never posted, no error is emitted, and
2602
+ * the composer looks ready the whole time.
2603
+ */
2604
+ detachStreamingTurn() {
2605
+ if (this._state !== "streaming") return;
2606
+ const oldConvId = this._activeConversationId;
2607
+ const jobId = this.session.currentJobId;
2608
+ if (oldConvId && jobId) {
2609
+ this._backgroundJobs.set(oldConvId, jobId);
2610
+ this.emit({ type: "backgroundJobsChanged", jobs: this._backgroundJobs });
2611
+ }
2612
+ this.session.detach();
2613
+ this.session.currentJobId = null;
2614
+ this._state = "idle";
2615
+ }
2616
+ /**
2617
+ * Announce `idle` unless a turn is actually streaming.
2618
+ *
2619
+ * A `send` can land inside any of the switch paths — the fast path most
2620
+ * easily, since it deliberately stays out of `restoring` and so leaves the
2621
+ * composer live for the whole probe. `send` sets `streaming` and does not
2622
+ * bump the generation, so the path resumes, passes its supersession check,
2623
+ * and would announce a ready composer over a running stream. From there
2624
+ * `finalizeStream` and the `message_stop` branch both no-op (they only act
2625
+ * on `streaming`), so it stays `idle` for the whole turn — and the next send
2626
+ * reaches `session.send`, which bails on its own `isStreaming`: message
2627
+ * never posted, no error, composer ready throughout.
2628
+ */
2629
+ settleIdle() {
2630
+ if (this._state === "streaming") return;
2631
+ this.setState("idle");
2632
+ }
2283
2633
  finalizeStream() {
2284
2634
  if (this._state === "streaming") {
2285
2635
  this.setState("idle");
2286
2636
  }
2287
2637
  }
2288
2638
  // ── Internal: restore ─────────────────────────────────────────
2289
- async restore(conversationId) {
2290
- this.setState("restoring");
2639
+ async restore(conversationId, gen) {
2640
+ const superseded = () => gen !== this.generation;
2641
+ if (superseded()) return;
2642
+ if (!this.session.isStreaming) this.setState("restoring");
2291
2643
  let activeJobId = null;
2292
2644
  try {
2293
2645
  const res = await this.session.client.getActiveJob(conversationId);
2294
2646
  activeJobId = res.jobId;
2295
2647
  } catch {
2296
2648
  }
2649
+ if (superseded()) return;
2297
2650
  if (activeJobId) {
2298
2651
  await this.session.loadConversation(conversationId);
2652
+ if (superseded()) return;
2299
2653
  this.setState("streaming");
2300
2654
  try {
2301
2655
  await this.session.reconnectToJob(activeJobId);
2302
2656
  } catch {
2303
2657
  }
2304
- if (this._state === "streaming") {
2658
+ if (superseded()) return;
2659
+ if (this._state === "streaming" && !this.session.isStreaming) {
2305
2660
  this.setState("idle");
2306
2661
  }
2307
2662
  } else {
2308
2663
  await this.session.loadConversation(conversationId);
2664
+ if (superseded()) return;
2309
2665
  try {
2310
2666
  const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2667
+ if (superseded()) return;
2311
2668
  const completedJobs = jobs.filter(
2312
2669
  (j) => j.status === "completed"
2313
2670
  );
@@ -2319,6 +2676,14 @@ var StreamManager = class {
2319
2676
  job_id: j.job_id,
2320
2677
  message_id: j.message_id
2321
2678
  })),
2679
+ // Completed jobs PLUS the ones still going. A send landing in the
2680
+ // probe window has a running job, so its prompt is claimed and does
2681
+ // not read as a steer replayed over the bubble the live send already
2682
+ // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2683
+ // they produce no `turn` step, so claiming them would delete the
2684
+ // user's prompt from the restore entirely rather than show it as a
2685
+ // steer.
2686
+ claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2322
2687
  userMessages: userMessages.map((m) => ({
2323
2688
  id: m.id,
2324
2689
  content: m.content
@@ -2329,10 +2694,12 @@ var StreamManager = class {
2329
2694
  (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2330
2695
  )
2331
2696
  );
2697
+ if (superseded()) return;
2332
2698
  const eventsByJobId = new Map(
2333
2699
  completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2334
2700
  );
2335
2701
  for (const step of plan) {
2702
+ if (superseded()) return;
2336
2703
  if (step.kind === "steer") {
2337
2704
  this.session.replayTurn(
2338
2705
  conversationId,
@@ -2350,6 +2717,7 @@ var StreamManager = class {
2350
2717
  step.messageId
2351
2718
  );
2352
2719
  }
2720
+ if (superseded()) return;
2353
2721
  if (completedJobs.length > 0) {
2354
2722
  this.emit({
2355
2723
  type: "versionsReady",
@@ -2359,13 +2727,17 @@ var StreamManager = class {
2359
2727
  }
2360
2728
  } catch {
2361
2729
  }
2362
- this.setState("idle");
2730
+ if (superseded()) return;
2731
+ this.settleIdle();
2363
2732
  }
2364
2733
  }
2365
2734
  // ── Internal: set active conversation ─────────────────────────
2366
2735
  setActiveConversation(id) {
2367
2736
  this._activeConversationId = id;
2737
+ this.session.invalidateLoadsInFlight();
2738
+ const claimed = ++this.generation;
2368
2739
  this.emit({ type: "conversationChanged", conversationId: id });
2740
+ return claimed;
2369
2741
  }
2370
2742
  };
2371
2743