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