@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.cjs CHANGED
@@ -1338,6 +1338,7 @@ function translateWireEvent(wire) {
1338
1338
 
1339
1339
  // src/session.ts
1340
1340
  var SSE_MAX_RECONNECTS = 6;
1341
+ var SSE_STALL_TIMEOUT_MS = 45e3;
1341
1342
  var TOOL_RESULT_MAX_RETRIES = 3;
1342
1343
  var CONVERSATION_PAGE_SIZE = 50;
1343
1344
  function sseReconnectDelayMs(attempt) {
@@ -1375,6 +1376,17 @@ var ChatSession = class {
1375
1376
  /** True while ``loadMoreConversations`` is in flight. */
1376
1377
  this.isLoadingConversations = false;
1377
1378
  this.messages = [];
1379
+ /**
1380
+ * Which conversation ``messages`` currently holds.
1381
+ *
1382
+ * Distinct from ``conversationId``, and the distinction is the point:
1383
+ * ``loadConversation`` moves the POINTER synchronously and installs the LIST
1384
+ * an await later, so for the whole duration of every load the two disagree.
1385
+ * Anything pairing a message with a conversation — ``regenerate`` above all —
1386
+ * has to read this one, or it will pair the previous conversation's last
1387
+ * message with the new conversation's id.
1388
+ */
1389
+ this.messagesConversationId = null;
1378
1390
  this.isStreaming = false;
1379
1391
  this.agentStatus = null;
1380
1392
  this.agents = [];
@@ -1407,6 +1419,43 @@ var ChatSession = class {
1407
1419
  * is discarded instead.
1408
1420
  */
1409
1421
  this.conversationsGeneration = 0;
1422
+ /**
1423
+ * Bumped by every ``loadConversation`` call, so an out-of-order fetch can
1424
+ * tell it is no longer the newest one and drop its result. Separate from
1425
+ * ``conversationsGeneration``, which guards the conversation LIST.
1426
+ */
1427
+ this.loadGeneration = 0;
1428
+ /**
1429
+ * Ids of locally-created user messages the server has not acknowledged yet.
1430
+ *
1431
+ * Arrival time cannot answer "could the reply have included this?" on its
1432
+ * own. Every `loadConversation` in `StreamManager` sits behind the
1433
+ * active-job probe, so the ORDINARY ordering is that a send lands BEFORE the
1434
+ * load starts — and an arrival-time rule drops exactly those, losing the
1435
+ * prompt the user just sent while its stream is still running. Membership
1436
+ * here is set by `send` and cleared when a server row turns up carrying the
1437
+ * same turn, so the keep-decision no longer depends on which side of the
1438
+ * fetch the push landed on.
1439
+ *
1440
+ * The value is `serverRowsKnown` as of the `message_stop` that proved the
1441
+ * row committed, or 0 until then — including after the job response, which
1442
+ * hands back the id but starts the loop as a background task and so proves
1443
+ * nothing about the row. That stamp is what separates "the snapshot predates
1444
+ * the row" from "the server does not have this row" — see
1445
+ * `loadConversation`.
1446
+ *
1447
+ * It is an ANNOTATION ON `this.messages`: reconciliation only ever consults
1448
+ * entries of that array, so an id whose message has left it is dead weight.
1449
+ * `setMessages` is the single place the array is replaced, and it prunes.
1450
+ */
1451
+ this.pendingUserMessages = /* @__PURE__ */ new Map();
1452
+ /**
1453
+ * Bumped once per completed turn, at `message_stop`, so a fetch can record
1454
+ * what was proven when it was ISSUED. A row proven committed before the
1455
+ * fetch went out must appear in its snapshot; one proven after may
1456
+ * legitimately be missing.
1457
+ */
1458
+ this.serverRowsKnown = 0;
1410
1459
  // Minimal in-session accumulation for the assistant message record.
1411
1460
  // Only top-level ``text`` blocks contribute; subagent / tool output
1412
1461
  // is tracked by the consumer's own block store.
@@ -1429,6 +1478,19 @@ var ChatSession = class {
1429
1478
  this.toolRegistry = new ToolRegistry();
1430
1479
  this.storage = storage ?? new InMemoryStorage();
1431
1480
  }
1481
+ /**
1482
+ * Replace the message list, keeping `pendingUserMessages` an annotation on
1483
+ * it. Every removal from the array goes through here — `push` is the only
1484
+ * other mutation and it cannot orphan an id.
1485
+ */
1486
+ setMessages(next) {
1487
+ this.messages = next;
1488
+ if (this.pendingUserMessages.size === 0) return;
1489
+ const present = new Set(next.map((m) => m.id));
1490
+ for (const id of this.pendingUserMessages.keys()) {
1491
+ if (!present.has(id)) this.pendingUserMessages.delete(id);
1492
+ }
1493
+ }
1432
1494
  on(handler) {
1433
1495
  this.handlers.add(handler);
1434
1496
  return () => {
@@ -1477,6 +1539,22 @@ var ChatSession = class {
1477
1539
  }
1478
1540
  if (this.isStreaming) return;
1479
1541
  const conversationId = options?.conversationId ?? this.conversationId ?? void 0;
1542
+ let relocatedFrom = null;
1543
+ if (conversationId) {
1544
+ if (conversationId !== this.conversationId) {
1545
+ this.loadGeneration++;
1546
+ relocatedFrom = {
1547
+ messages: this.messages,
1548
+ messagesId: this.messagesConversationId,
1549
+ conversationId: this.conversationId,
1550
+ generation: this.loadGeneration,
1551
+ target: conversationId
1552
+ };
1553
+ this.setMessages([]);
1554
+ this.messagesConversationId = null;
1555
+ }
1556
+ this.conversationId = conversationId;
1557
+ }
1480
1558
  const userMessage = {
1481
1559
  id: generateId(),
1482
1560
  conversationId: conversationId ?? "",
@@ -1486,9 +1564,11 @@ var ChatSession = class {
1486
1564
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1487
1565
  };
1488
1566
  if (conversationId) {
1489
- await this.storage.addMessage(userMessage, conversationId);
1567
+ await this.storage.addMessage(userMessage, conversationId).catch(() => {
1568
+ });
1490
1569
  }
1491
1570
  this.messages.push(userMessage);
1571
+ this.pendingUserMessages.set(userMessage.id, 0);
1492
1572
  const request = {
1493
1573
  message: content,
1494
1574
  conversation_id: conversationId,
@@ -1508,7 +1588,24 @@ var ChatSession = class {
1508
1588
  reasoning_effort: options?.reasoningEffort,
1509
1589
  temperature: options?.temperature
1510
1590
  };
1511
- await this.processStream(request);
1591
+ const wire = { reached: false };
1592
+ await this.processStream(request, wire);
1593
+ if (!wire.reached) {
1594
+ this.pendingUserMessages.delete(userMessage.id);
1595
+ const at = this.messages.indexOf(userMessage);
1596
+ if (at !== -1) this.messages.splice(at, 1);
1597
+ if (conversationId) {
1598
+ await this.storage.deleteMessage(userMessage.id).catch(() => {
1599
+ });
1600
+ }
1601
+ }
1602
+ if (relocatedFrom && wire.reached && this.loadGeneration === relocatedFrom.generation) {
1603
+ this.messagesConversationId = relocatedFrom.target;
1604
+ } else if (relocatedFrom && !wire.reached && this.loadGeneration === relocatedFrom.generation) {
1605
+ this.setMessages(relocatedFrom.messages);
1606
+ this.messagesConversationId = relocatedFrom.messagesId;
1607
+ this.conversationId = relocatedFrom.conversationId;
1608
+ }
1512
1609
  }
1513
1610
  async resendFromCheckpoint(messageId, newContent) {
1514
1611
  if (this.isStreaming) return;
@@ -1525,12 +1622,13 @@ var ChatSession = class {
1525
1622
  this.accumulatedText = "";
1526
1623
  this.currentTextPath = null;
1527
1624
  }
1528
- async processStream(request) {
1625
+ async processStream(request, wire) {
1529
1626
  this.isStreaming = true;
1530
1627
  this.resetStreamingState();
1531
- this.abortController = new AbortController();
1628
+ const controller = new AbortController();
1629
+ this.abortController = controller;
1532
1630
  try {
1533
- await this.consumeJobStream(request);
1631
+ await this.consumeJobStream(request, wire);
1534
1632
  } catch (err) {
1535
1633
  if (!(err instanceof DOMException && err.name === "AbortError")) {
1536
1634
  this.emit({
@@ -1541,16 +1639,20 @@ var ChatSession = class {
1541
1639
  });
1542
1640
  }
1543
1641
  } finally {
1544
- this.isStreaming = false;
1545
- this.abortController = null;
1642
+ if (this.abortController === controller) {
1643
+ this.isStreaming = false;
1644
+ this.abortController = null;
1645
+ }
1546
1646
  }
1547
1647
  }
1548
- async consumeJobStream(request) {
1648
+ async consumeJobStream(request, wire) {
1549
1649
  const job = await this.client.createJob(request);
1650
+ if (wire) wire.reached = true;
1550
1651
  this.currentJobId = job.job_id;
1551
1652
  const conversationId = job.conversation_id;
1552
1653
  if (!this.conversationId) {
1553
1654
  this.conversationId = conversationId;
1655
+ this.messagesConversationId = conversationId;
1554
1656
  }
1555
1657
  if (!this.conversations.some((c) => c.id === conversationId)) {
1556
1658
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1571,13 +1673,29 @@ var ChatSession = class {
1571
1673
  await this.storage.addMessage(lastMsg, conversationId).catch(() => {
1572
1674
  });
1573
1675
  }
1574
- const messageId = job.message_id;
1676
+ const promptMessageId = job.message_id;
1677
+ if (promptMessageId && lastMsg?.role === "user" && this.pendingUserMessages.has(lastMsg.id)) {
1678
+ this.pendingUserMessages.delete(lastMsg.id);
1679
+ const clientMintedId = lastMsg.id;
1680
+ lastMsg.id = promptMessageId;
1681
+ this.pendingUserMessages.set(promptMessageId, 0);
1682
+ if (conversationId && clientMintedId !== promptMessageId) {
1683
+ await this.storage.deleteMessage(clientMintedId).catch(() => {
1684
+ });
1685
+ await this.storage.addMessage(lastMsg, conversationId).catch(() => {
1686
+ });
1687
+ }
1688
+ const dupe = this.messages.findIndex(
1689
+ (m) => m !== lastMsg && m.id === promptMessageId
1690
+ );
1691
+ if (dupe !== -1) this.messages.splice(dupe, 1);
1692
+ }
1575
1693
  this.lastSeq = -1;
1576
1694
  this.submittedToolCallIds.clear();
1577
1695
  await this.consumeEventStream(
1578
1696
  job.job_id,
1579
1697
  conversationId,
1580
- messageId,
1698
+ promptMessageId,
1581
1699
  true
1582
1700
  // executeClientTools
1583
1701
  );
@@ -1586,17 +1704,26 @@ var ChatSession = class {
1586
1704
  * Shared event consumption loop. Parses each wire event, updates
1587
1705
  * minimal session state, and emits typed ChatEvents to consumers.
1588
1706
  */
1589
- async consumeEventStream(jobId, conversationId, messageId, executeClientTools) {
1707
+ async consumeEventStream(jobId, conversationId, promptMessageId, executeClientTools) {
1590
1708
  const signal = this.abortController?.signal;
1591
1709
  for (let attempt = 0; ; attempt++) {
1592
- const stream = this.client.streamJobEvents(jobId, this.lastSeq, signal);
1710
+ if (signal?.aborted) return;
1711
+ const attemptController = new AbortController();
1712
+ const linkAbort = () => attemptController.abort();
1713
+ signal?.addEventListener("abort", linkAbort);
1593
1714
  let sawTerminal;
1594
1715
  try {
1716
+ const stream = this.client.streamJobEvents(
1717
+ jobId,
1718
+ this.lastSeq,
1719
+ attemptController.signal
1720
+ );
1595
1721
  sawTerminal = await this.pumpStream(
1596
1722
  stream,
1597
1723
  conversationId,
1598
- messageId,
1599
- executeClientTools
1724
+ promptMessageId,
1725
+ executeClientTools,
1726
+ () => attemptController.abort()
1600
1727
  );
1601
1728
  } catch (err) {
1602
1729
  if (signal?.aborted) return;
@@ -1606,6 +1733,8 @@ var ChatSession = class {
1606
1733
  if (attempt >= SSE_MAX_RECONNECTS) throw err;
1607
1734
  await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1608
1735
  continue;
1736
+ } finally {
1737
+ signal?.removeEventListener("abort", linkAbort);
1609
1738
  }
1610
1739
  if (sawTerminal || signal?.aborted) return;
1611
1740
  if (attempt >= SSE_MAX_RECONNECTS) {
@@ -1618,35 +1747,66 @@ var ChatSession = class {
1618
1747
  * Consume a single SSE stream to exhaustion. Returns whether a terminal
1619
1748
  * event (``message_stop`` / ``error``) was seen, so the caller can decide
1620
1749
  * whether an ended stream means "turn done" vs "dropped, reconnect".
1750
+ *
1751
+ * ``onStall`` aborts the per-attempt connection: if no event arrives within
1752
+ * SSE_STALL_TIMEOUT_MS (backend keepalives land every 15s), the stream is a
1753
+ * zombie — ``reader.read()`` will never settle — so we kill the fetch and
1754
+ * throw a ConnectionError, feeding the caller's reconnect-from-lastSeq loop.
1621
1755
  */
1622
- async pumpStream(stream, conversationId, messageId, executeClientTools) {
1756
+ async pumpStream(stream, conversationId, promptMessageId, executeClientTools, onStall) {
1623
1757
  let sawTerminal = false;
1624
- for await (const raw of stream) {
1625
- let parsed;
1626
- try {
1627
- const data = JSON.parse(raw.data);
1628
- if (typeof data !== "object" || data === null || typeof data.type !== "string") {
1629
- if (typeof data?.seq === "number") {
1758
+ const iterator = stream[Symbol.asyncIterator]();
1759
+ try {
1760
+ while (true) {
1761
+ const next = iterator.next();
1762
+ let stallTimer;
1763
+ const stall = new Promise((_, reject) => {
1764
+ stallTimer = setTimeout(() => {
1765
+ reject(
1766
+ new ConnectionError(
1767
+ `Stream stalled: no events for ${SSE_STALL_TIMEOUT_MS}ms`
1768
+ )
1769
+ );
1770
+ onStall?.();
1771
+ }, SSE_STALL_TIMEOUT_MS);
1772
+ });
1773
+ let result;
1774
+ try {
1775
+ result = await Promise.race([next, stall]);
1776
+ } finally {
1777
+ clearTimeout(stallTimer);
1778
+ }
1779
+ if (result.done) break;
1780
+ const raw = result.value;
1781
+ let parsed;
1782
+ try {
1783
+ const data = JSON.parse(raw.data);
1784
+ if (typeof data !== "object" || data === null || typeof data.type !== "string") {
1785
+ if (typeof data?.seq === "number") {
1786
+ this.lastSeq = data.seq;
1787
+ }
1788
+ continue;
1789
+ }
1790
+ parsed = data;
1791
+ if (typeof data.seq === "number") {
1630
1792
  this.lastSeq = data.seq;
1631
1793
  }
1794
+ } catch {
1632
1795
  continue;
1633
1796
  }
1634
- parsed = data;
1635
- if (typeof data.seq === "number") {
1636
- this.lastSeq = data.seq;
1797
+ if (parsed.type === "message_stop" || parsed.type === "error") {
1798
+ sawTerminal = true;
1637
1799
  }
1638
- } catch {
1639
- continue;
1640
- }
1641
- if (parsed.type === "message_stop" || parsed.type === "error") {
1642
- sawTerminal = true;
1800
+ await this.dispatchWireEvent(
1801
+ parsed,
1802
+ conversationId,
1803
+ promptMessageId,
1804
+ executeClientTools
1805
+ );
1643
1806
  }
1644
- await this.dispatchWireEvent(
1645
- parsed,
1646
- conversationId,
1647
- messageId,
1648
- executeClientTools
1649
- );
1807
+ } finally {
1808
+ void iterator.return?.(void 0).catch(() => {
1809
+ });
1650
1810
  }
1651
1811
  return sawTerminal;
1652
1812
  }
@@ -1682,8 +1842,8 @@ var ChatSession = class {
1682
1842
  }
1683
1843
  }
1684
1844
  }
1685
- async dispatchWireEvent(wire, conversationId, messageId, executeClientTools) {
1686
- this.applyWireSideEffects(wire, conversationId, messageId);
1845
+ async dispatchWireEvent(wire, conversationId, promptMessageId, executeClientTools) {
1846
+ this.applyWireSideEffects(wire, conversationId, promptMessageId, true);
1687
1847
  const event = translateWireEvent(wire);
1688
1848
  if (event) {
1689
1849
  this.emit(event);
@@ -1701,7 +1861,9 @@ var ChatSession = class {
1701
1861
  const results = await this.executeClientTools([request]);
1702
1862
  await this.submitToolResultWithRetry({
1703
1863
  conversation_id: conversationId,
1704
- message_id: messageId,
1864
+ // The message that TRIGGERED the tool calls, which is what the
1865
+ // backend stores it against — the prompt id, correctly.
1866
+ message_id: promptMessageId,
1705
1867
  tool_results: results
1706
1868
  });
1707
1869
  this.submittedToolCallIds.add(callId);
@@ -1716,7 +1878,7 @@ var ChatSession = class {
1716
1878
  * instead of re-typing the whole conversation event by event.
1717
1879
  */
1718
1880
  replayWireEvent(wire, conversationId) {
1719
- this.applyWireSideEffects(wire, conversationId, "");
1881
+ this.applyWireSideEffects(wire, conversationId, "", false);
1720
1882
  const event = translateWireEvent(wire);
1721
1883
  if (event) {
1722
1884
  this.emit(event);
@@ -1727,37 +1889,52 @@ var ChatSession = class {
1727
1889
  * the pure wire → ChatEvent mapping can live in translate.ts and be reused
1728
1890
  * by the replay path.
1729
1891
  *
1730
- * ``messageId`` is the server-assigned assistant message id for the current
1731
- * turn; empty in the reconnect and conversation-switch replay paths where
1732
- * messages have already been loaded from REST and shouldn't be re-pushed.
1892
+ * ``promptMessageId`` is the id of the USER turn that started this job —
1893
+ * `POST /v1/jobs` returns it and the backend tags the prompt with it
1894
+ * (`HumanMessage(content=..., id=message_id)`), which is what lets a restore
1895
+ * pair a job with its prompt by id instead of by position. It was previously
1896
+ * documented here as the ASSISTANT's id and used as one; there is no
1897
+ * server-assigned assistant id on the wire, so that row gets a local one.
1898
+ * Empty in the reconnect and conversation-switch replay paths, where the
1899
+ * messages have already been loaded from REST and must not be re-pushed —
1900
+ * so it doubles as the "is this a live send?" gate.
1733
1901
  */
1734
- applyWireSideEffects(wire, conversationId, messageId) {
1902
+ applyWireSideEffects(wire, conversationId, promptMessageId, live) {
1903
+ const ownsTurnState = live || !this.isStreaming;
1735
1904
  switch (wire.type) {
1736
1905
  case "message_start":
1737
- this.resetStreamingState();
1906
+ if (ownsTurnState) this.resetStreamingState();
1738
1907
  if (wire.model) {
1739
1908
  this.modelDisplayName = wire.model;
1740
1909
  }
1741
1910
  return;
1742
1911
  case "block_start":
1743
- if (wire.kind === "text" && (!wire.parent_path || wire.parent_path.length === 0)) {
1912
+ if (ownsTurnState && wire.kind === "text" && (!wire.parent_path || wire.parent_path.length === 0)) {
1744
1913
  this.currentTextPath = wire.path;
1745
1914
  }
1746
1915
  return;
1747
1916
  case "block_delta":
1748
- if (wire.delta.channel === "text" && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1917
+ if (ownsTurnState && wire.delta.channel === "text" && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1749
1918
  this.accumulatedText += wire.delta.text;
1750
1919
  }
1751
1920
  return;
1752
1921
  case "block_stop":
1753
- if (this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1922
+ if (ownsTurnState && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1754
1923
  this.currentTextPath = null;
1755
1924
  }
1756
1925
  return;
1757
1926
  case "message_stop":
1758
- if (messageId) {
1927
+ if (promptMessageId && this.pendingUserMessages.has(promptMessageId)) {
1928
+ this.pendingUserMessages.set(promptMessageId, ++this.serverRowsKnown);
1929
+ }
1930
+ if (promptMessageId) {
1759
1931
  const assistantMessage = {
1760
- id: messageId,
1932
+ // NOT `promptMessageId` — that is the USER turn's id, which
1933
+ // `consumeJobStream` stamps onto the user row. Sharing it puts two
1934
+ // rows under one id, and `pendingUserMessages` is id-keyed. No
1935
+ // server-assigned assistant id exists on the wire, so this row is
1936
+ // local until a REST load replaces it.
1937
+ id: generateId(),
1761
1938
  conversationId,
1762
1939
  role: "assistant",
1763
1940
  content: this.accumulatedText,
@@ -1768,8 +1945,10 @@ var ChatSession = class {
1768
1945
  this.storage.addMessage(assistantMessage, conversationId).catch(() => {
1769
1946
  });
1770
1947
  }
1771
- this.isStreaming = false;
1772
- this.currentJobId = null;
1948
+ if (ownsTurnState) {
1949
+ this.isStreaming = false;
1950
+ this.currentJobId = null;
1951
+ }
1773
1952
  return;
1774
1953
  case "custom":
1775
1954
  if (wire.name === "title_generated") {
@@ -1803,9 +1982,27 @@ var ChatSession = class {
1803
1982
  * Used before reconnectToJob — SSE replay handles event replay.
1804
1983
  */
1805
1984
  async loadConversation(id) {
1985
+ const load = ++this.loadGeneration;
1806
1986
  this.conversationId = id;
1807
- this.resetStreamingState();
1808
- this.messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
1987
+ if (!this.isStreaming) this.resetStreamingState();
1988
+ const rowsKnownAtIssue = this.serverRowsKnown;
1989
+ const messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
1990
+ if (load !== this.loadGeneration) return;
1991
+ const pending = this.messages.filter(
1992
+ (m) => this.pendingUserMessages.has(m.id) && m.conversationId === id
1993
+ );
1994
+ const stillPending = pending.filter((m) => {
1995
+ if (messages.some((f) => f.id === m.id)) return false;
1996
+ const knownAt = this.pendingUserMessages.get(m.id) ?? 0;
1997
+ return knownAt === 0 || knownAt > rowsKnownAtIssue;
1998
+ });
1999
+ for (const m of pending) {
2000
+ if (!stillPending.includes(m)) this.pendingUserMessages.delete(m.id);
2001
+ }
2002
+ this.setMessages(
2003
+ stillPending.length ? [...messages, ...stillPending] : messages
2004
+ );
2005
+ this.messagesConversationId = id;
1809
2006
  }
1810
2007
  /**
1811
2008
  * Reconnect to a running job's SSE stream (e.g. after page reload).
@@ -1818,7 +2015,8 @@ var ChatSession = class {
1818
2015
  this.lastSeq = -1;
1819
2016
  this.submittedToolCallIds.clear();
1820
2017
  this.resetStreamingState();
1821
- this.abortController = new AbortController();
2018
+ const controller = new AbortController();
2019
+ this.abortController = controller;
1822
2020
  try {
1823
2021
  await this.consumeEventStream(
1824
2022
  jobId,
@@ -1835,8 +2033,10 @@ var ChatSession = class {
1835
2033
  blockPath: null
1836
2034
  });
1837
2035
  } finally {
1838
- this.isStreaming = false;
1839
- this.abortController = null;
2036
+ if (this.abortController === controller) {
2037
+ this.isStreaming = false;
2038
+ this.abortController = null;
2039
+ }
1840
2040
  }
1841
2041
  }
1842
2042
  /** Detach from the SSE stream without cancelling the job. */
@@ -1847,25 +2047,54 @@ var ChatSession = class {
1847
2047
  this.resetStreamingState();
1848
2048
  this.emit({ type: "disconnected" });
1849
2049
  }
1850
- /** Stop the job and disconnect (explicit user action). */
1851
- disconnect() {
2050
+ /**
2051
+ * Cancel the running turn: stop the job server-side and tear the stream
2052
+ * down, WITHOUT ending the session. A turn-level cancel is not a
2053
+ * session-level teardown, so unlike `disconnect()` this leaves the protocol
2054
+ * registry alone — the SDK never auto-registers adapters, so clearing them
2055
+ * on a Stop press would silently kill embedded-resource rendering for the
2056
+ * rest of the session with nothing to re-register it.
2057
+ */
2058
+ cancelTurn() {
1852
2059
  if (this.currentJobId) {
1853
2060
  this.client.cancelJob(this.currentJobId).catch(() => {
1854
2061
  });
1855
2062
  }
1856
2063
  this.detach();
1857
2064
  this.currentJobId = null;
2065
+ for (const [id, knownAt] of this.pendingUserMessages) {
2066
+ if (knownAt === 0) this.pendingUserMessages.delete(id);
2067
+ }
2068
+ }
2069
+ /** Stop the job and end the session's activity (explicit user action). */
2070
+ disconnect() {
2071
+ this.cancelTurn();
1858
2072
  this.protocols.clear();
1859
2073
  }
2074
+ /**
2075
+ * A pointer move happened above this layer, so any `loadConversation` in
2076
+ * flight must lose to it. `StreamManager` owns a pointer of its own and
2077
+ * moves it before this one; without this the two halves would gate on
2078
+ * counters that bump at different instants — `generation` synchronously in
2079
+ * `setActiveConversation`, `loadGeneration` only once the switch's own load
2080
+ * actually runs, which is behind the active-job probe.
2081
+ */
2082
+ invalidateLoadsInFlight() {
2083
+ this.loadGeneration++;
2084
+ }
1860
2085
  async createNewConversation() {
1861
2086
  const id = generateId();
2087
+ const load = this.loadGeneration;
1862
2088
  const conversation = await this.storage.createConversation(
1863
2089
  id,
1864
2090
  "New Conversation"
1865
2091
  );
1866
2092
  this.conversations.unshift(conversation);
2093
+ if (load !== this.loadGeneration) return id;
2094
+ this.loadGeneration++;
1867
2095
  this.conversationId = id;
1868
- this.messages = [];
2096
+ this.setMessages([]);
2097
+ this.messagesConversationId = id;
1869
2098
  return id;
1870
2099
  }
1871
2100
  /**
@@ -1884,7 +2113,7 @@ var ChatSession = class {
1884
2113
  */
1885
2114
  replayTurn(id, events, userMessageContent, userMessageId, isSteer = false) {
1886
2115
  this.conversationId = id;
1887
- this.resetStreamingState();
2116
+ if (!this.isStreaming) this.resetStreamingState();
1888
2117
  if (userMessageContent) {
1889
2118
  this.emit({
1890
2119
  type: "user_message",
@@ -1915,11 +2144,17 @@ var ChatSession = class {
1915
2144
  * job's events.
1916
2145
  */
1917
2146
  async switchConversation(id, jobId) {
1918
- const [messagesResult, eventsResult] = await Promise.allSettled([
1919
- this.client.getMessages(id).catch(() => this.storage.fetchMessages(id)),
2147
+ const loading = this.loadConversation(id);
2148
+ const token = this.loadGeneration;
2149
+ const [loadResult, eventsResult] = await Promise.allSettled([
2150
+ loading,
1920
2151
  this.client.getConversationEvents(id, jobId)
1921
2152
  ]);
1922
- this.messages = messagesResult.status === "fulfilled" ? messagesResult.value : [];
2153
+ if (token !== this.loadGeneration) return;
2154
+ if (loadResult.status === "rejected") {
2155
+ this.setMessages([]);
2156
+ this.messagesConversationId = id;
2157
+ }
1923
2158
  this.replayTurn(
1924
2159
  id,
1925
2160
  eventsResult.status === "fulfilled" ? eventsResult.value : []
@@ -2010,8 +2245,10 @@ var ChatSession = class {
2010
2245
  }
2011
2246
  this.conversations = this.conversations.filter((c) => c.id !== id);
2012
2247
  if (this.conversationId === id) {
2248
+ this.loadGeneration++;
2013
2249
  this.conversationId = null;
2014
- this.messages = [];
2250
+ this.setMessages([]);
2251
+ this.messagesConversationId = null;
2015
2252
  }
2016
2253
  }
2017
2254
  toggleClientTool(name) {
@@ -2027,6 +2264,11 @@ var ChatSession = class {
2027
2264
  // src/restore-plan.ts
2028
2265
  function planRestore(args) {
2029
2266
  const { completedJobs, userMessages } = args;
2267
+ const claimed = new Set(
2268
+ (args.claimedMessageIds ?? completedJobs.map((j) => j.message_id)).filter(
2269
+ (id) => !!id
2270
+ )
2271
+ );
2030
2272
  const byId = /* @__PURE__ */ new Map();
2031
2273
  userMessages.forEach((m, i) => {
2032
2274
  if (m.id) byId.set(m.id, i);
@@ -2048,7 +2290,7 @@ function planRestore(args) {
2048
2290
  userMessages.slice(0, cutover)
2049
2291
  );
2050
2292
  let cursor = cutover;
2051
- const isSteer = (m) => !!m?.id && !completedJobs.some((j) => j.message_id === m.id);
2293
+ const isSteer = (m) => !!m?.id && !claimed.has(m.id);
2052
2294
  const drainTo = (stopAt) => {
2053
2295
  while (cursor < stopAt) {
2054
2296
  const m = userMessages[cursor++];
@@ -2134,6 +2376,12 @@ var StreamManager = class {
2134
2376
  this._backgroundJobs = /* @__PURE__ */ new Map();
2135
2377
  this.handlers = [];
2136
2378
  this.unsub = null;
2379
+ /**
2380
+ * Bumped every time the active conversation moves. An async sequence that
2381
+ * captures it can then tell, at each await boundary, whether it is still the
2382
+ * one the user is waiting on — see ``restore``.
2383
+ */
2384
+ this.generation = 0;
2137
2385
  this.session = session;
2138
2386
  this.attach();
2139
2387
  }
@@ -2184,7 +2432,7 @@ var StreamManager = class {
2184
2432
  event
2185
2433
  });
2186
2434
  if (event.type === ChatEventType.MessageStop) {
2187
- if (this._state === "streaming") {
2435
+ if (this._state === "streaming" && !this.session.isStreaming) {
2188
2436
  this.setState("idle");
2189
2437
  }
2190
2438
  }
@@ -2197,13 +2445,21 @@ var StreamManager = class {
2197
2445
  );
2198
2446
  }
2199
2447
  if (this._state === "streaming") return;
2200
- if (!this._activeConversationId) {
2201
- const id = await this.session.createNewConversation();
2202
- this.setActiveConversation(id);
2448
+ let target = this._activeConversationId;
2449
+ if (!target) {
2450
+ target = await this.session.createNewConversation();
2451
+ this.setActiveConversation(target);
2203
2452
  }
2204
2453
  this.setState("streaming");
2205
2454
  try {
2206
2455
  await this.session.send(content, {
2456
+ // Address the send explicitly. `ChatSession.send` otherwise falls back
2457
+ // to `session.conversationId`, which LAGS this pointer: a restore
2458
+ // assigns it synchronously but only reaches the next switch's own
2459
+ // `loadConversation` an await later, so between the two the session
2460
+ // still names the conversation the user left. The manager's pointer
2461
+ // moved the moment the user clicked; it is the authority.
2462
+ conversationId: target ?? void 0,
2207
2463
  agentName: options?.agentName,
2208
2464
  uploadIds: options?.uploadIds,
2209
2465
  planMode: options?.planMode,
@@ -2222,6 +2478,9 @@ var StreamManager = class {
2222
2478
  // ── Regenerate ────────────────────────────────────────────────
2223
2479
  async regenerate() {
2224
2480
  if (this._state === "streaming") return;
2481
+ if (this.session.messagesConversationId !== this._activeConversationId) {
2482
+ return;
2483
+ }
2225
2484
  const userMsgs = this.session.messages.filter(
2226
2485
  (m) => m.role === "user"
2227
2486
  );
@@ -2258,44 +2517,78 @@ var StreamManager = class {
2258
2517
  async switchTo(conversationId, opts) {
2259
2518
  if (conversationId === this._activeConversationId) return;
2260
2519
  const targetHadBackgroundJob = this._backgroundJobs.has(conversationId);
2261
- if (this._state === "streaming") {
2262
- const oldConvId = this._activeConversationId;
2263
- const jobId = this.session.currentJobId;
2264
- if (oldConvId && jobId) {
2265
- this._backgroundJobs.set(oldConvId, jobId);
2266
- this.emit({
2267
- type: "backgroundJobsChanged",
2268
- jobs: this._backgroundJobs
2269
- });
2270
- }
2271
- this.session.detach();
2272
- }
2273
- if (this._backgroundJobs.has(conversationId)) {
2520
+ this.detachStreamingTurn();
2521
+ const parkedJobId = this._backgroundJobs.get(conversationId);
2522
+ if (parkedJobId !== void 0) {
2274
2523
  this._backgroundJobs.delete(conversationId);
2275
2524
  this.emit({
2276
2525
  type: "backgroundJobsChanged",
2277
2526
  jobs: this._backgroundJobs
2278
2527
  });
2279
2528
  }
2280
- this.setActiveConversation(conversationId);
2529
+ const gen = this.setActiveConversation(conversationId);
2281
2530
  if (opts?.skipHistoryReplay && !targetHadBackgroundJob) {
2282
2531
  let activeJobId = null;
2283
2532
  try {
2284
2533
  activeJobId = (await this.session.client.getActiveJob(conversationId)).jobId;
2285
2534
  } catch {
2286
2535
  }
2536
+ if (gen !== this.generation) return;
2287
2537
  if (!activeJobId) {
2288
- await this.session.loadConversation(conversationId);
2289
- this.setState("idle");
2538
+ try {
2539
+ await this.session.loadConversation(conversationId);
2540
+ } finally {
2541
+ if (gen === this.generation) this.settleIdle();
2542
+ }
2290
2543
  return;
2291
2544
  }
2292
2545
  }
2293
- await this.restore(conversationId);
2546
+ let tookOver = false;
2547
+ try {
2548
+ await this.restore(conversationId, gen);
2549
+ tookOver = gen === this.generation;
2550
+ } finally {
2551
+ const supersededOntoSame = gen !== this.generation && this._activeConversationId === conversationId;
2552
+ const stillExists = this.session.conversations.some(
2553
+ (c) => c.id === conversationId
2554
+ );
2555
+ if (parkedJobId !== void 0 && !tookOver && !supersededOntoSame && stillExists) {
2556
+ this._backgroundJobs.set(conversationId, parkedJobId);
2557
+ this.emit({
2558
+ type: "backgroundJobsChanged",
2559
+ jobs: this._backgroundJobs
2560
+ });
2561
+ }
2562
+ if (!tookOver && gen === this.generation && this._state === "restoring") {
2563
+ this.settleIdle();
2564
+ }
2565
+ }
2294
2566
  }
2295
2567
  // ── Create / rename / delete conversation ─────────────────────
2568
+ /**
2569
+ * Create a conversation and make it active.
2570
+ *
2571
+ * The returned id is NOT guaranteed to be the active conversation: if a
2572
+ * switch lands inside the storage round-trip, this declines the pointer move
2573
+ * so the newer one wins, and no `conversationChanged` fires for the new id.
2574
+ * A caller that routes on the return value should `switchTo(id)` rather than
2575
+ * assume it is current — that call is not a no-op in the declined case.
2576
+ */
2296
2577
  async createConversation() {
2297
- const id = await this.session.createNewConversation();
2578
+ this.detachStreamingTurn();
2579
+ let id;
2580
+ try {
2581
+ id = await this.session.createNewConversation();
2582
+ } catch (err) {
2583
+ this.settleIdle();
2584
+ throw err;
2585
+ }
2586
+ if (this.session.conversationId !== id) {
2587
+ this.settleIdle();
2588
+ return id;
2589
+ }
2298
2590
  this.setActiveConversation(id);
2591
+ this.settleIdle();
2299
2592
  return id;
2300
2593
  }
2301
2594
  /**
@@ -2306,16 +2599,34 @@ var StreamManager = class {
2306
2599
  await this.session.renameConversation(id, title);
2307
2600
  }
2308
2601
  async deleteConversation(id) {
2309
- await this.session.deleteConversation(id);
2310
- this._backgroundJobs.delete(id);
2602
+ const wasActive = this._activeConversationId === id;
2603
+ const cancelled = wasActive && this._state === "streaming";
2604
+ if (cancelled) {
2605
+ this.session.cancelTurn();
2606
+ this._state = "idle";
2607
+ }
2608
+ try {
2609
+ await this.session.deleteConversation(id);
2610
+ } catch (err) {
2611
+ if (cancelled) this.setState("idle");
2612
+ throw err;
2613
+ }
2311
2614
  if (this._activeConversationId === id) {
2312
- this._activeConversationId = null;
2313
- this.emit({ type: "conversationChanged", conversationId: null });
2615
+ this.setActiveConversation(null);
2616
+ this.settleIdle();
2617
+ }
2618
+ const parkedJobId = this._backgroundJobs.get(id);
2619
+ if (this._backgroundJobs.delete(id)) {
2620
+ if (parkedJobId) {
2621
+ this.session.client.cancelJob(parkedJobId).catch(() => {
2622
+ });
2623
+ }
2624
+ this.emit({ type: "backgroundJobsChanged", jobs: this._backgroundJobs });
2314
2625
  }
2315
2626
  }
2316
2627
  // ── Stop (explicit cancel) ────────────────────────────────────
2317
2628
  stop() {
2318
- this.session.disconnect();
2629
+ this.session.cancelTurn();
2319
2630
  this.setState("idle");
2320
2631
  }
2321
2632
  // ── Cleanup ───────────────────────────────────────────────────
@@ -2327,34 +2638,80 @@ var StreamManager = class {
2327
2638
  this.handlers = [];
2328
2639
  }
2329
2640
  // ── Internal: helpers ──────────────────────────────────────────
2641
+ /**
2642
+ * Park a streaming turn as a background job and detach from its SSE stream.
2643
+ *
2644
+ * Every method that relocates the active conversation has to do this before
2645
+ * announcing a new state. Announcing `idle` while `session.isStreaming` is
2646
+ * still true is worse than announcing nothing: `manager.send` no longer bails
2647
+ * on the streaming state, calls `session.send`, and THAT bails on its own
2648
+ * `isStreaming` — so the message is never posted, no error is emitted, and
2649
+ * the composer looks ready the whole time.
2650
+ */
2651
+ detachStreamingTurn() {
2652
+ if (this._state !== "streaming") return;
2653
+ const oldConvId = this._activeConversationId;
2654
+ const jobId = this.session.currentJobId;
2655
+ if (oldConvId && jobId) {
2656
+ this._backgroundJobs.set(oldConvId, jobId);
2657
+ this.emit({ type: "backgroundJobsChanged", jobs: this._backgroundJobs });
2658
+ }
2659
+ this.session.detach();
2660
+ this.session.currentJobId = null;
2661
+ this._state = "idle";
2662
+ }
2663
+ /**
2664
+ * Announce `idle` unless a turn is actually streaming.
2665
+ *
2666
+ * A `send` can land inside any of the switch paths — the fast path most
2667
+ * easily, since it deliberately stays out of `restoring` and so leaves the
2668
+ * composer live for the whole probe. `send` sets `streaming` and does not
2669
+ * bump the generation, so the path resumes, passes its supersession check,
2670
+ * and would announce a ready composer over a running stream. From there
2671
+ * `finalizeStream` and the `message_stop` branch both no-op (they only act
2672
+ * on `streaming`), so it stays `idle` for the whole turn — and the next send
2673
+ * reaches `session.send`, which bails on its own `isStreaming`: message
2674
+ * never posted, no error, composer ready throughout.
2675
+ */
2676
+ settleIdle() {
2677
+ if (this._state === "streaming") return;
2678
+ this.setState("idle");
2679
+ }
2330
2680
  finalizeStream() {
2331
2681
  if (this._state === "streaming") {
2332
2682
  this.setState("idle");
2333
2683
  }
2334
2684
  }
2335
2685
  // ── Internal: restore ─────────────────────────────────────────
2336
- async restore(conversationId) {
2337
- this.setState("restoring");
2686
+ async restore(conversationId, gen) {
2687
+ const superseded = () => gen !== this.generation;
2688
+ if (superseded()) return;
2689
+ if (!this.session.isStreaming) this.setState("restoring");
2338
2690
  let activeJobId = null;
2339
2691
  try {
2340
2692
  const res = await this.session.client.getActiveJob(conversationId);
2341
2693
  activeJobId = res.jobId;
2342
2694
  } catch {
2343
2695
  }
2696
+ if (superseded()) return;
2344
2697
  if (activeJobId) {
2345
2698
  await this.session.loadConversation(conversationId);
2699
+ if (superseded()) return;
2346
2700
  this.setState("streaming");
2347
2701
  try {
2348
2702
  await this.session.reconnectToJob(activeJobId);
2349
2703
  } catch {
2350
2704
  }
2351
- if (this._state === "streaming") {
2705
+ if (superseded()) return;
2706
+ if (this._state === "streaming" && !this.session.isStreaming) {
2352
2707
  this.setState("idle");
2353
2708
  }
2354
2709
  } else {
2355
2710
  await this.session.loadConversation(conversationId);
2711
+ if (superseded()) return;
2356
2712
  try {
2357
2713
  const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2714
+ if (superseded()) return;
2358
2715
  const completedJobs = jobs.filter(
2359
2716
  (j) => j.status === "completed"
2360
2717
  );
@@ -2366,6 +2723,14 @@ var StreamManager = class {
2366
2723
  job_id: j.job_id,
2367
2724
  message_id: j.message_id
2368
2725
  })),
2726
+ // Completed jobs PLUS the ones still going. A send landing in the
2727
+ // probe window has a running job, so its prompt is claimed and does
2728
+ // not read as a steer replayed over the bubble the live send already
2729
+ // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2730
+ // they produce no `turn` step, so claiming them would delete the
2731
+ // user's prompt from the restore entirely rather than show it as a
2732
+ // steer.
2733
+ claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2369
2734
  userMessages: userMessages.map((m) => ({
2370
2735
  id: m.id,
2371
2736
  content: m.content
@@ -2376,10 +2741,12 @@ var StreamManager = class {
2376
2741
  (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2377
2742
  )
2378
2743
  );
2744
+ if (superseded()) return;
2379
2745
  const eventsByJobId = new Map(
2380
2746
  completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2381
2747
  );
2382
2748
  for (const step of plan) {
2749
+ if (superseded()) return;
2383
2750
  if (step.kind === "steer") {
2384
2751
  this.session.replayTurn(
2385
2752
  conversationId,
@@ -2397,6 +2764,7 @@ var StreamManager = class {
2397
2764
  step.messageId
2398
2765
  );
2399
2766
  }
2767
+ if (superseded()) return;
2400
2768
  if (completedJobs.length > 0) {
2401
2769
  this.emit({
2402
2770
  type: "versionsReady",
@@ -2406,13 +2774,17 @@ var StreamManager = class {
2406
2774
  }
2407
2775
  } catch {
2408
2776
  }
2409
- this.setState("idle");
2777
+ if (superseded()) return;
2778
+ this.settleIdle();
2410
2779
  }
2411
2780
  }
2412
2781
  // ── Internal: set active conversation ─────────────────────────
2413
2782
  setActiveConversation(id) {
2414
2783
  this._activeConversationId = id;
2784
+ this.session.invalidateLoadsInFlight();
2785
+ const claimed = ++this.generation;
2415
2786
  this.emit({ type: "conversationChanged", conversationId: id });
2787
+ return claimed;
2416
2788
  }
2417
2789
  };
2418
2790