@astralform/js 4.9.1 → 5.1.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
@@ -723,6 +723,11 @@ var AstralformClient = class {
723
723
  // undefined so it matches the `url?: string` type and consumers that
724
724
  // check `!== undefined` never receive a null.
725
725
  url: raw.url ?? void 0,
726
+ // This mapping is an ALLOWLIST — a field the API returns and this
727
+ // function does not name is dropped silently, and no type error says so.
728
+ // `content_url` shipped that way and was invisible to every consumer.
729
+ contentUrl: raw.content_url ?? void 0,
730
+ posterUrl: raw.poster_url ?? void 0,
726
731
  createdAt: raw.created_at
727
732
  };
728
733
  }
@@ -1376,6 +1381,17 @@ var ChatSession = class {
1376
1381
  /** True while ``loadMoreConversations`` is in flight. */
1377
1382
  this.isLoadingConversations = false;
1378
1383
  this.messages = [];
1384
+ /**
1385
+ * Which conversation ``messages`` currently holds.
1386
+ *
1387
+ * Distinct from ``conversationId``, and the distinction is the point:
1388
+ * ``loadConversation`` moves the POINTER synchronously and installs the LIST
1389
+ * an await later, so for the whole duration of every load the two disagree.
1390
+ * Anything pairing a message with a conversation — ``regenerate`` above all —
1391
+ * has to read this one, or it will pair the previous conversation's last
1392
+ * message with the new conversation's id.
1393
+ */
1394
+ this.messagesConversationId = null;
1379
1395
  this.isStreaming = false;
1380
1396
  this.agentStatus = null;
1381
1397
  this.agents = [];
@@ -1408,6 +1424,43 @@ var ChatSession = class {
1408
1424
  * is discarded instead.
1409
1425
  */
1410
1426
  this.conversationsGeneration = 0;
1427
+ /**
1428
+ * Bumped by every ``loadConversation`` call, so an out-of-order fetch can
1429
+ * tell it is no longer the newest one and drop its result. Separate from
1430
+ * ``conversationsGeneration``, which guards the conversation LIST.
1431
+ */
1432
+ this.loadGeneration = 0;
1433
+ /**
1434
+ * Ids of locally-created user messages the server has not acknowledged yet.
1435
+ *
1436
+ * Arrival time cannot answer "could the reply have included this?" on its
1437
+ * own. Every `loadConversation` in `StreamManager` sits behind the
1438
+ * active-job probe, so the ORDINARY ordering is that a send lands BEFORE the
1439
+ * load starts — and an arrival-time rule drops exactly those, losing the
1440
+ * prompt the user just sent while its stream is still running. Membership
1441
+ * here is set by `send` and cleared when a server row turns up carrying the
1442
+ * same turn, so the keep-decision no longer depends on which side of the
1443
+ * fetch the push landed on.
1444
+ *
1445
+ * The value is `serverRowsKnown` as of the `message_stop` that proved the
1446
+ * row committed, or 0 until then — including after the job response, which
1447
+ * hands back the id but starts the loop as a background task and so proves
1448
+ * nothing about the row. That stamp is what separates "the snapshot predates
1449
+ * the row" from "the server does not have this row" — see
1450
+ * `loadConversation`.
1451
+ *
1452
+ * It is an ANNOTATION ON `this.messages`: reconciliation only ever consults
1453
+ * entries of that array, so an id whose message has left it is dead weight.
1454
+ * `setMessages` is the single place the array is replaced, and it prunes.
1455
+ */
1456
+ this.pendingUserMessages = /* @__PURE__ */ new Map();
1457
+ /**
1458
+ * Bumped once per completed turn, at `message_stop`, so a fetch can record
1459
+ * what was proven when it was ISSUED. A row proven committed before the
1460
+ * fetch went out must appear in its snapshot; one proven after may
1461
+ * legitimately be missing.
1462
+ */
1463
+ this.serverRowsKnown = 0;
1411
1464
  // Minimal in-session accumulation for the assistant message record.
1412
1465
  // Only top-level ``text`` blocks contribute; subagent / tool output
1413
1466
  // is tracked by the consumer's own block store.
@@ -1430,6 +1483,19 @@ var ChatSession = class {
1430
1483
  this.toolRegistry = new ToolRegistry();
1431
1484
  this.storage = storage ?? new InMemoryStorage();
1432
1485
  }
1486
+ /**
1487
+ * Replace the message list, keeping `pendingUserMessages` an annotation on
1488
+ * it. Every removal from the array goes through here — `push` is the only
1489
+ * other mutation and it cannot orphan an id.
1490
+ */
1491
+ setMessages(next) {
1492
+ this.messages = next;
1493
+ if (this.pendingUserMessages.size === 0) return;
1494
+ const present = new Set(next.map((m) => m.id));
1495
+ for (const id of this.pendingUserMessages.keys()) {
1496
+ if (!present.has(id)) this.pendingUserMessages.delete(id);
1497
+ }
1498
+ }
1433
1499
  on(handler) {
1434
1500
  this.handlers.add(handler);
1435
1501
  return () => {
@@ -1478,6 +1544,22 @@ var ChatSession = class {
1478
1544
  }
1479
1545
  if (this.isStreaming) return;
1480
1546
  const conversationId = options?.conversationId ?? this.conversationId ?? void 0;
1547
+ let relocatedFrom = null;
1548
+ if (conversationId) {
1549
+ if (conversationId !== this.conversationId) {
1550
+ this.loadGeneration++;
1551
+ relocatedFrom = {
1552
+ messages: this.messages,
1553
+ messagesId: this.messagesConversationId,
1554
+ conversationId: this.conversationId,
1555
+ generation: this.loadGeneration,
1556
+ target: conversationId
1557
+ };
1558
+ this.setMessages([]);
1559
+ this.messagesConversationId = null;
1560
+ }
1561
+ this.conversationId = conversationId;
1562
+ }
1481
1563
  const userMessage = {
1482
1564
  id: generateId(),
1483
1565
  conversationId: conversationId ?? "",
@@ -1487,9 +1569,11 @@ var ChatSession = class {
1487
1569
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1488
1570
  };
1489
1571
  if (conversationId) {
1490
- await this.storage.addMessage(userMessage, conversationId);
1572
+ await this.storage.addMessage(userMessage, conversationId).catch(() => {
1573
+ });
1491
1574
  }
1492
1575
  this.messages.push(userMessage);
1576
+ this.pendingUserMessages.set(userMessage.id, 0);
1493
1577
  const request = {
1494
1578
  message: content,
1495
1579
  conversation_id: conversationId,
@@ -1509,7 +1593,24 @@ var ChatSession = class {
1509
1593
  reasoning_effort: options?.reasoningEffort,
1510
1594
  temperature: options?.temperature
1511
1595
  };
1512
- await this.processStream(request);
1596
+ const wire = { reached: false };
1597
+ await this.processStream(request, wire);
1598
+ if (!wire.reached) {
1599
+ this.pendingUserMessages.delete(userMessage.id);
1600
+ const at = this.messages.indexOf(userMessage);
1601
+ if (at !== -1) this.messages.splice(at, 1);
1602
+ if (conversationId) {
1603
+ await this.storage.deleteMessage(userMessage.id).catch(() => {
1604
+ });
1605
+ }
1606
+ }
1607
+ if (relocatedFrom && wire.reached && this.loadGeneration === relocatedFrom.generation) {
1608
+ this.messagesConversationId = relocatedFrom.target;
1609
+ } else if (relocatedFrom && !wire.reached && this.loadGeneration === relocatedFrom.generation) {
1610
+ this.setMessages(relocatedFrom.messages);
1611
+ this.messagesConversationId = relocatedFrom.messagesId;
1612
+ this.conversationId = relocatedFrom.conversationId;
1613
+ }
1513
1614
  }
1514
1615
  async resendFromCheckpoint(messageId, newContent) {
1515
1616
  if (this.isStreaming) return;
@@ -1526,12 +1627,13 @@ var ChatSession = class {
1526
1627
  this.accumulatedText = "";
1527
1628
  this.currentTextPath = null;
1528
1629
  }
1529
- async processStream(request) {
1630
+ async processStream(request, wire) {
1530
1631
  this.isStreaming = true;
1531
1632
  this.resetStreamingState();
1532
- this.abortController = new AbortController();
1633
+ const controller = new AbortController();
1634
+ this.abortController = controller;
1533
1635
  try {
1534
- await this.consumeJobStream(request);
1636
+ await this.consumeJobStream(request, wire);
1535
1637
  } catch (err) {
1536
1638
  if (!(err instanceof DOMException && err.name === "AbortError")) {
1537
1639
  this.emit({
@@ -1542,16 +1644,20 @@ var ChatSession = class {
1542
1644
  });
1543
1645
  }
1544
1646
  } finally {
1545
- this.isStreaming = false;
1546
- this.abortController = null;
1647
+ if (this.abortController === controller) {
1648
+ this.isStreaming = false;
1649
+ this.abortController = null;
1650
+ }
1547
1651
  }
1548
1652
  }
1549
- async consumeJobStream(request) {
1653
+ async consumeJobStream(request, wire) {
1550
1654
  const job = await this.client.createJob(request);
1655
+ if (wire) wire.reached = true;
1551
1656
  this.currentJobId = job.job_id;
1552
1657
  const conversationId = job.conversation_id;
1553
1658
  if (!this.conversationId) {
1554
1659
  this.conversationId = conversationId;
1660
+ this.messagesConversationId = conversationId;
1555
1661
  }
1556
1662
  if (!this.conversations.some((c) => c.id === conversationId)) {
1557
1663
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1572,13 +1678,29 @@ var ChatSession = class {
1572
1678
  await this.storage.addMessage(lastMsg, conversationId).catch(() => {
1573
1679
  });
1574
1680
  }
1575
- const messageId = job.message_id;
1681
+ const promptMessageId = job.message_id;
1682
+ if (promptMessageId && lastMsg?.role === "user" && this.pendingUserMessages.has(lastMsg.id)) {
1683
+ this.pendingUserMessages.delete(lastMsg.id);
1684
+ const clientMintedId = lastMsg.id;
1685
+ lastMsg.id = promptMessageId;
1686
+ this.pendingUserMessages.set(promptMessageId, 0);
1687
+ if (conversationId && clientMintedId !== promptMessageId) {
1688
+ await this.storage.deleteMessage(clientMintedId).catch(() => {
1689
+ });
1690
+ await this.storage.addMessage(lastMsg, conversationId).catch(() => {
1691
+ });
1692
+ }
1693
+ const dupe = this.messages.findIndex(
1694
+ (m) => m !== lastMsg && m.id === promptMessageId
1695
+ );
1696
+ if (dupe !== -1) this.messages.splice(dupe, 1);
1697
+ }
1576
1698
  this.lastSeq = -1;
1577
1699
  this.submittedToolCallIds.clear();
1578
1700
  await this.consumeEventStream(
1579
1701
  job.job_id,
1580
1702
  conversationId,
1581
- messageId,
1703
+ promptMessageId,
1582
1704
  true
1583
1705
  // executeClientTools
1584
1706
  );
@@ -1587,7 +1709,7 @@ var ChatSession = class {
1587
1709
  * Shared event consumption loop. Parses each wire event, updates
1588
1710
  * minimal session state, and emits typed ChatEvents to consumers.
1589
1711
  */
1590
- async consumeEventStream(jobId, conversationId, messageId, executeClientTools) {
1712
+ async consumeEventStream(jobId, conversationId, promptMessageId, executeClientTools) {
1591
1713
  const signal = this.abortController?.signal;
1592
1714
  for (let attempt = 0; ; attempt++) {
1593
1715
  if (signal?.aborted) return;
@@ -1604,7 +1726,7 @@ var ChatSession = class {
1604
1726
  sawTerminal = await this.pumpStream(
1605
1727
  stream,
1606
1728
  conversationId,
1607
- messageId,
1729
+ promptMessageId,
1608
1730
  executeClientTools,
1609
1731
  () => attemptController.abort()
1610
1732
  );
@@ -1636,7 +1758,7 @@ var ChatSession = class {
1636
1758
  * zombie — ``reader.read()`` will never settle — so we kill the fetch and
1637
1759
  * throw a ConnectionError, feeding the caller's reconnect-from-lastSeq loop.
1638
1760
  */
1639
- async pumpStream(stream, conversationId, messageId, executeClientTools, onStall) {
1761
+ async pumpStream(stream, conversationId, promptMessageId, executeClientTools, onStall) {
1640
1762
  let sawTerminal = false;
1641
1763
  const iterator = stream[Symbol.asyncIterator]();
1642
1764
  try {
@@ -1683,7 +1805,7 @@ var ChatSession = class {
1683
1805
  await this.dispatchWireEvent(
1684
1806
  parsed,
1685
1807
  conversationId,
1686
- messageId,
1808
+ promptMessageId,
1687
1809
  executeClientTools
1688
1810
  );
1689
1811
  }
@@ -1725,8 +1847,8 @@ var ChatSession = class {
1725
1847
  }
1726
1848
  }
1727
1849
  }
1728
- async dispatchWireEvent(wire, conversationId, messageId, executeClientTools) {
1729
- this.applyWireSideEffects(wire, conversationId, messageId);
1850
+ async dispatchWireEvent(wire, conversationId, promptMessageId, executeClientTools) {
1851
+ this.applyWireSideEffects(wire, conversationId, promptMessageId, true);
1730
1852
  const event = translateWireEvent(wire);
1731
1853
  if (event) {
1732
1854
  this.emit(event);
@@ -1744,7 +1866,9 @@ var ChatSession = class {
1744
1866
  const results = await this.executeClientTools([request]);
1745
1867
  await this.submitToolResultWithRetry({
1746
1868
  conversation_id: conversationId,
1747
- message_id: messageId,
1869
+ // The message that TRIGGERED the tool calls, which is what the
1870
+ // backend stores it against — the prompt id, correctly.
1871
+ message_id: promptMessageId,
1748
1872
  tool_results: results
1749
1873
  });
1750
1874
  this.submittedToolCallIds.add(callId);
@@ -1759,7 +1883,7 @@ var ChatSession = class {
1759
1883
  * instead of re-typing the whole conversation event by event.
1760
1884
  */
1761
1885
  replayWireEvent(wire, conversationId) {
1762
- this.applyWireSideEffects(wire, conversationId, "");
1886
+ this.applyWireSideEffects(wire, conversationId, "", false);
1763
1887
  const event = translateWireEvent(wire);
1764
1888
  if (event) {
1765
1889
  this.emit(event);
@@ -1770,37 +1894,52 @@ var ChatSession = class {
1770
1894
  * the pure wire → ChatEvent mapping can live in translate.ts and be reused
1771
1895
  * by the replay path.
1772
1896
  *
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.
1897
+ * ``promptMessageId`` is the id of the USER turn that started this job —
1898
+ * `POST /v1/jobs` returns it and the backend tags the prompt with it
1899
+ * (`HumanMessage(content=..., id=message_id)`), which is what lets a restore
1900
+ * pair a job with its prompt by id instead of by position. It was previously
1901
+ * documented here as the ASSISTANT's id and used as one; there is no
1902
+ * server-assigned assistant id on the wire, so that row gets a local one.
1903
+ * Empty in the reconnect and conversation-switch replay paths, where the
1904
+ * messages have already been loaded from REST and must not be re-pushed —
1905
+ * so it doubles as the "is this a live send?" gate.
1776
1906
  */
1777
- applyWireSideEffects(wire, conversationId, messageId) {
1907
+ applyWireSideEffects(wire, conversationId, promptMessageId, live) {
1908
+ const ownsTurnState = live || !this.isStreaming;
1778
1909
  switch (wire.type) {
1779
1910
  case "message_start":
1780
- this.resetStreamingState();
1911
+ if (ownsTurnState) this.resetStreamingState();
1781
1912
  if (wire.model) {
1782
1913
  this.modelDisplayName = wire.model;
1783
1914
  }
1784
1915
  return;
1785
1916
  case "block_start":
1786
- if (wire.kind === "text" && (!wire.parent_path || wire.parent_path.length === 0)) {
1917
+ if (ownsTurnState && wire.kind === "text" && (!wire.parent_path || wire.parent_path.length === 0)) {
1787
1918
  this.currentTextPath = wire.path;
1788
1919
  }
1789
1920
  return;
1790
1921
  case "block_delta":
1791
- if (wire.delta.channel === "text" && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1922
+ if (ownsTurnState && wire.delta.channel === "text" && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1792
1923
  this.accumulatedText += wire.delta.text;
1793
1924
  }
1794
1925
  return;
1795
1926
  case "block_stop":
1796
- if (this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1927
+ if (ownsTurnState && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1797
1928
  this.currentTextPath = null;
1798
1929
  }
1799
1930
  return;
1800
1931
  case "message_stop":
1801
- if (messageId) {
1932
+ if (promptMessageId && this.pendingUserMessages.has(promptMessageId)) {
1933
+ this.pendingUserMessages.set(promptMessageId, ++this.serverRowsKnown);
1934
+ }
1935
+ if (promptMessageId) {
1802
1936
  const assistantMessage = {
1803
- id: messageId,
1937
+ // NOT `promptMessageId` — that is the USER turn's id, which
1938
+ // `consumeJobStream` stamps onto the user row. Sharing it puts two
1939
+ // rows under one id, and `pendingUserMessages` is id-keyed. No
1940
+ // server-assigned assistant id exists on the wire, so this row is
1941
+ // local until a REST load replaces it.
1942
+ id: generateId(),
1804
1943
  conversationId,
1805
1944
  role: "assistant",
1806
1945
  content: this.accumulatedText,
@@ -1811,8 +1950,10 @@ var ChatSession = class {
1811
1950
  this.storage.addMessage(assistantMessage, conversationId).catch(() => {
1812
1951
  });
1813
1952
  }
1814
- this.isStreaming = false;
1815
- this.currentJobId = null;
1953
+ if (ownsTurnState) {
1954
+ this.isStreaming = false;
1955
+ this.currentJobId = null;
1956
+ }
1816
1957
  return;
1817
1958
  case "custom":
1818
1959
  if (wire.name === "title_generated") {
@@ -1846,9 +1987,27 @@ var ChatSession = class {
1846
1987
  * Used before reconnectToJob — SSE replay handles event replay.
1847
1988
  */
1848
1989
  async loadConversation(id) {
1990
+ const load = ++this.loadGeneration;
1849
1991
  this.conversationId = id;
1850
- this.resetStreamingState();
1851
- this.messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
1992
+ if (!this.isStreaming) this.resetStreamingState();
1993
+ const rowsKnownAtIssue = this.serverRowsKnown;
1994
+ const messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
1995
+ if (load !== this.loadGeneration) return;
1996
+ const pending = this.messages.filter(
1997
+ (m) => this.pendingUserMessages.has(m.id) && m.conversationId === id
1998
+ );
1999
+ const stillPending = pending.filter((m) => {
2000
+ if (messages.some((f) => f.id === m.id)) return false;
2001
+ const knownAt = this.pendingUserMessages.get(m.id) ?? 0;
2002
+ return knownAt === 0 || knownAt > rowsKnownAtIssue;
2003
+ });
2004
+ for (const m of pending) {
2005
+ if (!stillPending.includes(m)) this.pendingUserMessages.delete(m.id);
2006
+ }
2007
+ this.setMessages(
2008
+ stillPending.length ? [...messages, ...stillPending] : messages
2009
+ );
2010
+ this.messagesConversationId = id;
1852
2011
  }
1853
2012
  /**
1854
2013
  * Reconnect to a running job's SSE stream (e.g. after page reload).
@@ -1861,7 +2020,8 @@ var ChatSession = class {
1861
2020
  this.lastSeq = -1;
1862
2021
  this.submittedToolCallIds.clear();
1863
2022
  this.resetStreamingState();
1864
- this.abortController = new AbortController();
2023
+ const controller = new AbortController();
2024
+ this.abortController = controller;
1865
2025
  try {
1866
2026
  await this.consumeEventStream(
1867
2027
  jobId,
@@ -1878,8 +2038,10 @@ var ChatSession = class {
1878
2038
  blockPath: null
1879
2039
  });
1880
2040
  } finally {
1881
- this.isStreaming = false;
1882
- this.abortController = null;
2041
+ if (this.abortController === controller) {
2042
+ this.isStreaming = false;
2043
+ this.abortController = null;
2044
+ }
1883
2045
  }
1884
2046
  }
1885
2047
  /** Detach from the SSE stream without cancelling the job. */
@@ -1890,25 +2052,54 @@ var ChatSession = class {
1890
2052
  this.resetStreamingState();
1891
2053
  this.emit({ type: "disconnected" });
1892
2054
  }
1893
- /** Stop the job and disconnect (explicit user action). */
1894
- disconnect() {
2055
+ /**
2056
+ * Cancel the running turn: stop the job server-side and tear the stream
2057
+ * down, WITHOUT ending the session. A turn-level cancel is not a
2058
+ * session-level teardown, so unlike `disconnect()` this leaves the protocol
2059
+ * registry alone — the SDK never auto-registers adapters, so clearing them
2060
+ * on a Stop press would silently kill embedded-resource rendering for the
2061
+ * rest of the session with nothing to re-register it.
2062
+ */
2063
+ cancelTurn() {
1895
2064
  if (this.currentJobId) {
1896
2065
  this.client.cancelJob(this.currentJobId).catch(() => {
1897
2066
  });
1898
2067
  }
1899
2068
  this.detach();
1900
2069
  this.currentJobId = null;
2070
+ for (const [id, knownAt] of this.pendingUserMessages) {
2071
+ if (knownAt === 0) this.pendingUserMessages.delete(id);
2072
+ }
2073
+ }
2074
+ /** Stop the job and end the session's activity (explicit user action). */
2075
+ disconnect() {
2076
+ this.cancelTurn();
1901
2077
  this.protocols.clear();
1902
2078
  }
2079
+ /**
2080
+ * A pointer move happened above this layer, so any `loadConversation` in
2081
+ * flight must lose to it. `StreamManager` owns a pointer of its own and
2082
+ * moves it before this one; without this the two halves would gate on
2083
+ * counters that bump at different instants — `generation` synchronously in
2084
+ * `setActiveConversation`, `loadGeneration` only once the switch's own load
2085
+ * actually runs, which is behind the active-job probe.
2086
+ */
2087
+ invalidateLoadsInFlight() {
2088
+ this.loadGeneration++;
2089
+ }
1903
2090
  async createNewConversation() {
1904
2091
  const id = generateId();
2092
+ const load = this.loadGeneration;
1905
2093
  const conversation = await this.storage.createConversation(
1906
2094
  id,
1907
2095
  "New Conversation"
1908
2096
  );
1909
2097
  this.conversations.unshift(conversation);
2098
+ if (load !== this.loadGeneration) return id;
2099
+ this.loadGeneration++;
1910
2100
  this.conversationId = id;
1911
- this.messages = [];
2101
+ this.setMessages([]);
2102
+ this.messagesConversationId = id;
1912
2103
  return id;
1913
2104
  }
1914
2105
  /**
@@ -1927,7 +2118,7 @@ var ChatSession = class {
1927
2118
  */
1928
2119
  replayTurn(id, events, userMessageContent, userMessageId, isSteer = false) {
1929
2120
  this.conversationId = id;
1930
- this.resetStreamingState();
2121
+ if (!this.isStreaming) this.resetStreamingState();
1931
2122
  if (userMessageContent) {
1932
2123
  this.emit({
1933
2124
  type: "user_message",
@@ -1958,11 +2149,17 @@ var ChatSession = class {
1958
2149
  * job's events.
1959
2150
  */
1960
2151
  async switchConversation(id, jobId) {
1961
- const [messagesResult, eventsResult] = await Promise.allSettled([
1962
- this.client.getMessages(id).catch(() => this.storage.fetchMessages(id)),
2152
+ const loading = this.loadConversation(id);
2153
+ const token = this.loadGeneration;
2154
+ const [loadResult, eventsResult] = await Promise.allSettled([
2155
+ loading,
1963
2156
  this.client.getConversationEvents(id, jobId)
1964
2157
  ]);
1965
- this.messages = messagesResult.status === "fulfilled" ? messagesResult.value : [];
2158
+ if (token !== this.loadGeneration) return;
2159
+ if (loadResult.status === "rejected") {
2160
+ this.setMessages([]);
2161
+ this.messagesConversationId = id;
2162
+ }
1966
2163
  this.replayTurn(
1967
2164
  id,
1968
2165
  eventsResult.status === "fulfilled" ? eventsResult.value : []
@@ -2053,8 +2250,10 @@ var ChatSession = class {
2053
2250
  }
2054
2251
  this.conversations = this.conversations.filter((c) => c.id !== id);
2055
2252
  if (this.conversationId === id) {
2253
+ this.loadGeneration++;
2056
2254
  this.conversationId = null;
2057
- this.messages = [];
2255
+ this.setMessages([]);
2256
+ this.messagesConversationId = null;
2058
2257
  }
2059
2258
  }
2060
2259
  toggleClientTool(name) {
@@ -2070,6 +2269,11 @@ var ChatSession = class {
2070
2269
  // src/restore-plan.ts
2071
2270
  function planRestore(args) {
2072
2271
  const { completedJobs, userMessages } = args;
2272
+ const claimed = new Set(
2273
+ (args.claimedMessageIds ?? completedJobs.map((j) => j.message_id)).filter(
2274
+ (id) => !!id
2275
+ )
2276
+ );
2073
2277
  const byId = /* @__PURE__ */ new Map();
2074
2278
  userMessages.forEach((m, i) => {
2075
2279
  if (m.id) byId.set(m.id, i);
@@ -2091,7 +2295,7 @@ function planRestore(args) {
2091
2295
  userMessages.slice(0, cutover)
2092
2296
  );
2093
2297
  let cursor = cutover;
2094
- const isSteer = (m) => !!m?.id && !completedJobs.some((j) => j.message_id === m.id);
2298
+ const isSteer = (m) => !!m?.id && !claimed.has(m.id);
2095
2299
  const drainTo = (stopAt) => {
2096
2300
  while (cursor < stopAt) {
2097
2301
  const m = userMessages[cursor++];
@@ -2177,6 +2381,12 @@ var StreamManager = class {
2177
2381
  this._backgroundJobs = /* @__PURE__ */ new Map();
2178
2382
  this.handlers = [];
2179
2383
  this.unsub = null;
2384
+ /**
2385
+ * Bumped every time the active conversation moves. An async sequence that
2386
+ * captures it can then tell, at each await boundary, whether it is still the
2387
+ * one the user is waiting on — see ``restore``.
2388
+ */
2389
+ this.generation = 0;
2180
2390
  this.session = session;
2181
2391
  this.attach();
2182
2392
  }
@@ -2227,7 +2437,7 @@ var StreamManager = class {
2227
2437
  event
2228
2438
  });
2229
2439
  if (event.type === ChatEventType.MessageStop) {
2230
- if (this._state === "streaming") {
2440
+ if (this._state === "streaming" && !this.session.isStreaming) {
2231
2441
  this.setState("idle");
2232
2442
  }
2233
2443
  }
@@ -2240,13 +2450,21 @@ var StreamManager = class {
2240
2450
  );
2241
2451
  }
2242
2452
  if (this._state === "streaming") return;
2243
- if (!this._activeConversationId) {
2244
- const id = await this.session.createNewConversation();
2245
- this.setActiveConversation(id);
2453
+ let target = this._activeConversationId;
2454
+ if (!target) {
2455
+ target = await this.session.createNewConversation();
2456
+ this.setActiveConversation(target);
2246
2457
  }
2247
2458
  this.setState("streaming");
2248
2459
  try {
2249
2460
  await this.session.send(content, {
2461
+ // Address the send explicitly. `ChatSession.send` otherwise falls back
2462
+ // to `session.conversationId`, which LAGS this pointer: a restore
2463
+ // assigns it synchronously but only reaches the next switch's own
2464
+ // `loadConversation` an await later, so between the two the session
2465
+ // still names the conversation the user left. The manager's pointer
2466
+ // moved the moment the user clicked; it is the authority.
2467
+ conversationId: target ?? void 0,
2250
2468
  agentName: options?.agentName,
2251
2469
  uploadIds: options?.uploadIds,
2252
2470
  planMode: options?.planMode,
@@ -2265,6 +2483,9 @@ var StreamManager = class {
2265
2483
  // ── Regenerate ────────────────────────────────────────────────
2266
2484
  async regenerate() {
2267
2485
  if (this._state === "streaming") return;
2486
+ if (this.session.messagesConversationId !== this._activeConversationId) {
2487
+ return;
2488
+ }
2268
2489
  const userMsgs = this.session.messages.filter(
2269
2490
  (m) => m.role === "user"
2270
2491
  );
@@ -2301,44 +2522,78 @@ var StreamManager = class {
2301
2522
  async switchTo(conversationId, opts) {
2302
2523
  if (conversationId === this._activeConversationId) return;
2303
2524
  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)) {
2525
+ this.detachStreamingTurn();
2526
+ const parkedJobId = this._backgroundJobs.get(conversationId);
2527
+ if (parkedJobId !== void 0) {
2317
2528
  this._backgroundJobs.delete(conversationId);
2318
2529
  this.emit({
2319
2530
  type: "backgroundJobsChanged",
2320
2531
  jobs: this._backgroundJobs
2321
2532
  });
2322
2533
  }
2323
- this.setActiveConversation(conversationId);
2534
+ const gen = this.setActiveConversation(conversationId);
2324
2535
  if (opts?.skipHistoryReplay && !targetHadBackgroundJob) {
2325
2536
  let activeJobId = null;
2326
2537
  try {
2327
2538
  activeJobId = (await this.session.client.getActiveJob(conversationId)).jobId;
2328
2539
  } catch {
2329
2540
  }
2541
+ if (gen !== this.generation) return;
2330
2542
  if (!activeJobId) {
2331
- await this.session.loadConversation(conversationId);
2332
- this.setState("idle");
2543
+ try {
2544
+ await this.session.loadConversation(conversationId);
2545
+ } finally {
2546
+ if (gen === this.generation) this.settleIdle();
2547
+ }
2333
2548
  return;
2334
2549
  }
2335
2550
  }
2336
- await this.restore(conversationId);
2551
+ let tookOver = false;
2552
+ try {
2553
+ await this.restore(conversationId, gen);
2554
+ tookOver = gen === this.generation;
2555
+ } finally {
2556
+ const supersededOntoSame = gen !== this.generation && this._activeConversationId === conversationId;
2557
+ const stillExists = this.session.conversations.some(
2558
+ (c) => c.id === conversationId
2559
+ );
2560
+ if (parkedJobId !== void 0 && !tookOver && !supersededOntoSame && stillExists) {
2561
+ this._backgroundJobs.set(conversationId, parkedJobId);
2562
+ this.emit({
2563
+ type: "backgroundJobsChanged",
2564
+ jobs: this._backgroundJobs
2565
+ });
2566
+ }
2567
+ if (!tookOver && gen === this.generation && this._state === "restoring") {
2568
+ this.settleIdle();
2569
+ }
2570
+ }
2337
2571
  }
2338
2572
  // ── Create / rename / delete conversation ─────────────────────
2573
+ /**
2574
+ * Create a conversation and make it active.
2575
+ *
2576
+ * The returned id is NOT guaranteed to be the active conversation: if a
2577
+ * switch lands inside the storage round-trip, this declines the pointer move
2578
+ * so the newer one wins, and no `conversationChanged` fires for the new id.
2579
+ * A caller that routes on the return value should `switchTo(id)` rather than
2580
+ * assume it is current — that call is not a no-op in the declined case.
2581
+ */
2339
2582
  async createConversation() {
2340
- const id = await this.session.createNewConversation();
2583
+ this.detachStreamingTurn();
2584
+ let id;
2585
+ try {
2586
+ id = await this.session.createNewConversation();
2587
+ } catch (err) {
2588
+ this.settleIdle();
2589
+ throw err;
2590
+ }
2591
+ if (this.session.conversationId !== id) {
2592
+ this.settleIdle();
2593
+ return id;
2594
+ }
2341
2595
  this.setActiveConversation(id);
2596
+ this.settleIdle();
2342
2597
  return id;
2343
2598
  }
2344
2599
  /**
@@ -2349,16 +2604,34 @@ var StreamManager = class {
2349
2604
  await this.session.renameConversation(id, title);
2350
2605
  }
2351
2606
  async deleteConversation(id) {
2352
- await this.session.deleteConversation(id);
2353
- this._backgroundJobs.delete(id);
2607
+ const wasActive = this._activeConversationId === id;
2608
+ const cancelled = wasActive && this._state === "streaming";
2609
+ if (cancelled) {
2610
+ this.session.cancelTurn();
2611
+ this._state = "idle";
2612
+ }
2613
+ try {
2614
+ await this.session.deleteConversation(id);
2615
+ } catch (err) {
2616
+ if (cancelled) this.setState("idle");
2617
+ throw err;
2618
+ }
2354
2619
  if (this._activeConversationId === id) {
2355
- this._activeConversationId = null;
2356
- this.emit({ type: "conversationChanged", conversationId: null });
2620
+ this.setActiveConversation(null);
2621
+ this.settleIdle();
2622
+ }
2623
+ const parkedJobId = this._backgroundJobs.get(id);
2624
+ if (this._backgroundJobs.delete(id)) {
2625
+ if (parkedJobId) {
2626
+ this.session.client.cancelJob(parkedJobId).catch(() => {
2627
+ });
2628
+ }
2629
+ this.emit({ type: "backgroundJobsChanged", jobs: this._backgroundJobs });
2357
2630
  }
2358
2631
  }
2359
2632
  // ── Stop (explicit cancel) ────────────────────────────────────
2360
2633
  stop() {
2361
- this.session.disconnect();
2634
+ this.session.cancelTurn();
2362
2635
  this.setState("idle");
2363
2636
  }
2364
2637
  // ── Cleanup ───────────────────────────────────────────────────
@@ -2370,34 +2643,80 @@ var StreamManager = class {
2370
2643
  this.handlers = [];
2371
2644
  }
2372
2645
  // ── Internal: helpers ──────────────────────────────────────────
2646
+ /**
2647
+ * Park a streaming turn as a background job and detach from its SSE stream.
2648
+ *
2649
+ * Every method that relocates the active conversation has to do this before
2650
+ * announcing a new state. Announcing `idle` while `session.isStreaming` is
2651
+ * still true is worse than announcing nothing: `manager.send` no longer bails
2652
+ * on the streaming state, calls `session.send`, and THAT bails on its own
2653
+ * `isStreaming` — so the message is never posted, no error is emitted, and
2654
+ * the composer looks ready the whole time.
2655
+ */
2656
+ detachStreamingTurn() {
2657
+ if (this._state !== "streaming") return;
2658
+ const oldConvId = this._activeConversationId;
2659
+ const jobId = this.session.currentJobId;
2660
+ if (oldConvId && jobId) {
2661
+ this._backgroundJobs.set(oldConvId, jobId);
2662
+ this.emit({ type: "backgroundJobsChanged", jobs: this._backgroundJobs });
2663
+ }
2664
+ this.session.detach();
2665
+ this.session.currentJobId = null;
2666
+ this._state = "idle";
2667
+ }
2668
+ /**
2669
+ * Announce `idle` unless a turn is actually streaming.
2670
+ *
2671
+ * A `send` can land inside any of the switch paths — the fast path most
2672
+ * easily, since it deliberately stays out of `restoring` and so leaves the
2673
+ * composer live for the whole probe. `send` sets `streaming` and does not
2674
+ * bump the generation, so the path resumes, passes its supersession check,
2675
+ * and would announce a ready composer over a running stream. From there
2676
+ * `finalizeStream` and the `message_stop` branch both no-op (they only act
2677
+ * on `streaming`), so it stays `idle` for the whole turn — and the next send
2678
+ * reaches `session.send`, which bails on its own `isStreaming`: message
2679
+ * never posted, no error, composer ready throughout.
2680
+ */
2681
+ settleIdle() {
2682
+ if (this._state === "streaming") return;
2683
+ this.setState("idle");
2684
+ }
2373
2685
  finalizeStream() {
2374
2686
  if (this._state === "streaming") {
2375
2687
  this.setState("idle");
2376
2688
  }
2377
2689
  }
2378
2690
  // ── Internal: restore ─────────────────────────────────────────
2379
- async restore(conversationId) {
2380
- this.setState("restoring");
2691
+ async restore(conversationId, gen) {
2692
+ const superseded = () => gen !== this.generation;
2693
+ if (superseded()) return;
2694
+ if (!this.session.isStreaming) this.setState("restoring");
2381
2695
  let activeJobId = null;
2382
2696
  try {
2383
2697
  const res = await this.session.client.getActiveJob(conversationId);
2384
2698
  activeJobId = res.jobId;
2385
2699
  } catch {
2386
2700
  }
2701
+ if (superseded()) return;
2387
2702
  if (activeJobId) {
2388
2703
  await this.session.loadConversation(conversationId);
2704
+ if (superseded()) return;
2389
2705
  this.setState("streaming");
2390
2706
  try {
2391
2707
  await this.session.reconnectToJob(activeJobId);
2392
2708
  } catch {
2393
2709
  }
2394
- if (this._state === "streaming") {
2710
+ if (superseded()) return;
2711
+ if (this._state === "streaming" && !this.session.isStreaming) {
2395
2712
  this.setState("idle");
2396
2713
  }
2397
2714
  } else {
2398
2715
  await this.session.loadConversation(conversationId);
2716
+ if (superseded()) return;
2399
2717
  try {
2400
2718
  const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2719
+ if (superseded()) return;
2401
2720
  const completedJobs = jobs.filter(
2402
2721
  (j) => j.status === "completed"
2403
2722
  );
@@ -2409,6 +2728,14 @@ var StreamManager = class {
2409
2728
  job_id: j.job_id,
2410
2729
  message_id: j.message_id
2411
2730
  })),
2731
+ // Completed jobs PLUS the ones still going. A send landing in the
2732
+ // probe window has a running job, so its prompt is claimed and does
2733
+ // not read as a steer replayed over the bubble the live send already
2734
+ // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2735
+ // they produce no `turn` step, so claiming them would delete the
2736
+ // user's prompt from the restore entirely rather than show it as a
2737
+ // steer.
2738
+ claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2412
2739
  userMessages: userMessages.map((m) => ({
2413
2740
  id: m.id,
2414
2741
  content: m.content
@@ -2419,10 +2746,12 @@ var StreamManager = class {
2419
2746
  (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2420
2747
  )
2421
2748
  );
2749
+ if (superseded()) return;
2422
2750
  const eventsByJobId = new Map(
2423
2751
  completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2424
2752
  );
2425
2753
  for (const step of plan) {
2754
+ if (superseded()) return;
2426
2755
  if (step.kind === "steer") {
2427
2756
  this.session.replayTurn(
2428
2757
  conversationId,
@@ -2440,6 +2769,7 @@ var StreamManager = class {
2440
2769
  step.messageId
2441
2770
  );
2442
2771
  }
2772
+ if (superseded()) return;
2443
2773
  if (completedJobs.length > 0) {
2444
2774
  this.emit({
2445
2775
  type: "versionsReady",
@@ -2449,13 +2779,17 @@ var StreamManager = class {
2449
2779
  }
2450
2780
  } catch {
2451
2781
  }
2452
- this.setState("idle");
2782
+ if (superseded()) return;
2783
+ this.settleIdle();
2453
2784
  }
2454
2785
  }
2455
2786
  // ── Internal: set active conversation ─────────────────────────
2456
2787
  setActiveConversation(id) {
2457
2788
  this._activeConversationId = id;
2789
+ this.session.invalidateLoadsInFlight();
2790
+ const claimed = ++this.generation;
2458
2791
  this.emit({ type: "conversationChanged", conversationId: id });
2792
+ return claimed;
2459
2793
  }
2460
2794
  };
2461
2795