@jskit-ai/assistant-runtime 0.1.237 → 0.2.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/CHANGELOG.md +138 -0
- package/locales/en.json +136 -0
- package/package.json +10 -9
- package/src/client/components/AssistantConversationClientElement.vue +11 -6
- package/src/client/components/AssistantSettingsClientElement.vue +19 -14
- package/src/client/components/AssistantSurfaceClientElement.vue +52 -29
- package/src/client/composables/useAssistantConversation.js +48 -29
- package/src/client/composables/useAssistantRuntime.js +18 -18
- package/src/client/i18n.js +10 -0
- package/src/client/support/assistantRuntimeState.js +15 -2
- package/src/client/support/subscribeAssistantConversation.js +20 -2
- package/src/server/actions.js +1 -1
- package/src/server/createAssistantRuntime.js +4 -2
- package/src/server/registerConversationSubscriptions.js +8 -4
- package/src/server/registerFastifyConversations.js +1 -1
- package/src/server/registerRoutes.js +8 -3
- package/src/server/services/assistantConfigService.js +1 -1
- package/src/server/services/chatService.js +38 -25
- package/src/server/services/transcriptService.js +8 -12
- package/src/server/sqlConversationStorage.js +1 -1
- package/src/server/support/assistantErrors.js +16 -1
- package/src/server/support/assistantRouteContext.js +5 -3
- package/src/server/support/assistantSurface.js +1 -1
- package/test/assistantConversationSubscriptions.test.js +65 -1
- package/test/assistantRuntimeState.test.js +16 -0
- package/test/i18nCatalog.test.js +16 -0
- package/test/lazyAppConfig.test.js +24 -2
- package/test/turnRequests.test.js +12 -2
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
|
-
import { AppError } from "@jskit-ai/kernel/server/runtime";
|
|
2
|
+
import { AppError, createValidationError } from "@jskit-ai/kernel/server/runtime";
|
|
3
3
|
import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
4
|
import { resolveWorkspaceSlug } from "@jskit-ai/assistant-core/server";
|
|
5
5
|
import { conversationAttachmentIds } from "@jskit-ai/assistant-core/server/conversation";
|
|
6
6
|
import { ASSISTANT_STREAM_EVENT_TYPES, MAX_HISTORY_MESSAGES, MAX_INPUT_CHARS, isAssistantProgressOnlyText } from "@jskit-ai/assistant-core/shared";
|
|
7
|
-
import { exposedAppErrorDetails, errorStatus } from "../support/assistantErrors.js";
|
|
7
|
+
import { errorMessageFields, exposedAppErrorDetails, errorStatus } from "../support/assistantErrors.js";
|
|
8
8
|
import { requireAssistantSurface } from "../support/assistantSurface.js";
|
|
9
9
|
import { ASSISTANT_STREAM_WRITER_METHODS } from "../support/assistantStreamWriter.js";
|
|
10
10
|
|
|
@@ -44,21 +44,13 @@ function normalizeStreamInput(payload = {}) {
|
|
|
44
44
|
const messageId = normalizeText(source.messageId);
|
|
45
45
|
const input = normalizeText(source.input).slice(0, MAX_INPUT_CHARS);
|
|
46
46
|
if (!messageId || messageId.length > 128) {
|
|
47
|
-
throw
|
|
48
|
-
|
|
49
|
-
fieldErrors: {
|
|
50
|
-
messageId: "messageId is required and must be at most 128 characters."
|
|
51
|
-
}
|
|
52
|
-
}
|
|
47
|
+
throw createValidationError({
|
|
48
|
+
messageId: { code: "assistant.runtime.request.messageIdInvalid", message: "messageId is required and must be at most 128 characters." }
|
|
53
49
|
});
|
|
54
50
|
}
|
|
55
51
|
if (!input) {
|
|
56
|
-
throw
|
|
57
|
-
|
|
58
|
-
fieldErrors: {
|
|
59
|
-
input: "input is required."
|
|
60
|
-
}
|
|
61
|
-
}
|
|
52
|
+
throw createValidationError({
|
|
53
|
+
input: { code: "assistant.runtime.request.inputRequired", message: "input is required." }
|
|
62
54
|
});
|
|
63
55
|
}
|
|
64
56
|
|
|
@@ -75,6 +67,19 @@ function hasStreamWriter(streamWriter) {
|
|
|
75
67
|
return ASSISTANT_STREAM_WRITER_METHODS.every(name => typeof streamWriter?.[name] === "function");
|
|
76
68
|
}
|
|
77
69
|
|
|
70
|
+
// A saved failure keeps its translation fields, except where the kernel would
|
|
71
|
+
// withhold them, so a replay shows the same message in the user's language.
|
|
72
|
+
function savedMessageFields(error) {
|
|
73
|
+
if (!error?.message) return { messageCode: "assistant.runtime.request.failed", messageParams: {} };
|
|
74
|
+
const { params, ...fields } = errorMessageFields(error, { expose: error.code !== "ACTION_PERMISSION_DENIED" });
|
|
75
|
+
return { ...(Object.keys(params).length ? { params } : {}), ...fields };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function streamErrorMessageFields(error, aborted) {
|
|
79
|
+
if (aborted) return { params: {}, messageCode: "assistant.runtime.request.cancelled", messageParams: {} };
|
|
80
|
+
return error?.message ? errorMessageFields(error) : { params: {}, messageCode: "assistant.runtime.request.failed", messageParams: {} };
|
|
81
|
+
}
|
|
82
|
+
|
|
78
83
|
function isAbortError(error) {
|
|
79
84
|
if (!error) {
|
|
80
85
|
return false;
|
|
@@ -187,18 +192,20 @@ function createChatService({
|
|
|
187
192
|
|
|
188
193
|
function replayRequest(record, request, writer) {
|
|
189
194
|
if (!isDeepStrictEqual(record.request, JSON.parse(JSON.stringify(request)))) {
|
|
190
|
-
throw new AppError(409, "This message ID belongs to a different request.");
|
|
195
|
+
throw new AppError(409, "This message ID belongs to a different request.", { code: "assistant.runtime.request.messageIdConflict" });
|
|
191
196
|
}
|
|
192
197
|
const response = record.response || {};
|
|
193
198
|
if (record.status === "running") {
|
|
194
199
|
if (response.meta) writer.sendMeta(response.meta);
|
|
195
200
|
writer.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, code: "assistant_request_unconfirmed",
|
|
196
|
-
message: "This message was already submitted. Its completion is not confirmed; check conversation history before sending new work."
|
|
201
|
+
message: "This message was already submitted. Its completion is not confirmed; check conversation history before sending new work.",
|
|
202
|
+
messageCode: "assistant.runtime.request.unconfirmed", messageParams: {} });
|
|
197
203
|
writer.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: request.messageId, status: "unconfirmed" });
|
|
198
204
|
return { conversationId: response.meta?.conversationId, messageId: request.messageId, status: "unconfirmed" };
|
|
199
205
|
}
|
|
200
206
|
if (response.failure && !response.meta) throw new AppError(response.failure.status, response.failure.message, {
|
|
201
|
-
code: response.failure.code, details: response.failure.details
|
|
207
|
+
code: response.failure.code, params: response.failure.params, details: response.failure.details,
|
|
208
|
+
messageCode: response.failure.messageCode, messageParams: response.failure.messageParams
|
|
202
209
|
});
|
|
203
210
|
if (response.meta) writer.sendMeta(response.meta);
|
|
204
211
|
if (response.answer) writer.sendAssistantMessage(response.answer);
|
|
@@ -216,7 +223,7 @@ function createChatService({
|
|
|
216
223
|
const source = normalizeStreamInput(payload);
|
|
217
224
|
const actor = context.actor;
|
|
218
225
|
const actorUserId = normalizeRecordId(actor?.id, { fallback: null });
|
|
219
|
-
if (!actorUserId) throw new AppError(401, "Authentication required.");
|
|
226
|
+
if (!actorUserId) throw new AppError(401, "Authentication required.", { code: "assistant.runtime.authenticationRequired" });
|
|
220
227
|
if (!hasStreamWriter(options.streamWriter)) throw new Error("assistant.chat.stream requires streamWriter methods.");
|
|
221
228
|
const scope = { actorUserId, surfaceId: assistantSurface.targetSurfaceId,
|
|
222
229
|
workspaceId: normalizeRecordId(workspace?.id || workspace, { fallback: null }) };
|
|
@@ -226,7 +233,7 @@ function createChatService({
|
|
|
226
233
|
const aiClient = await aiClientFactory.resolveClient(assistantSurface.targetSurfaceId, {
|
|
227
234
|
context: assistantContext, integrationId: payload.integrationId
|
|
228
235
|
});
|
|
229
|
-
if (!aiClient.enabled) throw new AppError(503, "Assistant provider is not configured.");
|
|
236
|
+
if (!aiClient.enabled) throw new AppError(503, "Assistant provider is not configured.", { code: "assistant.runtime.provider.notConfigured" });
|
|
230
237
|
const claim = await turnRequests.claim(scope, request);
|
|
231
238
|
if (!claim.acquired) return replayRequest(claim, request, options.streamWriter);
|
|
232
239
|
const response = {};
|
|
@@ -251,7 +258,8 @@ function createChatService({
|
|
|
251
258
|
response.failure = { message: String(error?.message || "Assistant request failed."),
|
|
252
259
|
status: errorStatus(error),
|
|
253
260
|
...(error instanceof AppError ? { code: error.code,
|
|
254
|
-
...(exposedAppErrorDetails(error) ? { details: error.details } : {}) } : {})
|
|
261
|
+
...(exposedAppErrorDetails(error) ? { details: error.details } : {}) } : {}),
|
|
262
|
+
...savedMessageFields(error) };
|
|
255
263
|
await turnRequests.update(claim, response, "failed");
|
|
256
264
|
throw error;
|
|
257
265
|
}
|
|
@@ -280,16 +288,18 @@ function createChatService({
|
|
|
280
288
|
const conversation = conversationResult.conversation;
|
|
281
289
|
const conversationId = conversation?.id;
|
|
282
290
|
if (!conversationId) {
|
|
283
|
-
throw new AppError(500, "Assistant failed to create conversation.");
|
|
291
|
+
throw new AppError(500, "Assistant failed to create conversation.", { code: "assistant.runtime.conversation.createFailed" });
|
|
284
292
|
}
|
|
285
293
|
|
|
286
294
|
const resolvedAttachments = [];
|
|
287
295
|
async function prepareAttachments(attachmentIds) {
|
|
288
296
|
if (attachmentIds === undefined || (Array.isArray(attachmentIds) && !attachmentIds.length)) return;
|
|
289
297
|
try { conversationAttachmentIds(attachmentIds); }
|
|
290
|
-
catch { throw new AppError(400, "Invalid attachment identifiers."); }
|
|
298
|
+
catch { throw new AppError(400, "Invalid attachment identifiers.", { code: "assistant.runtime.attachments.invalidIds" }); }
|
|
291
299
|
if (!attachments?.resolve || !aiClient.supportsAttachments) {
|
|
292
|
-
throw new AppError(400, "File attachments are not configured for this assistant."
|
|
300
|
+
throw new AppError(400, "File attachments are not configured for this assistant.", {
|
|
301
|
+
code: "assistant.runtime.attachments.notConfigured"
|
|
302
|
+
});
|
|
293
303
|
}
|
|
294
304
|
// Preserve the facade's existing pre-admission authorization order.
|
|
295
305
|
// Only these server-resolved materials reach the common file reader.
|
|
@@ -360,12 +370,14 @@ function createChatService({
|
|
|
360
370
|
const turn = snapshot.conversationLog.find(entry => entry.turnId === turnId);
|
|
361
371
|
const status = turn?.metadata?.runtime?.status;
|
|
362
372
|
if (status !== "complete") {
|
|
363
|
-
const
|
|
373
|
+
const reason = turn?.metadata?.runtime?.error || snapshot.error;
|
|
374
|
+
const error = reason ? new Error(reason) : Object.assign(new Error("Assistant request failed."), {
|
|
375
|
+
messageCode: "assistant.runtime.request.failed", messageParams: {} });
|
|
364
376
|
if (status === "cancelled") error.name = "AbortError";
|
|
365
377
|
throw error;
|
|
366
378
|
}
|
|
367
379
|
const text = normalizeText(turn.assistant?.text);
|
|
368
|
-
if (!text) throw new AppError(502, "Assistant returned no output.");
|
|
380
|
+
if (!text) throw new AppError(502, "Assistant returned no output.", { code: "assistant.runtime.request.noOutput" });
|
|
369
381
|
await transcriptService.completeConversation(assistantSurface, conversationId,
|
|
370
382
|
{ status: "completed", metadata: turn.metadata?.completion || {} }, { context: assistantContext, workspace });
|
|
371
383
|
streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text });
|
|
@@ -379,6 +391,7 @@ function createChatService({
|
|
|
379
391
|
streamWriter.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, messageId: source.messageId,
|
|
380
392
|
code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
|
|
381
393
|
message: aborted ? "Assistant request was cancelled." : String(error?.message || "Assistant request failed."),
|
|
394
|
+
...streamErrorMessageFields(error, aborted),
|
|
382
395
|
status: aborted ? 499 : errorStatus(error) });
|
|
383
396
|
streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status });
|
|
384
397
|
return { conversationId, messageId: source.messageId, status };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AppError, parsePositiveInteger } from "@jskit-ai/kernel/server/runtime";
|
|
1
|
+
import { AppError, createValidationError, parsePositiveInteger } from "@jskit-ai/kernel/server/runtime";
|
|
2
2
|
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
3
3
|
import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
4
|
import { normalizeConversationStatus } from "@jskit-ai/assistant-core/shared";
|
|
@@ -13,7 +13,7 @@ const MAX_CONVERSATION_TITLE_LENGTH = 80;
|
|
|
13
13
|
function resolveWorkspaceId(workspace, { required = false } = {}) {
|
|
14
14
|
const workspaceId = normalizeRecordId(workspace?.id || workspace, { fallback: null });
|
|
15
15
|
if (!workspaceId && required) {
|
|
16
|
-
throw new AppError(409, "Workspace selection required.");
|
|
16
|
+
throw new AppError(409, "Workspace selection required.", { code: "assistant.runtime.workspace.selectionRequired" });
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
return workspaceId;
|
|
@@ -22,7 +22,7 @@ function resolveWorkspaceId(workspace, { required = false } = {}) {
|
|
|
22
22
|
function resolveActorUserId(user, { required = false } = {}) {
|
|
23
23
|
const actorUserId = normalizeRecordId(user?.id, { fallback: null });
|
|
24
24
|
if (!actorUserId && required) {
|
|
25
|
-
throw new AppError(401, "Authentication required.");
|
|
25
|
+
throw new AppError(401, "Authentication required.", { code: "assistant.runtime.authenticationRequired" });
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
return actorUserId;
|
|
@@ -101,7 +101,7 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
101
101
|
surfaceId: resolvedAssistantSurface.targetSurfaceId
|
|
102
102
|
});
|
|
103
103
|
if (!existing) {
|
|
104
|
-
throw new AppError(404, "Conversation not found.");
|
|
104
|
+
throw new AppError(404, "Conversation not found.", { code: "assistant.runtime.conversation.notFound" });
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
if (existing.status !== "active") {
|
|
@@ -161,7 +161,7 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
161
161
|
surfaceId: resolvedAssistantSurface.targetSurfaceId
|
|
162
162
|
}, { trx, forUpdate: true });
|
|
163
163
|
if (!existing) {
|
|
164
|
-
throw new AppError(404, "Conversation not found.");
|
|
164
|
+
throw new AppError(404, "Conversation not found.", { code: "assistant.runtime.conversation.notFound" });
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
return conversationsRepository.updateById(normalizedConversationId, {
|
|
@@ -215,12 +215,8 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
215
215
|
});
|
|
216
216
|
const normalizedConversationId = normalizeRecordId(conversationId, { fallback: null });
|
|
217
217
|
if (!normalizedConversationId) {
|
|
218
|
-
throw
|
|
219
|
-
|
|
220
|
-
fieldErrors: {
|
|
221
|
-
conversationId: "conversationId must be a valid record id."
|
|
222
|
-
}
|
|
223
|
-
}
|
|
218
|
+
throw createValidationError({
|
|
219
|
+
conversationId: { code: "assistant.runtime.conversation.idInvalid", message: "conversationId must be a valid record id." }
|
|
224
220
|
});
|
|
225
221
|
}
|
|
226
222
|
|
|
@@ -233,7 +229,7 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
233
229
|
}
|
|
234
230
|
);
|
|
235
231
|
if (!conversation) {
|
|
236
|
-
throw new AppError(404, "Conversation not found.");
|
|
232
|
+
throw new AppError(404, "Conversation not found.", { code: "assistant.runtime.conversation.notFound" });
|
|
237
233
|
}
|
|
238
234
|
|
|
239
235
|
const pagination = normalizePagination(query, {
|
|
@@ -93,7 +93,7 @@ function requireUnambiguous(projection) {
|
|
|
93
93
|
function createSqlConversationStorage({ conversationsRepository, messagesRepository }) {
|
|
94
94
|
async function load(id, trx, write = false) {
|
|
95
95
|
const conversation = await conversationsRepository.findById(id, { trx, forUpdate: write });
|
|
96
|
-
if (!conversation) throw new AppError(404, "Conversation not found.");
|
|
96
|
+
if (!conversation) throw new AppError(404, "Conversation not found.", { code: "assistant.runtime.conversation.notFound" });
|
|
97
97
|
const rows = [];
|
|
98
98
|
for (let page = 1; ; page += 1) {
|
|
99
99
|
const batch = await messagesRepository.listByConversationScope(id, { workspaceId: conversation.workspaceId }, { page, pageSize: 500 }, { trx });
|
|
@@ -10,4 +10,19 @@ function exposedAppErrorDetails(error) {
|
|
|
10
10
|
return String(error.code || "").trim() === "ACTION_PERMISSION_DENIED" ? undefined : error.details;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The kernel error payload's translation fields: the code's params and, where one
|
|
18
|
+
// code covers several sentences, the sentence's own messageCode.
|
|
19
|
+
function errorMessageFields(error, { expose = true } = {}) {
|
|
20
|
+
return {
|
|
21
|
+
params: expose && isRecord(error?.params) ? error.params : {},
|
|
22
|
+
...(expose && error?.messageCode
|
|
23
|
+
? { messageCode: String(error.messageCode), messageParams: isRecord(error.messageParams) ? error.messageParams : {} }
|
|
24
|
+
: {})
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { errorStatus, exposedAppErrorDetails, errorMessageFields };
|
|
@@ -39,7 +39,9 @@ function requireHostSurfaceId(request) {
|
|
|
39
39
|
return hostSurfaceId;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
throw new AppError(400, "Assistant surface header x-jskit-surface is required."
|
|
42
|
+
throw new AppError(400, "Assistant surface header x-jskit-surface is required.", {
|
|
43
|
+
code: "assistant.runtime.route.surfaceHeaderRequired"
|
|
44
|
+
});
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
function resolveRouteRequestState(
|
|
@@ -63,10 +65,10 @@ function resolveRouteRequestState(
|
|
|
63
65
|
: assistantSurface.targetSurfaceId;
|
|
64
66
|
|
|
65
67
|
if (expectsWorkspace !== (requiresWorkspace === true)) {
|
|
66
|
-
throw new AppError(404, "Assistant route not found.");
|
|
68
|
+
throw new AppError(404, "Assistant route not found.", { code: "assistant.runtime.route.notFound" });
|
|
67
69
|
}
|
|
68
70
|
if (hostSurfaceId !== expectedHostSurfaceId) {
|
|
69
|
-
throw new AppError(403, "Assistant route is not available on this surface.");
|
|
71
|
+
throw new AppError(403, "Assistant route is not available on this surface.", { code: "assistant.runtime.route.surfaceForbidden" });
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
return Object.freeze({
|
|
@@ -7,7 +7,7 @@ function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
|
|
|
7
7
|
return assistantSurface;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
throw new AppError(404, "Assistant not found.");
|
|
10
|
+
throw new AppError(404, "Assistant not found.", { code: "assistant.runtime.route.assistantNotFound" });
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export { requireAssistantSurface };
|
|
@@ -343,7 +343,7 @@ test("the real ten-second subscription deadline releases its late observer and R
|
|
|
343
343
|
while (!view.errors.length || f.active() !== 0) {
|
|
344
344
|
await once(f.changes, "change", { signal: AbortSignal.timeout(12_000) });
|
|
345
345
|
}
|
|
346
|
-
assert.equal(view.errors[0].message, "Chat updates could not reconnect. Reload chat to try
|
|
346
|
+
assert.equal(view.errors[0].message, "Chat updates could not reconnect. Retrying automatically; Reload chat to try now.");
|
|
347
347
|
assert.equal(view.errors[0].cause.message, "operation has timed out");
|
|
348
348
|
assert.equal(socket.id, socketId);
|
|
349
349
|
assert.equal(socket.connected, true);
|
|
@@ -1012,6 +1012,70 @@ test("host subscription policy retains authenticated actor metadata and per-even
|
|
|
1012
1012
|
assert.ok(cookies.every(value => value === "host-session"));
|
|
1013
1013
|
});
|
|
1014
1014
|
|
|
1015
|
+
test("a transient subscription failure retries by itself while connected; denials never retry", t => {
|
|
1016
|
+
// Production: after a laptop woke, the resubscribe acknowledgement missed its
|
|
1017
|
+
// deadline while the socket stayed connected, and the chat stayed stuck.
|
|
1018
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
1019
|
+
const socket = new EventEmitter();
|
|
1020
|
+
socket.connected = true;
|
|
1021
|
+
const requests = [], states = [], errors = [];
|
|
1022
|
+
socket.timeout = () => ({ emit(name, input, acknowledge) { requests.push({ input, acknowledge }); } });
|
|
1023
|
+
const dispose = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
|
|
1024
|
+
read() {}, onState: state => states.push(state), onError: error => errors.push(error) });
|
|
1025
|
+
const state = { id: "chat", conversationLog: [], streaming: { revision: 0, messages: [] } };
|
|
1026
|
+
requests[0].acknowledge(new Error("operation has timed out"));
|
|
1027
|
+
assert.match(errors[0].message, /Retrying automatically/u);
|
|
1028
|
+
t.mock.timers.tick(999);
|
|
1029
|
+
assert.equal(requests.length, 1);
|
|
1030
|
+
t.mock.timers.tick(1);
|
|
1031
|
+
assert.equal(requests.length, 2);
|
|
1032
|
+
assert.deepEqual(requests[1].input, requests[0].input);
|
|
1033
|
+
requests[1].acknowledge(null, { ok: false, error: "Retry shortly.", status: 503 });
|
|
1034
|
+
t.mock.timers.tick(1_999);
|
|
1035
|
+
assert.equal(requests.length, 2, "Each retry waits twice as long");
|
|
1036
|
+
t.mock.timers.tick(1);
|
|
1037
|
+
assert.equal(requests.length, 3);
|
|
1038
|
+
requests[2].acknowledge(null, { ok: true, streamEpoch: "recovered", state });
|
|
1039
|
+
assert.equal(states.length, 1);
|
|
1040
|
+
// A reconnect replaces a pending retry, and success restarts the backoff.
|
|
1041
|
+
socket.emit("disconnect");
|
|
1042
|
+
socket.emit("connect");
|
|
1043
|
+
requests[3].acknowledge(new Error("operation has timed out"));
|
|
1044
|
+
socket.connected = false;
|
|
1045
|
+
socket.emit("disconnect");
|
|
1046
|
+
t.mock.timers.tick(60_000);
|
|
1047
|
+
assert.equal(requests.length, 4, "A disconnected socket waits for its own reconnect");
|
|
1048
|
+
socket.connected = true;
|
|
1049
|
+
socket.emit("connect");
|
|
1050
|
+
requests[4].acknowledge(null, { ok: false, error: "Access denied.", status: 403 });
|
|
1051
|
+
t.mock.timers.tick(60_000);
|
|
1052
|
+
assert.equal(requests.length, 5, "A denial never retries");
|
|
1053
|
+
dispose.reload();
|
|
1054
|
+
requests[5].acknowledge(new Error("operation has timed out"));
|
|
1055
|
+
t.mock.timers.tick(999);
|
|
1056
|
+
dispose();
|
|
1057
|
+
t.mock.timers.tick(60_000);
|
|
1058
|
+
assert.equal(requests.length, 6, "Disposal cancels the pending retry");
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
test("a retried resubscribe keeps the loaded history its caller preserved", t => {
|
|
1062
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
1063
|
+
const socket = new EventEmitter();
|
|
1064
|
+
socket.connected = true;
|
|
1065
|
+
const requests = [], publications = [];
|
|
1066
|
+
socket.timeout = () => ({ emit(name, input, acknowledge) { requests.push(acknowledge); } });
|
|
1067
|
+
const subscription = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
|
|
1068
|
+
read() {}, onState: (state, options) => publications.push(options) });
|
|
1069
|
+
const state = { id: "chat", conversationLog: [], streaming: { revision: 0, messages: [] } };
|
|
1070
|
+
requests[0](null, { ok: true, streamEpoch: "first", state });
|
|
1071
|
+
subscription.reload({ resubscribe: true });
|
|
1072
|
+
requests[1](new Error("operation has timed out"));
|
|
1073
|
+
t.mock.timers.tick(1_000);
|
|
1074
|
+
requests[2](null, { ok: true, streamEpoch: "second", state });
|
|
1075
|
+
assert.deepEqual(publications, [{ initial: true, canonical: true }, { initial: false, canonical: false }]);
|
|
1076
|
+
subscription();
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1015
1079
|
for (const status of [401, 403, 503]) {
|
|
1016
1080
|
test(`subscription read failure ${status} fences denied updates but retains transient history`, async t => {
|
|
1017
1081
|
const changes = new EventEmitter();
|
|
@@ -179,3 +179,19 @@ test("restored tool calls retain their association with the reply they preceded"
|
|
|
179
179
|
assert.equal(turn.messages[1].text, "Search — done");
|
|
180
180
|
assert.doesNotMatch(JSON.stringify(turn), /secret|private/);
|
|
181
181
|
});
|
|
182
|
+
|
|
183
|
+
test("tool activity follows the application's active language and keeps unknown statuses as sent", async (t) => {
|
|
184
|
+
const { createI18n } = await import("vue-i18n");
|
|
185
|
+
const { setJskitI18n } = await import("@jskit-ai/kernel/client");
|
|
186
|
+
const { mapAssistantConversationTurns, toolStatusText } = await import("../src/client/support/assistantRuntimeState.js");
|
|
187
|
+
const i18n = createI18n({ legacy: false, locale: "fr", missingWarn: false, fallbackWarn: false, messages: {
|
|
188
|
+
fr: { jskit: { assistant: { runtime: { tool: { progress: "{status} : {name}", status: { pending: "en attente" } } } } } }
|
|
189
|
+
} });
|
|
190
|
+
setJskitI18n(i18n);
|
|
191
|
+
t.after(() => setJskitI18n(null));
|
|
192
|
+
const messages = [{ id: "answer-1", role: "assistant", status: "streaming", text: "" }];
|
|
193
|
+
const tools = [{ id: "tool-1", messageId: "answer-1", name: "Search records", status: "pending" }];
|
|
194
|
+
assert.equal(mapAssistantConversationTurns(messages, tools)[0].messages.at(-1).text, "en attente : Search records");
|
|
195
|
+
assert.equal(toolStatusText("done"), "done");
|
|
196
|
+
assert.equal(toolStatusText("paused"), "paused");
|
|
197
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { evaluateI18nCatalogContract } from "../../../tooling/test-support/i18nCatalogContract.mjs";
|
|
6
|
+
|
|
7
|
+
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const REPO_ROOT = path.resolve(TEST_DIRECTORY, "..", "..", "..");
|
|
9
|
+
|
|
10
|
+
test("assistant-runtime English catalog matches its source", () => {
|
|
11
|
+
const { problems } = evaluateI18nCatalogContract({
|
|
12
|
+
repoRoot: REPO_ROOT,
|
|
13
|
+
packageDir: path.join(REPO_ROOT, "packages", "assistant-runtime")
|
|
14
|
+
});
|
|
15
|
+
assert.deepEqual(problems, []);
|
|
16
|
+
});
|
|
@@ -317,7 +317,7 @@ test("registerRoutes returns clear AppError payload for pre-stream assistant fai
|
|
|
317
317
|
},
|
|
318
318
|
executeAction: async ({ input }) => {
|
|
319
319
|
capturedInput = input;
|
|
320
|
-
throw new AppError(503, "Assistant provider is not configured.");
|
|
320
|
+
throw new AppError(503, "Assistant provider is not configured.", { code: "assistant.runtime.provider.notConfigured" });
|
|
321
321
|
}
|
|
322
322
|
},
|
|
323
323
|
reply
|
|
@@ -326,7 +326,8 @@ test("registerRoutes returns clear AppError payload for pre-stream assistant fai
|
|
|
326
326
|
assert.equal(reply.statusCode, 503);
|
|
327
327
|
assert.deepEqual(reply.payload, {
|
|
328
328
|
error: "Assistant provider is not configured.",
|
|
329
|
-
code: "
|
|
329
|
+
code: "assistant.runtime.provider.notConfigured",
|
|
330
|
+
params: {}
|
|
330
331
|
});
|
|
331
332
|
assert.deepEqual(capturedInput, {
|
|
332
333
|
targetSurfaceId: "admin",
|
|
@@ -335,6 +336,27 @@ test("registerRoutes returns clear AppError payload for pre-stream assistant fai
|
|
|
335
336
|
input: "hello",
|
|
336
337
|
history: []
|
|
337
338
|
});
|
|
339
|
+
|
|
340
|
+
// Coded conversation failures keep their per-sentence messageCode, like the kernel payload.
|
|
341
|
+
const busy = Object.assign(new Error("Wait for the current turn."), {
|
|
342
|
+
code: "conversation_busy", statusCode: 409, messageCode: "assistant.conversation.waitForTurn", messageParams: {}
|
|
343
|
+
});
|
|
344
|
+
const hidden = Object.assign(new Error("Private storage detail."), {
|
|
345
|
+
code: "conversation_storage_unavailable", statusCode: 503, messageCode: "assistant.conversation.private", messageParams: {}
|
|
346
|
+
});
|
|
347
|
+
for (const [failure, status, payload] of [
|
|
348
|
+
[busy, 409, { error: busy.message, code: "conversation_busy", params: {}, messageCode: busy.messageCode, messageParams: {} }],
|
|
349
|
+
[hidden, 503, { error: "Internal server error.", code: "conversation_storage_unavailable", params: {} }]
|
|
350
|
+
]) {
|
|
351
|
+
await route.handler({
|
|
352
|
+
raw: { on() {}, off() {} },
|
|
353
|
+
headers: { "x-jskit-surface": "admin" },
|
|
354
|
+
input: { params: { workspaceSlug: "dogandgroom", surfaceId: "admin" }, body: { messageId: "msg_2", input: "hello", history: [] } },
|
|
355
|
+
executeAction: async () => { throw failure; }
|
|
356
|
+
}, reply);
|
|
357
|
+
assert.equal(reply.statusCode, status);
|
|
358
|
+
assert.deepEqual(reply.payload, payload);
|
|
359
|
+
}
|
|
338
360
|
});
|
|
339
361
|
|
|
340
362
|
test("chat service uses explicit app config when conversations are listed", async () => {
|
|
@@ -117,7 +117,9 @@ test("an interrupted claim is never stolen after restart, and provider failures
|
|
|
117
117
|
await repository.claim(scope, { ...request, conversationId: null, integrationId: "" });
|
|
118
118
|
let calls = 0;
|
|
119
119
|
const chat = service(createRepository(connect()), { provider: () => { calls++; throw new Error("Provider failed."); } });
|
|
120
|
-
|
|
120
|
+
const unconfirmed = [];
|
|
121
|
+
assert.equal((await run(chat, request, { events: unconfirmed })).status, "unconfirmed");
|
|
122
|
+
assert.equal(unconfirmed.find(item => item.method === "sendError").event.messageCode, "assistant.runtime.request.unconfirmed");
|
|
121
123
|
assert.equal(calls, 0);
|
|
122
124
|
const failure = { ...request, messageId: "failing-message" };
|
|
123
125
|
assert.equal((await run(chat, failure)).status, "failed");
|
|
@@ -125,6 +127,14 @@ test("an interrupted claim is never stolen after restart, and provider failures
|
|
|
125
127
|
assert.equal((await run(chat, failure, { events: replay })).status, "failed");
|
|
126
128
|
assert.equal(calls, 1);
|
|
127
129
|
assert.match(replay.find(item => item.method === "sendError").event.message, /Provider failed/);
|
|
128
|
-
await assert.rejects(run(chat, { ...request, messageId: "x".repeat(129) }),
|
|
130
|
+
await assert.rejects(run(chat, { ...request, messageId: "x".repeat(129) }), error => {
|
|
131
|
+
assert.equal(error.message, "Validation failed.");
|
|
132
|
+
assert.equal(error.code, "validation_failed");
|
|
133
|
+
assert.deepEqual(error.details, {
|
|
134
|
+
fieldErrors: { messageId: "messageId is required and must be at most 128 characters." },
|
|
135
|
+
fieldErrorCodes: { messageId: { code: "assistant.runtime.request.messageIdInvalid", params: {} } }
|
|
136
|
+
});
|
|
137
|
+
return true;
|
|
138
|
+
});
|
|
129
139
|
assert.equal(calls, 1);
|
|
130
140
|
});
|