@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.js CHANGED
@@ -676,6 +676,11 @@ var AstralformClient = class {
676
676
  // undefined so it matches the `url?: string` type and consumers that
677
677
  // check `!== undefined` never receive a null.
678
678
  url: raw.url ?? void 0,
679
+ // This mapping is an ALLOWLIST — a field the API returns and this
680
+ // function does not name is dropped silently, and no type error says so.
681
+ // `content_url` shipped that way and was invisible to every consumer.
682
+ contentUrl: raw.content_url ?? void 0,
683
+ posterUrl: raw.poster_url ?? void 0,
679
684
  createdAt: raw.created_at
680
685
  };
681
686
  }
@@ -1329,6 +1334,17 @@ var ChatSession = class {
1329
1334
  /** True while ``loadMoreConversations`` is in flight. */
1330
1335
  this.isLoadingConversations = false;
1331
1336
  this.messages = [];
1337
+ /**
1338
+ * Which conversation ``messages`` currently holds.
1339
+ *
1340
+ * Distinct from ``conversationId``, and the distinction is the point:
1341
+ * ``loadConversation`` moves the POINTER synchronously and installs the LIST
1342
+ * an await later, so for the whole duration of every load the two disagree.
1343
+ * Anything pairing a message with a conversation — ``regenerate`` above all —
1344
+ * has to read this one, or it will pair the previous conversation's last
1345
+ * message with the new conversation's id.
1346
+ */
1347
+ this.messagesConversationId = null;
1332
1348
  this.isStreaming = false;
1333
1349
  this.agentStatus = null;
1334
1350
  this.agents = [];
@@ -1361,6 +1377,43 @@ var ChatSession = class {
1361
1377
  * is discarded instead.
1362
1378
  */
1363
1379
  this.conversationsGeneration = 0;
1380
+ /**
1381
+ * Bumped by every ``loadConversation`` call, so an out-of-order fetch can
1382
+ * tell it is no longer the newest one and drop its result. Separate from
1383
+ * ``conversationsGeneration``, which guards the conversation LIST.
1384
+ */
1385
+ this.loadGeneration = 0;
1386
+ /**
1387
+ * Ids of locally-created user messages the server has not acknowledged yet.
1388
+ *
1389
+ * Arrival time cannot answer "could the reply have included this?" on its
1390
+ * own. Every `loadConversation` in `StreamManager` sits behind the
1391
+ * active-job probe, so the ORDINARY ordering is that a send lands BEFORE the
1392
+ * load starts — and an arrival-time rule drops exactly those, losing the
1393
+ * prompt the user just sent while its stream is still running. Membership
1394
+ * here is set by `send` and cleared when a server row turns up carrying the
1395
+ * same turn, so the keep-decision no longer depends on which side of the
1396
+ * fetch the push landed on.
1397
+ *
1398
+ * The value is `serverRowsKnown` as of the `message_stop` that proved the
1399
+ * row committed, or 0 until then — including after the job response, which
1400
+ * hands back the id but starts the loop as a background task and so proves
1401
+ * nothing about the row. That stamp is what separates "the snapshot predates
1402
+ * the row" from "the server does not have this row" — see
1403
+ * `loadConversation`.
1404
+ *
1405
+ * It is an ANNOTATION ON `this.messages`: reconciliation only ever consults
1406
+ * entries of that array, so an id whose message has left it is dead weight.
1407
+ * `setMessages` is the single place the array is replaced, and it prunes.
1408
+ */
1409
+ this.pendingUserMessages = /* @__PURE__ */ new Map();
1410
+ /**
1411
+ * Bumped once per completed turn, at `message_stop`, so a fetch can record
1412
+ * what was proven when it was ISSUED. A row proven committed before the
1413
+ * fetch went out must appear in its snapshot; one proven after may
1414
+ * legitimately be missing.
1415
+ */
1416
+ this.serverRowsKnown = 0;
1364
1417
  // Minimal in-session accumulation for the assistant message record.
1365
1418
  // Only top-level ``text`` blocks contribute; subagent / tool output
1366
1419
  // is tracked by the consumer's own block store.
@@ -1383,6 +1436,19 @@ var ChatSession = class {
1383
1436
  this.toolRegistry = new ToolRegistry();
1384
1437
  this.storage = storage ?? new InMemoryStorage();
1385
1438
  }
1439
+ /**
1440
+ * Replace the message list, keeping `pendingUserMessages` an annotation on
1441
+ * it. Every removal from the array goes through here — `push` is the only
1442
+ * other mutation and it cannot orphan an id.
1443
+ */
1444
+ setMessages(next) {
1445
+ this.messages = next;
1446
+ if (this.pendingUserMessages.size === 0) return;
1447
+ const present = new Set(next.map((m) => m.id));
1448
+ for (const id of this.pendingUserMessages.keys()) {
1449
+ if (!present.has(id)) this.pendingUserMessages.delete(id);
1450
+ }
1451
+ }
1386
1452
  on(handler) {
1387
1453
  this.handlers.add(handler);
1388
1454
  return () => {
@@ -1431,6 +1497,22 @@ var ChatSession = class {
1431
1497
  }
1432
1498
  if (this.isStreaming) return;
1433
1499
  const conversationId = options?.conversationId ?? this.conversationId ?? void 0;
1500
+ let relocatedFrom = null;
1501
+ if (conversationId) {
1502
+ if (conversationId !== this.conversationId) {
1503
+ this.loadGeneration++;
1504
+ relocatedFrom = {
1505
+ messages: this.messages,
1506
+ messagesId: this.messagesConversationId,
1507
+ conversationId: this.conversationId,
1508
+ generation: this.loadGeneration,
1509
+ target: conversationId
1510
+ };
1511
+ this.setMessages([]);
1512
+ this.messagesConversationId = null;
1513
+ }
1514
+ this.conversationId = conversationId;
1515
+ }
1434
1516
  const userMessage = {
1435
1517
  id: generateId(),
1436
1518
  conversationId: conversationId ?? "",
@@ -1440,9 +1522,11 @@ var ChatSession = class {
1440
1522
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1441
1523
  };
1442
1524
  if (conversationId) {
1443
- await this.storage.addMessage(userMessage, conversationId);
1525
+ await this.storage.addMessage(userMessage, conversationId).catch(() => {
1526
+ });
1444
1527
  }
1445
1528
  this.messages.push(userMessage);
1529
+ this.pendingUserMessages.set(userMessage.id, 0);
1446
1530
  const request = {
1447
1531
  message: content,
1448
1532
  conversation_id: conversationId,
@@ -1462,7 +1546,24 @@ var ChatSession = class {
1462
1546
  reasoning_effort: options?.reasoningEffort,
1463
1547
  temperature: options?.temperature
1464
1548
  };
1465
- await this.processStream(request);
1549
+ const wire = { reached: false };
1550
+ await this.processStream(request, wire);
1551
+ if (!wire.reached) {
1552
+ this.pendingUserMessages.delete(userMessage.id);
1553
+ const at = this.messages.indexOf(userMessage);
1554
+ if (at !== -1) this.messages.splice(at, 1);
1555
+ if (conversationId) {
1556
+ await this.storage.deleteMessage(userMessage.id).catch(() => {
1557
+ });
1558
+ }
1559
+ }
1560
+ if (relocatedFrom && wire.reached && this.loadGeneration === relocatedFrom.generation) {
1561
+ this.messagesConversationId = relocatedFrom.target;
1562
+ } else if (relocatedFrom && !wire.reached && this.loadGeneration === relocatedFrom.generation) {
1563
+ this.setMessages(relocatedFrom.messages);
1564
+ this.messagesConversationId = relocatedFrom.messagesId;
1565
+ this.conversationId = relocatedFrom.conversationId;
1566
+ }
1466
1567
  }
1467
1568
  async resendFromCheckpoint(messageId, newContent) {
1468
1569
  if (this.isStreaming) return;
@@ -1479,12 +1580,13 @@ var ChatSession = class {
1479
1580
  this.accumulatedText = "";
1480
1581
  this.currentTextPath = null;
1481
1582
  }
1482
- async processStream(request) {
1583
+ async processStream(request, wire) {
1483
1584
  this.isStreaming = true;
1484
1585
  this.resetStreamingState();
1485
- this.abortController = new AbortController();
1586
+ const controller = new AbortController();
1587
+ this.abortController = controller;
1486
1588
  try {
1487
- await this.consumeJobStream(request);
1589
+ await this.consumeJobStream(request, wire);
1488
1590
  } catch (err) {
1489
1591
  if (!(err instanceof DOMException && err.name === "AbortError")) {
1490
1592
  this.emit({
@@ -1495,16 +1597,20 @@ var ChatSession = class {
1495
1597
  });
1496
1598
  }
1497
1599
  } finally {
1498
- this.isStreaming = false;
1499
- this.abortController = null;
1600
+ if (this.abortController === controller) {
1601
+ this.isStreaming = false;
1602
+ this.abortController = null;
1603
+ }
1500
1604
  }
1501
1605
  }
1502
- async consumeJobStream(request) {
1606
+ async consumeJobStream(request, wire) {
1503
1607
  const job = await this.client.createJob(request);
1608
+ if (wire) wire.reached = true;
1504
1609
  this.currentJobId = job.job_id;
1505
1610
  const conversationId = job.conversation_id;
1506
1611
  if (!this.conversationId) {
1507
1612
  this.conversationId = conversationId;
1613
+ this.messagesConversationId = conversationId;
1508
1614
  }
1509
1615
  if (!this.conversations.some((c) => c.id === conversationId)) {
1510
1616
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1525,13 +1631,29 @@ var ChatSession = class {
1525
1631
  await this.storage.addMessage(lastMsg, conversationId).catch(() => {
1526
1632
  });
1527
1633
  }
1528
- const messageId = job.message_id;
1634
+ const promptMessageId = job.message_id;
1635
+ if (promptMessageId && lastMsg?.role === "user" && this.pendingUserMessages.has(lastMsg.id)) {
1636
+ this.pendingUserMessages.delete(lastMsg.id);
1637
+ const clientMintedId = lastMsg.id;
1638
+ lastMsg.id = promptMessageId;
1639
+ this.pendingUserMessages.set(promptMessageId, 0);
1640
+ if (conversationId && clientMintedId !== promptMessageId) {
1641
+ await this.storage.deleteMessage(clientMintedId).catch(() => {
1642
+ });
1643
+ await this.storage.addMessage(lastMsg, conversationId).catch(() => {
1644
+ });
1645
+ }
1646
+ const dupe = this.messages.findIndex(
1647
+ (m) => m !== lastMsg && m.id === promptMessageId
1648
+ );
1649
+ if (dupe !== -1) this.messages.splice(dupe, 1);
1650
+ }
1529
1651
  this.lastSeq = -1;
1530
1652
  this.submittedToolCallIds.clear();
1531
1653
  await this.consumeEventStream(
1532
1654
  job.job_id,
1533
1655
  conversationId,
1534
- messageId,
1656
+ promptMessageId,
1535
1657
  true
1536
1658
  // executeClientTools
1537
1659
  );
@@ -1540,7 +1662,7 @@ var ChatSession = class {
1540
1662
  * Shared event consumption loop. Parses each wire event, updates
1541
1663
  * minimal session state, and emits typed ChatEvents to consumers.
1542
1664
  */
1543
- async consumeEventStream(jobId, conversationId, messageId, executeClientTools) {
1665
+ async consumeEventStream(jobId, conversationId, promptMessageId, executeClientTools) {
1544
1666
  const signal = this.abortController?.signal;
1545
1667
  for (let attempt = 0; ; attempt++) {
1546
1668
  if (signal?.aborted) return;
@@ -1557,7 +1679,7 @@ var ChatSession = class {
1557
1679
  sawTerminal = await this.pumpStream(
1558
1680
  stream,
1559
1681
  conversationId,
1560
- messageId,
1682
+ promptMessageId,
1561
1683
  executeClientTools,
1562
1684
  () => attemptController.abort()
1563
1685
  );
@@ -1589,7 +1711,7 @@ var ChatSession = class {
1589
1711
  * zombie — ``reader.read()`` will never settle — so we kill the fetch and
1590
1712
  * throw a ConnectionError, feeding the caller's reconnect-from-lastSeq loop.
1591
1713
  */
1592
- async pumpStream(stream, conversationId, messageId, executeClientTools, onStall) {
1714
+ async pumpStream(stream, conversationId, promptMessageId, executeClientTools, onStall) {
1593
1715
  let sawTerminal = false;
1594
1716
  const iterator = stream[Symbol.asyncIterator]();
1595
1717
  try {
@@ -1636,7 +1758,7 @@ var ChatSession = class {
1636
1758
  await this.dispatchWireEvent(
1637
1759
  parsed,
1638
1760
  conversationId,
1639
- messageId,
1761
+ promptMessageId,
1640
1762
  executeClientTools
1641
1763
  );
1642
1764
  }
@@ -1678,8 +1800,8 @@ var ChatSession = class {
1678
1800
  }
1679
1801
  }
1680
1802
  }
1681
- async dispatchWireEvent(wire, conversationId, messageId, executeClientTools) {
1682
- this.applyWireSideEffects(wire, conversationId, messageId);
1803
+ async dispatchWireEvent(wire, conversationId, promptMessageId, executeClientTools) {
1804
+ this.applyWireSideEffects(wire, conversationId, promptMessageId, true);
1683
1805
  const event = translateWireEvent(wire);
1684
1806
  if (event) {
1685
1807
  this.emit(event);
@@ -1697,7 +1819,9 @@ var ChatSession = class {
1697
1819
  const results = await this.executeClientTools([request]);
1698
1820
  await this.submitToolResultWithRetry({
1699
1821
  conversation_id: conversationId,
1700
- message_id: messageId,
1822
+ // The message that TRIGGERED the tool calls, which is what the
1823
+ // backend stores it against — the prompt id, correctly.
1824
+ message_id: promptMessageId,
1701
1825
  tool_results: results
1702
1826
  });
1703
1827
  this.submittedToolCallIds.add(callId);
@@ -1712,7 +1836,7 @@ var ChatSession = class {
1712
1836
  * instead of re-typing the whole conversation event by event.
1713
1837
  */
1714
1838
  replayWireEvent(wire, conversationId) {
1715
- this.applyWireSideEffects(wire, conversationId, "");
1839
+ this.applyWireSideEffects(wire, conversationId, "", false);
1716
1840
  const event = translateWireEvent(wire);
1717
1841
  if (event) {
1718
1842
  this.emit(event);
@@ -1723,37 +1847,52 @@ var ChatSession = class {
1723
1847
  * the pure wire → ChatEvent mapping can live in translate.ts and be reused
1724
1848
  * by the replay path.
1725
1849
  *
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.
1850
+ * ``promptMessageId`` is the id of the USER turn that started this job —
1851
+ * `POST /v1/jobs` returns it and the backend tags the prompt with it
1852
+ * (`HumanMessage(content=..., id=message_id)`), which is what lets a restore
1853
+ * pair a job with its prompt by id instead of by position. It was previously
1854
+ * documented here as the ASSISTANT's id and used as one; there is no
1855
+ * server-assigned assistant id on the wire, so that row gets a local one.
1856
+ * Empty in the reconnect and conversation-switch replay paths, where the
1857
+ * messages have already been loaded from REST and must not be re-pushed —
1858
+ * so it doubles as the "is this a live send?" gate.
1729
1859
  */
1730
- applyWireSideEffects(wire, conversationId, messageId) {
1860
+ applyWireSideEffects(wire, conversationId, promptMessageId, live) {
1861
+ const ownsTurnState = live || !this.isStreaming;
1731
1862
  switch (wire.type) {
1732
1863
  case "message_start":
1733
- this.resetStreamingState();
1864
+ if (ownsTurnState) this.resetStreamingState();
1734
1865
  if (wire.model) {
1735
1866
  this.modelDisplayName = wire.model;
1736
1867
  }
1737
1868
  return;
1738
1869
  case "block_start":
1739
- if (wire.kind === "text" && (!wire.parent_path || wire.parent_path.length === 0)) {
1870
+ if (ownsTurnState && wire.kind === "text" && (!wire.parent_path || wire.parent_path.length === 0)) {
1740
1871
  this.currentTextPath = wire.path;
1741
1872
  }
1742
1873
  return;
1743
1874
  case "block_delta":
1744
- if (wire.delta.channel === "text" && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1875
+ if (ownsTurnState && wire.delta.channel === "text" && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1745
1876
  this.accumulatedText += wire.delta.text;
1746
1877
  }
1747
1878
  return;
1748
1879
  case "block_stop":
1749
- if (this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1880
+ if (ownsTurnState && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
1750
1881
  this.currentTextPath = null;
1751
1882
  }
1752
1883
  return;
1753
1884
  case "message_stop":
1754
- if (messageId) {
1885
+ if (promptMessageId && this.pendingUserMessages.has(promptMessageId)) {
1886
+ this.pendingUserMessages.set(promptMessageId, ++this.serverRowsKnown);
1887
+ }
1888
+ if (promptMessageId) {
1755
1889
  const assistantMessage = {
1756
- id: messageId,
1890
+ // NOT `promptMessageId` — that is the USER turn's id, which
1891
+ // `consumeJobStream` stamps onto the user row. Sharing it puts two
1892
+ // rows under one id, and `pendingUserMessages` is id-keyed. No
1893
+ // server-assigned assistant id exists on the wire, so this row is
1894
+ // local until a REST load replaces it.
1895
+ id: generateId(),
1757
1896
  conversationId,
1758
1897
  role: "assistant",
1759
1898
  content: this.accumulatedText,
@@ -1764,8 +1903,10 @@ var ChatSession = class {
1764
1903
  this.storage.addMessage(assistantMessage, conversationId).catch(() => {
1765
1904
  });
1766
1905
  }
1767
- this.isStreaming = false;
1768
- this.currentJobId = null;
1906
+ if (ownsTurnState) {
1907
+ this.isStreaming = false;
1908
+ this.currentJobId = null;
1909
+ }
1769
1910
  return;
1770
1911
  case "custom":
1771
1912
  if (wire.name === "title_generated") {
@@ -1799,9 +1940,27 @@ var ChatSession = class {
1799
1940
  * Used before reconnectToJob — SSE replay handles event replay.
1800
1941
  */
1801
1942
  async loadConversation(id) {
1943
+ const load = ++this.loadGeneration;
1802
1944
  this.conversationId = id;
1803
- this.resetStreamingState();
1804
- this.messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
1945
+ if (!this.isStreaming) this.resetStreamingState();
1946
+ const rowsKnownAtIssue = this.serverRowsKnown;
1947
+ const messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
1948
+ if (load !== this.loadGeneration) return;
1949
+ const pending = this.messages.filter(
1950
+ (m) => this.pendingUserMessages.has(m.id) && m.conversationId === id
1951
+ );
1952
+ const stillPending = pending.filter((m) => {
1953
+ if (messages.some((f) => f.id === m.id)) return false;
1954
+ const knownAt = this.pendingUserMessages.get(m.id) ?? 0;
1955
+ return knownAt === 0 || knownAt > rowsKnownAtIssue;
1956
+ });
1957
+ for (const m of pending) {
1958
+ if (!stillPending.includes(m)) this.pendingUserMessages.delete(m.id);
1959
+ }
1960
+ this.setMessages(
1961
+ stillPending.length ? [...messages, ...stillPending] : messages
1962
+ );
1963
+ this.messagesConversationId = id;
1805
1964
  }
1806
1965
  /**
1807
1966
  * Reconnect to a running job's SSE stream (e.g. after page reload).
@@ -1814,7 +1973,8 @@ var ChatSession = class {
1814
1973
  this.lastSeq = -1;
1815
1974
  this.submittedToolCallIds.clear();
1816
1975
  this.resetStreamingState();
1817
- this.abortController = new AbortController();
1976
+ const controller = new AbortController();
1977
+ this.abortController = controller;
1818
1978
  try {
1819
1979
  await this.consumeEventStream(
1820
1980
  jobId,
@@ -1831,8 +1991,10 @@ var ChatSession = class {
1831
1991
  blockPath: null
1832
1992
  });
1833
1993
  } finally {
1834
- this.isStreaming = false;
1835
- this.abortController = null;
1994
+ if (this.abortController === controller) {
1995
+ this.isStreaming = false;
1996
+ this.abortController = null;
1997
+ }
1836
1998
  }
1837
1999
  }
1838
2000
  /** Detach from the SSE stream without cancelling the job. */
@@ -1843,25 +2005,54 @@ var ChatSession = class {
1843
2005
  this.resetStreamingState();
1844
2006
  this.emit({ type: "disconnected" });
1845
2007
  }
1846
- /** Stop the job and disconnect (explicit user action). */
1847
- disconnect() {
2008
+ /**
2009
+ * Cancel the running turn: stop the job server-side and tear the stream
2010
+ * down, WITHOUT ending the session. A turn-level cancel is not a
2011
+ * session-level teardown, so unlike `disconnect()` this leaves the protocol
2012
+ * registry alone — the SDK never auto-registers adapters, so clearing them
2013
+ * on a Stop press would silently kill embedded-resource rendering for the
2014
+ * rest of the session with nothing to re-register it.
2015
+ */
2016
+ cancelTurn() {
1848
2017
  if (this.currentJobId) {
1849
2018
  this.client.cancelJob(this.currentJobId).catch(() => {
1850
2019
  });
1851
2020
  }
1852
2021
  this.detach();
1853
2022
  this.currentJobId = null;
2023
+ for (const [id, knownAt] of this.pendingUserMessages) {
2024
+ if (knownAt === 0) this.pendingUserMessages.delete(id);
2025
+ }
2026
+ }
2027
+ /** Stop the job and end the session's activity (explicit user action). */
2028
+ disconnect() {
2029
+ this.cancelTurn();
1854
2030
  this.protocols.clear();
1855
2031
  }
2032
+ /**
2033
+ * A pointer move happened above this layer, so any `loadConversation` in
2034
+ * flight must lose to it. `StreamManager` owns a pointer of its own and
2035
+ * moves it before this one; without this the two halves would gate on
2036
+ * counters that bump at different instants — `generation` synchronously in
2037
+ * `setActiveConversation`, `loadGeneration` only once the switch's own load
2038
+ * actually runs, which is behind the active-job probe.
2039
+ */
2040
+ invalidateLoadsInFlight() {
2041
+ this.loadGeneration++;
2042
+ }
1856
2043
  async createNewConversation() {
1857
2044
  const id = generateId();
2045
+ const load = this.loadGeneration;
1858
2046
  const conversation = await this.storage.createConversation(
1859
2047
  id,
1860
2048
  "New Conversation"
1861
2049
  );
1862
2050
  this.conversations.unshift(conversation);
2051
+ if (load !== this.loadGeneration) return id;
2052
+ this.loadGeneration++;
1863
2053
  this.conversationId = id;
1864
- this.messages = [];
2054
+ this.setMessages([]);
2055
+ this.messagesConversationId = id;
1865
2056
  return id;
1866
2057
  }
1867
2058
  /**
@@ -1880,7 +2071,7 @@ var ChatSession = class {
1880
2071
  */
1881
2072
  replayTurn(id, events, userMessageContent, userMessageId, isSteer = false) {
1882
2073
  this.conversationId = id;
1883
- this.resetStreamingState();
2074
+ if (!this.isStreaming) this.resetStreamingState();
1884
2075
  if (userMessageContent) {
1885
2076
  this.emit({
1886
2077
  type: "user_message",
@@ -1911,11 +2102,17 @@ var ChatSession = class {
1911
2102
  * job's events.
1912
2103
  */
1913
2104
  async switchConversation(id, jobId) {
1914
- const [messagesResult, eventsResult] = await Promise.allSettled([
1915
- this.client.getMessages(id).catch(() => this.storage.fetchMessages(id)),
2105
+ const loading = this.loadConversation(id);
2106
+ const token = this.loadGeneration;
2107
+ const [loadResult, eventsResult] = await Promise.allSettled([
2108
+ loading,
1916
2109
  this.client.getConversationEvents(id, jobId)
1917
2110
  ]);
1918
- this.messages = messagesResult.status === "fulfilled" ? messagesResult.value : [];
2111
+ if (token !== this.loadGeneration) return;
2112
+ if (loadResult.status === "rejected") {
2113
+ this.setMessages([]);
2114
+ this.messagesConversationId = id;
2115
+ }
1919
2116
  this.replayTurn(
1920
2117
  id,
1921
2118
  eventsResult.status === "fulfilled" ? eventsResult.value : []
@@ -2006,8 +2203,10 @@ var ChatSession = class {
2006
2203
  }
2007
2204
  this.conversations = this.conversations.filter((c) => c.id !== id);
2008
2205
  if (this.conversationId === id) {
2206
+ this.loadGeneration++;
2009
2207
  this.conversationId = null;
2010
- this.messages = [];
2208
+ this.setMessages([]);
2209
+ this.messagesConversationId = null;
2011
2210
  }
2012
2211
  }
2013
2212
  toggleClientTool(name) {
@@ -2023,6 +2222,11 @@ var ChatSession = class {
2023
2222
  // src/restore-plan.ts
2024
2223
  function planRestore(args) {
2025
2224
  const { completedJobs, userMessages } = args;
2225
+ const claimed = new Set(
2226
+ (args.claimedMessageIds ?? completedJobs.map((j) => j.message_id)).filter(
2227
+ (id) => !!id
2228
+ )
2229
+ );
2026
2230
  const byId = /* @__PURE__ */ new Map();
2027
2231
  userMessages.forEach((m, i) => {
2028
2232
  if (m.id) byId.set(m.id, i);
@@ -2044,7 +2248,7 @@ function planRestore(args) {
2044
2248
  userMessages.slice(0, cutover)
2045
2249
  );
2046
2250
  let cursor = cutover;
2047
- const isSteer = (m) => !!m?.id && !completedJobs.some((j) => j.message_id === m.id);
2251
+ const isSteer = (m) => !!m?.id && !claimed.has(m.id);
2048
2252
  const drainTo = (stopAt) => {
2049
2253
  while (cursor < stopAt) {
2050
2254
  const m = userMessages[cursor++];
@@ -2130,6 +2334,12 @@ var StreamManager = class {
2130
2334
  this._backgroundJobs = /* @__PURE__ */ new Map();
2131
2335
  this.handlers = [];
2132
2336
  this.unsub = null;
2337
+ /**
2338
+ * Bumped every time the active conversation moves. An async sequence that
2339
+ * captures it can then tell, at each await boundary, whether it is still the
2340
+ * one the user is waiting on — see ``restore``.
2341
+ */
2342
+ this.generation = 0;
2133
2343
  this.session = session;
2134
2344
  this.attach();
2135
2345
  }
@@ -2180,7 +2390,7 @@ var StreamManager = class {
2180
2390
  event
2181
2391
  });
2182
2392
  if (event.type === ChatEventType.MessageStop) {
2183
- if (this._state === "streaming") {
2393
+ if (this._state === "streaming" && !this.session.isStreaming) {
2184
2394
  this.setState("idle");
2185
2395
  }
2186
2396
  }
@@ -2193,13 +2403,21 @@ var StreamManager = class {
2193
2403
  );
2194
2404
  }
2195
2405
  if (this._state === "streaming") return;
2196
- if (!this._activeConversationId) {
2197
- const id = await this.session.createNewConversation();
2198
- this.setActiveConversation(id);
2406
+ let target = this._activeConversationId;
2407
+ if (!target) {
2408
+ target = await this.session.createNewConversation();
2409
+ this.setActiveConversation(target);
2199
2410
  }
2200
2411
  this.setState("streaming");
2201
2412
  try {
2202
2413
  await this.session.send(content, {
2414
+ // Address the send explicitly. `ChatSession.send` otherwise falls back
2415
+ // to `session.conversationId`, which LAGS this pointer: a restore
2416
+ // assigns it synchronously but only reaches the next switch's own
2417
+ // `loadConversation` an await later, so between the two the session
2418
+ // still names the conversation the user left. The manager's pointer
2419
+ // moved the moment the user clicked; it is the authority.
2420
+ conversationId: target ?? void 0,
2203
2421
  agentName: options?.agentName,
2204
2422
  uploadIds: options?.uploadIds,
2205
2423
  planMode: options?.planMode,
@@ -2218,6 +2436,9 @@ var StreamManager = class {
2218
2436
  // ── Regenerate ────────────────────────────────────────────────
2219
2437
  async regenerate() {
2220
2438
  if (this._state === "streaming") return;
2439
+ if (this.session.messagesConversationId !== this._activeConversationId) {
2440
+ return;
2441
+ }
2221
2442
  const userMsgs = this.session.messages.filter(
2222
2443
  (m) => m.role === "user"
2223
2444
  );
@@ -2254,44 +2475,78 @@ var StreamManager = class {
2254
2475
  async switchTo(conversationId, opts) {
2255
2476
  if (conversationId === this._activeConversationId) return;
2256
2477
  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)) {
2478
+ this.detachStreamingTurn();
2479
+ const parkedJobId = this._backgroundJobs.get(conversationId);
2480
+ if (parkedJobId !== void 0) {
2270
2481
  this._backgroundJobs.delete(conversationId);
2271
2482
  this.emit({
2272
2483
  type: "backgroundJobsChanged",
2273
2484
  jobs: this._backgroundJobs
2274
2485
  });
2275
2486
  }
2276
- this.setActiveConversation(conversationId);
2487
+ const gen = this.setActiveConversation(conversationId);
2277
2488
  if (opts?.skipHistoryReplay && !targetHadBackgroundJob) {
2278
2489
  let activeJobId = null;
2279
2490
  try {
2280
2491
  activeJobId = (await this.session.client.getActiveJob(conversationId)).jobId;
2281
2492
  } catch {
2282
2493
  }
2494
+ if (gen !== this.generation) return;
2283
2495
  if (!activeJobId) {
2284
- await this.session.loadConversation(conversationId);
2285
- this.setState("idle");
2496
+ try {
2497
+ await this.session.loadConversation(conversationId);
2498
+ } finally {
2499
+ if (gen === this.generation) this.settleIdle();
2500
+ }
2286
2501
  return;
2287
2502
  }
2288
2503
  }
2289
- await this.restore(conversationId);
2504
+ let tookOver = false;
2505
+ try {
2506
+ await this.restore(conversationId, gen);
2507
+ tookOver = gen === this.generation;
2508
+ } finally {
2509
+ const supersededOntoSame = gen !== this.generation && this._activeConversationId === conversationId;
2510
+ const stillExists = this.session.conversations.some(
2511
+ (c) => c.id === conversationId
2512
+ );
2513
+ if (parkedJobId !== void 0 && !tookOver && !supersededOntoSame && stillExists) {
2514
+ this._backgroundJobs.set(conversationId, parkedJobId);
2515
+ this.emit({
2516
+ type: "backgroundJobsChanged",
2517
+ jobs: this._backgroundJobs
2518
+ });
2519
+ }
2520
+ if (!tookOver && gen === this.generation && this._state === "restoring") {
2521
+ this.settleIdle();
2522
+ }
2523
+ }
2290
2524
  }
2291
2525
  // ── Create / rename / delete conversation ─────────────────────
2526
+ /**
2527
+ * Create a conversation and make it active.
2528
+ *
2529
+ * The returned id is NOT guaranteed to be the active conversation: if a
2530
+ * switch lands inside the storage round-trip, this declines the pointer move
2531
+ * so the newer one wins, and no `conversationChanged` fires for the new id.
2532
+ * A caller that routes on the return value should `switchTo(id)` rather than
2533
+ * assume it is current — that call is not a no-op in the declined case.
2534
+ */
2292
2535
  async createConversation() {
2293
- const id = await this.session.createNewConversation();
2536
+ this.detachStreamingTurn();
2537
+ let id;
2538
+ try {
2539
+ id = await this.session.createNewConversation();
2540
+ } catch (err) {
2541
+ this.settleIdle();
2542
+ throw err;
2543
+ }
2544
+ if (this.session.conversationId !== id) {
2545
+ this.settleIdle();
2546
+ return id;
2547
+ }
2294
2548
  this.setActiveConversation(id);
2549
+ this.settleIdle();
2295
2550
  return id;
2296
2551
  }
2297
2552
  /**
@@ -2302,16 +2557,34 @@ var StreamManager = class {
2302
2557
  await this.session.renameConversation(id, title);
2303
2558
  }
2304
2559
  async deleteConversation(id) {
2305
- await this.session.deleteConversation(id);
2306
- this._backgroundJobs.delete(id);
2560
+ const wasActive = this._activeConversationId === id;
2561
+ const cancelled = wasActive && this._state === "streaming";
2562
+ if (cancelled) {
2563
+ this.session.cancelTurn();
2564
+ this._state = "idle";
2565
+ }
2566
+ try {
2567
+ await this.session.deleteConversation(id);
2568
+ } catch (err) {
2569
+ if (cancelled) this.setState("idle");
2570
+ throw err;
2571
+ }
2307
2572
  if (this._activeConversationId === id) {
2308
- this._activeConversationId = null;
2309
- this.emit({ type: "conversationChanged", conversationId: null });
2573
+ this.setActiveConversation(null);
2574
+ this.settleIdle();
2575
+ }
2576
+ const parkedJobId = this._backgroundJobs.get(id);
2577
+ if (this._backgroundJobs.delete(id)) {
2578
+ if (parkedJobId) {
2579
+ this.session.client.cancelJob(parkedJobId).catch(() => {
2580
+ });
2581
+ }
2582
+ this.emit({ type: "backgroundJobsChanged", jobs: this._backgroundJobs });
2310
2583
  }
2311
2584
  }
2312
2585
  // ── Stop (explicit cancel) ────────────────────────────────────
2313
2586
  stop() {
2314
- this.session.disconnect();
2587
+ this.session.cancelTurn();
2315
2588
  this.setState("idle");
2316
2589
  }
2317
2590
  // ── Cleanup ───────────────────────────────────────────────────
@@ -2323,34 +2596,80 @@ var StreamManager = class {
2323
2596
  this.handlers = [];
2324
2597
  }
2325
2598
  // ── Internal: helpers ──────────────────────────────────────────
2599
+ /**
2600
+ * Park a streaming turn as a background job and detach from its SSE stream.
2601
+ *
2602
+ * Every method that relocates the active conversation has to do this before
2603
+ * announcing a new state. Announcing `idle` while `session.isStreaming` is
2604
+ * still true is worse than announcing nothing: `manager.send` no longer bails
2605
+ * on the streaming state, calls `session.send`, and THAT bails on its own
2606
+ * `isStreaming` — so the message is never posted, no error is emitted, and
2607
+ * the composer looks ready the whole time.
2608
+ */
2609
+ detachStreamingTurn() {
2610
+ if (this._state !== "streaming") return;
2611
+ const oldConvId = this._activeConversationId;
2612
+ const jobId = this.session.currentJobId;
2613
+ if (oldConvId && jobId) {
2614
+ this._backgroundJobs.set(oldConvId, jobId);
2615
+ this.emit({ type: "backgroundJobsChanged", jobs: this._backgroundJobs });
2616
+ }
2617
+ this.session.detach();
2618
+ this.session.currentJobId = null;
2619
+ this._state = "idle";
2620
+ }
2621
+ /**
2622
+ * Announce `idle` unless a turn is actually streaming.
2623
+ *
2624
+ * A `send` can land inside any of the switch paths — the fast path most
2625
+ * easily, since it deliberately stays out of `restoring` and so leaves the
2626
+ * composer live for the whole probe. `send` sets `streaming` and does not
2627
+ * bump the generation, so the path resumes, passes its supersession check,
2628
+ * and would announce a ready composer over a running stream. From there
2629
+ * `finalizeStream` and the `message_stop` branch both no-op (they only act
2630
+ * on `streaming`), so it stays `idle` for the whole turn — and the next send
2631
+ * reaches `session.send`, which bails on its own `isStreaming`: message
2632
+ * never posted, no error, composer ready throughout.
2633
+ */
2634
+ settleIdle() {
2635
+ if (this._state === "streaming") return;
2636
+ this.setState("idle");
2637
+ }
2326
2638
  finalizeStream() {
2327
2639
  if (this._state === "streaming") {
2328
2640
  this.setState("idle");
2329
2641
  }
2330
2642
  }
2331
2643
  // ── Internal: restore ─────────────────────────────────────────
2332
- async restore(conversationId) {
2333
- this.setState("restoring");
2644
+ async restore(conversationId, gen) {
2645
+ const superseded = () => gen !== this.generation;
2646
+ if (superseded()) return;
2647
+ if (!this.session.isStreaming) this.setState("restoring");
2334
2648
  let activeJobId = null;
2335
2649
  try {
2336
2650
  const res = await this.session.client.getActiveJob(conversationId);
2337
2651
  activeJobId = res.jobId;
2338
2652
  } catch {
2339
2653
  }
2654
+ if (superseded()) return;
2340
2655
  if (activeJobId) {
2341
2656
  await this.session.loadConversation(conversationId);
2657
+ if (superseded()) return;
2342
2658
  this.setState("streaming");
2343
2659
  try {
2344
2660
  await this.session.reconnectToJob(activeJobId);
2345
2661
  } catch {
2346
2662
  }
2347
- if (this._state === "streaming") {
2663
+ if (superseded()) return;
2664
+ if (this._state === "streaming" && !this.session.isStreaming) {
2348
2665
  this.setState("idle");
2349
2666
  }
2350
2667
  } else {
2351
2668
  await this.session.loadConversation(conversationId);
2669
+ if (superseded()) return;
2352
2670
  try {
2353
2671
  const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2672
+ if (superseded()) return;
2354
2673
  const completedJobs = jobs.filter(
2355
2674
  (j) => j.status === "completed"
2356
2675
  );
@@ -2362,6 +2681,14 @@ var StreamManager = class {
2362
2681
  job_id: j.job_id,
2363
2682
  message_id: j.message_id
2364
2683
  })),
2684
+ // Completed jobs PLUS the ones still going. A send landing in the
2685
+ // probe window has a running job, so its prompt is claimed and does
2686
+ // not read as a steer replayed over the bubble the live send already
2687
+ // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2688
+ // they produce no `turn` step, so claiming them would delete the
2689
+ // user's prompt from the restore entirely rather than show it as a
2690
+ // steer.
2691
+ claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2365
2692
  userMessages: userMessages.map((m) => ({
2366
2693
  id: m.id,
2367
2694
  content: m.content
@@ -2372,10 +2699,12 @@ var StreamManager = class {
2372
2699
  (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2373
2700
  )
2374
2701
  );
2702
+ if (superseded()) return;
2375
2703
  const eventsByJobId = new Map(
2376
2704
  completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2377
2705
  );
2378
2706
  for (const step of plan) {
2707
+ if (superseded()) return;
2379
2708
  if (step.kind === "steer") {
2380
2709
  this.session.replayTurn(
2381
2710
  conversationId,
@@ -2393,6 +2722,7 @@ var StreamManager = class {
2393
2722
  step.messageId
2394
2723
  );
2395
2724
  }
2725
+ if (superseded()) return;
2396
2726
  if (completedJobs.length > 0) {
2397
2727
  this.emit({
2398
2728
  type: "versionsReady",
@@ -2402,13 +2732,17 @@ var StreamManager = class {
2402
2732
  }
2403
2733
  } catch {
2404
2734
  }
2405
- this.setState("idle");
2735
+ if (superseded()) return;
2736
+ this.settleIdle();
2406
2737
  }
2407
2738
  }
2408
2739
  // ── Internal: set active conversation ─────────────────────────
2409
2740
  setActiveConversation(id) {
2410
2741
  this._activeConversationId = id;
2742
+ this.session.invalidateLoadsInFlight();
2743
+ const claimed = ++this.generation;
2411
2744
  this.emit({ type: "conversationChanged", conversationId: id });
2745
+ return claimed;
2412
2746
  }
2413
2747
  };
2414
2748