@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/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,54 @@ 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
+ /**
807
+ * List the slash commands the active agent offers — the system commands
808
+ * followed by its enabled skills. Backs the composer's "/" menu.
809
+ *
810
+ * @param surface Who will execute them. Omit for the server's default,
811
+ * `"web"`: what `POST /v1/jobs` runs itself. `"telegram"` returns the
812
+ * bot's commands under Telegram-valid names; `"all"` returns every
813
+ * command, including those the other surfaces filter out — `surfaces` is
814
+ * set on every row either way, and is what tells them apart here.
815
+ *
816
+ * The default surface is not sent as a query parameter. The server already
817
+ * defaults to `web`, so omitting it keeps the request byte-identical to
818
+ * what clients that call the raw path send today — same reasoning as
819
+ * {@link getConversationEvents}'s `toolOutputs`.
820
+ */
821
+ async listSkillCommands(surface) {
822
+ const query = surface && surface !== "web" ? `?surface=${surface}` : "";
823
+ const raw = await this.get(
824
+ `/v1/skills/commands${query}`
825
+ );
826
+ return raw.map((c) => {
827
+ const command = camelizeKeys(c);
828
+ return {
829
+ ...command,
830
+ displayName: command.displayName ?? "",
831
+ description: command.description ?? "",
832
+ argsHint: command.argsHint ?? "",
833
+ surfaces: command.surfaces ?? []
834
+ };
835
+ });
836
+ }
837
+ async getConversationEvents(conversationId, jobId, options) {
838
+ const params = new URLSearchParams();
839
+ if (jobId) params.set("job_id", jobId);
840
+ if (options?.toolOutputs === "stub") params.set("tool_outputs", "stub");
841
+ const query = params.toString();
842
+ return this.get(
843
+ `/v1/conversations/${encodeURIComponent(conversationId)}/events${query ? `?${query}` : ""}`
844
+ );
845
+ }
846
+ async getToolOutput(conversationId, callIdOrStub, jobId) {
847
+ const callId = typeof callIdOrStub === "string" ? callIdOrStub : callIdOrStub.call_id;
848
+ const job = typeof callIdOrStub === "string" ? jobId : callIdOrStub.job_id;
849
+ const query = job ? `?job_id=${encodeURIComponent(job)}` : "";
850
+ const res = await this.get(
851
+ `/v1/conversations/${encodeURIComponent(conversationId)}/tool-output/${encodeURIComponent(callId)}${query}`
852
+ );
853
+ return res.output;
725
854
  }
726
855
  async submitToolResult(request) {
727
856
  await this.post("/v1/tool-result", request);
@@ -1323,6 +1452,13 @@ function translateCustomEvent(name, data) {
1323
1452
  key: data.key ?? null,
1324
1453
  namespace: data.namespace ?? null
1325
1454
  };
1455
+ case "memory_provider_error":
1456
+ return {
1457
+ type: "memory_provider_error",
1458
+ provider: data.provider ?? "",
1459
+ op: data.op ?? "",
1460
+ error: data.error ?? ""
1461
+ };
1326
1462
  case "desktop_stream":
1327
1463
  return {
1328
1464
  type: "desktop_stream",
@@ -1382,6 +1518,35 @@ function translateCustomEvent(name, data) {
1382
1518
  message: data.message ?? null,
1383
1519
  details: data.details ?? null
1384
1520
  };
1521
+ case "tool_progress":
1522
+ return {
1523
+ type: "tool_progress",
1524
+ callId: data.call_id ?? "",
1525
+ stream: data.stream ?? "progress",
1526
+ chunk: data.chunk ?? "",
1527
+ toolName: data.tool ?? null,
1528
+ item: data.item ?? null,
1529
+ index: data.index ?? null,
1530
+ total: data.total ?? null,
1531
+ // Every producer sends keys beyond the named ones, and one of them
1532
+ // (`video_tool._emit_progress`) splats `**extra`, so no fixed list can
1533
+ // be complete. Before this case existed the event fell through to
1534
+ // `{type:"custom", name, data}` and consumers read the whole dict;
1535
+ // keeping `data` verbatim is what makes the typed variant an addition
1536
+ // rather than a narrowing.
1537
+ data
1538
+ };
1539
+ case "nested_llm_usage":
1540
+ return {
1541
+ type: "nested_llm_usage",
1542
+ source: data.source ?? "",
1543
+ callId: data.call_id ?? "",
1544
+ inputTokens: data.input_tokens ?? 0,
1545
+ outputTokens: data.output_tokens ?? 0,
1546
+ cachedTokens: data.cached_tokens ?? 0,
1547
+ cacheCreationTokens: data.cache_creation_tokens ?? 0,
1548
+ llmCalls: data.llm_calls ?? 0
1549
+ };
1385
1550
  case "user_unavailable":
1386
1551
  return {
1387
1552
  type: "user_unavailable",
@@ -1540,6 +1705,19 @@ var ChatSession = class {
1540
1705
  * cheaper than adding a count query to every list call.
1541
1706
  */
1542
1707
  this.hasMoreConversations = false;
1708
+ /** True when the loaded window has OLDER turns behind it — the transcript's
1709
+ * analogue of `hasMoreConversations`, and what a scroll-to-top sentinel
1710
+ * gates on. False until a windowed load says otherwise, so a consumer that
1711
+ * never asks for a window never offers to page. */
1712
+ this.hasMoreTurns = false;
1713
+ /** Cursor for the next older MESSAGE page (`before_seq`), or null. */
1714
+ this.oldestMessageSeq = null;
1715
+ /** Cursor for the next older TURN page (`before`), or null. Set by the
1716
+ * restore that loaded the newest page; consumed by `loadEarlierTurns`. */
1717
+ this.oldestTurnCursor = null;
1718
+ /** Guards against a sentinel that re-fires while a page is still in flight
1719
+ * and stacks duplicate turns — the same guard `loadMoreConversations` uses. */
1720
+ this.isLoadingEarlierTurns = false;
1543
1721
  /** True while ``loadMoreConversations`` is in flight. */
1544
1722
  this.isLoadingConversations = false;
1545
1723
  this.messages = [];
@@ -2151,12 +2329,22 @@ var ChatSession = class {
2151
2329
  * Load conversation context (messages) without replaying events.
2152
2330
  * Used before reconnectToJob — SSE replay handles event replay.
2153
2331
  */
2154
- async loadConversation(id) {
2332
+ async loadConversation(id, options) {
2155
2333
  const load = ++this.loadGeneration;
2334
+ this.hasMoreTurns = false;
2335
+ this.oldestTurnCursor = null;
2336
+ this.oldestMessageSeq = null;
2156
2337
  this.conversationId = id;
2157
2338
  if (!this.isStreaming) this.resetStreamingState();
2158
2339
  const rowsKnownAtIssue = this.serverRowsKnown;
2159
- const messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
2340
+ let page = null;
2341
+ let messages;
2342
+ if (options?.limit == null) {
2343
+ messages = await this.client.getMessages(id).catch(() => this.storage.fetchMessages(id));
2344
+ } else {
2345
+ page = await this.client.getMessagesPage(id, { limit: options.limit }).catch(() => null);
2346
+ messages = page ? page.messages : await this.storage.fetchMessages(id);
2347
+ }
2160
2348
  if (load !== this.loadGeneration) return;
2161
2349
  const pending = this.messages.filter(
2162
2350
  (m) => this.pendingUserMessages.has(m.id) && m.conversationId === id
@@ -2169,6 +2357,7 @@ var ChatSession = class {
2169
2357
  for (const m of pending) {
2170
2358
  if (!stillPending.includes(m)) this.pendingUserMessages.delete(m.id);
2171
2359
  }
2360
+ this.oldestMessageSeq = page?.nextBeforeSeq ?? null;
2172
2361
  this.setMessages(
2173
2362
  stillPending.length ? [...messages, ...stillPending] : messages
2174
2363
  );
@@ -2363,6 +2552,25 @@ var ChatSession = class {
2363
2552
  * tracking ids client-side cannot discover a row that moved into a region
2364
2553
  * already scanned.
2365
2554
  */
2555
+ /**
2556
+ * Put an older page of messages in FRONT of the loaded window.
2557
+ *
2558
+ * Pending optimistic sends stay at the tail. They are the newest thing in
2559
+ * the session by construction — a message this browser has posted and the
2560
+ * server has not confirmed — so sorting them in with a page of history
2561
+ * would move an unsent bubble into the middle of the transcript.
2562
+ *
2563
+ * Ids already present are dropped rather than duplicated: pages are cut on a
2564
+ * row sequence, but a turn landing mid-walk can still put one message in two
2565
+ * pages, and a doubled prompt is more visible than a missing one.
2566
+ */
2567
+ prependMessages(older) {
2568
+ if (!older.length) return;
2569
+ const known = new Set(this.messages.map((m) => m.id));
2570
+ const fresh = older.filter((m) => !known.has(m.id));
2571
+ if (!fresh.length) return;
2572
+ this.setMessages([...fresh, ...this.messages]);
2573
+ }
2366
2574
  async loadMoreConversations() {
2367
2575
  if (this.isLoadingConversations || !this.hasMoreConversations) return [];
2368
2576
  this.isLoadingConversations = true;
@@ -2505,8 +2713,23 @@ function planRestore(args) {
2505
2713
  }
2506
2714
 
2507
2715
  // src/stream-manager.ts
2508
- var StreamManager = class {
2716
+ var RESTORE_TURN_PAGE_SIZE = 10;
2717
+ var RESTORE_MESSAGE_PAGE_SIZE = 40;
2718
+ var StreamManager = class _StreamManager {
2509
2719
  constructor(session) {
2720
+ /** The oldest prompt drawn so far — where a prepended page's span ends. */
2721
+ this.oldestDrawnMessageId = null;
2722
+ /**
2723
+ * Whether restore asks for tool outputs inline or as fetch handles.
2724
+ *
2725
+ * ONE setting, applied to both event waves — the newest page and every
2726
+ * `loadEarlierTurns` page. A consumer that got stubs only on scroll-up would
2727
+ * be worse off than one that got them nowhere: the pill would resolve itself
2728
+ * in the visible tail and need a fetch above the fold, for no stated reason.
2729
+ *
2730
+ * Defaults to inline, so this is inert until a consumer opts in.
2731
+ */
2732
+ this.toolOutputs = "inline";
2510
2733
  this._state = "idle";
2511
2734
  this._activeConversationId = null;
2512
2735
  this._backgroundJobs = /* @__PURE__ */ new Map();
@@ -2552,6 +2775,17 @@ var StreamManager = class {
2552
2775
  this.handlers = this.handlers.filter((h) => h !== handler);
2553
2776
  };
2554
2777
  }
2778
+ /**
2779
+ * Ask restore for stubbed tool outputs, resolved on demand.
2780
+ *
2781
+ * Only worth turning on by a consumer that can actually resolve a stub —
2782
+ * see `isToolOutputStub` and `client.getToolOutput`. One that cannot does
2783
+ * not render an empty result: it renders the stub OBJECT where the output
2784
+ * belongs, because that is what arrives in `final.output`.
2785
+ */
2786
+ setToolOutputMode(mode) {
2787
+ this.toolOutputs = mode;
2788
+ }
2555
2789
  emit(event) {
2556
2790
  for (const handler of this.handlers) {
2557
2791
  try {
@@ -2894,10 +3128,11 @@ var StreamManager = class {
2894
3128
  * probe and the message list while ``replayHistory``, which consumes it,
2895
3129
  * keeps owning the shape it reads.
2896
3130
  */
2897
- jobList(conversationId) {
2898
- return this.session.client.get(
2899
- `/v1/conversations/${encodeURIComponent(conversationId)}/jobs`
2900
- );
3131
+ jobList(conversationId, before) {
3132
+ return this.session.client.getConversationJobsPage(conversationId, {
3133
+ limit: RESTORE_TURN_PAGE_SIZE,
3134
+ ...before ? { before } : {}
3135
+ });
2901
3136
  }
2902
3137
  async restore(conversationId, gen) {
2903
3138
  const superseded = () => gen !== this.generation;
@@ -2907,14 +3142,20 @@ var StreamManager = class {
2907
3142
  if (announcedRestoring) this.setState("restoring");
2908
3143
  if (superseded()) return;
2909
3144
  const probeRequest = this.session.client.getActiveJob(conversationId).catch(() => null);
2910
- const loadRequest = this.session.loadConversation(conversationId);
3145
+ const loadRequest = this.session.loadConversation(
3146
+ conversationId,
3147
+ announcedRestoring ? { limit: RESTORE_MESSAGE_PAGE_SIZE } : void 0
3148
+ );
2911
3149
  const jobsRequest = announcedRestoring ? this.jobList(conversationId) : null;
2912
3150
  void jobsRequest?.catch(() => {
2913
3151
  });
2914
3152
  const [probe] = await Promise.all([probeRequest, loadRequest]);
2915
3153
  const activeJobId = probe?.jobId ?? null;
2916
3154
  if (superseded()) return;
2917
- if (announcedRestoring && this.viewTakenOverByLiveTurn()) return;
3155
+ if (announcedRestoring && this.viewTakenOverByLiveTurn()) {
3156
+ this.reloadUnwindowed(conversationId);
3157
+ return;
3158
+ }
2918
3159
  if (jobsRequest && !await this.replayHistory(
2919
3160
  conversationId,
2920
3161
  gen,
@@ -2966,6 +3207,26 @@ var StreamManager = class {
2966
3207
  turnStarted(since) {
2967
3208
  return this.turnCounter !== since;
2968
3209
  }
3210
+ /**
3211
+ * Re-issue the message load UNWINDOWED after a restore stopped before it
3212
+ * could write a turn cursor.
3213
+ *
3214
+ * The window is decided synchronously at restore entry, but "this restore
3215
+ * will page" is only settled once `replayHistory` clears its first abort
3216
+ * check. A send landing in between — nothing gates one during a restore —
3217
+ * stops the replay with the window already installed and the cursor never
3218
+ * written: a transcript truncated to one page that no pager can extend,
3219
+ * which is worse than the unbounded load this path did before windowing.
3220
+ * Reloading whole is that behaviour restored. Fire-and-forget: the load
3221
+ * token still makes a later switch win, and the pending-send reconciliation
3222
+ * inside `loadConversation` is what keeps the interrupting send's prompt.
3223
+ * Only called when the conversation has NOT moved — a superseding switch
3224
+ * announces and loads its own.
3225
+ */
3226
+ reloadUnwindowed(conversationId) {
3227
+ void this.session.loadConversation(conversationId).catch(() => {
3228
+ });
3229
+ }
2969
3230
  /**
2970
3231
  * Replay a conversation's persisted history into the consumer's block view.
2971
3232
  *
@@ -2986,10 +3247,119 @@ var StreamManager = class {
2986
3247
  * what keeps a failed job list non-blocking exactly as it was when the fetch
2987
3248
  * lived here.
2988
3249
  */
3250
+ /**
3251
+ * Fetch and replay the next OLDER page of turns.
3252
+ *
3253
+ * The scroll-up half of tail-first restore: `restore` renders the newest
3254
+ * page and clears `restoring`, and this brings back what precedes it, on
3255
+ * demand. Full fidelity — the same per-job events wave the newest page uses,
3256
+ * just later — so a turn paged in here is byte-identical to the same turn
3257
+ * rendered live. That is the whole reason this defers the fetch rather than
3258
+ * rebuilding older turns from the message list, which persists no thinking
3259
+ * blocks and no custom events.
3260
+ *
3261
+ * Resolves to the number of turns emitted; 0 when there is nothing older,
3262
+ * a page is already in flight, or the view was taken over mid-fetch.
3263
+ */
3264
+ /** User prompts from a slice of the message window, in plan input shape. */
3265
+ static userMessagesOf(messages) {
3266
+ return messages.filter((m) => m.role === "user").map((m) => ({ id: m.id, content: m.content }));
3267
+ }
3268
+ async loadEarlierTurns(conversationId) {
3269
+ const session = this.session;
3270
+ if (session.isLoadingEarlierTurns || !session.hasMoreTurns || !session.oldestTurnCursor) {
3271
+ return 0;
3272
+ }
3273
+ const gen = this.generation;
3274
+ const turn = this.turnCounter;
3275
+ const cursor = session.oldestTurnCursor;
3276
+ const stop = () => gen !== this.generation || conversationId !== session.conversationId || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3277
+ session.isLoadingEarlierTurns = true;
3278
+ try {
3279
+ const page = await this.jobList(conversationId, cursor);
3280
+ if (stop()) return 0;
3281
+ const messages = session.oldestMessageSeq ? await session.client.getMessagesPage(conversationId, {
3282
+ limit: RESTORE_MESSAGE_PAGE_SIZE,
3283
+ beforeSeq: session.oldestMessageSeq
3284
+ }).catch(() => null) : null;
3285
+ if (stop()) return 0;
3286
+ const boundary = this.oldestDrawnMessageId;
3287
+ const eventLists = await Promise.all(
3288
+ page.jobs.map(
3289
+ (job) => session.client.getConversationEvents(conversationId, job.job_id, {
3290
+ toolOutputs: this.toolOutputs
3291
+ }).catch(() => [])
3292
+ )
3293
+ );
3294
+ if (stop()) return 0;
3295
+ if (messages) session.prependMessages(messages.messages);
3296
+ const plan = planRestore({
3297
+ completedJobs: page.jobs.map((j) => ({
3298
+ job_id: j.job_id,
3299
+ message_id: j.message_id
3300
+ })),
3301
+ // Same array feeds the walk and the claim set, for the reason the
3302
+ // newest-page path documents: derived apart, they drift, and the
3303
+ // prompts of any turn dropped from one surface as steers in the other.
3304
+ claimedMessageIds: page.jobs.map((j) => j.message_id),
3305
+ userMessages: (() => {
3306
+ const all = session.messages;
3307
+ if (!boundary) return _StreamManager.userMessagesOf(all);
3308
+ const cut = all.findIndex((m) => m.id === boundary);
3309
+ return cut < 0 ? [] : _StreamManager.userMessagesOf(all.slice(0, cut));
3310
+ })()
3311
+ });
3312
+ this.emit({
3313
+ type: "historyPageStart",
3314
+ conversationId,
3315
+ position: "prepend"
3316
+ });
3317
+ let emitted = 0;
3318
+ const byJob = new Map(
3319
+ page.jobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
3320
+ );
3321
+ let completed = true;
3322
+ for (const step of plan) {
3323
+ if (stop()) {
3324
+ completed = false;
3325
+ break;
3326
+ }
3327
+ if (step.kind === "steer") {
3328
+ session.replayTurn(conversationId, [], step.content, step.messageId, true);
3329
+ } else {
3330
+ session.replayTurn(
3331
+ conversationId,
3332
+ byJob.get(step.jobId) ?? [],
3333
+ step.content,
3334
+ step.messageId
3335
+ );
3336
+ }
3337
+ emitted++;
3338
+ }
3339
+ if (completed) {
3340
+ session.hasMoreTurns = page.hasMore;
3341
+ session.oldestTurnCursor = page.nextBefore;
3342
+ if (messages) session.oldestMessageSeq = messages.nextBeforeSeq;
3343
+ this.oldestDrawnMessageId = plan[0]?.messageId ?? boundary;
3344
+ }
3345
+ this.emit({
3346
+ type: "historyPageEnd",
3347
+ conversationId,
3348
+ position: "prepend",
3349
+ hasMore: completed ? page.hasMore : true,
3350
+ complete: completed
3351
+ });
3352
+ return emitted;
3353
+ } finally {
3354
+ session.isLoadingEarlierTurns = false;
3355
+ }
3356
+ }
2989
3357
  async replayHistory(conversationId, gen, activeJobId, turn, jobsRequest) {
2990
3358
  const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
3359
+ let complete = false;
2991
3360
  try {
2992
- const jobs = await jobsRequest;
3361
+ const page = await jobsRequest;
3362
+ const jobs = page.jobs;
2993
3363
  if (stopReplay()) return false;
2994
3364
  const replayableJobs = jobs.filter(
2995
3365
  (j) => j.job_id !== activeJobId
@@ -3033,7 +3403,9 @@ var StreamManager = class {
3033
3403
  });
3034
3404
  const eventLists = await Promise.all(
3035
3405
  replayableJobs.map(
3036
- (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
3406
+ (job) => this.session.client.getConversationEvents(conversationId, job.job_id, {
3407
+ toolOutputs: this.toolOutputs
3408
+ }).catch(() => [])
3037
3409
  )
3038
3410
  );
3039
3411
  if (stopReplay()) return false;
@@ -3059,6 +3431,10 @@ var StreamManager = class {
3059
3431
  step.messageId
3060
3432
  );
3061
3433
  }
3434
+ this.session.hasMoreTurns = page.hasMore;
3435
+ this.session.oldestTurnCursor = page.nextBefore;
3436
+ this.oldestDrawnMessageId = plan[0]?.messageId ?? null;
3437
+ complete = true;
3062
3438
  if (stopReplay()) return false;
3063
3439
  this.emit({ type: "restoreSettled", conversationId });
3064
3440
  const versionCount = replayableJobs.filter(
@@ -3072,6 +3448,10 @@ var StreamManager = class {
3072
3448
  });
3073
3449
  }
3074
3450
  } catch {
3451
+ } finally {
3452
+ if (!complete && gen === this.generation) {
3453
+ this.reloadUnwindowed(conversationId);
3454
+ }
3075
3455
  }
3076
3456
  return !stopReplay();
3077
3457
  }
@@ -3080,6 +3460,10 @@ var StreamManager = class {
3080
3460
  this._activeConversationId = id;
3081
3461
  this.session.invalidateLoadsInFlight();
3082
3462
  const claimed = ++this.generation;
3463
+ this.session.hasMoreTurns = false;
3464
+ this.session.oldestTurnCursor = null;
3465
+ this.session.oldestMessageSeq = null;
3466
+ this.oldestDrawnMessageId = null;
3083
3467
  this.emit({ type: "conversationChanged", conversationId: id });
3084
3468
  return claimed;
3085
3469
  }
@@ -3169,6 +3553,7 @@ export {
3169
3553
  VOICE_POLISH_MODES,
3170
3554
  generateId,
3171
3555
  isEmbeddedResource,
3556
+ isToolOutputStub,
3172
3557
  isVoiceLLMMode,
3173
3558
  isVoicePolishMode,
3174
3559
  mapSseToChat,