@astralform/js 9.4.0 → 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
@@ -258,7 +258,7 @@ declare const ChatEventType: {
258
258
  type ChatEventTypeValue = (typeof ChatEventType)[keyof typeof ChatEventType];
259
259
  type WireBlockKind = "text" | "thinking" | "tool_use";
260
260
  type WireBlockStatus = "streaming" | "awaiting_client_result" | "ok" | "error" | "denied" | "cancelled";
261
- type WireStopReason = "end_turn" | "tool_use" | "max_tokens" | "context_overflow" | "error";
261
+ type WireStopReason = "end_turn" | "tool_use" | "max_tokens" | "context_overflow" | "incomplete" | "error";
262
262
  interface WireTextDelta {
263
263
  channel: "text";
264
264
  text: string;
@@ -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:
@@ -1120,7 +1146,11 @@ interface VoiceConfig {
1120
1146
  * (or compare against `"raw"`) before polishing.
1121
1147
  */
1122
1148
  defaultMode: VoicePolishMode;
1123
- /** In tap-to-talk mode, the pause that ends a recording. */
1149
+ /**
1150
+ * @deprecated Always `2`. The server no longer sends the field and no
1151
+ * shipped client implements pause detection; kept so existing code compiles,
1152
+ * slated for removal in the next major.
1153
+ */
1124
1154
  silenceAutoStopSeconds: number;
1125
1155
  /**
1126
1156
  * @deprecated Always `false`. Auto-send was removed from the platform
@@ -1966,12 +1996,6 @@ type ChatEventHandler = (event: ChatEvent) => void;
1966
1996
  * would leave the second page's offset pointing at the wrong row.
1967
1997
  */
1968
1998
  declare const CONVERSATION_PAGE_SIZE = 50;
1969
- /**
1970
- * ChatSession — translates the backend wire protocol into typed ChatEvents
1971
- * for consumers. Owns HTTP + SSE plumbing, conversation state, and the
1972
- * client-tool round-trip. Does NOT own block construction — consumers
1973
- * build their own block state from the typed events.
1974
- */
1975
1999
  declare class ChatSession {
1976
2000
  readonly client: AstralformClient;
1977
2001
  readonly toolRegistry: ToolRegistry;
@@ -2111,6 +2135,33 @@ declare class ChatSession {
2111
2135
  private processStream;
2112
2136
  /** Last received sequence number for resumable reconnection */
2113
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;
2114
2165
  /**
2115
2166
  * Client-tool call_ids whose result was already submitted this turn. On a
2116
2167
  * reconnect the resumed stream can replay a tool request we already handled;
@@ -2178,7 +2229,15 @@ declare class ChatSession {
2178
2229
  * Reconnect to a running job's SSE stream (e.g. after page reload).
2179
2230
  * Replays all events from the beginning and continues live.
2180
2231
  */
2181
- 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>;
2182
2241
  /** Detach from the SSE stream without cancelling the job. */
2183
2242
  detach(): void;
2184
2243
  /**
@@ -2601,6 +2660,35 @@ declare class StreamManager {
2601
2660
  */
2602
2661
  switchTo(conversationId: string, opts?: {
2603
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
+ }>;
2604
2692
  }): Promise<void>;
2605
2693
  /**
2606
2694
  * Re-attach to whatever the server says is live for the ACTIVE conversation.
package/dist/index.d.ts CHANGED
@@ -258,7 +258,7 @@ declare const ChatEventType: {
258
258
  type ChatEventTypeValue = (typeof ChatEventType)[keyof typeof ChatEventType];
259
259
  type WireBlockKind = "text" | "thinking" | "tool_use";
260
260
  type WireBlockStatus = "streaming" | "awaiting_client_result" | "ok" | "error" | "denied" | "cancelled";
261
- type WireStopReason = "end_turn" | "tool_use" | "max_tokens" | "context_overflow" | "error";
261
+ type WireStopReason = "end_turn" | "tool_use" | "max_tokens" | "context_overflow" | "incomplete" | "error";
262
262
  interface WireTextDelta {
263
263
  channel: "text";
264
264
  text: string;
@@ -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:
@@ -1120,7 +1146,11 @@ interface VoiceConfig {
1120
1146
  * (or compare against `"raw"`) before polishing.
1121
1147
  */
1122
1148
  defaultMode: VoicePolishMode;
1123
- /** In tap-to-talk mode, the pause that ends a recording. */
1149
+ /**
1150
+ * @deprecated Always `2`. The server no longer sends the field and no
1151
+ * shipped client implements pause detection; kept so existing code compiles,
1152
+ * slated for removal in the next major.
1153
+ */
1124
1154
  silenceAutoStopSeconds: number;
1125
1155
  /**
1126
1156
  * @deprecated Always `false`. Auto-send was removed from the platform
@@ -1966,12 +1996,6 @@ type ChatEventHandler = (event: ChatEvent) => void;
1966
1996
  * would leave the second page's offset pointing at the wrong row.
1967
1997
  */
1968
1998
  declare const CONVERSATION_PAGE_SIZE = 50;
1969
- /**
1970
- * ChatSession — translates the backend wire protocol into typed ChatEvents
1971
- * for consumers. Owns HTTP + SSE plumbing, conversation state, and the
1972
- * client-tool round-trip. Does NOT own block construction — consumers
1973
- * build their own block state from the typed events.
1974
- */
1975
1999
  declare class ChatSession {
1976
2000
  readonly client: AstralformClient;
1977
2001
  readonly toolRegistry: ToolRegistry;
@@ -2111,6 +2135,33 @@ declare class ChatSession {
2111
2135
  private processStream;
2112
2136
  /** Last received sequence number for resumable reconnection */
2113
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;
2114
2165
  /**
2115
2166
  * Client-tool call_ids whose result was already submitted this turn. On a
2116
2167
  * reconnect the resumed stream can replay a tool request we already handled;
@@ -2178,7 +2229,15 @@ declare class ChatSession {
2178
2229
  * Reconnect to a running job's SSE stream (e.g. after page reload).
2179
2230
  * Replays all events from the beginning and continues live.
2180
2231
  */
2181
- 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>;
2182
2241
  /** Detach from the SSE stream without cancelling the job. */
2183
2242
  detach(): void;
2184
2243
  /**
@@ -2601,6 +2660,35 @@ declare class StreamManager {
2601
2660
  */
2602
2661
  switchTo(conversationId: string, opts?: {
2603
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
+ }>;
2604
2692
  }): Promise<void>;
2605
2693
  /**
2606
2694
  * Re-attach to whatever the server says is live for the ACTIVE conversation.
package/dist/index.js CHANGED
@@ -1081,7 +1081,11 @@ var AstralformClient = class {
1081
1081
  // A mode this SDK does not know must not reach a `switch` typed as
1082
1082
  // `VoicePolishMode`; `structured` is the server's own default.
1083
1083
  defaultMode: isVoicePolishMode(raw.default_mode) ? raw.default_mode : "structured",
1084
- silenceAutoStopSeconds: raw.silence_auto_stop_seconds ?? 2,
1084
+ // Deliberately not read from the wire. The field was retired from the
1085
+ // platform (2026-10-09): no shipped client implements pause detection
1086
+ // and the server no longer sends it, so an older server's stored value
1087
+ // must not resurrect it.
1088
+ silenceAutoStopSeconds: 2,
1085
1089
  // Deliberately not read from the wire. Auto-send was removed from the
1086
1090
  // platform (2026-09-14) and the server pins `auto_send` to false; an
1087
1091
  // older server still sending `true` must not make a client auto-send, so
@@ -1488,13 +1492,29 @@ function translateDelta(wire) {
1488
1492
  }
1489
1493
  }
1490
1494
  function translateAgentIdentity(raw) {
1495
+ const source = raw !== null && typeof raw === "object" ? { ...raw } : {};
1496
+ const read = (key) => source[key];
1491
1497
  return {
1492
- name: raw.name ?? "",
1493
- displayName: raw.display_name ?? null,
1494
- avatarUrl: raw.avatar_url ?? null,
1495
- 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
1496
1502
  };
1497
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
+ }
1498
1518
  function translateTodoItem(raw) {
1499
1519
  return {
1500
1520
  id: raw.id ?? 0,
@@ -1545,21 +1565,33 @@ function translateCustomEvent(name, data) {
1545
1565
  phase: data.phase ?? null,
1546
1566
  updatedAt: data.updated_at ?? null
1547
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":
1548
1573
  case "subagent_start":
1549
1574
  return {
1550
1575
  type: "subagent_start",
1551
- agent: translateAgentIdentity(
1552
- data.agent ?? {}
1553
- ),
1554
- 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
1555
1581
  };
1582
+ case "agent_stop":
1556
1583
  case "subagent_stop":
1557
1584
  return {
1558
1585
  type: "subagent_stop",
1559
- agent: translateAgentIdentity(
1560
- data.agent ?? {}
1561
- ),
1562
- 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)
1563
1595
  };
1564
1596
  case "context_warning":
1565
1597
  return {
@@ -1738,22 +1770,24 @@ function translateWireEvent(wire) {
1738
1770
  status: wire.status,
1739
1771
  final: wire.final
1740
1772
  };
1741
- case "message_stop":
1773
+ case "message_stop": {
1774
+ const usage = wire.usage ?? {};
1742
1775
  return {
1743
1776
  type: "message_stop",
1744
1777
  turnId: wire.turn_id,
1745
1778
  jobId: wire.job_id,
1746
1779
  stopReason: wire.stop_reason,
1747
1780
  usage: {
1748
- inputTokens: wire.usage.input_tokens ?? 0,
1749
- outputTokens: wire.usage.output_tokens ?? 0,
1750
- cachedTokens: wire.usage.cached_tokens ?? 0,
1751
- cacheCreationTokens: wire.usage.cache_creation_tokens ?? 0
1781
+ inputTokens: usage.input_tokens ?? 0,
1782
+ outputTokens: usage.output_tokens ?? 0,
1783
+ cachedTokens: usage.cached_tokens ?? 0,
1784
+ cacheCreationTokens: usage.cache_creation_tokens ?? 0
1752
1785
  },
1753
1786
  ttfbMs: wire.ttfb_ms,
1754
1787
  totalMs: wire.total_ms,
1755
1788
  stallCount: wire.stall_count
1756
1789
  };
1790
+ }
1757
1791
  case "stall":
1758
1792
  return {
1759
1793
  type: "stall",
@@ -1807,6 +1841,11 @@ function pathEquals(a, b) {
1807
1841
  }
1808
1842
  return true;
1809
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
+ }
1810
1849
  var ChatSession = class {
1811
1850
  constructor(config, storage) {
1812
1851
  /**
@@ -2105,7 +2144,7 @@ var ChatSession = class {
2105
2144
  if (!(err instanceof DOMException && err.name === "AbortError")) {
2106
2145
  this.emit({
2107
2146
  type: "error",
2108
- code: "connection_error",
2147
+ code: err instanceof AstralformError ? err.code : "connection_error",
2109
2148
  message: err instanceof Error ? err.message : String(err),
2110
2149
  blockPath: null
2111
2150
  });
@@ -2117,6 +2156,37 @@ var ChatSession = class {
2117
2156
  }
2118
2157
  }
2119
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
+ }
2120
2190
  async consumeJobStream(request, wire) {
2121
2191
  const job = await this.client.createJob(request);
2122
2192
  if (wire) wire.reached = true;
@@ -2254,15 +2324,14 @@ var ChatSession = class {
2254
2324
  try {
2255
2325
  const data = JSON.parse(raw.data);
2256
2326
  if (typeof data !== "object" || data === null || typeof data.type !== "string") {
2257
- if (typeof data?.seq === "number") {
2258
- this.lastSeq = data.seq;
2327
+ this.harvestResumeCursor(frameSeq(data), void 0);
2328
+ if (typeof data === "object" && data !== null && "data" in data && data.data === "[DONE]") {
2329
+ sawTerminal = true;
2259
2330
  }
2260
2331
  continue;
2261
2332
  }
2262
2333
  parsed = data;
2263
- if (typeof data.seq === "number") {
2264
- this.lastSeq = data.seq;
2265
- }
2334
+ this.harvestResumeCursor(frameSeq(data), parsed.type);
2266
2335
  } catch {
2267
2336
  continue;
2268
2337
  }
@@ -2386,7 +2455,7 @@ var ChatSession = class {
2386
2455
  }
2387
2456
  return;
2388
2457
  case "block_delta":
2389
- if (ownsTurnState && wire.delta.channel === "text" && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
2458
+ if (ownsTurnState && wire.delta?.channel === "text" && this.currentTextPath !== null && pathEquals(this.currentTextPath, wire.path)) {
2390
2459
  this.accumulatedText += wire.delta.text;
2391
2460
  }
2392
2461
  return;
@@ -2491,11 +2560,19 @@ var ChatSession = class {
2491
2560
  * Reconnect to a running job's SSE stream (e.g. after page reload).
2492
2561
  * Replays all events from the beginning and continues live.
2493
2562
  */
2494
- 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) {
2495
2572
  if (this.isStreaming) return;
2496
2573
  this.isStreaming = true;
2497
2574
  this.currentJobId = jobId;
2498
- this.lastSeq = -1;
2575
+ this.lastSeq = afterSeq ?? -1;
2499
2576
  this.submittedToolCallIds.clear();
2500
2577
  this.resetStreamingState();
2501
2578
  const controller = new AbortController();
@@ -2786,7 +2863,10 @@ function planRestore(args) {
2786
2863
  messageId: msgs[i]?.id
2787
2864
  }));
2788
2865
  if (jobs.length === 0) {
2789
- 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) => ({
2790
2870
  kind: "steer",
2791
2871
  content: m.content,
2792
2872
  messageId: m.id
@@ -3065,7 +3145,7 @@ var StreamManager = class _StreamManager {
3065
3145
  }
3066
3146
  let tookOver = false;
3067
3147
  try {
3068
- await this.restore(conversationId, gen);
3148
+ await this.restore(conversationId, gen, opts?.have);
3069
3149
  tookOver = gen === this.generation;
3070
3150
  } finally {
3071
3151
  const supersededOntoSame = gen !== this.generation && this._activeConversationId === conversationId;
@@ -3283,7 +3363,10 @@ var StreamManager = class _StreamManager {
3283
3363
  ...before ? { before } : {}
3284
3364
  });
3285
3365
  }
3286
- async restore(conversationId, gen) {
3366
+ async restore(conversationId, gen, have) {
3367
+ const haveByJobId = new Map(
3368
+ (have ?? []).map((h) => [h.jobId, h.lastSeq])
3369
+ );
3287
3370
  const superseded = () => gen !== this.generation;
3288
3371
  if (superseded()) return;
3289
3372
  const turn = this.turnCounter;
@@ -3310,7 +3393,8 @@ var StreamManager = class _StreamManager {
3310
3393
  gen,
3311
3394
  activeJobId,
3312
3395
  turn,
3313
- jobsRequest
3396
+ jobsRequest,
3397
+ haveByJobId
3314
3398
  ))
3315
3399
  return;
3316
3400
  if (activeJobId) {
@@ -3507,7 +3591,7 @@ var StreamManager = class _StreamManager {
3507
3591
  session.isLoadingEarlierTurns = false;
3508
3592
  }
3509
3593
  }
3510
- async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest) {
3594
+ async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest, haveByJobId) {
3511
3595
  const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3512
3596
  let complete = false;
3513
3597
  try {
@@ -3515,7 +3599,7 @@ var StreamManager = class _StreamManager {
3515
3599
  const jobs = page.jobs;
3516
3600
  if (stopReplay()) return false;
3517
3601
  const replayableJobs = jobs.filter(
3518
- (j) => j.job_id !== activeJobId
3602
+ (j) => j.job_id !== activeJobId && !haveByJobId.has(j.job_id)
3519
3603
  );
3520
3604
  const userMessages = this.session.messages.filter(
3521
3605
  (m) => m.role === "user"
@@ -3549,6 +3633,12 @@ var StreamManager = class _StreamManager {
3549
3633
  // them, silently. Deriving both from `jobs` makes that impossible to
3550
3634
  // get half-right.
3551
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),
3552
3642
  userMessages: userMessages.map((m) => ({
3553
3643
  id: m.id,
3554
3644
  content: m.content