@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 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();
@@ -2757,6 +2825,48 @@ var ChatSession = class {
2757
2825
  if (!fresh.length) return;
2758
2826
  this.setMessages([...fresh, ...this.messages]);
2759
2827
  }
2828
+ /**
2829
+ * Re-fetch the NEWEST page of the conversation list and merge it in.
2830
+ *
2831
+ * The list the sidebar renders is seeded once at `connect()` and then only
2832
+ * changed by this client's own actions (create, delete, rename) — so a row's
2833
+ * `activeJobId` is as fresh as that seed, and a job started in ANOTHER tab or
2834
+ * on another device never appears, while one that ended elsewhere stays lit
2835
+ * forever. A caller that refreshes on a trigger (the tab becoming visible, or
2836
+ * a bounded interval while any job is live) keeps the rows honest without a
2837
+ * socket per conversation.
2838
+ *
2839
+ * Merge rules, and why each is what it is:
2840
+ *
2841
+ * - the server's ORDER wins for the rows it returns: an updated conversation
2842
+ * moves to its new place, which is the whole point of the call;
2843
+ * - rows paged in EARLIER (`loadMoreConversations`) are kept — this reads page
2844
+ * 1 only, and dropping the tail would make a refresh silently truncate a
2845
+ * list the reader has already scrolled;
2846
+ * - a row the caller MINTED locally and the server does not know yet is kept
2847
+ * too (it has no server row to be refreshed by);
2848
+ * - `conversationsGeneration` is bumped, for the same reason `connect()`
2849
+ * bumps it: a page request already in flight was cut against the old head
2850
+ * and its offset no longer means what it did.
2851
+ */
2852
+ async refreshConversations() {
2853
+ const page = await this.client.getConversations(CONVERSATION_PAGE_SIZE, 0);
2854
+ const pageIds = new Set(page.map((c) => c.id));
2855
+ const known = new Set(this.conversations.map((c) => c.id));
2856
+ this.conversationsGeneration++;
2857
+ this.conversations = [
2858
+ ...page,
2859
+ // Everything the page did not mention: older pages, and rows that exist
2860
+ // only on this client (a minted conversation the server has not been told
2861
+ // about yet).
2862
+ ...this.conversations.filter((c) => !pageIds.has(c.id))
2863
+ ];
2864
+ for (const c of page) {
2865
+ this.serverConversationIds.add(c.id);
2866
+ known.delete(c.id);
2867
+ }
2868
+ return page;
2869
+ }
2760
2870
  async loadMoreConversations() {
2761
2871
  if (this.isLoadingConversations || !this.hasMoreConversations) return [];
2762
2872
  this.isLoadingConversations = true;
@@ -2848,7 +2958,10 @@ function planRestore(args) {
2848
2958
  messageId: msgs[i]?.id
2849
2959
  }));
2850
2960
  if (jobs.length === 0) {
2851
- return userMessages.map((m) => ({
2961
+ const held = new Set(
2962
+ (args.heldMessageIds ?? []).filter((id) => !!id)
2963
+ );
2964
+ return userMessages.filter((m) => !(m.id && held.has(m.id))).map((m) => ({
2852
2965
  kind: "steer",
2853
2966
  content: m.content,
2854
2967
  messageId: m.id
@@ -3127,7 +3240,7 @@ var StreamManager = class _StreamManager {
3127
3240
  }
3128
3241
  let tookOver = false;
3129
3242
  try {
3130
- await this.restore(conversationId, gen);
3243
+ await this.restore(conversationId, gen, opts?.have);
3131
3244
  tookOver = gen === this.generation;
3132
3245
  } finally {
3133
3246
  const supersededOntoSame = gen !== this.generation && this._activeConversationId === conversationId;
@@ -3345,7 +3458,10 @@ var StreamManager = class _StreamManager {
3345
3458
  ...before ? { before } : {}
3346
3459
  });
3347
3460
  }
3348
- async restore(conversationId, gen) {
3461
+ async restore(conversationId, gen, have) {
3462
+ const haveByJobId = new Map(
3463
+ (have ?? []).map((h) => [h.jobId, h.lastSeq])
3464
+ );
3349
3465
  const superseded = () => gen !== this.generation;
3350
3466
  if (superseded()) return;
3351
3467
  const turn = this.turnCounter;
@@ -3372,7 +3488,8 @@ var StreamManager = class _StreamManager {
3372
3488
  gen,
3373
3489
  activeJobId,
3374
3490
  turn,
3375
- jobsRequest
3491
+ jobsRequest,
3492
+ haveByJobId
3376
3493
  ))
3377
3494
  return;
3378
3495
  if (activeJobId) {
@@ -3569,7 +3686,7 @@ var StreamManager = class _StreamManager {
3569
3686
  session.isLoadingEarlierTurns = false;
3570
3687
  }
3571
3688
  }
3572
- async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest) {
3689
+ async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest, haveByJobId) {
3573
3690
  const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3574
3691
  let complete = false;
3575
3692
  try {
@@ -3577,7 +3694,7 @@ var StreamManager = class _StreamManager {
3577
3694
  const jobs = page.jobs;
3578
3695
  if (stopReplay()) return false;
3579
3696
  const replayableJobs = jobs.filter(
3580
- (j) => j.job_id !== activeJobId
3697
+ (j) => j.job_id !== activeJobId && !haveByJobId.has(j.job_id)
3581
3698
  );
3582
3699
  const userMessages = this.session.messages.filter(
3583
3700
  (m) => m.role === "user"
@@ -3611,6 +3728,12 @@ var StreamManager = class _StreamManager {
3611
3728
  // them, silently. Deriving both from `jobs` makes that impossible to
3612
3729
  // get half-right.
3613
3730
  claimedMessageIds: jobs.map((j) => j.message_id),
3731
+ // The prompts whose turn the CALLER holds (see `switchTo`'s `have`).
3732
+ // The walk below is empty for exactly those jobs — which is the
3733
+ // primary cache scenario, a SETTLED conversation whose turns are all
3734
+ // held — and the empty-walk rescue would otherwise re-emit every one of
3735
+ // them as a steer bubble.
3736
+ heldMessageIds: jobs.filter((j) => haveByJobId.has(j.job_id)).map((j) => j.message_id),
3614
3737
  userMessages: userMessages.map((m) => ({
3615
3738
  id: m.id,
3616
3739
  content: m.content