@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 +7 -7
- package/src/client/composables/useAssistantRuntime.js +25 -14
- package/src/client/support/conversationRestoreSupport.js +75 -0
- package/src/server/services/chatService.js +56 -1
- package/test/chatServiceLifecycle.test.js +136 -21
- package/test/conversationRestoreSupport.test.js +110 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
15
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
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.
|
|
20
|
-
"@jskit-ai/http-web": "0.1.
|
|
21
|
-
"@jskit-ai/kernel": "0.1.
|
|
22
|
-
"@jskit-ai/shell-web": "0.1.
|
|
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:
|
|
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
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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(
|
|
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 =
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
|
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.
|
|
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
|
+
});
|