@astralform/js 6.0.0 → 6.0.2

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