@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.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
- reconnectToJob(jobId: string): Promise<void>;
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
- reconnectToJob(jobId: string): Promise<void>;
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: raw.name ?? "",
1497
- displayName: raw.display_name ?? null,
1498
- avatarUrl: raw.avatar_url ?? null,
1499
- description: raw.description ?? null
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
- agent: translateAgentIdentity(
1556
- data.agent ?? {}
1557
- ),
1558
- taskCallId: data.task_call_id ?? null
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
- agent: translateAgentIdentity(
1564
- data.agent ?? {}
1565
- ),
1566
- taskCallId: data.task_call_id ?? null
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
- if (typeof data?.seq === "number") {
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
- if (typeof data.seq === "number") {
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
- async reconnectToJob(jobId) {
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
- return userMessages.map((m) => ({
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