@astralform/js 8.0.0 → 8.3.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
@@ -38,6 +38,7 @@ __export(index_exports, {
38
38
  VOICE_POLISH_MODES: () => VOICE_POLISH_MODES,
39
39
  generateId: () => generateId,
40
40
  isEmbeddedResource: () => isEmbeddedResource,
41
+ isToolOutputStub: () => isToolOutputStub,
41
42
  isVoiceLLMMode: () => isVoiceLLMMode,
42
43
  isVoicePolishMode: () => isVoicePolishMode,
43
44
  mapSseToChat: () => mapSseToChat,
@@ -342,6 +343,7 @@ var ChatEventType = {
342
343
  ContextWarning: "context_warning",
343
344
  MemoryRecall: "memory_recall",
344
345
  MemoryUpdate: "memory_update",
346
+ MemoryProviderError: "memory_provider_error",
345
347
  DesktopStream: "desktop_stream",
346
348
  AttachmentStaged: "attachment_staged",
347
349
  WorkspaceReady: "workspace_ready",
@@ -350,6 +352,8 @@ var ChatEventType = {
350
352
  ToolApprovalGranted: "tool_approval_granted",
351
353
  ToolPermissionDenied: "tool_permission_denied",
352
354
  ToolHarnessWarning: "tool_harness_warning",
355
+ ToolProgress: "tool_progress",
356
+ NestedLlmUsage: "nested_llm_usage",
353
357
  UserUnavailable: "user_unavailable",
354
358
  PromptSuggestion: "prompt_suggestion",
355
359
  StateChanged: "state_changed",
@@ -363,6 +367,11 @@ function isVoicePolishMode(value) {
363
367
  function isVoiceLLMMode(mode) {
364
368
  return mode !== "raw";
365
369
  }
370
+ function isToolOutputStub(value) {
371
+ if (typeof value !== "object" || value === null) return false;
372
+ const v = value;
373
+ return v.__stub === "tool_output" && typeof v.call_id === "string" && v.call_id.length > 0;
374
+ }
366
375
 
367
376
  // src/client.ts
368
377
  var DEFAULT_BASE_URL = "https://api.astralform.ai";
@@ -387,6 +396,17 @@ function validateBaseURL(url) {
387
396
  function isApiKeyConfig(config) {
388
397
  return "apiKey" in config;
389
398
  }
399
+ function toMessage(m) {
400
+ return {
401
+ id: m.id,
402
+ conversationId: m.conversation_id,
403
+ role: m.role,
404
+ content: m.content,
405
+ parentId: m.parent_id,
406
+ status: "complete",
407
+ createdAt: m.created_at
408
+ };
409
+ }
390
410
  var AstralformClient = class {
391
411
  constructor(config) {
392
412
  // --- Projects: the repositories this app user works with ---
@@ -626,6 +646,25 @@ var AstralformClient = class {
626
646
  // INSIDE the raced callback is the entire fix: `json()` outside the deadline
627
647
  // is the original bug (headers arrive, body stalls, caller hangs forever).
628
648
  // `request()` survives for `del()`, which never reads the body.
649
+ /**
650
+ * A GET whose response HEADERS the caller needs, not only its body.
651
+ *
652
+ * Paging metadata rides on headers (`X-Has-More`, `X-Next-Before`) because
653
+ * the bodies are bare lists that installed clients already parse as such.
654
+ * Reading them needs the `Response`, which `get<T>` discards.
655
+ *
656
+ * Note the shape: the body is parsed INSIDE the raced callback, exactly as
657
+ * `get`/`post`/`patch` do. See the comment above them — doing the parse
658
+ * outside the deadline is the original hang, and this method is not an
659
+ * exception to it.
660
+ */
661
+ async getWithHeaders(path) {
662
+ return this.withDeadline(async (signal) => {
663
+ const response = await this.send("GET", path, void 0, signal);
664
+ const data = await response.json();
665
+ return { data, headers: response.headers };
666
+ });
667
+ }
629
668
  async get(path) {
630
669
  return this.withDeadline(async (signal) => {
631
670
  const response = await this.send("GET", path, void 0, signal);
@@ -708,17 +747,64 @@ var AstralformClient = class {
708
747
  const raw = await this.get(`/v1/conversations?limit=${safeLimit}&offset=${safeOffset}${filter}`);
709
748
  return raw.map((c) => camelizeKeys(c));
710
749
  }
750
+ /**
751
+ * One page of a conversation's turns, newest-first window returned oldest-first.
752
+ *
753
+ * `hasMore` asks "are there OLDER turns beyond this page" — the only
754
+ * direction a restore pages in, since it starts at the tail.
755
+ *
756
+ * **Degrades on an old backend.** A server without the cursor ignores the
757
+ * unknown `limit` query param and returns the whole list, and its response
758
+ * carries neither header — which reads here as one page with nothing older,
759
+ * i.e. exactly today's behaviour. That is why absent headers must mean
760
+ * `hasMore: false` rather than an error: the fallback has to be "everything
761
+ * arrived", not "paging is broken".
762
+ */
763
+ async getConversationJobsPage(conversationId, options) {
764
+ const params = new URLSearchParams();
765
+ if (options?.limit != null) params.set("limit", String(options.limit));
766
+ if (options?.before) params.set("before", options.before);
767
+ const query = params.toString();
768
+ const { data, headers } = await this.getWithHeaders(
769
+ `/v1/conversations/${encodeURIComponent(conversationId)}/jobs${query ? `?${query}` : ""}`
770
+ );
771
+ return {
772
+ jobs: data,
773
+ hasMore: headers.get("X-Has-More") === "true",
774
+ nextBefore: headers.get("X-Next-Before")
775
+ };
776
+ }
777
+ /**
778
+ * One page of a conversation's messages, oldest-first within the page.
779
+ *
780
+ * Deliberately NOT folded into `getMessages`: that method is public surface
781
+ * whose `Message[]` return type callers depend on, and the server keeps its
782
+ * unbounded branch for exactly the same reason.
783
+ *
784
+ * Degrades like `getConversationJobsPage` — an old server ignores `limit`
785
+ * and returns the whole branch with no headers, which reads as a single
786
+ * complete page.
787
+ */
788
+ async getMessagesPage(conversationId, options) {
789
+ const params = new URLSearchParams({ limit: String(options.limit) });
790
+ if (options.beforeSeq != null) {
791
+ params.set("before_seq", String(options.beforeSeq));
792
+ }
793
+ const { data, headers } = await this.getWithHeaders(
794
+ `/v1/conversations/${encodeURIComponent(conversationId)}/messages?${params}`
795
+ );
796
+ const next = headers.get("X-Next-Before-Seq");
797
+ return {
798
+ messages: data.map(toMessage),
799
+ hasMore: headers.get("X-Has-More") === "true",
800
+ nextBeforeSeq: next == null ? null : Number(next)
801
+ };
802
+ }
711
803
  async getMessages(conversationId) {
712
- const raw = await this.get(`/v1/conversations/${encodeURIComponent(conversationId)}/messages`);
713
- return raw.map((m) => ({
714
- id: m.id,
715
- conversationId: m.conversation_id,
716
- role: m.role,
717
- content: m.content,
718
- parentId: m.parent_id,
719
- status: "complete",
720
- createdAt: m.created_at
721
- }));
804
+ const raw = await this.get(
805
+ `/v1/conversations/${encodeURIComponent(conversationId)}/messages`
806
+ );
807
+ return raw.map(toMessage);
722
808
  }
723
809
  /**
724
810
  * Replace the title the server generated from the conversation's first turn.
@@ -769,10 +855,23 @@ var AstralformClient = class {
769
855
  const raw = await this.get("/v1/skills");
770
856
  return raw.map((s) => camelizeKeys(s));
771
857
  }
772
- async getConversationEvents(conversationId, jobId) {
773
- let url = `/v1/conversations/${encodeURIComponent(conversationId)}/events`;
774
- if (jobId) url += `?job_id=${encodeURIComponent(jobId)}`;
775
- return this.get(url);
858
+ async getConversationEvents(conversationId, jobId, options) {
859
+ const params = new URLSearchParams();
860
+ if (jobId) params.set("job_id", jobId);
861
+ if (options?.toolOutputs === "stub") params.set("tool_outputs", "stub");
862
+ const query = params.toString();
863
+ return this.get(
864
+ `/v1/conversations/${encodeURIComponent(conversationId)}/events${query ? `?${query}` : ""}`
865
+ );
866
+ }
867
+ async getToolOutput(conversationId, callIdOrStub, jobId) {
868
+ const callId = typeof callIdOrStub === "string" ? callIdOrStub : callIdOrStub.call_id;
869
+ const job = typeof callIdOrStub === "string" ? jobId : callIdOrStub.job_id;
870
+ const query = job ? `?job_id=${encodeURIComponent(job)}` : "";
871
+ const res = await this.get(
872
+ `/v1/conversations/${encodeURIComponent(conversationId)}/tool-output/${encodeURIComponent(callId)}${query}`
873
+ );
874
+ return res.output;
776
875
  }
777
876
  async submitToolResult(request) {
778
877
  await this.post("/v1/tool-result", request);
@@ -1374,6 +1473,13 @@ function translateCustomEvent(name, data) {
1374
1473
  key: data.key ?? null,
1375
1474
  namespace: data.namespace ?? null
1376
1475
  };
1476
+ case "memory_provider_error":
1477
+ return {
1478
+ type: "memory_provider_error",
1479
+ provider: data.provider ?? "",
1480
+ op: data.op ?? "",
1481
+ error: data.error ?? ""
1482
+ };
1377
1483
  case "desktop_stream":
1378
1484
  return {
1379
1485
  type: "desktop_stream",
@@ -1433,6 +1539,35 @@ function translateCustomEvent(name, data) {
1433
1539
  message: data.message ?? null,
1434
1540
  details: data.details ?? null
1435
1541
  };
1542
+ case "tool_progress":
1543
+ return {
1544
+ type: "tool_progress",
1545
+ callId: data.call_id ?? "",
1546
+ stream: data.stream ?? "progress",
1547
+ chunk: data.chunk ?? "",
1548
+ toolName: data.tool ?? null,
1549
+ item: data.item ?? null,
1550
+ index: data.index ?? null,
1551
+ total: data.total ?? null,
1552
+ // Every producer sends keys beyond the named ones, and one of them
1553
+ // (`video_tool._emit_progress`) splats `**extra`, so no fixed list can
1554
+ // be complete. Before this case existed the event fell through to
1555
+ // `{type:"custom", name, data}` and consumers read the whole dict;
1556
+ // keeping `data` verbatim is what makes the typed variant an addition
1557
+ // rather than a narrowing.
1558
+ data
1559
+ };
1560
+ case "nested_llm_usage":
1561
+ return {
1562
+ type: "nested_llm_usage",
1563
+ source: data.source ?? "",
1564
+ callId: data.call_id ?? "",
1565
+ inputTokens: data.input_tokens ?? 0,
1566
+ outputTokens: data.output_tokens ?? 0,
1567
+ cachedTokens: data.cached_tokens ?? 0,
1568
+ cacheCreationTokens: data.cache_creation_tokens ?? 0,
1569
+ llmCalls: data.llm_calls ?? 0
1570
+ };
1436
1571
  case "user_unavailable":
1437
1572
  return {
1438
1573
  type: "user_unavailable",
@@ -1591,6 +1726,19 @@ var ChatSession = class {
1591
1726
  * cheaper than adding a count query to every list call.
1592
1727
  */
1593
1728
  this.hasMoreConversations = false;
1729
+ /** True when the loaded window has OLDER turns behind it — the transcript's
1730
+ * analogue of `hasMoreConversations`, and what a scroll-to-top sentinel
1731
+ * gates on. False until a windowed load says otherwise, so a consumer that
1732
+ * never asks for a window never offers to page. */
1733
+ this.hasMoreTurns = false;
1734
+ /** Cursor for the next older MESSAGE page (`before_seq`), or null. */
1735
+ this.oldestMessageSeq = null;
1736
+ /** Cursor for the next older TURN page (`before`), or null. Set by the
1737
+ * restore that loaded the newest page; consumed by `loadEarlierTurns`. */
1738
+ this.oldestTurnCursor = null;
1739
+ /** Guards against a sentinel that re-fires while a page is still in flight
1740
+ * and stacks duplicate turns — the same guard `loadMoreConversations` uses. */
1741
+ this.isLoadingEarlierTurns = false;
1594
1742
  /** True while ``loadMoreConversations`` is in flight. */
1595
1743
  this.isLoadingConversations = false;
1596
1744
  this.messages = [];
@@ -2202,12 +2350,22 @@ var ChatSession = class {
2202
2350
  * Load conversation context (messages) without replaying events.
2203
2351
  * Used before reconnectToJob — SSE replay handles event replay.
2204
2352
  */
2205
- async loadConversation(id) {
2353
+ async loadConversation(id, options) {
2206
2354
  const load = ++this.loadGeneration;
2355
+ this.hasMoreTurns = false;
2356
+ this.oldestTurnCursor = null;
2357
+ this.oldestMessageSeq = null;
2207
2358
  this.conversationId = id;
2208
2359
  if (!this.isStreaming) this.resetStreamingState();
2209
2360
  const rowsKnownAtIssue = this.serverRowsKnown;
2210
- const messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
2361
+ let page = null;
2362
+ let messages;
2363
+ if (options?.limit == null) {
2364
+ messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
2365
+ } else {
2366
+ page = await this.client.getMessagesPage(id, { limit: options.limit }).catch(() => null);
2367
+ messages = page ? page.messages : await this.storage.fetchMessages(id);
2368
+ }
2211
2369
  if (load !== this.loadGeneration) return;
2212
2370
  const pending = this.messages.filter(
2213
2371
  (m) => this.pendingUserMessages.has(m.id) && m.conversationId === id
@@ -2220,6 +2378,7 @@ var ChatSession = class {
2220
2378
  for (const m of pending) {
2221
2379
  if (!stillPending.includes(m)) this.pendingUserMessages.delete(m.id);
2222
2380
  }
2381
+ this.oldestMessageSeq = page?.nextBeforeSeq ?? null;
2223
2382
  this.setMessages(
2224
2383
  stillPending.length ? [...messages, ...stillPending] : messages
2225
2384
  );
@@ -2414,6 +2573,25 @@ var ChatSession = class {
2414
2573
  * tracking ids client-side cannot discover a row that moved into a region
2415
2574
  * already scanned.
2416
2575
  */
2576
+ /**
2577
+ * Put an older page of messages in FRONT of the loaded window.
2578
+ *
2579
+ * Pending optimistic sends stay at the tail. They are the newest thing in
2580
+ * the session by construction — a message this browser has posted and the
2581
+ * server has not confirmed — so sorting them in with a page of history
2582
+ * would move an unsent bubble into the middle of the transcript.
2583
+ *
2584
+ * Ids already present are dropped rather than duplicated: pages are cut on a
2585
+ * row sequence, but a turn landing mid-walk can still put one message in two
2586
+ * pages, and a doubled prompt is more visible than a missing one.
2587
+ */
2588
+ prependMessages(older) {
2589
+ if (!older.length) return;
2590
+ const known = new Set(this.messages.map((m) => m.id));
2591
+ const fresh = older.filter((m) => !known.has(m.id));
2592
+ if (!fresh.length) return;
2593
+ this.setMessages([...fresh, ...this.messages]);
2594
+ }
2417
2595
  async loadMoreConversations() {
2418
2596
  if (this.isLoadingConversations || !this.hasMoreConversations) return [];
2419
2597
  this.isLoadingConversations = true;
@@ -2556,8 +2734,23 @@ function planRestore(args) {
2556
2734
  }
2557
2735
 
2558
2736
  // src/stream-manager.ts
2559
- var StreamManager = class {
2737
+ var RESTORE_TURN_PAGE_SIZE = 10;
2738
+ var RESTORE_MESSAGE_PAGE_SIZE = 40;
2739
+ var StreamManager = class _StreamManager {
2560
2740
  constructor(session) {
2741
+ /** The oldest prompt drawn so far — where a prepended page's span ends. */
2742
+ this.oldestDrawnMessageId = null;
2743
+ /**
2744
+ * Whether restore asks for tool outputs inline or as fetch handles.
2745
+ *
2746
+ * ONE setting, applied to both event waves — the newest page and every
2747
+ * `loadEarlierTurns` page. A consumer that got stubs only on scroll-up would
2748
+ * be worse off than one that got them nowhere: the pill would resolve itself
2749
+ * in the visible tail and need a fetch above the fold, for no stated reason.
2750
+ *
2751
+ * Defaults to inline, so this is inert until a consumer opts in.
2752
+ */
2753
+ this.toolOutputs = "inline";
2561
2754
  this._state = "idle";
2562
2755
  this._activeConversationId = null;
2563
2756
  this._backgroundJobs = /* @__PURE__ */ new Map();
@@ -2603,6 +2796,17 @@ var StreamManager = class {
2603
2796
  this.handlers = this.handlers.filter((h) => h !== handler);
2604
2797
  };
2605
2798
  }
2799
+ /**
2800
+ * Ask restore for stubbed tool outputs, resolved on demand.
2801
+ *
2802
+ * Only worth turning on by a consumer that can actually resolve a stub —
2803
+ * see `isToolOutputStub` and `client.getToolOutput`. One that cannot does
2804
+ * not render an empty result: it renders the stub OBJECT where the output
2805
+ * belongs, because that is what arrives in `final.output`.
2806
+ */
2807
+ setToolOutputMode(mode) {
2808
+ this.toolOutputs = mode;
2809
+ }
2606
2810
  emit(event) {
2607
2811
  for (const handler of this.handlers) {
2608
2812
  try {
@@ -2945,10 +3149,11 @@ var StreamManager = class {
2945
3149
  * probe and the message list while ``replayHistory``, which consumes it,
2946
3150
  * keeps owning the shape it reads.
2947
3151
  */
2948
- jobList(conversationId) {
2949
- return this.session.client.get(
2950
- `/v1/conversations/${encodeURIComponent(conversationId)}/jobs`
2951
- );
3152
+ jobList(conversationId, before) {
3153
+ return this.session.client.getConversationJobsPage(conversationId, {
3154
+ limit: RESTORE_TURN_PAGE_SIZE,
3155
+ ...before ? { before } : {}
3156
+ });
2952
3157
  }
2953
3158
  async restore(conversationId, gen) {
2954
3159
  const superseded = () => gen !== this.generation;
@@ -2958,14 +3163,20 @@ var StreamManager = class {
2958
3163
  if (announcedRestoring) this.setState("restoring");
2959
3164
  if (superseded()) return;
2960
3165
  const probeRequest = this.session.client.getActiveJob(conversationId).catch(() => null);
2961
- const loadRequest = this.session.loadConversation(conversationId);
3166
+ const loadRequest = this.session.loadConversation(
3167
+ conversationId,
3168
+ announcedRestoring ? { limit: RESTORE_MESSAGE_PAGE_SIZE } : void 0
3169
+ );
2962
3170
  const jobsRequest = announcedRestoring ? this.jobList(conversationId) : null;
2963
3171
  void jobsRequest?.catch(() => {
2964
3172
  });
2965
3173
  const [probe] = await Promise.all([probeRequest, loadRequest]);
2966
3174
  const activeJobId = probe?.jobId ?? null;
2967
3175
  if (superseded()) return;
2968
- if (announcedRestoring && this.viewTakenOverByLiveTurn()) return;
3176
+ if (announcedRestoring && this.viewTakenOverByLiveTurn()) {
3177
+ this.reloadUnwindowed(conversationId);
3178
+ return;
3179
+ }
2969
3180
  if (jobsRequest && !await this.replayHistory(
2970
3181
  conversationId,
2971
3182
  gen,
@@ -3017,6 +3228,26 @@ var StreamManager = class {
3017
3228
  turnStarted(since) {
3018
3229
  return this.turnCounter !== since;
3019
3230
  }
3231
+ /**
3232
+ * Re-issue the message load UNWINDOWED after a restore stopped before it
3233
+ * could write a turn cursor.
3234
+ *
3235
+ * The window is decided synchronously at restore entry, but "this restore
3236
+ * will page" is only settled once `replayHistory` clears its first abort
3237
+ * check. A send landing in between — nothing gates one during a restore —
3238
+ * stops the replay with the window already installed and the cursor never
3239
+ * written: a transcript truncated to one page that no pager can extend,
3240
+ * which is worse than the unbounded load this path did before windowing.
3241
+ * Reloading whole is that behaviour restored. Fire-and-forget: the load
3242
+ * token still makes a later switch win, and the pending-send reconciliation
3243
+ * inside `loadConversation` is what keeps the interrupting send's prompt.
3244
+ * Only called when the conversation has NOT moved — a superseding switch
3245
+ * announces and loads its own.
3246
+ */
3247
+ reloadUnwindowed(conversationId) {
3248
+ void this.session.loadConversation(conversationId).catch(() => {
3249
+ });
3250
+ }
3020
3251
  /**
3021
3252
  * Replay a conversation's persisted history into the consumer's block view.
3022
3253
  *
@@ -3037,10 +3268,119 @@ var StreamManager = class {
3037
3268
  * what keeps a failed job list non-blocking exactly as it was when the fetch
3038
3269
  * lived here.
3039
3270
  */
3271
+ /**
3272
+ * Fetch and replay the next OLDER page of turns.
3273
+ *
3274
+ * The scroll-up half of tail-first restore: `restore` renders the newest
3275
+ * page and clears `restoring`, and this brings back what precedes it, on
3276
+ * demand. Full fidelity — the same per-job events wave the newest page uses,
3277
+ * just later — so a turn paged in here is byte-identical to the same turn
3278
+ * rendered live. That is the whole reason this defers the fetch rather than
3279
+ * rebuilding older turns from the message list, which persists no thinking
3280
+ * blocks and no custom events.
3281
+ *
3282
+ * Resolves to the number of turns emitted; 0 when there is nothing older,
3283
+ * a page is already in flight, or the view was taken over mid-fetch.
3284
+ */
3285
+ /** User prompts from a slice of the message window, in plan input shape. */
3286
+ static userMessagesOf(messages) {
3287
+ return messages.filter((m) => m.role === "user").map((m) => ({ id: m.id, content: m.content }));
3288
+ }
3289
+ async loadEarlierTurns(conversationId) {
3290
+ const session = this.session;
3291
+ if (session.isLoadingEarlierTurns || !session.hasMoreTurns || !session.oldestTurnCursor) {
3292
+ return 0;
3293
+ }
3294
+ const gen = this.generation;
3295
+ const turn = this.turnCounter;
3296
+ const cursor = session.oldestTurnCursor;
3297
+ const stop = () => gen !== this.generation || conversationId !== session.conversationId || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3298
+ session.isLoadingEarlierTurns = true;
3299
+ try {
3300
+ const page = await this.jobList(conversationId, cursor);
3301
+ if (stop()) return 0;
3302
+ const messages = session.oldestMessageSeq ? await session.client.getMessagesPage(conversationId, {
3303
+ limit: RESTORE_MESSAGE_PAGE_SIZE,
3304
+ beforeSeq: session.oldestMessageSeq
3305
+ }).catch(() => null) : null;
3306
+ if (stop()) return 0;
3307
+ const boundary = this.oldestDrawnMessageId;
3308
+ const eventLists = await Promise.all(
3309
+ page.jobs.map(
3310
+ (job) => session.client.getConversationEvents(conversationId, job.job_id, {
3311
+ toolOutputs: this.toolOutputs
3312
+ }).catch(() => [])
3313
+ )
3314
+ );
3315
+ if (stop()) return 0;
3316
+ if (messages) session.prependMessages(messages.messages);
3317
+ const plan = planRestore({
3318
+ completedJobs: page.jobs.map((j) => ({
3319
+ job_id: j.job_id,
3320
+ message_id: j.message_id
3321
+ })),
3322
+ // Same array feeds the walk and the claim set, for the reason the
3323
+ // newest-page path documents: derived apart, they drift, and the
3324
+ // prompts of any turn dropped from one surface as steers in the other.
3325
+ claimedMessageIds: page.jobs.map((j) => j.message_id),
3326
+ userMessages: (() => {
3327
+ const all = session.messages;
3328
+ if (!boundary) return _StreamManager.userMessagesOf(all);
3329
+ const cut = all.findIndex((m) => m.id === boundary);
3330
+ return cut < 0 ? [] : _StreamManager.userMessagesOf(all.slice(0, cut));
3331
+ })()
3332
+ });
3333
+ this.emit({
3334
+ type: "historyPageStart",
3335
+ conversationId,
3336
+ position: "prepend"
3337
+ });
3338
+ let emitted = 0;
3339
+ const byJob = new Map(
3340
+ page.jobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
3341
+ );
3342
+ let completed = true;
3343
+ for (const step of plan) {
3344
+ if (stop()) {
3345
+ completed = false;
3346
+ break;
3347
+ }
3348
+ if (step.kind === "steer") {
3349
+ session.replayTurn(conversationId, [], step.content, step.messageId, true);
3350
+ } else {
3351
+ session.replayTurn(
3352
+ conversationId,
3353
+ byJob.get(step.jobId) ?? [],
3354
+ step.content,
3355
+ step.messageId
3356
+ );
3357
+ }
3358
+ emitted++;
3359
+ }
3360
+ if (completed) {
3361
+ session.hasMoreTurns = page.hasMore;
3362
+ session.oldestTurnCursor = page.nextBefore;
3363
+ if (messages) session.oldestMessageSeq = messages.nextBeforeSeq;
3364
+ this.oldestDrawnMessageId = plan[0]?.messageId ?? boundary;
3365
+ }
3366
+ this.emit({
3367
+ type: "historyPageEnd",
3368
+ conversationId,
3369
+ position: "prepend",
3370
+ hasMore: completed ? page.hasMore : true,
3371
+ complete: completed
3372
+ });
3373
+ return emitted;
3374
+ } finally {
3375
+ session.isLoadingEarlierTurns = false;
3376
+ }
3377
+ }
3040
3378
  async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest) {
3041
3379
  const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3380
+ let complete = false;
3042
3381
  try {
3043
- const jobs = await jobsRequest;
3382
+ const page = await jobsRequest;
3383
+ const jobs = page.jobs;
3044
3384
  if (stopReplay()) return false;
3045
3385
  const replayableJobs = jobs.filter(
3046
3386
  (j) => j.job_id !== activeJobId
@@ -3084,7 +3424,9 @@ var StreamManager = class {
3084
3424
  });
3085
3425
  const eventLists = await Promise.all(
3086
3426
  replayableJobs.map(
3087
- (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
3427
+ (job) => this.session.client.getConversationEvents(conversationId, job.job_id, {
3428
+ toolOutputs: this.toolOutputs
3429
+ }).catch(() => [])
3088
3430
  )
3089
3431
  );
3090
3432
  if (stopReplay()) return false;
@@ -3110,6 +3452,10 @@ var StreamManager = class {
3110
3452
  step.messageId
3111
3453
  );
3112
3454
  }
3455
+ this.session.hasMoreTurns = page.hasMore;
3456
+ this.session.oldestTurnCursor = page.nextBefore;
3457
+ this.oldestDrawnMessageId = plan[0]?.messageId ?? null;
3458
+ complete = true;
3113
3459
  if (stopReplay()) return false;
3114
3460
  this.emit({ type: "restoreSettled", conversationId });
3115
3461
  const versionCount = replayableJobs.filter(
@@ -3123,6 +3469,10 @@ var StreamManager = class {
3123
3469
  });
3124
3470
  }
3125
3471
  } catch {
3472
+ } finally {
3473
+ if (!complete && gen === this.generation) {
3474
+ this.reloadUnwindowed(conversationId);
3475
+ }
3126
3476
  }
3127
3477
  return !stopReplay();
3128
3478
  }
@@ -3131,6 +3481,10 @@ var StreamManager = class {
3131
3481
  this._activeConversationId = id;
3132
3482
  this.session.invalidateLoadsInFlight();
3133
3483
  const claimed = ++this.generation;
3484
+ this.session.hasMoreTurns = false;
3485
+ this.session.oldestTurnCursor = null;
3486
+ this.session.oldestMessageSeq = null;
3487
+ this.oldestDrawnMessageId = null;
3134
3488
  this.emit({ type: "conversationChanged", conversationId: id });
3135
3489
  return claimed;
3136
3490
  }
@@ -3221,6 +3575,7 @@ function parseEmbeddedResource(value) {
3221
3575
  VOICE_POLISH_MODES,
3222
3576
  generateId,
3223
3577
  isEmbeddedResource,
3578
+ isToolOutputStub,
3224
3579
  isVoiceLLMMode,
3225
3580
  isVoicePolishMode,
3226
3581
  mapSseToChat,