@jskit-ai/assistant-core 0.1.243 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/examples/conversation/package.json +5 -5
  3. package/locales/en.json +383 -0
  4. package/package.json +7 -6
  5. package/src/client/components/AssistantSettingsFormCard.vue +4 -1
  6. package/src/client/conversation/AssistantAttachmentPreview.vue +6 -3
  7. package/src/client/conversation/AssistantAttachmentQueue.vue +36 -37
  8. package/src/client/conversation/AssistantComposerActions.vue +6 -3
  9. package/src/client/conversation/AssistantComposerSupport.vue +8 -5
  10. package/src/client/conversation/AssistantConversationElement.vue +16 -13
  11. package/src/client/conversation/AssistantConversationStatus.vue +5 -2
  12. package/src/client/conversation/AssistantGoalControl.vue +22 -16
  13. package/src/client/conversation/AssistantMessageAttachments.vue +7 -4
  14. package/src/client/conversation/AssistantModelControl.vue +23 -20
  15. package/src/client/conversation/AssistantProgress.vue +6 -3
  16. package/src/client/conversation/AssistantQuestionInputs.vue +9 -6
  17. package/src/client/conversation/AssistantTranscript.vue +39 -28
  18. package/src/client/conversation/LongTextPreviewBlocks.vue +10 -2
  19. package/src/client/conversation/attachmentSize.js +38 -0
  20. package/src/client/conversation/messageDelivery.js +14 -5
  21. package/src/client/conversation/submitText.js +11 -4
  22. package/src/client/conversation/useAssistantAttachments.js +40 -30
  23. package/src/client/conversation/useAssistantSuggestions.js +4 -1
  24. package/src/client/i18n.js +6 -0
  25. package/src/client/lib/assistantApi.js +14 -2
  26. package/src/server/conversation/attachments.js +24 -14
  27. package/src/server/conversation/claudeProcess.js +4 -2
  28. package/src/server/conversation/claudeTurn.js +14 -11
  29. package/src/server/conversation/codexConversations.js +4 -0
  30. package/src/server/conversation/codexGoalCommands.js +9 -9
  31. package/src/server/conversation/codexHelperExecution.js +4 -0
  32. package/src/server/conversation/codexHelperThreads.js +2 -0
  33. package/src/server/conversation/codexMessageCommands.js +7 -1
  34. package/src/server/conversation/codexTurnControl.js +2 -1
  35. package/src/server/conversation/codexTurnState.js +2 -0
  36. package/src/server/conversation/continuity.js +16 -6
  37. package/src/server/conversation/openCodeProcess.js +12 -3
  38. package/src/server/conversation/runtime.js +193 -141
  39. package/src/server/lib/ndjson.js +19 -1
  40. package/src/server/lib/providers/common.js +7 -2
  41. package/src/shared/conversation/numberedQuestionSugar.js +7 -1
  42. package/src/shared/conversation/turns.js +10 -2
  43. package/test/i18nCatalog.test.js +22 -0
  44. package/test/openCodeProcess.test.js +15 -1
@@ -1,3 +1,8 @@
1
+ // Client-made errors keep their English message; messageCode lets a caller show
2
+ // them in the active language through useJskitText().serverMessage(error).
3
+ const CANNOT_SEND_YET = "Your words are in the chat box, but this conversation cannot send yet.";
4
+ const submissionError = (message, { messageCode }) => Object.assign(new Error(message), { messageCode, messageParams: {} });
5
+
1
6
  /** A companion's text enters the same draft and send action as keyboard input. */
2
7
  export function createAssistantTextSubmission({ getState, setDraft, submit, afterDraftChange = async () => {} }) {
3
8
  let revision = 0;
@@ -5,7 +10,9 @@ export function createAssistantTextSubmission({ getState, setDraft, submit, afte
5
10
  const request = ++revision;
6
11
  const value = String(text || "").trim();
7
12
  const initial = getState();
8
- if (!value || !initial.active || signal?.aborted) throw new Error("The chat composer is not ready yet.");
13
+ if (!value || !initial.active || signal?.aborted) {
14
+ throw submissionError("The chat composer is not ready yet.", { messageCode: "assistant.composer.notReady" });
15
+ }
9
16
  const hasDraft = Boolean(initial.draft?.trim());
10
17
  const writtenValue = hasDraft ? `${initial.draft}\n${value}` : value;
11
18
  setDraft(writtenValue);
@@ -15,14 +22,14 @@ export function createAssistantTextSubmission({ getState, setDraft, submit, afte
15
22
  while (true) {
16
23
  const state = getState();
17
24
  if (signal?.aborted || request !== revision || !state.active || state.id !== initial.id || state.draft !== writtenValue) {
18
- throw new Error("The chat changed before sending. Review the draft before sending.");
25
+ throw submissionError("The chat changed before sending. Review the draft before sending.", { messageCode: "assistant.composer.chatChanged" });
19
26
  }
20
27
  if (state.canSend) {
21
28
  const result = await submit();
22
- if (result === false) throw new Error("Your words are in the chat box, but this conversation cannot send yet.");
29
+ if (result === false) throw submissionError(CANNOT_SEND_YET, { messageCode: "assistant.composer.cannotSendYet" });
23
30
  return state.turnActive ? "steer" : "send";
24
31
  }
25
- if (Date.now() >= deadline) throw new Error("Your words are in the chat box, but this conversation cannot send yet.");
32
+ if (Date.now() >= deadline) throw submissionError(CANNOT_SEND_YET, { messageCode: "assistant.composer.cannotSendYet" });
26
33
  await new Promise((resolve) => setTimeout(resolve, 16));
27
34
  }
28
35
  };
@@ -8,6 +8,16 @@ import {
8
8
  watch
9
9
  } from "vue";
10
10
  import { ASSISTANT_ATTACHMENT_MAX_ITEMS } from "../../shared/conversation/attachments.js";
11
+ import { useJskitText } from "../i18n.js";
12
+ import { attachmentLimitText } from "./attachmentSize.js";
13
+
14
+ const { serverMessage, t } = useJskitText();
15
+
16
+ // An upload or preparation failure in the active language; a thrown error keeps
17
+ // its own English when it has no translation.
18
+ function attachmentErrorText(error, fallback) {
19
+ return String((error && typeof error === "object" && serverMessage(error)) || error?.message || error || fallback);
20
+ }
11
21
 
12
22
  function assistantAttachmentSessionId(sessionId) {
13
23
  return String(toValue(sessionId) || "").trim();
@@ -217,9 +227,6 @@ function useAssistantAttachments({
217
227
  if (![maxBytes, maxItems, uploadConcurrency].every(value => Number.isSafeInteger(value) && value > 0)) {
218
228
  throw new TypeError("Attachment limits must be positive integers.");
219
229
  }
220
- const maxSizeLabel = maxBytes % (1024 * 1024) === 0
221
- ? `${maxBytes / (1024 * 1024)} MiB`
222
- : `${maxBytes / 1_000_000} MB`;
223
230
  const attachments = ref([]);
224
231
  const queueItems = ref([]);
225
232
  const dragDepth = ref(0);
@@ -266,10 +273,13 @@ function useAssistantAttachments({
266
273
  };
267
274
  });
268
275
 
269
- function reportAttachmentError(error, fallbackMessage = "Attachment upload failed.", {
276
+ const tooLarge = (row) => t("jskit.assistant.attachments.error.tooLarge", { fileName: row.fileName, limit: attachmentLimitText(maxBytes) });
277
+ const tooMany = () => t("jskit.assistant.attachments.error.tooMany", { count: maxItems });
278
+
279
+ function reportAttachmentError(error, fallbackMessage = t("jskit.assistant.attachments.error.uploadFailed"), {
270
280
  notify = true
271
281
  } = {}) {
272
- const message = String(error?.message || error || fallbackMessage);
282
+ const message = attachmentErrorText(error, fallbackMessage);
273
283
  status.value = message;
274
284
  if (notify && typeof onError === "function") {
275
285
  try {
@@ -319,7 +329,7 @@ function useAssistantAttachments({
319
329
 
320
330
  function trackCleanup(task) {
321
331
  const cleanup = Promise.resolve(task).catch((error) => {
322
- reportAttachmentError(error, "Attachment cleanup failed.");
332
+ reportAttachmentError(error, t("jskit.assistant.attachments.error.cleanupFailed"));
323
333
  return false;
324
334
  });
325
335
  cleanupTasks.add(cleanup);
@@ -334,7 +344,7 @@ function useAssistantAttachments({
334
344
  }
335
345
  const response = await deleteAttachment(currentSessionId, attachmentId);
336
346
  if (response?.ok === false) {
337
- throw new Error(attachmentUploadError(response) || "Attachment cleanup failed.");
347
+ throw new Error(attachmentUploadError(response) || t("jskit.assistant.attachments.error.cleanupFailed"));
338
348
  }
339
349
  return true;
340
350
  }
@@ -424,11 +434,11 @@ function useAssistantAttachments({
424
434
  }
425
435
  row.activeHandoffToken = null;
426
436
  removeReadyReceipt(row);
427
- row.error = String(error?.message || error || "Uploaded attachment could not be used.");
437
+ row.error = attachmentErrorText(error, t("jskit.assistant.attachments.error.unusable"));
428
438
  row.failureStage = "handoff";
429
439
  row.phase = "failed";
430
440
  settleWaiter(row, null, waiter);
431
- reportAttachmentError(error, "Uploaded attachment could not be used.", { notify: false });
441
+ reportAttachmentError(error, t("jskit.assistant.attachments.error.unusable"), { notify: false });
432
442
  return null;
433
443
  }
434
444
  }
@@ -468,13 +478,13 @@ function useAssistantAttachments({
468
478
  await handOffUploadedAttachment(row, receipt, attempt.waiter);
469
479
  } else {
470
480
  const error = uploadError || new Error(
471
- attachmentUploadError(uploaded) || "Attachment upload failed."
481
+ attachmentUploadError(uploaded) || t("jskit.assistant.attachments.error.uploadFailed")
472
482
  );
473
- row.error = String(error?.message || error || "Attachment upload failed.");
483
+ row.error = attachmentErrorText(error, t("jskit.assistant.attachments.error.uploadFailed"));
474
484
  row.failureStage = "upload";
475
485
  row.phase = "failed";
476
486
  settleWaiter(row, null, attempt.waiter);
477
- reportAttachmentError(error, "Attachment upload failed.", { notify: false });
487
+ reportAttachmentError(error, t("jskit.assistant.attachments.error.uploadFailed"), { notify: false });
478
488
  }
479
489
  pumpQueue();
480
490
  }
@@ -572,7 +582,7 @@ function useAssistantAttachments({
572
582
  return Promise.resolve(null);
573
583
  }
574
584
  if (row.phase === "cancelled" && retainedItemCount.value >= maxItems) {
575
- reportAttachmentError(`A message can keep at most ${maxItems} attachments.`);
585
+ reportAttachmentError(tooMany());
576
586
  return Promise.resolve(null);
577
587
  }
578
588
  if (row.failureStage === "handoff" && row.receipt) {
@@ -588,10 +598,10 @@ function useAssistantAttachments({
588
598
  return waiter.promise;
589
599
  }
590
600
  if (row.size > maxBytes) {
591
- row.error = `${row.fileName} is larger than the ${maxSizeLabel} attachment limit.`;
601
+ row.error = tooLarge(row);
592
602
  row.failureStage = "validation";
593
603
  row.phase = "failed";
594
- reportAttachmentError(row.error, "Attachment upload failed.", { notify: false });
604
+ reportAttachmentError(row.error, t("jskit.assistant.attachments.error.uploadFailed"), { notify: false });
595
605
  return Promise.resolve(null);
596
606
  }
597
607
  row.bytesSent = 0;
@@ -763,18 +773,20 @@ function useAssistantAttachments({
763
773
  function queuePreparedFile(row, file, waiter = row.waiter) {
764
774
  const preparedFile = assistantAttachmentFiles([file])[0];
765
775
  if (!preparedFile) {
766
- throw new Error("The prepared attachment did not produce a file.");
776
+ throw Object.assign(new Error("The prepared attachment did not produce a file."), {
777
+ messageCode: "assistant.attachments.error.noFile", messageParams: {}
778
+ });
767
779
  }
768
780
  row.file = preparedFile;
769
781
  row.fileName = attachmentFileName(preparedFile);
770
782
  row.size = Number(preparedFile.size) || 0;
771
783
  row.totalBytes = row.size;
772
784
  if (row.size > maxBytes) {
773
- row.error = `${row.fileName} is larger than the ${maxSizeLabel} attachment limit.`;
785
+ row.error = tooLarge(row);
774
786
  row.failureStage = "validation";
775
787
  row.phase = "failed";
776
788
  settleWaiter(row, null, waiter);
777
- reportAttachmentError(row.error, "Attachment upload failed.", { notify: false });
789
+ reportAttachmentError(row.error, t("jskit.assistant.attachments.error.uploadFailed"), { notify: false });
778
790
  return false;
779
791
  }
780
792
  row.phase = "queued";
@@ -807,11 +819,11 @@ function useAssistantAttachments({
807
819
  try {
808
820
  queuePreparedFile(row, file, waiter);
809
821
  } catch (error) {
810
- row.error = String(error?.message || error || "Attachment preparation failed.");
822
+ row.error = attachmentErrorText(error, t("jskit.assistant.attachments.error.preparationFailed"));
811
823
  row.failureStage = "preparing";
812
824
  row.phase = "failed";
813
825
  settleWaiter(row, null, waiter);
814
- reportAttachmentError(error, "Attachment preparation failed.", { notify: false });
826
+ reportAttachmentError(error, t("jskit.assistant.attachments.error.preparationFailed"), { notify: false });
815
827
  }
816
828
  return file;
817
829
  }, (error) => {
@@ -825,10 +837,10 @@ function useAssistantAttachments({
825
837
  row.phase = "cancelled";
826
838
  row.error = "";
827
839
  } else {
828
- row.error = String(error?.message || error || "Attachment preparation failed.");
840
+ row.error = attachmentErrorText(error, t("jskit.assistant.attachments.error.preparationFailed"));
829
841
  row.failureStage = "preparing";
830
842
  row.phase = "failed";
831
- reportAttachmentError(error, "Attachment preparation failed.", { notify: false });
843
+ reportAttachmentError(error, t("jskit.assistant.attachments.error.preparationFailed"), { notify: false });
832
844
  }
833
845
  settleWaiter(row, null, waiter);
834
846
  return null;
@@ -836,13 +848,13 @@ function useAssistantAttachments({
836
848
  }
837
849
 
838
850
  function uploadFileProducer({
839
- fileName = "Preparing attachment…",
851
+ fileName = t("jskit.assistant.attachments.preparingName"),
840
852
  produce
841
853
  } = {}) {
842
854
  const currentSessionId = assistantAttachmentSessionId(sessionId);
843
855
  if (!canAddFiles.value || typeof produce !== "function") {
844
856
  if (atCapacity.value) {
845
- reportAttachmentError(`A message can keep at most ${maxItems} attachments.`);
857
+ reportAttachmentError(tooMany());
846
858
  }
847
859
  return Promise.resolve(null);
848
860
  }
@@ -875,7 +887,7 @@ function useAssistantAttachments({
875
887
  const availableSlots = Math.max(0, maxItems - retainedItemCount.value);
876
888
  const acceptedFiles = uploadableFiles.slice(0, availableSlots);
877
889
  if (acceptedFiles.length < uploadableFiles.length) {
878
- reportAttachmentError(`A message can keep at most ${maxItems} attachments.`);
890
+ reportAttachmentError(tooMany());
879
891
  }
880
892
 
881
893
  const waiters = [];
@@ -884,11 +896,11 @@ function useAssistantAttachments({
884
896
  const waiter = row.waiter;
885
897
  waiters.push(waiter.promise);
886
898
  if (row.size > maxBytes) {
887
- row.error = `${row.fileName} is larger than the ${maxSizeLabel} attachment limit.`;
899
+ row.error = tooLarge(row);
888
900
  row.failureStage = "validation";
889
901
  row.phase = "failed";
890
902
  settleWaiter(row, null);
891
- reportAttachmentError(row.error, "Attachment upload failed.", { notify: false });
903
+ reportAttachmentError(row.error, t("jskit.assistant.attachments.error.uploadFailed"), { notify: false });
892
904
  } else {
893
905
  uploadQueue.push(row);
894
906
  }
@@ -910,9 +922,7 @@ function useAssistantAttachments({
910
922
  const files = assistantAttachmentFilesFromPasteEvent(event);
911
923
  if (files.length < 1) {
912
924
  if (assistantAttachmentClipboardLocalFileReference(event)) {
913
- reportAttachmentError(
914
- "Copied local files cannot be pasted from this browser. Drop the file or use Attach files."
915
- );
925
+ reportAttachmentError(t("jskit.assistant.attachments.error.localFilePaste"));
916
926
  }
917
927
  return [];
918
928
  }
@@ -1,4 +1,7 @@
1
1
  import { computed, onScopeDispose, ref, toValue, unref, watch } from "vue";
2
+ import { useJskitText } from "../i18n.js";
3
+
4
+ const { serverMessage } = useJskitText();
2
5
 
3
6
  /** Suggestion requests use an application-supplied agent; credentials stay on its server. */
4
7
  export function useAssistantSuggestions({
@@ -53,7 +56,7 @@ export function useAssistantSuggestions({
53
56
  }
54
57
  items.value = result.map(({ label, prompt }) => ({ label: label.trim(), prompt: prompt.trim() }));
55
58
  } catch (failure) {
56
- if (request === controller && !controller.signal.aborted) error.value = String(failure.message || failure);
59
+ if (request === controller && !controller.signal.aborted) error.value = String(serverMessage(failure) || failure);
57
60
  } finally {
58
61
  if (request === controller) {
59
62
  request = null;
@@ -0,0 +1,6 @@
1
+ import { registerJskitMessages, useJskitText } from "@jskit-ai/kernel/client";
2
+ import en from "../../locales/en.json" with { type: "json" };
3
+
4
+ registerJskitMessages(en);
5
+
6
+ export { useJskitText };
@@ -9,10 +9,22 @@ import {
9
9
  normalizeAssistantStreamEventType
10
10
  } from "../../shared/index.js";
11
11
 
12
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
13
+
14
+ // A stream error event keeps its code and English message, plus the params and
15
+ // messageCode useJskitText().serverMessage(error) needs to show it in the active language.
12
16
  function buildStreamEventError(event) {
13
- const message = String(event?.message || "Assistant request failed.");
14
- const error = new Error(message);
17
+ const sent = String(event?.message || "");
18
+ const error = new Error(sent || "Assistant request failed.");
15
19
  error.code = String(event?.code || "assistant_stream_error");
20
+ error.params = isRecord(event?.params) ? event.params : {};
21
+ if (event?.messageCode) {
22
+ error.messageCode = String(event.messageCode);
23
+ error.messageParams = isRecord(event.messageParams) ? event.messageParams : {};
24
+ } else if (!sent) {
25
+ error.messageCode = "assistant.api.requestFailed";
26
+ error.messageParams = {};
27
+ }
16
28
  error.status = Number(event?.status || 500);
17
29
  error.event = event && typeof event === "object" ? { ...event } : null;
18
30
  return error;
@@ -1,15 +1,18 @@
1
1
  import path from "node:path";
2
2
  import { ASSISTANT_ATTACHMENT_ID_MAX_LENGTH, ASSISTANT_ATTACHMENT_MAX_ITEMS, conversationAttachmentManifest } from "../../shared/conversation/attachments.js";
3
3
 
4
- function invalid(message) {
5
- return Object.assign(new Error(message), { code: "conversation_invalid_attachments", statusCode: 400 });
4
+ // messageCode names the sentence; the shared code stays for callers.
5
+ function invalid(message, { messageCode, messageParams = {} }) {
6
+ return Object.assign(new Error(message), { code: "conversation_invalid_attachments", statusCode: 400, messageCode, messageParams });
6
7
  }
7
8
 
8
9
  export function conversationAttachmentIds(value = []) {
9
10
  if (!Array.isArray(value) || value.length > ASSISTANT_ATTACHMENT_MAX_ITEMS ||
10
11
  value.some(id => typeof id !== "string" || !id.trim() || id.length > ASSISTANT_ATTACHMENT_ID_MAX_LENGTH) ||
11
12
  new Set(value).size !== value.length) {
12
- throw invalid(`Attachments require at most ${ASSISTANT_ATTACHMENT_MAX_ITEMS} distinct, nonempty attachment IDs.`);
13
+ throw invalid(`Attachments require at most ${ASSISTANT_ATTACHMENT_MAX_ITEMS} distinct, nonempty attachment IDs.`, {
14
+ messageCode: "assistant.conversation.attachmentIdsInvalid", messageParams: { max: ASSISTANT_ATTACHMENT_MAX_ITEMS }
15
+ });
13
16
  }
14
17
  return [...value];
15
18
  }
@@ -20,7 +23,8 @@ export function createConversationAttachmentReader({ attachments, context, conve
20
23
  return async references => {
21
24
  if (!references?.length) return { attachments: [], content: [], localFiles: [], attachmentManifest: "" };
22
25
  if (typeof attachments?.resolve !== "function") {
23
- throw Object.assign(new Error("File attachments are not configured for this conversation."), { code: "conversation_unsupported", statusCode: 400 });
26
+ throw Object.assign(new Error("File attachments are not configured for this conversation."), { code: "conversation_unsupported", statusCode: 400,
27
+ messageCode: "assistant.conversation.attachmentsNotConfigured", messageParams: {} });
24
28
  }
25
29
  const receipts = [];
26
30
  const content = [];
@@ -38,16 +42,19 @@ export function createConversationAttachmentReader({ attachments, context, conve
38
42
  (local ? !Array.isArray(resolved.localFiles) || resolved.localFiles.length !== attachmentIds.length ||
39
43
  resolved.content !== undefined && (!Array.isArray(resolved.content) || resolved.content.length)
40
44
  : !Array.isArray(resolved.content) || !resolved.content.length || resolved.content.length > 100)) {
41
- throw invalid("The attachment resolver must return a receipt for every requested file and bounded model content.");
45
+ throw invalid("The attachment resolver must return a receipt for every requested file and bounded model content.",
46
+ { messageCode: "assistant.conversation.attachmentResolverIncomplete" });
42
47
  }
43
48
  if (local && !types.includes("localFile")) {
44
- throw invalid("This engine cannot accept local attachment files. Supply extracted text or supported image bytes.");
49
+ throw invalid("This engine cannot accept local attachment files. Supply extracted text or supported image bytes.",
50
+ { messageCode: "assistant.conversation.attachmentLocalFilesUnsupported" });
45
51
  }
46
52
  for (const [index, receipt] of resolved.attachments.entries()) {
47
53
  if (receipt?.attachmentId !== attachmentIds[index] || typeof receipt.fileName !== "string" || !receipt.fileName.trim() ||
48
54
  receipt.fileName.length > 256 || !Number.isSafeInteger(receipt.size) || receipt.size < 0 ||
49
55
  receipt.reference !== undefined && (typeof receipt.reference !== "string" || receipt.reference.length > 256)) {
50
- throw invalid("Attachment receipts require the requested ID, a bounded fileName and a nonnegative size.");
56
+ throw invalid("Attachment receipts require the requested ID, a bounded fileName and a nonnegative size.",
57
+ { messageCode: "assistant.conversation.attachmentReceiptInvalid" });
51
58
  }
52
59
  receipts.push({ attachmentId: receipt.attachmentId, fileName: receipt.fileName, size: receipt.size,
53
60
  ...(receipt.reference ? { reference: receipt.reference } : {}) });
@@ -57,16 +64,18 @@ export function createConversationAttachmentReader({ attachments, context, conve
57
64
  typeof receipt.reference !== "string" || !receipt.reference.trim() || file.reference !== receipt.reference ||
58
65
  typeof file.path !== "string" || !path.isAbsolute(file.path) || file.path.includes("\0") ||
59
66
  typeof file.contentType !== "string" || !/^[\w.+-]+\/[\w.+-]+$/u.test(file.contentType)) {
60
- throw invalid("Local attachment files require their exact requested receipt and an authorized absolute native path.");
67
+ throw invalid("Local attachment files require their exact requested receipt and an authorized absolute native path.",
68
+ { messageCode: "assistant.conversation.attachmentLocalFileInvalid" });
61
69
  }
62
70
  localFiles.push({ attachmentId: file.attachmentId, fileName: file.fileName, size: file.size,
63
71
  reference: file.reference, contentType: file.contentType, path: file.path });
64
72
  }
65
73
  }
66
74
  for (const part of resolved.content || []) {
67
- if (!types.includes(part?.type)) throw invalid("This engine cannot accept that attachment format. Supply extracted text or a supported image.");
75
+ if (!types.includes(part?.type)) throw invalid("This engine cannot accept that attachment format. Supply extracted text or a supported image.",
76
+ { messageCode: "assistant.conversation.attachmentFormatUnsupported" });
68
77
  if (part.type === "text") {
69
- if (typeof part.text !== "string" || !part.text.trim()) throw invalid("Attachment text must be nonempty.");
78
+ if (typeof part.text !== "string" || !part.text.trim()) throw invalid("Attachment text must be nonempty.", { messageCode: "assistant.conversation.attachmentTextEmpty" });
70
79
  bytes += Buffer.byteLength(part.text);
71
80
  content.push({ type: "text", text: part.text });
72
81
  } else {
@@ -75,19 +84,20 @@ export function createConversationAttachmentReader({ attachments, context, conve
75
84
  !/^[\w.+-]+\/[\w.+-]+$/u.test(part.mediaType) ||
76
85
  part.type === "image" && !["image/png", "image/jpeg", "image/webp", "image/gif"].includes(part.mediaType) ||
77
86
  part.filename !== undefined && (typeof part.filename !== "string" || part.filename.length > 256)) {
78
- throw invalid("Attachments require authorized bytes and a supported media type; remote URLs and paths are not accepted.");
87
+ throw invalid("Attachments require authorized bytes and a supported media type; remote URLs and paths are not accepted.",
88
+ { messageCode: "assistant.conversation.attachmentBytesRequired" });
79
89
  }
80
90
  bytes += data.byteLength;
81
- if (bytes > maximumBytes) throw invalid("Attachment content exceeds the configured byte limit for this request.");
91
+ if (bytes > maximumBytes) throw invalid("Attachment content exceeds the configured byte limit for this request.", { messageCode: "assistant.conversation.attachmentBytesExceeded" });
82
92
  content.push(part.type === "image" ? { type: "image", image: Buffer.from(data), mediaType: part.mediaType }
83
93
  : { type: "file", data: Buffer.from(data), mediaType: part.mediaType, ...(part.filename ? { filename: part.filename } : {}) });
84
94
  }
85
- if (bytes > maximumBytes) throw invalid("Attachment content exceeds the configured byte limit for this request.");
95
+ if (bytes > maximumBytes) throw invalid("Attachment content exceeds the configured byte limit for this request.", { messageCode: "assistant.conversation.attachmentBytesExceeded" });
86
96
  }
87
97
  }
88
98
  const attachmentManifest = conversationAttachmentManifest(localFiles);
89
99
  bytes += Buffer.byteLength(attachmentManifest);
90
- if (bytes > maximumBytes) throw invalid("Attachment content exceeds the configured byte limit for this request.");
100
+ if (bytes > maximumBytes) throw invalid("Attachment content exceeds the configured byte limit for this request.", { messageCode: "assistant.conversation.attachmentBytesExceeded" });
91
101
  await authorize();
92
102
  return { attachments: receipts, content, localFiles, attachmentManifest };
93
103
  };
@@ -67,7 +67,8 @@ async function readNativeClaudeCodeAuthStatus({ env, credentialHome, command, co
67
67
  credentialHome, cwd: credentialHome.home, mode: "capture",
68
68
  timeout: 30_000, maxBuffer: 64 * 1024, ...(signal ? { signal } : {})
69
69
  });
70
- const failure = { loggedIn: false, error: result.error || "Claude Code is not connected." };
70
+ const failure = result.error ? { loggedIn: false, error: result.error }
71
+ : { loggedIn: false, error: "Claude Code is not connected.", messageCode: "assistant.claude.notConnected", messageParams: {} };
71
72
  if (!result.ok && (result.exitCode !== 1 || result.timedOut || result.signal)) return failure;
72
73
  try {
73
74
  const status = JSON.parse(result.stdout || result.output);
@@ -77,7 +78,8 @@ async function readNativeClaudeCodeAuthStatus({ env, credentialHome, command, co
77
78
  return { loggedIn: status.loggedIn === true, email: String(status.email || ""),
78
79
  authMethod: String(status.authMethod || ""), subscriptionType: String(status.subscriptionType || "") };
79
80
  } catch {
80
- return result.ok ? { loggedIn: false, error: "Claude Code returned an invalid account status." } : failure;
81
+ return result.ok ? { loggedIn: false, error: "Claude Code returned an invalid account status.",
82
+ messageCode: "assistant.claude.accountStatusInvalid", messageParams: {} } : failure;
81
83
  }
82
84
  }
83
85
 
@@ -108,8 +108,8 @@ export async function runClaudeRenewalTurn(conversation, {
108
108
  export function createClaudeConversationGoals({ conversationId, readHistory, readState, interrupt, send, readRetainedGoal, saveRetainedGoal }) {
109
109
  // These checks run before interrupt/send. Native execution and receipt
110
110
  // failures keep their own codes and must not be treated as safe rejections.
111
- const goalError = message => Object.assign(failure(message, "conversation_invalid_goal"), {
112
- statusCode: 400, delivery: "not-sent"
111
+ const goalError = (message, { messageCode }) => Object.assign(failure(message, "conversation_invalid_goal"), {
112
+ statusCode: 400, delivery: "not-sent", messageCode, messageParams: {}
113
113
  });
114
114
 
115
115
  async function read(history = null) {
@@ -127,13 +127,14 @@ export function createClaudeConversationGoals({ conversationId, readHistory, rea
127
127
  const action = input.action;
128
128
  if (action !== "set" && (!goal || input.threadId !== conversationId ||
129
129
  input.createdAt !== goal.createdAt || input.objective !== goal.objective)) {
130
- throw goalError("The Claude goal changed. Refresh before trying again.");
130
+ throw goalError("The Claude goal changed. Refresh before trying again.", { messageCode: "assistant.claude.goalChanged" });
131
131
  }
132
- if (input.tokenBudget != null) throw goalError("Claude goals do not support a token budget.");
132
+ if (input.tokenBudget != null) throw goalError("Claude goals do not support a token budget.", { messageCode: "assistant.claude.goalTokenBudgetUnsupported" });
133
133
  if (action === "pause" || action === "block") {
134
134
  const reason = typeof input.reason === "string" ? input.reason.trim() : "";
135
135
  if (action === "block" && (!reason || reason.length > 1000 || goal.status !== "active")) {
136
- throw goalError("An active Claude goal needs a blocker reason of up to 1,000 characters.");
136
+ throw goalError("An active Claude goal needs a blocker reason of up to 1,000 characters.",
137
+ { messageCode: "assistant.claude.goalBlockerReasonRequired" });
137
138
  }
138
139
  if (goal.status !== "complete") await saveRetainedGoal({ ...goal,
139
140
  status: action === "block" ? "blocked" : "paused", ...(action === "block" ? { reason } : {}) });
@@ -144,13 +145,13 @@ export function createClaudeConversationGoals({ conversationId, readHistory, rea
144
145
  } else if (action === "set" || action === "resume") {
145
146
  const objective = String((action === "set" ? input.objective : goal.objective) ?? "").trim();
146
147
  if (!objective || objective.length > 4000 || /^(clear|stop|off|reset|none|cancel)$/iu.test(objective)) {
147
- throw goalError("Enter a goal condition of up to 4,000 characters.");
148
+ throw goalError("Enter a goal condition of up to 4,000 characters.", { messageCode: "assistant.claude.goalConditionRequired" });
148
149
  }
149
150
  if (action === "set" && goal && goal.status !== "complete") {
150
- throw goalError("Cancel the existing goal before setting another.");
151
+ throw goalError("Cancel the existing goal before setting another.", { messageCode: "assistant.claude.goalAlreadySet" });
151
152
  }
152
153
  await sendGoal({ message: `/goal ${objective}`, signal });
153
- } else throw goalError("Unknown Claude goal action.");
154
+ } else throw goalError("Unknown Claude goal action.", { messageCode: "assistant.claude.goalActionUnknown" });
154
155
  }
155
156
 
156
157
  return Object.freeze({ read, update });
@@ -552,7 +553,8 @@ export function createClaudeConversationOwner({
552
553
  async function compactContext(entry, input = {}) {
553
554
  input.signal?.throwIfAborted();
554
555
  if (entry.turn?.active || entry.nativeTurn.read().active) {
555
- throw Object.assign(createError("Claude is still working. Wait for it to finish before freeing its context."), { statusCode: 409 });
556
+ throw Object.assign(createError("Claude is still working. Wait for it to finish before freeing its context."), { statusCode: 409,
557
+ messageCode: "assistant.claude.compactWhileWorking", messageParams: {} });
556
558
  }
557
559
  await ensureProcess(entry, input);
558
560
  input.signal?.throwIfAborted();
@@ -1684,7 +1686,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1684
1686
  if (!active || stopped || cancelling) throw Object.assign(
1685
1687
  failure("Claude has no active turn for this message.", !active && !stopped && !cancelling
1686
1688
  ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
1687
- { delivery: "not-sent", ...(!active && !stopped && !cancelling ? { statusCode: 409 } : {}) }
1689
+ { delivery: "not-sent", messageCode: "assistant.claude.noActiveTurn", messageParams: {},
1690
+ ...(!active && !stopped && !cancelling ? { statusCode: 409 } : {}) }
1688
1691
  );
1689
1692
  if (!uuid || typeof accept !== "function" || admissions.has(uuid)) throw new TypeError("A distinct message id and admission writer are required.");
1690
1693
  clearFinalAssistantResult();
@@ -1714,7 +1717,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1714
1717
  const completed = !active && !stopped && !cancelling && outcome?.status === "completed";
1715
1718
  throw Object.assign(failure("This Claude turn cannot accept steering now.", completed
1716
1719
  ? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
1717
- { delivery: "not-sent", ...(completed ? { statusCode: 409 } : {}) });
1720
+ { delivery: "not-sent", messageCode: "assistant.claude.steeringUnavailable", messageParams: {}, ...(completed ? { statusCode: 409 } : {}) });
1718
1721
  }
1719
1722
  // Claude Code acknowledges streamed input as queued and folds it into the
1720
1723
  // running turn after the current tool call, or starts it once the main
@@ -655,6 +655,8 @@ export function createCodexConversationCommands({
655
655
  return {
656
656
  code: `${errorPrefix}codex_detached_thread_delete_unavailable`,
657
657
  error: "Codex app-server thread deletion is not available.",
658
+ messageCode: "assistant.codex.threadDeleteUnavailable",
659
+ messageParams: {},
658
660
  ok: false,
659
661
  statusCode: 409,
660
662
  threadId
@@ -869,6 +871,8 @@ export function createCodexConversationCommands({
869
871
  return {
870
872
  code: `${errorPrefix}temporary_conversation_turn_active`,
871
873
  error: "Temporary AI is already working on this conversation.",
874
+ messageCode: "assistant.codex.temporaryConversationBusy",
875
+ messageParams: {},
872
876
  ok: false
873
877
  };
874
878
  }
@@ -27,11 +27,11 @@ export function createCodexGoalCommands({
27
27
 
28
28
  function validateCodexAppServerGoalInput(input = {}) {
29
29
  if (!["set", "pause", "resume", "cancel"].includes(input.action)) {
30
- return { ok: false, error: "Choose set, pause, resume, or cancel." };
30
+ return { ok: false, error: "Choose set, pause, resume, or cancel.", messageCode: "assistant.codex.goalActionInvalid", messageParams: {} };
31
31
  }
32
32
  if (input.action === "set" && (typeof input.objective !== "string" || !input.objective.trim() ||
33
33
  (input.tokenBudget !== undefined && (!Number.isSafeInteger(input.tokenBudget) || input.tokenBudget <= 0)))) {
34
- return { ok: false, error: "Enter a goal objective and an optional positive whole-number token budget." };
34
+ return { ok: false, error: "Enter a goal objective and an optional positive whole-number token budget.", messageCode: "assistant.codex.goalObjectiveInvalid", messageParams: {} };
35
35
  }
36
36
  return null;
37
37
  }
@@ -46,11 +46,11 @@ export function createCodexGoalCommands({
46
46
  if (context.ok === false) return context;
47
47
  let threadId = context.threadId;
48
48
  if (threadId !== input.threadId || (!threadId && input.action !== "set")) {
49
- return { ok: false, error: "The Codex conversation changed. Refresh the goal before trying again." };
49
+ return { ok: false, error: "The Codex conversation changed. Refresh the goal before trying again.", messageCode: "assistant.codex.goalConversationChanged", messageParams: {} };
50
50
  }
51
51
  if (input.action === "set") {
52
52
  if (turnState(context.session).status === "observation_lost") {
53
- return { ok: false, error: "Resume or send a message to restore the stopped conversation before setting a goal." };
53
+ return { ok: false, error: "Resume or send a message to restore the stopped conversation before setting a goal.", messageCode: "assistant.codex.goalConversationStopped", messageParams: {} };
54
54
  }
55
55
  if (!threadId) {
56
56
  context = await prepareConversation();
@@ -59,7 +59,7 @@ export function createCodexGoalCommands({
59
59
  }
60
60
  const { goal: previousGoal } = await context.provider.readGoal(threadId);
61
61
  if (previousGoal && (previousGoal.status !== "complete" || previousGoal.createdAt !== input.createdAt)) {
62
- return { ok: false, error: "The Codex goal changed. Refresh it before setting a new goal." };
62
+ return { ok: false, error: "The Codex goal changed. Refresh it before setting a new goal.", messageCode: "assistant.codex.goalChangedBeforeSet", messageParams: {} };
63
63
  }
64
64
  signal?.throwIfAborted();
65
65
  subscribeCodexAppServerEvents(sessionId, context.provider, threadId, context);
@@ -74,7 +74,7 @@ export function createCodexGoalCommands({
74
74
  }
75
75
  const { goal } = await context.provider.readGoal(threadId);
76
76
  if (!goal || goal.status === "complete" || goal.createdAt !== input.createdAt || goal.objective !== input.objective) {
77
- return { ok: false, error: "The Codex goal changed. Refresh it before trying again." };
77
+ return { ok: false, error: "The Codex goal changed. Refresh it before trying again.", messageCode: "assistant.codex.goalChanged", messageParams: {} };
78
78
  }
79
79
  if (input.action === "cancel") {
80
80
  subscribeCodexAppServerEvents(sessionId, context.provider, threadId, context);
@@ -87,14 +87,14 @@ export function createCodexGoalCommands({
87
87
  return { ok: true, status: "available", threadId, goal: null };
88
88
  }
89
89
  if (input.action === "pause" && !["active", "paused"].includes(goal.status)) {
90
- return { ok: false, error: "This goal is already stopped. Refresh its details." };
90
+ return { ok: false, error: "This goal is already stopped. Refresh its details.", messageCode: "assistant.codex.goalAlreadyStopped", messageParams: {} };
91
91
  }
92
92
  const observationLost = turnState(context.session).status === "observation_lost";
93
93
  if (observationLost && turnState(context.session).active) {
94
- return { ok: false, error: "Codex has not yet confirmed a stop. Retry after the service has stopped." };
94
+ return { ok: false, error: "Codex has not yet confirmed a stop. Retry after the service has stopped.", messageCode: "assistant.codex.goalStopUnconfirmed", messageParams: {} };
95
95
  }
96
96
  if (input.action === "resume" && !["paused", "blocked", "usageLimited", ...(observationLost ? ["active"] : [])].includes(goal.status)) {
97
- return { ok: false, error: "This goal cannot be resumed from its current status. Refresh its details." };
97
+ return { ok: false, error: "This goal cannot be resumed from its current status. Refresh its details.", messageCode: "assistant.codex.goalNotResumable", messageParams: {} };
98
98
  }
99
99
  subscribeCodexAppServerEvents(sessionId, context.provider, threadId, context);
100
100
  if (observationLost && input.action === "resume") {
@@ -10,6 +10,10 @@ export function createCodexHelperOwnershipError({ applicationName = "Application
10
10
  );
11
11
  error.code = `${errorPrefix}codex_helper_ownership_blocked`;
12
12
  error.statusCode = 409;
13
+ if (!normalizeText(message)) {
14
+ error.messageCode = "assistant.codex.helperOwnershipBlocked";
15
+ error.messageParams = { applicationName };
16
+ }
13
17
  error.retryable = details.retryable !== false;
14
18
  error.details = {
15
19
  ...details,
@@ -73,6 +73,8 @@ export function createCodexHelperThreadLifecycle({
73
73
  );
74
74
  error.code = `${errorPrefix}codex_helper_thread_unavailable`;
75
75
  error.statusCode = 409;
76
+ error.messageCode = "assistant.codex.helperThreadUnavailable";
77
+ error.messageParams = {};
76
78
  error.threadId = normalizeText(threadId);
77
79
  return error;
78
80
  }
@@ -184,6 +184,8 @@ export function createCodexMessageCommands({
184
184
  code: steerFailedCode,
185
185
  delivered: false,
186
186
  error: "The active assistant turn is not ready to accept this message yet.",
187
+ messageCode: "assistant.codex.turnNotReady",
188
+ messageParams: {},
187
189
  ok: false,
188
190
  operationOutcome: "active_turn_not_ready",
189
191
  refreshRecommended: true,
@@ -266,7 +268,9 @@ export function createCodexMessageCommands({
266
268
  if (!userRequest && options.preparedInput === undefined) {
267
269
  return { value: {
268
270
  ok: false,
269
- error: "Codex message is empty."
271
+ error: "Codex message is empty.",
272
+ messageCode: "assistant.codex.messageEmpty",
273
+ messageParams: {}
270
274
  } };
271
275
  }
272
276
 
@@ -363,6 +367,8 @@ export function createCodexMessageCommands({
363
367
  return { value: {
364
368
  code: steerFailedCode,
365
369
  error: "Codex message input is empty.",
370
+ messageCode: "assistant.codex.messageInputEmpty",
371
+ messageParams: {},
366
372
  ok: false,
367
373
  operationOutcome: "message_empty",
368
374
  refreshRecommended: false