@jskit-ai/assistant-runtime 0.1.143 → 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.
@@ -0,0 +1,415 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createChatService } from "../src/server/services/chatService.js";
4
+
5
+ const APP_CONFIG = Object.freeze({
6
+ surfaceDefinitions: {
7
+ assistant: {
8
+ id: "assistant",
9
+ enabled: true,
10
+ requiresWorkspace: false,
11
+ accessPolicyId: "public"
12
+ }
13
+ },
14
+ assistantSurfaces: {
15
+ assistant: {
16
+ settingsSurfaceId: "assistant",
17
+ configScope: "global"
18
+ }
19
+ }
20
+ });
21
+
22
+ function textCompletion(text) {
23
+ return { text };
24
+ }
25
+
26
+ function toolCompletion(name, sequence, { text = "" } = {}) {
27
+ return {
28
+ text,
29
+ toolCall: {
30
+ id: `tool_call_${sequence}`,
31
+ name,
32
+ arguments: JSON.stringify({ sequence })
33
+ }
34
+ };
35
+ }
36
+
37
+ async function* completionStream(completion = {}) {
38
+ if (completion.text) {
39
+ yield {
40
+ choices: [
41
+ {
42
+ delta: {
43
+ content: completion.text
44
+ }
45
+ }
46
+ ]
47
+ };
48
+ }
49
+
50
+ if (completion.toolCall) {
51
+ yield {
52
+ choices: [
53
+ {
54
+ delta: {
55
+ tool_calls: [
56
+ {
57
+ index: 0,
58
+ id: completion.toolCall.id,
59
+ function: {
60
+ name: completion.toolCall.name,
61
+ arguments: completion.toolCall.arguments
62
+ }
63
+ }
64
+ ]
65
+ }
66
+ }
67
+ ]
68
+ };
69
+ }
70
+ }
71
+
72
+ function createHarness(completions, { executeToolCall = null, tools: configuredTools = null } = {}) {
73
+ const pendingCompletions = [...completions];
74
+ const completionRequests = [];
75
+ const transcriptMessages = [];
76
+ const completedConversations = [];
77
+ const executedTools = [];
78
+ const streamEvents = [];
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
+ }));
90
+
91
+ const chatService = createChatService({
92
+ aiClientFactory: {
93
+ resolveClient() {
94
+ return {
95
+ enabled: true,
96
+ provider: "test",
97
+ defaultModel: "test-model",
98
+ async createChatCompletionStream(request) {
99
+ completionRequests.push({
100
+ messages: structuredClone(request.messages),
101
+ tools: structuredClone(request.tools)
102
+ });
103
+ const completion = pendingCompletions.shift();
104
+ assert.ok(completion, "Expected a queued assistant completion.");
105
+ return completionStream(completion);
106
+ }
107
+ };
108
+ }
109
+ },
110
+ transcriptService: {
111
+ async createConversationForTurn() {
112
+ return {
113
+ conversation: {
114
+ id: "conversation_1"
115
+ }
116
+ };
117
+ },
118
+ async appendMessage(_surface, _conversationId, message) {
119
+ transcriptMessages.push(structuredClone(message));
120
+ },
121
+ async completeConversation(_surface, _conversationId, completion) {
122
+ completedConversations.push(structuredClone(completion));
123
+ }
124
+ },
125
+ serviceToolCatalog: {
126
+ resolveToolSet() {
127
+ return { tools };
128
+ },
129
+ toOpenAiToolSchema(tool) {
130
+ return {
131
+ type: "function",
132
+ function: {
133
+ name: tool.name,
134
+ parameters: tool.parameters
135
+ }
136
+ };
137
+ },
138
+ async executeToolCall(request) {
139
+ executedTools.push(structuredClone(request));
140
+ if (typeof executeToolCall === "function") {
141
+ return executeToolCall(request, executedTools.length);
142
+ }
143
+ return {
144
+ ok: true,
145
+ result: {
146
+ sequence: executedTools.length,
147
+ tool: request.toolName
148
+ }
149
+ };
150
+ }
151
+ },
152
+ assistantConfigService: {
153
+ async resolveSystemPrompt() {
154
+ return "";
155
+ }
156
+ },
157
+ appConfig: APP_CONFIG
158
+ });
159
+
160
+ const streamWriter = {};
161
+ for (const method of [
162
+ "sendMeta",
163
+ "sendAssistantDelta",
164
+ "sendAssistantMessage",
165
+ "sendToolCall",
166
+ "sendToolResult",
167
+ "sendError",
168
+ "sendDone"
169
+ ]) {
170
+ streamWriter[method] = (payload) => {
171
+ streamEvents.push({ method, payload: structuredClone(payload) });
172
+ };
173
+ }
174
+
175
+ async function run(input = "Help me") {
176
+ return chatService.streamChat(
177
+ {
178
+ targetSurfaceId: "assistant",
179
+ messageId: "message_1",
180
+ input,
181
+ history: []
182
+ },
183
+ {
184
+ context: {
185
+ actor: {
186
+ id: "user_1"
187
+ }
188
+ },
189
+ streamWriter
190
+ }
191
+ );
192
+ }
193
+
194
+ return {
195
+ completedConversations,
196
+ completionRequests,
197
+ executedTools,
198
+ run,
199
+ streamEvents,
200
+ transcriptMessages
201
+ };
202
+ }
203
+
204
+ function assistantMessages(events) {
205
+ return events
206
+ .filter((event) => event.method === "sendAssistantMessage")
207
+ .map((event) => event.payload.text);
208
+ }
209
+
210
+ test("progress-only output is retried silently and current-time prompts require a workspace clock", async () => {
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
+ );
238
+
239
+ const result = await harness.run("What day is it today?");
240
+
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
+ );
249
+ assert.deepEqual(assistantMessages(harness.streamEvents), ["It is Tuesday in the workspace timezone."]);
250
+ assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
251
+ assert.match(
252
+ harness.completionRequests[0].messages[0].content,
253
+ /first use any available authoritative workspace clock action/u
254
+ );
255
+ assert.equal(
256
+ harness.completionRequests[1].messages.some((message) => message.content === "Let me query the current time."),
257
+ false
258
+ );
259
+ assert.equal(
260
+ harness.completionRequests[1].messages.some((message) => /Either call the required available tool now/u.test(message.content)),
261
+ true
262
+ );
263
+ assert.deepEqual(
264
+ harness.transcriptMessages
265
+ .filter((message) => message.kind === "chat")
266
+ .map((message) => message.contentText),
267
+ ["What day is it today?", "It is Tuesday in the workspace timezone."]
268
+ );
269
+ });
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
+
315
+ test("native search, contract, and execution workflows can exceed four silent tool rounds", async () => {
316
+ const harness = createHarness([
317
+ toolCompletion("action_search", 1, { text: "I'll search first." }),
318
+ toolCompletion("action_contract", 2, { text: "Let me inspect that contract." }),
319
+ toolCompletion("action_execute", 3, { text: "I'll execute it now." }),
320
+ toolCompletion("action_contract", 4),
321
+ toolCompletion("action_execute", 5),
322
+ textCompletion("The requested operation completed successfully.")
323
+ ]);
324
+
325
+ await harness.run();
326
+
327
+ assert.deepEqual(
328
+ harness.executedTools.map((request) => request.toolName),
329
+ ["action_search", "action_contract", "action_execute", "action_contract", "action_execute"]
330
+ );
331
+ assert.deepEqual(assistantMessages(harness.streamEvents), ["The requested operation completed successfully."]);
332
+ assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
333
+ const assistantToolMessages = harness.completionRequests
334
+ .flatMap((request) => request.messages)
335
+ .filter((message) => Array.isArray(message.tool_calls));
336
+ assert.ok(assistantToolMessages.length > 0);
337
+ assert.equal(assistantToolMessages.every((message) => message.content === ""), true);
338
+ });
339
+
340
+ test("tool-loop exhaustion gives the concise new-conversation instruction", async () => {
341
+ const mainRounds = Array.from({ length: 16 }, (_, index) =>
342
+ toolCompletion("action_execute", index + 1)
343
+ );
344
+ const harness = createHarness(
345
+ [
346
+ ...mainRounds,
347
+ textCompletion("Let me prepare the answer."),
348
+ textCompletion("I'll summarize the result."),
349
+ textCompletion("Checking the final output.")
350
+ ],
351
+ {
352
+ executeToolCall(_request, sequence) {
353
+ return {
354
+ ok: true,
355
+ result: {
356
+ sequence,
357
+ payload: sequence === 16 ? "x".repeat(10_000) : "ok"
358
+ }
359
+ };
360
+ }
361
+ }
362
+ );
363
+
364
+ await harness.run();
365
+
366
+ const finalMessages = assistantMessages(harness.streamEvents);
367
+ assert.equal(harness.executedTools.length, 16);
368
+ assert.equal(harness.completionRequests.length, 19);
369
+ assert.deepEqual(finalMessages, ["Limit reached. Start a new conversation."]);
370
+ assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
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
+ });