@astralform/js 7.5.1 → 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.js CHANGED
@@ -291,6 +291,7 @@ var ChatEventType = {
291
291
  ContextWarning: "context_warning",
292
292
  MemoryRecall: "memory_recall",
293
293
  MemoryUpdate: "memory_update",
294
+ MemoryProviderError: "memory_provider_error",
294
295
  DesktopStream: "desktop_stream",
295
296
  AttachmentStaged: "attachment_staged",
296
297
  WorkspaceReady: "workspace_ready",
@@ -299,6 +300,8 @@ var ChatEventType = {
299
300
  ToolApprovalGranted: "tool_approval_granted",
300
301
  ToolPermissionDenied: "tool_permission_denied",
301
302
  ToolHarnessWarning: "tool_harness_warning",
303
+ ToolProgress: "tool_progress",
304
+ NestedLlmUsage: "nested_llm_usage",
302
305
  UserUnavailable: "user_unavailable",
303
306
  PromptSuggestion: "prompt_suggestion",
304
307
  StateChanged: "state_changed",
@@ -312,6 +315,11 @@ function isVoicePolishMode(value) {
312
315
  function isVoiceLLMMode(mode) {
313
316
  return mode !== "raw";
314
317
  }
318
+ function isToolOutputStub(value) {
319
+ if (typeof value !== "object" || value === null) return false;
320
+ const v = value;
321
+ return v.__stub === "tool_output" && typeof v.call_id === "string" && v.call_id.length > 0;
322
+ }
315
323
 
316
324
  // src/client.ts
317
325
  var DEFAULT_BASE_URL = "https://api.astralform.ai";
@@ -336,6 +344,17 @@ function validateBaseURL(url) {
336
344
  function isApiKeyConfig(config) {
337
345
  return "apiKey" in config;
338
346
  }
347
+ function toMessage(m) {
348
+ return {
349
+ id: m.id,
350
+ conversationId: m.conversation_id,
351
+ role: m.role,
352
+ content: m.content,
353
+ parentId: m.parent_id,
354
+ status: "complete",
355
+ createdAt: m.created_at
356
+ };
357
+ }
339
358
  var AstralformClient = class {
340
359
  constructor(config) {
341
360
  // --- Projects: the repositories this app user works with ---
@@ -575,6 +594,25 @@ var AstralformClient = class {
575
594
  // INSIDE the raced callback is the entire fix: `json()` outside the deadline
576
595
  // is the original bug (headers arrive, body stalls, caller hangs forever).
577
596
  // `request()` survives for `del()`, which never reads the body.
597
+ /**
598
+ * A GET whose response HEADERS the caller needs, not only its body.
599
+ *
600
+ * Paging metadata rides on headers (`X-Has-More`, `X-Next-Before`) because
601
+ * the bodies are bare lists that installed clients already parse as such.
602
+ * Reading them needs the `Response`, which `get<T>` discards.
603
+ *
604
+ * Note the shape: the body is parsed INSIDE the raced callback, exactly as
605
+ * `get`/`post`/`patch` do. See the comment above them — doing the parse
606
+ * outside the deadline is the original hang, and this method is not an
607
+ * exception to it.
608
+ */
609
+ async getWithHeaders(path) {
610
+ return this.withDeadline(async (signal) => {
611
+ const response = await this.send("GET", path, void 0, signal);
612
+ const data = await response.json();
613
+ return { data, headers: response.headers };
614
+ });
615
+ }
578
616
  async get(path) {
579
617
  return this.withDeadline(async (signal) => {
580
618
  const response = await this.send("GET", path, void 0, signal);
@@ -657,17 +695,64 @@ var AstralformClient = class {
657
695
  const raw = await this.get(`/v1/conversations?limit=${safeLimit}&offset=${safeOffset}${filter}`);
658
696
  return raw.map((c) => camelizeKeys(c));
659
697
  }
698
+ /**
699
+ * One page of a conversation's turns, newest-first window returned oldest-first.
700
+ *
701
+ * `hasMore` asks "are there OLDER turns beyond this page" — the only
702
+ * direction a restore pages in, since it starts at the tail.
703
+ *
704
+ * **Degrades on an old backend.** A server without the cursor ignores the
705
+ * unknown `limit` query param and returns the whole list, and its response
706
+ * carries neither header — which reads here as one page with nothing older,
707
+ * i.e. exactly today's behaviour. That is why absent headers must mean
708
+ * `hasMore: false` rather than an error: the fallback has to be "everything
709
+ * arrived", not "paging is broken".
710
+ */
711
+ async getConversationJobsPage(conversationId, options) {
712
+ const params = new URLSearchParams();
713
+ if (options?.limit != null) params.set("limit", String(options.limit));
714
+ if (options?.before) params.set("before", options.before);
715
+ const query = params.toString();
716
+ const { data, headers } = await this.getWithHeaders(
717
+ `/v1/conversations/${encodeURIComponent(conversationId)}/jobs${query ? `?${query}` : ""}`
718
+ );
719
+ return {
720
+ jobs: data,
721
+ hasMore: headers.get("X-Has-More") === "true",
722
+ nextBefore: headers.get("X-Next-Before")
723
+ };
724
+ }
725
+ /**
726
+ * One page of a conversation's messages, oldest-first within the page.
727
+ *
728
+ * Deliberately NOT folded into `getMessages`: that method is public surface
729
+ * whose `Message[]` return type callers depend on, and the server keeps its
730
+ * unbounded branch for exactly the same reason.
731
+ *
732
+ * Degrades like `getConversationJobsPage` — an old server ignores `limit`
733
+ * and returns the whole branch with no headers, which reads as a single
734
+ * complete page.
735
+ */
736
+ async getMessagesPage(conversationId, options) {
737
+ const params = new URLSearchParams({ limit: String(options.limit) });
738
+ if (options.beforeSeq != null) {
739
+ params.set("before_seq", String(options.beforeSeq));
740
+ }
741
+ const { data, headers } = await this.getWithHeaders(
742
+ `/v1/conversations/${encodeURIComponent(conversationId)}/messages?${params}`
743
+ );
744
+ const next = headers.get("X-Next-Before-Seq");
745
+ return {
746
+ messages: data.map(toMessage),
747
+ hasMore: headers.get("X-Has-More") === "true",
748
+ nextBeforeSeq: next == null ? null : Number(next)
749
+ };
750
+ }
660
751
  async getMessages(conversationId) {
661
- const raw = await this.get(`/v1/conversations/${encodeURIComponent(conversationId)}/messages`);
662
- return raw.map((m) => ({
663
- id: m.id,
664
- conversationId: m.conversation_id,
665
- role: m.role,
666
- content: m.content,
667
- parentId: m.parent_id,
668
- status: "complete",
669
- createdAt: m.created_at
670
- }));
752
+ const raw = await this.get(
753
+ `/v1/conversations/${encodeURIComponent(conversationId)}/messages`
754
+ );
755
+ return raw.map(toMessage);
671
756
  }
672
757
  /**
673
758
  * Replace the title the server generated from the conversation's first turn.
@@ -718,10 +803,23 @@ var AstralformClient = class {
718
803
  const raw = await this.get("/v1/skills");
719
804
  return raw.map((s) => camelizeKeys(s));
720
805
  }
721
- async getConversationEvents(conversationId, jobId) {
722
- let url = `/v1/conversations/${encodeURIComponent(conversationId)}/events`;
723
- if (jobId) url += `?job_id=${encodeURIComponent(jobId)}`;
724
- return this.get(url);
806
+ async getConversationEvents(conversationId, jobId, options) {
807
+ const params = new URLSearchParams();
808
+ if (jobId) params.set("job_id", jobId);
809
+ if (options?.toolOutputs === "stub") params.set("tool_outputs", "stub");
810
+ const query = params.toString();
811
+ return this.get(
812
+ `/v1/conversations/${encodeURIComponent(conversationId)}/events${query ? `?${query}` : ""}`
813
+ );
814
+ }
815
+ async getToolOutput(conversationId, callIdOrStub, jobId) {
816
+ const callId = typeof callIdOrStub === "string" ? callIdOrStub : callIdOrStub.call_id;
817
+ const job = typeof callIdOrStub === "string" ? jobId : callIdOrStub.job_id;
818
+ const query = job ? `?job_id=${encodeURIComponent(job)}` : "";
819
+ const res = await this.get(
820
+ `/v1/conversations/${encodeURIComponent(conversationId)}/tool-output/${encodeURIComponent(callId)}${query}`
821
+ );
822
+ return res.output;
725
823
  }
726
824
  async submitToolResult(request) {
727
825
  await this.post("/v1/tool-result", request);
@@ -1323,6 +1421,13 @@ function translateCustomEvent(name, data) {
1323
1421
  key: data.key ?? null,
1324
1422
  namespace: data.namespace ?? null
1325
1423
  };
1424
+ case "memory_provider_error":
1425
+ return {
1426
+ type: "memory_provider_error",
1427
+ provider: data.provider ?? "",
1428
+ op: data.op ?? "",
1429
+ error: data.error ?? ""
1430
+ };
1326
1431
  case "desktop_stream":
1327
1432
  return {
1328
1433
  type: "desktop_stream",
@@ -1382,6 +1487,35 @@ function translateCustomEvent(name, data) {
1382
1487
  message: data.message ?? null,
1383
1488
  details: data.details ?? null
1384
1489
  };
1490
+ case "tool_progress":
1491
+ return {
1492
+ type: "tool_progress",
1493
+ callId: data.call_id ?? "",
1494
+ stream: data.stream ?? "progress",
1495
+ chunk: data.chunk ?? "",
1496
+ toolName: data.tool ?? null,
1497
+ item: data.item ?? null,
1498
+ index: data.index ?? null,
1499
+ total: data.total ?? null,
1500
+ // Every producer sends keys beyond the named ones, and one of them
1501
+ // (`video_tool._emit_progress`) splats `**extra`, so no fixed list can
1502
+ // be complete. Before this case existed the event fell through to
1503
+ // `{type:"custom", name, data}` and consumers read the whole dict;
1504
+ // keeping `data` verbatim is what makes the typed variant an addition
1505
+ // rather than a narrowing.
1506
+ data
1507
+ };
1508
+ case "nested_llm_usage":
1509
+ return {
1510
+ type: "nested_llm_usage",
1511
+ source: data.source ?? "",
1512
+ callId: data.call_id ?? "",
1513
+ inputTokens: data.input_tokens ?? 0,
1514
+ outputTokens: data.output_tokens ?? 0,
1515
+ cachedTokens: data.cached_tokens ?? 0,
1516
+ cacheCreationTokens: data.cache_creation_tokens ?? 0,
1517
+ llmCalls: data.llm_calls ?? 0
1518
+ };
1385
1519
  case "user_unavailable":
1386
1520
  return {
1387
1521
  type: "user_unavailable",
@@ -1540,6 +1674,19 @@ var ChatSession = class {
1540
1674
  * cheaper than adding a count query to every list call.
1541
1675
  */
1542
1676
  this.hasMoreConversations = false;
1677
+ /** True when the loaded window has OLDER turns behind it — the transcript's
1678
+ * analogue of `hasMoreConversations`, and what a scroll-to-top sentinel
1679
+ * gates on. False until a windowed load says otherwise, so a consumer that
1680
+ * never asks for a window never offers to page. */
1681
+ this.hasMoreTurns = false;
1682
+ /** Cursor for the next older MESSAGE page (`before_seq`), or null. */
1683
+ this.oldestMessageSeq = null;
1684
+ /** Cursor for the next older TURN page (`before`), or null. Set by the
1685
+ * restore that loaded the newest page; consumed by `loadEarlierTurns`. */
1686
+ this.oldestTurnCursor = null;
1687
+ /** Guards against a sentinel that re-fires while a page is still in flight
1688
+ * and stacks duplicate turns — the same guard `loadMoreConversations` uses. */
1689
+ this.isLoadingEarlierTurns = false;
1543
1690
  /** True while ``loadMoreConversations`` is in flight. */
1544
1691
  this.isLoadingConversations = false;
1545
1692
  this.messages = [];
@@ -2151,12 +2298,22 @@ var ChatSession = class {
2151
2298
  * Load conversation context (messages) without replaying events.
2152
2299
  * Used before reconnectToJob — SSE replay handles event replay.
2153
2300
  */
2154
- async loadConversation(id) {
2301
+ async loadConversation(id, options) {
2155
2302
  const load = ++this.loadGeneration;
2303
+ this.hasMoreTurns = false;
2304
+ this.oldestTurnCursor = null;
2305
+ this.oldestMessageSeq = null;
2156
2306
  this.conversationId = id;
2157
2307
  if (!this.isStreaming) this.resetStreamingState();
2158
2308
  const rowsKnownAtIssue = this.serverRowsKnown;
2159
- const messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
2309
+ let page = null;
2310
+ let messages;
2311
+ if (options?.limit == null) {
2312
+ messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
2313
+ } else {
2314
+ page = await this.client.getMessagesPage(id, { limit: options.limit }).catch(() => null);
2315
+ messages = page ? page.messages : await this.storage.fetchMessages(id);
2316
+ }
2160
2317
  if (load !== this.loadGeneration) return;
2161
2318
  const pending = this.messages.filter(
2162
2319
  (m) => this.pendingUserMessages.has(m.id) && m.conversationId === id
@@ -2169,6 +2326,7 @@ var ChatSession = class {
2169
2326
  for (const m of pending) {
2170
2327
  if (!stillPending.includes(m)) this.pendingUserMessages.delete(m.id);
2171
2328
  }
2329
+ this.oldestMessageSeq = page?.nextBeforeSeq ?? null;
2172
2330
  this.setMessages(
2173
2331
  stillPending.length ? [...messages, ...stillPending] : messages
2174
2332
  );
@@ -2363,6 +2521,25 @@ var ChatSession = class {
2363
2521
  * tracking ids client-side cannot discover a row that moved into a region
2364
2522
  * already scanned.
2365
2523
  */
2524
+ /**
2525
+ * Put an older page of messages in FRONT of the loaded window.
2526
+ *
2527
+ * Pending optimistic sends stay at the tail. They are the newest thing in
2528
+ * the session by construction — a message this browser has posted and the
2529
+ * server has not confirmed — so sorting them in with a page of history
2530
+ * would move an unsent bubble into the middle of the transcript.
2531
+ *
2532
+ * Ids already present are dropped rather than duplicated: pages are cut on a
2533
+ * row sequence, but a turn landing mid-walk can still put one message in two
2534
+ * pages, and a doubled prompt is more visible than a missing one.
2535
+ */
2536
+ prependMessages(older) {
2537
+ if (!older.length) return;
2538
+ const known = new Set(this.messages.map((m) => m.id));
2539
+ const fresh = older.filter((m) => !known.has(m.id));
2540
+ if (!fresh.length) return;
2541
+ this.setMessages([...fresh, ...this.messages]);
2542
+ }
2366
2543
  async loadMoreConversations() {
2367
2544
  if (this.isLoadingConversations || !this.hasMoreConversations) return [];
2368
2545
  this.isLoadingConversations = true;
@@ -2505,8 +2682,23 @@ function planRestore(args) {
2505
2682
  }
2506
2683
 
2507
2684
  // src/stream-manager.ts
2508
- var StreamManager = class {
2685
+ var RESTORE_TURN_PAGE_SIZE = 10;
2686
+ var RESTORE_MESSAGE_PAGE_SIZE = 40;
2687
+ var StreamManager = class _StreamManager {
2509
2688
  constructor(session) {
2689
+ /** The oldest prompt drawn so far — where a prepended page's span ends. */
2690
+ this.oldestDrawnMessageId = null;
2691
+ /**
2692
+ * Whether restore asks for tool outputs inline or as fetch handles.
2693
+ *
2694
+ * ONE setting, applied to both event waves — the newest page and every
2695
+ * `loadEarlierTurns` page. A consumer that got stubs only on scroll-up would
2696
+ * be worse off than one that got them nowhere: the pill would resolve itself
2697
+ * in the visible tail and need a fetch above the fold, for no stated reason.
2698
+ *
2699
+ * Defaults to inline, so this is inert until a consumer opts in.
2700
+ */
2701
+ this.toolOutputs = "inline";
2510
2702
  this._state = "idle";
2511
2703
  this._activeConversationId = null;
2512
2704
  this._backgroundJobs = /* @__PURE__ */ new Map();
@@ -2552,6 +2744,17 @@ var StreamManager = class {
2552
2744
  this.handlers = this.handlers.filter((h) => h !== handler);
2553
2745
  };
2554
2746
  }
2747
+ /**
2748
+ * Ask restore for stubbed tool outputs, resolved on demand.
2749
+ *
2750
+ * Only worth turning on by a consumer that can actually resolve a stub —
2751
+ * see `isToolOutputStub` and `client.getToolOutput`. One that cannot does
2752
+ * not render an empty result: it renders the stub OBJECT where the output
2753
+ * belongs, because that is what arrives in `final.output`.
2754
+ */
2755
+ setToolOutputMode(mode) {
2756
+ this.toolOutputs = mode;
2757
+ }
2555
2758
  emit(event) {
2556
2759
  for (const handler of this.handlers) {
2557
2760
  try {
@@ -2894,10 +3097,11 @@ var StreamManager = class {
2894
3097
  * probe and the message list while ``replayHistory``, which consumes it,
2895
3098
  * keeps owning the shape it reads.
2896
3099
  */
2897
- jobList(conversationId) {
2898
- return this.session.client.get(
2899
- `/v1/conversations/${encodeURIComponent(conversationId)}/jobs`
2900
- );
3100
+ jobList(conversationId, before) {
3101
+ return this.session.client.getConversationJobsPage(conversationId, {
3102
+ limit: RESTORE_TURN_PAGE_SIZE,
3103
+ ...before ? { before } : {}
3104
+ });
2901
3105
  }
2902
3106
  async restore(conversationId, gen) {
2903
3107
  const superseded = () => gen !== this.generation;
@@ -2907,14 +3111,20 @@ var StreamManager = class {
2907
3111
  if (announcedRestoring) this.setState("restoring");
2908
3112
  if (superseded()) return;
2909
3113
  const probeRequest = this.session.client.getActiveJob(conversationId).catch(() => null);
2910
- const loadRequest = this.session.loadConversation(conversationId);
3114
+ const loadRequest = this.session.loadConversation(
3115
+ conversationId,
3116
+ announcedRestoring ? { limit: RESTORE_MESSAGE_PAGE_SIZE } : void 0
3117
+ );
2911
3118
  const jobsRequest = announcedRestoring ? this.jobList(conversationId) : null;
2912
3119
  void jobsRequest?.catch(() => {
2913
3120
  });
2914
3121
  const [probe] = await Promise.all([probeRequest, loadRequest]);
2915
3122
  const activeJobId = probe?.jobId ?? null;
2916
3123
  if (superseded()) return;
2917
- if (announcedRestoring && this.viewTakenOverByLiveTurn()) return;
3124
+ if (announcedRestoring && this.viewTakenOverByLiveTurn()) {
3125
+ this.reloadUnwindowed(conversationId);
3126
+ return;
3127
+ }
2918
3128
  if (jobsRequest && !await this.replayHistory(
2919
3129
  conversationId,
2920
3130
  gen,
@@ -2966,6 +3176,26 @@ var StreamManager = class {
2966
3176
  turnStarted(since) {
2967
3177
  return this.turnCounter !== since;
2968
3178
  }
3179
+ /**
3180
+ * Re-issue the message load UNWINDOWED after a restore stopped before it
3181
+ * could write a turn cursor.
3182
+ *
3183
+ * The window is decided synchronously at restore entry, but "this restore
3184
+ * will page" is only settled once `replayHistory` clears its first abort
3185
+ * check. A send landing in between — nothing gates one during a restore —
3186
+ * stops the replay with the window already installed and the cursor never
3187
+ * written: a transcript truncated to one page that no pager can extend,
3188
+ * which is worse than the unbounded load this path did before windowing.
3189
+ * Reloading whole is that behaviour restored. Fire-and-forget: the load
3190
+ * token still makes a later switch win, and the pending-send reconciliation
3191
+ * inside `loadConversation` is what keeps the interrupting send's prompt.
3192
+ * Only called when the conversation has NOT moved — a superseding switch
3193
+ * announces and loads its own.
3194
+ */
3195
+ reloadUnwindowed(conversationId) {
3196
+ void this.session.loadConversation(conversationId).catch(() => {
3197
+ });
3198
+ }
2969
3199
  /**
2970
3200
  * Replay a conversation's persisted history into the consumer's block view.
2971
3201
  *
@@ -2986,10 +3216,119 @@ var StreamManager = class {
2986
3216
  * what keeps a failed job list non-blocking exactly as it was when the fetch
2987
3217
  * lived here.
2988
3218
  */
3219
+ /**
3220
+ * Fetch and replay the next OLDER page of turns.
3221
+ *
3222
+ * The scroll-up half of tail-first restore: `restore` renders the newest
3223
+ * page and clears `restoring`, and this brings back what precedes it, on
3224
+ * demand. Full fidelity — the same per-job events wave the newest page uses,
3225
+ * just later — so a turn paged in here is byte-identical to the same turn
3226
+ * rendered live. That is the whole reason this defers the fetch rather than
3227
+ * rebuilding older turns from the message list, which persists no thinking
3228
+ * blocks and no custom events.
3229
+ *
3230
+ * Resolves to the number of turns emitted; 0 when there is nothing older,
3231
+ * a page is already in flight, or the view was taken over mid-fetch.
3232
+ */
3233
+ /** User prompts from a slice of the message window, in plan input shape. */
3234
+ static userMessagesOf(messages) {
3235
+ return messages.filter((m) => m.role === "user").map((m) => ({ id: m.id, content: m.content }));
3236
+ }
3237
+ async loadEarlierTurns(conversationId) {
3238
+ const session = this.session;
3239
+ if (session.isLoadingEarlierTurns || !session.hasMoreTurns || !session.oldestTurnCursor) {
3240
+ return 0;
3241
+ }
3242
+ const gen = this.generation;
3243
+ const turn = this.turnCounter;
3244
+ const cursor = session.oldestTurnCursor;
3245
+ const stop = () => gen !== this.generation || conversationId !== session.conversationId || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3246
+ session.isLoadingEarlierTurns = true;
3247
+ try {
3248
+ const page = await this.jobList(conversationId, cursor);
3249
+ if (stop()) return 0;
3250
+ const messages = session.oldestMessageSeq ? await session.client.getMessagesPage(conversationId, {
3251
+ limit: RESTORE_MESSAGE_PAGE_SIZE,
3252
+ beforeSeq: session.oldestMessageSeq
3253
+ }).catch(() => null) : null;
3254
+ if (stop()) return 0;
3255
+ const boundary = this.oldestDrawnMessageId;
3256
+ const eventLists = await Promise.all(
3257
+ page.jobs.map(
3258
+ (job) => session.client.getConversationEvents(conversationId, job.job_id, {
3259
+ toolOutputs: this.toolOutputs
3260
+ }).catch(() => [])
3261
+ )
3262
+ );
3263
+ if (stop()) return 0;
3264
+ if (messages) session.prependMessages(messages.messages);
3265
+ const plan = planRestore({
3266
+ completedJobs: page.jobs.map((j) => ({
3267
+ job_id: j.job_id,
3268
+ message_id: j.message_id
3269
+ })),
3270
+ // Same array feeds the walk and the claim set, for the reason the
3271
+ // newest-page path documents: derived apart, they drift, and the
3272
+ // prompts of any turn dropped from one surface as steers in the other.
3273
+ claimedMessageIds: page.jobs.map((j) => j.message_id),
3274
+ userMessages: (() => {
3275
+ const all = session.messages;
3276
+ if (!boundary) return _StreamManager.userMessagesOf(all);
3277
+ const cut = all.findIndex((m) => m.id === boundary);
3278
+ return cut < 0 ? [] : _StreamManager.userMessagesOf(all.slice(0, cut));
3279
+ })()
3280
+ });
3281
+ this.emit({
3282
+ type: "historyPageStart",
3283
+ conversationId,
3284
+ position: "prepend"
3285
+ });
3286
+ let emitted = 0;
3287
+ const byJob = new Map(
3288
+ page.jobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
3289
+ );
3290
+ let completed = true;
3291
+ for (const step of plan) {
3292
+ if (stop()) {
3293
+ completed = false;
3294
+ break;
3295
+ }
3296
+ if (step.kind === "steer") {
3297
+ session.replayTurn(conversationId, [], step.content, step.messageId, true);
3298
+ } else {
3299
+ session.replayTurn(
3300
+ conversationId,
3301
+ byJob.get(step.jobId) ?? [],
3302
+ step.content,
3303
+ step.messageId
3304
+ );
3305
+ }
3306
+ emitted++;
3307
+ }
3308
+ if (completed) {
3309
+ session.hasMoreTurns = page.hasMore;
3310
+ session.oldestTurnCursor = page.nextBefore;
3311
+ if (messages) session.oldestMessageSeq = messages.nextBeforeSeq;
3312
+ this.oldestDrawnMessageId = plan[0]?.messageId ?? boundary;
3313
+ }
3314
+ this.emit({
3315
+ type: "historyPageEnd",
3316
+ conversationId,
3317
+ position: "prepend",
3318
+ hasMore: completed ? page.hasMore : true,
3319
+ complete: completed
3320
+ });
3321
+ return emitted;
3322
+ } finally {
3323
+ session.isLoadingEarlierTurns = false;
3324
+ }
3325
+ }
2989
3326
  async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest) {
2990
3327
  const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3328
+ let complete = false;
2991
3329
  try {
2992
- const jobs = await jobsRequest;
3330
+ const page = await jobsRequest;
3331
+ const jobs = page.jobs;
2993
3332
  if (stopReplay()) return false;
2994
3333
  const replayableJobs = jobs.filter(
2995
3334
  (j) => j.job_id !== activeJobId
@@ -3033,7 +3372,9 @@ var StreamManager = class {
3033
3372
  });
3034
3373
  const eventLists = await Promise.all(
3035
3374
  replayableJobs.map(
3036
- (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
3375
+ (job) => this.session.client.getConversationEvents(conversationId, job.job_id, {
3376
+ toolOutputs: this.toolOutputs
3377
+ }).catch(() => [])
3037
3378
  )
3038
3379
  );
3039
3380
  if (stopReplay()) return false;
@@ -3059,6 +3400,10 @@ var StreamManager = class {
3059
3400
  step.messageId
3060
3401
  );
3061
3402
  }
3403
+ this.session.hasMoreTurns = page.hasMore;
3404
+ this.session.oldestTurnCursor = page.nextBefore;
3405
+ this.oldestDrawnMessageId = plan[0]?.messageId ?? null;
3406
+ complete = true;
3062
3407
  if (stopReplay()) return false;
3063
3408
  this.emit({ type: "restoreSettled", conversationId });
3064
3409
  const versionCount = replayableJobs.filter(
@@ -3072,6 +3417,10 @@ var StreamManager = class {
3072
3417
  });
3073
3418
  }
3074
3419
  } catch {
3420
+ } finally {
3421
+ if (!complete && gen === this.generation) {
3422
+ this.reloadUnwindowed(conversationId);
3423
+ }
3075
3424
  }
3076
3425
  return !stopReplay();
3077
3426
  }
@@ -3080,6 +3429,10 @@ var StreamManager = class {
3080
3429
  this._activeConversationId = id;
3081
3430
  this.session.invalidateLoadsInFlight();
3082
3431
  const claimed = ++this.generation;
3432
+ this.session.hasMoreTurns = false;
3433
+ this.session.oldestTurnCursor = null;
3434
+ this.session.oldestMessageSeq = null;
3435
+ this.oldestDrawnMessageId = null;
3083
3436
  this.emit({ type: "conversationChanged", conversationId: id });
3084
3437
  return claimed;
3085
3438
  }
@@ -3169,6 +3522,7 @@ export {
3169
3522
  VOICE_POLISH_MODES,
3170
3523
  generateId,
3171
3524
  isEmbeddedResource,
3525
+ isToolOutputStub,
3172
3526
  isVoiceLLMMode,
3173
3527
  isVoicePolishMode,
3174
3528
  mapSseToChat,