@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.js
CHANGED
|
@@ -1291,6 +1291,7 @@ function translateWireEvent(wire) {
|
|
|
1291
1291
|
|
|
1292
1292
|
// src/session.ts
|
|
1293
1293
|
var SSE_MAX_RECONNECTS = 6;
|
|
1294
|
+
var SSE_STALL_TIMEOUT_MS = 45e3;
|
|
1294
1295
|
var TOOL_RESULT_MAX_RETRIES = 3;
|
|
1295
1296
|
var CONVERSATION_PAGE_SIZE = 50;
|
|
1296
1297
|
function sseReconnectDelayMs(attempt) {
|
|
@@ -1328,6 +1329,17 @@ var ChatSession = class {
|
|
|
1328
1329
|
/** True while ``loadMoreConversations`` is in flight. */
|
|
1329
1330
|
this.isLoadingConversations = false;
|
|
1330
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;
|
|
1331
1343
|
this.isStreaming = false;
|
|
1332
1344
|
this.agentStatus = null;
|
|
1333
1345
|
this.agents = [];
|
|
@@ -1360,6 +1372,43 @@ var ChatSession = class {
|
|
|
1360
1372
|
* is discarded instead.
|
|
1361
1373
|
*/
|
|
1362
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;
|
|
1363
1412
|
// Minimal in-session accumulation for the assistant message record.
|
|
1364
1413
|
// Only top-level ``text`` blocks contribute; subagent / tool output
|
|
1365
1414
|
// is tracked by the consumer's own block store.
|
|
@@ -1382,6 +1431,19 @@ var ChatSession = class {
|
|
|
1382
1431
|
this.toolRegistry = new ToolRegistry();
|
|
1383
1432
|
this.storage = storage ?? new InMemoryStorage();
|
|
1384
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
|
+
}
|
|
1385
1447
|
on(handler) {
|
|
1386
1448
|
this.handlers.add(handler);
|
|
1387
1449
|
return () => {
|
|
@@ -1430,6 +1492,22 @@ var ChatSession = class {
|
|
|
1430
1492
|
}
|
|
1431
1493
|
if (this.isStreaming) return;
|
|
1432
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
|
+
}
|
|
1433
1511
|
const userMessage = {
|
|
1434
1512
|
id: generateId(),
|
|
1435
1513
|
conversationId: conversationId ?? "",
|
|
@@ -1439,9 +1517,11 @@ var ChatSession = class {
|
|
|
1439
1517
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1440
1518
|
};
|
|
1441
1519
|
if (conversationId) {
|
|
1442
|
-
await this.storage.addMessage(userMessage, conversationId)
|
|
1520
|
+
await this.storage.addMessage(userMessage, conversationId).catch(() => {
|
|
1521
|
+
});
|
|
1443
1522
|
}
|
|
1444
1523
|
this.messages.push(userMessage);
|
|
1524
|
+
this.pendingUserMessages.set(userMessage.id, 0);
|
|
1445
1525
|
const request = {
|
|
1446
1526
|
message: content,
|
|
1447
1527
|
conversation_id: conversationId,
|
|
@@ -1461,7 +1541,24 @@ var ChatSession = class {
|
|
|
1461
1541
|
reasoning_effort: options?.reasoningEffort,
|
|
1462
1542
|
temperature: options?.temperature
|
|
1463
1543
|
};
|
|
1464
|
-
|
|
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
|
+
}
|
|
1465
1562
|
}
|
|
1466
1563
|
async resendFromCheckpoint(messageId, newContent) {
|
|
1467
1564
|
if (this.isStreaming) return;
|
|
@@ -1478,12 +1575,13 @@ var ChatSession = class {
|
|
|
1478
1575
|
this.accumulatedText = "";
|
|
1479
1576
|
this.currentTextPath = null;
|
|
1480
1577
|
}
|
|
1481
|
-
async processStream(request) {
|
|
1578
|
+
async processStream(request, wire) {
|
|
1482
1579
|
this.isStreaming = true;
|
|
1483
1580
|
this.resetStreamingState();
|
|
1484
|
-
|
|
1581
|
+
const controller = new AbortController();
|
|
1582
|
+
this.abortController = controller;
|
|
1485
1583
|
try {
|
|
1486
|
-
await this.consumeJobStream(request);
|
|
1584
|
+
await this.consumeJobStream(request, wire);
|
|
1487
1585
|
} catch (err) {
|
|
1488
1586
|
if (!(err instanceof DOMException && err.name === "AbortError")) {
|
|
1489
1587
|
this.emit({
|
|
@@ -1494,16 +1592,20 @@ var ChatSession = class {
|
|
|
1494
1592
|
});
|
|
1495
1593
|
}
|
|
1496
1594
|
} finally {
|
|
1497
|
-
this.
|
|
1498
|
-
|
|
1595
|
+
if (this.abortController === controller) {
|
|
1596
|
+
this.isStreaming = false;
|
|
1597
|
+
this.abortController = null;
|
|
1598
|
+
}
|
|
1499
1599
|
}
|
|
1500
1600
|
}
|
|
1501
|
-
async consumeJobStream(request) {
|
|
1601
|
+
async consumeJobStream(request, wire) {
|
|
1502
1602
|
const job = await this.client.createJob(request);
|
|
1603
|
+
if (wire) wire.reached = true;
|
|
1503
1604
|
this.currentJobId = job.job_id;
|
|
1504
1605
|
const conversationId = job.conversation_id;
|
|
1505
1606
|
if (!this.conversationId) {
|
|
1506
1607
|
this.conversationId = conversationId;
|
|
1608
|
+
this.messagesConversationId = conversationId;
|
|
1507
1609
|
}
|
|
1508
1610
|
if (!this.conversations.some((c) => c.id === conversationId)) {
|
|
1509
1611
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -1524,13 +1626,29 @@ var ChatSession = class {
|
|
|
1524
1626
|
await this.storage.addMessage(lastMsg, conversationId).catch(() => {
|
|
1525
1627
|
});
|
|
1526
1628
|
}
|
|
1527
|
-
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
|
+
}
|
|
1528
1646
|
this.lastSeq = -1;
|
|
1529
1647
|
this.submittedToolCallIds.clear();
|
|
1530
1648
|
await this.consumeEventStream(
|
|
1531
1649
|
job.job_id,
|
|
1532
1650
|
conversationId,
|
|
1533
|
-
|
|
1651
|
+
promptMessageId,
|
|
1534
1652
|
true
|
|
1535
1653
|
// executeClientTools
|
|
1536
1654
|
);
|
|
@@ -1539,17 +1657,26 @@ var ChatSession = class {
|
|
|
1539
1657
|
* Shared event consumption loop. Parses each wire event, updates
|
|
1540
1658
|
* minimal session state, and emits typed ChatEvents to consumers.
|
|
1541
1659
|
*/
|
|
1542
|
-
async consumeEventStream(jobId, conversationId,
|
|
1660
|
+
async consumeEventStream(jobId, conversationId, promptMessageId, executeClientTools) {
|
|
1543
1661
|
const signal = this.abortController?.signal;
|
|
1544
1662
|
for (let attempt = 0; ; attempt++) {
|
|
1545
|
-
|
|
1663
|
+
if (signal?.aborted) return;
|
|
1664
|
+
const attemptController = new AbortController();
|
|
1665
|
+
const linkAbort = () => attemptController.abort();
|
|
1666
|
+
signal?.addEventListener("abort", linkAbort);
|
|
1546
1667
|
let sawTerminal;
|
|
1547
1668
|
try {
|
|
1669
|
+
const stream = this.client.streamJobEvents(
|
|
1670
|
+
jobId,
|
|
1671
|
+
this.lastSeq,
|
|
1672
|
+
attemptController.signal
|
|
1673
|
+
);
|
|
1548
1674
|
sawTerminal = await this.pumpStream(
|
|
1549
1675
|
stream,
|
|
1550
1676
|
conversationId,
|
|
1551
|
-
|
|
1552
|
-
executeClientTools
|
|
1677
|
+
promptMessageId,
|
|
1678
|
+
executeClientTools,
|
|
1679
|
+
() => attemptController.abort()
|
|
1553
1680
|
);
|
|
1554
1681
|
} catch (err) {
|
|
1555
1682
|
if (signal?.aborted) return;
|
|
@@ -1559,6 +1686,8 @@ var ChatSession = class {
|
|
|
1559
1686
|
if (attempt >= SSE_MAX_RECONNECTS) throw err;
|
|
1560
1687
|
await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
|
|
1561
1688
|
continue;
|
|
1689
|
+
} finally {
|
|
1690
|
+
signal?.removeEventListener("abort", linkAbort);
|
|
1562
1691
|
}
|
|
1563
1692
|
if (sawTerminal || signal?.aborted) return;
|
|
1564
1693
|
if (attempt >= SSE_MAX_RECONNECTS) {
|
|
@@ -1571,35 +1700,66 @@ var ChatSession = class {
|
|
|
1571
1700
|
* Consume a single SSE stream to exhaustion. Returns whether a terminal
|
|
1572
1701
|
* event (``message_stop`` / ``error``) was seen, so the caller can decide
|
|
1573
1702
|
* whether an ended stream means "turn done" vs "dropped, reconnect".
|
|
1703
|
+
*
|
|
1704
|
+
* ``onStall`` aborts the per-attempt connection: if no event arrives within
|
|
1705
|
+
* SSE_STALL_TIMEOUT_MS (backend keepalives land every 15s), the stream is a
|
|
1706
|
+
* zombie — ``reader.read()`` will never settle — so we kill the fetch and
|
|
1707
|
+
* throw a ConnectionError, feeding the caller's reconnect-from-lastSeq loop.
|
|
1574
1708
|
*/
|
|
1575
|
-
async pumpStream(stream, conversationId,
|
|
1709
|
+
async pumpStream(stream, conversationId, promptMessageId, executeClientTools, onStall) {
|
|
1576
1710
|
let sawTerminal = false;
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
const
|
|
1581
|
-
|
|
1582
|
-
|
|
1711
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
1712
|
+
try {
|
|
1713
|
+
while (true) {
|
|
1714
|
+
const next = iterator.next();
|
|
1715
|
+
let stallTimer;
|
|
1716
|
+
const stall = new Promise((_, reject) => {
|
|
1717
|
+
stallTimer = setTimeout(() => {
|
|
1718
|
+
reject(
|
|
1719
|
+
new ConnectionError(
|
|
1720
|
+
`Stream stalled: no events for ${SSE_STALL_TIMEOUT_MS}ms`
|
|
1721
|
+
)
|
|
1722
|
+
);
|
|
1723
|
+
onStall?.();
|
|
1724
|
+
}, SSE_STALL_TIMEOUT_MS);
|
|
1725
|
+
});
|
|
1726
|
+
let result;
|
|
1727
|
+
try {
|
|
1728
|
+
result = await Promise.race([next, stall]);
|
|
1729
|
+
} finally {
|
|
1730
|
+
clearTimeout(stallTimer);
|
|
1731
|
+
}
|
|
1732
|
+
if (result.done) break;
|
|
1733
|
+
const raw = result.value;
|
|
1734
|
+
let parsed;
|
|
1735
|
+
try {
|
|
1736
|
+
const data = JSON.parse(raw.data);
|
|
1737
|
+
if (typeof data !== "object" || data === null || typeof data.type !== "string") {
|
|
1738
|
+
if (typeof data?.seq === "number") {
|
|
1739
|
+
this.lastSeq = data.seq;
|
|
1740
|
+
}
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
parsed = data;
|
|
1744
|
+
if (typeof data.seq === "number") {
|
|
1583
1745
|
this.lastSeq = data.seq;
|
|
1584
1746
|
}
|
|
1747
|
+
} catch {
|
|
1585
1748
|
continue;
|
|
1586
1749
|
}
|
|
1587
|
-
parsed
|
|
1588
|
-
|
|
1589
|
-
this.lastSeq = data.seq;
|
|
1750
|
+
if (parsed.type === "message_stop" || parsed.type === "error") {
|
|
1751
|
+
sawTerminal = true;
|
|
1590
1752
|
}
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1753
|
+
await this.dispatchWireEvent(
|
|
1754
|
+
parsed,
|
|
1755
|
+
conversationId,
|
|
1756
|
+
promptMessageId,
|
|
1757
|
+
executeClientTools
|
|
1758
|
+
);
|
|
1596
1759
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
messageId,
|
|
1601
|
-
executeClientTools
|
|
1602
|
-
);
|
|
1760
|
+
} finally {
|
|
1761
|
+
void iterator.return?.(void 0).catch(() => {
|
|
1762
|
+
});
|
|
1603
1763
|
}
|
|
1604
1764
|
return sawTerminal;
|
|
1605
1765
|
}
|
|
@@ -1635,8 +1795,8 @@ var ChatSession = class {
|
|
|
1635
1795
|
}
|
|
1636
1796
|
}
|
|
1637
1797
|
}
|
|
1638
|
-
async dispatchWireEvent(wire, conversationId,
|
|
1639
|
-
this.applyWireSideEffects(wire, conversationId,
|
|
1798
|
+
async dispatchWireEvent(wire, conversationId, promptMessageId, executeClientTools) {
|
|
1799
|
+
this.applyWireSideEffects(wire, conversationId, promptMessageId, true);
|
|
1640
1800
|
const event = translateWireEvent(wire);
|
|
1641
1801
|
if (event) {
|
|
1642
1802
|
this.emit(event);
|
|
@@ -1654,7 +1814,9 @@ var ChatSession = class {
|
|
|
1654
1814
|
const results = await this.executeClientTools([request]);
|
|
1655
1815
|
await this.submitToolResultWithRetry({
|
|
1656
1816
|
conversation_id: conversationId,
|
|
1657
|
-
|
|
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,
|
|
1658
1820
|
tool_results: results
|
|
1659
1821
|
});
|
|
1660
1822
|
this.submittedToolCallIds.add(callId);
|
|
@@ -1669,7 +1831,7 @@ var ChatSession = class {
|
|
|
1669
1831
|
* instead of re-typing the whole conversation event by event.
|
|
1670
1832
|
*/
|
|
1671
1833
|
replayWireEvent(wire, conversationId) {
|
|
1672
|
-
this.applyWireSideEffects(wire, conversationId, "");
|
|
1834
|
+
this.applyWireSideEffects(wire, conversationId, "", false);
|
|
1673
1835
|
const event = translateWireEvent(wire);
|
|
1674
1836
|
if (event) {
|
|
1675
1837
|
this.emit(event);
|
|
@@ -1680,37 +1842,52 @@ var ChatSession = class {
|
|
|
1680
1842
|
* the pure wire → ChatEvent mapping can live in translate.ts and be reused
|
|
1681
1843
|
* by the replay path.
|
|
1682
1844
|
*
|
|
1683
|
-
* ``
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
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.
|
|
1686
1854
|
*/
|
|
1687
|
-
applyWireSideEffects(wire, conversationId,
|
|
1855
|
+
applyWireSideEffects(wire, conversationId, promptMessageId, live) {
|
|
1856
|
+
const ownsTurnState = live || !this.isStreaming;
|
|
1688
1857
|
switch (wire.type) {
|
|
1689
1858
|
case "message_start":
|
|
1690
|
-
this.resetStreamingState();
|
|
1859
|
+
if (ownsTurnState) this.resetStreamingState();
|
|
1691
1860
|
if (wire.model) {
|
|
1692
1861
|
this.modelDisplayName = wire.model;
|
|
1693
1862
|
}
|
|
1694
1863
|
return;
|
|
1695
1864
|
case "block_start":
|
|
1696
|
-
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)) {
|
|
1697
1866
|
this.currentTextPath = wire.path;
|
|
1698
1867
|
}
|
|
1699
1868
|
return;
|
|
1700
1869
|
case "block_delta":
|
|
1701
|
-
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)) {
|
|
1702
1871
|
this.accumulatedText += wire.delta.text;
|
|
1703
1872
|
}
|
|
1704
1873
|
return;
|
|
1705
1874
|
case "block_stop":
|
|
1706
|
-
if (this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
|
|
1875
|
+
if (ownsTurnState && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
|
|
1707
1876
|
this.currentTextPath = null;
|
|
1708
1877
|
}
|
|
1709
1878
|
return;
|
|
1710
1879
|
case "message_stop":
|
|
1711
|
-
if (
|
|
1880
|
+
if (promptMessageId && this.pendingUserMessages.has(promptMessageId)) {
|
|
1881
|
+
this.pendingUserMessages.set(promptMessageId, ++this.serverRowsKnown);
|
|
1882
|
+
}
|
|
1883
|
+
if (promptMessageId) {
|
|
1712
1884
|
const assistantMessage = {
|
|
1713
|
-
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(),
|
|
1714
1891
|
conversationId,
|
|
1715
1892
|
role: "assistant",
|
|
1716
1893
|
content: this.accumulatedText,
|
|
@@ -1721,8 +1898,10 @@ var ChatSession = class {
|
|
|
1721
1898
|
this.storage.addMessage(assistantMessage, conversationId).catch(() => {
|
|
1722
1899
|
});
|
|
1723
1900
|
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1901
|
+
if (ownsTurnState) {
|
|
1902
|
+
this.isStreaming = false;
|
|
1903
|
+
this.currentJobId = null;
|
|
1904
|
+
}
|
|
1726
1905
|
return;
|
|
1727
1906
|
case "custom":
|
|
1728
1907
|
if (wire.name === "title_generated") {
|
|
@@ -1756,9 +1935,27 @@ var ChatSession = class {
|
|
|
1756
1935
|
* Used before reconnectToJob — SSE replay handles event replay.
|
|
1757
1936
|
*/
|
|
1758
1937
|
async loadConversation(id) {
|
|
1938
|
+
const load = ++this.loadGeneration;
|
|
1759
1939
|
this.conversationId = id;
|
|
1760
|
-
this.resetStreamingState();
|
|
1761
|
-
|
|
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;
|
|
1762
1959
|
}
|
|
1763
1960
|
/**
|
|
1764
1961
|
* Reconnect to a running job's SSE stream (e.g. after page reload).
|
|
@@ -1771,7 +1968,8 @@ var ChatSession = class {
|
|
|
1771
1968
|
this.lastSeq = -1;
|
|
1772
1969
|
this.submittedToolCallIds.clear();
|
|
1773
1970
|
this.resetStreamingState();
|
|
1774
|
-
|
|
1971
|
+
const controller = new AbortController();
|
|
1972
|
+
this.abortController = controller;
|
|
1775
1973
|
try {
|
|
1776
1974
|
await this.consumeEventStream(
|
|
1777
1975
|
jobId,
|
|
@@ -1788,8 +1986,10 @@ var ChatSession = class {
|
|
|
1788
1986
|
blockPath: null
|
|
1789
1987
|
});
|
|
1790
1988
|
} finally {
|
|
1791
|
-
this.
|
|
1792
|
-
|
|
1989
|
+
if (this.abortController === controller) {
|
|
1990
|
+
this.isStreaming = false;
|
|
1991
|
+
this.abortController = null;
|
|
1992
|
+
}
|
|
1793
1993
|
}
|
|
1794
1994
|
}
|
|
1795
1995
|
/** Detach from the SSE stream without cancelling the job. */
|
|
@@ -1800,25 +2000,54 @@ var ChatSession = class {
|
|
|
1800
2000
|
this.resetStreamingState();
|
|
1801
2001
|
this.emit({ type: "disconnected" });
|
|
1802
2002
|
}
|
|
1803
|
-
/**
|
|
1804
|
-
|
|
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() {
|
|
1805
2012
|
if (this.currentJobId) {
|
|
1806
2013
|
this.client.cancelJob(this.currentJobId).catch(() => {
|
|
1807
2014
|
});
|
|
1808
2015
|
}
|
|
1809
2016
|
this.detach();
|
|
1810
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();
|
|
1811
2025
|
this.protocols.clear();
|
|
1812
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
|
+
}
|
|
1813
2038
|
async createNewConversation() {
|
|
1814
2039
|
const id = generateId();
|
|
2040
|
+
const load = this.loadGeneration;
|
|
1815
2041
|
const conversation = await this.storage.createConversation(
|
|
1816
2042
|
id,
|
|
1817
2043
|
"New Conversation"
|
|
1818
2044
|
);
|
|
1819
2045
|
this.conversations.unshift(conversation);
|
|
2046
|
+
if (load !== this.loadGeneration) return id;
|
|
2047
|
+
this.loadGeneration++;
|
|
1820
2048
|
this.conversationId = id;
|
|
1821
|
-
this.
|
|
2049
|
+
this.setMessages([]);
|
|
2050
|
+
this.messagesConversationId = id;
|
|
1822
2051
|
return id;
|
|
1823
2052
|
}
|
|
1824
2053
|
/**
|
|
@@ -1837,7 +2066,7 @@ var ChatSession = class {
|
|
|
1837
2066
|
*/
|
|
1838
2067
|
replayTurn(id, events, userMessageContent, userMessageId, isSteer = false) {
|
|
1839
2068
|
this.conversationId = id;
|
|
1840
|
-
this.resetStreamingState();
|
|
2069
|
+
if (!this.isStreaming) this.resetStreamingState();
|
|
1841
2070
|
if (userMessageContent) {
|
|
1842
2071
|
this.emit({
|
|
1843
2072
|
type: "user_message",
|
|
@@ -1868,11 +2097,17 @@ var ChatSession = class {
|
|
|
1868
2097
|
* job's events.
|
|
1869
2098
|
*/
|
|
1870
2099
|
async switchConversation(id, jobId) {
|
|
1871
|
-
const
|
|
1872
|
-
|
|
2100
|
+
const loading = this.loadConversation(id);
|
|
2101
|
+
const token = this.loadGeneration;
|
|
2102
|
+
const [loadResult, eventsResult] = await Promise.allSettled([
|
|
2103
|
+
loading,
|
|
1873
2104
|
this.client.getConversationEvents(id, jobId)
|
|
1874
2105
|
]);
|
|
1875
|
-
|
|
2106
|
+
if (token !== this.loadGeneration) return;
|
|
2107
|
+
if (loadResult.status === "rejected") {
|
|
2108
|
+
this.setMessages([]);
|
|
2109
|
+
this.messagesConversationId = id;
|
|
2110
|
+
}
|
|
1876
2111
|
this.replayTurn(
|
|
1877
2112
|
id,
|
|
1878
2113
|
eventsResult.status === "fulfilled" ? eventsResult.value : []
|
|
@@ -1963,8 +2198,10 @@ var ChatSession = class {
|
|
|
1963
2198
|
}
|
|
1964
2199
|
this.conversations = this.conversations.filter((c) => c.id !== id);
|
|
1965
2200
|
if (this.conversationId === id) {
|
|
2201
|
+
this.loadGeneration++;
|
|
1966
2202
|
this.conversationId = null;
|
|
1967
|
-
this.
|
|
2203
|
+
this.setMessages([]);
|
|
2204
|
+
this.messagesConversationId = null;
|
|
1968
2205
|
}
|
|
1969
2206
|
}
|
|
1970
2207
|
toggleClientTool(name) {
|
|
@@ -1980,6 +2217,11 @@ var ChatSession = class {
|
|
|
1980
2217
|
// src/restore-plan.ts
|
|
1981
2218
|
function planRestore(args) {
|
|
1982
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
|
+
);
|
|
1983
2225
|
const byId = /* @__PURE__ */ new Map();
|
|
1984
2226
|
userMessages.forEach((m, i) => {
|
|
1985
2227
|
if (m.id) byId.set(m.id, i);
|
|
@@ -2001,7 +2243,7 @@ function planRestore(args) {
|
|
|
2001
2243
|
userMessages.slice(0, cutover)
|
|
2002
2244
|
);
|
|
2003
2245
|
let cursor = cutover;
|
|
2004
|
-
const isSteer = (m) => !!m?.id && !
|
|
2246
|
+
const isSteer = (m) => !!m?.id && !claimed.has(m.id);
|
|
2005
2247
|
const drainTo = (stopAt) => {
|
|
2006
2248
|
while (cursor < stopAt) {
|
|
2007
2249
|
const m = userMessages[cursor++];
|
|
@@ -2087,6 +2329,12 @@ var StreamManager = class {
|
|
|
2087
2329
|
this._backgroundJobs = /* @__PURE__ */ new Map();
|
|
2088
2330
|
this.handlers = [];
|
|
2089
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;
|
|
2090
2338
|
this.session = session;
|
|
2091
2339
|
this.attach();
|
|
2092
2340
|
}
|
|
@@ -2137,7 +2385,7 @@ var StreamManager = class {
|
|
|
2137
2385
|
event
|
|
2138
2386
|
});
|
|
2139
2387
|
if (event.type === ChatEventType.MessageStop) {
|
|
2140
|
-
if (this._state === "streaming") {
|
|
2388
|
+
if (this._state === "streaming" && !this.session.isStreaming) {
|
|
2141
2389
|
this.setState("idle");
|
|
2142
2390
|
}
|
|
2143
2391
|
}
|
|
@@ -2150,13 +2398,21 @@ var StreamManager = class {
|
|
|
2150
2398
|
);
|
|
2151
2399
|
}
|
|
2152
2400
|
if (this._state === "streaming") return;
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
this.
|
|
2401
|
+
let target = this._activeConversationId;
|
|
2402
|
+
if (!target) {
|
|
2403
|
+
target = await this.session.createNewConversation();
|
|
2404
|
+
this.setActiveConversation(target);
|
|
2156
2405
|
}
|
|
2157
2406
|
this.setState("streaming");
|
|
2158
2407
|
try {
|
|
2159
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,
|
|
2160
2416
|
agentName: options?.agentName,
|
|
2161
2417
|
uploadIds: options?.uploadIds,
|
|
2162
2418
|
planMode: options?.planMode,
|
|
@@ -2175,6 +2431,9 @@ var StreamManager = class {
|
|
|
2175
2431
|
// ── Regenerate ────────────────────────────────────────────────
|
|
2176
2432
|
async regenerate() {
|
|
2177
2433
|
if (this._state === "streaming") return;
|
|
2434
|
+
if (this.session.messagesConversationId !== this._activeConversationId) {
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2178
2437
|
const userMsgs = this.session.messages.filter(
|
|
2179
2438
|
(m) => m.role === "user"
|
|
2180
2439
|
);
|
|
@@ -2211,44 +2470,78 @@ var StreamManager = class {
|
|
|
2211
2470
|
async switchTo(conversationId, opts) {
|
|
2212
2471
|
if (conversationId === this._activeConversationId) return;
|
|
2213
2472
|
const targetHadBackgroundJob = this._backgroundJobs.has(conversationId);
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
if (oldConvId && jobId) {
|
|
2218
|
-
this._backgroundJobs.set(oldConvId, jobId);
|
|
2219
|
-
this.emit({
|
|
2220
|
-
type: "backgroundJobsChanged",
|
|
2221
|
-
jobs: this._backgroundJobs
|
|
2222
|
-
});
|
|
2223
|
-
}
|
|
2224
|
-
this.session.detach();
|
|
2225
|
-
}
|
|
2226
|
-
if (this._backgroundJobs.has(conversationId)) {
|
|
2473
|
+
this.detachStreamingTurn();
|
|
2474
|
+
const parkedJobId = this._backgroundJobs.get(conversationId);
|
|
2475
|
+
if (parkedJobId !== void 0) {
|
|
2227
2476
|
this._backgroundJobs.delete(conversationId);
|
|
2228
2477
|
this.emit({
|
|
2229
2478
|
type: "backgroundJobsChanged",
|
|
2230
2479
|
jobs: this._backgroundJobs
|
|
2231
2480
|
});
|
|
2232
2481
|
}
|
|
2233
|
-
this.setActiveConversation(conversationId);
|
|
2482
|
+
const gen = this.setActiveConversation(conversationId);
|
|
2234
2483
|
if (opts?.skipHistoryReplay && !targetHadBackgroundJob) {
|
|
2235
2484
|
let activeJobId = null;
|
|
2236
2485
|
try {
|
|
2237
2486
|
activeJobId = (await this.session.client.getActiveJob(conversationId)).jobId;
|
|
2238
2487
|
} catch {
|
|
2239
2488
|
}
|
|
2489
|
+
if (gen !== this.generation) return;
|
|
2240
2490
|
if (!activeJobId) {
|
|
2241
|
-
|
|
2242
|
-
|
|
2491
|
+
try {
|
|
2492
|
+
await this.session.loadConversation(conversationId);
|
|
2493
|
+
} finally {
|
|
2494
|
+
if (gen === this.generation) this.settleIdle();
|
|
2495
|
+
}
|
|
2243
2496
|
return;
|
|
2244
2497
|
}
|
|
2245
2498
|
}
|
|
2246
|
-
|
|
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
|
+
}
|
|
2247
2519
|
}
|
|
2248
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
|
+
*/
|
|
2249
2530
|
async createConversation() {
|
|
2250
|
-
|
|
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
|
+
}
|
|
2251
2543
|
this.setActiveConversation(id);
|
|
2544
|
+
this.settleIdle();
|
|
2252
2545
|
return id;
|
|
2253
2546
|
}
|
|
2254
2547
|
/**
|
|
@@ -2259,16 +2552,34 @@ var StreamManager = class {
|
|
|
2259
2552
|
await this.session.renameConversation(id, title);
|
|
2260
2553
|
}
|
|
2261
2554
|
async deleteConversation(id) {
|
|
2262
|
-
|
|
2263
|
-
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
|
+
}
|
|
2264
2567
|
if (this._activeConversationId === id) {
|
|
2265
|
-
this.
|
|
2266
|
-
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 });
|
|
2267
2578
|
}
|
|
2268
2579
|
}
|
|
2269
2580
|
// ── Stop (explicit cancel) ────────────────────────────────────
|
|
2270
2581
|
stop() {
|
|
2271
|
-
this.session.
|
|
2582
|
+
this.session.cancelTurn();
|
|
2272
2583
|
this.setState("idle");
|
|
2273
2584
|
}
|
|
2274
2585
|
// ── Cleanup ───────────────────────────────────────────────────
|
|
@@ -2280,34 +2591,80 @@ var StreamManager = class {
|
|
|
2280
2591
|
this.handlers = [];
|
|
2281
2592
|
}
|
|
2282
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
|
+
}
|
|
2283
2633
|
finalizeStream() {
|
|
2284
2634
|
if (this._state === "streaming") {
|
|
2285
2635
|
this.setState("idle");
|
|
2286
2636
|
}
|
|
2287
2637
|
}
|
|
2288
2638
|
// ── Internal: restore ─────────────────────────────────────────
|
|
2289
|
-
async restore(conversationId) {
|
|
2290
|
-
this.
|
|
2639
|
+
async restore(conversationId, gen) {
|
|
2640
|
+
const superseded = () => gen !== this.generation;
|
|
2641
|
+
if (superseded()) return;
|
|
2642
|
+
if (!this.session.isStreaming) this.setState("restoring");
|
|
2291
2643
|
let activeJobId = null;
|
|
2292
2644
|
try {
|
|
2293
2645
|
const res = await this.session.client.getActiveJob(conversationId);
|
|
2294
2646
|
activeJobId = res.jobId;
|
|
2295
2647
|
} catch {
|
|
2296
2648
|
}
|
|
2649
|
+
if (superseded()) return;
|
|
2297
2650
|
if (activeJobId) {
|
|
2298
2651
|
await this.session.loadConversation(conversationId);
|
|
2652
|
+
if (superseded()) return;
|
|
2299
2653
|
this.setState("streaming");
|
|
2300
2654
|
try {
|
|
2301
2655
|
await this.session.reconnectToJob(activeJobId);
|
|
2302
2656
|
} catch {
|
|
2303
2657
|
}
|
|
2304
|
-
if (
|
|
2658
|
+
if (superseded()) return;
|
|
2659
|
+
if (this._state === "streaming" && !this.session.isStreaming) {
|
|
2305
2660
|
this.setState("idle");
|
|
2306
2661
|
}
|
|
2307
2662
|
} else {
|
|
2308
2663
|
await this.session.loadConversation(conversationId);
|
|
2664
|
+
if (superseded()) return;
|
|
2309
2665
|
try {
|
|
2310
2666
|
const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
|
|
2667
|
+
if (superseded()) return;
|
|
2311
2668
|
const completedJobs = jobs.filter(
|
|
2312
2669
|
(j) => j.status === "completed"
|
|
2313
2670
|
);
|
|
@@ -2319,6 +2676,14 @@ var StreamManager = class {
|
|
|
2319
2676
|
job_id: j.job_id,
|
|
2320
2677
|
message_id: j.message_id
|
|
2321
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),
|
|
2322
2687
|
userMessages: userMessages.map((m) => ({
|
|
2323
2688
|
id: m.id,
|
|
2324
2689
|
content: m.content
|
|
@@ -2329,10 +2694,12 @@ var StreamManager = class {
|
|
|
2329
2694
|
(job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
|
|
2330
2695
|
)
|
|
2331
2696
|
);
|
|
2697
|
+
if (superseded()) return;
|
|
2332
2698
|
const eventsByJobId = new Map(
|
|
2333
2699
|
completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
|
|
2334
2700
|
);
|
|
2335
2701
|
for (const step of plan) {
|
|
2702
|
+
if (superseded()) return;
|
|
2336
2703
|
if (step.kind === "steer") {
|
|
2337
2704
|
this.session.replayTurn(
|
|
2338
2705
|
conversationId,
|
|
@@ -2350,6 +2717,7 @@ var StreamManager = class {
|
|
|
2350
2717
|
step.messageId
|
|
2351
2718
|
);
|
|
2352
2719
|
}
|
|
2720
|
+
if (superseded()) return;
|
|
2353
2721
|
if (completedJobs.length > 0) {
|
|
2354
2722
|
this.emit({
|
|
2355
2723
|
type: "versionsReady",
|
|
@@ -2359,13 +2727,17 @@ var StreamManager = class {
|
|
|
2359
2727
|
}
|
|
2360
2728
|
} catch {
|
|
2361
2729
|
}
|
|
2362
|
-
|
|
2730
|
+
if (superseded()) return;
|
|
2731
|
+
this.settleIdle();
|
|
2363
2732
|
}
|
|
2364
2733
|
}
|
|
2365
2734
|
// ── Internal: set active conversation ─────────────────────────
|
|
2366
2735
|
setActiveConversation(id) {
|
|
2367
2736
|
this._activeConversationId = id;
|
|
2737
|
+
this.session.invalidateLoadsInFlight();
|
|
2738
|
+
const claimed = ++this.generation;
|
|
2368
2739
|
this.emit({ type: "conversationChanged", conversationId: id });
|
|
2740
|
+
return claimed;
|
|
2369
2741
|
}
|
|
2370
2742
|
};
|
|
2371
2743
|
|