@astralform/js 9.4.1 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1545,13 +1545,29 @@ function translateDelta(wire) {
1545
1545
  }
1546
1546
  }
1547
1547
  function translateAgentIdentity(raw) {
1548
+ const source = raw !== null && typeof raw === "object" ? { ...raw } : {};
1549
+ const read = (key) => source[key];
1548
1550
  return {
1549
- name: raw.name ?? "",
1550
- displayName: raw.display_name ?? null,
1551
- avatarUrl: raw.avatar_url ?? null,
1552
- description: raw.description ?? null
1551
+ name: read("name") ?? "",
1552
+ displayName: read("display_name") ?? null,
1553
+ avatarUrl: read("avatar_url") ?? null,
1554
+ description: read("description") ?? null
1553
1555
  };
1554
1556
  }
1557
+ function agentIdentityFromEvent(data) {
1558
+ if ("agent" in data) return translateAgentIdentity(data.agent);
1559
+ return translateAgentIdentity({
1560
+ name: data.agent_name,
1561
+ display_name: data.agent_display_name,
1562
+ avatar_url: data.agent_avatar_url
1563
+ });
1564
+ }
1565
+ function optionalString(value) {
1566
+ return typeof value === "string" ? value : void 0;
1567
+ }
1568
+ function optionalNumber(value) {
1569
+ return typeof value === "number" ? value : void 0;
1570
+ }
1555
1571
  function translateTodoItem(raw) {
1556
1572
  return {
1557
1573
  id: raw.id ?? 0,
@@ -1602,21 +1618,33 @@ function translateCustomEvent(name, data) {
1602
1618
  phase: data.phase ?? null,
1603
1619
  updatedAt: data.updated_at ?? null
1604
1620
  };
1621
+ // Two names for one event: the backend renamed these to `agent_*` (a nested
1622
+ // run is not necessarily a "subagent" in the product's vocabulary) while
1623
+ // older servers still emit `subagent_*`. Both are translated, so a client
1624
+ // works across the rename.
1625
+ case "agent_start":
1605
1626
  case "subagent_start":
1606
1627
  return {
1607
1628
  type: "subagent_start",
1608
- agent: translateAgentIdentity(
1609
- data.agent ?? {}
1610
- ),
1611
- taskCallId: data.task_call_id ?? null
1629
+ runId: optionalString(data.run_id),
1630
+ agent: agentIdentityFromEvent(data),
1631
+ // `parent_call_id` is the spawn block's call_id — the same key a client
1632
+ // uses to find the tool block whose subtree is this child's work.
1633
+ taskCallId: optionalString(data.parent_call_id) ?? optionalString(data.task_call_id) ?? null
1612
1634
  };
1635
+ case "agent_stop":
1613
1636
  case "subagent_stop":
1614
1637
  return {
1615
1638
  type: "subagent_stop",
1616
- agent: translateAgentIdentity(
1617
- data.agent ?? {}
1618
- ),
1619
- taskCallId: data.task_call_id ?? null
1639
+ runId: optionalString(data.run_id),
1640
+ agent: agentIdentityFromEvent(data),
1641
+ taskCallId: optionalString(data.parent_call_id) ?? optionalString(data.task_call_id) ?? null,
1642
+ // The child's OWN spend and outcome, which the wire surfaces separately
1643
+ // from the turn totals (`message_stop.usage` folds them in too).
1644
+ stopReason: optionalString(data.stop_reason),
1645
+ inputTokens: optionalNumber(data.input_tokens),
1646
+ outputTokens: optionalNumber(data.output_tokens),
1647
+ durationMs: optionalNumber(data.duration_ms)
1620
1648
  };
1621
1649
  case "context_warning":
1622
1650
  return {
@@ -1866,6 +1894,11 @@ function pathEquals(a, b) {
1866
1894
  }
1867
1895
  return true;
1868
1896
  }
1897
+ function frameSeq(frame) {
1898
+ if (typeof frame !== "object" || frame === null) return void 0;
1899
+ if (!("seq" in frame)) return void 0;
1900
+ return frame.seq;
1901
+ }
1869
1902
  var ChatSession = class {
1870
1903
  constructor(config, storage) {
1871
1904
  /**
@@ -2176,6 +2209,37 @@ var ChatSession = class {
2176
2209
  }
2177
2210
  }
2178
2211
  }
2212
+ /**
2213
+ * Advance the resume cursor from a frame's `seq` — the position the next
2214
+ * reconnect asks the backend to replay after (`?after=`).
2215
+ *
2216
+ * Two guards, because getting this wrong is silent and expensive: the whole
2217
+ * turn replays over blocks the consumer is already rendering.
2218
+ *
2219
+ * - **A keepalive is not an event.** The backend sends it as an ordinary
2220
+ * `data:` frame with `seq: 0` BY DESIGN (``WireKeepalive(seq=0, ts=0.0)``
2221
+ * — an idle tick, not a position), and it arrives exactly when a turn has
2222
+ * been quiet for 45 s: the long-tool-run case. Harvesting it rewound the
2223
+ * cursor to 0, so a reconnect after a blip replayed the turn from its
2224
+ * start — deltas and all.
2225
+ * - **The cursor only advances.** `send` and `reconnectToJob` reset it to
2226
+ * -1 per job, so a new job's `seq: 0` still moves it forward; a frame
2227
+ * carrying an older (or zero) position cannot move it back.
2228
+ *
2229
+ * The one other frame on the wire with a non-position `seq` is the
2230
+ * subscriber's SYNTHETIC terminal — a back-filled `error` for a job whose
2231
+ * worker died, built without a `seq` so Pydantic's default `0` reaches the
2232
+ * wire. Deliberately not named here: the advance-only rule already ignores it
2233
+ * whenever the client has drained anything (0 never exceeds a real cursor),
2234
+ * and when it is the FIRST frame the pump ends on `sawTerminal` before any
2235
+ * reconnect could use the cursor. Naming it would add a rule that no
2236
+ * reachable input exercises.
2237
+ */
2238
+ harvestResumeCursor(seq, type) {
2239
+ if (type === "keepalive") return;
2240
+ if (typeof seq !== "number") return;
2241
+ if (seq > this.lastSeq) this.lastSeq = seq;
2242
+ }
2179
2243
  async consumeJobStream(request, wire) {
2180
2244
  const job = await this.client.createJob(request);
2181
2245
  if (wire) wire.reached = true;
@@ -2313,18 +2377,14 @@ var ChatSession = class {
2313
2377
  try {
2314
2378
  const data = JSON.parse(raw.data);
2315
2379
  if (typeof data !== "object" || data === null || typeof data.type !== "string") {
2316
- if (typeof data?.seq === "number") {
2317
- this.lastSeq = data.seq;
2318
- }
2380
+ this.harvestResumeCursor(frameSeq(data), void 0);
2319
2381
  if (typeof data === "object" && data !== null && "data" in data && data.data === "[DONE]") {
2320
2382
  sawTerminal = true;
2321
2383
  }
2322
2384
  continue;
2323
2385
  }
2324
2386
  parsed = data;
2325
- if (typeof data.seq === "number") {
2326
- this.lastSeq = data.seq;
2327
- }
2387
+ this.harvestResumeCursor(frameSeq(data), parsed.type);
2328
2388
  } catch {
2329
2389
  continue;
2330
2390
  }
@@ -2553,11 +2613,19 @@ var ChatSession = class {
2553
2613
  * Reconnect to a running job's SSE stream (e.g. after page reload).
2554
2614
  * Replays all events from the beginning and continues live.
2555
2615
  */
2556
- async reconnectToJob(jobId) {
2616
+ /**
2617
+ * @param afterSeq Resume the job's event stream AFTER this sequence number.
2618
+ * Omitted (the reconnect default) replays the job from the beginning. A
2619
+ * caller that holds the job's events up to `afterSeq` — the transcript
2620
+ * cache — passes it so the backend replays only the delta and then goes
2621
+ * live; the backend's `?after=` handles a job that already ended by
2622
+ * back-filling its terminal event.
2623
+ */
2624
+ async reconnectToJob(jobId, afterSeq) {
2557
2625
  if (this.isStreaming) return;
2558
2626
  this.isStreaming = true;
2559
2627
  this.currentJobId = jobId;
2560
- this.lastSeq = -1;
2628
+ this.lastSeq = afterSeq ?? -1;
2561
2629
  this.submittedToolCallIds.clear();
2562
2630
  this.resetStreamingState();
2563
2631
  const controller = new AbortController();
@@ -2848,7 +2916,10 @@ function planRestore(args) {
2848
2916
  messageId: msgs[i]?.id
2849
2917
  }));
2850
2918
  if (jobs.length === 0) {
2851
- return userMessages.map((m) => ({
2919
+ const held = new Set(
2920
+ (args.heldMessageIds ?? []).filter((id) => !!id)
2921
+ );
2922
+ return userMessages.filter((m) => !(m.id && held.has(m.id))).map((m) => ({
2852
2923
  kind: "steer",
2853
2924
  content: m.content,
2854
2925
  messageId: m.id
@@ -3127,7 +3198,7 @@ var StreamManager = class _StreamManager {
3127
3198
  }
3128
3199
  let tookOver = false;
3129
3200
  try {
3130
- await this.restore(conversationId, gen);
3201
+ await this.restore(conversationId, gen, opts?.have);
3131
3202
  tookOver = gen === this.generation;
3132
3203
  } finally {
3133
3204
  const supersededOntoSame = gen !== this.generation && this._activeConversationId === conversationId;
@@ -3345,7 +3416,10 @@ var StreamManager = class _StreamManager {
3345
3416
  ...before ? { before } : {}
3346
3417
  });
3347
3418
  }
3348
- async restore(conversationId, gen) {
3419
+ async restore(conversationId, gen, have) {
3420
+ const haveByJobId = new Map(
3421
+ (have ?? []).map((h) => [h.jobId, h.lastSeq])
3422
+ );
3349
3423
  const superseded = () => gen !== this.generation;
3350
3424
  if (superseded()) return;
3351
3425
  const turn = this.turnCounter;
@@ -3372,7 +3446,8 @@ var StreamManager = class _StreamManager {
3372
3446
  gen,
3373
3447
  activeJobId,
3374
3448
  turn,
3375
- jobsRequest
3449
+ jobsRequest,
3450
+ haveByJobId
3376
3451
  ))
3377
3452
  return;
3378
3453
  if (activeJobId) {
@@ -3569,7 +3644,7 @@ var StreamManager = class _StreamManager {
3569
3644
  session.isLoadingEarlierTurns = false;
3570
3645
  }
3571
3646
  }
3572
- async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest) {
3647
+ async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest, haveByJobId) {
3573
3648
  const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3574
3649
  let complete = false;
3575
3650
  try {
@@ -3577,7 +3652,7 @@ var StreamManager = class _StreamManager {
3577
3652
  const jobs = page.jobs;
3578
3653
  if (stopReplay()) return false;
3579
3654
  const replayableJobs = jobs.filter(
3580
- (j) => j.job_id !== activeJobId
3655
+ (j) => j.job_id !== activeJobId && !haveByJobId.has(j.job_id)
3581
3656
  );
3582
3657
  const userMessages = this.session.messages.filter(
3583
3658
  (m) => m.role === "user"
@@ -3611,6 +3686,12 @@ var StreamManager = class _StreamManager {
3611
3686
  // them, silently. Deriving both from `jobs` makes that impossible to
3612
3687
  // get half-right.
3613
3688
  claimedMessageIds: jobs.map((j) => j.message_id),
3689
+ // The prompts whose turn the CALLER holds (see `switchTo`'s `have`).
3690
+ // The walk below is empty for exactly those jobs — which is the
3691
+ // primary cache scenario, a SETTLED conversation whose turns are all
3692
+ // held — and the empty-walk rescue would otherwise re-emit every one of
3693
+ // them as a steer bubble.
3694
+ heldMessageIds: jobs.filter((j) => haveByJobId.has(j.job_id)).map((j) => j.message_id),
3614
3695
  userMessages: userMessages.map((m) => ({
3615
3696
  id: m.id,
3616
3697
  content: m.content