@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.d.cts CHANGED
@@ -1532,6 +1532,13 @@ declare class StreamManager {
1532
1532
  * one the user is waiting on — see ``restore``.
1533
1533
  */
1534
1534
  private generation;
1535
+ /**
1536
+ * Bumped every time a turn STARTS. `generation` does not move for a send
1537
+ * (only a pointer move does), and the streaming state returns to idle when a
1538
+ * turn ends — so neither can tell a restore that a turn ran inside one of
1539
+ * its awaits. This can.
1540
+ */
1541
+ private turnCounter;
1535
1542
  constructor(session: ChatSession);
1536
1543
  get state(): StreamState;
1537
1544
  get activeConversationId(): string | null;
@@ -1608,6 +1615,45 @@ declare class StreamManager {
1608
1615
  private settleIdle;
1609
1616
  private finalizeStream;
1610
1617
  private restore;
1618
+ /**
1619
+ * Has a live turn taken the block view over?
1620
+ *
1621
+ * ``_state`` is the SYNCHRONOUS authority and ``session.isStreaming`` lags it
1622
+ * by an await: ``send`` sets ``_state = "streaming"`` before its first await,
1623
+ * while the session only raises its flag inside ``processStream``, behind the
1624
+ * ``storage.addMessage`` write. For that whole window a send is underway —
1625
+ * composer cleared, optimistic bubble drawn — and the session flag still
1626
+ * reads false. Reading both closes the window from either end, since
1627
+ * ``reconnectToJob`` is the mirror case: it raises the session flag without
1628
+ * ever moving ``_state``.
1629
+ */
1630
+ private viewTakenOverByLiveTurn;
1631
+ /**
1632
+ * Has a turn STARTED since ``turn`` was captured?
1633
+ *
1634
+ * ``viewTakenOverByLiveTurn`` reads the current state, so it cannot see a
1635
+ * turn that both started and ENDED inside one of the restore's awaits — a
1636
+ * send that fails fast (auth, rate limit) resolves in about the time the job
1637
+ * list takes, and leaves `_state` back at idle with its blocks already
1638
+ * rendered. A monotonic count is the only thing that survives a state that
1639
+ * has returned to where it started.
1640
+ */
1641
+ private turnStarted;
1642
+ /**
1643
+ * Replay a conversation's persisted history into the consumer's block view.
1644
+ *
1645
+ * Returns false when this restore lost the right to finish — a newer switch
1646
+ * superseded it, or a send took the view over — in which case the caller
1647
+ * must stop rather than finish. See ``restore``.
1648
+ *
1649
+ * ``activeJobId`` names the turn that is still running, if any. Its events
1650
+ * are NOT fetched here: they are the live stream the caller reconnects to
1651
+ * straight after. It is passed so ``planRestore`` can pair it with the prompt
1652
+ * that started it, which is emitted as a bubble with no events — the whole
1653
+ * reason a conversation reopened mid-turn now shows the message that started
1654
+ * that turn.
1655
+ */
1656
+ private replayHistory;
1611
1657
  private setActiveConversation;
1612
1658
  }
1613
1659
 
package/dist/index.d.ts CHANGED
@@ -1532,6 +1532,13 @@ declare class StreamManager {
1532
1532
  * one the user is waiting on — see ``restore``.
1533
1533
  */
1534
1534
  private generation;
1535
+ /**
1536
+ * Bumped every time a turn STARTS. `generation` does not move for a send
1537
+ * (only a pointer move does), and the streaming state returns to idle when a
1538
+ * turn ends — so neither can tell a restore that a turn ran inside one of
1539
+ * its awaits. This can.
1540
+ */
1541
+ private turnCounter;
1535
1542
  constructor(session: ChatSession);
1536
1543
  get state(): StreamState;
1537
1544
  get activeConversationId(): string | null;
@@ -1608,6 +1615,45 @@ declare class StreamManager {
1608
1615
  private settleIdle;
1609
1616
  private finalizeStream;
1610
1617
  private restore;
1618
+ /**
1619
+ * Has a live turn taken the block view over?
1620
+ *
1621
+ * ``_state`` is the SYNCHRONOUS authority and ``session.isStreaming`` lags it
1622
+ * by an await: ``send`` sets ``_state = "streaming"`` before its first await,
1623
+ * while the session only raises its flag inside ``processStream``, behind the
1624
+ * ``storage.addMessage`` write. For that whole window a send is underway —
1625
+ * composer cleared, optimistic bubble drawn — and the session flag still
1626
+ * reads false. Reading both closes the window from either end, since
1627
+ * ``reconnectToJob`` is the mirror case: it raises the session flag without
1628
+ * ever moving ``_state``.
1629
+ */
1630
+ private viewTakenOverByLiveTurn;
1631
+ /**
1632
+ * Has a turn STARTED since ``turn`` was captured?
1633
+ *
1634
+ * ``viewTakenOverByLiveTurn`` reads the current state, so it cannot see a
1635
+ * turn that both started and ENDED inside one of the restore's awaits — a
1636
+ * send that fails fast (auth, rate limit) resolves in about the time the job
1637
+ * list takes, and leaves `_state` back at idle with its blocks already
1638
+ * rendered. A monotonic count is the only thing that survives a state that
1639
+ * has returned to where it started.
1640
+ */
1641
+ private turnStarted;
1642
+ /**
1643
+ * Replay a conversation's persisted history into the consumer's block view.
1644
+ *
1645
+ * Returns false when this restore lost the right to finish — a newer switch
1646
+ * superseded it, or a send took the view over — in which case the caller
1647
+ * must stop rather than finish. See ``restore``.
1648
+ *
1649
+ * ``activeJobId`` names the turn that is still running, if any. Its events
1650
+ * are NOT fetched here: they are the live stream the caller reconnects to
1651
+ * straight after. It is passed so ``planRestore`` can pair it with the prompt
1652
+ * that started it, which is emitted as a bubble with no events — the whole
1653
+ * reason a conversation reopened mid-turn now shows the message that started
1654
+ * that turn.
1655
+ */
1656
+ private replayHistory;
1611
1657
  private setActiveConversation;
1612
1658
  }
1613
1659
 
package/dist/index.js CHANGED
@@ -2194,9 +2194,10 @@ var ChatSession = class {
2194
2194
 
2195
2195
  // src/restore-plan.ts
2196
2196
  function planRestore(args) {
2197
- const { completedJobs, userMessages } = args;
2197
+ const { completedJobs, runningJob, userMessages } = args;
2198
+ const jobs = runningJob ? [...completedJobs, runningJob] : completedJobs;
2198
2199
  const claimed = new Set(
2199
- (args.claimedMessageIds ?? completedJobs.map((j) => j.message_id)).filter(
2200
+ (args.claimedMessageIds ?? jobs.map((j) => j.message_id)).filter(
2200
2201
  (id) => !!id
2201
2202
  )
2202
2203
  );
@@ -2205,19 +2206,19 @@ function planRestore(args) {
2205
2206
  if (m.id) byId.set(m.id, i);
2206
2207
  });
2207
2208
  const linkOf = (j) => j.message_id ? byId.get(j.message_id) : void 0;
2208
- const positional = (jobs, msgs) => jobs.map((job, i) => ({
2209
+ const positional = (slice, msgs) => slice.map((job, i) => ({
2209
2210
  kind: "turn",
2210
2211
  jobId: job.job_id,
2211
2212
  content: msgs[i]?.content,
2212
2213
  messageId: msgs[i]?.id
2213
2214
  }));
2214
- const firstLinked = completedJobs.findIndex((j) => linkOf(j) !== void 0);
2215
+ const firstLinked = jobs.findIndex((j) => linkOf(j) !== void 0);
2215
2216
  if (firstLinked === -1) {
2216
- return positional(completedJobs, userMessages);
2217
+ return positional(jobs, userMessages);
2217
2218
  }
2218
- const cutover = linkOf(completedJobs[firstLinked]);
2219
+ const cutover = linkOf(jobs[firstLinked]);
2219
2220
  const steps = positional(
2220
- completedJobs.slice(0, firstLinked),
2221
+ jobs.slice(0, firstLinked),
2221
2222
  userMessages.slice(0, cutover)
2222
2223
  );
2223
2224
  let cursor = cutover;
@@ -2231,7 +2232,7 @@ function planRestore(args) {
2231
2232
  }
2232
2233
  cursor = stopAt + 1;
2233
2234
  };
2234
- for (const job of completedJobs.slice(firstLinked)) {
2235
+ for (const job of jobs.slice(firstLinked)) {
2235
2236
  const at = linkOf(job);
2236
2237
  if (at !== void 0) {
2237
2238
  drainTo(at);
@@ -2313,6 +2314,13 @@ var StreamManager = class {
2313
2314
  * one the user is waiting on — see ``restore``.
2314
2315
  */
2315
2316
  this.generation = 0;
2317
+ /**
2318
+ * Bumped every time a turn STARTS. `generation` does not move for a send
2319
+ * (only a pointer move does), and the streaming state returns to idle when a
2320
+ * turn ends — so neither can tell a restore that a turn ran inside one of
2321
+ * its awaits. This can.
2322
+ */
2323
+ this.turnCounter = 0;
2316
2324
  this.session = session;
2317
2325
  this.attach();
2318
2326
  }
@@ -2381,6 +2389,7 @@ var StreamManager = class {
2381
2389
  target = await this.session.createNewConversation();
2382
2390
  this.setActiveConversation(target);
2383
2391
  }
2392
+ this.turnCounter++;
2384
2393
  this.setState("streaming");
2385
2394
  try {
2386
2395
  await this.session.send(content, {
@@ -2417,6 +2426,7 @@ var StreamManager = class {
2417
2426
  );
2418
2427
  const lastUserMsg = userMsgs[userMsgs.length - 1];
2419
2428
  if (!lastUserMsg) return;
2429
+ this.turnCounter++;
2420
2430
  this.setState("streaming");
2421
2431
  try {
2422
2432
  await this.session.resendFromCheckpoint(
@@ -2617,7 +2627,9 @@ var StreamManager = class {
2617
2627
  async restore(conversationId, gen) {
2618
2628
  const superseded = () => gen !== this.generation;
2619
2629
  if (superseded()) return;
2620
- if (!this.session.isStreaming) this.setState("restoring");
2630
+ const turn = this.turnCounter;
2631
+ const announcedRestoring = !this.session.isStreaming;
2632
+ if (announcedRestoring) this.setState("restoring");
2621
2633
  let activeJobId = null;
2622
2634
  try {
2623
2635
  const res = await this.session.client.getActiveJob(conversationId);
@@ -2625,9 +2637,12 @@ var StreamManager = class {
2625
2637
  } catch {
2626
2638
  }
2627
2639
  if (superseded()) return;
2640
+ await this.session.loadConversation(conversationId);
2641
+ if (superseded()) return;
2642
+ if (announcedRestoring && this.viewTakenOverByLiveTurn()) return;
2643
+ if (announcedRestoring && !await this.replayHistory(conversationId, gen, activeJobId, turn))
2644
+ return;
2628
2645
  if (activeJobId) {
2629
- await this.session.loadConversation(conversationId);
2630
- if (superseded()) return;
2631
2646
  this.setState("streaming");
2632
2647
  try {
2633
2648
  await this.session.reconnectToJob(activeJobId);
@@ -2638,76 +2653,125 @@ var StreamManager = class {
2638
2653
  this.setState("idle");
2639
2654
  }
2640
2655
  } else {
2641
- await this.session.loadConversation(conversationId);
2642
2656
  if (superseded()) return;
2643
- try {
2644
- const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2645
- if (superseded()) return;
2646
- const completedJobs = jobs.filter(
2647
- (j) => j.status === "completed"
2648
- );
2649
- const userMessages = this.session.messages.filter(
2650
- (m) => m.role === "user"
2651
- );
2652
- const plan = planRestore({
2653
- completedJobs: completedJobs.map((j) => ({
2654
- job_id: j.job_id,
2655
- message_id: j.message_id
2656
- })),
2657
- // Completed jobs PLUS the ones still going. A send landing in the
2658
- // probe window has a running job, so its prompt is claimed and does
2659
- // not read as a steer replayed over the bubble the live send already
2660
- // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2661
- // they produce no `turn` step, so claiming them would delete the
2662
- // user's prompt from the restore entirely rather than show it as a
2663
- // steer.
2664
- claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2665
- userMessages: userMessages.map((m) => ({
2666
- id: m.id,
2667
- content: m.content
2668
- }))
2669
- });
2670
- const eventLists = await Promise.all(
2671
- completedJobs.map(
2672
- (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2673
- )
2674
- );
2675
- if (superseded()) return;
2676
- const eventsByJobId = new Map(
2677
- completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2678
- );
2679
- for (const step of plan) {
2680
- if (superseded()) return;
2681
- if (step.kind === "steer") {
2682
- this.session.replayTurn(
2683
- conversationId,
2684
- [],
2685
- step.content,
2686
- step.messageId,
2687
- true
2688
- );
2689
- continue;
2690
- }
2657
+ this.settleIdle();
2658
+ }
2659
+ }
2660
+ /**
2661
+ * Has a live turn taken the block view over?
2662
+ *
2663
+ * ``_state`` is the SYNCHRONOUS authority and ``session.isStreaming`` lags it
2664
+ * by an await: ``send`` sets ``_state = "streaming"`` before its first await,
2665
+ * while the session only raises its flag inside ``processStream``, behind the
2666
+ * ``storage.addMessage`` write. For that whole window a send is underway —
2667
+ * composer cleared, optimistic bubble drawn — and the session flag still
2668
+ * reads false. Reading both closes the window from either end, since
2669
+ * ``reconnectToJob`` is the mirror case: it raises the session flag without
2670
+ * ever moving ``_state``.
2671
+ */
2672
+ viewTakenOverByLiveTurn() {
2673
+ return this._state === "streaming" || this.session.isStreaming;
2674
+ }
2675
+ /**
2676
+ * Has a turn STARTED since ``turn`` was captured?
2677
+ *
2678
+ * ``viewTakenOverByLiveTurn`` reads the current state, so it cannot see a
2679
+ * turn that both started and ENDED inside one of the restore's awaits — a
2680
+ * send that fails fast (auth, rate limit) resolves in about the time the job
2681
+ * list takes, and leaves `_state` back at idle with its blocks already
2682
+ * rendered. A monotonic count is the only thing that survives a state that
2683
+ * has returned to where it started.
2684
+ */
2685
+ turnStarted(since) {
2686
+ return this.turnCounter !== since;
2687
+ }
2688
+ /**
2689
+ * Replay a conversation's persisted history into the consumer's block view.
2690
+ *
2691
+ * Returns false when this restore lost the right to finish — a newer switch
2692
+ * superseded it, or a send took the view over — in which case the caller
2693
+ * must stop rather than finish. See ``restore``.
2694
+ *
2695
+ * ``activeJobId`` names the turn that is still running, if any. Its events
2696
+ * are NOT fetched here: they are the live stream the caller reconnects to
2697
+ * straight after. It is passed so ``planRestore`` can pair it with the prompt
2698
+ * that started it, which is emitted as a bubble with no events — the whole
2699
+ * reason a conversation reopened mid-turn now shows the message that started
2700
+ * that turn.
2701
+ */
2702
+ async replayHistory(conversationId, gen, activeJobId, turn) {
2703
+ const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
2704
+ try {
2705
+ const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2706
+ if (stopReplay()) return false;
2707
+ const completedJobs = jobs.filter(
2708
+ (j) => j.status === "completed" && j.job_id !== activeJobId
2709
+ );
2710
+ const userMessages = this.session.messages.filter(
2711
+ (m) => m.role === "user"
2712
+ );
2713
+ const runningJob = activeJobId ? jobs.find((j) => j.job_id === activeJobId) : void 0;
2714
+ const plan = planRestore({
2715
+ completedJobs: completedJobs.map((j) => ({
2716
+ job_id: j.job_id,
2717
+ message_id: j.message_id
2718
+ })),
2719
+ runningJob: runningJob && {
2720
+ job_id: runningJob.job_id,
2721
+ message_id: runningJob.message_id
2722
+ },
2723
+ // Completed jobs PLUS the ones still going. A send landing in the
2724
+ // probe window has a running job, so its prompt is claimed and does
2725
+ // not read as a steer replayed over the bubble the live send already
2726
+ // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2727
+ // they produce no `turn` step, so claiming them would delete the
2728
+ // user's prompt from the restore entirely rather than show it as a
2729
+ // steer.
2730
+ claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2731
+ userMessages: userMessages.map((m) => ({
2732
+ id: m.id,
2733
+ content: m.content
2734
+ }))
2735
+ });
2736
+ const eventLists = await Promise.all(
2737
+ completedJobs.map(
2738
+ (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2739
+ )
2740
+ );
2741
+ if (stopReplay()) return false;
2742
+ const eventsByJobId = new Map(
2743
+ completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2744
+ );
2745
+ for (const step of plan) {
2746
+ if (stopReplay()) return false;
2747
+ if (step.kind === "steer") {
2691
2748
  this.session.replayTurn(
2692
2749
  conversationId,
2693
- eventsByJobId.get(step.jobId) ?? [],
2750
+ [],
2694
2751
  step.content,
2695
- step.messageId
2752
+ step.messageId,
2753
+ true
2696
2754
  );
2755
+ continue;
2697
2756
  }
2698
- if (superseded()) return;
2699
- if (completedJobs.length > 0) {
2700
- this.emit({
2701
- type: "versionsReady",
2702
- conversationId,
2703
- count: completedJobs.length
2704
- });
2705
- }
2706
- } catch {
2757
+ this.session.replayTurn(
2758
+ conversationId,
2759
+ eventsByJobId.get(step.jobId) ?? [],
2760
+ step.content,
2761
+ step.messageId
2762
+ );
2707
2763
  }
2708
- if (superseded()) return;
2709
- this.settleIdle();
2764
+ if (stopReplay()) return false;
2765
+ if (completedJobs.length > 0) {
2766
+ this.emit({
2767
+ type: "versionsReady",
2768
+ conversationId,
2769
+ count: completedJobs.length
2770
+ });
2771
+ }
2772
+ } catch {
2710
2773
  }
2774
+ return !stopReplay();
2711
2775
  }
2712
2776
  // ── Internal: set active conversation ─────────────────────────
2713
2777
  setActiveConversation(id) {