@jskit-ai/assistant-core 0.2.2 → 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.
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
- package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
- package/locales/en.json +4 -2
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +8 -1
- package/src/client/conversation/AssistantGoalControl.vue +2 -2
- package/src/client/conversation/AssistantTranscript.vue +41 -11
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +105 -58
- package/src/server/conversation/claudeTurn.js +179 -59
- package/src/server/conversation/codexAuthPreflight.js +3 -0
- package/src/server/conversation/codexConfiguration.js +42 -4
- package/src/server/conversation/codexConversations.js +256 -114
- package/src/server/conversation/codexEvents.js +16 -3
- package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
- package/src/server/conversation/codexHelperThreads.js +10 -1
- package/src/server/conversation/codexHomeStorage.js +69 -0
- package/src/server/conversation/codexProcess.js +7 -2
- package/src/server/conversation/codexProvider.js +57 -46
- package/src/server/conversation/codexProviderOwner.js +234 -29
- package/src/server/conversation/codexProviderSelection.js +13 -3
- package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
- package/src/server/conversation/codexSessionCleanup.js +67 -20
- package/src/server/conversation/codexSessionReconciliation.js +18 -4
- package/src/server/conversation/codexTurn.js +9 -2
- package/src/server/conversation/continuity.js +13 -1
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +225 -64
- package/src/server/conversation/providers/claudeDriver.js +3 -3
- package/src/server/conversation/providers/codexDriver.js +10 -4
- package/src/server/conversation/providers/opencodeDriver.js +4 -2
- package/src/server/conversation/runtime.js +72 -2
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +93 -0
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +195 -51
- package/test/claudeTurn.test.js +174 -3
- package/test/codexAccountChange.test.js +261 -0
- package/test/codexConversationsCheckpoint.test.js +3 -0
- package/test/codexEvents.test.js +23 -1
- package/test/codexHomeStorage.test.js +57 -0
- package/test/codexInteractiveArguments.test.js +9 -0
- package/test/codexLifecycleIsolation.test.js +441 -0
- package/test/codexModelCatalog.test.js +48 -2
- package/test/codexProcess.test.js +21 -0
- package/test/codexTemporaryProviderRelease.test.js +180 -0
- package/test/codexTurn.test.js +21 -1
- package/test/conversationNativeStorage.test.js +57 -0
- package/test/conversationRuntime.test.js +66 -0
- package/test/conversationSession.test.js +4 -8
- package/test/messageDelivery.browser.test.js +18 -2
- package/test/messageDelivery.test.js +34 -1
- package/test/openCodeClient.test.js +31 -0
- package/test/openCodeConnectionChange.test.js +166 -0
- package/test/openCodeProcess.test.js +36 -0
- 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
|
-
|
|
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
|
|
@@ -316,7 +316,19 @@ export function createConversationChangeover({ state, transcript, agent, identit
|
|
|
316
316
|
return delivered;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
|
|
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 };
|
|
320
332
|
}
|
|
321
333
|
|
|
322
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
|
-
|
|
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,
|
|
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 => {
|
|
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:
|
|
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 =
|
|
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 }; }
|