@astralform/js 6.0.0 → 6.0.1

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
@@ -2241,9 +2241,10 @@ var ChatSession = class {
2241
2241
 
2242
2242
  // src/restore-plan.ts
2243
2243
  function planRestore(args) {
2244
- const { completedJobs, userMessages } = args;
2244
+ const { completedJobs, runningJob, userMessages } = args;
2245
+ const jobs = runningJob ? [...completedJobs, runningJob] : completedJobs;
2245
2246
  const claimed = new Set(
2246
- (args.claimedMessageIds ?? completedJobs.map((j) => j.message_id)).filter(
2247
+ (args.claimedMessageIds ?? jobs.map((j) => j.message_id)).filter(
2247
2248
  (id) => !!id
2248
2249
  )
2249
2250
  );
@@ -2252,19 +2253,19 @@ function planRestore(args) {
2252
2253
  if (m.id) byId.set(m.id, i);
2253
2254
  });
2254
2255
  const linkOf = (j) => j.message_id ? byId.get(j.message_id) : void 0;
2255
- const positional = (jobs, msgs) => jobs.map((job, i) => ({
2256
+ const positional = (slice, msgs) => slice.map((job, i) => ({
2256
2257
  kind: "turn",
2257
2258
  jobId: job.job_id,
2258
2259
  content: msgs[i]?.content,
2259
2260
  messageId: msgs[i]?.id
2260
2261
  }));
2261
- const firstLinked = completedJobs.findIndex((j) => linkOf(j) !== void 0);
2262
+ const firstLinked = jobs.findIndex((j) => linkOf(j) !== void 0);
2262
2263
  if (firstLinked === -1) {
2263
- return positional(completedJobs, userMessages);
2264
+ return positional(jobs, userMessages);
2264
2265
  }
2265
- const cutover = linkOf(completedJobs[firstLinked]);
2266
+ const cutover = linkOf(jobs[firstLinked]);
2266
2267
  const steps = positional(
2267
- completedJobs.slice(0, firstLinked),
2268
+ jobs.slice(0, firstLinked),
2268
2269
  userMessages.slice(0, cutover)
2269
2270
  );
2270
2271
  let cursor = cutover;
@@ -2278,7 +2279,7 @@ function planRestore(args) {
2278
2279
  }
2279
2280
  cursor = stopAt + 1;
2280
2281
  };
2281
- for (const job of completedJobs.slice(firstLinked)) {
2282
+ for (const job of jobs.slice(firstLinked)) {
2282
2283
  const at = linkOf(job);
2283
2284
  if (at !== void 0) {
2284
2285
  drainTo(at);
@@ -2360,6 +2361,13 @@ var StreamManager = class {
2360
2361
  * one the user is waiting on — see ``restore``.
2361
2362
  */
2362
2363
  this.generation = 0;
2364
+ /**
2365
+ * Bumped every time a turn STARTS. `generation` does not move for a send
2366
+ * (only a pointer move does), and the streaming state returns to idle when a
2367
+ * turn ends — so neither can tell a restore that a turn ran inside one of
2368
+ * its awaits. This can.
2369
+ */
2370
+ this.turnCounter = 0;
2363
2371
  this.session = session;
2364
2372
  this.attach();
2365
2373
  }
@@ -2428,6 +2436,7 @@ var StreamManager = class {
2428
2436
  target = await this.session.createNewConversation();
2429
2437
  this.setActiveConversation(target);
2430
2438
  }
2439
+ this.turnCounter++;
2431
2440
  this.setState("streaming");
2432
2441
  try {
2433
2442
  await this.session.send(content, {
@@ -2464,6 +2473,7 @@ var StreamManager = class {
2464
2473
  );
2465
2474
  const lastUserMsg = userMsgs[userMsgs.length - 1];
2466
2475
  if (!lastUserMsg) return;
2476
+ this.turnCounter++;
2467
2477
  this.setState("streaming");
2468
2478
  try {
2469
2479
  await this.session.resendFromCheckpoint(
@@ -2664,7 +2674,9 @@ var StreamManager = class {
2664
2674
  async restore(conversationId, gen) {
2665
2675
  const superseded = () => gen !== this.generation;
2666
2676
  if (superseded()) return;
2667
- if (!this.session.isStreaming) this.setState("restoring");
2677
+ const turn = this.turnCounter;
2678
+ const announcedRestoring = !this.session.isStreaming;
2679
+ if (announcedRestoring) this.setState("restoring");
2668
2680
  let activeJobId = null;
2669
2681
  try {
2670
2682
  const res = await this.session.client.getActiveJob(conversationId);
@@ -2672,9 +2684,12 @@ var StreamManager = class {
2672
2684
  } catch {
2673
2685
  }
2674
2686
  if (superseded()) return;
2687
+ await this.session.loadConversation(conversationId);
2688
+ if (superseded()) return;
2689
+ if (announcedRestoring && this.viewTakenOverByLiveTurn()) return;
2690
+ if (announcedRestoring && !await this.replayHistory(conversationId, gen, activeJobId, turn))
2691
+ return;
2675
2692
  if (activeJobId) {
2676
- await this.session.loadConversation(conversationId);
2677
- if (superseded()) return;
2678
2693
  this.setState("streaming");
2679
2694
  try {
2680
2695
  await this.session.reconnectToJob(activeJobId);
@@ -2685,76 +2700,125 @@ var StreamManager = class {
2685
2700
  this.setState("idle");
2686
2701
  }
2687
2702
  } else {
2688
- await this.session.loadConversation(conversationId);
2689
2703
  if (superseded()) return;
2690
- try {
2691
- const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2692
- if (superseded()) return;
2693
- const completedJobs = jobs.filter(
2694
- (j) => j.status === "completed"
2695
- );
2696
- const userMessages = this.session.messages.filter(
2697
- (m) => m.role === "user"
2698
- );
2699
- const plan = planRestore({
2700
- completedJobs: completedJobs.map((j) => ({
2701
- job_id: j.job_id,
2702
- message_id: j.message_id
2703
- })),
2704
- // Completed jobs PLUS the ones still going. A send landing in the
2705
- // probe window has a running job, so its prompt is claimed and does
2706
- // not read as a steer replayed over the bubble the live send already
2707
- // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2708
- // they produce no `turn` step, so claiming them would delete the
2709
- // user's prompt from the restore entirely rather than show it as a
2710
- // steer.
2711
- claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2712
- userMessages: userMessages.map((m) => ({
2713
- id: m.id,
2714
- content: m.content
2715
- }))
2716
- });
2717
- const eventLists = await Promise.all(
2718
- completedJobs.map(
2719
- (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2720
- )
2721
- );
2722
- if (superseded()) return;
2723
- const eventsByJobId = new Map(
2724
- completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2725
- );
2726
- for (const step of plan) {
2727
- if (superseded()) return;
2728
- if (step.kind === "steer") {
2729
- this.session.replayTurn(
2730
- conversationId,
2731
- [],
2732
- step.content,
2733
- step.messageId,
2734
- true
2735
- );
2736
- continue;
2737
- }
2704
+ this.settleIdle();
2705
+ }
2706
+ }
2707
+ /**
2708
+ * Has a live turn taken the block view over?
2709
+ *
2710
+ * ``_state`` is the SYNCHRONOUS authority and ``session.isStreaming`` lags it
2711
+ * by an await: ``send`` sets ``_state = "streaming"`` before its first await,
2712
+ * while the session only raises its flag inside ``processStream``, behind the
2713
+ * ``storage.addMessage`` write. For that whole window a send is underway —
2714
+ * composer cleared, optimistic bubble drawn — and the session flag still
2715
+ * reads false. Reading both closes the window from either end, since
2716
+ * ``reconnectToJob`` is the mirror case: it raises the session flag without
2717
+ * ever moving ``_state``.
2718
+ */
2719
+ viewTakenOverByLiveTurn() {
2720
+ return this._state === "streaming" || this.session.isStreaming;
2721
+ }
2722
+ /**
2723
+ * Has a turn STARTED since ``turn`` was captured?
2724
+ *
2725
+ * ``viewTakenOverByLiveTurn`` reads the current state, so it cannot see a
2726
+ * turn that both started and ENDED inside one of the restore's awaits — a
2727
+ * send that fails fast (auth, rate limit) resolves in about the time the job
2728
+ * list takes, and leaves `_state` back at idle with its blocks already
2729
+ * rendered. A monotonic count is the only thing that survives a state that
2730
+ * has returned to where it started.
2731
+ */
2732
+ turnStarted(since) {
2733
+ return this.turnCounter !== since;
2734
+ }
2735
+ /**
2736
+ * Replay a conversation's persisted history into the consumer's block view.
2737
+ *
2738
+ * Returns false when this restore lost the right to finish — a newer switch
2739
+ * superseded it, or a send took the view over — in which case the caller
2740
+ * must stop rather than finish. See ``restore``.
2741
+ *
2742
+ * ``activeJobId`` names the turn that is still running, if any. Its events
2743
+ * are NOT fetched here: they are the live stream the caller reconnects to
2744
+ * straight after. It is passed so ``planRestore`` can pair it with the prompt
2745
+ * that started it, which is emitted as a bubble with no events — the whole
2746
+ * reason a conversation reopened mid-turn now shows the message that started
2747
+ * that turn.
2748
+ */
2749
+ async replayHistory(conversationId, gen, activeJobId, turn) {
2750
+ const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
2751
+ try {
2752
+ const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2753
+ if (stopReplay()) return false;
2754
+ const completedJobs = jobs.filter(
2755
+ (j) => j.status === "completed" && j.job_id !== activeJobId
2756
+ );
2757
+ const userMessages = this.session.messages.filter(
2758
+ (m) => m.role === "user"
2759
+ );
2760
+ const runningJob = activeJobId ? jobs.find((j) => j.job_id === activeJobId) : void 0;
2761
+ const plan = planRestore({
2762
+ completedJobs: completedJobs.map((j) => ({
2763
+ job_id: j.job_id,
2764
+ message_id: j.message_id
2765
+ })),
2766
+ runningJob: runningJob && {
2767
+ job_id: runningJob.job_id,
2768
+ message_id: runningJob.message_id
2769
+ },
2770
+ // Completed jobs PLUS the ones still going. A send landing in the
2771
+ // probe window has a running job, so its prompt is claimed and does
2772
+ // not read as a steer replayed over the bubble the live send already
2773
+ // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2774
+ // they produce no `turn` step, so claiming them would delete the
2775
+ // user's prompt from the restore entirely rather than show it as a
2776
+ // steer.
2777
+ claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2778
+ userMessages: userMessages.map((m) => ({
2779
+ id: m.id,
2780
+ content: m.content
2781
+ }))
2782
+ });
2783
+ const eventLists = await Promise.all(
2784
+ completedJobs.map(
2785
+ (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2786
+ )
2787
+ );
2788
+ if (stopReplay()) return false;
2789
+ const eventsByJobId = new Map(
2790
+ completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2791
+ );
2792
+ for (const step of plan) {
2793
+ if (stopReplay()) return false;
2794
+ if (step.kind === "steer") {
2738
2795
  this.session.replayTurn(
2739
2796
  conversationId,
2740
- eventsByJobId.get(step.jobId) ?? [],
2797
+ [],
2741
2798
  step.content,
2742
- step.messageId
2799
+ step.messageId,
2800
+ true
2743
2801
  );
2802
+ continue;
2744
2803
  }
2745
- if (superseded()) return;
2746
- if (completedJobs.length > 0) {
2747
- this.emit({
2748
- type: "versionsReady",
2749
- conversationId,
2750
- count: completedJobs.length
2751
- });
2752
- }
2753
- } catch {
2804
+ this.session.replayTurn(
2805
+ conversationId,
2806
+ eventsByJobId.get(step.jobId) ?? [],
2807
+ step.content,
2808
+ step.messageId
2809
+ );
2754
2810
  }
2755
- if (superseded()) return;
2756
- this.settleIdle();
2811
+ if (stopReplay()) return false;
2812
+ if (completedJobs.length > 0) {
2813
+ this.emit({
2814
+ type: "versionsReady",
2815
+ conversationId,
2816
+ count: completedJobs.length
2817
+ });
2818
+ }
2819
+ } catch {
2757
2820
  }
2821
+ return !stopReplay();
2758
2822
  }
2759
2823
  // ── Internal: set active conversation ─────────────────────────
2760
2824
  setActiveConversation(id) {