@astralform/js 8.0.0 → 8.4.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/README.md CHANGED
@@ -320,6 +320,7 @@ const conversations = await client.getConversations();
320
320
  const messages = await client.getMessages("conversation-id");
321
321
  const agents = await client.getAgents();
322
322
  const skills = await client.getSkills();
323
+ const commands = await client.listSkillCommands();
323
324
 
324
325
  // Job-based streaming
325
326
  const job = await client.createJob({ message: "Hello" });
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,54 @@ 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
+ /**
859
+ * List the slash commands the active agent offers — the system commands
860
+ * followed by its enabled skills. Backs the composer's "/" menu.
861
+ *
862
+ * @param surface Who will execute them. Omit for the server's default,
863
+ * `"web"`: what `POST /v1/jobs` runs itself. `"telegram"` returns the
864
+ * bot's commands under Telegram-valid names; `"all"` returns every
865
+ * command, including those the other surfaces filter out — `surfaces` is
866
+ * set on every row either way, and is what tells them apart here.
867
+ *
868
+ * The default surface is not sent as a query parameter. The server already
869
+ * defaults to `web`, so omitting it keeps the request byte-identical to
870
+ * what clients that call the raw path send today — same reasoning as
871
+ * {@link getConversationEvents}'s `toolOutputs`.
872
+ */
873
+ async listSkillCommands(surface) {
874
+ const query = surface && surface !== "web" ? `?surface=${surface}` : "";
875
+ const raw = await this.get(
876
+ `/v1/skills/commands${query}`
877
+ );
878
+ return raw.map((c) => {
879
+ const command = camelizeKeys(c);
880
+ return {
881
+ ...command,
882
+ displayName: command.displayName ?? "",
883
+ description: command.description ?? "",
884
+ argsHint: command.argsHint ?? "",
885
+ surfaces: command.surfaces ?? []
886
+ };
887
+ });
888
+ }
889
+ async getConversationEvents(conversationId, jobId, options) {
890
+ const params = new URLSearchParams();
891
+ if (jobId) params.set("job_id", jobId);
892
+ if (options?.toolOutputs === "stub") params.set("tool_outputs", "stub");
893
+ const query = params.toString();
894
+ return this.get(
895
+ `/v1/conversations/${encodeURIComponent(conversationId)}/events${query ? `?${query}` : ""}`
896
+ );
897
+ }
898
+ async getToolOutput(conversationId, callIdOrStub, jobId) {
899
+ const callId = typeof callIdOrStub === "string" ? callIdOrStub : callIdOrStub.call_id;
900
+ const job = typeof callIdOrStub === "string" ? jobId : callIdOrStub.job_id;
901
+ const query = job ? `?job_id=${encodeURIComponent(job)}` : "";
902
+ const res = await this.get(
903
+ `/v1/conversations/${encodeURIComponent(conversationId)}/tool-output/${encodeURIComponent(callId)}${query}`
904
+ );
905
+ return res.output;
776
906
  }
777
907
  async submitToolResult(request) {
778
908
  await this.post("/v1/tool-result", request);
@@ -1374,6 +1504,13 @@ function translateCustomEvent(name, data) {
1374
1504
  key: data.key ?? null,
1375
1505
  namespace: data.namespace ?? null
1376
1506
  };
1507
+ case "memory_provider_error":
1508
+ return {
1509
+ type: "memory_provider_error",
1510
+ provider: data.provider ?? "",
1511
+ op: data.op ?? "",
1512
+ error: data.error ?? ""
1513
+ };
1377
1514
  case "desktop_stream":
1378
1515
  return {
1379
1516
  type: "desktop_stream",
@@ -1433,6 +1570,35 @@ function translateCustomEvent(name, data) {
1433
1570
  message: data.message ?? null,
1434
1571
  details: data.details ?? null
1435
1572
  };
1573
+ case "tool_progress":
1574
+ return {
1575
+ type: "tool_progress",
1576
+ callId: data.call_id ?? "",
1577
+ stream: data.stream ?? "progress",
1578
+ chunk: data.chunk ?? "",
1579
+ toolName: data.tool ?? null,
1580
+ item: data.item ?? null,
1581
+ index: data.index ?? null,
1582
+ total: data.total ?? null,
1583
+ // Every producer sends keys beyond the named ones, and one of them
1584
+ // (`video_tool._emit_progress`) splats `**extra`, so no fixed list can
1585
+ // be complete. Before this case existed the event fell through to
1586
+ // `{type:"custom", name, data}` and consumers read the whole dict;
1587
+ // keeping `data` verbatim is what makes the typed variant an addition
1588
+ // rather than a narrowing.
1589
+ data
1590
+ };
1591
+ case "nested_llm_usage":
1592
+ return {
1593
+ type: "nested_llm_usage",
1594
+ source: data.source ?? "",
1595
+ callId: data.call_id ?? "",
1596
+ inputTokens: data.input_tokens ?? 0,
1597
+ outputTokens: data.output_tokens ?? 0,
1598
+ cachedTokens: data.cached_tokens ?? 0,
1599
+ cacheCreationTokens: data.cache_creation_tokens ?? 0,
1600
+ llmCalls: data.llm_calls ?? 0
1601
+ };
1436
1602
  case "user_unavailable":
1437
1603
  return {
1438
1604
  type: "user_unavailable",
@@ -1591,6 +1757,19 @@ var ChatSession = class {
1591
1757
  * cheaper than adding a count query to every list call.
1592
1758
  */
1593
1759
  this.hasMoreConversations = false;
1760
+ /** True when the loaded window has OLDER turns behind it — the transcript's
1761
+ * analogue of `hasMoreConversations`, and what a scroll-to-top sentinel
1762
+ * gates on. False until a windowed load says otherwise, so a consumer that
1763
+ * never asks for a window never offers to page. */
1764
+ this.hasMoreTurns = false;
1765
+ /** Cursor for the next older MESSAGE page (`before_seq`), or null. */
1766
+ this.oldestMessageSeq = null;
1767
+ /** Cursor for the next older TURN page (`before`), or null. Set by the
1768
+ * restore that loaded the newest page; consumed by `loadEarlierTurns`. */
1769
+ this.oldestTurnCursor = null;
1770
+ /** Guards against a sentinel that re-fires while a page is still in flight
1771
+ * and stacks duplicate turns — the same guard `loadMoreConversations` uses. */
1772
+ this.isLoadingEarlierTurns = false;
1594
1773
  /** True while ``loadMoreConversations`` is in flight. */
1595
1774
  this.isLoadingConversations = false;
1596
1775
  this.messages = [];
@@ -2202,12 +2381,22 @@ var ChatSession = class {
2202
2381
  * Load conversation context (messages) without replaying events.
2203
2382
  * Used before reconnectToJob — SSE replay handles event replay.
2204
2383
  */
2205
- async loadConversation(id) {
2384
+ async loadConversation(id, options) {
2206
2385
  const load = ++this.loadGeneration;
2386
+ this.hasMoreTurns = false;
2387
+ this.oldestTurnCursor = null;
2388
+ this.oldestMessageSeq = null;
2207
2389
  this.conversationId = id;
2208
2390
  if (!this.isStreaming) this.resetStreamingState();
2209
2391
  const rowsKnownAtIssue = this.serverRowsKnown;
2210
- const messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
2392
+ let page = null;
2393
+ let messages;
2394
+ if (options?.limit == null) {
2395
+ messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
2396
+ } else {
2397
+ page = await this.client.getMessagesPage(id, { limit: options.limit }).catch(() => null);
2398
+ messages = page ? page.messages : await this.storage.fetchMessages(id);
2399
+ }
2211
2400
  if (load !== this.loadGeneration) return;
2212
2401
  const pending = this.messages.filter(
2213
2402
  (m) => this.pendingUserMessages.has(m.id) && m.conversationId === id
@@ -2220,6 +2409,7 @@ var ChatSession = class {
2220
2409
  for (const m of pending) {
2221
2410
  if (!stillPending.includes(m)) this.pendingUserMessages.delete(m.id);
2222
2411
  }
2412
+ this.oldestMessageSeq = page?.nextBeforeSeq ?? null;
2223
2413
  this.setMessages(
2224
2414
  stillPending.length ? [...messages, ...stillPending] : messages
2225
2415
  );
@@ -2414,6 +2604,25 @@ var ChatSession = class {
2414
2604
  * tracking ids client-side cannot discover a row that moved into a region
2415
2605
  * already scanned.
2416
2606
  */
2607
+ /**
2608
+ * Put an older page of messages in FRONT of the loaded window.
2609
+ *
2610
+ * Pending optimistic sends stay at the tail. They are the newest thing in
2611
+ * the session by construction — a message this browser has posted and the
2612
+ * server has not confirmed — so sorting them in with a page of history
2613
+ * would move an unsent bubble into the middle of the transcript.
2614
+ *
2615
+ * Ids already present are dropped rather than duplicated: pages are cut on a
2616
+ * row sequence, but a turn landing mid-walk can still put one message in two
2617
+ * pages, and a doubled prompt is more visible than a missing one.
2618
+ */
2619
+ prependMessages(older) {
2620
+ if (!older.length) return;
2621
+ const known = new Set(this.messages.map((m) => m.id));
2622
+ const fresh = older.filter((m) => !known.has(m.id));
2623
+ if (!fresh.length) return;
2624
+ this.setMessages([...fresh, ...this.messages]);
2625
+ }
2417
2626
  async loadMoreConversations() {
2418
2627
  if (this.isLoadingConversations || !this.hasMoreConversations) return [];
2419
2628
  this.isLoadingConversations = true;
@@ -2556,8 +2765,23 @@ function planRestore(args) {
2556
2765
  }
2557
2766
 
2558
2767
  // src/stream-manager.ts
2559
- var StreamManager = class {
2768
+ var RESTORE_TURN_PAGE_SIZE = 10;
2769
+ var RESTORE_MESSAGE_PAGE_SIZE = 40;
2770
+ var StreamManager = class _StreamManager {
2560
2771
  constructor(session) {
2772
+ /** The oldest prompt drawn so far — where a prepended page's span ends. */
2773
+ this.oldestDrawnMessageId = null;
2774
+ /**
2775
+ * Whether restore asks for tool outputs inline or as fetch handles.
2776
+ *
2777
+ * ONE setting, applied to both event waves — the newest page and every
2778
+ * `loadEarlierTurns` page. A consumer that got stubs only on scroll-up would
2779
+ * be worse off than one that got them nowhere: the pill would resolve itself
2780
+ * in the visible tail and need a fetch above the fold, for no stated reason.
2781
+ *
2782
+ * Defaults to inline, so this is inert until a consumer opts in.
2783
+ */
2784
+ this.toolOutputs = "inline";
2561
2785
  this._state = "idle";
2562
2786
  this._activeConversationId = null;
2563
2787
  this._backgroundJobs = /* @__PURE__ */ new Map();
@@ -2603,6 +2827,17 @@ var StreamManager = class {
2603
2827
  this.handlers = this.handlers.filter((h) => h !== handler);
2604
2828
  };
2605
2829
  }
2830
+ /**
2831
+ * Ask restore for stubbed tool outputs, resolved on demand.
2832
+ *
2833
+ * Only worth turning on by a consumer that can actually resolve a stub —
2834
+ * see `isToolOutputStub` and `client.getToolOutput`. One that cannot does
2835
+ * not render an empty result: it renders the stub OBJECT where the output
2836
+ * belongs, because that is what arrives in `final.output`.
2837
+ */
2838
+ setToolOutputMode(mode) {
2839
+ this.toolOutputs = mode;
2840
+ }
2606
2841
  emit(event) {
2607
2842
  for (const handler of this.handlers) {
2608
2843
  try {
@@ -2945,10 +3180,11 @@ var StreamManager = class {
2945
3180
  * probe and the message list while ``replayHistory``, which consumes it,
2946
3181
  * keeps owning the shape it reads.
2947
3182
  */
2948
- jobList(conversationId) {
2949
- return this.session.client.get(
2950
- `/v1/conversations/${encodeURIComponent(conversationId)}/jobs`
2951
- );
3183
+ jobList(conversationId, before) {
3184
+ return this.session.client.getConversationJobsPage(conversationId, {
3185
+ limit: RESTORE_TURN_PAGE_SIZE,
3186
+ ...before ? { before } : {}
3187
+ });
2952
3188
  }
2953
3189
  async restore(conversationId, gen) {
2954
3190
  const superseded = () => gen !== this.generation;
@@ -2958,14 +3194,20 @@ var StreamManager = class {
2958
3194
  if (announcedRestoring) this.setState("restoring");
2959
3195
  if (superseded()) return;
2960
3196
  const probeRequest = this.session.client.getActiveJob(conversationId).catch(() => null);
2961
- const loadRequest = this.session.loadConversation(conversationId);
3197
+ const loadRequest = this.session.loadConversation(
3198
+ conversationId,
3199
+ announcedRestoring ? { limit: RESTORE_MESSAGE_PAGE_SIZE } : void 0
3200
+ );
2962
3201
  const jobsRequest = announcedRestoring ? this.jobList(conversationId) : null;
2963
3202
  void jobsRequest?.catch(() => {
2964
3203
  });
2965
3204
  const [probe] = await Promise.all([probeRequest, loadRequest]);
2966
3205
  const activeJobId = probe?.jobId ?? null;
2967
3206
  if (superseded()) return;
2968
- if (announcedRestoring && this.viewTakenOverByLiveTurn()) return;
3207
+ if (announcedRestoring && this.viewTakenOverByLiveTurn()) {
3208
+ this.reloadUnwindowed(conversationId);
3209
+ return;
3210
+ }
2969
3211
  if (jobsRequest && !await this.replayHistory(
2970
3212
  conversationId,
2971
3213
  gen,
@@ -3017,6 +3259,26 @@ var StreamManager = class {
3017
3259
  turnStarted(since) {
3018
3260
  return this.turnCounter !== since;
3019
3261
  }
3262
+ /**
3263
+ * Re-issue the message load UNWINDOWED after a restore stopped before it
3264
+ * could write a turn cursor.
3265
+ *
3266
+ * The window is decided synchronously at restore entry, but "this restore
3267
+ * will page" is only settled once `replayHistory` clears its first abort
3268
+ * check. A send landing in between — nothing gates one during a restore —
3269
+ * stops the replay with the window already installed and the cursor never
3270
+ * written: a transcript truncated to one page that no pager can extend,
3271
+ * which is worse than the unbounded load this path did before windowing.
3272
+ * Reloading whole is that behaviour restored. Fire-and-forget: the load
3273
+ * token still makes a later switch win, and the pending-send reconciliation
3274
+ * inside `loadConversation` is what keeps the interrupting send's prompt.
3275
+ * Only called when the conversation has NOT moved — a superseding switch
3276
+ * announces and loads its own.
3277
+ */
3278
+ reloadUnwindowed(conversationId) {
3279
+ void this.session.loadConversation(conversationId).catch(() => {
3280
+ });
3281
+ }
3020
3282
  /**
3021
3283
  * Replay a conversation's persisted history into the consumer's block view.
3022
3284
  *
@@ -3037,10 +3299,119 @@ var StreamManager = class {
3037
3299
  * what keeps a failed job list non-blocking exactly as it was when the fetch
3038
3300
  * lived here.
3039
3301
  */
3302
+ /**
3303
+ * Fetch and replay the next OLDER page of turns.
3304
+ *
3305
+ * The scroll-up half of tail-first restore: `restore` renders the newest
3306
+ * page and clears `restoring`, and this brings back what precedes it, on
3307
+ * demand. Full fidelity — the same per-job events wave the newest page uses,
3308
+ * just later — so a turn paged in here is byte-identical to the same turn
3309
+ * rendered live. That is the whole reason this defers the fetch rather than
3310
+ * rebuilding older turns from the message list, which persists no thinking
3311
+ * blocks and no custom events.
3312
+ *
3313
+ * Resolves to the number of turns emitted; 0 when there is nothing older,
3314
+ * a page is already in flight, or the view was taken over mid-fetch.
3315
+ */
3316
+ /** User prompts from a slice of the message window, in plan input shape. */
3317
+ static userMessagesOf(messages) {
3318
+ return messages.filter((m) => m.role === "user").map((m) => ({ id: m.id, content: m.content }));
3319
+ }
3320
+ async loadEarlierTurns(conversationId) {
3321
+ const session = this.session;
3322
+ if (session.isLoadingEarlierTurns || !session.hasMoreTurns || !session.oldestTurnCursor) {
3323
+ return 0;
3324
+ }
3325
+ const gen = this.generation;
3326
+ const turn = this.turnCounter;
3327
+ const cursor = session.oldestTurnCursor;
3328
+ const stop = () => gen !== this.generation || conversationId !== session.conversationId || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3329
+ session.isLoadingEarlierTurns = true;
3330
+ try {
3331
+ const page = await this.jobList(conversationId, cursor);
3332
+ if (stop()) return 0;
3333
+ const messages = session.oldestMessageSeq ? await session.client.getMessagesPage(conversationId, {
3334
+ limit: RESTORE_MESSAGE_PAGE_SIZE,
3335
+ beforeSeq: session.oldestMessageSeq
3336
+ }).catch(() => null) : null;
3337
+ if (stop()) return 0;
3338
+ const boundary = this.oldestDrawnMessageId;
3339
+ const eventLists = await Promise.all(
3340
+ page.jobs.map(
3341
+ (job) => session.client.getConversationEvents(conversationId, job.job_id, {
3342
+ toolOutputs: this.toolOutputs
3343
+ }).catch(() => [])
3344
+ )
3345
+ );
3346
+ if (stop()) return 0;
3347
+ if (messages) session.prependMessages(messages.messages);
3348
+ const plan = planRestore({
3349
+ completedJobs: page.jobs.map((j) => ({
3350
+ job_id: j.job_id,
3351
+ message_id: j.message_id
3352
+ })),
3353
+ // Same array feeds the walk and the claim set, for the reason the
3354
+ // newest-page path documents: derived apart, they drift, and the
3355
+ // prompts of any turn dropped from one surface as steers in the other.
3356
+ claimedMessageIds: page.jobs.map((j) => j.message_id),
3357
+ userMessages: (() => {
3358
+ const all = session.messages;
3359
+ if (!boundary) return _StreamManager.userMessagesOf(all);
3360
+ const cut = all.findIndex((m) => m.id === boundary);
3361
+ return cut < 0 ? [] : _StreamManager.userMessagesOf(all.slice(0, cut));
3362
+ })()
3363
+ });
3364
+ this.emit({
3365
+ type: "historyPageStart",
3366
+ conversationId,
3367
+ position: "prepend"
3368
+ });
3369
+ let emitted = 0;
3370
+ const byJob = new Map(
3371
+ page.jobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
3372
+ );
3373
+ let completed = true;
3374
+ for (const step of plan) {
3375
+ if (stop()) {
3376
+ completed = false;
3377
+ break;
3378
+ }
3379
+ if (step.kind === "steer") {
3380
+ session.replayTurn(conversationId, [], step.content, step.messageId, true);
3381
+ } else {
3382
+ session.replayTurn(
3383
+ conversationId,
3384
+ byJob.get(step.jobId) ?? [],
3385
+ step.content,
3386
+ step.messageId
3387
+ );
3388
+ }
3389
+ emitted++;
3390
+ }
3391
+ if (completed) {
3392
+ session.hasMoreTurns = page.hasMore;
3393
+ session.oldestTurnCursor = page.nextBefore;
3394
+ if (messages) session.oldestMessageSeq = messages.nextBeforeSeq;
3395
+ this.oldestDrawnMessageId = plan[0]?.messageId ?? boundary;
3396
+ }
3397
+ this.emit({
3398
+ type: "historyPageEnd",
3399
+ conversationId,
3400
+ position: "prepend",
3401
+ hasMore: completed ? page.hasMore : true,
3402
+ complete: completed
3403
+ });
3404
+ return emitted;
3405
+ } finally {
3406
+ session.isLoadingEarlierTurns = false;
3407
+ }
3408
+ }
3040
3409
  async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest) {
3041
3410
  const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3411
+ let complete = false;
3042
3412
  try {
3043
- const jobs = await jobsRequest;
3413
+ const page = await jobsRequest;
3414
+ const jobs = page.jobs;
3044
3415
  if (stopReplay()) return false;
3045
3416
  const replayableJobs = jobs.filter(
3046
3417
  (j) => j.job_id !== activeJobId
@@ -3084,7 +3455,9 @@ var StreamManager = class {
3084
3455
  });
3085
3456
  const eventLists = await Promise.all(
3086
3457
  replayableJobs.map(
3087
- (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
3458
+ (job) => this.session.client.getConversationEvents(conversationId, job.job_id, {
3459
+ toolOutputs: this.toolOutputs
3460
+ }).catch(() => [])
3088
3461
  )
3089
3462
  );
3090
3463
  if (stopReplay()) return false;
@@ -3110,6 +3483,10 @@ var StreamManager = class {
3110
3483
  step.messageId
3111
3484
  );
3112
3485
  }
3486
+ this.session.hasMoreTurns = page.hasMore;
3487
+ this.session.oldestTurnCursor = page.nextBefore;
3488
+ this.oldestDrawnMessageId = plan[0]?.messageId ?? null;
3489
+ complete = true;
3113
3490
  if (stopReplay()) return false;
3114
3491
  this.emit({ type: "restoreSettled", conversationId });
3115
3492
  const versionCount = replayableJobs.filter(
@@ -3123,6 +3500,10 @@ var StreamManager = class {
3123
3500
  });
3124
3501
  }
3125
3502
  } catch {
3503
+ } finally {
3504
+ if (!complete && gen === this.generation) {
3505
+ this.reloadUnwindowed(conversationId);
3506
+ }
3126
3507
  }
3127
3508
  return !stopReplay();
3128
3509
  }
@@ -3131,6 +3512,10 @@ var StreamManager = class {
3131
3512
  this._activeConversationId = id;
3132
3513
  this.session.invalidateLoadsInFlight();
3133
3514
  const claimed = ++this.generation;
3515
+ this.session.hasMoreTurns = false;
3516
+ this.session.oldestTurnCursor = null;
3517
+ this.session.oldestMessageSeq = null;
3518
+ this.oldestDrawnMessageId = null;
3134
3519
  this.emit({ type: "conversationChanged", conversationId: id });
3135
3520
  return claimed;
3136
3521
  }
@@ -3221,6 +3606,7 @@ function parseEmbeddedResource(value) {
3221
3606
  VOICE_POLISH_MODES,
3222
3607
  generateId,
3223
3608
  isEmbeddedResource,
3609
+ isToolOutputStub,
3224
3610
  isVoiceLLMMode,
3225
3611
  isVoicePolishMode,
3226
3612
  mapSseToChat,