@jskit-ai/assistant-core 0.2.1 → 0.2.3

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 (61) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
  4. package/locales/en.json +4 -2
  5. package/package.json +5 -5
  6. package/src/client/conversation/AssistantConversationElement.vue +8 -1
  7. package/src/client/conversation/AssistantGoalControl.vue +2 -2
  8. package/src/client/conversation/AssistantTranscript.vue +41 -11
  9. package/src/client/conversation/messageDelivery.js +23 -6
  10. package/src/client/lib/assistantApi.js +5 -0
  11. package/src/server/conversation/claudeProcess.js +171 -53
  12. package/src/server/conversation/claudeTurn.js +199 -63
  13. package/src/server/conversation/codexAuthPreflight.js +3 -0
  14. package/src/server/conversation/codexConfiguration.js +42 -4
  15. package/src/server/conversation/codexConversations.js +256 -114
  16. package/src/server/conversation/codexEvents.js +16 -3
  17. package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
  18. package/src/server/conversation/codexHelperThreads.js +10 -1
  19. package/src/server/conversation/codexHomeStorage.js +69 -0
  20. package/src/server/conversation/codexProcess.js +7 -2
  21. package/src/server/conversation/codexProvider.js +57 -46
  22. package/src/server/conversation/codexProviderOwner.js +272 -10
  23. package/src/server/conversation/codexProviderSelection.js +13 -3
  24. package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
  25. package/src/server/conversation/codexSessionCleanup.js +67 -20
  26. package/src/server/conversation/codexSessionReconciliation.js +18 -4
  27. package/src/server/conversation/codexTurn.js +9 -2
  28. package/src/server/conversation/continuity.js +34 -14
  29. package/src/server/conversation/openCodeClient.js +34 -0
  30. package/src/server/conversation/openCodeProcess.js +37 -5
  31. package/src/server/conversation/openCodeRuntime.js +257 -69
  32. package/src/server/conversation/providers/claudeDriver.js +7 -5
  33. package/src/server/conversation/providers/codexDriver.js +23 -16
  34. package/src/server/conversation/providers/opencodeDriver.js +17 -14
  35. package/src/server/conversation/providers/scoped.js +5 -2
  36. package/src/server/conversation/runtime.js +93 -6
  37. package/src/server/hosts/codexConfiguration.js +4 -0
  38. package/test/assistantScroll.browser.test.js +134 -18
  39. package/test/claudeConversationSession.test.js +26 -1
  40. package/test/claudeLifecycle.test.js +293 -0
  41. package/test/claudeProcess.test.js +364 -33
  42. package/test/claudeTurn.test.js +192 -3
  43. package/test/codexAccountChange.test.js +261 -0
  44. package/test/codexConversationsCheckpoint.test.js +3 -0
  45. package/test/codexEvents.test.js +23 -1
  46. package/test/codexHomeStorage.test.js +57 -0
  47. package/test/codexInteractiveArguments.test.js +9 -0
  48. package/test/codexLifecycleIsolation.test.js +441 -0
  49. package/test/codexModelCatalog.test.js +48 -2
  50. package/test/codexProcess.test.js +21 -0
  51. package/test/codexTemporaryProviderRelease.test.js +180 -0
  52. package/test/codexTurn.test.js +52 -1
  53. package/test/conversationNativeStorage.test.js +284 -0
  54. package/test/conversationRuntime.test.js +66 -0
  55. package/test/conversationSession.test.js +4 -8
  56. package/test/messageDelivery.browser.test.js +18 -2
  57. package/test/messageDelivery.test.js +34 -1
  58. package/test/openCodeClient.test.js +31 -0
  59. package/test/openCodeConnectionChange.test.js +166 -0
  60. package/test/openCodeProcess.test.js +36 -0
  61. package/test/openCodeRuntime.test.js +25 -78
@@ -12,8 +12,9 @@ export function createCodexSessionReconciliation({
12
12
  projectContextRoot = ""
13
13
  } = {}) {
14
14
  const normalizedProjectContextRoot = normalizeText(projectContextRoot);
15
+ const failed = [];
15
16
  if (!normalizedProjectContextRoot) {
16
- return;
17
+ return failed;
17
18
  }
18
19
  for (const [providerKey, managed] of [...providerSessions.managed.entries()]) {
19
20
  if (keepProviderKeys.has(providerKey)) {
@@ -21,9 +22,22 @@ export function createCodexSessionReconciliation({
21
22
  }
22
23
  if (providerSessions.owner.providers.get(providerKey)?.observationFailure) continue;
23
24
  if (normalizeText(managed.projectContext?.targetRoot) === normalizedProjectContextRoot) {
24
- await lifecycle.retireProvider(providerKey);
25
+ // One provider that cannot retire does not keep the others running.
26
+ try {
27
+ await lifecycle.retireProvider(providerKey);
28
+ } catch (error) {
29
+ failed.push({
30
+ code: normalizeText(error?.code),
31
+ error: errorMessage(error),
32
+ providerKey,
33
+ retryable: error?.retryable === true,
34
+ sessionId: normalizeText(managed.sessionId)
35
+ });
36
+ debugLog("appServerThread.reconcile.prune.error", { error: debugError(error), providerKey });
37
+ }
25
38
  }
26
39
  }
40
+ return failed;
27
41
  }
28
42
 
29
43
  async function retireAndCloseCodexAppServerProviderForSession(sessionId = "", options = null) {
@@ -233,10 +247,10 @@ export function createCodexSessionReconciliation({
233
247
  });
234
248
  }
235
249
  if (reconcileGeneration === lifecycle.reconcileGeneration) {
236
- await pruneCodexAppServerManagedSessions({
250
+ failed.push(...await pruneCodexAppServerManagedSessions({
237
251
  keepProviderKeys,
238
252
  projectContextRoot
239
- });
253
+ }));
240
254
  } else {
241
255
  debugLog("appServerThread.reconcile.pruneSkipped", {
242
256
  reason: "stale_reconcile",
@@ -108,6 +108,10 @@ export function createCodexAppServerRunOwner({
108
108
  onNotificationSignal = async () => null,
109
109
  captureContext = () => null,
110
110
  runInContext = (_context, operation) => operation(),
111
+ // Resumes a goal an account change paused, once the previous account's
112
+ // runtime has stopped: `(sessionId, { threadId })`. Without it the goal stays
113
+ // paused for the person to resume.
114
+ continueGoalAfterAccountChange = null,
111
115
  notificationQueue = null,
112
116
  activeReconcileMs = 2000,
113
117
  finalizingGraceMs = 10000,
@@ -314,8 +318,8 @@ export function createCodexAppServerRunOwner({
314
318
  interruptFailedCode,
315
319
  namespace,
316
320
  providerSessions,
317
- sessionConnection: codexProviderSelection.sessionConnection,
318
321
  ensureSessionProvider: codexProviderSelection.ensureSessionProvider,
322
+ borrowSessionProvider: codexProviderSelection.borrowSessionProvider,
319
323
  steerFailedCode,
320
324
  turnOutcomes
321
325
  });
@@ -416,7 +420,9 @@ export function createCodexAppServerRunOwner({
416
420
  const connection = prepared.connection;
417
421
  const { workdir, threadId } = connection;
418
422
  if (threadId && connection.available && turnState(session).state !== "starting") {
419
- const providerKey = codexProviderSelection.managedProviderKeyForThread(sessionId, workdir, threadId);
423
+ const providerKey = codexProviderSelection.managedProviderKeyForThread(sessionId, workdir, threadId, {
424
+ turnActive: turnState(session).active
425
+ });
420
426
  if (providerKey !== null) {
421
427
  const observed = await codexThreadReadiness.checkManagedConnection(providerKey, { reconnect: false });
422
428
  if (!observed.requiresPreparation) {
@@ -547,6 +553,7 @@ export function createCodexAppServerRunOwner({
547
553
  helperLifecycle,
548
554
  captureContext,
549
555
  runInContext,
556
+ continueGoalAfterAccountChange,
550
557
  debugLog,
551
558
  debugError,
552
559
  closingError: serverClosingError
@@ -122,7 +122,7 @@ export function createConversationChangeover({ state, transcript, agent, identit
122
122
  code: unconfirmedCode, messageId, threadId, error: unconfirmedMessage };
123
123
  }
124
124
 
125
- async function send({ engineId, messages, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [] }) {
125
+ async function send({ engineId, messages, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [], inspectOnly = false }) {
126
126
  if (typeof completedEnvelope !== "boolean" || !Array.isArray(excludedMessageIds) ||
127
127
  excludedMessageIds.some(id => typeof id !== "string" || !id.trim() || id.length > 128) ||
128
128
  new Set(excludedMessageIds).size !== excludedMessageIds.length || excludedMessageIds.length && !completedEnvelope) {
@@ -163,20 +163,28 @@ export function createConversationChangeover({ state, transcript, agent, identit
163
163
  const receipt = savedReceipt ? { admission: "accepted" } : await agent.inspectMessageAdmission({
164
164
  messageId: pending.messageId, threadId: pending.threadId
165
165
  }).catch(() => null);
166
- if (receipt?.admission !== "accepted") {
166
+ if (receipt?.admission === "not-admitted") {
167
+ // The engine proved this attempt never arrived. It was not sent, so
168
+ // a send may dispatch it (or a newer message) without duplicating it.
169
+ pending.attempted = false;
170
+ await persist();
171
+ log({ event: "not-sent", engineId, messageId: pending.messageId, threadId: pending.threadId });
172
+ if (inspectOnly) return { ok: false, delivered: false, notSent: true, messageId: pending.messageId, threadId: pending.threadId };
173
+ } else if (receipt?.admission !== "accepted") {
167
174
  log({ event: "unconfirmed", engineId, messageId: pending.messageId, threadId: pending.threadId });
168
175
  return unconfirmed(pending.messageId, pending.threadId);
169
- }
170
- await transcript.writeUserMessage({
171
- text: pending.displayMessage, messageId: pending.messageId,
172
- attachments: pending.displayAttachments,
173
- ...(pending.data !== undefined ? { data: pending.data } : {}),
174
- turnMetadata: { ...pending.turnMetadata, ...turnMetadata }
175
- });
176
- await markDelivered(pending.seen);
177
- log({ event: "accepted", engineId, messageId: pending.messageId, threadId: pending.threadId, recovered: true });
178
- if (pending.messageId === input.messageId) {
179
- return { ok: true, delivered: true, messageId: input.messageId, threadId: pending.threadId };
176
+ } else {
177
+ await transcript.writeUserMessage({
178
+ text: pending.displayMessage, messageId: pending.messageId,
179
+ attachments: pending.displayAttachments,
180
+ ...(pending.data !== undefined ? { data: pending.data } : {}),
181
+ turnMetadata: { ...pending.turnMetadata, ...turnMetadata }
182
+ });
183
+ await markDelivered(pending.seen);
184
+ log({ event: "accepted", engineId, messageId: pending.messageId, threadId: pending.threadId, recovered: true });
185
+ if (pending.messageId === input.messageId) {
186
+ return { ok: true, delivered: true, messageId: input.messageId, threadId: pending.threadId };
187
+ }
180
188
  }
181
189
  }
182
190
 
@@ -308,7 +316,19 @@ export function createConversationChangeover({ state, transcript, agent, identit
308
316
  return delivered;
309
317
  }
310
318
 
311
- return { readState, remember, replace, send };
319
+ // Cancel drops the message's pending native attempt in every engine, so no
320
+ // later send replays it or waits on its unknown receipt. Native history an
321
+ // engine already admitted stays; the transcript remains authoritative.
322
+ async function discard({ messageId }) {
323
+ const value = await state.read();
324
+ const bindings = Object.values(value?.engines || {}).filter(binding => binding.pending?.messageId === messageId);
325
+ if (!bindings.length) return false;
326
+ for (const binding of bindings) delete binding.pending;
327
+ await state.write(value);
328
+ return true;
329
+ }
330
+
331
+ return { readState, remember, replace, send, discard };
312
332
  }
313
333
 
314
334
  function writtenMessages(history, native = false) {
@@ -298,11 +298,17 @@ function decodeOpenCodeEventData(value = "") {
298
298
  return parsedJson(first) ?? first;
299
299
  }
300
300
 
301
+ // OpenCode loads one instance per directory and keeps it, with the credentials it
302
+ // read, until that directory is disposed. Every client derived from one server
303
+ // shares the record of the directories it has addressed; `defaultDirectory` is
304
+ // the server's working directory, which requests without a directory reach.
301
305
  function createOpenCodeServerClient({
302
306
  allowAttachmentDirectories = false,
303
307
  baseUrl = "",
308
+ defaultDirectory = "",
304
309
  directory = "",
305
310
  fetchImpl = globalThis.fetch,
311
+ instances = { addressed: new Map(), sequence: 0 },
306
312
  password = "",
307
313
  username = "opencode"
308
314
  } = {}) {
@@ -316,6 +322,17 @@ function createOpenCodeServerClient({
316
322
  const authorization = `Basic ${Buffer.from(`${text(username) || "opencode"}:${String(password)}`).toString("base64")}`;
317
323
  const scopedDirectory = text(directory);
318
324
 
325
+ function instanceDirectory(requestDirectory = scopedDirectory) {
326
+ const value = text(requestDirectory) || text(defaultDirectory);
327
+ return value ? path.resolve(value) : "";
328
+ }
329
+
330
+ function addressInstance(requestPath, requestDirectory) {
331
+ // Global routes and disposal itself load no directory instance.
332
+ const instance = /^\/(?:global|instance)\//u.test(requestPath) ? "" : instanceDirectory(requestDirectory);
333
+ if (instance) instances.addressed.set(instance, ++instances.sequence);
334
+ }
335
+
319
336
  function requestHeaders(accept = "application/json", body = undefined, directory = scopedDirectory) {
320
337
  return {
321
338
  accept,
@@ -331,6 +348,7 @@ function createOpenCodeServerClient({
331
348
  limitBytes = OPENCODE_RESPONSE_LIMIT_BYTES,
332
349
  signal
333
350
  } = {}) {
351
+ addressInstance(requestPath, directory);
334
352
  const response = await fetchImpl(new URL(requestPath, origin), {
335
353
  body: body === undefined ? undefined : JSON.stringify(body),
336
354
  headers: requestHeaders("application/json", body, directory),
@@ -381,6 +399,7 @@ function createOpenCodeServerClient({
381
399
 
382
400
  async function *events(sessionId = "", { onReady = null, signal } = {}) {
383
401
  const requestPath = "/event";
402
+ addressInstance(requestPath, scopedDirectory);
384
403
  const response = await fetchImpl(new URL(requestPath, origin), {
385
404
  headers: requestHeaders("text/event-stream"),
386
405
  method: "GET",
@@ -550,6 +569,15 @@ function createOpenCodeServerClient({
550
569
  if (!text(messageId)) throw new TypeError("OpenCode message deletion requires a message id.");
551
570
  return request("DELETE", stableSessionPath(sessionId, `/message/${encodeURIComponent(text(messageId))}`), { signal });
552
571
  },
572
+ // The next request to this client's directory loads a new instance, which
573
+ // reads the server's current credentials. A turn in flight there is aborted.
574
+ async disposeInstance({ signal } = {}) {
575
+ const instance = instanceDirectory();
576
+ const sequence = instances.sequence;
577
+ await request("POST", "/instance/dispose", { signal });
578
+ // A request that reached the directory during disposal may have loaded it again.
579
+ if (instance && (instances.addressed.get(instance) ?? 0) <= sequence) instances.addressed.delete(instance);
580
+ },
553
581
  events,
554
582
  forDirectory(nextDirectory = "") {
555
583
  const normalizedDirectory = text(nextDirectory);
@@ -559,12 +587,18 @@ function createOpenCodeServerClient({
559
587
  return createOpenCodeServerClient({
560
588
  allowAttachmentDirectories,
561
589
  baseUrl: origin.toString(),
590
+ defaultDirectory,
562
591
  directory: normalizedDirectory,
563
592
  fetchImpl,
593
+ instances,
564
594
  password,
565
595
  username
566
596
  });
567
597
  },
598
+ // Absolute directories whose loaded instances may hold older credentials.
599
+ instanceDirectories() {
600
+ return [...instances.addressed.keys()];
601
+ },
568
602
  async health({ signal } = {}) {
569
603
  return request("GET", "/global/health", { signal });
570
604
  },
@@ -80,6 +80,23 @@ async function availableLoopbackPort() {
80
80
  });
81
81
  }
82
82
 
83
+ const OPENCODE_EXIT_REASON_MAX_CHARACTERS = 300;
84
+
85
+ // OpenCode's last stderr line, as one bounded line of plain text.
86
+ function openCodeExitReason(logs) {
87
+ const line = String(logs?.stderr || "")
88
+ // eslint-disable-next-line no-control-regex -- Strip terminal escape sequences from captured stderr.
89
+ .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/gu, "")
90
+ .split(/\r?\n/u)
91
+ // eslint-disable-next-line no-control-regex -- The reason becomes one line of plain text.
92
+ .map((candidate) => candidate.replace(/[\u0000-\u001f\u007f]/gu, " ").trim())
93
+ .filter(Boolean)
94
+ .at(-1) || "";
95
+ return line.length > OPENCODE_EXIT_REASON_MAX_CHARACTERS
96
+ ? `${line.slice(0, OPENCODE_EXIT_REASON_MAX_CHARACTERS - 1)}…`
97
+ : line;
98
+ }
99
+
83
100
  async function waitForOpenCodeReady({
84
101
  client,
85
102
  expectedVersion = OPENCODE_EXPECTED_VERSION,
@@ -92,7 +109,13 @@ async function waitForOpenCodeReady({
92
109
  while (Date.now() < deadline) {
93
110
  signal?.throwIfAborted();
94
111
  if (processHandle.exited) {
95
- throw new Error("OpenCode exited before its server became ready.");
112
+ let logs = null;
113
+ // The exit is the failure; output that cannot be read only loses its reason.
114
+ try { logs = processHandle.readLogs(); } catch { logs = null; }
115
+ const reason = openCodeExitReason(logs);
116
+ throw new Error(reason
117
+ ? `OpenCode exited before its server became ready: ${reason}`
118
+ : "OpenCode exited before its server became ready.");
96
119
  }
97
120
  try {
98
121
  const probe = AbortSignal.timeout(Math.max(100, Math.min(2_000, deadline - Date.now())));
@@ -216,15 +239,18 @@ export async function createOpenCodeServerProcess({
216
239
  const baseUrl = `http://${OPENCODE_HOST}:${selectedPort}`;
217
240
  const password = randomBytes(32).toString("base64url");
218
241
  const environment = { ...env, OPENCODE_SERVER_USERNAME: "opencode", OPENCODE_SERVER_PASSWORD: password };
219
- const client = createOpenCodeServerClient({ baseUrl, password, directory, fetchImpl, allowAttachmentDirectories });
242
+ const client = createOpenCodeServerClient({ baseUrl, password, directory, defaultDirectory: workdir, fetchImpl,
243
+ allowAttachmentDirectories });
220
244
  let native;
221
245
  let executionId;
222
246
  let closing = false;
223
247
  let stopped = false;
224
248
  let stopPromise = null;
249
+ const unexpectedExit = Promise.withResolvers();
225
250
  const logPath = path.join(privateRoot, "opencode-server.log");
226
251
  const processHandle = {
227
252
  get exited() { return stopped || native?.running === false; },
253
+ readLogs: () => native?.readLogs?.() || { stderr: "", stdout: "" },
228
254
  async stop() {
229
255
  const proof = await execution.stop(executionId, {
230
256
  allowMissingRecordScopeRecovery: true,
@@ -268,7 +294,11 @@ export async function createOpenCodeServerProcess({
268
294
  logPath, args: ["serve", "--hostname", OPENCODE_HOST, "--port", String(selectedPort), "--mdns=false"] });
269
295
  executionId = native.id;
270
296
  await onStarted?.(native.id);
271
- const fail = error => { if (!closing) Promise.resolve().then(() => onFailure?.(error)).catch(() => {}); };
297
+ const fail = error => {
298
+ if (closing) return;
299
+ unexpectedExit.resolve(error);
300
+ Promise.resolve().then(() => onFailure?.(error)).catch(() => {});
301
+ };
272
302
  native.exited.then(() => fail(new Error("The owned OpenCode process exited."))).catch(fail);
273
303
  const health = await waitForOpenCodeReady({ client, expectedVersion, processHandle, timeoutMs: readinessTimeoutMs, signal });
274
304
  const authenticated = new Set();
@@ -283,7 +313,9 @@ export async function createOpenCodeServerProcess({
283
313
  }
284
314
  return Object.freeze({ client, executionId: native.id, privateRoot, pid: native.pid, port: selectedPort,
285
315
  environment, health: Object.freeze({ ...health }), modelProviderIds: Object.freeze([...authenticated]),
286
- readLogs: () => native.readLogs?.() || { stderr: "", stdout: "" }, stop,
316
+ readLogs: processHandle.readLogs, stop,
317
+ // Resolves when the process exits without being stopped.
318
+ unexpectedExit: unexpectedExit.promise,
287
319
  attachArguments({ conversationId, directory }) {
288
320
  if (!conversationId || !path.isAbsolute(directory || "")) throw new TypeError("An OpenCode terminal requires a conversation and absolute directory.");
289
321
  return ["attach", baseUrl, "--dir", directory, "--session", conversationId, "--pure"];
@@ -297,7 +329,7 @@ export async function createOpenCodeServerProcess({
297
329
  try { await onStarted?.(executionId); }
298
330
  catch (bindingError) { error.bindingError = bindingError; }
299
331
  }
300
- try { error.details = native?.readLogs?.() || { stderr: "", stdout: "" }; }
332
+ try { error.details = processHandle.readLogs(); }
301
333
  catch (logError) { error.logError = logError; }
302
334
  try { error.stopProof = await stop(); }
303
335
  catch (cleanupError) { error.cleanupError = cleanupError; error.stopProof = { scopeEmpty: false }; }