@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.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,26 @@ 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
+ if (jobs.length === 0) {
2216
+ return userMessages.map((m) => ({
2217
+ kind: "steer",
2218
+ content: m.content,
2219
+ messageId: m.id
2220
+ }));
2221
+ }
2222
+ const firstLinked = jobs.findIndex((j) => linkOf(j) !== void 0);
2215
2223
  if (firstLinked === -1) {
2216
- return positional(completedJobs, userMessages);
2224
+ return positional(jobs, userMessages);
2217
2225
  }
2218
- const cutover = linkOf(completedJobs[firstLinked]);
2226
+ const cutover = linkOf(jobs[firstLinked]);
2219
2227
  const steps = positional(
2220
- completedJobs.slice(0, firstLinked),
2228
+ jobs.slice(0, firstLinked),
2221
2229
  userMessages.slice(0, cutover)
2222
2230
  );
2223
2231
  let cursor = cutover;
@@ -2231,7 +2239,7 @@ function planRestore(args) {
2231
2239
  }
2232
2240
  cursor = stopAt + 1;
2233
2241
  };
2234
- for (const job of completedJobs.slice(firstLinked)) {
2242
+ for (const job of jobs.slice(firstLinked)) {
2235
2243
  const at = linkOf(job);
2236
2244
  if (at !== void 0) {
2237
2245
  drainTo(at);
@@ -2313,6 +2321,13 @@ var StreamManager = class {
2313
2321
  * one the user is waiting on — see ``restore``.
2314
2322
  */
2315
2323
  this.generation = 0;
2324
+ /**
2325
+ * Bumped every time a turn STARTS. `generation` does not move for a send
2326
+ * (only a pointer move does), and the streaming state returns to idle when a
2327
+ * turn ends — so neither can tell a restore that a turn ran inside one of
2328
+ * its awaits. This can.
2329
+ */
2330
+ this.turnCounter = 0;
2316
2331
  this.session = session;
2317
2332
  this.attach();
2318
2333
  }
@@ -2381,6 +2396,7 @@ var StreamManager = class {
2381
2396
  target = await this.session.createNewConversation();
2382
2397
  this.setActiveConversation(target);
2383
2398
  }
2399
+ this.turnCounter++;
2384
2400
  this.setState("streaming");
2385
2401
  try {
2386
2402
  await this.session.send(content, {
@@ -2417,6 +2433,7 @@ var StreamManager = class {
2417
2433
  );
2418
2434
  const lastUserMsg = userMsgs[userMsgs.length - 1];
2419
2435
  if (!lastUserMsg) return;
2436
+ this.turnCounter++;
2420
2437
  this.setState("streaming");
2421
2438
  try {
2422
2439
  await this.session.resendFromCheckpoint(
@@ -2617,7 +2634,9 @@ var StreamManager = class {
2617
2634
  async restore(conversationId, gen) {
2618
2635
  const superseded = () => gen !== this.generation;
2619
2636
  if (superseded()) return;
2620
- if (!this.session.isStreaming) this.setState("restoring");
2637
+ const turn = this.turnCounter;
2638
+ const announcedRestoring = !this.session.isStreaming;
2639
+ if (announcedRestoring) this.setState("restoring");
2621
2640
  let activeJobId = null;
2622
2641
  try {
2623
2642
  const res = await this.session.client.getActiveJob(conversationId);
@@ -2625,9 +2644,12 @@ var StreamManager = class {
2625
2644
  } catch {
2626
2645
  }
2627
2646
  if (superseded()) return;
2647
+ await this.session.loadConversation(conversationId);
2648
+ if (superseded()) return;
2649
+ if (announcedRestoring && this.viewTakenOverByLiveTurn()) return;
2650
+ if (announcedRestoring && !await this.replayHistory(conversationId, gen, activeJobId, turn))
2651
+ return;
2628
2652
  if (activeJobId) {
2629
- await this.session.loadConversation(conversationId);
2630
- if (superseded()) return;
2631
2653
  this.setState("streaming");
2632
2654
  try {
2633
2655
  await this.session.reconnectToJob(activeJobId);
@@ -2638,76 +2660,125 @@ var StreamManager = class {
2638
2660
  this.setState("idle");
2639
2661
  }
2640
2662
  } else {
2641
- await this.session.loadConversation(conversationId);
2642
2663
  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
- }
2664
+ this.settleIdle();
2665
+ }
2666
+ }
2667
+ /**
2668
+ * Has a live turn taken the block view over?
2669
+ *
2670
+ * ``_state`` is the SYNCHRONOUS authority and ``session.isStreaming`` lags it
2671
+ * by an await: ``send`` sets ``_state = "streaming"`` before its first await,
2672
+ * while the session only raises its flag inside ``processStream``, behind the
2673
+ * ``storage.addMessage`` write. For that whole window a send is underway —
2674
+ * composer cleared, optimistic bubble drawn — and the session flag still
2675
+ * reads false. Reading both closes the window from either end, since
2676
+ * ``reconnectToJob`` is the mirror case: it raises the session flag without
2677
+ * ever moving ``_state``.
2678
+ */
2679
+ viewTakenOverByLiveTurn() {
2680
+ return this._state === "streaming" || this.session.isStreaming;
2681
+ }
2682
+ /**
2683
+ * Has a turn STARTED since ``turn`` was captured?
2684
+ *
2685
+ * ``viewTakenOverByLiveTurn`` reads the current state, so it cannot see a
2686
+ * turn that both started and ENDED inside one of the restore's awaits — a
2687
+ * send that fails fast (auth, rate limit) resolves in about the time the job
2688
+ * list takes, and leaves `_state` back at idle with its blocks already
2689
+ * rendered. A monotonic count is the only thing that survives a state that
2690
+ * has returned to where it started.
2691
+ */
2692
+ turnStarted(since) {
2693
+ return this.turnCounter !== since;
2694
+ }
2695
+ /**
2696
+ * Replay a conversation's persisted history into the consumer's block view.
2697
+ *
2698
+ * Returns false when this restore lost the right to finish — a newer switch
2699
+ * superseded it, or a send took the view over — in which case the caller
2700
+ * must stop rather than finish. See ``restore``.
2701
+ *
2702
+ * ``activeJobId`` names the turn that is still running, if any. Its events
2703
+ * are NOT fetched here: they are the live stream the caller reconnects to
2704
+ * straight after. It is passed so ``planRestore`` can pair it with the prompt
2705
+ * that started it, which is emitted as a bubble with no events — the whole
2706
+ * reason a conversation reopened mid-turn now shows the message that started
2707
+ * that turn.
2708
+ */
2709
+ async replayHistory(conversationId, gen, activeJobId, turn) {
2710
+ const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
2711
+ try {
2712
+ const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2713
+ if (stopReplay()) return false;
2714
+ const completedJobs = jobs.filter(
2715
+ (j) => j.status === "completed" && j.job_id !== activeJobId
2716
+ );
2717
+ const userMessages = this.session.messages.filter(
2718
+ (m) => m.role === "user"
2719
+ );
2720
+ const runningJob = activeJobId ? jobs.find((j) => j.job_id === activeJobId) : void 0;
2721
+ const plan = planRestore({
2722
+ completedJobs: completedJobs.map((j) => ({
2723
+ job_id: j.job_id,
2724
+ message_id: j.message_id
2725
+ })),
2726
+ runningJob: runningJob && {
2727
+ job_id: runningJob.job_id,
2728
+ message_id: runningJob.message_id
2729
+ },
2730
+ // Completed jobs PLUS the ones still going. A send landing in the
2731
+ // probe window has a running job, so its prompt is claimed and does
2732
+ // not read as a steer replayed over the bubble the live send already
2733
+ // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2734
+ // they produce no `turn` step, so claiming them would delete the
2735
+ // user's prompt from the restore entirely rather than show it as a
2736
+ // steer.
2737
+ claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2738
+ userMessages: userMessages.map((m) => ({
2739
+ id: m.id,
2740
+ content: m.content
2741
+ }))
2742
+ });
2743
+ const eventLists = await Promise.all(
2744
+ completedJobs.map(
2745
+ (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2746
+ )
2747
+ );
2748
+ if (stopReplay()) return false;
2749
+ const eventsByJobId = new Map(
2750
+ completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2751
+ );
2752
+ for (const step of plan) {
2753
+ if (stopReplay()) return false;
2754
+ if (step.kind === "steer") {
2691
2755
  this.session.replayTurn(
2692
2756
  conversationId,
2693
- eventsByJobId.get(step.jobId) ?? [],
2757
+ [],
2694
2758
  step.content,
2695
- step.messageId
2759
+ step.messageId,
2760
+ true
2696
2761
  );
2762
+ continue;
2697
2763
  }
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 {
2764
+ this.session.replayTurn(
2765
+ conversationId,
2766
+ eventsByJobId.get(step.jobId) ?? [],
2767
+ step.content,
2768
+ step.messageId
2769
+ );
2707
2770
  }
2708
- if (superseded()) return;
2709
- this.settleIdle();
2771
+ if (stopReplay()) return false;
2772
+ if (completedJobs.length > 0) {
2773
+ this.emit({
2774
+ type: "versionsReady",
2775
+ conversationId,
2776
+ count: completedJobs.length
2777
+ });
2778
+ }
2779
+ } catch {
2710
2780
  }
2781
+ return !stopReplay();
2711
2782
  }
2712
2783
  // ── Internal: set active conversation ─────────────────────────
2713
2784
  setActiveConversation(id) {