@astralform/js 9.4.1 → 9.5.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 +107 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +91 -7
- package/dist/index.d.ts +91 -7
- package/dist/index.js +107 -26
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -506,10 +506,23 @@ type ChatEvent = {
|
|
|
506
506
|
type: "subagent_start";
|
|
507
507
|
agent: AgentIdentity;
|
|
508
508
|
taskCallId?: string | null;
|
|
509
|
+
/** The run's id, when the frame carries one (`agent_start`). */
|
|
510
|
+
runId?: string;
|
|
509
511
|
} | {
|
|
510
512
|
type: "subagent_stop";
|
|
511
513
|
agent: AgentIdentity;
|
|
512
514
|
taskCallId?: string | null;
|
|
515
|
+
/** The run's id, when the frame carries one (`agent_stop`). */
|
|
516
|
+
runId?: string;
|
|
517
|
+
/**
|
|
518
|
+
* The child's OWN outcome and spend, surfaced separately from the turn
|
|
519
|
+
* totals (`message_stop.usage` still folds them in). Absent on the older
|
|
520
|
+
* `subagent_stop` frames, which carried only the identity.
|
|
521
|
+
*/
|
|
522
|
+
stopReason?: string;
|
|
523
|
+
inputTokens?: number;
|
|
524
|
+
outputTokens?: number;
|
|
525
|
+
durationMs?: number;
|
|
513
526
|
} | {
|
|
514
527
|
type: "context_warning";
|
|
515
528
|
/** Known values: "info" | "warning" | "critical". Typed as string for forward compat. */
|
|
@@ -644,6 +657,19 @@ interface Conversation {
|
|
|
644
657
|
* null) from an Astralform older than 0.69.46.
|
|
645
658
|
*/
|
|
646
659
|
repository?: string | null;
|
|
660
|
+
/**
|
|
661
|
+
* The live job for this conversation, or `null`/absent when nothing is
|
|
662
|
+
* running. The sidebar's per-row liveness signal: without it a client cannot
|
|
663
|
+
* know that a conversation it has never opened is running, because its only
|
|
664
|
+
* other liveness signals are the per-conversation streams it is attached to.
|
|
665
|
+
*
|
|
666
|
+
* A JOB ID rather than a state string, deliberately: the backend can cheaply
|
|
667
|
+
* say "a job is live here", while "waiting for the user" lives in the
|
|
668
|
+
* worker's in-memory state and reaches only the open conversation's stream.
|
|
669
|
+
* `null` from a server that projects the field; `undefined` from one older
|
|
670
|
+
* than it (same convention as `repository`), so treat both as "not running".
|
|
671
|
+
*/
|
|
672
|
+
activeJobId?: string | null;
|
|
647
673
|
/**
|
|
648
674
|
* The code group this task is filed under, or `null`/absent when it is not in
|
|
649
675
|
* one. MUTABLE — unlike `repository`, which is written once on the first turn:
|
|
@@ -1970,12 +1996,6 @@ type ChatEventHandler = (event: ChatEvent) => void;
|
|
|
1970
1996
|
* would leave the second page's offset pointing at the wrong row.
|
|
1971
1997
|
*/
|
|
1972
1998
|
declare const CONVERSATION_PAGE_SIZE = 50;
|
|
1973
|
-
/**
|
|
1974
|
-
* ChatSession — translates the backend wire protocol into typed ChatEvents
|
|
1975
|
-
* for consumers. Owns HTTP + SSE plumbing, conversation state, and the
|
|
1976
|
-
* client-tool round-trip. Does NOT own block construction — consumers
|
|
1977
|
-
* build their own block state from the typed events.
|
|
1978
|
-
*/
|
|
1979
1999
|
declare class ChatSession {
|
|
1980
2000
|
readonly client: AstralformClient;
|
|
1981
2001
|
readonly toolRegistry: ToolRegistry;
|
|
@@ -2115,6 +2135,33 @@ declare class ChatSession {
|
|
|
2115
2135
|
private processStream;
|
|
2116
2136
|
/** Last received sequence number for resumable reconnection */
|
|
2117
2137
|
private lastSeq;
|
|
2138
|
+
/**
|
|
2139
|
+
* Advance the resume cursor from a frame's `seq` — the position the next
|
|
2140
|
+
* reconnect asks the backend to replay after (`?after=`).
|
|
2141
|
+
*
|
|
2142
|
+
* Two guards, because getting this wrong is silent and expensive: the whole
|
|
2143
|
+
* turn replays over blocks the consumer is already rendering.
|
|
2144
|
+
*
|
|
2145
|
+
* - **A keepalive is not an event.** The backend sends it as an ordinary
|
|
2146
|
+
* `data:` frame with `seq: 0` BY DESIGN (``WireKeepalive(seq=0, ts=0.0)``
|
|
2147
|
+
* — an idle tick, not a position), and it arrives exactly when a turn has
|
|
2148
|
+
* been quiet for 45 s: the long-tool-run case. Harvesting it rewound the
|
|
2149
|
+
* cursor to 0, so a reconnect after a blip replayed the turn from its
|
|
2150
|
+
* start — deltas and all.
|
|
2151
|
+
* - **The cursor only advances.** `send` and `reconnectToJob` reset it to
|
|
2152
|
+
* -1 per job, so a new job's `seq: 0` still moves it forward; a frame
|
|
2153
|
+
* carrying an older (or zero) position cannot move it back.
|
|
2154
|
+
*
|
|
2155
|
+
* The one other frame on the wire with a non-position `seq` is the
|
|
2156
|
+
* subscriber's SYNTHETIC terminal — a back-filled `error` for a job whose
|
|
2157
|
+
* worker died, built without a `seq` so Pydantic's default `0` reaches the
|
|
2158
|
+
* wire. Deliberately not named here: the advance-only rule already ignores it
|
|
2159
|
+
* whenever the client has drained anything (0 never exceeds a real cursor),
|
|
2160
|
+
* and when it is the FIRST frame the pump ends on `sawTerminal` before any
|
|
2161
|
+
* reconnect could use the cursor. Naming it would add a rule that no
|
|
2162
|
+
* reachable input exercises.
|
|
2163
|
+
*/
|
|
2164
|
+
private harvestResumeCursor;
|
|
2118
2165
|
/**
|
|
2119
2166
|
* Client-tool call_ids whose result was already submitted this turn. On a
|
|
2120
2167
|
* reconnect the resumed stream can replay a tool request we already handled;
|
|
@@ -2182,7 +2229,15 @@ declare class ChatSession {
|
|
|
2182
2229
|
* Reconnect to a running job's SSE stream (e.g. after page reload).
|
|
2183
2230
|
* Replays all events from the beginning and continues live.
|
|
2184
2231
|
*/
|
|
2185
|
-
|
|
2232
|
+
/**
|
|
2233
|
+
* @param afterSeq Resume the job's event stream AFTER this sequence number.
|
|
2234
|
+
* Omitted (the reconnect default) replays the job from the beginning. A
|
|
2235
|
+
* caller that holds the job's events up to `afterSeq` — the transcript
|
|
2236
|
+
* cache — passes it so the backend replays only the delta and then goes
|
|
2237
|
+
* live; the backend's `?after=` handles a job that already ended by
|
|
2238
|
+
* back-filling its terminal event.
|
|
2239
|
+
*/
|
|
2240
|
+
reconnectToJob(jobId: string, afterSeq?: number): Promise<void>;
|
|
2186
2241
|
/** Detach from the SSE stream without cancelling the job. */
|
|
2187
2242
|
detach(): void;
|
|
2188
2243
|
/**
|
|
@@ -2605,6 +2660,35 @@ declare class StreamManager {
|
|
|
2605
2660
|
*/
|
|
2606
2661
|
switchTo(conversationId: string, opts?: {
|
|
2607
2662
|
skipHistoryReplay?: boolean;
|
|
2663
|
+
/**
|
|
2664
|
+
* Jobs whose events the caller ALREADY holds — the transcript-cache
|
|
2665
|
+
* resume. A COMPLETED job named here is neither fetched nor replayed: its
|
|
2666
|
+
* blocks are in the caller's store, and replaying them would draw a
|
|
2667
|
+
* second copy.
|
|
2668
|
+
*
|
|
2669
|
+
* Two limits, both deliberate and both stating what a review proved the
|
|
2670
|
+
* wider contract could not:
|
|
2671
|
+
*
|
|
2672
|
+
* - **The newest turn page only.** The marks are matched against the page
|
|
2673
|
+
* of jobs this restore walks, so a mark for a turn OLDER than that page
|
|
2674
|
+
* is not consulted. The message window (40 rows) and the turn page (10)
|
|
2675
|
+
* are independent spans, so a cache deeper than one page would need its
|
|
2676
|
+
* own cursor design — a prompt whose turn is not on the page cannot be
|
|
2677
|
+
* recognized as held, and would come back as a steer bubble.
|
|
2678
|
+
* - **Completed jobs only.** A mark for the RUNNING job is ignored (its
|
|
2679
|
+
* stream starts from the beginning, as it always did). Resuming a live
|
|
2680
|
+
* turn's stream from a mark would deliver the delta but NOT the prompt
|
|
2681
|
+
* — a prompt is a message-table row, not a stream event — so the plan
|
|
2682
|
+
* would still draw a bubble the caller's cache already renders.
|
|
2683
|
+
*
|
|
2684
|
+
* Absent → today's behaviour exactly. A job NOT named is fetched whole, as
|
|
2685
|
+
* now, so a stale mark loses at most one completed turn's skip, never a
|
|
2686
|
+
* turn.
|
|
2687
|
+
*/
|
|
2688
|
+
have?: ReadonlyArray<{
|
|
2689
|
+
jobId: string;
|
|
2690
|
+
lastSeq: number;
|
|
2691
|
+
}>;
|
|
2608
2692
|
}): Promise<void>;
|
|
2609
2693
|
/**
|
|
2610
2694
|
* Re-attach to whatever the server says is live for the ACTIVE conversation.
|
package/dist/index.d.ts
CHANGED
|
@@ -506,10 +506,23 @@ type ChatEvent = {
|
|
|
506
506
|
type: "subagent_start";
|
|
507
507
|
agent: AgentIdentity;
|
|
508
508
|
taskCallId?: string | null;
|
|
509
|
+
/** The run's id, when the frame carries one (`agent_start`). */
|
|
510
|
+
runId?: string;
|
|
509
511
|
} | {
|
|
510
512
|
type: "subagent_stop";
|
|
511
513
|
agent: AgentIdentity;
|
|
512
514
|
taskCallId?: string | null;
|
|
515
|
+
/** The run's id, when the frame carries one (`agent_stop`). */
|
|
516
|
+
runId?: string;
|
|
517
|
+
/**
|
|
518
|
+
* The child's OWN outcome and spend, surfaced separately from the turn
|
|
519
|
+
* totals (`message_stop.usage` still folds them in). Absent on the older
|
|
520
|
+
* `subagent_stop` frames, which carried only the identity.
|
|
521
|
+
*/
|
|
522
|
+
stopReason?: string;
|
|
523
|
+
inputTokens?: number;
|
|
524
|
+
outputTokens?: number;
|
|
525
|
+
durationMs?: number;
|
|
513
526
|
} | {
|
|
514
527
|
type: "context_warning";
|
|
515
528
|
/** Known values: "info" | "warning" | "critical". Typed as string for forward compat. */
|
|
@@ -644,6 +657,19 @@ interface Conversation {
|
|
|
644
657
|
* null) from an Astralform older than 0.69.46.
|
|
645
658
|
*/
|
|
646
659
|
repository?: string | null;
|
|
660
|
+
/**
|
|
661
|
+
* The live job for this conversation, or `null`/absent when nothing is
|
|
662
|
+
* running. The sidebar's per-row liveness signal: without it a client cannot
|
|
663
|
+
* know that a conversation it has never opened is running, because its only
|
|
664
|
+
* other liveness signals are the per-conversation streams it is attached to.
|
|
665
|
+
*
|
|
666
|
+
* A JOB ID rather than a state string, deliberately: the backend can cheaply
|
|
667
|
+
* say "a job is live here", while "waiting for the user" lives in the
|
|
668
|
+
* worker's in-memory state and reaches only the open conversation's stream.
|
|
669
|
+
* `null` from a server that projects the field; `undefined` from one older
|
|
670
|
+
* than it (same convention as `repository`), so treat both as "not running".
|
|
671
|
+
*/
|
|
672
|
+
activeJobId?: string | null;
|
|
647
673
|
/**
|
|
648
674
|
* The code group this task is filed under, or `null`/absent when it is not in
|
|
649
675
|
* one. MUTABLE — unlike `repository`, which is written once on the first turn:
|
|
@@ -1970,12 +1996,6 @@ type ChatEventHandler = (event: ChatEvent) => void;
|
|
|
1970
1996
|
* would leave the second page's offset pointing at the wrong row.
|
|
1971
1997
|
*/
|
|
1972
1998
|
declare const CONVERSATION_PAGE_SIZE = 50;
|
|
1973
|
-
/**
|
|
1974
|
-
* ChatSession — translates the backend wire protocol into typed ChatEvents
|
|
1975
|
-
* for consumers. Owns HTTP + SSE plumbing, conversation state, and the
|
|
1976
|
-
* client-tool round-trip. Does NOT own block construction — consumers
|
|
1977
|
-
* build their own block state from the typed events.
|
|
1978
|
-
*/
|
|
1979
1999
|
declare class ChatSession {
|
|
1980
2000
|
readonly client: AstralformClient;
|
|
1981
2001
|
readonly toolRegistry: ToolRegistry;
|
|
@@ -2115,6 +2135,33 @@ declare class ChatSession {
|
|
|
2115
2135
|
private processStream;
|
|
2116
2136
|
/** Last received sequence number for resumable reconnection */
|
|
2117
2137
|
private lastSeq;
|
|
2138
|
+
/**
|
|
2139
|
+
* Advance the resume cursor from a frame's `seq` — the position the next
|
|
2140
|
+
* reconnect asks the backend to replay after (`?after=`).
|
|
2141
|
+
*
|
|
2142
|
+
* Two guards, because getting this wrong is silent and expensive: the whole
|
|
2143
|
+
* turn replays over blocks the consumer is already rendering.
|
|
2144
|
+
*
|
|
2145
|
+
* - **A keepalive is not an event.** The backend sends it as an ordinary
|
|
2146
|
+
* `data:` frame with `seq: 0` BY DESIGN (``WireKeepalive(seq=0, ts=0.0)``
|
|
2147
|
+
* — an idle tick, not a position), and it arrives exactly when a turn has
|
|
2148
|
+
* been quiet for 45 s: the long-tool-run case. Harvesting it rewound the
|
|
2149
|
+
* cursor to 0, so a reconnect after a blip replayed the turn from its
|
|
2150
|
+
* start — deltas and all.
|
|
2151
|
+
* - **The cursor only advances.** `send` and `reconnectToJob` reset it to
|
|
2152
|
+
* -1 per job, so a new job's `seq: 0` still moves it forward; a frame
|
|
2153
|
+
* carrying an older (or zero) position cannot move it back.
|
|
2154
|
+
*
|
|
2155
|
+
* The one other frame on the wire with a non-position `seq` is the
|
|
2156
|
+
* subscriber's SYNTHETIC terminal — a back-filled `error` for a job whose
|
|
2157
|
+
* worker died, built without a `seq` so Pydantic's default `0` reaches the
|
|
2158
|
+
* wire. Deliberately not named here: the advance-only rule already ignores it
|
|
2159
|
+
* whenever the client has drained anything (0 never exceeds a real cursor),
|
|
2160
|
+
* and when it is the FIRST frame the pump ends on `sawTerminal` before any
|
|
2161
|
+
* reconnect could use the cursor. Naming it would add a rule that no
|
|
2162
|
+
* reachable input exercises.
|
|
2163
|
+
*/
|
|
2164
|
+
private harvestResumeCursor;
|
|
2118
2165
|
/**
|
|
2119
2166
|
* Client-tool call_ids whose result was already submitted this turn. On a
|
|
2120
2167
|
* reconnect the resumed stream can replay a tool request we already handled;
|
|
@@ -2182,7 +2229,15 @@ declare class ChatSession {
|
|
|
2182
2229
|
* Reconnect to a running job's SSE stream (e.g. after page reload).
|
|
2183
2230
|
* Replays all events from the beginning and continues live.
|
|
2184
2231
|
*/
|
|
2185
|
-
|
|
2232
|
+
/**
|
|
2233
|
+
* @param afterSeq Resume the job's event stream AFTER this sequence number.
|
|
2234
|
+
* Omitted (the reconnect default) replays the job from the beginning. A
|
|
2235
|
+
* caller that holds the job's events up to `afterSeq` — the transcript
|
|
2236
|
+
* cache — passes it so the backend replays only the delta and then goes
|
|
2237
|
+
* live; the backend's `?after=` handles a job that already ended by
|
|
2238
|
+
* back-filling its terminal event.
|
|
2239
|
+
*/
|
|
2240
|
+
reconnectToJob(jobId: string, afterSeq?: number): Promise<void>;
|
|
2186
2241
|
/** Detach from the SSE stream without cancelling the job. */
|
|
2187
2242
|
detach(): void;
|
|
2188
2243
|
/**
|
|
@@ -2605,6 +2660,35 @@ declare class StreamManager {
|
|
|
2605
2660
|
*/
|
|
2606
2661
|
switchTo(conversationId: string, opts?: {
|
|
2607
2662
|
skipHistoryReplay?: boolean;
|
|
2663
|
+
/**
|
|
2664
|
+
* Jobs whose events the caller ALREADY holds — the transcript-cache
|
|
2665
|
+
* resume. A COMPLETED job named here is neither fetched nor replayed: its
|
|
2666
|
+
* blocks are in the caller's store, and replaying them would draw a
|
|
2667
|
+
* second copy.
|
|
2668
|
+
*
|
|
2669
|
+
* Two limits, both deliberate and both stating what a review proved the
|
|
2670
|
+
* wider contract could not:
|
|
2671
|
+
*
|
|
2672
|
+
* - **The newest turn page only.** The marks are matched against the page
|
|
2673
|
+
* of jobs this restore walks, so a mark for a turn OLDER than that page
|
|
2674
|
+
* is not consulted. The message window (40 rows) and the turn page (10)
|
|
2675
|
+
* are independent spans, so a cache deeper than one page would need its
|
|
2676
|
+
* own cursor design — a prompt whose turn is not on the page cannot be
|
|
2677
|
+
* recognized as held, and would come back as a steer bubble.
|
|
2678
|
+
* - **Completed jobs only.** A mark for the RUNNING job is ignored (its
|
|
2679
|
+
* stream starts from the beginning, as it always did). Resuming a live
|
|
2680
|
+
* turn's stream from a mark would deliver the delta but NOT the prompt
|
|
2681
|
+
* — a prompt is a message-table row, not a stream event — so the plan
|
|
2682
|
+
* would still draw a bubble the caller's cache already renders.
|
|
2683
|
+
*
|
|
2684
|
+
* Absent → today's behaviour exactly. A job NOT named is fetched whole, as
|
|
2685
|
+
* now, so a stale mark loses at most one completed turn's skip, never a
|
|
2686
|
+
* turn.
|
|
2687
|
+
*/
|
|
2688
|
+
have?: ReadonlyArray<{
|
|
2689
|
+
jobId: string;
|
|
2690
|
+
lastSeq: number;
|
|
2691
|
+
}>;
|
|
2608
2692
|
}): Promise<void>;
|
|
2609
2693
|
/**
|
|
2610
2694
|
* Re-attach to whatever the server says is live for the ACTIVE conversation.
|
package/dist/index.js
CHANGED
|
@@ -1492,13 +1492,29 @@ function translateDelta(wire) {
|
|
|
1492
1492
|
}
|
|
1493
1493
|
}
|
|
1494
1494
|
function translateAgentIdentity(raw) {
|
|
1495
|
+
const source = raw !== null && typeof raw === "object" ? { ...raw } : {};
|
|
1496
|
+
const read = (key) => source[key];
|
|
1495
1497
|
return {
|
|
1496
|
-
name:
|
|
1497
|
-
displayName:
|
|
1498
|
-
avatarUrl:
|
|
1499
|
-
description:
|
|
1498
|
+
name: read("name") ?? "",
|
|
1499
|
+
displayName: read("display_name") ?? null,
|
|
1500
|
+
avatarUrl: read("avatar_url") ?? null,
|
|
1501
|
+
description: read("description") ?? null
|
|
1500
1502
|
};
|
|
1501
1503
|
}
|
|
1504
|
+
function agentIdentityFromEvent(data) {
|
|
1505
|
+
if ("agent" in data) return translateAgentIdentity(data.agent);
|
|
1506
|
+
return translateAgentIdentity({
|
|
1507
|
+
name: data.agent_name,
|
|
1508
|
+
display_name: data.agent_display_name,
|
|
1509
|
+
avatar_url: data.agent_avatar_url
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
function optionalString(value) {
|
|
1513
|
+
return typeof value === "string" ? value : void 0;
|
|
1514
|
+
}
|
|
1515
|
+
function optionalNumber(value) {
|
|
1516
|
+
return typeof value === "number" ? value : void 0;
|
|
1517
|
+
}
|
|
1502
1518
|
function translateTodoItem(raw) {
|
|
1503
1519
|
return {
|
|
1504
1520
|
id: raw.id ?? 0,
|
|
@@ -1549,21 +1565,33 @@ function translateCustomEvent(name, data) {
|
|
|
1549
1565
|
phase: data.phase ?? null,
|
|
1550
1566
|
updatedAt: data.updated_at ?? null
|
|
1551
1567
|
};
|
|
1568
|
+
// Two names for one event: the backend renamed these to `agent_*` (a nested
|
|
1569
|
+
// run is not necessarily a "subagent" in the product's vocabulary) while
|
|
1570
|
+
// older servers still emit `subagent_*`. Both are translated, so a client
|
|
1571
|
+
// works across the rename.
|
|
1572
|
+
case "agent_start":
|
|
1552
1573
|
case "subagent_start":
|
|
1553
1574
|
return {
|
|
1554
1575
|
type: "subagent_start",
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1576
|
+
runId: optionalString(data.run_id),
|
|
1577
|
+
agent: agentIdentityFromEvent(data),
|
|
1578
|
+
// `parent_call_id` is the spawn block's call_id — the same key a client
|
|
1579
|
+
// uses to find the tool block whose subtree is this child's work.
|
|
1580
|
+
taskCallId: optionalString(data.parent_call_id) ?? optionalString(data.task_call_id) ?? null
|
|
1559
1581
|
};
|
|
1582
|
+
case "agent_stop":
|
|
1560
1583
|
case "subagent_stop":
|
|
1561
1584
|
return {
|
|
1562
1585
|
type: "subagent_stop",
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
),
|
|
1566
|
-
|
|
1586
|
+
runId: optionalString(data.run_id),
|
|
1587
|
+
agent: agentIdentityFromEvent(data),
|
|
1588
|
+
taskCallId: optionalString(data.parent_call_id) ?? optionalString(data.task_call_id) ?? null,
|
|
1589
|
+
// The child's OWN spend and outcome, which the wire surfaces separately
|
|
1590
|
+
// from the turn totals (`message_stop.usage` folds them in too).
|
|
1591
|
+
stopReason: optionalString(data.stop_reason),
|
|
1592
|
+
inputTokens: optionalNumber(data.input_tokens),
|
|
1593
|
+
outputTokens: optionalNumber(data.output_tokens),
|
|
1594
|
+
durationMs: optionalNumber(data.duration_ms)
|
|
1567
1595
|
};
|
|
1568
1596
|
case "context_warning":
|
|
1569
1597
|
return {
|
|
@@ -1813,6 +1841,11 @@ function pathEquals(a, b) {
|
|
|
1813
1841
|
}
|
|
1814
1842
|
return true;
|
|
1815
1843
|
}
|
|
1844
|
+
function frameSeq(frame) {
|
|
1845
|
+
if (typeof frame !== "object" || frame === null) return void 0;
|
|
1846
|
+
if (!("seq" in frame)) return void 0;
|
|
1847
|
+
return frame.seq;
|
|
1848
|
+
}
|
|
1816
1849
|
var ChatSession = class {
|
|
1817
1850
|
constructor(config, storage) {
|
|
1818
1851
|
/**
|
|
@@ -2123,6 +2156,37 @@ var ChatSession = class {
|
|
|
2123
2156
|
}
|
|
2124
2157
|
}
|
|
2125
2158
|
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Advance the resume cursor from a frame's `seq` — the position the next
|
|
2161
|
+
* reconnect asks the backend to replay after (`?after=`).
|
|
2162
|
+
*
|
|
2163
|
+
* Two guards, because getting this wrong is silent and expensive: the whole
|
|
2164
|
+
* turn replays over blocks the consumer is already rendering.
|
|
2165
|
+
*
|
|
2166
|
+
* - **A keepalive is not an event.** The backend sends it as an ordinary
|
|
2167
|
+
* `data:` frame with `seq: 0` BY DESIGN (``WireKeepalive(seq=0, ts=0.0)``
|
|
2168
|
+
* — an idle tick, not a position), and it arrives exactly when a turn has
|
|
2169
|
+
* been quiet for 45 s: the long-tool-run case. Harvesting it rewound the
|
|
2170
|
+
* cursor to 0, so a reconnect after a blip replayed the turn from its
|
|
2171
|
+
* start — deltas and all.
|
|
2172
|
+
* - **The cursor only advances.** `send` and `reconnectToJob` reset it to
|
|
2173
|
+
* -1 per job, so a new job's `seq: 0` still moves it forward; a frame
|
|
2174
|
+
* carrying an older (or zero) position cannot move it back.
|
|
2175
|
+
*
|
|
2176
|
+
* The one other frame on the wire with a non-position `seq` is the
|
|
2177
|
+
* subscriber's SYNTHETIC terminal — a back-filled `error` for a job whose
|
|
2178
|
+
* worker died, built without a `seq` so Pydantic's default `0` reaches the
|
|
2179
|
+
* wire. Deliberately not named here: the advance-only rule already ignores it
|
|
2180
|
+
* whenever the client has drained anything (0 never exceeds a real cursor),
|
|
2181
|
+
* and when it is the FIRST frame the pump ends on `sawTerminal` before any
|
|
2182
|
+
* reconnect could use the cursor. Naming it would add a rule that no
|
|
2183
|
+
* reachable input exercises.
|
|
2184
|
+
*/
|
|
2185
|
+
harvestResumeCursor(seq, type) {
|
|
2186
|
+
if (type === "keepalive") return;
|
|
2187
|
+
if (typeof seq !== "number") return;
|
|
2188
|
+
if (seq > this.lastSeq) this.lastSeq = seq;
|
|
2189
|
+
}
|
|
2126
2190
|
async consumeJobStream(request, wire) {
|
|
2127
2191
|
const job = await this.client.createJob(request);
|
|
2128
2192
|
if (wire) wire.reached = true;
|
|
@@ -2260,18 +2324,14 @@ var ChatSession = class {
|
|
|
2260
2324
|
try {
|
|
2261
2325
|
const data = JSON.parse(raw.data);
|
|
2262
2326
|
if (typeof data !== "object" || data === null || typeof data.type !== "string") {
|
|
2263
|
-
|
|
2264
|
-
this.lastSeq = data.seq;
|
|
2265
|
-
}
|
|
2327
|
+
this.harvestResumeCursor(frameSeq(data), void 0);
|
|
2266
2328
|
if (typeof data === "object" && data !== null && "data" in data && data.data === "[DONE]") {
|
|
2267
2329
|
sawTerminal = true;
|
|
2268
2330
|
}
|
|
2269
2331
|
continue;
|
|
2270
2332
|
}
|
|
2271
2333
|
parsed = data;
|
|
2272
|
-
|
|
2273
|
-
this.lastSeq = data.seq;
|
|
2274
|
-
}
|
|
2334
|
+
this.harvestResumeCursor(frameSeq(data), parsed.type);
|
|
2275
2335
|
} catch {
|
|
2276
2336
|
continue;
|
|
2277
2337
|
}
|
|
@@ -2500,11 +2560,19 @@ var ChatSession = class {
|
|
|
2500
2560
|
* Reconnect to a running job's SSE stream (e.g. after page reload).
|
|
2501
2561
|
* Replays all events from the beginning and continues live.
|
|
2502
2562
|
*/
|
|
2503
|
-
|
|
2563
|
+
/**
|
|
2564
|
+
* @param afterSeq Resume the job's event stream AFTER this sequence number.
|
|
2565
|
+
* Omitted (the reconnect default) replays the job from the beginning. A
|
|
2566
|
+
* caller that holds the job's events up to `afterSeq` — the transcript
|
|
2567
|
+
* cache — passes it so the backend replays only the delta and then goes
|
|
2568
|
+
* live; the backend's `?after=` handles a job that already ended by
|
|
2569
|
+
* back-filling its terminal event.
|
|
2570
|
+
*/
|
|
2571
|
+
async reconnectToJob(jobId, afterSeq) {
|
|
2504
2572
|
if (this.isStreaming) return;
|
|
2505
2573
|
this.isStreaming = true;
|
|
2506
2574
|
this.currentJobId = jobId;
|
|
2507
|
-
this.lastSeq = -1;
|
|
2575
|
+
this.lastSeq = afterSeq ?? -1;
|
|
2508
2576
|
this.submittedToolCallIds.clear();
|
|
2509
2577
|
this.resetStreamingState();
|
|
2510
2578
|
const controller = new AbortController();
|
|
@@ -2795,7 +2863,10 @@ function planRestore(args) {
|
|
|
2795
2863
|
messageId: msgs[i]?.id
|
|
2796
2864
|
}));
|
|
2797
2865
|
if (jobs.length === 0) {
|
|
2798
|
-
|
|
2866
|
+
const held = new Set(
|
|
2867
|
+
(args.heldMessageIds ?? []).filter((id) => !!id)
|
|
2868
|
+
);
|
|
2869
|
+
return userMessages.filter((m) => !(m.id && held.has(m.id))).map((m) => ({
|
|
2799
2870
|
kind: "steer",
|
|
2800
2871
|
content: m.content,
|
|
2801
2872
|
messageId: m.id
|
|
@@ -3074,7 +3145,7 @@ var StreamManager = class _StreamManager {
|
|
|
3074
3145
|
}
|
|
3075
3146
|
let tookOver = false;
|
|
3076
3147
|
try {
|
|
3077
|
-
await this.restore(conversationId, gen);
|
|
3148
|
+
await this.restore(conversationId, gen, opts?.have);
|
|
3078
3149
|
tookOver = gen === this.generation;
|
|
3079
3150
|
} finally {
|
|
3080
3151
|
const supersededOntoSame = gen !== this.generation && this._activeConversationId === conversationId;
|
|
@@ -3292,7 +3363,10 @@ var StreamManager = class _StreamManager {
|
|
|
3292
3363
|
...before ? { before } : {}
|
|
3293
3364
|
});
|
|
3294
3365
|
}
|
|
3295
|
-
async restore(conversationId, gen) {
|
|
3366
|
+
async restore(conversationId, gen, have) {
|
|
3367
|
+
const haveByJobId = new Map(
|
|
3368
|
+
(have ?? []).map((h) => [h.jobId, h.lastSeq])
|
|
3369
|
+
);
|
|
3296
3370
|
const superseded = () => gen !== this.generation;
|
|
3297
3371
|
if (superseded()) return;
|
|
3298
3372
|
const turn = this.turnCounter;
|
|
@@ -3319,7 +3393,8 @@ var StreamManager = class _StreamManager {
|
|
|
3319
3393
|
gen,
|
|
3320
3394
|
activeJobId,
|
|
3321
3395
|
turn,
|
|
3322
|
-
jobsRequest
|
|
3396
|
+
jobsRequest,
|
|
3397
|
+
haveByJobId
|
|
3323
3398
|
))
|
|
3324
3399
|
return;
|
|
3325
3400
|
if (activeJobId) {
|
|
@@ -3516,7 +3591,7 @@ var StreamManager = class _StreamManager {
|
|
|
3516
3591
|
session.isLoadingEarlierTurns = false;
|
|
3517
3592
|
}
|
|
3518
3593
|
}
|
|
3519
|
-
async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest) {
|
|
3594
|
+
async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest, haveByJobId) {
|
|
3520
3595
|
const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
|
|
3521
3596
|
let complete = false;
|
|
3522
3597
|
try {
|
|
@@ -3524,7 +3599,7 @@ var StreamManager = class _StreamManager {
|
|
|
3524
3599
|
const jobs = page.jobs;
|
|
3525
3600
|
if (stopReplay()) return false;
|
|
3526
3601
|
const replayableJobs = jobs.filter(
|
|
3527
|
-
(j) => j.job_id !== activeJobId
|
|
3602
|
+
(j) => j.job_id !== activeJobId && !haveByJobId.has(j.job_id)
|
|
3528
3603
|
);
|
|
3529
3604
|
const userMessages = this.session.messages.filter(
|
|
3530
3605
|
(m) => m.role === "user"
|
|
@@ -3558,6 +3633,12 @@ var StreamManager = class _StreamManager {
|
|
|
3558
3633
|
// them, silently. Deriving both from `jobs` makes that impossible to
|
|
3559
3634
|
// get half-right.
|
|
3560
3635
|
claimedMessageIds: jobs.map((j) => j.message_id),
|
|
3636
|
+
// The prompts whose turn the CALLER holds (see `switchTo`'s `have`).
|
|
3637
|
+
// The walk below is empty for exactly those jobs — which is the
|
|
3638
|
+
// primary cache scenario, a SETTLED conversation whose turns are all
|
|
3639
|
+
// held — and the empty-walk rescue would otherwise re-emit every one of
|
|
3640
|
+
// them as a steer bubble.
|
|
3641
|
+
heldMessageIds: jobs.filter((j) => haveByJobId.has(j.job_id)).map((j) => j.message_id),
|
|
3561
3642
|
userMessages: userMessages.map((m) => ({
|
|
3562
3643
|
id: m.id,
|
|
3563
3644
|
content: m.content
|