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