@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.cjs +380 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +351 -16
- package/dist/index.d.ts +351 -16
- package/dist/index.js +379 -25
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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(
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
774
|
-
if (jobId)
|
|
775
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
2950
|
-
|
|
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(
|
|
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())
|
|
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
|
|
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
|
|
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,
|