@astralform/js 4.9.1 → 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
@@ -1376,6 +1376,17 @@ var ChatSession = class {
1376
1376
  /** True while ``loadMoreConversations`` is in flight. */
1377
1377
  this.isLoadingConversations = false;
1378
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;
1379
1390
  this.isStreaming = false;
1380
1391
  this.agentStatus = null;
1381
1392
  this.agents = [];
@@ -1408,6 +1419,43 @@ var ChatSession = class {
1408
1419
  * is discarded instead.
1409
1420
  */
1410
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;
1411
1459
  // Minimal in-session accumulation for the assistant message record.
1412
1460
  // Only top-level ``text`` blocks contribute; subagent / tool output
1413
1461
  // is tracked by the consumer's own block store.
@@ -1430,6 +1478,19 @@ var ChatSession = class {
1430
1478
  this.toolRegistry = new ToolRegistry();
1431
1479
  this.storage = storage ?? new InMemoryStorage();
1432
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
+ }
1433
1494
  on(handler) {
1434
1495
  this.handlers.add(handler);
1435
1496
  return () => {
@@ -1478,6 +1539,22 @@ var ChatSession = class {
1478
1539
  }
1479
1540
  if (this.isStreaming) return;
1480
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
+ }
1481
1558
  const userMessage = {
1482
1559
  id: generateId(),
1483
1560
  conversationId: conversationId ?? "",
@@ -1487,9 +1564,11 @@ var ChatSession = class {
1487
1564
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1488
1565
  };
1489
1566
  if (conversationId) {
1490
- await this.storage.addMessage(userMessage, conversationId);
1567
+ await this.storage.addMessage(userMessage, conversationId).catch(() => {
1568
+ });
1491
1569
  }
1492
1570
  this.messages.push(userMessage);
1571
+ this.pendingUserMessages.set(userMessage.id, 0);
1493
1572
  const request = {
1494
1573
  message: content,
1495
1574
  conversation_id: conversationId,
@@ -1509,7 +1588,24 @@ var ChatSession = class {
1509
1588
  reasoning_effort: options?.reasoningEffort,
1510
1589
  temperature: options?.temperature
1511
1590
  };
1512
- 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
+ }
1513
1609
  }
1514
1610
  async resendFromCheckpoint(messageId, newContent) {
1515
1611
  if (this.isStreaming) return;
@@ -1526,12 +1622,13 @@ var ChatSession = class {
1526
1622
  this.accumulatedText = "";
1527
1623
  this.currentTextPath = null;
1528
1624
  }
1529
- async processStream(request) {
1625
+ async processStream(request, wire) {
1530
1626
  this.isStreaming = true;
1531
1627
  this.resetStreamingState();
1532
- this.abortController = new AbortController();
1628
+ const controller = new AbortController();
1629
+ this.abortController = controller;
1533
1630
  try {
1534
- await this.consumeJobStream(request);
1631
+ await this.consumeJobStream(request, wire);
1535
1632
  } catch (err) {
1536
1633
  if (!(err instanceof DOMException && err.name === "AbortError")) {
1537
1634
  this.emit({
@@ -1542,16 +1639,20 @@ var ChatSession = class {
1542
1639
  });
1543
1640
  }
1544
1641
  } finally {
1545
- this.isStreaming = false;
1546
- this.abortController = null;
1642
+ if (this.abortController === controller) {
1643
+ this.isStreaming = false;
1644
+ this.abortController = null;
1645
+ }
1547
1646
  }
1548
1647
  }
1549
- async consumeJobStream(request) {
1648
+ async consumeJobStream(request, wire) {
1550
1649
  const job = await this.client.createJob(request);
1650
+ if (wire) wire.reached = true;
1551
1651
  this.currentJobId = job.job_id;
1552
1652
  const conversationId = job.conversation_id;
1553
1653
  if (!this.conversationId) {
1554
1654
  this.conversationId = conversationId;
1655
+ this.messagesConversationId = conversationId;
1555
1656
  }
1556
1657
  if (!this.conversations.some((c) => c.id === conversationId)) {
1557
1658
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1572,13 +1673,29 @@ var ChatSession = class {
1572
1673
  await this.storage.addMessage(lastMsg, conversationId).catch(() => {
1573
1674
  });
1574
1675
  }
1575
- 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
+ }
1576
1693
  this.lastSeq = -1;
1577
1694
  this.submittedToolCallIds.clear();
1578
1695
  await this.consumeEventStream(
1579
1696
  job.job_id,
1580
1697
  conversationId,
1581
- messageId,
1698
+ promptMessageId,
1582
1699
  true
1583
1700
  // executeClientTools
1584
1701
  );
@@ -1587,7 +1704,7 @@ var ChatSession = class {
1587
1704
  * Shared event consumption loop. Parses each wire event, updates
1588
1705
  * minimal session state, and emits typed ChatEvents to consumers.
1589
1706
  */
1590
- async consumeEventStream(jobId, conversationId, messageId, executeClientTools) {
1707
+ async consumeEventStream(jobId, conversationId, promptMessageId, executeClientTools) {
1591
1708
  const signal = this.abortController?.signal;
1592
1709
  for (let attempt = 0; ; attempt++) {
1593
1710
  if (signal?.aborted) return;
@@ -1604,7 +1721,7 @@ var ChatSession = class {
1604
1721
  sawTerminal = await this.pumpStream(
1605
1722
  stream,
1606
1723
  conversationId,
1607
- messageId,
1724
+ promptMessageId,
1608
1725
  executeClientTools,
1609
1726
  () => attemptController.abort()
1610
1727
  );
@@ -1636,7 +1753,7 @@ var ChatSession = class {
1636
1753
  * zombie — ``reader.read()`` will never settle — so we kill the fetch and
1637
1754
  * throw a ConnectionError, feeding the caller's reconnect-from-lastSeq loop.
1638
1755
  */
1639
- async pumpStream(stream, conversationId, messageId, executeClientTools, onStall) {
1756
+ async pumpStream(stream, conversationId, promptMessageId, executeClientTools, onStall) {
1640
1757
  let sawTerminal = false;
1641
1758
  const iterator = stream[Symbol.asyncIterator]();
1642
1759
  try {
@@ -1683,7 +1800,7 @@ var ChatSession = class {
1683
1800
  await this.dispatchWireEvent(
1684
1801
  parsed,
1685
1802
  conversationId,
1686
- messageId,
1803
+ promptMessageId,
1687
1804
  executeClientTools
1688
1805
  );
1689
1806
  }
@@ -1725,8 +1842,8 @@ var ChatSession = class {
1725
1842
  }
1726
1843
  }
1727
1844
  }
1728
- async dispatchWireEvent(wire, conversationId, messageId, executeClientTools) {
1729
- this.applyWireSideEffects(wire, conversationId, messageId);
1845
+ async dispatchWireEvent(wire, conversationId, promptMessageId, executeClientTools) {
1846
+ this.applyWireSideEffects(wire, conversationId, promptMessageId, true);
1730
1847
  const event = translateWireEvent(wire);
1731
1848
  if (event) {
1732
1849
  this.emit(event);
@@ -1744,7 +1861,9 @@ var ChatSession = class {
1744
1861
  const results = await this.executeClientTools([request]);
1745
1862
  await this.submitToolResultWithRetry({
1746
1863
  conversation_id: conversationId,
1747
- 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,
1748
1867
  tool_results: results
1749
1868
  });
1750
1869
  this.submittedToolCallIds.add(callId);
@@ -1759,7 +1878,7 @@ var ChatSession = class {
1759
1878
  * instead of re-typing the whole conversation event by event.
1760
1879
  */
1761
1880
  replayWireEvent(wire, conversationId) {
1762
- this.applyWireSideEffects(wire, conversationId, "");
1881
+ this.applyWireSideEffects(wire, conversationId, "", false);
1763
1882
  const event = translateWireEvent(wire);
1764
1883
  if (event) {
1765
1884
  this.emit(event);
@@ -1770,37 +1889,52 @@ var ChatSession = class {
1770
1889
  * the pure wire → ChatEvent mapping can live in translate.ts and be reused
1771
1890
  * by the replay path.
1772
1891
  *
1773
- * ``messageId`` is the server-assigned assistant message id for the current
1774
- * turn; empty in the reconnect and conversation-switch replay paths where
1775
- * 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.
1776
1901
  */
1777
- applyWireSideEffects(wire, conversationId, messageId) {
1902
+ applyWireSideEffects(wire, conversationId, promptMessageId, live) {
1903
+ const ownsTurnState = live || !this.isStreaming;
1778
1904
  switch (wire.type) {
1779
1905
  case "message_start":
1780
- this.resetStreamingState();
1906
+ if (ownsTurnState) this.resetStreamingState();
1781
1907
  if (wire.model) {
1782
1908
  this.modelDisplayName = wire.model;
1783
1909
  }
1784
1910
  return;
1785
1911
  case "block_start":
1786
- 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)) {
1787
1913
  this.currentTextPath = wire.path;
1788
1914
  }
1789
1915
  return;
1790
1916
  case "block_delta":
1791
- 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)) {
1792
1918
  this.accumulatedText += wire.delta.text;
1793
1919
  }
1794
1920
  return;
1795
1921
  case "block_stop":
1796
- if (this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1922
+ if (ownsTurnState && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1797
1923
  this.currentTextPath = null;
1798
1924
  }
1799
1925
  return;
1800
1926
  case "message_stop":
1801
- if (messageId) {
1927
+ if (promptMessageId && this.pendingUserMessages.has(promptMessageId)) {
1928
+ this.pendingUserMessages.set(promptMessageId, ++this.serverRowsKnown);
1929
+ }
1930
+ if (promptMessageId) {
1802
1931
  const assistantMessage = {
1803
- 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(),
1804
1938
  conversationId,
1805
1939
  role: "assistant",
1806
1940
  content: this.accumulatedText,
@@ -1811,8 +1945,10 @@ var ChatSession = class {
1811
1945
  this.storage.addMessage(assistantMessage, conversationId).catch(() => {
1812
1946
  });
1813
1947
  }
1814
- this.isStreaming = false;
1815
- this.currentJobId = null;
1948
+ if (ownsTurnState) {
1949
+ this.isStreaming = false;
1950
+ this.currentJobId = null;
1951
+ }
1816
1952
  return;
1817
1953
  case "custom":
1818
1954
  if (wire.name === "title_generated") {
@@ -1846,9 +1982,27 @@ var ChatSession = class {
1846
1982
  * Used before reconnectToJob — SSE replay handles event replay.
1847
1983
  */
1848
1984
  async loadConversation(id) {
1985
+ const load = ++this.loadGeneration;
1849
1986
  this.conversationId = id;
1850
- this.resetStreamingState();
1851
- 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;
1852
2006
  }
1853
2007
  /**
1854
2008
  * Reconnect to a running job's SSE stream (e.g. after page reload).
@@ -1861,7 +2015,8 @@ var ChatSession = class {
1861
2015
  this.lastSeq = -1;
1862
2016
  this.submittedToolCallIds.clear();
1863
2017
  this.resetStreamingState();
1864
- this.abortController = new AbortController();
2018
+ const controller = new AbortController();
2019
+ this.abortController = controller;
1865
2020
  try {
1866
2021
  await this.consumeEventStream(
1867
2022
  jobId,
@@ -1878,8 +2033,10 @@ var ChatSession = class {
1878
2033
  blockPath: null
1879
2034
  });
1880
2035
  } finally {
1881
- this.isStreaming = false;
1882
- this.abortController = null;
2036
+ if (this.abortController === controller) {
2037
+ this.isStreaming = false;
2038
+ this.abortController = null;
2039
+ }
1883
2040
  }
1884
2041
  }
1885
2042
  /** Detach from the SSE stream without cancelling the job. */
@@ -1890,25 +2047,54 @@ var ChatSession = class {
1890
2047
  this.resetStreamingState();
1891
2048
  this.emit({ type: "disconnected" });
1892
2049
  }
1893
- /** Stop the job and disconnect (explicit user action). */
1894
- 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() {
1895
2059
  if (this.currentJobId) {
1896
2060
  this.client.cancelJob(this.currentJobId).catch(() => {
1897
2061
  });
1898
2062
  }
1899
2063
  this.detach();
1900
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();
1901
2072
  this.protocols.clear();
1902
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
+ }
1903
2085
  async createNewConversation() {
1904
2086
  const id = generateId();
2087
+ const load = this.loadGeneration;
1905
2088
  const conversation = await this.storage.createConversation(
1906
2089
  id,
1907
2090
  "New Conversation"
1908
2091
  );
1909
2092
  this.conversations.unshift(conversation);
2093
+ if (load !== this.loadGeneration) return id;
2094
+ this.loadGeneration++;
1910
2095
  this.conversationId = id;
1911
- this.messages = [];
2096
+ this.setMessages([]);
2097
+ this.messagesConversationId = id;
1912
2098
  return id;
1913
2099
  }
1914
2100
  /**
@@ -1927,7 +2113,7 @@ var ChatSession = class {
1927
2113
  */
1928
2114
  replayTurn(id, events, userMessageContent, userMessageId, isSteer = false) {
1929
2115
  this.conversationId = id;
1930
- this.resetStreamingState();
2116
+ if (!this.isStreaming) this.resetStreamingState();
1931
2117
  if (userMessageContent) {
1932
2118
  this.emit({
1933
2119
  type: "user_message",
@@ -1958,11 +2144,17 @@ var ChatSession = class {
1958
2144
  * job's events.
1959
2145
  */
1960
2146
  async switchConversation(id, jobId) {
1961
- const [messagesResult, eventsResult] = await Promise.allSettled([
1962
- 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,
1963
2151
  this.client.getConversationEvents(id, jobId)
1964
2152
  ]);
1965
- 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
+ }
1966
2158
  this.replayTurn(
1967
2159
  id,
1968
2160
  eventsResult.status === "fulfilled" ? eventsResult.value : []
@@ -2053,8 +2245,10 @@ var ChatSession = class {
2053
2245
  }
2054
2246
  this.conversations = this.conversations.filter((c) => c.id !== id);
2055
2247
  if (this.conversationId === id) {
2248
+ this.loadGeneration++;
2056
2249
  this.conversationId = null;
2057
- this.messages = [];
2250
+ this.setMessages([]);
2251
+ this.messagesConversationId = null;
2058
2252
  }
2059
2253
  }
2060
2254
  toggleClientTool(name) {
@@ -2070,6 +2264,11 @@ var ChatSession = class {
2070
2264
  // src/restore-plan.ts
2071
2265
  function planRestore(args) {
2072
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
+ );
2073
2272
  const byId = /* @__PURE__ */ new Map();
2074
2273
  userMessages.forEach((m, i) => {
2075
2274
  if (m.id) byId.set(m.id, i);
@@ -2091,7 +2290,7 @@ function planRestore(args) {
2091
2290
  userMessages.slice(0, cutover)
2092
2291
  );
2093
2292
  let cursor = cutover;
2094
- const isSteer = (m) => !!m?.id && !completedJobs.some((j) => j.message_id === m.id);
2293
+ const isSteer = (m) => !!m?.id && !claimed.has(m.id);
2095
2294
  const drainTo = (stopAt) => {
2096
2295
  while (cursor < stopAt) {
2097
2296
  const m = userMessages[cursor++];
@@ -2177,6 +2376,12 @@ var StreamManager = class {
2177
2376
  this._backgroundJobs = /* @__PURE__ */ new Map();
2178
2377
  this.handlers = [];
2179
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;
2180
2385
  this.session = session;
2181
2386
  this.attach();
2182
2387
  }
@@ -2227,7 +2432,7 @@ var StreamManager = class {
2227
2432
  event
2228
2433
  });
2229
2434
  if (event.type === ChatEventType.MessageStop) {
2230
- if (this._state === "streaming") {
2435
+ if (this._state === "streaming" && !this.session.isStreaming) {
2231
2436
  this.setState("idle");
2232
2437
  }
2233
2438
  }
@@ -2240,13 +2445,21 @@ var StreamManager = class {
2240
2445
  );
2241
2446
  }
2242
2447
  if (this._state === "streaming") return;
2243
- if (!this._activeConversationId) {
2244
- const id = await this.session.createNewConversation();
2245
- this.setActiveConversation(id);
2448
+ let target = this._activeConversationId;
2449
+ if (!target) {
2450
+ target = await this.session.createNewConversation();
2451
+ this.setActiveConversation(target);
2246
2452
  }
2247
2453
  this.setState("streaming");
2248
2454
  try {
2249
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,
2250
2463
  agentName: options?.agentName,
2251
2464
  uploadIds: options?.uploadIds,
2252
2465
  planMode: options?.planMode,
@@ -2265,6 +2478,9 @@ var StreamManager = class {
2265
2478
  // ── Regenerate ────────────────────────────────────────────────
2266
2479
  async regenerate() {
2267
2480
  if (this._state === "streaming") return;
2481
+ if (this.session.messagesConversationId !== this._activeConversationId) {
2482
+ return;
2483
+ }
2268
2484
  const userMsgs = this.session.messages.filter(
2269
2485
  (m) => m.role === "user"
2270
2486
  );
@@ -2301,44 +2517,78 @@ var StreamManager = class {
2301
2517
  async switchTo(conversationId, opts) {
2302
2518
  if (conversationId === this._activeConversationId) return;
2303
2519
  const targetHadBackgroundJob = this._backgroundJobs.has(conversationId);
2304
- if (this._state === "streaming") {
2305
- const oldConvId = this._activeConversationId;
2306
- const jobId = this.session.currentJobId;
2307
- if (oldConvId && jobId) {
2308
- this._backgroundJobs.set(oldConvId, jobId);
2309
- this.emit({
2310
- type: "backgroundJobsChanged",
2311
- jobs: this._backgroundJobs
2312
- });
2313
- }
2314
- this.session.detach();
2315
- }
2316
- if (this._backgroundJobs.has(conversationId)) {
2520
+ this.detachStreamingTurn();
2521
+ const parkedJobId = this._backgroundJobs.get(conversationId);
2522
+ if (parkedJobId !== void 0) {
2317
2523
  this._backgroundJobs.delete(conversationId);
2318
2524
  this.emit({
2319
2525
  type: "backgroundJobsChanged",
2320
2526
  jobs: this._backgroundJobs
2321
2527
  });
2322
2528
  }
2323
- this.setActiveConversation(conversationId);
2529
+ const gen = this.setActiveConversation(conversationId);
2324
2530
  if (opts?.skipHistoryReplay && !targetHadBackgroundJob) {
2325
2531
  let activeJobId = null;
2326
2532
  try {
2327
2533
  activeJobId = (await this.session.client.getActiveJob(conversationId)).jobId;
2328
2534
  } catch {
2329
2535
  }
2536
+ if (gen !== this.generation) return;
2330
2537
  if (!activeJobId) {
2331
- await this.session.loadConversation(conversationId);
2332
- this.setState("idle");
2538
+ try {
2539
+ await this.session.loadConversation(conversationId);
2540
+ } finally {
2541
+ if (gen === this.generation) this.settleIdle();
2542
+ }
2333
2543
  return;
2334
2544
  }
2335
2545
  }
2336
- 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
+ }
2337
2566
  }
2338
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
+ */
2339
2577
  async createConversation() {
2340
- 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
+ }
2341
2590
  this.setActiveConversation(id);
2591
+ this.settleIdle();
2342
2592
  return id;
2343
2593
  }
2344
2594
  /**
@@ -2349,16 +2599,34 @@ var StreamManager = class {
2349
2599
  await this.session.renameConversation(id, title);
2350
2600
  }
2351
2601
  async deleteConversation(id) {
2352
- await this.session.deleteConversation(id);
2353
- 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
+ }
2354
2614
  if (this._activeConversationId === id) {
2355
- this._activeConversationId = null;
2356
- 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 });
2357
2625
  }
2358
2626
  }
2359
2627
  // ── Stop (explicit cancel) ────────────────────────────────────
2360
2628
  stop() {
2361
- this.session.disconnect();
2629
+ this.session.cancelTurn();
2362
2630
  this.setState("idle");
2363
2631
  }
2364
2632
  // ── Cleanup ───────────────────────────────────────────────────
@@ -2370,34 +2638,80 @@ var StreamManager = class {
2370
2638
  this.handlers = [];
2371
2639
  }
2372
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
+ }
2373
2680
  finalizeStream() {
2374
2681
  if (this._state === "streaming") {
2375
2682
  this.setState("idle");
2376
2683
  }
2377
2684
  }
2378
2685
  // ── Internal: restore ─────────────────────────────────────────
2379
- async restore(conversationId) {
2380
- 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");
2381
2690
  let activeJobId = null;
2382
2691
  try {
2383
2692
  const res = await this.session.client.getActiveJob(conversationId);
2384
2693
  activeJobId = res.jobId;
2385
2694
  } catch {
2386
2695
  }
2696
+ if (superseded()) return;
2387
2697
  if (activeJobId) {
2388
2698
  await this.session.loadConversation(conversationId);
2699
+ if (superseded()) return;
2389
2700
  this.setState("streaming");
2390
2701
  try {
2391
2702
  await this.session.reconnectToJob(activeJobId);
2392
2703
  } catch {
2393
2704
  }
2394
- if (this._state === "streaming") {
2705
+ if (superseded()) return;
2706
+ if (this._state === "streaming" && !this.session.isStreaming) {
2395
2707
  this.setState("idle");
2396
2708
  }
2397
2709
  } else {
2398
2710
  await this.session.loadConversation(conversationId);
2711
+ if (superseded()) return;
2399
2712
  try {
2400
2713
  const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2714
+ if (superseded()) return;
2401
2715
  const completedJobs = jobs.filter(
2402
2716
  (j) => j.status === "completed"
2403
2717
  );
@@ -2409,6 +2723,14 @@ var StreamManager = class {
2409
2723
  job_id: j.job_id,
2410
2724
  message_id: j.message_id
2411
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),
2412
2734
  userMessages: userMessages.map((m) => ({
2413
2735
  id: m.id,
2414
2736
  content: m.content
@@ -2419,10 +2741,12 @@ var StreamManager = class {
2419
2741
  (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2420
2742
  )
2421
2743
  );
2744
+ if (superseded()) return;
2422
2745
  const eventsByJobId = new Map(
2423
2746
  completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2424
2747
  );
2425
2748
  for (const step of plan) {
2749
+ if (superseded()) return;
2426
2750
  if (step.kind === "steer") {
2427
2751
  this.session.replayTurn(
2428
2752
  conversationId,
@@ -2440,6 +2764,7 @@ var StreamManager = class {
2440
2764
  step.messageId
2441
2765
  );
2442
2766
  }
2767
+ if (superseded()) return;
2443
2768
  if (completedJobs.length > 0) {
2444
2769
  this.emit({
2445
2770
  type: "versionsReady",
@@ -2449,13 +2774,17 @@ var StreamManager = class {
2449
2774
  }
2450
2775
  } catch {
2451
2776
  }
2452
- this.setState("idle");
2777
+ if (superseded()) return;
2778
+ this.settleIdle();
2453
2779
  }
2454
2780
  }
2455
2781
  // ── Internal: set active conversation ─────────────────────────
2456
2782
  setActiveConversation(id) {
2457
2783
  this._activeConversationId = id;
2784
+ this.session.invalidateLoadsInFlight();
2785
+ const claimed = ++this.generation;
2458
2786
  this.emit({ type: "conversationChanged", conversationId: id });
2787
+ return claimed;
2459
2788
  }
2460
2789
  };
2461
2790