@astralform/js 5.1.0 → 6.0.1
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/README.md +8 -3
- package/dist/index.cjs +169 -132
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +67 -6
- package/dist/index.d.ts +67 -6
- package/dist/index.js +169 -132
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -1400,11 +1400,12 @@ declare class ChatSession {
|
|
|
1400
1400
|
/**
|
|
1401
1401
|
* Rename a conversation, server first.
|
|
1402
1402
|
*
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
1403
|
+
* Server first, like the delete below: a failed rename written locally would
|
|
1404
|
+
* leave the sidebar showing a title the server never accepted, and nothing
|
|
1405
|
+
* refetches a conversation that is already in the loaded list. (The delete
|
|
1406
|
+
* used to be the counter-example here — it dropped the row whatever the
|
|
1407
|
+
* server said, on the theory that a failed one was self-correcting. It was
|
|
1408
|
+
* not: the row stayed deleted locally and alive on the server.)
|
|
1408
1409
|
*
|
|
1409
1410
|
* Mirrors the `title_generated` path: the entry in `conversations` is
|
|
1410
1411
|
* mutated in place, which is what every consumer of the list reads.
|
|
@@ -1444,7 +1445,21 @@ declare class LLMNotConfiguredError extends AstralformError {
|
|
|
1444
1445
|
constructor(message?: string);
|
|
1445
1446
|
}
|
|
1446
1447
|
declare class ServerError extends AstralformError {
|
|
1447
|
-
|
|
1448
|
+
/**
|
|
1449
|
+
* The HTTP status, when this came from a response.
|
|
1450
|
+
*
|
|
1451
|
+
* Every status except 401 and 429 collapses into this one class, so without
|
|
1452
|
+
* it a caller cannot tell "the thing is already gone" (404) from "the server
|
|
1453
|
+
* broke" (500) or "this is not yours" (403) — the message is the only other
|
|
1454
|
+
* signal and it is prose. `deleteConversation` is the case that forced it:
|
|
1455
|
+
* it read EVERY failure as already-deleted and dropped the conversation
|
|
1456
|
+
* locally regardless, so a 500 looked exactly like success and the row came
|
|
1457
|
+
* back on the next device.
|
|
1458
|
+
*
|
|
1459
|
+
* Undefined when a ServerError is constructed without a response.
|
|
1460
|
+
*/
|
|
1461
|
+
readonly status?: number;
|
|
1462
|
+
constructor(message?: string, status?: number);
|
|
1448
1463
|
}
|
|
1449
1464
|
declare class ConnectionError extends AstralformError {
|
|
1450
1465
|
constructor(message?: string);
|
|
@@ -1517,6 +1532,13 @@ declare class StreamManager {
|
|
|
1517
1532
|
* one the user is waiting on — see ``restore``.
|
|
1518
1533
|
*/
|
|
1519
1534
|
private generation;
|
|
1535
|
+
/**
|
|
1536
|
+
* Bumped every time a turn STARTS. `generation` does not move for a send
|
|
1537
|
+
* (only a pointer move does), and the streaming state returns to idle when a
|
|
1538
|
+
* turn ends — so neither can tell a restore that a turn ran inside one of
|
|
1539
|
+
* its awaits. This can.
|
|
1540
|
+
*/
|
|
1541
|
+
private turnCounter;
|
|
1520
1542
|
constructor(session: ChatSession);
|
|
1521
1543
|
get state(): StreamState;
|
|
1522
1544
|
get activeConversationId(): string | null;
|
|
@@ -1593,6 +1615,45 @@ declare class StreamManager {
|
|
|
1593
1615
|
private settleIdle;
|
|
1594
1616
|
private finalizeStream;
|
|
1595
1617
|
private restore;
|
|
1618
|
+
/**
|
|
1619
|
+
* Has a live turn taken the block view over?
|
|
1620
|
+
*
|
|
1621
|
+
* ``_state`` is the SYNCHRONOUS authority and ``session.isStreaming`` lags it
|
|
1622
|
+
* by an await: ``send`` sets ``_state = "streaming"`` before its first await,
|
|
1623
|
+
* while the session only raises its flag inside ``processStream``, behind the
|
|
1624
|
+
* ``storage.addMessage`` write. For that whole window a send is underway —
|
|
1625
|
+
* composer cleared, optimistic bubble drawn — and the session flag still
|
|
1626
|
+
* reads false. Reading both closes the window from either end, since
|
|
1627
|
+
* ``reconnectToJob`` is the mirror case: it raises the session flag without
|
|
1628
|
+
* ever moving ``_state``.
|
|
1629
|
+
*/
|
|
1630
|
+
private viewTakenOverByLiveTurn;
|
|
1631
|
+
/**
|
|
1632
|
+
* Has a turn STARTED since ``turn`` was captured?
|
|
1633
|
+
*
|
|
1634
|
+
* ``viewTakenOverByLiveTurn`` reads the current state, so it cannot see a
|
|
1635
|
+
* turn that both started and ENDED inside one of the restore's awaits — a
|
|
1636
|
+
* send that fails fast (auth, rate limit) resolves in about the time the job
|
|
1637
|
+
* list takes, and leaves `_state` back at idle with its blocks already
|
|
1638
|
+
* rendered. A monotonic count is the only thing that survives a state that
|
|
1639
|
+
* has returned to where it started.
|
|
1640
|
+
*/
|
|
1641
|
+
private turnStarted;
|
|
1642
|
+
/**
|
|
1643
|
+
* Replay a conversation's persisted history into the consumer's block view.
|
|
1644
|
+
*
|
|
1645
|
+
* Returns false when this restore lost the right to finish — a newer switch
|
|
1646
|
+
* superseded it, or a send took the view over — in which case the caller
|
|
1647
|
+
* must stop rather than finish. See ``restore``.
|
|
1648
|
+
*
|
|
1649
|
+
* ``activeJobId`` names the turn that is still running, if any. Its events
|
|
1650
|
+
* are NOT fetched here: they are the live stream the caller reconnects to
|
|
1651
|
+
* straight after. It is passed so ``planRestore`` can pair it with the prompt
|
|
1652
|
+
* that started it, which is emitted as a bubble with no events — the whole
|
|
1653
|
+
* reason a conversation reopened mid-turn now shows the message that started
|
|
1654
|
+
* that turn.
|
|
1655
|
+
*/
|
|
1656
|
+
private replayHistory;
|
|
1596
1657
|
private setActiveConversation;
|
|
1597
1658
|
}
|
|
1598
1659
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1400,11 +1400,12 @@ declare class ChatSession {
|
|
|
1400
1400
|
/**
|
|
1401
1401
|
* Rename a conversation, server first.
|
|
1402
1402
|
*
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
1403
|
+
* Server first, like the delete below: a failed rename written locally would
|
|
1404
|
+
* leave the sidebar showing a title the server never accepted, and nothing
|
|
1405
|
+
* refetches a conversation that is already in the loaded list. (The delete
|
|
1406
|
+
* used to be the counter-example here — it dropped the row whatever the
|
|
1407
|
+
* server said, on the theory that a failed one was self-correcting. It was
|
|
1408
|
+
* not: the row stayed deleted locally and alive on the server.)
|
|
1408
1409
|
*
|
|
1409
1410
|
* Mirrors the `title_generated` path: the entry in `conversations` is
|
|
1410
1411
|
* mutated in place, which is what every consumer of the list reads.
|
|
@@ -1444,7 +1445,21 @@ declare class LLMNotConfiguredError extends AstralformError {
|
|
|
1444
1445
|
constructor(message?: string);
|
|
1445
1446
|
}
|
|
1446
1447
|
declare class ServerError extends AstralformError {
|
|
1447
|
-
|
|
1448
|
+
/**
|
|
1449
|
+
* The HTTP status, when this came from a response.
|
|
1450
|
+
*
|
|
1451
|
+
* Every status except 401 and 429 collapses into this one class, so without
|
|
1452
|
+
* it a caller cannot tell "the thing is already gone" (404) from "the server
|
|
1453
|
+
* broke" (500) or "this is not yours" (403) — the message is the only other
|
|
1454
|
+
* signal and it is prose. `deleteConversation` is the case that forced it:
|
|
1455
|
+
* it read EVERY failure as already-deleted and dropped the conversation
|
|
1456
|
+
* locally regardless, so a 500 looked exactly like success and the row came
|
|
1457
|
+
* back on the next device.
|
|
1458
|
+
*
|
|
1459
|
+
* Undefined when a ServerError is constructed without a response.
|
|
1460
|
+
*/
|
|
1461
|
+
readonly status?: number;
|
|
1462
|
+
constructor(message?: string, status?: number);
|
|
1448
1463
|
}
|
|
1449
1464
|
declare class ConnectionError extends AstralformError {
|
|
1450
1465
|
constructor(message?: string);
|
|
@@ -1517,6 +1532,13 @@ declare class StreamManager {
|
|
|
1517
1532
|
* one the user is waiting on — see ``restore``.
|
|
1518
1533
|
*/
|
|
1519
1534
|
private generation;
|
|
1535
|
+
/**
|
|
1536
|
+
* Bumped every time a turn STARTS. `generation` does not move for a send
|
|
1537
|
+
* (only a pointer move does), and the streaming state returns to idle when a
|
|
1538
|
+
* turn ends — so neither can tell a restore that a turn ran inside one of
|
|
1539
|
+
* its awaits. This can.
|
|
1540
|
+
*/
|
|
1541
|
+
private turnCounter;
|
|
1520
1542
|
constructor(session: ChatSession);
|
|
1521
1543
|
get state(): StreamState;
|
|
1522
1544
|
get activeConversationId(): string | null;
|
|
@@ -1593,6 +1615,45 @@ declare class StreamManager {
|
|
|
1593
1615
|
private settleIdle;
|
|
1594
1616
|
private finalizeStream;
|
|
1595
1617
|
private restore;
|
|
1618
|
+
/**
|
|
1619
|
+
* Has a live turn taken the block view over?
|
|
1620
|
+
*
|
|
1621
|
+
* ``_state`` is the SYNCHRONOUS authority and ``session.isStreaming`` lags it
|
|
1622
|
+
* by an await: ``send`` sets ``_state = "streaming"`` before its first await,
|
|
1623
|
+
* while the session only raises its flag inside ``processStream``, behind the
|
|
1624
|
+
* ``storage.addMessage`` write. For that whole window a send is underway —
|
|
1625
|
+
* composer cleared, optimistic bubble drawn — and the session flag still
|
|
1626
|
+
* reads false. Reading both closes the window from either end, since
|
|
1627
|
+
* ``reconnectToJob`` is the mirror case: it raises the session flag without
|
|
1628
|
+
* ever moving ``_state``.
|
|
1629
|
+
*/
|
|
1630
|
+
private viewTakenOverByLiveTurn;
|
|
1631
|
+
/**
|
|
1632
|
+
* Has a turn STARTED since ``turn`` was captured?
|
|
1633
|
+
*
|
|
1634
|
+
* ``viewTakenOverByLiveTurn`` reads the current state, so it cannot see a
|
|
1635
|
+
* turn that both started and ENDED inside one of the restore's awaits — a
|
|
1636
|
+
* send that fails fast (auth, rate limit) resolves in about the time the job
|
|
1637
|
+
* list takes, and leaves `_state` back at idle with its blocks already
|
|
1638
|
+
* rendered. A monotonic count is the only thing that survives a state that
|
|
1639
|
+
* has returned to where it started.
|
|
1640
|
+
*/
|
|
1641
|
+
private turnStarted;
|
|
1642
|
+
/**
|
|
1643
|
+
* Replay a conversation's persisted history into the consumer's block view.
|
|
1644
|
+
*
|
|
1645
|
+
* Returns false when this restore lost the right to finish — a newer switch
|
|
1646
|
+
* superseded it, or a send took the view over — in which case the caller
|
|
1647
|
+
* must stop rather than finish. See ``restore``.
|
|
1648
|
+
*
|
|
1649
|
+
* ``activeJobId`` names the turn that is still running, if any. Its events
|
|
1650
|
+
* are NOT fetched here: they are the live stream the caller reconnects to
|
|
1651
|
+
* straight after. It is passed so ``planRestore`` can pair it with the prompt
|
|
1652
|
+
* that started it, which is emitted as a bubble with no events — the whole
|
|
1653
|
+
* reason a conversation reopened mid-turn now shows the message that started
|
|
1654
|
+
* that turn.
|
|
1655
|
+
*/
|
|
1656
|
+
private replayHistory;
|
|
1596
1657
|
private setActiveConversation;
|
|
1597
1658
|
}
|
|
1598
1659
|
|
package/dist/index.js
CHANGED
|
@@ -26,9 +26,10 @@ var LLMNotConfiguredError = class extends AstralformError {
|
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
28
|
var ServerError = class extends AstralformError {
|
|
29
|
-
constructor(message = "Internal server error") {
|
|
29
|
+
constructor(message = "Internal server error", status) {
|
|
30
30
|
super(message, "server_error");
|
|
31
31
|
this.name = "ServerError";
|
|
32
|
+
if (status !== void 0) Object.assign(this, { status });
|
|
32
33
|
}
|
|
33
34
|
};
|
|
34
35
|
var ConnectionError = class extends AstralformError {
|
|
@@ -58,6 +59,16 @@ function generateId() {
|
|
|
58
59
|
return v.toString(16);
|
|
59
60
|
});
|
|
60
61
|
}
|
|
62
|
+
function snakeToCamel(str) {
|
|
63
|
+
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
64
|
+
}
|
|
65
|
+
function camelizeKeys(obj) {
|
|
66
|
+
const result = {};
|
|
67
|
+
for (const key of Object.keys(obj)) {
|
|
68
|
+
result[snakeToCamel(key)] = obj[key];
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
61
72
|
|
|
62
73
|
// src/rate-limit.ts
|
|
63
74
|
var DEFAULT_MESSAGE = "Rate limit exceeded";
|
|
@@ -208,7 +219,7 @@ async function* streamJobSSE(options) {
|
|
|
208
219
|
case 429:
|
|
209
220
|
throw createRateLimitErrorFromHttp(response, rawText);
|
|
210
221
|
default:
|
|
211
|
-
throw new ServerError(text || `HTTP ${response.status}
|
|
222
|
+
throw new ServerError(text || `HTTP ${response.status}`, response.status);
|
|
212
223
|
}
|
|
213
224
|
}
|
|
214
225
|
if (!response.body) {
|
|
@@ -479,7 +490,10 @@ var AstralformClient = class {
|
|
|
479
490
|
throw createRateLimitErrorFromHttp(response, text);
|
|
480
491
|
default: {
|
|
481
492
|
const safeText = text ? sanitizeErrorText(text) : "";
|
|
482
|
-
throw new ServerError(
|
|
493
|
+
throw new ServerError(
|
|
494
|
+
safeText || `HTTP ${response.status}`,
|
|
495
|
+
response.status
|
|
496
|
+
);
|
|
483
497
|
}
|
|
484
498
|
}
|
|
485
499
|
}
|
|
@@ -516,13 +530,7 @@ var AstralformClient = class {
|
|
|
516
530
|
const safeLimit = Math.max(1, Math.min(200, Math.floor(Number(limit))));
|
|
517
531
|
const safeOffset = Math.max(0, Math.floor(Number(offset)));
|
|
518
532
|
const raw = await this.get(`/v1/conversations?limit=${safeLimit}&offset=${safeOffset}`);
|
|
519
|
-
return raw.map((c) => (
|
|
520
|
-
id: c.id,
|
|
521
|
-
title: c.title,
|
|
522
|
-
messageCount: c.message_count,
|
|
523
|
-
createdAt: c.created_at,
|
|
524
|
-
updatedAt: c.updated_at
|
|
525
|
-
}));
|
|
533
|
+
return raw.map((c) => camelizeKeys(c));
|
|
526
534
|
}
|
|
527
535
|
async getMessages(conversationId) {
|
|
528
536
|
const raw = await this.get(`/v1/conversations/${encodeURIComponent(conversationId)}/messages`);
|
|
@@ -547,13 +555,7 @@ var AstralformClient = class {
|
|
|
547
555
|
*/
|
|
548
556
|
async renameConversation(id, title) {
|
|
549
557
|
const c = await this.patch(`/v1/conversations/${encodeURIComponent(id)}`, { title });
|
|
550
|
-
return
|
|
551
|
-
id: c.id,
|
|
552
|
-
title: c.title,
|
|
553
|
-
messageCount: c.message_count,
|
|
554
|
-
createdAt: c.created_at,
|
|
555
|
-
updatedAt: c.updated_at
|
|
556
|
-
};
|
|
558
|
+
return camelizeKeys(c);
|
|
557
559
|
}
|
|
558
560
|
async deleteConversation(id) {
|
|
559
561
|
await this.del(`/v1/conversations/${encodeURIComponent(id)}`);
|
|
@@ -566,14 +568,7 @@ var AstralformClient = class {
|
|
|
566
568
|
*/
|
|
567
569
|
async getAgents() {
|
|
568
570
|
const raw = await this.get("/v1/agents");
|
|
569
|
-
return raw.map((a) => (
|
|
570
|
-
name: a.name,
|
|
571
|
-
displayName: a.display_name,
|
|
572
|
-
description: a.description,
|
|
573
|
-
isOrchestrator: a.is_orchestrator,
|
|
574
|
-
isEnabled: a.is_enabled,
|
|
575
|
-
avatarUrl: a.avatar_url
|
|
576
|
-
}));
|
|
571
|
+
return raw.map((a) => camelizeKeys(a));
|
|
577
572
|
}
|
|
578
573
|
/**
|
|
579
574
|
* List the models the caller may pick this turn — expanded from the curated
|
|
@@ -600,12 +595,7 @@ var AstralformClient = class {
|
|
|
600
595
|
}
|
|
601
596
|
async getSkills() {
|
|
602
597
|
const raw = await this.get("/v1/skills");
|
|
603
|
-
return raw.map((s) => (
|
|
604
|
-
name: s.name,
|
|
605
|
-
displayName: s.display_name,
|
|
606
|
-
description: s.description,
|
|
607
|
-
isEnabled: s.is_enabled
|
|
608
|
-
}));
|
|
598
|
+
return raw.map((s) => camelizeKeys(s));
|
|
609
599
|
}
|
|
610
600
|
async getConversationEvents(conversationId, jobId) {
|
|
611
601
|
let url = `/v1/conversations/${encodeURIComponent(conversationId)}/events`;
|
|
@@ -722,13 +712,7 @@ var AstralformClient = class {
|
|
|
722
712
|
// sending them in API-key mode yields 401.
|
|
723
713
|
async listTeams() {
|
|
724
714
|
const raw = await this.get("/v1/teams");
|
|
725
|
-
return raw.map((t) => (
|
|
726
|
-
id: t.id,
|
|
727
|
-
name: t.name,
|
|
728
|
-
slug: t.slug,
|
|
729
|
-
isDefault: t.is_default,
|
|
730
|
-
role: t.role
|
|
731
|
-
}));
|
|
715
|
+
return raw.map((t) => camelizeKeys(t));
|
|
732
716
|
}
|
|
733
717
|
/**
|
|
734
718
|
* List the team-level agents (formerly "projects") the signed-in user can
|
|
@@ -737,14 +721,7 @@ var AstralformClient = class {
|
|
|
737
721
|
*/
|
|
738
722
|
async listAgents(teamId) {
|
|
739
723
|
const raw = await this.get(`/v1/teams/${encodeURIComponent(teamId)}/agents`);
|
|
740
|
-
return raw.map((a) => (
|
|
741
|
-
id: a.id,
|
|
742
|
-
name: a.name,
|
|
743
|
-
teamId: a.team_id,
|
|
744
|
-
createdAt: a.created_at,
|
|
745
|
-
updatedAt: a.updated_at,
|
|
746
|
-
avatarUrl: a.avatar_url ?? null
|
|
747
|
-
}));
|
|
724
|
+
return raw.map((a) => camelizeKeys(a));
|
|
748
725
|
}
|
|
749
726
|
// --- Jobs API ---
|
|
750
727
|
async createJob(request) {
|
|
@@ -781,13 +758,7 @@ var AstralformClient = class {
|
|
|
781
758
|
};
|
|
782
759
|
if (request.comment != null) body.comment = request.comment;
|
|
783
760
|
const raw = await this.post(`/v1/jobs/${encodeURIComponent(jobId)}/feedback`, body);
|
|
784
|
-
return
|
|
785
|
-
id: raw.id,
|
|
786
|
-
jobId: raw.job_id,
|
|
787
|
-
rating: raw.rating,
|
|
788
|
-
comment: raw.comment,
|
|
789
|
-
createdAt: raw.created_at
|
|
790
|
-
};
|
|
761
|
+
return camelizeKeys(raw);
|
|
791
762
|
}
|
|
792
763
|
async getActiveJob(conversationId) {
|
|
793
764
|
const raw = await this.get(`/v1/conversations/${encodeURIComponent(conversationId)}/active-job`);
|
|
@@ -2175,11 +2146,12 @@ var ChatSession = class {
|
|
|
2175
2146
|
/**
|
|
2176
2147
|
* Rename a conversation, server first.
|
|
2177
2148
|
*
|
|
2178
|
-
*
|
|
2179
|
-
*
|
|
2180
|
-
*
|
|
2181
|
-
*
|
|
2182
|
-
*
|
|
2149
|
+
* Server first, like the delete below: a failed rename written locally would
|
|
2150
|
+
* leave the sidebar showing a title the server never accepted, and nothing
|
|
2151
|
+
* refetches a conversation that is already in the loaded list. (The delete
|
|
2152
|
+
* used to be the counter-example here — it dropped the row whatever the
|
|
2153
|
+
* server said, on the theory that a failed one was self-correcting. It was
|
|
2154
|
+
* not: the row stayed deleted locally and alive on the server.)
|
|
2183
2155
|
*
|
|
2184
2156
|
* Mirrors the `title_generated` path: the entry in `conversations` is
|
|
2185
2157
|
* mutated in place, which is what every consumer of the list reads.
|
|
@@ -2195,7 +2167,8 @@ var ChatSession = class {
|
|
|
2195
2167
|
async deleteConversation(id) {
|
|
2196
2168
|
try {
|
|
2197
2169
|
await this.client.deleteConversation(id);
|
|
2198
|
-
} catch {
|
|
2170
|
+
} catch (err) {
|
|
2171
|
+
if (!(err instanceof ServerError) || err.status !== 404) throw err;
|
|
2199
2172
|
}
|
|
2200
2173
|
await this.storage.deleteConversation(id);
|
|
2201
2174
|
if (this.serverConversationIds.delete(id)) {
|
|
@@ -2221,9 +2194,10 @@ var ChatSession = class {
|
|
|
2221
2194
|
|
|
2222
2195
|
// src/restore-plan.ts
|
|
2223
2196
|
function planRestore(args) {
|
|
2224
|
-
const { completedJobs, userMessages } = args;
|
|
2197
|
+
const { completedJobs, runningJob, userMessages } = args;
|
|
2198
|
+
const jobs = runningJob ? [...completedJobs, runningJob] : completedJobs;
|
|
2225
2199
|
const claimed = new Set(
|
|
2226
|
-
(args.claimedMessageIds ??
|
|
2200
|
+
(args.claimedMessageIds ?? jobs.map((j) => j.message_id)).filter(
|
|
2227
2201
|
(id) => !!id
|
|
2228
2202
|
)
|
|
2229
2203
|
);
|
|
@@ -2232,19 +2206,19 @@ function planRestore(args) {
|
|
|
2232
2206
|
if (m.id) byId.set(m.id, i);
|
|
2233
2207
|
});
|
|
2234
2208
|
const linkOf = (j) => j.message_id ? byId.get(j.message_id) : void 0;
|
|
2235
|
-
const positional = (
|
|
2209
|
+
const positional = (slice, msgs) => slice.map((job, i) => ({
|
|
2236
2210
|
kind: "turn",
|
|
2237
2211
|
jobId: job.job_id,
|
|
2238
2212
|
content: msgs[i]?.content,
|
|
2239
2213
|
messageId: msgs[i]?.id
|
|
2240
2214
|
}));
|
|
2241
|
-
const firstLinked =
|
|
2215
|
+
const firstLinked = jobs.findIndex((j) => linkOf(j) !== void 0);
|
|
2242
2216
|
if (firstLinked === -1) {
|
|
2243
|
-
return positional(
|
|
2217
|
+
return positional(jobs, userMessages);
|
|
2244
2218
|
}
|
|
2245
|
-
const cutover = linkOf(
|
|
2219
|
+
const cutover = linkOf(jobs[firstLinked]);
|
|
2246
2220
|
const steps = positional(
|
|
2247
|
-
|
|
2221
|
+
jobs.slice(0, firstLinked),
|
|
2248
2222
|
userMessages.slice(0, cutover)
|
|
2249
2223
|
);
|
|
2250
2224
|
let cursor = cutover;
|
|
@@ -2258,7 +2232,7 @@ function planRestore(args) {
|
|
|
2258
2232
|
}
|
|
2259
2233
|
cursor = stopAt + 1;
|
|
2260
2234
|
};
|
|
2261
|
-
for (const job of
|
|
2235
|
+
for (const job of jobs.slice(firstLinked)) {
|
|
2262
2236
|
const at = linkOf(job);
|
|
2263
2237
|
if (at !== void 0) {
|
|
2264
2238
|
drainTo(at);
|
|
@@ -2340,6 +2314,13 @@ var StreamManager = class {
|
|
|
2340
2314
|
* one the user is waiting on — see ``restore``.
|
|
2341
2315
|
*/
|
|
2342
2316
|
this.generation = 0;
|
|
2317
|
+
/**
|
|
2318
|
+
* Bumped every time a turn STARTS. `generation` does not move for a send
|
|
2319
|
+
* (only a pointer move does), and the streaming state returns to idle when a
|
|
2320
|
+
* turn ends — so neither can tell a restore that a turn ran inside one of
|
|
2321
|
+
* its awaits. This can.
|
|
2322
|
+
*/
|
|
2323
|
+
this.turnCounter = 0;
|
|
2343
2324
|
this.session = session;
|
|
2344
2325
|
this.attach();
|
|
2345
2326
|
}
|
|
@@ -2408,6 +2389,7 @@ var StreamManager = class {
|
|
|
2408
2389
|
target = await this.session.createNewConversation();
|
|
2409
2390
|
this.setActiveConversation(target);
|
|
2410
2391
|
}
|
|
2392
|
+
this.turnCounter++;
|
|
2411
2393
|
this.setState("streaming");
|
|
2412
2394
|
try {
|
|
2413
2395
|
await this.session.send(content, {
|
|
@@ -2444,6 +2426,7 @@ var StreamManager = class {
|
|
|
2444
2426
|
);
|
|
2445
2427
|
const lastUserMsg = userMsgs[userMsgs.length - 1];
|
|
2446
2428
|
if (!lastUserMsg) return;
|
|
2429
|
+
this.turnCounter++;
|
|
2447
2430
|
this.setState("streaming");
|
|
2448
2431
|
try {
|
|
2449
2432
|
await this.session.resendFromCheckpoint(
|
|
@@ -2644,7 +2627,9 @@ var StreamManager = class {
|
|
|
2644
2627
|
async restore(conversationId, gen) {
|
|
2645
2628
|
const superseded = () => gen !== this.generation;
|
|
2646
2629
|
if (superseded()) return;
|
|
2647
|
-
|
|
2630
|
+
const turn = this.turnCounter;
|
|
2631
|
+
const announcedRestoring = !this.session.isStreaming;
|
|
2632
|
+
if (announcedRestoring) this.setState("restoring");
|
|
2648
2633
|
let activeJobId = null;
|
|
2649
2634
|
try {
|
|
2650
2635
|
const res = await this.session.client.getActiveJob(conversationId);
|
|
@@ -2652,9 +2637,12 @@ var StreamManager = class {
|
|
|
2652
2637
|
} catch {
|
|
2653
2638
|
}
|
|
2654
2639
|
if (superseded()) return;
|
|
2640
|
+
await this.session.loadConversation(conversationId);
|
|
2641
|
+
if (superseded()) return;
|
|
2642
|
+
if (announcedRestoring && this.viewTakenOverByLiveTurn()) return;
|
|
2643
|
+
if (announcedRestoring && !await this.replayHistory(conversationId, gen, activeJobId, turn))
|
|
2644
|
+
return;
|
|
2655
2645
|
if (activeJobId) {
|
|
2656
|
-
await this.session.loadConversation(conversationId);
|
|
2657
|
-
if (superseded()) return;
|
|
2658
2646
|
this.setState("streaming");
|
|
2659
2647
|
try {
|
|
2660
2648
|
await this.session.reconnectToJob(activeJobId);
|
|
@@ -2665,76 +2653,125 @@ var StreamManager = class {
|
|
|
2665
2653
|
this.setState("idle");
|
|
2666
2654
|
}
|
|
2667
2655
|
} else {
|
|
2668
|
-
await this.session.loadConversation(conversationId);
|
|
2669
2656
|
if (superseded()) return;
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2657
|
+
this.settleIdle();
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
/**
|
|
2661
|
+
* Has a live turn taken the block view over?
|
|
2662
|
+
*
|
|
2663
|
+
* ``_state`` is the SYNCHRONOUS authority and ``session.isStreaming`` lags it
|
|
2664
|
+
* by an await: ``send`` sets ``_state = "streaming"`` before its first await,
|
|
2665
|
+
* while the session only raises its flag inside ``processStream``, behind the
|
|
2666
|
+
* ``storage.addMessage`` write. For that whole window a send is underway —
|
|
2667
|
+
* composer cleared, optimistic bubble drawn — and the session flag still
|
|
2668
|
+
* reads false. Reading both closes the window from either end, since
|
|
2669
|
+
* ``reconnectToJob`` is the mirror case: it raises the session flag without
|
|
2670
|
+
* ever moving ``_state``.
|
|
2671
|
+
*/
|
|
2672
|
+
viewTakenOverByLiveTurn() {
|
|
2673
|
+
return this._state === "streaming" || this.session.isStreaming;
|
|
2674
|
+
}
|
|
2675
|
+
/**
|
|
2676
|
+
* Has a turn STARTED since ``turn`` was captured?
|
|
2677
|
+
*
|
|
2678
|
+
* ``viewTakenOverByLiveTurn`` reads the current state, so it cannot see a
|
|
2679
|
+
* turn that both started and ENDED inside one of the restore's awaits — a
|
|
2680
|
+
* send that fails fast (auth, rate limit) resolves in about the time the job
|
|
2681
|
+
* list takes, and leaves `_state` back at idle with its blocks already
|
|
2682
|
+
* rendered. A monotonic count is the only thing that survives a state that
|
|
2683
|
+
* has returned to where it started.
|
|
2684
|
+
*/
|
|
2685
|
+
turnStarted(since) {
|
|
2686
|
+
return this.turnCounter !== since;
|
|
2687
|
+
}
|
|
2688
|
+
/**
|
|
2689
|
+
* Replay a conversation's persisted history into the consumer's block view.
|
|
2690
|
+
*
|
|
2691
|
+
* Returns false when this restore lost the right to finish — a newer switch
|
|
2692
|
+
* superseded it, or a send took the view over — in which case the caller
|
|
2693
|
+
* must stop rather than finish. See ``restore``.
|
|
2694
|
+
*
|
|
2695
|
+
* ``activeJobId`` names the turn that is still running, if any. Its events
|
|
2696
|
+
* are NOT fetched here: they are the live stream the caller reconnects to
|
|
2697
|
+
* straight after. It is passed so ``planRestore`` can pair it with the prompt
|
|
2698
|
+
* that started it, which is emitted as a bubble with no events — the whole
|
|
2699
|
+
* reason a conversation reopened mid-turn now shows the message that started
|
|
2700
|
+
* that turn.
|
|
2701
|
+
*/
|
|
2702
|
+
async replayHistory(conversationId, gen, activeJobId, turn) {
|
|
2703
|
+
const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
|
|
2704
|
+
try {
|
|
2705
|
+
const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
|
|
2706
|
+
if (stopReplay()) return false;
|
|
2707
|
+
const completedJobs = jobs.filter(
|
|
2708
|
+
(j) => j.status === "completed" && j.job_id !== activeJobId
|
|
2709
|
+
);
|
|
2710
|
+
const userMessages = this.session.messages.filter(
|
|
2711
|
+
(m) => m.role === "user"
|
|
2712
|
+
);
|
|
2713
|
+
const runningJob = activeJobId ? jobs.find((j) => j.job_id === activeJobId) : void 0;
|
|
2714
|
+
const plan = planRestore({
|
|
2715
|
+
completedJobs: completedJobs.map((j) => ({
|
|
2716
|
+
job_id: j.job_id,
|
|
2717
|
+
message_id: j.message_id
|
|
2718
|
+
})),
|
|
2719
|
+
runningJob: runningJob && {
|
|
2720
|
+
job_id: runningJob.job_id,
|
|
2721
|
+
message_id: runningJob.message_id
|
|
2722
|
+
},
|
|
2723
|
+
// Completed jobs PLUS the ones still going. A send landing in the
|
|
2724
|
+
// probe window has a running job, so its prompt is claimed and does
|
|
2725
|
+
// not read as a steer replayed over the bubble the live send already
|
|
2726
|
+
// rendered. Failed and cancelled jobs are deliberately NOT claimed:
|
|
2727
|
+
// they produce no `turn` step, so claiming them would delete the
|
|
2728
|
+
// user's prompt from the restore entirely rather than show it as a
|
|
2729
|
+
// steer.
|
|
2730
|
+
claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
|
|
2731
|
+
userMessages: userMessages.map((m) => ({
|
|
2732
|
+
id: m.id,
|
|
2733
|
+
content: m.content
|
|
2734
|
+
}))
|
|
2735
|
+
});
|
|
2736
|
+
const eventLists = await Promise.all(
|
|
2737
|
+
completedJobs.map(
|
|
2738
|
+
(job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
|
|
2739
|
+
)
|
|
2740
|
+
);
|
|
2741
|
+
if (stopReplay()) return false;
|
|
2742
|
+
const eventsByJobId = new Map(
|
|
2743
|
+
completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
|
|
2744
|
+
);
|
|
2745
|
+
for (const step of plan) {
|
|
2746
|
+
if (stopReplay()) return false;
|
|
2747
|
+
if (step.kind === "steer") {
|
|
2718
2748
|
this.session.replayTurn(
|
|
2719
2749
|
conversationId,
|
|
2720
|
-
|
|
2750
|
+
[],
|
|
2721
2751
|
step.content,
|
|
2722
|
-
step.messageId
|
|
2752
|
+
step.messageId,
|
|
2753
|
+
true
|
|
2723
2754
|
);
|
|
2755
|
+
continue;
|
|
2724
2756
|
}
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
});
|
|
2732
|
-
}
|
|
2733
|
-
} catch {
|
|
2757
|
+
this.session.replayTurn(
|
|
2758
|
+
conversationId,
|
|
2759
|
+
eventsByJobId.get(step.jobId) ?? [],
|
|
2760
|
+
step.content,
|
|
2761
|
+
step.messageId
|
|
2762
|
+
);
|
|
2734
2763
|
}
|
|
2735
|
-
if (
|
|
2736
|
-
|
|
2764
|
+
if (stopReplay()) return false;
|
|
2765
|
+
if (completedJobs.length > 0) {
|
|
2766
|
+
this.emit({
|
|
2767
|
+
type: "versionsReady",
|
|
2768
|
+
conversationId,
|
|
2769
|
+
count: completedJobs.length
|
|
2770
|
+
});
|
|
2771
|
+
}
|
|
2772
|
+
} catch {
|
|
2737
2773
|
}
|
|
2774
|
+
return !stopReplay();
|
|
2738
2775
|
}
|
|
2739
2776
|
// ── Internal: set active conversation ─────────────────────────
|
|
2740
2777
|
setActiveConversation(id) {
|