@jskit-ai/assistant-core 0.2.2 → 0.2.4
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 +49 -12
- 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 +169 -10
- 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
|
@@ -183,15 +183,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
183
183
|
let sharedProcessStop = null;
|
|
184
184
|
let failedStartup = null;
|
|
185
185
|
let closed = false;
|
|
186
|
-
let
|
|
186
|
+
let connectionUpdate = Promise.resolve();
|
|
187
187
|
let storageReads = 0;
|
|
188
188
|
let storageIdle = null;
|
|
189
|
-
|
|
190
|
-
function requireConnectionAvailable() {
|
|
191
|
-
if (connectionChanging) throw Object.assign(new Error("OpenCode is changing a connection. Retry after the connection change finishes."), {
|
|
192
|
-
code: "assistant_opencode_connection_changing", statusCode: 409
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
189
|
const bindingScopes = new Map();
|
|
196
190
|
let registryPath = "";
|
|
197
191
|
let bindingWrite = Promise.resolve();
|
|
@@ -240,28 +234,161 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
240
234
|
}
|
|
241
235
|
}
|
|
242
236
|
|
|
243
|
-
|
|
244
|
-
|
|
237
|
+
// A credential change reaches a native directory when OpenCode reloads that
|
|
238
|
+
// directory's instance. An idle directory reloads at once; one with a turn in
|
|
239
|
+
// flight reloads when its last turn there ends. New work in a directory waits
|
|
240
|
+
// for its reload, so it never starts on credentials the server no longer holds.
|
|
241
|
+
function directoryState(shared, workdir) {
|
|
242
|
+
const directory = path.resolve(workdir);
|
|
243
|
+
let state = shared.directories.get(directory);
|
|
244
|
+
if (!state) {
|
|
245
|
+
state = { directory, generation: shared.generation, turns: 0, reload: null, idle: null };
|
|
246
|
+
shared.directories.set(directory, state);
|
|
247
|
+
}
|
|
248
|
+
return state;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function directoryCurrent(workdir) {
|
|
252
|
+
const state = sharedProcess && text(workdir) ? sharedProcess.directories.get(path.resolve(workdir)) : null;
|
|
253
|
+
return !state || (!state.reload && state.generation === sharedProcess.generation);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function reloadDirectory(shared, state) {
|
|
257
|
+
if (state.reload || state.generation === shared.generation) return state.reload;
|
|
258
|
+
state.reload = (async () => {
|
|
259
|
+
try {
|
|
260
|
+
while (state.generation < shared.generation) {
|
|
261
|
+
const generation = shared.generation;
|
|
262
|
+
await shared.server.client.forDirectory(state.directory).disposeInstance();
|
|
263
|
+
state.generation = generation;
|
|
264
|
+
}
|
|
265
|
+
} finally {
|
|
266
|
+
state.reload = null;
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
269
|
+
return state.reload;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function waitForDirectory(workdir) {
|
|
273
|
+
const shared = sharedProcess;
|
|
274
|
+
const state = directoryState(shared, workdir);
|
|
275
|
+
try {
|
|
276
|
+
if (state.reload || !state.turns) await reloadDirectory(shared, state);
|
|
277
|
+
else await (state.idle ||= Promise.withResolvers()).promise;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (sharedProcess !== shared) return;
|
|
280
|
+
throw Object.assign(new Error("OpenCode could not load the changed AI connection for this work. Send your message again."), {
|
|
281
|
+
cause: error, code: "assistant_opencode_connection_reload_failed", details: {}, statusCode: 503
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function currentDirectory(workdir) {
|
|
287
|
+
while (!directoryCurrent(workdir)) await waitForDirectory(workdir);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Holds a directory's credentials for a turn in flight there.
|
|
291
|
+
function holdDirectory(workdir) {
|
|
292
|
+
const shared = sharedProcess;
|
|
293
|
+
if (!shared || !text(workdir)) return () => {};
|
|
294
|
+
const state = directoryState(shared, workdir);
|
|
295
|
+
state.turns += 1;
|
|
296
|
+
let held = true;
|
|
297
|
+
return () => {
|
|
298
|
+
if (!held) return;
|
|
299
|
+
held = false;
|
|
300
|
+
state.turns -= 1;
|
|
301
|
+
if (state.turns) return;
|
|
302
|
+
state.idle?.resolve();
|
|
303
|
+
state.idle = null;
|
|
304
|
+
if (sharedProcess === shared) reloadDirectory(shared, state)?.catch(() => {});
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function admitDirectory(workdir) {
|
|
309
|
+
while (!directoryCurrent(workdir)) await waitForDirectory(workdir);
|
|
310
|
+
return holdDirectory(workdir);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function reloadChangedCredentials(shared) {
|
|
314
|
+
for (const directory of shared.server.client.instanceDirectories?.() || []) directoryState(shared, directory);
|
|
315
|
+
shared.generation += 1;
|
|
316
|
+
for (const state of shared.directories.values()) {
|
|
317
|
+
if (!state.turns) reloadDirectory(shared, state)?.catch(() => {});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function connectionRecord(connection) {
|
|
322
|
+
return { canonicalUrl: connection.canonicalUrl, endpointCode: connection.endpointCode, fingerprint: connection.fingerprint };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function sameConnection(recorded, connection) {
|
|
326
|
+
return recorded?.canonicalUrl === connection.canonicalUrl && recorded.fingerprint === connection.fingerprint &&
|
|
327
|
+
recorded.endpointCode === connection.endpointCode;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// A key changes in place; a provider route belongs to the server's configuration.
|
|
331
|
+
function inPlaceConnection(recorded, connection) {
|
|
332
|
+
return Boolean(connection?.apiKey) && (recorded
|
|
333
|
+
? recorded.canonicalUrl === connection.canonicalUrl && recorded.endpointCode === connection.endpointCode
|
|
334
|
+
: !text(connection.canonicalUrl));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The running server takes the host's saved connections without stopping its
|
|
338
|
+
// turns. Reading them inside this queue applies the newest saved state last.
|
|
339
|
+
function updateConnections(readConnections) {
|
|
340
|
+
const update = connectionUpdate.then(async () => {
|
|
341
|
+
const shared = sharedProcess;
|
|
342
|
+
if (!shared) return;
|
|
343
|
+
shared.connectionsCurrent = false;
|
|
344
|
+
let changed = false;
|
|
345
|
+
try {
|
|
346
|
+
const saved = new Map((await readConnections()).map(connection => [connection.modelProviderId, connection]));
|
|
347
|
+
for (const [providerId, connection] of saved) {
|
|
348
|
+
const recorded = shared.connections.get(providerId);
|
|
349
|
+
if (sameConnection(recorded, connection) || !inPlaceConnection(recorded, connection)) continue;
|
|
350
|
+
await shared.server.client.authenticateApiKey(providerId, connection.apiKey);
|
|
351
|
+
shared.connections.set(providerId, connectionRecord(connection));
|
|
352
|
+
changed = true;
|
|
353
|
+
}
|
|
354
|
+
for (const providerId of [...shared.connections.keys()]) {
|
|
355
|
+
if (saved.has(providerId)) continue;
|
|
356
|
+
await shared.server.client.removeAuthentication(providerId);
|
|
357
|
+
shared.connections.delete(providerId);
|
|
358
|
+
changed = true;
|
|
359
|
+
}
|
|
360
|
+
shared.connectionsCurrent = true;
|
|
361
|
+
} finally {
|
|
362
|
+
if (changed) reloadChangedCredentials(shared);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
connectionUpdate = update.catch(() => {});
|
|
366
|
+
return update;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// `readConnections` returns the host's saved connections. Without it, a changed
|
|
370
|
+
// account needs a new server.
|
|
371
|
+
async function ensure(selected, start, readConnections = null) {
|
|
245
372
|
await sharedProcessStop;
|
|
246
|
-
requireConnectionAvailable();
|
|
247
373
|
if (failedStartup) await stop("opencode-startup-cleanup");
|
|
248
374
|
if (sharedProcess) {
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
currentConnection?.fingerprint === selected.fingerprint &&
|
|
253
|
-
currentConnection?.endpointCode === selected.endpointCode
|
|
254
|
-
)) {
|
|
375
|
+
const recorded = sharedProcess.connections.get(selected?.modelProviderId);
|
|
376
|
+
const current = !selected || sameConnection(recorded, selected);
|
|
377
|
+
if (current || (readConnections && inPlaceConnection(recorded, selected))) {
|
|
255
378
|
const probe = AbortSignal.timeout(1_000);
|
|
379
|
+
let healthy = false;
|
|
256
380
|
try {
|
|
257
381
|
await sharedProcess.server.client.health({ signal: probe });
|
|
258
|
-
|
|
382
|
+
healthy = true;
|
|
259
383
|
} catch (error) {
|
|
260
384
|
// A slow probe is not proof that the shared server stopped working.
|
|
261
385
|
if (probe.aborted) throw error;
|
|
262
|
-
requireConnectionAvailable();
|
|
263
386
|
await stop("opencode-health-check-failed");
|
|
264
387
|
}
|
|
388
|
+
if (healthy && sharedProcess) {
|
|
389
|
+
if (readConnections && (!current || !sharedProcess.connectionsCurrent)) await updateConnections(readConnections);
|
|
390
|
+
return sharedProcess;
|
|
391
|
+
}
|
|
265
392
|
} else {
|
|
266
393
|
await stop("opencode-connection-changed");
|
|
267
394
|
}
|
|
@@ -269,13 +396,14 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
269
396
|
if (sharedProcessStart) return sharedProcessStart;
|
|
270
397
|
sharedProcessStart = Promise.resolve().then(async () => {
|
|
271
398
|
const { connections, server } = await start();
|
|
272
|
-
|
|
273
|
-
connections: new Map(connections.map(connection => [
|
|
274
|
-
|
|
275
|
-
{ canonicalUrl: connection.canonicalUrl, endpointCode: connection.endpointCode, fingerprint: connection.fingerprint }
|
|
276
|
-
])),
|
|
277
|
-
server
|
|
399
|
+
const started = {
|
|
400
|
+
connections: new Map(connections.map(connection => [connection.modelProviderId, connectionRecord(connection)])),
|
|
401
|
+
connectionsCurrent: true, directories: new Map(), generation: 0, server
|
|
278
402
|
};
|
|
403
|
+
sharedProcess = started;
|
|
404
|
+
// An unexpected exit retires the server's leftovers at once.
|
|
405
|
+
void server.unexpectedExit?.then(() => sharedProcess === started ? stop("opencode-server-exited") : null)
|
|
406
|
+
.catch(() => {});
|
|
279
407
|
for (const target of processes.values()) {
|
|
280
408
|
target.server = openCodeServerForDirectory(server, target.workdir);
|
|
281
409
|
target.upstream = null;
|
|
@@ -360,7 +488,6 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
360
488
|
}
|
|
361
489
|
|
|
362
490
|
async function acquire(key, prepare, onFailure = async () => {}) {
|
|
363
|
-
requireConnectionAvailable();
|
|
364
491
|
const pending = processStarts.get(key);
|
|
365
492
|
if (pending) return pending;
|
|
366
493
|
const start = Promise.resolve().then(async () => {
|
|
@@ -486,31 +613,18 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
486
613
|
}
|
|
487
614
|
const application = preparation.release;
|
|
488
615
|
if (typeof input.publish === "function") {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
try {
|
|
492
|
-
await Promise.all([...processStarts.values()].map(start => start.catch(() => null)));
|
|
493
|
-
await sharedProcessStart?.catch(() => null);
|
|
494
|
-
await application.assertConnectionChange?.();
|
|
495
|
-
const targets = [...processes.values()];
|
|
496
|
-
const providerId = text(input.modelProviderId);
|
|
497
|
-
if (!providerId || targets.some(target => text(target.modelProviderId) !== providerId || !application.ownsTarget(target))) {
|
|
498
|
-
throw Object.assign(new Error("Close the other OpenCode conversations that use this connection, then retry this connection change. The saved connection has not changed."), {
|
|
499
|
-
code: "assistant_opencode_connection_in_use", statusCode: 409
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
const results = [];
|
|
503
|
-
for (const target of targets) {
|
|
504
|
-
const proof = await releaseProcessTarget(target, { retainSharedProcess: true }, application);
|
|
505
|
-
if (proof?.exited === false || proof?.ok === false) throw new Error("OpenCode could not stop the selected conversation. Retry the connection change.");
|
|
506
|
-
results.push(proof);
|
|
507
|
-
}
|
|
508
|
-
if (sharedProcess || failedStartup) results.push(await stop("opencode-connection-changed"));
|
|
509
|
-
await input.publish();
|
|
510
|
-
return { closed: results.length, ok: true };
|
|
511
|
-
} finally {
|
|
512
|
-
connectionChanging = false;
|
|
616
|
+
if (typeof preparation.connections !== "function") {
|
|
617
|
+
throw new TypeError("OpenCode connection changes require the host's saved connections.");
|
|
513
618
|
}
|
|
619
|
+
// Running turns, retained conversations and the shared server continue.
|
|
620
|
+
// The server takes the saved connections at once, and each directory
|
|
621
|
+
// reloads them as soon as it has no turn in flight.
|
|
622
|
+
await application.assertConnectionChange?.();
|
|
623
|
+
await input.publish();
|
|
624
|
+
const update = () => updateConnections(preparation.connections).catch(() => null);
|
|
625
|
+
void sharedProcessStart?.then(update, () => null);
|
|
626
|
+
await update();
|
|
627
|
+
return { closed: 0, ok: true };
|
|
514
628
|
}
|
|
515
629
|
await Promise.all([...processStarts.entries()]
|
|
516
630
|
.filter(([key]) => key.includes("\0"))
|
|
@@ -559,7 +673,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
559
673
|
return acquire(prepared.key, async () => {
|
|
560
674
|
const configured = await prepared.configure();
|
|
561
675
|
const shared = await ensure(prepared.connection,
|
|
562
|
-
async () => startPreparedProcess(await configured.prepareServer())
|
|
676
|
+
async () => startPreparedProcess(await configured.prepareServer()),
|
|
677
|
+
async () => (await configured.prepareServer()).connections).catch(prepared.failure);
|
|
563
678
|
configured.target.server = shared.server;
|
|
564
679
|
return targetFor(prepared.key, configured.target);
|
|
565
680
|
}, prepared.onFailure).catch(prepared.failure);
|
|
@@ -572,13 +687,18 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
572
687
|
|
|
573
688
|
async function runPreparedRenewalTurn(preparation) {
|
|
574
689
|
const acquired = await acquirePrepared(await preparation.process());
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
690
|
+
const releaseDirectory = await admitDirectory(acquired.workdir);
|
|
691
|
+
try {
|
|
692
|
+
const target = await ensureOpenCodeSession(acquired, preparation.session());
|
|
693
|
+
const turn = preparation.turn(target);
|
|
694
|
+
const result = await runOpenCodeConversationTurn(target.server.client, target.upstreamSessionId, turn.input, {
|
|
695
|
+
...turn.options,
|
|
696
|
+
...(turn.release ? { close: () => releaseProcessTarget(target, {}, turn.release) } : {})
|
|
697
|
+
});
|
|
698
|
+
return turn.completeResult(result);
|
|
699
|
+
} finally {
|
|
700
|
+
releaseDirectory();
|
|
701
|
+
}
|
|
582
702
|
}
|
|
583
703
|
|
|
584
704
|
async function createPreparedConversation(prepared) {
|
|
@@ -596,6 +716,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
596
716
|
throw selection.profileChanged();
|
|
597
717
|
}
|
|
598
718
|
if (!conversationId && !selection.createIfMissing) throw selection.identityRequired();
|
|
719
|
+
if (!previous?.active) await currentDirectory(target.workdir);
|
|
599
720
|
const selected = await selectConversation(prepared.key, target, conversationId, selection.input, selection.registration);
|
|
600
721
|
return { ...selected, executionProfile: prepared.executionProfile, target };
|
|
601
722
|
}
|
|
@@ -640,7 +761,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
640
761
|
});
|
|
641
762
|
if (!result.deleted) return result;
|
|
642
763
|
const cleanup = prepared.cleanupAfterDelete;
|
|
643
|
-
|
|
764
|
+
let providerExit = null;
|
|
765
|
+
if (cleanup) {
|
|
766
|
+
providerExit = await closeSession(cleanup.sessionId, cleanup.options, cleanup.application);
|
|
767
|
+
// The result carries the deleted scope's exit proof. Only renewal releases
|
|
768
|
+
// a kept proof, and a scope is never renewed, so none is kept.
|
|
769
|
+
processExitProofs.delete(cleanup.sessionId);
|
|
770
|
+
}
|
|
644
771
|
return { conversationId, deleted: true, ok: providerExit?.ok !== false,
|
|
645
772
|
...(providerExit ? { providerExit } : {}) };
|
|
646
773
|
}
|
|
@@ -837,12 +964,15 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
837
964
|
deliveryMode: "steer"
|
|
838
965
|
};
|
|
839
966
|
}
|
|
967
|
+
// A new native turn starts only on its directory's current credentials.
|
|
968
|
+
while (!tracked.active && !directoryCurrent(target.workdir)) await waitForDirectory(target.workdir);
|
|
840
969
|
if (tracked.active) {
|
|
841
970
|
throw Object.assign(new Error("This conversation is still working."), {
|
|
842
971
|
code: "assistant_opencode_conversation_busy", details: {}, statusCode: 409
|
|
843
972
|
});
|
|
844
973
|
}
|
|
845
974
|
tracked.active = true;
|
|
975
|
+
const releaseDirectory = holdDirectory(target.workdir);
|
|
846
976
|
tracked.error = null;
|
|
847
977
|
tracked.interrupted = false;
|
|
848
978
|
const turnAbort = new AbortController();
|
|
@@ -869,10 +999,14 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
869
999
|
beforeDispatch: options.beforeDispatch
|
|
870
1000
|
});
|
|
871
1001
|
} catch (error) {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1002
|
+
try {
|
|
1003
|
+
await events.close();
|
|
1004
|
+
tracked.error = error;
|
|
1005
|
+
if (tracked.promptAttempted) await stopUnobservedSession(observedTarget, conversationId);
|
|
1006
|
+
tracked.active = false;
|
|
1007
|
+
} finally {
|
|
1008
|
+
releaseDirectory();
|
|
1009
|
+
}
|
|
876
1010
|
throw error;
|
|
877
1011
|
}
|
|
878
1012
|
tracked.inputMessageId = text(admitted.id);
|
|
@@ -899,6 +1033,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
899
1033
|
}
|
|
900
1034
|
return result;
|
|
901
1035
|
})();
|
|
1036
|
+
void tracked.completion.then(releaseDirectory, releaseDirectory);
|
|
902
1037
|
// Keep a failed admitted turn observable to the client's conversation reads.
|
|
903
1038
|
void tracked.completion.catch((error) => {
|
|
904
1039
|
if (tracked.abortController === turnAbort) tracked.error = error;
|
|
@@ -1135,6 +1270,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1135
1270
|
try {
|
|
1136
1271
|
const preparation = await application.prepare({ overwrite: !currentMonitor, threadId: currentThreadId });
|
|
1137
1272
|
const preparedTarget = preparation.process ? await acquirePrepared(preparation.process) : preparation.target;
|
|
1273
|
+
if (!monitors.get(key)) await currentDirectory(preparedTarget.workdir);
|
|
1138
1274
|
target = await ensureOpenCodeSession(preparedTarget, preparation.session);
|
|
1139
1275
|
currentThreadId = target.upstreamSessionId;
|
|
1140
1276
|
if (startingTurn) {
|
|
@@ -1166,6 +1302,16 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1166
1302
|
turn: openCodeTurnSnapshot(turns.get(key), currentThreadId) } };
|
|
1167
1303
|
}
|
|
1168
1304
|
}
|
|
1305
|
+
// A new native turn starts only on its directory's current credentials;
|
|
1306
|
+
// the wait, if any, is for that directory's own running turns.
|
|
1307
|
+
while (!currentMonitor && !directoryCurrent(target.workdir)) {
|
|
1308
|
+
await waitForDirectory(target.workdir);
|
|
1309
|
+
currentMonitor = monitors.get(key);
|
|
1310
|
+
if (currentMonitor && !turns.get(key)?.active) {
|
|
1311
|
+
await currentMonitor;
|
|
1312
|
+
currentMonitor = monitors.get(key);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1169
1315
|
admission = Promise.withResolvers();
|
|
1170
1316
|
// The observer may be between polls when dispatch or persistence fails.
|
|
1171
1317
|
// Keep the raw promise rejectable without an unhandled rejection.
|
|
@@ -1391,6 +1537,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1391
1537
|
return monitor;
|
|
1392
1538
|
}
|
|
1393
1539
|
|
|
1540
|
+
// A removed connection's models are gone from its reloaded directory.
|
|
1541
|
+
function disconnectedProviderFailure(failure) {
|
|
1542
|
+
const providerId = /\bModel not found: ([^/\s]+)\//u.exec(failure)?.[1];
|
|
1543
|
+
return providerId && sharedProcess && !sharedProcess.connections.has(providerId)
|
|
1544
|
+
? `${providerId} isn't connected. Connect it again, then send your message again.` : "";
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1394
1547
|
function beginMonitor(key, target, turn, {
|
|
1395
1548
|
observe, observation, eventReady, readiness, finalResponse,
|
|
1396
1549
|
writeRun, projectMessages, completeResult, onRetired
|
|
@@ -1405,6 +1558,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1405
1558
|
registeredTarget.upstreamSessionId === target.upstreamSessionId ? registeredTarget : null;
|
|
1406
1559
|
const finalAssistantResultProcess = finalAssistantResultTarget ? sharedProcess : null;
|
|
1407
1560
|
turns.set(key, turn);
|
|
1561
|
+
const releaseDirectory = holdDirectory(target.workdir);
|
|
1408
1562
|
const signal = AbortSignal.any([target.abortController.signal, turn.abortController.signal]);
|
|
1409
1563
|
const monitor = Promise.resolve().then(async () => {
|
|
1410
1564
|
const events = observation
|
|
@@ -1472,7 +1626,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1472
1626
|
failure = "OpenCode finished without a user-facing final response. Please send your message again.";
|
|
1473
1627
|
finalState = "failed";
|
|
1474
1628
|
} else if (failure) {
|
|
1475
|
-
|
|
1629
|
+
const disconnected = disconnectedProviderFailure(failure);
|
|
1630
|
+
failure = disconnected || failure;
|
|
1631
|
+
credentialFailure = Boolean(disconnected) || openCodeCredentialFailure(failure);
|
|
1476
1632
|
finalState = "failed";
|
|
1477
1633
|
} else if (turn.interruptRequested) {
|
|
1478
1634
|
finalState = "interrupted";
|
|
@@ -1492,7 +1648,9 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1492
1648
|
} else {
|
|
1493
1649
|
const cause = signal.aborted ? signal.reason : error;
|
|
1494
1650
|
failure = text(cause?.message) || "OpenCode turn failed.";
|
|
1495
|
-
|
|
1651
|
+
const disconnected = disconnectedProviderFailure(failure);
|
|
1652
|
+
failure = disconnected || failure;
|
|
1653
|
+
credentialFailure = Boolean(disconnected) || openCodeCredentialFailure(failure);
|
|
1496
1654
|
providerApiFailure = openCodeProviderApiFailure(cause);
|
|
1497
1655
|
finalState = target.abortController.signal.aborted
|
|
1498
1656
|
? "cancelled"
|
|
@@ -1533,6 +1691,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1533
1691
|
if (monitors.get(key) === monitor) {
|
|
1534
1692
|
monitors.delete(key);
|
|
1535
1693
|
}
|
|
1694
|
+
releaseDirectory();
|
|
1536
1695
|
onRetired();
|
|
1537
1696
|
});
|
|
1538
1697
|
monitors.set(key, monitor);
|
|
@@ -1740,6 +1899,8 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1740
1899
|
|
|
1741
1900
|
async function ensurePreparedTerminal(preparation) {
|
|
1742
1901
|
const target = await acquirePreparedTerminal(preparation);
|
|
1902
|
+
// The native terminal starts its own turns there.
|
|
1903
|
+
await currentDirectory(target.workdir);
|
|
1743
1904
|
return ensureOpenCodeSession(target, preparation.session());
|
|
1744
1905
|
}
|
|
1745
1906
|
|
|
@@ -258,13 +258,13 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
|
|
|
258
258
|
return conversation.read(context, representation, nativeResult);
|
|
259
259
|
},
|
|
260
260
|
inspectTemporaryActivity({ native, context }) {
|
|
261
|
-
return native.owner.hasActiveTemporaryConversation(context
|
|
261
|
+
return native.owner.hasActiveTemporaryConversation(context);
|
|
262
262
|
},
|
|
263
263
|
releaseRenewalPredecessorProcessExitProof({ native, context }) {
|
|
264
|
-
return native.owner.
|
|
264
|
+
return native.owner.releaseSession(context, native.application);
|
|
265
265
|
},
|
|
266
266
|
releaseRenewalSuccessorProcessExitProof({ native, context }) {
|
|
267
|
-
return native.owner.
|
|
267
|
+
return native.owner.releaseSession(context, native.application);
|
|
268
268
|
},
|
|
269
269
|
async generateRenewalHandover({ native, input, context }) {
|
|
270
270
|
const prepared = native.preparation.handover(input, context);
|
|
@@ -218,10 +218,16 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
|
|
|
218
218
|
return native.runOwner.ensureSessionReadiness(sessionId, await native.preparation.readiness());
|
|
219
219
|
},
|
|
220
220
|
async updateConversationSettings({ sessionId, native, context, input }) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
221
|
+
// Changing a thread's settings is not a use that keeps Codex running.
|
|
222
|
+
const borrow = { release() {} };
|
|
223
|
+
try {
|
|
224
|
+
const acquired = await native.runOwner.conversationContext(sessionId, { conversationId: input.threadId }, context, borrow);
|
|
225
|
+
if (acquired.ok === false) return acquired;
|
|
226
|
+
await acquired.provider.updateThreadSettings(input.threadId, { model: input.model, effort: input.effort });
|
|
227
|
+
return { ok: true };
|
|
228
|
+
} finally {
|
|
229
|
+
borrow.release();
|
|
230
|
+
}
|
|
225
231
|
},
|
|
226
232
|
async createConversation({ sessionId, native, input, context }) {
|
|
227
233
|
return native.runOwner.createConversation(sessionId, input, context);
|
|
@@ -490,13 +490,15 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
490
490
|
const start = host.opencode
|
|
491
491
|
? async () => facilities.runtime.startPreparedProcess(await facilities.prepareServer())
|
|
492
492
|
: () => facilities.start({ signal, connection });
|
|
493
|
+
// A managed host's saved connections reach its running server in place.
|
|
494
|
+
const savedConnections = host.opencode ? async () => (await facilities.prepareServer()).connections : null;
|
|
493
495
|
if (!native) {
|
|
494
496
|
native = await facilities.runtime.acquire(binding.directory, async () => {
|
|
495
|
-
const shared = await facilities.runtime.ensure(facilities.selected, start);
|
|
497
|
+
const shared = await facilities.runtime.ensure(facilities.selected, start, savedConnections);
|
|
496
498
|
return { key: binding.directory, workdir: binding.workdir, upstreamSessionId: binding.sessionId,
|
|
497
499
|
abortController: new AbortController(), server: openCodeServerForDirectory(shared.server, binding.workdir) };
|
|
498
500
|
});
|
|
499
|
-
} else await facilities.runtime.ensure(facilities.selected, start);
|
|
501
|
+
} else await facilities.runtime.ensure(facilities.selected, start, savedConnections);
|
|
500
502
|
signal.throwIfAborted();
|
|
501
503
|
await updateBinding({ executionId: native.server.executionId, processDirectory: native.server.privateRoot || "" });
|
|
502
504
|
const catalogue = await native.server.client.providers({ signal });
|
|
@@ -28,6 +28,9 @@ function failure(message, code, statusCode = 409, { messageCode, messageParams }
|
|
|
28
28
|
return Object.assign(new Error(message), { code, statusCode }, messageCode ? { messageCode, messageParams: messageParams || {} } : {});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// The newest messages cancelled in one open conversation, refused if sent again.
|
|
32
|
+
const MAX_CANCELLED_MESSAGES = 100;
|
|
33
|
+
|
|
31
34
|
// A native delivery result keeps its own sentence and messageCode when it has one.
|
|
32
35
|
function deliveryFailure(delivered) {
|
|
33
36
|
const code = delivered?.code || "conversation_delivery_uncertain";
|
|
@@ -1558,6 +1561,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1558
1561
|
deliveryTiming?.("preparation-started");
|
|
1559
1562
|
const operation = goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send";
|
|
1560
1563
|
await access(context, entry.id, operation, entry, grant);
|
|
1564
|
+
if (entry.cancelledMessages.has(input?.messageId)) {
|
|
1565
|
+
throw Object.assign(failure("This message was cancelled. Send it again as a new message.", "conversation_message_cancelled", 409,
|
|
1566
|
+
{ messageCode: "assistant.conversation.messageCancelled" }), { delivery: "not-sent" });
|
|
1567
|
+
}
|
|
1561
1568
|
await entry.conversation?.admission(context, operation);
|
|
1562
1569
|
deliveryTiming?.("authorized");
|
|
1563
1570
|
if (goal) context?.signal?.throwIfAborted();
|
|
@@ -1738,6 +1745,34 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1738
1745
|
return { stopped: true };
|
|
1739
1746
|
}
|
|
1740
1747
|
|
|
1748
|
+
// Cancel of one message in a runtime-owned conversation never waits for the
|
|
1749
|
+
// conversation queue. The message is recorded as cancelled at once, so a send
|
|
1750
|
+
// of it still queued, or arriving later, is refused as not sent; its own
|
|
1751
|
+
// submission stops, ending any turn it started. Its unconfirmed request or
|
|
1752
|
+
// pending native attempt is discarded afterwards, in queue order. A message
|
|
1753
|
+
// already accepted stays history.
|
|
1754
|
+
async function cancelDelivery(entry, context, messageId, representation) {
|
|
1755
|
+
entry.cancelledMessages.delete(messageId);
|
|
1756
|
+
entry.cancelledMessages.add(messageId);
|
|
1757
|
+
if (entry.cancelledMessages.size > MAX_CANCELLED_MESSAGES) entry.cancelledMessages.delete(entry.cancelledMessages.values().next().value);
|
|
1758
|
+
const interrupted = Boolean(entry.active?.requests.has(messageId));
|
|
1759
|
+
if (interrupted) await stop(entry, context, undefined, representation);
|
|
1760
|
+
void serial(entry, async () => {
|
|
1761
|
+
await access(context, entry.id, "cancel", entry);
|
|
1762
|
+
if (entry.conversation) {
|
|
1763
|
+
await nativeChangeover(entry, context, null, () => { throw new Error("Cancel never sends a message."); }).discard({ messageId });
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
await storage.write(entry.id, async transaction => {
|
|
1767
|
+
const metadata = await transaction.readMetadata();
|
|
1768
|
+
if (metadata.runtime.request?.messageId !== messageId) return;
|
|
1769
|
+
delete metadata.runtime.request;
|
|
1770
|
+
await transaction.writeMetadata(metadata);
|
|
1771
|
+
});
|
|
1772
|
+
}).catch(() => {});
|
|
1773
|
+
return { ok: true, cancelled: true, messageId, interrupted };
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1741
1776
|
async function readGoal(entry, context, representation) {
|
|
1742
1777
|
await access(context, entry.id, "readGoal", entry);
|
|
1743
1778
|
if (!capabilities(entry).goals) return null;
|
|
@@ -2316,7 +2351,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2316
2351
|
function retireEntry(entry) {
|
|
2317
2352
|
entry.disposed = true;
|
|
2318
2353
|
entry.listeners.clear();
|
|
2319
|
-
streams.
|
|
2354
|
+
// Live streams are kept per conversation id. A logical conversation streams
|
|
2355
|
+
// through its own owner, so the stream under its id belongs to another entry
|
|
2356
|
+
// with the same id, such as its session's Main conversation.
|
|
2357
|
+
if (!entry.conversation?.commands) streams.clear(entry.id);
|
|
2320
2358
|
if (entries.get(entry.key) === entry) entries.delete(entry.key);
|
|
2321
2359
|
}
|
|
2322
2360
|
|
|
@@ -2570,7 +2608,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2570
2608
|
const key = conversation?.namespace || id;
|
|
2571
2609
|
let entry = entries.get(key);
|
|
2572
2610
|
if (!entry) {
|
|
2573
|
-
entry = { id, key, conversation, host: selectedHost, pending: Promise.resolve(), active: null, listeners: new Set(), error: ""
|
|
2611
|
+
entry = { id, key, conversation, host: selectedHost, pending: Promise.resolve(), active: null, listeners: new Set(), error: "",
|
|
2612
|
+
cancelledMessages: new Set() };
|
|
2574
2613
|
entries.set(key, entry);
|
|
2575
2614
|
entry.initializing = initialize(entry, configuration, engine).catch(error => {
|
|
2576
2615
|
if (entries.get(key) === entry) entries.delete(key);
|
|
@@ -2623,6 +2662,20 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2623
2662
|
await access(context, id, "read", entry);
|
|
2624
2663
|
return read(entry);
|
|
2625
2664
|
},
|
|
2665
|
+
// Cancel of one unsent or unconfirmed message always records it; an
|
|
2666
|
+
// application conversation supplies its own operation for that.
|
|
2667
|
+
async cancelDelivery({ messageId } = {}) {
|
|
2668
|
+
if (typeof messageId !== "string" || !messageId) throw new TypeError("A message id is required.");
|
|
2669
|
+
await access(context, id, "cancel", entry);
|
|
2670
|
+
if (entry.conversation?.commands) {
|
|
2671
|
+
if (typeof entry.conversation.commands.cancelDelivery !== "function") {
|
|
2672
|
+
throw failure("This conversation cannot cancel one message.", "conversation_unsupported", 400,
|
|
2673
|
+
{ messageCode: "assistant.conversation.messageCancelUnsupported" });
|
|
2674
|
+
}
|
|
2675
|
+
return entry.conversation.commands.cancelDelivery({ messageId }, context);
|
|
2676
|
+
}
|
|
2677
|
+
return cancelDelivery(entry, context, messageId, representation);
|
|
2678
|
+
},
|
|
2626
2679
|
async cancel(input) {
|
|
2627
2680
|
await access(context, id, "cancel", entry);
|
|
2628
2681
|
if (entry.conversation?.commands) return entry.conversation.commands.cancel(input, context);
|
|
@@ -2847,6 +2900,23 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2847
2900
|
});
|
|
2848
2901
|
return retained || cleanupUnopened();
|
|
2849
2902
|
},
|
|
2903
|
+
// Trusted server lifecycle: the application deleted or archived a logical
|
|
2904
|
+
// conversation that it binds through its own commands. Its open entry, if
|
|
2905
|
+
// any, leaves memory and its handles close. Nothing is opened; native and
|
|
2906
|
+
// storage-owned conversations keep their own disposal.
|
|
2907
|
+
async retireOpenConversation({ namespace, sessionId }) {
|
|
2908
|
+
const entry = entries.get(namespace);
|
|
2909
|
+
if (!entry || entry.id !== sessionId) return { retired: false };
|
|
2910
|
+
if (!entry.conversation?.commands) {
|
|
2911
|
+
throw new TypeError("Only a logical conversation bound through its commands retires without disposal.");
|
|
2912
|
+
}
|
|
2913
|
+
await entry.initializing.catch(() => {});
|
|
2914
|
+
return serial(entry, async () => {
|
|
2915
|
+
if (entry.disposed || entries.get(namespace) !== entry) return { retired: false };
|
|
2916
|
+
retireEntry(entry);
|
|
2917
|
+
return { retired: true };
|
|
2918
|
+
});
|
|
2919
|
+
},
|
|
2850
2920
|
// Host publication from the same native owner, through the existing entry.
|
|
2851
2921
|
// This does not subscribe to a provider or retain another observer registry.
|
|
2852
2922
|
async publishNative({ namespace, sessionId, event }) {
|