@jskit-ai/assistant-runtime 0.1.238 → 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.
@@ -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 new AppError(400, "Validation failed.", {
48
- details: {
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 new AppError(400, "Validation failed.", {
57
- details: {
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 error = new Error(turn?.metadata?.runtime?.error || snapshot.error || "Assistant request failed.");
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 new AppError(400, "Validation failed.", {
219
- details: {
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
- export { errorStatus, exposedAppErrorDetails };
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 };
@@ -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: "APP_ERROR"
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
- assert.equal((await run(chat)).status, "unconfirmed");
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) }), /Validation failed/);
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
  });