@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.js CHANGED
@@ -1329,6 +1329,17 @@ var ChatSession = class {
1329
1329
  /** True while ``loadMoreConversations`` is in flight. */
1330
1330
  this.isLoadingConversations = false;
1331
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;
1332
1343
  this.isStreaming = false;
1333
1344
  this.agentStatus = null;
1334
1345
  this.agents = [];
@@ -1361,6 +1372,43 @@ var ChatSession = class {
1361
1372
  * is discarded instead.
1362
1373
  */
1363
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;
1364
1412
  // Minimal in-session accumulation for the assistant message record.
1365
1413
  // Only top-level ``text`` blocks contribute; subagent / tool output
1366
1414
  // is tracked by the consumer's own block store.
@@ -1383,6 +1431,19 @@ var ChatSession = class {
1383
1431
  this.toolRegistry = new ToolRegistry();
1384
1432
  this.storage = storage ?? new InMemoryStorage();
1385
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
+ }
1386
1447
  on(handler) {
1387
1448
  this.handlers.add(handler);
1388
1449
  return () => {
@@ -1431,6 +1492,22 @@ var ChatSession = class {
1431
1492
  }
1432
1493
  if (this.isStreaming) return;
1433
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
+ }
1434
1511
  const userMessage = {
1435
1512
  id: generateId(),
1436
1513
  conversationId: conversationId ?? "",
@@ -1440,9 +1517,11 @@ var ChatSession = class {
1440
1517
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1441
1518
  };
1442
1519
  if (conversationId) {
1443
- await this.storage.addMessage(userMessage, conversationId);
1520
+ await this.storage.addMessage(userMessage, conversationId).catch(() => {
1521
+ });
1444
1522
  }
1445
1523
  this.messages.push(userMessage);
1524
+ this.pendingUserMessages.set(userMessage.id, 0);
1446
1525
  const request = {
1447
1526
  message: content,
1448
1527
  conversation_id: conversationId,
@@ -1462,7 +1541,24 @@ var ChatSession = class {
1462
1541
  reasoning_effort: options?.reasoningEffort,
1463
1542
  temperature: options?.temperature
1464
1543
  };
1465
- 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
+ }
1466
1562
  }
1467
1563
  async resendFromCheckpoint(messageId, newContent) {
1468
1564
  if (this.isStreaming) return;
@@ -1479,12 +1575,13 @@ var ChatSession = class {
1479
1575
  this.accumulatedText = "";
1480
1576
  this.currentTextPath = null;
1481
1577
  }
1482
- async processStream(request) {
1578
+ async processStream(request, wire) {
1483
1579
  this.isStreaming = true;
1484
1580
  this.resetStreamingState();
1485
- this.abortController = new AbortController();
1581
+ const controller = new AbortController();
1582
+ this.abortController = controller;
1486
1583
  try {
1487
- await this.consumeJobStream(request);
1584
+ await this.consumeJobStream(request, wire);
1488
1585
  } catch (err) {
1489
1586
  if (!(err instanceof DOMException && err.name === "AbortError")) {
1490
1587
  this.emit({
@@ -1495,16 +1592,20 @@ var ChatSession = class {
1495
1592
  });
1496
1593
  }
1497
1594
  } finally {
1498
- this.isStreaming = false;
1499
- this.abortController = null;
1595
+ if (this.abortController === controller) {
1596
+ this.isStreaming = false;
1597
+ this.abortController = null;
1598
+ }
1500
1599
  }
1501
1600
  }
1502
- async consumeJobStream(request) {
1601
+ async consumeJobStream(request, wire) {
1503
1602
  const job = await this.client.createJob(request);
1603
+ if (wire) wire.reached = true;
1504
1604
  this.currentJobId = job.job_id;
1505
1605
  const conversationId = job.conversation_id;
1506
1606
  if (!this.conversationId) {
1507
1607
  this.conversationId = conversationId;
1608
+ this.messagesConversationId = conversationId;
1508
1609
  }
1509
1610
  if (!this.conversations.some((c) => c.id === conversationId)) {
1510
1611
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1525,13 +1626,29 @@ var ChatSession = class {
1525
1626
  await this.storage.addMessage(lastMsg, conversationId).catch(() => {
1526
1627
  });
1527
1628
  }
1528
- 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
+ }
1529
1646
  this.lastSeq = -1;
1530
1647
  this.submittedToolCallIds.clear();
1531
1648
  await this.consumeEventStream(
1532
1649
  job.job_id,
1533
1650
  conversationId,
1534
- messageId,
1651
+ promptMessageId,
1535
1652
  true
1536
1653
  // executeClientTools
1537
1654
  );
@@ -1540,7 +1657,7 @@ var ChatSession = class {
1540
1657
  * Shared event consumption loop. Parses each wire event, updates
1541
1658
  * minimal session state, and emits typed ChatEvents to consumers.
1542
1659
  */
1543
- async consumeEventStream(jobId, conversationId, messageId, executeClientTools) {
1660
+ async consumeEventStream(jobId, conversationId, promptMessageId, executeClientTools) {
1544
1661
  const signal = this.abortController?.signal;
1545
1662
  for (let attempt = 0; ; attempt++) {
1546
1663
  if (signal?.aborted) return;
@@ -1557,7 +1674,7 @@ var ChatSession = class {
1557
1674
  sawTerminal = await this.pumpStream(
1558
1675
  stream,
1559
1676
  conversationId,
1560
- messageId,
1677
+ promptMessageId,
1561
1678
  executeClientTools,
1562
1679
  () => attemptController.abort()
1563
1680
  );
@@ -1589,7 +1706,7 @@ var ChatSession = class {
1589
1706
  * zombie — ``reader.read()`` will never settle — so we kill the fetch and
1590
1707
  * throw a ConnectionError, feeding the caller's reconnect-from-lastSeq loop.
1591
1708
  */
1592
- async pumpStream(stream, conversationId, messageId, executeClientTools, onStall) {
1709
+ async pumpStream(stream, conversationId, promptMessageId, executeClientTools, onStall) {
1593
1710
  let sawTerminal = false;
1594
1711
  const iterator = stream[Symbol.asyncIterator]();
1595
1712
  try {
@@ -1636,7 +1753,7 @@ var ChatSession = class {
1636
1753
  await this.dispatchWireEvent(
1637
1754
  parsed,
1638
1755
  conversationId,
1639
- messageId,
1756
+ promptMessageId,
1640
1757
  executeClientTools
1641
1758
  );
1642
1759
  }
@@ -1678,8 +1795,8 @@ var ChatSession = class {
1678
1795
  }
1679
1796
  }
1680
1797
  }
1681
- async dispatchWireEvent(wire, conversationId, messageId, executeClientTools) {
1682
- this.applyWireSideEffects(wire, conversationId, messageId);
1798
+ async dispatchWireEvent(wire, conversationId, promptMessageId, executeClientTools) {
1799
+ this.applyWireSideEffects(wire, conversationId, promptMessageId, true);
1683
1800
  const event = translateWireEvent(wire);
1684
1801
  if (event) {
1685
1802
  this.emit(event);
@@ -1697,7 +1814,9 @@ var ChatSession = class {
1697
1814
  const results = await this.executeClientTools([request]);
1698
1815
  await this.submitToolResultWithRetry({
1699
1816
  conversation_id: conversationId,
1700
- 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,
1701
1820
  tool_results: results
1702
1821
  });
1703
1822
  this.submittedToolCallIds.add(callId);
@@ -1712,7 +1831,7 @@ var ChatSession = class {
1712
1831
  * instead of re-typing the whole conversation event by event.
1713
1832
  */
1714
1833
  replayWireEvent(wire, conversationId) {
1715
- this.applyWireSideEffects(wire, conversationId, "");
1834
+ this.applyWireSideEffects(wire, conversationId, "", false);
1716
1835
  const event = translateWireEvent(wire);
1717
1836
  if (event) {
1718
1837
  this.emit(event);
@@ -1723,37 +1842,52 @@ var ChatSession = class {
1723
1842
  * the pure wire → ChatEvent mapping can live in translate.ts and be reused
1724
1843
  * by the replay path.
1725
1844
  *
1726
- * ``messageId`` is the server-assigned assistant message id for the current
1727
- * turn; empty in the reconnect and conversation-switch replay paths where
1728
- * 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.
1729
1854
  */
1730
- applyWireSideEffects(wire, conversationId, messageId) {
1855
+ applyWireSideEffects(wire, conversationId, promptMessageId, live) {
1856
+ const ownsTurnState = live || !this.isStreaming;
1731
1857
  switch (wire.type) {
1732
1858
  case "message_start":
1733
- this.resetStreamingState();
1859
+ if (ownsTurnState) this.resetStreamingState();
1734
1860
  if (wire.model) {
1735
1861
  this.modelDisplayName = wire.model;
1736
1862
  }
1737
1863
  return;
1738
1864
  case "block_start":
1739
- 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)) {
1740
1866
  this.currentTextPath = wire.path;
1741
1867
  }
1742
1868
  return;
1743
1869
  case "block_delta":
1744
- 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)) {
1745
1871
  this.accumulatedText += wire.delta.text;
1746
1872
  }
1747
1873
  return;
1748
1874
  case "block_stop":
1749
- if (this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1875
+ if (ownsTurnState && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1750
1876
  this.currentTextPath = null;
1751
1877
  }
1752
1878
  return;
1753
1879
  case "message_stop":
1754
- if (messageId) {
1880
+ if (promptMessageId && this.pendingUserMessages.has(promptMessageId)) {
1881
+ this.pendingUserMessages.set(promptMessageId, ++this.serverRowsKnown);
1882
+ }
1883
+ if (promptMessageId) {
1755
1884
  const assistantMessage = {
1756
- 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(),
1757
1891
  conversationId,
1758
1892
  role: "assistant",
1759
1893
  content: this.accumulatedText,
@@ -1764,8 +1898,10 @@ var ChatSession = class {
1764
1898
  this.storage.addMessage(assistantMessage, conversationId).catch(() => {
1765
1899
  });
1766
1900
  }
1767
- this.isStreaming = false;
1768
- this.currentJobId = null;
1901
+ if (ownsTurnState) {
1902
+ this.isStreaming = false;
1903
+ this.currentJobId = null;
1904
+ }
1769
1905
  return;
1770
1906
  case "custom":
1771
1907
  if (wire.name === "title_generated") {
@@ -1799,9 +1935,27 @@ var ChatSession = class {
1799
1935
  * Used before reconnectToJob — SSE replay handles event replay.
1800
1936
  */
1801
1937
  async loadConversation(id) {
1938
+ const load = ++this.loadGeneration;
1802
1939
  this.conversationId = id;
1803
- this.resetStreamingState();
1804
- 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;
1805
1959
  }
1806
1960
  /**
1807
1961
  * Reconnect to a running job's SSE stream (e.g. after page reload).
@@ -1814,7 +1968,8 @@ var ChatSession = class {
1814
1968
  this.lastSeq = -1;
1815
1969
  this.submittedToolCallIds.clear();
1816
1970
  this.resetStreamingState();
1817
- this.abortController = new AbortController();
1971
+ const controller = new AbortController();
1972
+ this.abortController = controller;
1818
1973
  try {
1819
1974
  await this.consumeEventStream(
1820
1975
  jobId,
@@ -1831,8 +1986,10 @@ var ChatSession = class {
1831
1986
  blockPath: null
1832
1987
  });
1833
1988
  } finally {
1834
- this.isStreaming = false;
1835
- this.abortController = null;
1989
+ if (this.abortController === controller) {
1990
+ this.isStreaming = false;
1991
+ this.abortController = null;
1992
+ }
1836
1993
  }
1837
1994
  }
1838
1995
  /** Detach from the SSE stream without cancelling the job. */
@@ -1843,25 +2000,54 @@ var ChatSession = class {
1843
2000
  this.resetStreamingState();
1844
2001
  this.emit({ type: "disconnected" });
1845
2002
  }
1846
- /** Stop the job and disconnect (explicit user action). */
1847
- 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() {
1848
2012
  if (this.currentJobId) {
1849
2013
  this.client.cancelJob(this.currentJobId).catch(() => {
1850
2014
  });
1851
2015
  }
1852
2016
  this.detach();
1853
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();
1854
2025
  this.protocols.clear();
1855
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
+ }
1856
2038
  async createNewConversation() {
1857
2039
  const id = generateId();
2040
+ const load = this.loadGeneration;
1858
2041
  const conversation = await this.storage.createConversation(
1859
2042
  id,
1860
2043
  "New Conversation"
1861
2044
  );
1862
2045
  this.conversations.unshift(conversation);
2046
+ if (load !== this.loadGeneration) return id;
2047
+ this.loadGeneration++;
1863
2048
  this.conversationId = id;
1864
- this.messages = [];
2049
+ this.setMessages([]);
2050
+ this.messagesConversationId = id;
1865
2051
  return id;
1866
2052
  }
1867
2053
  /**
@@ -1880,7 +2066,7 @@ var ChatSession = class {
1880
2066
  */
1881
2067
  replayTurn(id, events, userMessageContent, userMessageId, isSteer = false) {
1882
2068
  this.conversationId = id;
1883
- this.resetStreamingState();
2069
+ if (!this.isStreaming) this.resetStreamingState();
1884
2070
  if (userMessageContent) {
1885
2071
  this.emit({
1886
2072
  type: "user_message",
@@ -1911,11 +2097,17 @@ var ChatSession = class {
1911
2097
  * job's events.
1912
2098
  */
1913
2099
  async switchConversation(id, jobId) {
1914
- const [messagesResult, eventsResult] = await Promise.allSettled([
1915
- 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,
1916
2104
  this.client.getConversationEvents(id, jobId)
1917
2105
  ]);
1918
- 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
+ }
1919
2111
  this.replayTurn(
1920
2112
  id,
1921
2113
  eventsResult.status === "fulfilled" ? eventsResult.value : []
@@ -2006,8 +2198,10 @@ var ChatSession = class {
2006
2198
  }
2007
2199
  this.conversations = this.conversations.filter((c) => c.id !== id);
2008
2200
  if (this.conversationId === id) {
2201
+ this.loadGeneration++;
2009
2202
  this.conversationId = null;
2010
- this.messages = [];
2203
+ this.setMessages([]);
2204
+ this.messagesConversationId = null;
2011
2205
  }
2012
2206
  }
2013
2207
  toggleClientTool(name) {
@@ -2023,6 +2217,11 @@ var ChatSession = class {
2023
2217
  // src/restore-plan.ts
2024
2218
  function planRestore(args) {
2025
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
+ );
2026
2225
  const byId = /* @__PURE__ */ new Map();
2027
2226
  userMessages.forEach((m, i) => {
2028
2227
  if (m.id) byId.set(m.id, i);
@@ -2044,7 +2243,7 @@ function planRestore(args) {
2044
2243
  userMessages.slice(0, cutover)
2045
2244
  );
2046
2245
  let cursor = cutover;
2047
- const isSteer = (m) => !!m?.id && !completedJobs.some((j) => j.message_id === m.id);
2246
+ const isSteer = (m) => !!m?.id && !claimed.has(m.id);
2048
2247
  const drainTo = (stopAt) => {
2049
2248
  while (cursor < stopAt) {
2050
2249
  const m = userMessages[cursor++];
@@ -2130,6 +2329,12 @@ var StreamManager = class {
2130
2329
  this._backgroundJobs = /* @__PURE__ */ new Map();
2131
2330
  this.handlers = [];
2132
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;
2133
2338
  this.session = session;
2134
2339
  this.attach();
2135
2340
  }
@@ -2180,7 +2385,7 @@ var StreamManager = class {
2180
2385
  event
2181
2386
  });
2182
2387
  if (event.type === ChatEventType.MessageStop) {
2183
- if (this._state === "streaming") {
2388
+ if (this._state === "streaming" && !this.session.isStreaming) {
2184
2389
  this.setState("idle");
2185
2390
  }
2186
2391
  }
@@ -2193,13 +2398,21 @@ var StreamManager = class {
2193
2398
  );
2194
2399
  }
2195
2400
  if (this._state === "streaming") return;
2196
- if (!this._activeConversationId) {
2197
- const id = await this.session.createNewConversation();
2198
- this.setActiveConversation(id);
2401
+ let target = this._activeConversationId;
2402
+ if (!target) {
2403
+ target = await this.session.createNewConversation();
2404
+ this.setActiveConversation(target);
2199
2405
  }
2200
2406
  this.setState("streaming");
2201
2407
  try {
2202
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,
2203
2416
  agentName: options?.agentName,
2204
2417
  uploadIds: options?.uploadIds,
2205
2418
  planMode: options?.planMode,
@@ -2218,6 +2431,9 @@ var StreamManager = class {
2218
2431
  // ── Regenerate ────────────────────────────────────────────────
2219
2432
  async regenerate() {
2220
2433
  if (this._state === "streaming") return;
2434
+ if (this.session.messagesConversationId !== this._activeConversationId) {
2435
+ return;
2436
+ }
2221
2437
  const userMsgs = this.session.messages.filter(
2222
2438
  (m) => m.role === "user"
2223
2439
  );
@@ -2254,44 +2470,78 @@ var StreamManager = class {
2254
2470
  async switchTo(conversationId, opts) {
2255
2471
  if (conversationId === this._activeConversationId) return;
2256
2472
  const targetHadBackgroundJob = this._backgroundJobs.has(conversationId);
2257
- if (this._state === "streaming") {
2258
- const oldConvId = this._activeConversationId;
2259
- const jobId = this.session.currentJobId;
2260
- if (oldConvId && jobId) {
2261
- this._backgroundJobs.set(oldConvId, jobId);
2262
- this.emit({
2263
- type: "backgroundJobsChanged",
2264
- jobs: this._backgroundJobs
2265
- });
2266
- }
2267
- this.session.detach();
2268
- }
2269
- if (this._backgroundJobs.has(conversationId)) {
2473
+ this.detachStreamingTurn();
2474
+ const parkedJobId = this._backgroundJobs.get(conversationId);
2475
+ if (parkedJobId !== void 0) {
2270
2476
  this._backgroundJobs.delete(conversationId);
2271
2477
  this.emit({
2272
2478
  type: "backgroundJobsChanged",
2273
2479
  jobs: this._backgroundJobs
2274
2480
  });
2275
2481
  }
2276
- this.setActiveConversation(conversationId);
2482
+ const gen = this.setActiveConversation(conversationId);
2277
2483
  if (opts?.skipHistoryReplay && !targetHadBackgroundJob) {
2278
2484
  let activeJobId = null;
2279
2485
  try {
2280
2486
  activeJobId = (await this.session.client.getActiveJob(conversationId)).jobId;
2281
2487
  } catch {
2282
2488
  }
2489
+ if (gen !== this.generation) return;
2283
2490
  if (!activeJobId) {
2284
- await this.session.loadConversation(conversationId);
2285
- this.setState("idle");
2491
+ try {
2492
+ await this.session.loadConversation(conversationId);
2493
+ } finally {
2494
+ if (gen === this.generation) this.settleIdle();
2495
+ }
2286
2496
  return;
2287
2497
  }
2288
2498
  }
2289
- 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
+ }
2290
2519
  }
2291
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
+ */
2292
2530
  async createConversation() {
2293
- 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
+ }
2294
2543
  this.setActiveConversation(id);
2544
+ this.settleIdle();
2295
2545
  return id;
2296
2546
  }
2297
2547
  /**
@@ -2302,16 +2552,34 @@ var StreamManager = class {
2302
2552
  await this.session.renameConversation(id, title);
2303
2553
  }
2304
2554
  async deleteConversation(id) {
2305
- await this.session.deleteConversation(id);
2306
- 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
+ }
2307
2567
  if (this._activeConversationId === id) {
2308
- this._activeConversationId = null;
2309
- 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 });
2310
2578
  }
2311
2579
  }
2312
2580
  // ── Stop (explicit cancel) ────────────────────────────────────
2313
2581
  stop() {
2314
- this.session.disconnect();
2582
+ this.session.cancelTurn();
2315
2583
  this.setState("idle");
2316
2584
  }
2317
2585
  // ── Cleanup ───────────────────────────────────────────────────
@@ -2323,34 +2591,80 @@ var StreamManager = class {
2323
2591
  this.handlers = [];
2324
2592
  }
2325
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
+ }
2326
2633
  finalizeStream() {
2327
2634
  if (this._state === "streaming") {
2328
2635
  this.setState("idle");
2329
2636
  }
2330
2637
  }
2331
2638
  // ── Internal: restore ─────────────────────────────────────────
2332
- async restore(conversationId) {
2333
- 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");
2334
2643
  let activeJobId = null;
2335
2644
  try {
2336
2645
  const res = await this.session.client.getActiveJob(conversationId);
2337
2646
  activeJobId = res.jobId;
2338
2647
  } catch {
2339
2648
  }
2649
+ if (superseded()) return;
2340
2650
  if (activeJobId) {
2341
2651
  await this.session.loadConversation(conversationId);
2652
+ if (superseded()) return;
2342
2653
  this.setState("streaming");
2343
2654
  try {
2344
2655
  await this.session.reconnectToJob(activeJobId);
2345
2656
  } catch {
2346
2657
  }
2347
- if (this._state === "streaming") {
2658
+ if (superseded()) return;
2659
+ if (this._state === "streaming" && !this.session.isStreaming) {
2348
2660
  this.setState("idle");
2349
2661
  }
2350
2662
  } else {
2351
2663
  await this.session.loadConversation(conversationId);
2664
+ if (superseded()) return;
2352
2665
  try {
2353
2666
  const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2667
+ if (superseded()) return;
2354
2668
  const completedJobs = jobs.filter(
2355
2669
  (j) => j.status === "completed"
2356
2670
  );
@@ -2362,6 +2676,14 @@ var StreamManager = class {
2362
2676
  job_id: j.job_id,
2363
2677
  message_id: j.message_id
2364
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),
2365
2687
  userMessages: userMessages.map((m) => ({
2366
2688
  id: m.id,
2367
2689
  content: m.content
@@ -2372,10 +2694,12 @@ var StreamManager = class {
2372
2694
  (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2373
2695
  )
2374
2696
  );
2697
+ if (superseded()) return;
2375
2698
  const eventsByJobId = new Map(
2376
2699
  completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2377
2700
  );
2378
2701
  for (const step of plan) {
2702
+ if (superseded()) return;
2379
2703
  if (step.kind === "steer") {
2380
2704
  this.session.replayTurn(
2381
2705
  conversationId,
@@ -2393,6 +2717,7 @@ var StreamManager = class {
2393
2717
  step.messageId
2394
2718
  );
2395
2719
  }
2720
+ if (superseded()) return;
2396
2721
  if (completedJobs.length > 0) {
2397
2722
  this.emit({
2398
2723
  type: "versionsReady",
@@ -2402,13 +2727,17 @@ var StreamManager = class {
2402
2727
  }
2403
2728
  } catch {
2404
2729
  }
2405
- this.setState("idle");
2730
+ if (superseded()) return;
2731
+ this.settleIdle();
2406
2732
  }
2407
2733
  }
2408
2734
  // ── Internal: set active conversation ─────────────────────────
2409
2735
  setActiveConversation(id) {
2410
2736
  this._activeConversationId = id;
2737
+ this.session.invalidateLoadsInFlight();
2738
+ const claimed = ++this.generation;
2411
2739
  this.emit({ type: "conversationChanged", conversationId: id });
2740
+ return claimed;
2412
2741
  }
2413
2742
  };
2414
2743