@jskit-ai/assistant-core 0.2.13 → 0.2.15

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 (48) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/examples/conversation/package.json +5 -5
  3. package/locales/en.json +171 -6
  4. package/package.json +5 -5
  5. package/src/server/conversation/boundedToolLoop.js +4 -2
  6. package/src/server/conversation/claudeClient.js +15 -10
  7. package/src/server/conversation/claudeCommandRun.js +16 -6
  8. package/src/server/conversation/claudeNativeTurn.js +18 -6
  9. package/src/server/conversation/claudeProcess.js +20 -8
  10. package/src/server/conversation/claudeProcessStart.js +1 -1
  11. package/src/server/conversation/claudeSessionCleanup.js +6 -10
  12. package/src/server/conversation/claudeTurn.js +3 -3
  13. package/src/server/conversation/codexAccountReader.js +15 -6
  14. package/src/server/conversation/codexConversationStops.js +3 -3
  15. package/src/server/conversation/codexConversations.js +1 -1
  16. package/src/server/conversation/codexDetachedTurn.js +4 -2
  17. package/src/server/conversation/codexGoalCommands.js +3 -1
  18. package/src/server/conversation/codexHelperExecution.js +4 -4
  19. package/src/server/conversation/codexHelperThreadRecovery.js +2 -2
  20. package/src/server/conversation/codexHistoryAdapter.js +1 -1
  21. package/src/server/conversation/codexMessageCommands.js +4 -2
  22. package/src/server/conversation/codexModelCatalog.js +1 -1
  23. package/src/server/conversation/codexObservationControl.js +1 -1
  24. package/src/server/conversation/codexProcess.js +3 -1
  25. package/src/server/conversation/codexProvider.js +4 -2
  26. package/src/server/conversation/codexProviderAuth.js +17 -9
  27. package/src/server/conversation/codexProviderInvalidation.js +6 -2
  28. package/src/server/conversation/codexProviderThreads.js +4 -2
  29. package/src/server/conversation/codexProviderTurns.js +4 -2
  30. package/src/server/conversation/codexRenewalThreads.js +2 -2
  31. package/src/server/conversation/conversationTurnDelivery.js +3 -1
  32. package/src/server/conversation/conversationTurns.js +3 -1
  33. package/src/server/conversation/openCodeProcess.js +5 -9
  34. package/src/server/conversation/openCodeRuntime.js +3 -1
  35. package/src/server/conversation/openCodeTurn.js +3 -2
  36. package/src/server/conversation/providers/claudeDriver.js +12 -4
  37. package/src/server/conversation/providers/codex.js +8 -4
  38. package/src/server/conversation/providers/codexDriver.js +23 -10
  39. package/src/server/conversation/providers/codexDriverConnection.js +18 -6
  40. package/src/server/conversation/providers/codexDriverTurns.js +3 -1
  41. package/src/server/conversation/providers/opencode.js +3 -1
  42. package/src/server/conversation/providers/opencodeDriver.js +3 -1
  43. package/src/server/conversation/providers/opencodeStandalone.js +37 -13
  44. package/src/server/conversation/providers/scoped.js +3 -1
  45. package/src/server/conversation/systemPrompt.js +3 -1
  46. package/src/server/conversation/tools.js +6 -2
  47. package/src/shared/assistantSettingsResource.js +14 -2
  48. package/test/claudeLifecycle.test.js +60 -5
@@ -99,7 +99,9 @@ export async function openStandaloneOpenCodeConversation({
99
99
 
100
100
  if (!binding || typeof binding.sessionId !== "string" || binding.workdir !== await realpath(workdir) ||
101
101
  path.dirname(binding.directory || "") !== stateDirectory) {
102
- throw new Error("This OpenCode conversation belongs to another working directory or native state directory. Restore its original host configuration.");
102
+ throw Object.assign(new Error("This OpenCode conversation belongs to another working directory or native state directory. Restore its original host configuration."), {
103
+ messageCode: "assistant.opencode.otherWorkingDirectory", messageParams: {}
104
+ });
103
105
  }
104
106
  let native;
105
107
  let toolBridge;
@@ -151,7 +153,8 @@ export async function openStandaloneOpenCodeConversation({
151
153
  })().catch(error => {
152
154
  error.cleanupFailed = true;
153
155
  if (error.code === "assistant_opencode_stop_unverified") {
154
- error.message = "OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.";
156
+ Object.assign(error, { message: "OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.",
157
+ messageCode: "assistant.opencode.cleanupUnconfirmed", messageParams: {} });
155
158
  }
156
159
  throw error;
157
160
  }).finally(() => { stopping = null; });
@@ -175,11 +178,15 @@ export async function openStandaloneOpenCodeConversation({
175
178
  binding.runtimeDirectory && binding.runtimeDirectory !== preparedFacilities.runtimeDirectory ||
176
179
  facilities && facilities.runtime !== preparedFacilities.runtime
177
180
  )) {
178
- throw new Error("This OpenCode conversation belongs to another native history store or runtime. Restore its original host configuration.");
181
+ throw Object.assign(new Error("This OpenCode conversation belongs to another native history store or runtime. Restore its original host configuration."), {
182
+ messageCode: "assistant.opencode.otherHistoryStoreOrRuntime", messageParams: {}
183
+ });
179
184
  }
180
185
  const allowCredentialRefresh = authoredPreparation && host.opencode && preparedFacilities.allowCredentialRefresh === true;
181
186
  if (binding.accountIdentity && identity !== binding.accountIdentity && !allowCredentialRefresh) {
182
- throw new Error("This conversation belongs to another OpenCode account. Restore that connection or start a new conversation.");
187
+ throw Object.assign(new Error("This conversation belongs to another OpenCode account. Restore that connection or start a new conversation."), {
188
+ messageCode: "assistant.opencode.otherAccount", messageParams: {}
189
+ });
183
190
  }
184
191
  if (binding.accountIdentity !== identity) await updateBinding({ accountIdentity: identity });
185
192
  const model = { providerID: connection.providerId, id: connection.model };
@@ -223,7 +230,9 @@ export async function openStandaloneOpenCodeConversation({
223
230
  };
224
231
  }
225
232
  if (binding.databasePath && binding.databasePath !== facilities.databasePath) {
226
- throw new Error("This OpenCode conversation belongs to another native history store. Restore its original host configuration.");
233
+ throw Object.assign(new Error("This OpenCode conversation belongs to another native history store. Restore its original host configuration."), {
234
+ messageCode: "assistant.opencode.otherHistoryStore", messageParams: {}
235
+ });
227
236
  }
228
237
  await updateBinding({ databasePath: facilities.databasePath, runtimeDirectory: facilities.runtimeDirectory });
229
238
  }
@@ -257,19 +266,25 @@ export async function openStandaloneOpenCodeConversation({
257
266
  await updateBinding({ executionId: native.server.executionId, processDirectory: native.server.privateRoot || "" });
258
267
  const catalogue = await native.server.client.providers({ signal });
259
268
  const selected = catalogue.all.find(provider => provider.id === connection.providerId)?.models?.[connection.model];
260
- if (selected?.id !== connection.model) throw new Error("OpenCode could not load the configured model definition. Check the selected integration before sending.");
269
+ if (selected?.id !== connection.model) throw Object.assign(new Error("OpenCode could not load the configured model definition. Check the selected integration before sending."), {
270
+ messageCode: "assistant.opencode.modelDefinitionUnavailable", messageParams: {}
271
+ });
261
272
  processVariants = selected.variants || {};
262
273
  } catch (error) {
263
274
  if (error.stopProof?.scopeEmpty) await updateBinding({ executionId: "", processDirectory: "" });
264
275
  throw error;
265
276
  }
266
277
  if (configuration.effort) {
267
- if (!Object.hasOwn(processVariants, configuration.effort)) throw new Error("The selected OpenCode model does not support this reasoning effort.");
278
+ if (!Object.hasOwn(processVariants, configuration.effort)) throw Object.assign(new Error("The selected OpenCode model does not support this reasoning effort."), {
279
+ messageCode: "assistant.opencode.reasoningEffortUnsupported", messageParams: {}
280
+ });
268
281
  model.variant = configuration.effort;
269
282
  }
270
283
  const retainedId = binding.sessionId;
271
284
  if (retainedId && (await native.server.client.sessionStatus(retainedId, { signal })).type !== "idle") {
272
- throw new Error("The native OpenCode conversation is still working. Stop or inspect it before sending another message.");
285
+ throw Object.assign(new Error("The native OpenCode conversation is still working. Stop or inspect it before sending another message."), {
286
+ messageCode: "assistant.opencode.nativeStillWorking", messageParams: {}
287
+ });
273
288
  }
274
289
  const session = {
275
290
  selection: { agentId: agent, modelId: model.id, modelProviderId: model.providerID, variantId: model.variant || "" },
@@ -277,7 +292,9 @@ export async function openStandaloneOpenCodeConversation({
277
292
  invalidIdentity: () => new Error("OpenCode did not acknowledge a native conversation."),
278
293
  identity: {
279
294
  async write(nativeId) {
280
- if (retainedId && nativeId !== retainedId) throw new Error("The saved OpenCode conversation is unavailable. Restore its native history before continuing.");
295
+ if (retainedId && nativeId !== retainedId) throw Object.assign(new Error("The saved OpenCode conversation is unavailable. Restore its native history before continuing."), {
296
+ messageCode: "assistant.opencode.savedConversationUnavailable", messageParams: {}
297
+ });
281
298
  if (binding.sessionId !== nativeId) await updateBinding({ sessionId: nativeId });
282
299
  },
283
300
  publish(nativeId) {
@@ -390,7 +407,9 @@ export async function openStandaloneOpenCodeConversation({
390
407
  monitor: messageMonitor(active, command)
391
408
  });
392
409
  if (value.failure) throw value.failure.error;
393
- if (!value.delivered) throw new Error("OpenCode did not acknowledge the message.");
410
+ if (!value.delivered) throw Object.assign(new Error("OpenCode did not acknowledge the message."), {
411
+ messageCode: "assistant.opencode.messageNotAcknowledged", messageParams: {}
412
+ });
394
413
  } catch (error) {
395
414
  const turn = facilities.runtime.turns.get(binding.directory);
396
415
  const attempted = value?.failure ? value.failure.attempted : turn?.promptAttempted === true;
@@ -427,11 +446,15 @@ export async function openStandaloneOpenCodeConversation({
427
446
  text: configuration.outputSchema ? openCodeStructuredOutput(openCodeAssistantMessageText(message)) : openCodeAssistantMessageText(message) })) };
428
447
  },
429
448
  steer(command) {
430
- if (!current || current.finished) throw new Error("OpenCode has no active turn to steer.");
449
+ if (!current || current.finished) throw Object.assign(new Error("OpenCode has no active turn to steer."), {
450
+ messageCode: "assistant.opencode.noTurnToSteer", messageParams: {}
451
+ });
431
452
  return send(command);
432
453
  },
433
454
  async run(command) {
434
- if (disposed || current) throw new Error("This OpenCode conversation is closed or already working.");
455
+ if (disposed || current) throw Object.assign(new Error("This OpenCode conversation is closed or already working."), {
456
+ messageCode: "assistant.opencode.closedOrWorking", messageParams: {}
457
+ });
435
458
  const active = current = { tools: command.tools, configuration: command.configuration,
436
459
  onMessage: command.onMessage, onEvent: command.onEvent, messageIds: new Map(), previous: new Map() };
437
460
  let problem;
@@ -461,7 +484,8 @@ export async function openStandaloneOpenCodeConversation({
461
484
  }
462
485
  } catch (error) {
463
486
  problem = error.cleanupFailed ? error : command.signal.aborted ? command.signal.reason : error;
464
- if (problem.cleanupFailed) problem.message = "OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.";
487
+ if (problem.cleanupFailed) Object.assign(problem, { message: "OpenCode process cleanup could not be confirmed. Restore its execution host before continuing.",
488
+ messageCode: "assistant.opencode.cleanupUnconfirmed", messageParams: {} });
465
489
  }
466
490
  active.finished = true;
467
491
  try { if (!problem?.cleanupFailed && (problem || command.signal.aborted)) await stop(); }
@@ -376,7 +376,9 @@ export async function preparePersistentConversationChangeover(options) {
376
376
  const current = await records.read(context, record.conversationId);
377
377
  const native = current.providerConversationId ? requireSuccess(await operations.readConversation(sessionId,
378
378
  records.input(current), context)) : { status: "ready" };
379
- if (["starting", "inProgress"].includes(native.status)) throw new Error("Stop this temporary conversation before changing its AI.");
379
+ if (["starting", "inProgress"].includes(native.status)) throw Object.assign(new Error("Stop this temporary conversation before changing its AI."), {
380
+ messageCode: "assistant.conversation.stopTemporaryBeforeChange", messageParams: {}
381
+ });
380
382
  record = await records.read(context, record.conversationId);
381
383
  if (record.providerConversationId) {
382
384
  requireSuccess(await operations.stopConversation(sessionId, records.input(record, { runId: record.runId }), context));
@@ -41,7 +41,9 @@ export function createConversationSystemPrompt() {
41
41
  }
42
42
  // Do not let a caller start inference against an obsolete installation.
43
43
  if (generation !== startedGeneration) {
44
- throw new Error("Conversation context changed while installing its system prompt. Retry before sending.");
44
+ throw Object.assign(new Error("Conversation context changed while installing its system prompt. Retry before sending."), {
45
+ messageCode: "assistant.conversation.contextChangedInstallingPrompt", messageParams: {}
46
+ });
45
47
  }
46
48
  installed = { systemPrompt, contextIdentity, value };
47
49
  return value;
@@ -61,9 +61,13 @@ export function createConversationTools({ catalog, toolSet: retainedToolSet, con
61
61
  signal.throwIfAborted();
62
62
  if (typeof id !== "string" || !id || id.length > 256 || typeof name !== "string" || !name || name.length > 256 ||
63
63
  typeof argumentsText !== "string") throw new TypeError("An application tool requires its call id, name and JSON arguments.");
64
- if (Buffer.byteLength(argumentsText) > catalog.limits.maxToolArgumentBytes) throw new Error("The application tool arguments exceed their size limit.");
64
+ if (Buffer.byteLength(argumentsText) > catalog.limits.maxToolArgumentBytes) throw Object.assign(new Error("The application tool arguments exceed their size limit."), {
65
+ messageCode: "assistant.conversation.toolArgumentsTooLarge", messageParams: {}
66
+ });
65
67
  const input = { id, name, arguments: argumentsText };
66
- if (++requested > maximumCalls) throw new Error("The assistant reached the application tool-call limit for this turn.");
68
+ if (++requested > maximumCalls) throw Object.assign(new Error("The assistant reached the application tool-call limit for this turn."), {
69
+ messageCode: "assistant.conversation.toolCallLimit", messageParams: {}
70
+ });
67
71
  const recorded = calls.get(id);
68
72
  if (recorded) {
69
73
  if (!isDeepStrictEqual(input, { id: recorded.id, name: recorded.name, arguments: recorded.arguments })) {
@@ -1,9 +1,21 @@
1
1
  import { createSchema } from "json-rest-schema";
2
2
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
3
+ import { deepFreeze } from "@jskit-ai/kernel/shared/support/deepFreeze";
3
4
  import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
4
5
 
5
6
  const MAX_SYSTEM_PROMPT_CHARS = 12_000;
6
7
 
8
+ // Each field message: its English and the code a client renders it from in the
9
+ // user's language, the { code, message, params } form of resource field messages.
10
+ const ASSISTANT_SETTINGS_FIELD_MESSAGES = deepFreeze({
11
+ systemPromptTooLong: {
12
+ code: "assistant.settings.systemPromptTooLong",
13
+ message: `Assistant system prompt must be at most ${MAX_SYSTEM_PROMPT_CHARS} characters.`,
14
+ params: { max: MAX_SYSTEM_PROMPT_CHARS }
15
+ },
16
+ systemPromptInvalid: { code: "assistant.settings.systemPromptInvalid", message: "Assistant system prompt must be valid text." }
17
+ });
18
+
7
19
  const assistantConfigRecordSchema = createSchema({
8
20
  targetSurfaceId: {
9
21
  type: "string",
@@ -43,8 +55,8 @@ const assistantConfigPatchSchema = createSchema({
43
55
  required: false,
44
56
  maxLength: MAX_SYSTEM_PROMPT_CHARS,
45
57
  messages: {
46
- maxLength: `Assistant system prompt must be at most ${MAX_SYSTEM_PROMPT_CHARS} characters.`,
47
- default: "Assistant system prompt must be valid text."
58
+ maxLength: ASSISTANT_SETTINGS_FIELD_MESSAGES.systemPromptTooLong,
59
+ default: ASSISTANT_SETTINGS_FIELD_MESSAGES.systemPromptInvalid
48
60
  }
49
61
  }
50
62
  });
@@ -18,10 +18,11 @@ const IDLE_ID = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
18
18
  // A host with Vibe64's storage rules: every save writes the session record, an
19
19
  // archived session refuses writes, saved conversations are re-read from storage
20
20
  // and a session whose workspace is gone cannot prepare a conversation context.
21
- async function lifecycleFixture(t) {
21
+ // A running turn's end first runs the host's checkpoint, a Git snapshot there.
22
+ async function lifecycleFixture(t, { turnEndCheckpoint = async () => {} } = {}) {
22
23
  const root = await mkdtemp(path.join(os.tmpdir(), "claude-lifecycle-"));
23
24
  const sessions = new Map();
24
- const calls = { saves: [], reads: 0, stops: [] };
25
+ const calls = { saves: [], reads: 0, stops: [], checkpoints: [] };
25
26
  const processes = [];
26
27
  const session = sessionId => {
27
28
  if (!sessions.has(sessionId)) {
@@ -83,7 +84,13 @@ async function lifecycleFixture(t) {
83
84
  }
84
85
  },
85
86
  // As in Vibe64, the native turn's own checkpoints write the session record.
86
- async onEvent(entry, event) { if (event.type === "save") await store.save(entry); }
87
+ async onEvent(entry, event) {
88
+ if (event.type === "before-state" && !event.active && entry.turn?.active) {
89
+ calls.checkpoints.push(`${entry.context.sessionId}:${event.state}`);
90
+ await turnEndCheckpoint();
91
+ }
92
+ if (event.type === "save") await store.save(entry);
93
+ }
87
94
  });
88
95
  owner = create();
89
96
  const terminals = [];
@@ -199,9 +206,40 @@ test("Claude shutdown stops sessions together and never rewrites an idle chat",
199
206
  assert.equal(result.ok, true);
200
207
  assert.deepEqual(running.map(entry => entry.process), [null, null, null]);
201
208
  const before = f.calls.saves.length;
202
- // Shutdown invalidates more than once; the chats are idle now.
209
+ // Shutdown invalidates more than once; nothing is left to stop or write.
203
210
  assert.equal((await f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account)).ok, true);
204
- assert.equal(f.calls.saves.length, before, "idle chats were not written again");
211
+ assert.equal(f.calls.saves.length, before, "the chats were not written again");
212
+ });
213
+
214
+ test("Claude shutdown leaves a running turn's end and its checkpoint to the next server", { timeout: 5_000 }, async t => {
215
+ // The host's checkpoint can take longer than the server has to stop.
216
+ const checkpoint = Promise.withResolvers();
217
+ let checkpointWaits = true;
218
+ const f = await lifecycleFixture(t, { turnEndCheckpoint: () => checkpointWaits ? checkpoint.promise : undefined });
219
+ const a = await f.running("a");
220
+ const saved = f.session("a").records.get(MAIN_IDS.a);
221
+ let timer;
222
+ const result = await Promise.race([
223
+ f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account),
224
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Shutdown waited for the turn's checkpoint")), 1_000); })
225
+ ]).finally(() => {
226
+ clearTimeout(timer);
227
+ checkpoint.resolve();
228
+ });
229
+ assert.equal(result.ok, true, JSON.stringify(result));
230
+ assert.equal(a.process, null, "the process stopped");
231
+ assert.deepEqual(f.calls.checkpoints, [], "shutdown ran no checkpoint");
232
+ assert.equal(f.session("a").records.get(MAIN_IDS.a), saved, "the saved record was not rewritten");
233
+ const custody = ({ executionId, state }) => ({ executionId, state });
234
+ assert.deepEqual(custody(JSON.parse(saved)), { executionId: "execution-0", state: "active" });
235
+ // The next server's close proves that exit, then interrupts the turn through its checkpoint.
236
+ checkpointWaits = false;
237
+ f.restart();
238
+ const stops = f.calls.stops.length;
239
+ assert.equal((await f.owner.closeSession({ sessionId: "a" }, f.application)).ok, true);
240
+ assert.deepEqual(f.calls.stops.slice(stops), ["execution-0"]);
241
+ assert.deepEqual(f.calls.checkpoints, ["a:interrupted"]);
242
+ assert.deepEqual(custody(JSON.parse(f.session("a").records.get(MAIN_IDS.a))), { executionId: "", state: "interrupted" });
205
243
  });
206
244
 
207
245
  test("Claude project close releases its sessions from memory without reopening saved conversations", async t => {
@@ -280,6 +318,23 @@ test("closing a Claude session reopens only the saved custody an earlier server
280
318
  new RegExp(`saved Claude conversation ${IDLE_ID} is unreadable`));
281
319
  });
282
320
 
321
+ test("reconciling a Claude session reopens only the saved custody an earlier server left", async t => {
322
+ const f = await lifecycleFixture(t);
323
+ const records = f.session("a").records;
324
+ const idle = JSON.stringify({ state: "completed", main: false });
325
+ records.set(MAIN_IDS.a, JSON.stringify({ state: "completed", turnId: "done", main: true }));
326
+ records.set(IDLE_ID, idle);
327
+ records.set(TEMPORARY_ID, JSON.stringify({ executionId: "execution-before-restart", state: "active", turnId: "turn", main: false }));
328
+ const result = await f.owner.reconcileSessions([{ sessionId: "a" }], {}, f.application);
329
+ assert.equal(result.ok, true);
330
+ assert.deepEqual(result.results.map(({ ok, sessionId }) => [sessionId, ok]), [["a", true]]);
331
+ assert.deepEqual(f.calls.stops, ["execution-before-restart"], "the saved execution's exit was proven");
332
+ assert.equal(JSON.parse(records.get(TEMPORARY_ID)).executionId, "");
333
+ assert.equal(JSON.parse(records.get(TEMPORARY_ID)).state, "interrupted");
334
+ assert.deepEqual([...f.owner.entries.values()].map(entry => entry.id), [TEMPORARY_ID], "idle saved chats stayed closed");
335
+ assert.equal(records.get(IDLE_ID), idle, "an idle saved chat was not rewritten");
336
+ });
337
+
283
338
  test("after shutdown invalidation a Claude session close reopens nothing", async t => {
284
339
  const f = await lifecycleFixture(t);
285
340
  await f.idle("a");