@astralform/js 4.9.0 → 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 +468 -96
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +145 -4
- package/dist/index.d.ts +145 -4
- package/dist/index.js +468 -96
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -1338,6 +1338,7 @@ function translateWireEvent(wire) {
|
|
|
1338
1338
|
|
|
1339
1339
|
// src/session.ts
|
|
1340
1340
|
var SSE_MAX_RECONNECTS = 6;
|
|
1341
|
+
var SSE_STALL_TIMEOUT_MS = 45e3;
|
|
1341
1342
|
var TOOL_RESULT_MAX_RETRIES = 3;
|
|
1342
1343
|
var CONVERSATION_PAGE_SIZE = 50;
|
|
1343
1344
|
function sseReconnectDelayMs(attempt) {
|
|
@@ -1375,6 +1376,17 @@ var ChatSession = class {
|
|
|
1375
1376
|
/** True while ``loadMoreConversations`` is in flight. */
|
|
1376
1377
|
this.isLoadingConversations = false;
|
|
1377
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;
|
|
1378
1390
|
this.isStreaming = false;
|
|
1379
1391
|
this.agentStatus = null;
|
|
1380
1392
|
this.agents = [];
|
|
@@ -1407,6 +1419,43 @@ var ChatSession = class {
|
|
|
1407
1419
|
* is discarded instead.
|
|
1408
1420
|
*/
|
|
1409
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;
|
|
1410
1459
|
// Minimal in-session accumulation for the assistant message record.
|
|
1411
1460
|
// Only top-level ``text`` blocks contribute; subagent / tool output
|
|
1412
1461
|
// is tracked by the consumer's own block store.
|
|
@@ -1429,6 +1478,19 @@ var ChatSession = class {
|
|
|
1429
1478
|
this.toolRegistry = new ToolRegistry();
|
|
1430
1479
|
this.storage = storage ?? new InMemoryStorage();
|
|
1431
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
|
+
}
|
|
1432
1494
|
on(handler) {
|
|
1433
1495
|
this.handlers.add(handler);
|
|
1434
1496
|
return () => {
|
|
@@ -1477,6 +1539,22 @@ var ChatSession = class {
|
|
|
1477
1539
|
}
|
|
1478
1540
|
if (this.isStreaming) return;
|
|
1479
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
|
+
}
|
|
1480
1558
|
const userMessage = {
|
|
1481
1559
|
id: generateId(),
|
|
1482
1560
|
conversationId: conversationId ?? "",
|
|
@@ -1486,9 +1564,11 @@ var ChatSession = class {
|
|
|
1486
1564
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1487
1565
|
};
|
|
1488
1566
|
if (conversationId) {
|
|
1489
|
-
await this.storage.addMessage(userMessage, conversationId)
|
|
1567
|
+
await this.storage.addMessage(userMessage, conversationId).catch(() => {
|
|
1568
|
+
});
|
|
1490
1569
|
}
|
|
1491
1570
|
this.messages.push(userMessage);
|
|
1571
|
+
this.pendingUserMessages.set(userMessage.id, 0);
|
|
1492
1572
|
const request = {
|
|
1493
1573
|
message: content,
|
|
1494
1574
|
conversation_id: conversationId,
|
|
@@ -1508,7 +1588,24 @@ var ChatSession = class {
|
|
|
1508
1588
|
reasoning_effort: options?.reasoningEffort,
|
|
1509
1589
|
temperature: options?.temperature
|
|
1510
1590
|
};
|
|
1511
|
-
|
|
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
|
+
}
|
|
1512
1609
|
}
|
|
1513
1610
|
async resendFromCheckpoint(messageId, newContent) {
|
|
1514
1611
|
if (this.isStreaming) return;
|
|
@@ -1525,12 +1622,13 @@ var ChatSession = class {
|
|
|
1525
1622
|
this.accumulatedText = "";
|
|
1526
1623
|
this.currentTextPath = null;
|
|
1527
1624
|
}
|
|
1528
|
-
async processStream(request) {
|
|
1625
|
+
async processStream(request, wire) {
|
|
1529
1626
|
this.isStreaming = true;
|
|
1530
1627
|
this.resetStreamingState();
|
|
1531
|
-
|
|
1628
|
+
const controller = new AbortController();
|
|
1629
|
+
this.abortController = controller;
|
|
1532
1630
|
try {
|
|
1533
|
-
await this.consumeJobStream(request);
|
|
1631
|
+
await this.consumeJobStream(request, wire);
|
|
1534
1632
|
} catch (err) {
|
|
1535
1633
|
if (!(err instanceof DOMException && err.name === "AbortError")) {
|
|
1536
1634
|
this.emit({
|
|
@@ -1541,16 +1639,20 @@ var ChatSession = class {
|
|
|
1541
1639
|
});
|
|
1542
1640
|
}
|
|
1543
1641
|
} finally {
|
|
1544
|
-
this.
|
|
1545
|
-
|
|
1642
|
+
if (this.abortController === controller) {
|
|
1643
|
+
this.isStreaming = false;
|
|
1644
|
+
this.abortController = null;
|
|
1645
|
+
}
|
|
1546
1646
|
}
|
|
1547
1647
|
}
|
|
1548
|
-
async consumeJobStream(request) {
|
|
1648
|
+
async consumeJobStream(request, wire) {
|
|
1549
1649
|
const job = await this.client.createJob(request);
|
|
1650
|
+
if (wire) wire.reached = true;
|
|
1550
1651
|
this.currentJobId = job.job_id;
|
|
1551
1652
|
const conversationId = job.conversation_id;
|
|
1552
1653
|
if (!this.conversationId) {
|
|
1553
1654
|
this.conversationId = conversationId;
|
|
1655
|
+
this.messagesConversationId = conversationId;
|
|
1554
1656
|
}
|
|
1555
1657
|
if (!this.conversations.some((c) => c.id === conversationId)) {
|
|
1556
1658
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1571,13 +1673,29 @@ var ChatSession = class {
|
|
|
1571
1673
|
await this.storage.addMessage(lastMsg, conversationId).catch(() => {
|
|
1572
1674
|
});
|
|
1573
1675
|
}
|
|
1574
|
-
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
|
+
}
|
|
1575
1693
|
this.lastSeq = -1;
|
|
1576
1694
|
this.submittedToolCallIds.clear();
|
|
1577
1695
|
await this.consumeEventStream(
|
|
1578
1696
|
job.job_id,
|
|
1579
1697
|
conversationId,
|
|
1580
|
-
|
|
1698
|
+
promptMessageId,
|
|
1581
1699
|
true
|
|
1582
1700
|
// executeClientTools
|
|
1583
1701
|
);
|
|
@@ -1586,17 +1704,26 @@ var ChatSession = class {
|
|
|
1586
1704
|
* Shared event consumption loop. Parses each wire event, updates
|
|
1587
1705
|
* minimal session state, and emits typed ChatEvents to consumers.
|
|
1588
1706
|
*/
|
|
1589
|
-
async consumeEventStream(jobId, conversationId,
|
|
1707
|
+
async consumeEventStream(jobId, conversationId, promptMessageId, executeClientTools) {
|
|
1590
1708
|
const signal = this.abortController?.signal;
|
|
1591
1709
|
for (let attempt = 0; ; attempt++) {
|
|
1592
|
-
|
|
1710
|
+
if (signal?.aborted) return;
|
|
1711
|
+
const attemptController = new AbortController();
|
|
1712
|
+
const linkAbort = () => attemptController.abort();
|
|
1713
|
+
signal?.addEventListener("abort", linkAbort);
|
|
1593
1714
|
let sawTerminal;
|
|
1594
1715
|
try {
|
|
1716
|
+
const stream = this.client.streamJobEvents(
|
|
1717
|
+
jobId,
|
|
1718
|
+
this.lastSeq,
|
|
1719
|
+
attemptController.signal
|
|
1720
|
+
);
|
|
1595
1721
|
sawTerminal = await this.pumpStream(
|
|
1596
1722
|
stream,
|
|
1597
1723
|
conversationId,
|
|
1598
|
-
|
|
1599
|
-
executeClientTools
|
|
1724
|
+
promptMessageId,
|
|
1725
|
+
executeClientTools,
|
|
1726
|
+
() => attemptController.abort()
|
|
1600
1727
|
);
|
|
1601
1728
|
} catch (err) {
|
|
1602
1729
|
if (signal?.aborted) return;
|
|
@@ -1606,6 +1733,8 @@ var ChatSession = class {
|
|
|
1606
1733
|
if (attempt >= SSE_MAX_RECONNECTS) throw err;
|
|
1607
1734
|
await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
|
|
1608
1735
|
continue;
|
|
1736
|
+
} finally {
|
|
1737
|
+
signal?.removeEventListener("abort", linkAbort);
|
|
1609
1738
|
}
|
|
1610
1739
|
if (sawTerminal || signal?.aborted) return;
|
|
1611
1740
|
if (attempt >= SSE_MAX_RECONNECTS) {
|
|
@@ -1618,35 +1747,66 @@ var ChatSession = class {
|
|
|
1618
1747
|
* Consume a single SSE stream to exhaustion. Returns whether a terminal
|
|
1619
1748
|
* event (``message_stop`` / ``error``) was seen, so the caller can decide
|
|
1620
1749
|
* whether an ended stream means "turn done" vs "dropped, reconnect".
|
|
1750
|
+
*
|
|
1751
|
+
* ``onStall`` aborts the per-attempt connection: if no event arrives within
|
|
1752
|
+
* SSE_STALL_TIMEOUT_MS (backend keepalives land every 15s), the stream is a
|
|
1753
|
+
* zombie — ``reader.read()`` will never settle — so we kill the fetch and
|
|
1754
|
+
* throw a ConnectionError, feeding the caller's reconnect-from-lastSeq loop.
|
|
1621
1755
|
*/
|
|
1622
|
-
async pumpStream(stream, conversationId,
|
|
1756
|
+
async pumpStream(stream, conversationId, promptMessageId, executeClientTools, onStall) {
|
|
1623
1757
|
let sawTerminal = false;
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
const
|
|
1628
|
-
|
|
1629
|
-
|
|
1758
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
1759
|
+
try {
|
|
1760
|
+
while (true) {
|
|
1761
|
+
const next = iterator.next();
|
|
1762
|
+
let stallTimer;
|
|
1763
|
+
const stall = new Promise((_, reject) => {
|
|
1764
|
+
stallTimer = setTimeout(() => {
|
|
1765
|
+
reject(
|
|
1766
|
+
new ConnectionError(
|
|
1767
|
+
`Stream stalled: no events for ${SSE_STALL_TIMEOUT_MS}ms`
|
|
1768
|
+
)
|
|
1769
|
+
);
|
|
1770
|
+
onStall?.();
|
|
1771
|
+
}, SSE_STALL_TIMEOUT_MS);
|
|
1772
|
+
});
|
|
1773
|
+
let result;
|
|
1774
|
+
try {
|
|
1775
|
+
result = await Promise.race([next, stall]);
|
|
1776
|
+
} finally {
|
|
1777
|
+
clearTimeout(stallTimer);
|
|
1778
|
+
}
|
|
1779
|
+
if (result.done) break;
|
|
1780
|
+
const raw = result.value;
|
|
1781
|
+
let parsed;
|
|
1782
|
+
try {
|
|
1783
|
+
const data = JSON.parse(raw.data);
|
|
1784
|
+
if (typeof data !== "object" || data === null || typeof data.type !== "string") {
|
|
1785
|
+
if (typeof data?.seq === "number") {
|
|
1786
|
+
this.lastSeq = data.seq;
|
|
1787
|
+
}
|
|
1788
|
+
continue;
|
|
1789
|
+
}
|
|
1790
|
+
parsed = data;
|
|
1791
|
+
if (typeof data.seq === "number") {
|
|
1630
1792
|
this.lastSeq = data.seq;
|
|
1631
1793
|
}
|
|
1794
|
+
} catch {
|
|
1632
1795
|
continue;
|
|
1633
1796
|
}
|
|
1634
|
-
parsed
|
|
1635
|
-
|
|
1636
|
-
this.lastSeq = data.seq;
|
|
1797
|
+
if (parsed.type === "message_stop" || parsed.type === "error") {
|
|
1798
|
+
sawTerminal = true;
|
|
1637
1799
|
}
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1800
|
+
await this.dispatchWireEvent(
|
|
1801
|
+
parsed,
|
|
1802
|
+
conversationId,
|
|
1803
|
+
promptMessageId,
|
|
1804
|
+
executeClientTools
|
|
1805
|
+
);
|
|
1643
1806
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
messageId,
|
|
1648
|
-
executeClientTools
|
|
1649
|
-
);
|
|
1807
|
+
} finally {
|
|
1808
|
+
void iterator.return?.(void 0).catch(() => {
|
|
1809
|
+
});
|
|
1650
1810
|
}
|
|
1651
1811
|
return sawTerminal;
|
|
1652
1812
|
}
|
|
@@ -1682,8 +1842,8 @@ var ChatSession = class {
|
|
|
1682
1842
|
}
|
|
1683
1843
|
}
|
|
1684
1844
|
}
|
|
1685
|
-
async dispatchWireEvent(wire, conversationId,
|
|
1686
|
-
this.applyWireSideEffects(wire, conversationId,
|
|
1845
|
+
async dispatchWireEvent(wire, conversationId, promptMessageId, executeClientTools) {
|
|
1846
|
+
this.applyWireSideEffects(wire, conversationId, promptMessageId, true);
|
|
1687
1847
|
const event = translateWireEvent(wire);
|
|
1688
1848
|
if (event) {
|
|
1689
1849
|
this.emit(event);
|
|
@@ -1701,7 +1861,9 @@ var ChatSession = class {
|
|
|
1701
1861
|
const results = await this.executeClientTools([request]);
|
|
1702
1862
|
await this.submitToolResultWithRetry({
|
|
1703
1863
|
conversation_id: conversationId,
|
|
1704
|
-
|
|
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,
|
|
1705
1867
|
tool_results: results
|
|
1706
1868
|
});
|
|
1707
1869
|
this.submittedToolCallIds.add(callId);
|
|
@@ -1716,7 +1878,7 @@ var ChatSession = class {
|
|
|
1716
1878
|
* instead of re-typing the whole conversation event by event.
|
|
1717
1879
|
*/
|
|
1718
1880
|
replayWireEvent(wire, conversationId) {
|
|
1719
|
-
this.applyWireSideEffects(wire, conversationId, "");
|
|
1881
|
+
this.applyWireSideEffects(wire, conversationId, "", false);
|
|
1720
1882
|
const event = translateWireEvent(wire);
|
|
1721
1883
|
if (event) {
|
|
1722
1884
|
this.emit(event);
|
|
@@ -1727,37 +1889,52 @@ var ChatSession = class {
|
|
|
1727
1889
|
* the pure wire → ChatEvent mapping can live in translate.ts and be reused
|
|
1728
1890
|
* by the replay path.
|
|
1729
1891
|
*
|
|
1730
|
-
* ``
|
|
1731
|
-
*
|
|
1732
|
-
*
|
|
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.
|
|
1733
1901
|
*/
|
|
1734
|
-
applyWireSideEffects(wire, conversationId,
|
|
1902
|
+
applyWireSideEffects(wire, conversationId, promptMessageId, live) {
|
|
1903
|
+
const ownsTurnState = live || !this.isStreaming;
|
|
1735
1904
|
switch (wire.type) {
|
|
1736
1905
|
case "message_start":
|
|
1737
|
-
this.resetStreamingState();
|
|
1906
|
+
if (ownsTurnState) this.resetStreamingState();
|
|
1738
1907
|
if (wire.model) {
|
|
1739
1908
|
this.modelDisplayName = wire.model;
|
|
1740
1909
|
}
|
|
1741
1910
|
return;
|
|
1742
1911
|
case "block_start":
|
|
1743
|
-
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)) {
|
|
1744
1913
|
this.currentTextPath = wire.path;
|
|
1745
1914
|
}
|
|
1746
1915
|
return;
|
|
1747
1916
|
case "block_delta":
|
|
1748
|
-
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)) {
|
|
1749
1918
|
this.accumulatedText += wire.delta.text;
|
|
1750
1919
|
}
|
|
1751
1920
|
return;
|
|
1752
1921
|
case "block_stop":
|
|
1753
|
-
if (this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
|
|
1922
|
+
if (ownsTurnState && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
|
|
1754
1923
|
this.currentTextPath = null;
|
|
1755
1924
|
}
|
|
1756
1925
|
return;
|
|
1757
1926
|
case "message_stop":
|
|
1758
|
-
if (
|
|
1927
|
+
if (promptMessageId && this.pendingUserMessages.has(promptMessageId)) {
|
|
1928
|
+
this.pendingUserMessages.set(promptMessageId, ++this.serverRowsKnown);
|
|
1929
|
+
}
|
|
1930
|
+
if (promptMessageId) {
|
|
1759
1931
|
const assistantMessage = {
|
|
1760
|
-
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(),
|
|
1761
1938
|
conversationId,
|
|
1762
1939
|
role: "assistant",
|
|
1763
1940
|
content: this.accumulatedText,
|
|
@@ -1768,8 +1945,10 @@ var ChatSession = class {
|
|
|
1768
1945
|
this.storage.addMessage(assistantMessage, conversationId).catch(() => {
|
|
1769
1946
|
});
|
|
1770
1947
|
}
|
|
1771
|
-
|
|
1772
|
-
|
|
1948
|
+
if (ownsTurnState) {
|
|
1949
|
+
this.isStreaming = false;
|
|
1950
|
+
this.currentJobId = null;
|
|
1951
|
+
}
|
|
1773
1952
|
return;
|
|
1774
1953
|
case "custom":
|
|
1775
1954
|
if (wire.name === "title_generated") {
|
|
@@ -1803,9 +1982,27 @@ var ChatSession = class {
|
|
|
1803
1982
|
* Used before reconnectToJob — SSE replay handles event replay.
|
|
1804
1983
|
*/
|
|
1805
1984
|
async loadConversation(id) {
|
|
1985
|
+
const load = ++this.loadGeneration;
|
|
1806
1986
|
this.conversationId = id;
|
|
1807
|
-
this.resetStreamingState();
|
|
1808
|
-
|
|
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;
|
|
1809
2006
|
}
|
|
1810
2007
|
/**
|
|
1811
2008
|
* Reconnect to a running job's SSE stream (e.g. after page reload).
|
|
@@ -1818,7 +2015,8 @@ var ChatSession = class {
|
|
|
1818
2015
|
this.lastSeq = -1;
|
|
1819
2016
|
this.submittedToolCallIds.clear();
|
|
1820
2017
|
this.resetStreamingState();
|
|
1821
|
-
|
|
2018
|
+
const controller = new AbortController();
|
|
2019
|
+
this.abortController = controller;
|
|
1822
2020
|
try {
|
|
1823
2021
|
await this.consumeEventStream(
|
|
1824
2022
|
jobId,
|
|
@@ -1835,8 +2033,10 @@ var ChatSession = class {
|
|
|
1835
2033
|
blockPath: null
|
|
1836
2034
|
});
|
|
1837
2035
|
} finally {
|
|
1838
|
-
this.
|
|
1839
|
-
|
|
2036
|
+
if (this.abortController === controller) {
|
|
2037
|
+
this.isStreaming = false;
|
|
2038
|
+
this.abortController = null;
|
|
2039
|
+
}
|
|
1840
2040
|
}
|
|
1841
2041
|
}
|
|
1842
2042
|
/** Detach from the SSE stream without cancelling the job. */
|
|
@@ -1847,25 +2047,54 @@ var ChatSession = class {
|
|
|
1847
2047
|
this.resetStreamingState();
|
|
1848
2048
|
this.emit({ type: "disconnected" });
|
|
1849
2049
|
}
|
|
1850
|
-
/**
|
|
1851
|
-
|
|
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() {
|
|
1852
2059
|
if (this.currentJobId) {
|
|
1853
2060
|
this.client.cancelJob(this.currentJobId).catch(() => {
|
|
1854
2061
|
});
|
|
1855
2062
|
}
|
|
1856
2063
|
this.detach();
|
|
1857
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();
|
|
1858
2072
|
this.protocols.clear();
|
|
1859
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
|
+
}
|
|
1860
2085
|
async createNewConversation() {
|
|
1861
2086
|
const id = generateId();
|
|
2087
|
+
const load = this.loadGeneration;
|
|
1862
2088
|
const conversation = await this.storage.createConversation(
|
|
1863
2089
|
id,
|
|
1864
2090
|
"New Conversation"
|
|
1865
2091
|
);
|
|
1866
2092
|
this.conversations.unshift(conversation);
|
|
2093
|
+
if (load !== this.loadGeneration) return id;
|
|
2094
|
+
this.loadGeneration++;
|
|
1867
2095
|
this.conversationId = id;
|
|
1868
|
-
this.
|
|
2096
|
+
this.setMessages([]);
|
|
2097
|
+
this.messagesConversationId = id;
|
|
1869
2098
|
return id;
|
|
1870
2099
|
}
|
|
1871
2100
|
/**
|
|
@@ -1884,7 +2113,7 @@ var ChatSession = class {
|
|
|
1884
2113
|
*/
|
|
1885
2114
|
replayTurn(id, events, userMessageContent, userMessageId, isSteer = false) {
|
|
1886
2115
|
this.conversationId = id;
|
|
1887
|
-
this.resetStreamingState();
|
|
2116
|
+
if (!this.isStreaming) this.resetStreamingState();
|
|
1888
2117
|
if (userMessageContent) {
|
|
1889
2118
|
this.emit({
|
|
1890
2119
|
type: "user_message",
|
|
@@ -1915,11 +2144,17 @@ var ChatSession = class {
|
|
|
1915
2144
|
* job's events.
|
|
1916
2145
|
*/
|
|
1917
2146
|
async switchConversation(id, jobId) {
|
|
1918
|
-
const
|
|
1919
|
-
|
|
2147
|
+
const loading = this.loadConversation(id);
|
|
2148
|
+
const token = this.loadGeneration;
|
|
2149
|
+
const [loadResult, eventsResult] = await Promise.allSettled([
|
|
2150
|
+
loading,
|
|
1920
2151
|
this.client.getConversationEvents(id, jobId)
|
|
1921
2152
|
]);
|
|
1922
|
-
|
|
2153
|
+
if (token !== this.loadGeneration) return;
|
|
2154
|
+
if (loadResult.status === "rejected") {
|
|
2155
|
+
this.setMessages([]);
|
|
2156
|
+
this.messagesConversationId = id;
|
|
2157
|
+
}
|
|
1923
2158
|
this.replayTurn(
|
|
1924
2159
|
id,
|
|
1925
2160
|
eventsResult.status === "fulfilled" ? eventsResult.value : []
|
|
@@ -2010,8 +2245,10 @@ var ChatSession = class {
|
|
|
2010
2245
|
}
|
|
2011
2246
|
this.conversations = this.conversations.filter((c) => c.id !== id);
|
|
2012
2247
|
if (this.conversationId === id) {
|
|
2248
|
+
this.loadGeneration++;
|
|
2013
2249
|
this.conversationId = null;
|
|
2014
|
-
this.
|
|
2250
|
+
this.setMessages([]);
|
|
2251
|
+
this.messagesConversationId = null;
|
|
2015
2252
|
}
|
|
2016
2253
|
}
|
|
2017
2254
|
toggleClientTool(name) {
|
|
@@ -2027,6 +2264,11 @@ var ChatSession = class {
|
|
|
2027
2264
|
// src/restore-plan.ts
|
|
2028
2265
|
function planRestore(args) {
|
|
2029
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
|
+
);
|
|
2030
2272
|
const byId = /* @__PURE__ */ new Map();
|
|
2031
2273
|
userMessages.forEach((m, i) => {
|
|
2032
2274
|
if (m.id) byId.set(m.id, i);
|
|
@@ -2048,7 +2290,7 @@ function planRestore(args) {
|
|
|
2048
2290
|
userMessages.slice(0, cutover)
|
|
2049
2291
|
);
|
|
2050
2292
|
let cursor = cutover;
|
|
2051
|
-
const isSteer = (m) => !!m?.id && !
|
|
2293
|
+
const isSteer = (m) => !!m?.id && !claimed.has(m.id);
|
|
2052
2294
|
const drainTo = (stopAt) => {
|
|
2053
2295
|
while (cursor < stopAt) {
|
|
2054
2296
|
const m = userMessages[cursor++];
|
|
@@ -2134,6 +2376,12 @@ var StreamManager = class {
|
|
|
2134
2376
|
this._backgroundJobs = /* @__PURE__ */ new Map();
|
|
2135
2377
|
this.handlers = [];
|
|
2136
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;
|
|
2137
2385
|
this.session = session;
|
|
2138
2386
|
this.attach();
|
|
2139
2387
|
}
|
|
@@ -2184,7 +2432,7 @@ var StreamManager = class {
|
|
|
2184
2432
|
event
|
|
2185
2433
|
});
|
|
2186
2434
|
if (event.type === ChatEventType.MessageStop) {
|
|
2187
|
-
if (this._state === "streaming") {
|
|
2435
|
+
if (this._state === "streaming" && !this.session.isStreaming) {
|
|
2188
2436
|
this.setState("idle");
|
|
2189
2437
|
}
|
|
2190
2438
|
}
|
|
@@ -2197,13 +2445,21 @@ var StreamManager = class {
|
|
|
2197
2445
|
);
|
|
2198
2446
|
}
|
|
2199
2447
|
if (this._state === "streaming") return;
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
this.
|
|
2448
|
+
let target = this._activeConversationId;
|
|
2449
|
+
if (!target) {
|
|
2450
|
+
target = await this.session.createNewConversation();
|
|
2451
|
+
this.setActiveConversation(target);
|
|
2203
2452
|
}
|
|
2204
2453
|
this.setState("streaming");
|
|
2205
2454
|
try {
|
|
2206
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,
|
|
2207
2463
|
agentName: options?.agentName,
|
|
2208
2464
|
uploadIds: options?.uploadIds,
|
|
2209
2465
|
planMode: options?.planMode,
|
|
@@ -2222,6 +2478,9 @@ var StreamManager = class {
|
|
|
2222
2478
|
// ── Regenerate ────────────────────────────────────────────────
|
|
2223
2479
|
async regenerate() {
|
|
2224
2480
|
if (this._state === "streaming") return;
|
|
2481
|
+
if (this.session.messagesConversationId !== this._activeConversationId) {
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2225
2484
|
const userMsgs = this.session.messages.filter(
|
|
2226
2485
|
(m) => m.role === "user"
|
|
2227
2486
|
);
|
|
@@ -2258,44 +2517,78 @@ var StreamManager = class {
|
|
|
2258
2517
|
async switchTo(conversationId, opts) {
|
|
2259
2518
|
if (conversationId === this._activeConversationId) return;
|
|
2260
2519
|
const targetHadBackgroundJob = this._backgroundJobs.has(conversationId);
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
if (oldConvId && jobId) {
|
|
2265
|
-
this._backgroundJobs.set(oldConvId, jobId);
|
|
2266
|
-
this.emit({
|
|
2267
|
-
type: "backgroundJobsChanged",
|
|
2268
|
-
jobs: this._backgroundJobs
|
|
2269
|
-
});
|
|
2270
|
-
}
|
|
2271
|
-
this.session.detach();
|
|
2272
|
-
}
|
|
2273
|
-
if (this._backgroundJobs.has(conversationId)) {
|
|
2520
|
+
this.detachStreamingTurn();
|
|
2521
|
+
const parkedJobId = this._backgroundJobs.get(conversationId);
|
|
2522
|
+
if (parkedJobId !== void 0) {
|
|
2274
2523
|
this._backgroundJobs.delete(conversationId);
|
|
2275
2524
|
this.emit({
|
|
2276
2525
|
type: "backgroundJobsChanged",
|
|
2277
2526
|
jobs: this._backgroundJobs
|
|
2278
2527
|
});
|
|
2279
2528
|
}
|
|
2280
|
-
this.setActiveConversation(conversationId);
|
|
2529
|
+
const gen = this.setActiveConversation(conversationId);
|
|
2281
2530
|
if (opts?.skipHistoryReplay && !targetHadBackgroundJob) {
|
|
2282
2531
|
let activeJobId = null;
|
|
2283
2532
|
try {
|
|
2284
2533
|
activeJobId = (await this.session.client.getActiveJob(conversationId)).jobId;
|
|
2285
2534
|
} catch {
|
|
2286
2535
|
}
|
|
2536
|
+
if (gen !== this.generation) return;
|
|
2287
2537
|
if (!activeJobId) {
|
|
2288
|
-
|
|
2289
|
-
|
|
2538
|
+
try {
|
|
2539
|
+
await this.session.loadConversation(conversationId);
|
|
2540
|
+
} finally {
|
|
2541
|
+
if (gen === this.generation) this.settleIdle();
|
|
2542
|
+
}
|
|
2290
2543
|
return;
|
|
2291
2544
|
}
|
|
2292
2545
|
}
|
|
2293
|
-
|
|
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
|
+
}
|
|
2294
2566
|
}
|
|
2295
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
|
+
*/
|
|
2296
2577
|
async createConversation() {
|
|
2297
|
-
|
|
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
|
+
}
|
|
2298
2590
|
this.setActiveConversation(id);
|
|
2591
|
+
this.settleIdle();
|
|
2299
2592
|
return id;
|
|
2300
2593
|
}
|
|
2301
2594
|
/**
|
|
@@ -2306,16 +2599,34 @@ var StreamManager = class {
|
|
|
2306
2599
|
await this.session.renameConversation(id, title);
|
|
2307
2600
|
}
|
|
2308
2601
|
async deleteConversation(id) {
|
|
2309
|
-
|
|
2310
|
-
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
|
+
}
|
|
2311
2614
|
if (this._activeConversationId === id) {
|
|
2312
|
-
this.
|
|
2313
|
-
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 });
|
|
2314
2625
|
}
|
|
2315
2626
|
}
|
|
2316
2627
|
// ── Stop (explicit cancel) ────────────────────────────────────
|
|
2317
2628
|
stop() {
|
|
2318
|
-
this.session.
|
|
2629
|
+
this.session.cancelTurn();
|
|
2319
2630
|
this.setState("idle");
|
|
2320
2631
|
}
|
|
2321
2632
|
// ── Cleanup ───────────────────────────────────────────────────
|
|
@@ -2327,34 +2638,80 @@ var StreamManager = class {
|
|
|
2327
2638
|
this.handlers = [];
|
|
2328
2639
|
}
|
|
2329
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
|
+
}
|
|
2330
2680
|
finalizeStream() {
|
|
2331
2681
|
if (this._state === "streaming") {
|
|
2332
2682
|
this.setState("idle");
|
|
2333
2683
|
}
|
|
2334
2684
|
}
|
|
2335
2685
|
// ── Internal: restore ─────────────────────────────────────────
|
|
2336
|
-
async restore(conversationId) {
|
|
2337
|
-
this.
|
|
2686
|
+
async restore(conversationId, gen) {
|
|
2687
|
+
const superseded = () => gen !== this.generation;
|
|
2688
|
+
if (superseded()) return;
|
|
2689
|
+
if (!this.session.isStreaming) this.setState("restoring");
|
|
2338
2690
|
let activeJobId = null;
|
|
2339
2691
|
try {
|
|
2340
2692
|
const res = await this.session.client.getActiveJob(conversationId);
|
|
2341
2693
|
activeJobId = res.jobId;
|
|
2342
2694
|
} catch {
|
|
2343
2695
|
}
|
|
2696
|
+
if (superseded()) return;
|
|
2344
2697
|
if (activeJobId) {
|
|
2345
2698
|
await this.session.loadConversation(conversationId);
|
|
2699
|
+
if (superseded()) return;
|
|
2346
2700
|
this.setState("streaming");
|
|
2347
2701
|
try {
|
|
2348
2702
|
await this.session.reconnectToJob(activeJobId);
|
|
2349
2703
|
} catch {
|
|
2350
2704
|
}
|
|
2351
|
-
if (
|
|
2705
|
+
if (superseded()) return;
|
|
2706
|
+
if (this._state === "streaming" && !this.session.isStreaming) {
|
|
2352
2707
|
this.setState("idle");
|
|
2353
2708
|
}
|
|
2354
2709
|
} else {
|
|
2355
2710
|
await this.session.loadConversation(conversationId);
|
|
2711
|
+
if (superseded()) return;
|
|
2356
2712
|
try {
|
|
2357
2713
|
const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
|
|
2714
|
+
if (superseded()) return;
|
|
2358
2715
|
const completedJobs = jobs.filter(
|
|
2359
2716
|
(j) => j.status === "completed"
|
|
2360
2717
|
);
|
|
@@ -2366,6 +2723,14 @@ var StreamManager = class {
|
|
|
2366
2723
|
job_id: j.job_id,
|
|
2367
2724
|
message_id: j.message_id
|
|
2368
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),
|
|
2369
2734
|
userMessages: userMessages.map((m) => ({
|
|
2370
2735
|
id: m.id,
|
|
2371
2736
|
content: m.content
|
|
@@ -2376,10 +2741,12 @@ var StreamManager = class {
|
|
|
2376
2741
|
(job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
|
|
2377
2742
|
)
|
|
2378
2743
|
);
|
|
2744
|
+
if (superseded()) return;
|
|
2379
2745
|
const eventsByJobId = new Map(
|
|
2380
2746
|
completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
|
|
2381
2747
|
);
|
|
2382
2748
|
for (const step of plan) {
|
|
2749
|
+
if (superseded()) return;
|
|
2383
2750
|
if (step.kind === "steer") {
|
|
2384
2751
|
this.session.replayTurn(
|
|
2385
2752
|
conversationId,
|
|
@@ -2397,6 +2764,7 @@ var StreamManager = class {
|
|
|
2397
2764
|
step.messageId
|
|
2398
2765
|
);
|
|
2399
2766
|
}
|
|
2767
|
+
if (superseded()) return;
|
|
2400
2768
|
if (completedJobs.length > 0) {
|
|
2401
2769
|
this.emit({
|
|
2402
2770
|
type: "versionsReady",
|
|
@@ -2406,13 +2774,17 @@ var StreamManager = class {
|
|
|
2406
2774
|
}
|
|
2407
2775
|
} catch {
|
|
2408
2776
|
}
|
|
2409
|
-
|
|
2777
|
+
if (superseded()) return;
|
|
2778
|
+
this.settleIdle();
|
|
2410
2779
|
}
|
|
2411
2780
|
}
|
|
2412
2781
|
// ── Internal: set active conversation ─────────────────────────
|
|
2413
2782
|
setActiveConversation(id) {
|
|
2414
2783
|
this._activeConversationId = id;
|
|
2784
|
+
this.session.invalidateLoadsInFlight();
|
|
2785
|
+
const claimed = ++this.generation;
|
|
2415
2786
|
this.emit({ type: "conversationChanged", conversationId: id });
|
|
2787
|
+
return claimed;
|
|
2416
2788
|
}
|
|
2417
2789
|
};
|
|
2418
2790
|
|