@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 +1 -0
- package/dist/index.cjs +411 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +401 -3
- package/dist/index.d.ts +401 -3
- package/dist/index.js +410 -25
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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(
|
|
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,54 @@ var AstralformClient = class {
|
|
|
769
855
|
const raw = await this.get("/v1/skills");
|
|
770
856
|
return raw.map((s) => camelizeKeys(s));
|
|
771
857
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
2950
|
-
|
|
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(
|
|
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())
|
|
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
|
|
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
|
|
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,
|