@astralform/js 9.4.1 → 9.6.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 +149 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +116 -7
- package/dist/index.d.ts +116 -7
- package/dist/index.js +149 -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
|
/**
|
|
@@ -2279,6 +2334,31 @@ declare class ChatSession {
|
|
|
2279
2334
|
* pages, and a doubled prompt is more visible than a missing one.
|
|
2280
2335
|
*/
|
|
2281
2336
|
prependMessages(older: Message[]): void;
|
|
2337
|
+
/**
|
|
2338
|
+
* Re-fetch the NEWEST page of the conversation list and merge it in.
|
|
2339
|
+
*
|
|
2340
|
+
* The list the sidebar renders is seeded once at `connect()` and then only
|
|
2341
|
+
* changed by this client's own actions (create, delete, rename) — so a row's
|
|
2342
|
+
* `activeJobId` is as fresh as that seed, and a job started in ANOTHER tab or
|
|
2343
|
+
* on another device never appears, while one that ended elsewhere stays lit
|
|
2344
|
+
* forever. A caller that refreshes on a trigger (the tab becoming visible, or
|
|
2345
|
+
* a bounded interval while any job is live) keeps the rows honest without a
|
|
2346
|
+
* socket per conversation.
|
|
2347
|
+
*
|
|
2348
|
+
* Merge rules, and why each is what it is:
|
|
2349
|
+
*
|
|
2350
|
+
* - the server's ORDER wins for the rows it returns: an updated conversation
|
|
2351
|
+
* moves to its new place, which is the whole point of the call;
|
|
2352
|
+
* - rows paged in EARLIER (`loadMoreConversations`) are kept — this reads page
|
|
2353
|
+
* 1 only, and dropping the tail would make a refresh silently truncate a
|
|
2354
|
+
* list the reader has already scrolled;
|
|
2355
|
+
* - a row the caller MINTED locally and the server does not know yet is kept
|
|
2356
|
+
* too (it has no server row to be refreshed by);
|
|
2357
|
+
* - `conversationsGeneration` is bumped, for the same reason `connect()`
|
|
2358
|
+
* bumps it: a page request already in flight was cut against the old head
|
|
2359
|
+
* and its offset no longer means what it did.
|
|
2360
|
+
*/
|
|
2361
|
+
refreshConversations(): Promise<Conversation[]>;
|
|
2282
2362
|
loadMoreConversations(): Promise<Conversation[]>;
|
|
2283
2363
|
/**
|
|
2284
2364
|
* Rename a conversation, server first.
|
|
@@ -2605,6 +2685,35 @@ declare class StreamManager {
|
|
|
2605
2685
|
*/
|
|
2606
2686
|
switchTo(conversationId: string, opts?: {
|
|
2607
2687
|
skipHistoryReplay?: boolean;
|
|
2688
|
+
/**
|
|
2689
|
+
* Jobs whose events the caller ALREADY holds — the transcript-cache
|
|
2690
|
+
* resume. A COMPLETED job named here is neither fetched nor replayed: its
|
|
2691
|
+
* blocks are in the caller's store, and replaying them would draw a
|
|
2692
|
+
* second copy.
|
|
2693
|
+
*
|
|
2694
|
+
* Two limits, both deliberate and both stating what a review proved the
|
|
2695
|
+
* wider contract could not:
|
|
2696
|
+
*
|
|
2697
|
+
* - **The newest turn page only.** The marks are matched against the page
|
|
2698
|
+
* of jobs this restore walks, so a mark for a turn OLDER than that page
|
|
2699
|
+
* is not consulted. The message window (40 rows) and the turn page (10)
|
|
2700
|
+
* are independent spans, so a cache deeper than one page would need its
|
|
2701
|
+
* own cursor design — a prompt whose turn is not on the page cannot be
|
|
2702
|
+
* recognized as held, and would come back as a steer bubble.
|
|
2703
|
+
* - **Completed jobs only.** A mark for the RUNNING job is ignored (its
|
|
2704
|
+
* stream starts from the beginning, as it always did). Resuming a live
|
|
2705
|
+
* turn's stream from a mark would deliver the delta but NOT the prompt
|
|
2706
|
+
* — a prompt is a message-table row, not a stream event — so the plan
|
|
2707
|
+
* would still draw a bubble the caller's cache already renders.
|
|
2708
|
+
*
|
|
2709
|
+
* Absent → today's behaviour exactly. A job NOT named is fetched whole, as
|
|
2710
|
+
* now, so a stale mark loses at most one completed turn's skip, never a
|
|
2711
|
+
* turn.
|
|
2712
|
+
*/
|
|
2713
|
+
have?: ReadonlyArray<{
|
|
2714
|
+
jobId: string;
|
|
2715
|
+
lastSeq: number;
|
|
2716
|
+
}>;
|
|
2608
2717
|
}): Promise<void>;
|
|
2609
2718
|
/**
|
|
2610
2719
|
* 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
|
/**
|
|
@@ -2279,6 +2334,31 @@ declare class ChatSession {
|
|
|
2279
2334
|
* pages, and a doubled prompt is more visible than a missing one.
|
|
2280
2335
|
*/
|
|
2281
2336
|
prependMessages(older: Message[]): void;
|
|
2337
|
+
/**
|
|
2338
|
+
* Re-fetch the NEWEST page of the conversation list and merge it in.
|
|
2339
|
+
*
|
|
2340
|
+
* The list the sidebar renders is seeded once at `connect()` and then only
|
|
2341
|
+
* changed by this client's own actions (create, delete, rename) — so a row's
|
|
2342
|
+
* `activeJobId` is as fresh as that seed, and a job started in ANOTHER tab or
|
|
2343
|
+
* on another device never appears, while one that ended elsewhere stays lit
|
|
2344
|
+
* forever. A caller that refreshes on a trigger (the tab becoming visible, or
|
|
2345
|
+
* a bounded interval while any job is live) keeps the rows honest without a
|
|
2346
|
+
* socket per conversation.
|
|
2347
|
+
*
|
|
2348
|
+
* Merge rules, and why each is what it is:
|
|
2349
|
+
*
|
|
2350
|
+
* - the server's ORDER wins for the rows it returns: an updated conversation
|
|
2351
|
+
* moves to its new place, which is the whole point of the call;
|
|
2352
|
+
* - rows paged in EARLIER (`loadMoreConversations`) are kept — this reads page
|
|
2353
|
+
* 1 only, and dropping the tail would make a refresh silently truncate a
|
|
2354
|
+
* list the reader has already scrolled;
|
|
2355
|
+
* - a row the caller MINTED locally and the server does not know yet is kept
|
|
2356
|
+
* too (it has no server row to be refreshed by);
|
|
2357
|
+
* - `conversationsGeneration` is bumped, for the same reason `connect()`
|
|
2358
|
+
* bumps it: a page request already in flight was cut against the old head
|
|
2359
|
+
* and its offset no longer means what it did.
|
|
2360
|
+
*/
|
|
2361
|
+
refreshConversations(): Promise<Conversation[]>;
|
|
2282
2362
|
loadMoreConversations(): Promise<Conversation[]>;
|
|
2283
2363
|
/**
|
|
2284
2364
|
* Rename a conversation, server first.
|
|
@@ -2605,6 +2685,35 @@ declare class StreamManager {
|
|
|
2605
2685
|
*/
|
|
2606
2686
|
switchTo(conversationId: string, opts?: {
|
|
2607
2687
|
skipHistoryReplay?: boolean;
|
|
2688
|
+
/**
|
|
2689
|
+
* Jobs whose events the caller ALREADY holds — the transcript-cache
|
|
2690
|
+
* resume. A COMPLETED job named here is neither fetched nor replayed: its
|
|
2691
|
+
* blocks are in the caller's store, and replaying them would draw a
|
|
2692
|
+
* second copy.
|
|
2693
|
+
*
|
|
2694
|
+
* Two limits, both deliberate and both stating what a review proved the
|
|
2695
|
+
* wider contract could not:
|
|
2696
|
+
*
|
|
2697
|
+
* - **The newest turn page only.** The marks are matched against the page
|
|
2698
|
+
* of jobs this restore walks, so a mark for a turn OLDER than that page
|
|
2699
|
+
* is not consulted. The message window (40 rows) and the turn page (10)
|
|
2700
|
+
* are independent spans, so a cache deeper than one page would need its
|
|
2701
|
+
* own cursor design — a prompt whose turn is not on the page cannot be
|
|
2702
|
+
* recognized as held, and would come back as a steer bubble.
|
|
2703
|
+
* - **Completed jobs only.** A mark for the RUNNING job is ignored (its
|
|
2704
|
+
* stream starts from the beginning, as it always did). Resuming a live
|
|
2705
|
+
* turn's stream from a mark would deliver the delta but NOT the prompt
|
|
2706
|
+
* — a prompt is a message-table row, not a stream event — so the plan
|
|
2707
|
+
* would still draw a bubble the caller's cache already renders.
|
|
2708
|
+
*
|
|
2709
|
+
* Absent → today's behaviour exactly. A job NOT named is fetched whole, as
|
|
2710
|
+
* now, so a stale mark loses at most one completed turn's skip, never a
|
|
2711
|
+
* turn.
|
|
2712
|
+
*/
|
|
2713
|
+
have?: ReadonlyArray<{
|
|
2714
|
+
jobId: string;
|
|
2715
|
+
lastSeq: number;
|
|
2716
|
+
}>;
|
|
2608
2717
|
}): Promise<void>;
|
|
2609
2718
|
/**
|
|
2610
2719
|
* 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();
|
|
@@ -2704,6 +2772,48 @@ var ChatSession = class {
|
|
|
2704
2772
|
if (!fresh.length) return;
|
|
2705
2773
|
this.setMessages([...fresh, ...this.messages]);
|
|
2706
2774
|
}
|
|
2775
|
+
/**
|
|
2776
|
+
* Re-fetch the NEWEST page of the conversation list and merge it in.
|
|
2777
|
+
*
|
|
2778
|
+
* The list the sidebar renders is seeded once at `connect()` and then only
|
|
2779
|
+
* changed by this client's own actions (create, delete, rename) — so a row's
|
|
2780
|
+
* `activeJobId` is as fresh as that seed, and a job started in ANOTHER tab or
|
|
2781
|
+
* on another device never appears, while one that ended elsewhere stays lit
|
|
2782
|
+
* forever. A caller that refreshes on a trigger (the tab becoming visible, or
|
|
2783
|
+
* a bounded interval while any job is live) keeps the rows honest without a
|
|
2784
|
+
* socket per conversation.
|
|
2785
|
+
*
|
|
2786
|
+
* Merge rules, and why each is what it is:
|
|
2787
|
+
*
|
|
2788
|
+
* - the server's ORDER wins for the rows it returns: an updated conversation
|
|
2789
|
+
* moves to its new place, which is the whole point of the call;
|
|
2790
|
+
* - rows paged in EARLIER (`loadMoreConversations`) are kept — this reads page
|
|
2791
|
+
* 1 only, and dropping the tail would make a refresh silently truncate a
|
|
2792
|
+
* list the reader has already scrolled;
|
|
2793
|
+
* - a row the caller MINTED locally and the server does not know yet is kept
|
|
2794
|
+
* too (it has no server row to be refreshed by);
|
|
2795
|
+
* - `conversationsGeneration` is bumped, for the same reason `connect()`
|
|
2796
|
+
* bumps it: a page request already in flight was cut against the old head
|
|
2797
|
+
* and its offset no longer means what it did.
|
|
2798
|
+
*/
|
|
2799
|
+
async refreshConversations() {
|
|
2800
|
+
const page = await this.client.getConversations(CONVERSATION_PAGE_SIZE, 0);
|
|
2801
|
+
const pageIds = new Set(page.map((c) => c.id));
|
|
2802
|
+
const known = new Set(this.conversations.map((c) => c.id));
|
|
2803
|
+
this.conversationsGeneration++;
|
|
2804
|
+
this.conversations = [
|
|
2805
|
+
...page,
|
|
2806
|
+
// Everything the page did not mention: older pages, and rows that exist
|
|
2807
|
+
// only on this client (a minted conversation the server has not been told
|
|
2808
|
+
// about yet).
|
|
2809
|
+
...this.conversations.filter((c) => !pageIds.has(c.id))
|
|
2810
|
+
];
|
|
2811
|
+
for (const c of page) {
|
|
2812
|
+
this.serverConversationIds.add(c.id);
|
|
2813
|
+
known.delete(c.id);
|
|
2814
|
+
}
|
|
2815
|
+
return page;
|
|
2816
|
+
}
|
|
2707
2817
|
async loadMoreConversations() {
|
|
2708
2818
|
if (this.isLoadingConversations || !this.hasMoreConversations) return [];
|
|
2709
2819
|
this.isLoadingConversations = true;
|
|
@@ -2795,7 +2905,10 @@ function planRestore(args) {
|
|
|
2795
2905
|
messageId: msgs[i]?.id
|
|
2796
2906
|
}));
|
|
2797
2907
|
if (jobs.length === 0) {
|
|
2798
|
-
|
|
2908
|
+
const held = new Set(
|
|
2909
|
+
(args.heldMessageIds ?? []).filter((id) => !!id)
|
|
2910
|
+
);
|
|
2911
|
+
return userMessages.filter((m) => !(m.id && held.has(m.id))).map((m) => ({
|
|
2799
2912
|
kind: "steer",
|
|
2800
2913
|
content: m.content,
|
|
2801
2914
|
messageId: m.id
|
|
@@ -3074,7 +3187,7 @@ var StreamManager = class _StreamManager {
|
|
|
3074
3187
|
}
|
|
3075
3188
|
let tookOver = false;
|
|
3076
3189
|
try {
|
|
3077
|
-
await this.restore(conversationId, gen);
|
|
3190
|
+
await this.restore(conversationId, gen, opts?.have);
|
|
3078
3191
|
tookOver = gen === this.generation;
|
|
3079
3192
|
} finally {
|
|
3080
3193
|
const supersededOntoSame = gen !== this.generation && this._activeConversationId === conversationId;
|
|
@@ -3292,7 +3405,10 @@ var StreamManager = class _StreamManager {
|
|
|
3292
3405
|
...before ? { before } : {}
|
|
3293
3406
|
});
|
|
3294
3407
|
}
|
|
3295
|
-
async restore(conversationId, gen) {
|
|
3408
|
+
async restore(conversationId, gen, have) {
|
|
3409
|
+
const haveByJobId = new Map(
|
|
3410
|
+
(have ?? []).map((h) => [h.jobId, h.lastSeq])
|
|
3411
|
+
);
|
|
3296
3412
|
const superseded = () => gen !== this.generation;
|
|
3297
3413
|
if (superseded()) return;
|
|
3298
3414
|
const turn = this.turnCounter;
|
|
@@ -3319,7 +3435,8 @@ var StreamManager = class _StreamManager {
|
|
|
3319
3435
|
gen,
|
|
3320
3436
|
activeJobId,
|
|
3321
3437
|
turn,
|
|
3322
|
-
jobsRequest
|
|
3438
|
+
jobsRequest,
|
|
3439
|
+
haveByJobId
|
|
3323
3440
|
))
|
|
3324
3441
|
return;
|
|
3325
3442
|
if (activeJobId) {
|
|
@@ -3516,7 +3633,7 @@ var StreamManager = class _StreamManager {
|
|
|
3516
3633
|
session.isLoadingEarlierTurns = false;
|
|
3517
3634
|
}
|
|
3518
3635
|
}
|
|
3519
|
-
async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest) {
|
|
3636
|
+
async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest, haveByJobId) {
|
|
3520
3637
|
const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
|
|
3521
3638
|
let complete = false;
|
|
3522
3639
|
try {
|
|
@@ -3524,7 +3641,7 @@ var StreamManager = class _StreamManager {
|
|
|
3524
3641
|
const jobs = page.jobs;
|
|
3525
3642
|
if (stopReplay()) return false;
|
|
3526
3643
|
const replayableJobs = jobs.filter(
|
|
3527
|
-
(j) => j.job_id !== activeJobId
|
|
3644
|
+
(j) => j.job_id !== activeJobId && !haveByJobId.has(j.job_id)
|
|
3528
3645
|
);
|
|
3529
3646
|
const userMessages = this.session.messages.filter(
|
|
3530
3647
|
(m) => m.role === "user"
|
|
@@ -3558,6 +3675,12 @@ var StreamManager = class _StreamManager {
|
|
|
3558
3675
|
// them, silently. Deriving both from `jobs` makes that impossible to
|
|
3559
3676
|
// get half-right.
|
|
3560
3677
|
claimedMessageIds: jobs.map((j) => j.message_id),
|
|
3678
|
+
// The prompts whose turn the CALLER holds (see `switchTo`'s `have`).
|
|
3679
|
+
// The walk below is empty for exactly those jobs — which is the
|
|
3680
|
+
// primary cache scenario, a SETTLED conversation whose turns are all
|
|
3681
|
+
// held — and the empty-walk rescue would otherwise re-emit every one of
|
|
3682
|
+
// them as a steer bubble.
|
|
3683
|
+
heldMessageIds: jobs.filter((j) => haveByJobId.has(j.job_id)).map((j) => j.message_id),
|
|
3561
3684
|
userMessages: userMessages.map((m) => ({
|
|
3562
3685
|
id: m.id,
|
|
3563
3686
|
content: m.content
|