@jskit-ai/assistant-runtime 0.1.144 → 0.1.145

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.144",
3
+ "version": "0.1.145",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -11,15 +11,15 @@
11
11
  "./server/actionIds": "./src/server/actionIds.js"
12
12
  },
13
13
  "dependencies": {
14
- "@jskit-ai/assistant-core": "0.1.149",
15
- "@jskit-ai/database-runtime": "0.1.173",
14
+ "@jskit-ai/assistant-core": "0.1.150",
15
+ "@jskit-ai/database-runtime": "0.1.174",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.171",
20
- "@jskit-ai/http-web": "0.1.18",
21
- "@jskit-ai/kernel": "0.1.173",
22
- "@jskit-ai/shell-web": "0.1.177",
19
+ "@jskit-ai/http-runtime": "0.1.172",
20
+ "@jskit-ai/http-web": "0.1.19",
21
+ "@jskit-ai/kernel": "0.1.174",
22
+ "@jskit-ai/shell-web": "0.1.178",
23
23
  "@tanstack/vue-query": "^5.90.5",
24
24
  "vue": "^3.5.13",
25
25
  "vuetify": "^4.0.0"
@@ -29,13 +29,15 @@ import {
29
29
  normalizeToolName
30
30
  } from "../support/assistantRuntimeState.js";
31
31
  import { insertTextAtSelection } from "../support/composerInputSupport.js";
32
+ import {
33
+ loadConversationTranscript,
34
+ resolveConversationRestorePolicy
35
+ } from "../support/conversationRestoreSupport.js";
32
36
  import { useWorkspaceWebScopeSupport } from "../support/workspaceScopeSupport.js";
33
37
 
34
38
  const DEFAULT_STREAM_TIMEOUT_MS = 120_000;
35
39
  const DEFAULT_HISTORY_PAGE_SIZE = 20;
36
- const DEFAULT_MESSAGES_PAGE_SIZE = 200;
37
40
  const DEFAULT_HISTORY_STALE_TIME_MS = 60_000;
38
- const RESTORE_MESSAGES_PAGE = 1;
39
41
 
40
42
  function toNonNegativeInteger(value, fallback = 0) {
41
43
  const parsed = Number(value);
@@ -106,11 +108,16 @@ function formatConversationStartedAt(value) {
106
108
  function resolveRuntimePolicy() {
107
109
  const appConfig = getClientAppConfig();
108
110
  const assistantConfig = normalizeObject(appConfig?.assistant);
111
+ const conversationRestorePolicy = resolveConversationRestorePolicy({
112
+ pageSize: assistantConfig.restoreMessagesPageSize,
113
+ maxEntries: assistantConfig.restoreMessagesMaxEntries
114
+ });
109
115
 
110
116
  return Object.freeze({
111
117
  timeoutMs: toPositiveInteger(assistantConfig.timeoutMs, DEFAULT_STREAM_TIMEOUT_MS),
112
118
  historyPageSize: toPositiveInteger(assistantConfig.historyPageSize, DEFAULT_HISTORY_PAGE_SIZE),
113
- restoreMessagesPageSize: toPositiveInteger(assistantConfig.restoreMessagesPageSize, DEFAULT_MESSAGES_PAGE_SIZE),
119
+ restoreMessagesPageSize: conversationRestorePolicy.pageSize,
120
+ restoreMessagesMaxEntries: conversationRestorePolicy.maxEntries,
114
121
  historyStaleTimeMs: toNonNegativeInteger(assistantConfig.historyStaleTimeMs, DEFAULT_HISTORY_STALE_TIME_MS)
115
122
  });
116
123
  }
@@ -370,19 +377,23 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
370
377
  setRuntimeError("");
371
378
 
372
379
  try {
373
- const response = await queryClient.fetchQuery({
374
- queryKey: assistantConversationMessagesQueryKey(runtimeScope.value, parsedConversationId, {
375
- page: RESTORE_MESSAGES_PAGE,
376
- pageSize: runtimePolicy.restoreMessagesPageSize
377
- }),
378
- queryFn: () =>
379
- runtimeApi.getConversationMessages(parsedConversationId, {
380
- page: RESTORE_MESSAGES_PAGE,
381
- pageSize: runtimePolicy.restoreMessagesPageSize
382
- })
380
+ const transcript = await loadConversationTranscript({
381
+ pageSize: runtimePolicy.restoreMessagesPageSize,
382
+ maxEntries: runtimePolicy.restoreMessagesMaxEntries,
383
+ fetchPage: (page, pageSize) => queryClient.fetchQuery({
384
+ queryKey: assistantConversationMessagesQueryKey(runtimeScope.value, parsedConversationId, {
385
+ page,
386
+ pageSize
387
+ }),
388
+ queryFn: () => runtimeApi.getConversationMessages(parsedConversationId, {
389
+ page,
390
+ pageSize
391
+ }),
392
+ staleTime: runtimePolicy.historyStaleTimeMs
393
+ })
383
394
  });
384
395
 
385
- const restored = mapTranscriptEntriesToAssistantState(response?.entries);
396
+ const restored = mapTranscriptEntriesToAssistantState(transcript.entries);
386
397
  messages.value = restored.messages;
387
398
  pendingToolEvents.value = restored.pendingToolEvents;
388
399
  input.value = "";
@@ -0,0 +1,75 @@
1
+ import { MAX_MESSAGE_PAGE_SIZE } from "@jskit-ai/assistant-core/shared";
2
+
3
+ const DEFAULT_RESTORE_MESSAGES_PAGE_SIZE = 200;
4
+ const DEFAULT_RESTORE_MESSAGES_MAX_ENTRIES = 2000;
5
+ const MAX_RESTORE_MESSAGES_MAX_ENTRIES = 5000;
6
+
7
+ function normalizeBoundedPositiveInteger(value, fallback, maximum) {
8
+ const parsed = Number(value);
9
+ if (!Number.isInteger(parsed) || parsed < 1) {
10
+ return fallback;
11
+ }
12
+
13
+ return Math.min(parsed, maximum);
14
+ }
15
+
16
+ function normalizeTotalPages(value) {
17
+ const parsed = Number(value);
18
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
19
+ }
20
+
21
+ function resolveConversationRestorePolicy({ pageSize, maxEntries } = {}) {
22
+ return Object.freeze({
23
+ pageSize: normalizeBoundedPositiveInteger(
24
+ pageSize,
25
+ DEFAULT_RESTORE_MESSAGES_PAGE_SIZE,
26
+ MAX_MESSAGE_PAGE_SIZE
27
+ ),
28
+ maxEntries: normalizeBoundedPositiveInteger(
29
+ maxEntries,
30
+ DEFAULT_RESTORE_MESSAGES_MAX_ENTRIES,
31
+ MAX_RESTORE_MESSAGES_MAX_ENTRIES
32
+ )
33
+ });
34
+ }
35
+
36
+ async function loadConversationTranscript({
37
+ fetchPage,
38
+ pageSize = DEFAULT_RESTORE_MESSAGES_PAGE_SIZE,
39
+ maxEntries = DEFAULT_RESTORE_MESSAGES_MAX_ENTRIES
40
+ } = {}) {
41
+ if (typeof fetchPage !== "function") {
42
+ throw new TypeError("loadConversationTranscript requires fetchPage().");
43
+ }
44
+
45
+ const restorePolicy = resolveConversationRestorePolicy({ pageSize, maxEntries });
46
+ const normalizedPageSize = restorePolicy.pageSize;
47
+ const normalizedMaxEntries = restorePolicy.maxEntries;
48
+ const firstResponse = await fetchPage(1, normalizedPageSize);
49
+ const totalPages = normalizeTotalPages(firstResponse?.totalPages);
50
+ const restoredPageCount = Math.max(1, Math.ceil(normalizedMaxEntries / normalizedPageSize));
51
+ const firstRestoredPage = Math.max(1, totalPages - restoredPageCount + 1);
52
+ const entries = firstRestoredPage === 1 && Array.isArray(firstResponse?.entries)
53
+ ? [...firstResponse.entries]
54
+ : [];
55
+
56
+ for (let page = Math.max(2, firstRestoredPage); page <= totalPages; page += 1) {
57
+ const response = await fetchPage(page, normalizedPageSize);
58
+ if (Array.isArray(response?.entries)) {
59
+ entries.push(...response.entries);
60
+ }
61
+ }
62
+
63
+ return Object.freeze({
64
+ entries: Object.freeze(entries.slice(-normalizedMaxEntries)),
65
+ firstRestoredPage,
66
+ pageSize: normalizedPageSize,
67
+ totalPages,
68
+ truncated: firstRestoredPage > 1
69
+ });
70
+ }
71
+
72
+ export {
73
+ loadConversationTranscript,
74
+ resolveConversationRestorePolicy
75
+ };
@@ -12,6 +12,7 @@ const MAX_INPUT_CHARS = 8000;
12
12
  const MAX_TOOL_ROUNDS = 16;
13
13
  const MAX_RECOVERY_PASSES = 3;
14
14
  const MAX_TOOL_RESULT_FALLBACK_CHARS = 4000;
15
+ const CURRENT_TIME_PREFLIGHT_INTENT = "current-time";
15
16
  const CLOCK_INSTRUCTION = "For current or relative date and time questions, first use any available authoritative workspace clock action; never infer the current date or time from model knowledge.";
16
17
  const COMPLETION_INSTRUCTION = "Do not narrate future work or describe what you are about to do. Either call the required available tool now or provide the completed final answer.";
17
18
 
@@ -95,6 +96,36 @@ function isAbortError(error) {
95
96
  return String(error.name || "").trim() === "AbortError";
96
97
  }
97
98
 
99
+ function requiresCurrentTime(value = "") {
100
+ const text = normalizeText(value);
101
+ if (!text) {
102
+ return false;
103
+ }
104
+
105
+ return [
106
+ /\b(?:now|today|tomorrow|yesterday|tonight)\b/iu,
107
+ /\b(?:current|local)\s+(?:date|day|time|date\s+and\s+time)\b/iu,
108
+ /\bwhat(?:'s|\s+is)\s+(?:the\s+)?(?:date|day|time)\b/iu,
109
+ /\b(?:this|next|last)\s+(?:day|week|month|year|monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/iu
110
+ ].some((pattern) => pattern.test(text));
111
+ }
112
+
113
+ function resolvePreflightTools(toolDescriptors = [], input = "") {
114
+ if (!requiresCurrentTime(input)) {
115
+ return [];
116
+ }
117
+
118
+ const currentTimeTool = toolDescriptors.find((tool) => {
119
+ const intents = Array.isArray(tool?.preflight) ? tool.preflight : [];
120
+ const requiredParameters = Array.isArray(tool?.parameters?.required)
121
+ ? tool.parameters.required
122
+ : [];
123
+ return requiredParameters.length < 1 && intents.includes(CURRENT_TIME_PREFLIGHT_INTENT);
124
+ });
125
+
126
+ return currentTimeTool ? [currentTimeTool] : [];
127
+ }
128
+
98
129
  function extractTextDelta(deltaContent) {
99
130
  if (typeof deltaContent === "string") {
100
131
  return deltaContent;
@@ -195,7 +226,11 @@ function buildRecoveryPrompt({ reason = "", toolFailures = [], toolSuccesses = [
195
226
  return `Tool-call rounds were exhausted. Provide the best direct answer with available context and successful results only. ${COMPLETION_INSTRUCTION}${failureSuffix}${successSuffix}`;
196
227
  }
197
228
 
198
- function buildRecoveryFallbackAnswer({ toolFailures = [], toolSuccesses = [] } = {}) {
229
+ function buildRecoveryFallbackAnswer({ reason = "", toolFailures = [], toolSuccesses = [] } = {}) {
230
+ if (normalizeText(reason).toLowerCase() === "max_tool_rounds") {
231
+ return "Limit reached. Start a new conversation.";
232
+ }
233
+
199
234
  return buildToolOutcomeFallbackAnswer({
200
235
  toolFailures,
201
236
  toolSuccesses
@@ -743,6 +778,26 @@ function createChatService({
743
778
  const toolFailures = [];
744
779
  const toolSuccesses = [];
745
780
 
781
+ const preflightTools = resolvePreflightTools(toolSet.tools, source.input);
782
+ for (const [index, tool] of preflightTools.entries()) {
783
+ const toolCall = {
784
+ id: `assistant_preflight_${index + 1}`,
785
+ name: tool.name,
786
+ arguments: "{}"
787
+ };
788
+ messages.push(buildAssistantToolCallMessage([toolCall]));
789
+ const preflightFailures = await executeToolCalls([toolCall], {
790
+ toolFailures,
791
+ toolSuccesses
792
+ });
793
+ for (const failure of preflightFailures) {
794
+ const toolName = normalizeText(failure?.name);
795
+ if (toolName) {
796
+ excludedToolNames.add(toolName);
797
+ }
798
+ }
799
+ }
800
+
746
801
  for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
747
802
  const roundToolDescriptors = toolSet.tools.filter(
748
803
  (tool) => !excludedToolNames.has(normalizeText(tool.name))
@@ -69,22 +69,24 @@ async function* completionStream(completion = {}) {
69
69
  }
70
70
  }
71
71
 
72
- function createHarness(completions, { executeToolCall = null } = {}) {
72
+ function createHarness(completions, { executeToolCall = null, tools: configuredTools = null } = {}) {
73
73
  const pendingCompletions = [...completions];
74
74
  const completionRequests = [];
75
75
  const transcriptMessages = [];
76
76
  const completedConversations = [];
77
77
  const executedTools = [];
78
78
  const streamEvents = [];
79
- const tools = ["action_search", "action_contract", "action_execute"].map((name) => ({
80
- name,
81
- parameters: {
82
- type: "object"
83
- },
84
- outputSchema: {
85
- type: "object"
86
- }
87
- }));
79
+ const tools = Array.isArray(configuredTools)
80
+ ? configuredTools
81
+ : ["action_search", "action_contract", "action_execute"].map((name) => ({
82
+ name,
83
+ parameters: {
84
+ type: "object"
85
+ },
86
+ outputSchema: {
87
+ type: "object"
88
+ }
89
+ }));
88
90
 
89
91
  const chatService = createChatService({
90
92
  aiClientFactory: {
@@ -206,14 +208,44 @@ function assistantMessages(events) {
206
208
  }
207
209
 
208
210
  test("progress-only output is retried silently and current-time prompts require a workspace clock", async () => {
209
- const harness = createHarness([
210
- textCompletion("Let me query the current time."),
211
- textCompletion("<think>This must remain private.</think>\nIt is Tuesday in the workspace timezone.")
212
- ]);
211
+ const harness = createHarness(
212
+ [
213
+ textCompletion("Let me query the current time."),
214
+ textCompletion("<think>This must remain private.</think>\nIt is Tuesday in the workspace timezone.")
215
+ ],
216
+ {
217
+ tools: [{
218
+ name: "workspace_clock",
219
+ parameters: {
220
+ type: "object",
221
+ additionalProperties: false,
222
+ properties: {}
223
+ },
224
+ outputSchema: { type: "object" },
225
+ preflight: ["current-time"]
226
+ }],
227
+ executeToolCall() {
228
+ return {
229
+ ok: true,
230
+ result: {
231
+ localDateTime: "2026-08-25T11:15:00+08:00",
232
+ timeZone: "Australia/Perth"
233
+ }
234
+ };
235
+ }
236
+ }
237
+ );
213
238
 
214
239
  const result = await harness.run("What day is it today?");
215
240
 
216
241
  assert.equal(result.status, "completed");
242
+ assert.deepEqual(harness.executedTools.map((request) => request.toolName), ["workspace_clock"]);
243
+ assert.equal(
244
+ harness.completionRequests[0].messages.some((message) => (
245
+ message.role === "tool" && /Australia\/Perth/u.test(message.content)
246
+ )),
247
+ true
248
+ );
217
249
  assert.deepEqual(assistantMessages(harness.streamEvents), ["It is Tuesday in the workspace timezone."]);
218
250
  assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
219
251
  assert.match(
@@ -236,6 +268,50 @@ test("progress-only output is retried silently and current-time prompts require
236
268
  );
237
269
  });
238
270
 
271
+ test("current-time preflight does not run for unrelated prompts", async () => {
272
+ const harness = createHarness(
273
+ [textCompletion("There are three active bookings.")],
274
+ {
275
+ tools: [{
276
+ name: "workspace_clock",
277
+ parameters: { type: "object", properties: {} },
278
+ outputSchema: { type: "object" },
279
+ preflight: ["current-time"]
280
+ }]
281
+ }
282
+ );
283
+
284
+ await harness.run("How many active bookings are there?");
285
+
286
+ assert.deepEqual(harness.executedTools, []);
287
+ assert.deepEqual(assistantMessages(harness.streamEvents), ["There are three active bookings."]);
288
+ });
289
+
290
+ test("current-time preflight does not invent required tool input", async () => {
291
+ const harness = createHarness(
292
+ [textCompletion("Choose a timezone before asking for its local time.")],
293
+ {
294
+ tools: [{
295
+ name: "timezone_clock",
296
+ parameters: {
297
+ type: "object",
298
+ required: ["timeZone"],
299
+ properties: {
300
+ timeZone: { type: "string" }
301
+ }
302
+ },
303
+ outputSchema: { type: "object" },
304
+ preflight: ["current-time"]
305
+ }]
306
+ }
307
+ );
308
+
309
+ await harness.run("What time is it now?");
310
+
311
+ assert.deepEqual(harness.executedTools, []);
312
+ assert.deepEqual(assistantMessages(harness.streamEvents), ["Choose a timezone before asking for its local time."]);
313
+ });
314
+
239
315
  test("native search, contract, and execution workflows can exceed four silent tool rounds", async () => {
240
316
  const harness = createHarness([
241
317
  toolCompletion("action_search", 1, { text: "I'll search first." }),
@@ -261,7 +337,7 @@ test("native search, contract, and execution workflows can exceed four silent to
261
337
  assert.equal(assistantToolMessages.every((message) => message.content === ""), true);
262
338
  });
263
339
 
264
- test("tool-loop exhaustion returns the latest successful result with a hard output cap", async () => {
340
+ test("tool-loop exhaustion gives the concise new-conversation instruction", async () => {
265
341
  const mainRounds = Array.from({ length: 16 }, (_, index) =>
266
342
  toolCompletion("action_execute", index + 1)
267
343
  );
@@ -290,11 +366,50 @@ test("tool-loop exhaustion returns the latest successful result with a hard outp
290
366
  const finalMessages = assistantMessages(harness.streamEvents);
291
367
  assert.equal(harness.executedTools.length, 16);
292
368
  assert.equal(harness.completionRequests.length, 19);
293
- assert.equal(finalMessages.length, 1);
294
- assert.ok(finalMessages[0].length <= 4000);
295
- assert.match(finalMessages[0], /Latest successful result from action_execute/u);
296
- assert.match(finalMessages[0], /"sequence": 16/u);
297
- assert.match(finalMessages[0], /…\[truncated\]$/u);
298
- assert.doesNotMatch(finalMessages[0], /Please narrow the request/u);
369
+ assert.deepEqual(finalMessages, ["Limit reached. Start a new conversation."]);
299
370
  assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
300
371
  });
372
+
373
+ test("tool-failure recovery retains the bounded latest successful result", async () => {
374
+ const mainRounds = Array.from({ length: 16 }, (_, index) =>
375
+ toolCompletion(index === 1 ? "action_search" : "action_execute", index + 1)
376
+ );
377
+ const harness = createHarness(
378
+ [
379
+ ...mainRounds,
380
+ textCompletion("Let me prepare the answer."),
381
+ textCompletion("I'll summarize the result."),
382
+ textCompletion("Checking the final output.")
383
+ ],
384
+ {
385
+ executeToolCall(request, sequence) {
386
+ if (request.toolName === "action_search") {
387
+ return {
388
+ ok: false,
389
+ error: {
390
+ code: "assistant_tool_failed",
391
+ message: "Tool call failed."
392
+ }
393
+ };
394
+ }
395
+ return {
396
+ ok: true,
397
+ result: {
398
+ sequence,
399
+ payload: sequence === 16 ? "x".repeat(10_000) : "ok"
400
+ }
401
+ };
402
+ }
403
+ }
404
+ );
405
+
406
+ await harness.run();
407
+
408
+ const [fallback] = assistantMessages(harness.streamEvents);
409
+ assert.equal(harness.executedTools.length, 16);
410
+ assert.ok(fallback.length <= 4000);
411
+ assert.match(fallback, /Latest successful result from action_execute/u);
412
+ assert.match(fallback, /"sequence": 16/u);
413
+ assert.match(fallback, /…\[truncated\]$/u);
414
+ assert.doesNotMatch(fallback, /Limit reached/u);
415
+ });
@@ -0,0 +1,110 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { QueryClient } from "@tanstack/vue-query";
4
+
5
+ import { assistantConversationMessagesQueryKey } from "@jskit-ai/assistant-core/shared";
6
+ import {
7
+ loadConversationTranscript,
8
+ resolveConversationRestorePolicy
9
+ } from "../src/client/support/conversationRestoreSupport.js";
10
+
11
+ test("conversation restore includes completed replies beyond the first transcript page", async () => {
12
+ const requests = [];
13
+ const pages = new Map([
14
+ [1, {
15
+ total: 201,
16
+ totalPages: 2,
17
+ entries: [
18
+ { id: "199", role: "assistant", kind: "tool_result" },
19
+ { id: "200", role: "user", kind: "chat", contentText: "What is happening today?" }
20
+ ]
21
+ }],
22
+ [2, {
23
+ total: 201,
24
+ totalPages: 2,
25
+ entries: [
26
+ { id: "201", role: "assistant", kind: "chat", contentText: "There are 17 bookings today." }
27
+ ]
28
+ }]
29
+ ]);
30
+
31
+ const transcript = await loadConversationTranscript({
32
+ async fetchPage(page, pageSize) {
33
+ requests.push({ page, pageSize });
34
+ return pages.get(page);
35
+ }
36
+ });
37
+
38
+ assert.deepEqual(requests, [
39
+ { page: 1, pageSize: 200 },
40
+ { page: 2, pageSize: 200 }
41
+ ]);
42
+ assert.deepEqual(transcript.entries.map((entry) => entry.id), ["199", "200", "201"]);
43
+ assert.equal(transcript.entries.at(-1).contentText, "There are 17 bookings today.");
44
+ assert.equal(transcript.truncated, false);
45
+ });
46
+
47
+ test("conversation restore keeps the newest bounded transcript pages", async () => {
48
+ const requests = [];
49
+ const pages = new Map([
50
+ [1, { total: 12, totalPages: 6, entries: [{ id: "1" }, { id: "2" }] }],
51
+ [5, { total: 12, totalPages: 6, entries: [{ id: "9" }, { id: "10" }] }],
52
+ [6, { total: 12, totalPages: 6, entries: [{ id: "11" }, { id: "12" }] }]
53
+ ]);
54
+
55
+ const transcript = await loadConversationTranscript({
56
+ pageSize: 2,
57
+ maxEntries: 4,
58
+ async fetchPage(page) {
59
+ requests.push(page);
60
+ return pages.get(page);
61
+ }
62
+ });
63
+
64
+ assert.deepEqual(requests, [1, 5, 6]);
65
+ assert.deepEqual(transcript.entries.map((entry) => entry.id), ["9", "10", "11", "12"]);
66
+ assert.equal(transcript.firstRestoredPage, 5);
67
+ assert.equal(transcript.truncated, true);
68
+ });
69
+
70
+ test("conversation restore reuses every cached transcript page after remount", async () => {
71
+ const backendRequests = [];
72
+ const queryClient = new QueryClient({
73
+ defaultOptions: {
74
+ queries: {
75
+ retry: false,
76
+ staleTime: 60_000
77
+ }
78
+ }
79
+ });
80
+ const scope = { targetSurfaceId: "admin", workspaceId: "7" };
81
+ const pages = new Map([
82
+ [1, { total: 201, totalPages: 2, entries: [{ id: "200" }] }],
83
+ [2, { total: 201, totalPages: 2, entries: [{ id: "201" }] }]
84
+ ]);
85
+ const fetchPage = (page, pageSize) => queryClient.fetchQuery({
86
+ queryKey: assistantConversationMessagesQueryKey(scope, "41", { page, pageSize }),
87
+ queryFn: async () => {
88
+ backendRequests.push(page);
89
+ return pages.get(page);
90
+ },
91
+ staleTime: 60_000
92
+ });
93
+
94
+ const firstRestore = await loadConversationTranscript({ fetchPage });
95
+ const warmRestore = await loadConversationTranscript({ fetchPage });
96
+
97
+ assert.deepEqual(firstRestore.entries.map((entry) => entry.id), ["200", "201"]);
98
+ assert.deepEqual(warmRestore.entries.map((entry) => entry.id), ["200", "201"]);
99
+ assert.deepEqual(backendRequests, [1, 2]);
100
+ });
101
+
102
+ test("conversation restore policy caps page and transcript sizes", () => {
103
+ assert.deepEqual(resolveConversationRestorePolicy({
104
+ pageSize: 100_000,
105
+ maxEntries: 100_000
106
+ }), {
107
+ pageSize: 500,
108
+ maxEntries: 5000
109
+ });
110
+ });