@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
|
@@ -19,14 +19,41 @@ export function createCodexConversationCommands({
|
|
|
19
19
|
interruptFailedCode,
|
|
20
20
|
namespace,
|
|
21
21
|
providerSessions,
|
|
22
|
-
sessionConnection,
|
|
23
22
|
ensureSessionProvider,
|
|
23
|
+
borrowSessionProvider,
|
|
24
24
|
steerFailedCode,
|
|
25
25
|
turnOutcomes
|
|
26
26
|
}) {
|
|
27
27
|
const codexAppServerConversations = new Map();
|
|
28
28
|
const codexAppServerConversationTurnStarts = new Map();
|
|
29
29
|
|
|
30
|
+
// Reads and Temporary chat commands borrow their provider instead of claiming
|
|
31
|
+
// it: the operation returns its borrow when it finishes, and an unused
|
|
32
|
+
// provider stops a short idle later.
|
|
33
|
+
function providerBorrow() {
|
|
34
|
+
return { release() {} };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A persistent conversation holds its borrowed provider only while it
|
|
38
|
+
// observes native work running there: a turn, or an active goal.
|
|
39
|
+
function holdWhileActive(state) {
|
|
40
|
+
const active = state.persistent === true && Boolean(state.unsubscribe) &&
|
|
41
|
+
(codexAppServerConversationTurnIsActive(state.status) || state.goal?.status === "active");
|
|
42
|
+
if (active && !state.releaseProvider) {
|
|
43
|
+
state.releaseProvider = providerSessions.owner.holdProvider(state.provider);
|
|
44
|
+
} else if (!active && state.releaseProvider) {
|
|
45
|
+
const release = state.releaseProvider;
|
|
46
|
+
state.releaseProvider = null;
|
|
47
|
+
release();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function forgetObservation(state) {
|
|
52
|
+
state.unsubscribe?.();
|
|
53
|
+
state.unsubscribe = null;
|
|
54
|
+
holdWhileActive(state);
|
|
55
|
+
}
|
|
56
|
+
|
|
30
57
|
function codexAppServerConversation(sessionId = "", conversationId = "") {
|
|
31
58
|
return codexAppServerConversations.get(
|
|
32
59
|
namespace(sessionId)
|
|
@@ -70,7 +97,7 @@ export function createCodexConversationCommands({
|
|
|
70
97
|
const conversations = codexAppServerConversations.get(key) || new Map();
|
|
71
98
|
let state = conversations.get(conversationId);
|
|
72
99
|
if (state?.provider === provider && state.unsubscribe) return state;
|
|
73
|
-
state
|
|
100
|
+
if (state) forgetObservation(state);
|
|
74
101
|
state = { conversationId, provider, persistent: true, status: "ready", runId: "", error: "", turnOutcome: "" };
|
|
75
102
|
state.unsubscribe = provider.subscribe((notification) => {
|
|
76
103
|
if (codexAppServerNotificationThreadId(notification) !== conversationId) return;
|
|
@@ -105,6 +132,7 @@ export function createCodexConversationCommands({
|
|
|
105
132
|
turnOutcome: completedOutcome
|
|
106
133
|
}).catch(() => {});
|
|
107
134
|
}
|
|
135
|
+
holdWhileActive(state);
|
|
108
136
|
});
|
|
109
137
|
conversations.set(conversationId, state);
|
|
110
138
|
codexAppServerConversations.set(key, conversations);
|
|
@@ -139,6 +167,7 @@ export function createCodexConversationCommands({
|
|
|
139
167
|
// Stop it once; browser reloads reuse the existing backend observer.
|
|
140
168
|
if (previous?.provider !== context.provider && (codexAppServerConversationTurnIsActive(status) || goal?.status === "active")) {
|
|
141
169
|
Object.assign(state, { runId, status: "inProgress", goal });
|
|
170
|
+
holdWhileActive(state);
|
|
142
171
|
try {
|
|
143
172
|
await context.provider.stopThreadForObservationLoss(conversationId, "");
|
|
144
173
|
} catch (error) {
|
|
@@ -150,6 +179,7 @@ export function createCodexConversationCommands({
|
|
|
150
179
|
}
|
|
151
180
|
if (goal?.status === "active") status = "inProgress";
|
|
152
181
|
Object.assign(state, { runId, status, goal });
|
|
182
|
+
holdWhileActive(state);
|
|
153
183
|
const messages = [];
|
|
154
184
|
for (const turn of turns) {
|
|
155
185
|
const turnId = codexAppServerProviderTurnId(turn);
|
|
@@ -231,7 +261,7 @@ export function createCodexConversationCommands({
|
|
|
231
261
|
return preparation.settings(config);
|
|
232
262
|
}
|
|
233
263
|
|
|
234
|
-
async function acquireCodexAppServerConversationContext(sessionId, prepared, input = {}) {
|
|
264
|
+
async function acquireCodexAppServerConversationContext(sessionId, prepared, input = {}, borrow = null) {
|
|
235
265
|
if (prepared.result) return prepared.result;
|
|
236
266
|
const { context } = prepared;
|
|
237
267
|
if (context.assistantScope) {
|
|
@@ -267,14 +297,21 @@ export function createCodexConversationCommands({
|
|
|
267
297
|
assertAdmitted();
|
|
268
298
|
const providerOptions = activeProvider ? null : await prepared.providerOptions;
|
|
269
299
|
assertAdmitted();
|
|
270
|
-
|
|
300
|
+
let provider = activeProvider?.provider;
|
|
301
|
+
// A Helper's provider keeps its own retained lifecycle.
|
|
302
|
+
if (!provider && borrow && !prepared.helperTurn) {
|
|
303
|
+
const borrowed = await borrowSessionProvider(sessionId, providerOptions);
|
|
304
|
+
borrow.release = borrowed.release;
|
|
305
|
+
provider = borrowed.provider;
|
|
306
|
+
}
|
|
307
|
+
provider ||= await ensureSessionProvider(sessionId, providerOptions);
|
|
271
308
|
assertAdmitted();
|
|
272
309
|
return prepared.project(provider, activeProvider?.providerOptions || providerOptions, helperRestore);
|
|
273
310
|
}
|
|
274
311
|
|
|
275
|
-
async function codexAppServerConversationContext(sessionId, input, options) {
|
|
312
|
+
async function codexAppServerConversationContext(sessionId, input, options, borrow = null) {
|
|
276
313
|
return acquireCodexAppServerConversationContext(sessionId,
|
|
277
|
-
await conversationPreparation.context(sessionId, input, options), input);
|
|
314
|
+
await conversationPreparation.context(sessionId, input, options), input, borrow);
|
|
278
315
|
}
|
|
279
316
|
|
|
280
317
|
async function codexAppServerEphemeralScopeContext(sessionId, input, scope) {
|
|
@@ -289,15 +326,20 @@ export function createCodexConversationCommands({
|
|
|
289
326
|
|
|
290
327
|
async function createCodexAppServerConversation(sessionId, input = {}, options = {}) {
|
|
291
328
|
return codexAppServerConversationResult(async () => {
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
329
|
+
const borrow = input.persistent === true ? providerBorrow() : null;
|
|
330
|
+
try {
|
|
331
|
+
const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
|
|
332
|
+
if (context.ok === false) return context;
|
|
333
|
+
const execution = conversationPreparation.execution(sessionId, input, options, context, null, "create");
|
|
334
|
+
return await createPreparedCodexAppServerConversation(sessionId, input, {
|
|
335
|
+
provider: context.provider,
|
|
336
|
+
executionProfile: execution.executionProfile,
|
|
337
|
+
helperIsolation: conversationPreparation.isolation,
|
|
338
|
+
threadPreparation: () => execution.threadPreparation
|
|
339
|
+
});
|
|
340
|
+
} finally {
|
|
341
|
+
borrow?.release();
|
|
342
|
+
}
|
|
301
343
|
});
|
|
302
344
|
}
|
|
303
345
|
|
|
@@ -401,35 +443,44 @@ export function createCodexConversationCommands({
|
|
|
401
443
|
ok: false
|
|
402
444
|
};
|
|
403
445
|
}
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
};
|
|
446
|
+
const borrow = input.persistent === true ? providerBorrow() : null;
|
|
447
|
+
try {
|
|
448
|
+
const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
|
|
449
|
+
if (context.ok === false) return context;
|
|
450
|
+
// A persistent turn is observed on the provider it runs on, which its
|
|
451
|
+
// conversation then holds until the turn ends.
|
|
452
|
+
const conversationState = input.persistent === true
|
|
453
|
+
? observeCodexConversation(sessionId, conversationId, context.provider)
|
|
454
|
+
: codexAppServerConversation(sessionId, conversationId);
|
|
455
|
+
const execution = conversationPreparation.execution(sessionId, input, options, context, conversationState, "start", { conversationId, prompt });
|
|
456
|
+
if (input.ephemeral === true && !conversationState) {
|
|
457
|
+
return {
|
|
458
|
+
...conversationPreparation.expired(conversationId, input),
|
|
459
|
+
code: `${errorPrefix}temporary_conversation_expired`,
|
|
460
|
+
ok: false,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
// Do not evaluate native preparation until the original duplicate/active
|
|
464
|
+
// checks and Helper isolation capability checks have run.
|
|
465
|
+
return await startPreparedCodexAppServerConversationTurn(sessionId, input, {
|
|
466
|
+
executionProfile: execution.executionProfile,
|
|
467
|
+
helperIsolation: conversationPreparation.isolation,
|
|
468
|
+
prepareTurn: execution.prepareTurn,
|
|
469
|
+
authorized: execution.authorized,
|
|
470
|
+
readOnly: execution.readOnly,
|
|
471
|
+
actorMetadata: execution.actorMetadata,
|
|
472
|
+
onEvent: execution.onEvent,
|
|
473
|
+
projectResult: execution.projectResult,
|
|
474
|
+
conversationId,
|
|
475
|
+
messageId,
|
|
476
|
+
prompt,
|
|
477
|
+
provider: context.provider,
|
|
478
|
+
conversationState,
|
|
479
|
+
threadPreparation: () => execution.threadPreparation
|
|
480
|
+
});
|
|
481
|
+
} finally {
|
|
482
|
+
borrow?.release();
|
|
414
483
|
}
|
|
415
|
-
// Do not evaluate native preparation until the original duplicate/active
|
|
416
|
-
// checks and Helper isolation capability checks have run.
|
|
417
|
-
return startPreparedCodexAppServerConversationTurn(sessionId, input, {
|
|
418
|
-
executionProfile: execution.executionProfile,
|
|
419
|
-
helperIsolation: conversationPreparation.isolation,
|
|
420
|
-
prepareTurn: execution.prepareTurn,
|
|
421
|
-
authorized: execution.authorized,
|
|
422
|
-
readOnly: execution.readOnly,
|
|
423
|
-
actorMetadata: execution.actorMetadata,
|
|
424
|
-
onEvent: execution.onEvent,
|
|
425
|
-
projectResult: execution.projectResult,
|
|
426
|
-
conversationId,
|
|
427
|
-
messageId,
|
|
428
|
-
prompt,
|
|
429
|
-
provider: context.provider,
|
|
430
|
-
conversationState,
|
|
431
|
-
threadPreparation: () => execution.threadPreparation
|
|
432
|
-
});
|
|
433
484
|
}));
|
|
434
485
|
}
|
|
435
486
|
|
|
@@ -453,30 +504,42 @@ export function createCodexConversationCommands({
|
|
|
453
504
|
if (input.persistent && conversationState?.persistent && conversationState.provider.isAvailable?.() &&
|
|
454
505
|
!conversationState.provider.observationFailure && !conversationPreparation.admissionError(sessionId)) {
|
|
455
506
|
// Poll the already observed native thread. Execution environment setup
|
|
456
|
-
// belongs to Send and reconnection, not every history read.
|
|
457
|
-
|
|
507
|
+
// belongs to Send and reconnection, not every history read. The read
|
|
508
|
+
// holds that provider so its idle release cannot stop it mid-read.
|
|
509
|
+
const release = providerSessions.owner.holdProvider(conversationState.provider);
|
|
510
|
+
try {
|
|
511
|
+
return await readPreparedPersistentCodexConversation(sessionId, input, { provider: conversationState.provider });
|
|
512
|
+
} finally {
|
|
513
|
+
release();
|
|
514
|
+
}
|
|
458
515
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
516
|
+
// Reading history or status is not a use that keeps Codex running.
|
|
517
|
+
const borrow = providerBorrow();
|
|
518
|
+
try {
|
|
519
|
+
const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
|
|
520
|
+
if (context.ok === false) {
|
|
521
|
+
return context;
|
|
522
|
+
}
|
|
523
|
+
if (input.persistent) return await readPreparedPersistentCodexConversation(sessionId, input, context);
|
|
524
|
+
const thread = await context.provider.readThread(conversationId);
|
|
525
|
+
const runId = normalizeCodexRunText(input.runId) || codexAppServerThreadTurnId(thread);
|
|
526
|
+
const status = codexAppServerThreadStatus(thread);
|
|
527
|
+
const text = runId
|
|
528
|
+
? codexAppServerProviderThreadAssistantSegments(thread, runId)
|
|
529
|
+
.map((segment) => segment.text)
|
|
530
|
+
.join("\n\n")
|
|
531
|
+
: "";
|
|
532
|
+
return {
|
|
533
|
+
conversationId,
|
|
534
|
+
error: codexAppServerThreadError(thread),
|
|
535
|
+
ok: true,
|
|
536
|
+
runId,
|
|
537
|
+
status,
|
|
538
|
+
...conversationPreparation.response(text)
|
|
539
|
+
};
|
|
540
|
+
} finally {
|
|
541
|
+
borrow.release();
|
|
462
542
|
}
|
|
463
|
-
if (input.persistent) return readPreparedPersistentCodexConversation(sessionId, input, context);
|
|
464
|
-
const thread = await context.provider.readThread(conversationId);
|
|
465
|
-
const runId = normalizeCodexRunText(input.runId) || codexAppServerThreadTurnId(thread);
|
|
466
|
-
const status = codexAppServerThreadStatus(thread);
|
|
467
|
-
const text = runId
|
|
468
|
-
? codexAppServerProviderThreadAssistantSegments(thread, runId)
|
|
469
|
-
.map((segment) => segment.text)
|
|
470
|
-
.join("\n\n")
|
|
471
|
-
: "";
|
|
472
|
-
return {
|
|
473
|
-
conversationId,
|
|
474
|
-
error: codexAppServerThreadError(thread),
|
|
475
|
-
ok: true,
|
|
476
|
-
runId,
|
|
477
|
-
status,
|
|
478
|
-
...conversationPreparation.response(text)
|
|
479
|
-
};
|
|
480
543
|
});
|
|
481
544
|
}
|
|
482
545
|
|
|
@@ -500,17 +563,22 @@ export function createCodexConversationCommands({
|
|
|
500
563
|
const result = await scoped.completion;
|
|
501
564
|
return { ...result, conversationId, ok: true, runId };
|
|
502
565
|
}
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
566
|
+
const borrow = input.persistent === true ? providerBorrow() : null;
|
|
567
|
+
try {
|
|
568
|
+
const context = await codexAppServerConversationContext(sessionId, input, {
|
|
569
|
+
assistantScope
|
|
570
|
+
}, borrow);
|
|
571
|
+
if (context.ok === false) {
|
|
572
|
+
return context;
|
|
573
|
+
}
|
|
574
|
+
const result = await waitForPreparedCodexAppServerConversationTurn(sessionId, input, {
|
|
575
|
+
provider: context.provider,
|
|
576
|
+
onEvent
|
|
577
|
+
});
|
|
578
|
+
return { ...result, ...conversationPreparation.response(result.rawText) };
|
|
579
|
+
} finally {
|
|
580
|
+
borrow?.release();
|
|
508
581
|
}
|
|
509
|
-
const result = await waitForPreparedCodexAppServerConversationTurn(sessionId, input, {
|
|
510
|
-
provider: context.provider,
|
|
511
|
-
onEvent
|
|
512
|
-
});
|
|
513
|
-
return { ...result, ...conversationPreparation.response(result.rawText) };
|
|
514
582
|
});
|
|
515
583
|
}
|
|
516
584
|
|
|
@@ -536,14 +604,19 @@ export function createCodexConversationCommands({
|
|
|
536
604
|
};
|
|
537
605
|
}
|
|
538
606
|
if (input.persistent) {
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
607
|
+
const borrow = providerBorrow();
|
|
608
|
+
try {
|
|
609
|
+
const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
|
|
610
|
+
if (context.ok === false) return context;
|
|
611
|
+
return await stopPersistentCodexConversation({
|
|
612
|
+
conversationId,
|
|
613
|
+
conversationState,
|
|
614
|
+
provider: context.provider,
|
|
615
|
+
turnOutcome
|
|
616
|
+
});
|
|
617
|
+
} finally {
|
|
618
|
+
borrow.release();
|
|
619
|
+
}
|
|
547
620
|
}
|
|
548
621
|
const result = await interruptDetachedCodexAppServerConversationTurn(sessionId, {
|
|
549
622
|
agentSettings: input.agentSettings,
|
|
@@ -557,12 +630,47 @@ export function createCodexConversationCommands({
|
|
|
557
630
|
};
|
|
558
631
|
}
|
|
559
632
|
|
|
633
|
+
// A scope's delete stops only what this server runs for it: its live providers,
|
|
634
|
+
// and a runtime whose earlier stop could not prove its exit. It never acquires
|
|
635
|
+
// a provider, which would start a Codex app-server only to stop it, so it needs
|
|
636
|
+
// no account, tool home or workdir. A runtime other providers share stays theirs.
|
|
637
|
+
async function stopCodexScopeRuntimes(sessionKey) {
|
|
638
|
+
const owner = providerSessions.owner;
|
|
639
|
+
const attempted = new Set([...owner.providers]
|
|
640
|
+
.filter(([providerKey]) => owner.providerOwners.get(providerKey)?.sessionKey === sessionKey)
|
|
641
|
+
.map(([, provider]) => provider));
|
|
642
|
+
const stopped = await owner.stopCachedProvidersForSession(sessionKey, { requireStopped: true });
|
|
643
|
+
const failed = [...stopped.failed];
|
|
644
|
+
const results = [...stopped.results];
|
|
645
|
+
for (const record of [...owner.ownedRuntimes.values()]) {
|
|
646
|
+
if (record.sessionKey !== sessionKey || attempted.has(record.provider) ||
|
|
647
|
+
owner.runtimeIsShared(record.providerKey, record.providerOptions)) continue;
|
|
648
|
+
try {
|
|
649
|
+
// Its provider is no longer cached, so the runtime's own options name its lock.
|
|
650
|
+
results.push(await owner.withLifecycle(() => owner.stopOwnedRuntime(record),
|
|
651
|
+
record.providerKey, record.providerOptions));
|
|
652
|
+
} catch (error) {
|
|
653
|
+
failed.push({ code: normalizeCodexRunText(error?.code), error: codexAppServerErrorMessage(error),
|
|
654
|
+
providerKey: record.providerKey, retryable: error?.retryable === true });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (failed.length) {
|
|
658
|
+
const error = new Error(failed.map((failure) => failure.error).join(" "));
|
|
659
|
+
error.code = failed[0].code;
|
|
660
|
+
error.retryable = failed.every((failure) => failure.retryable);
|
|
661
|
+
error.details = { failed };
|
|
662
|
+
throw error;
|
|
663
|
+
}
|
|
664
|
+
return { ok: true, results, stopped: results.some((result) => result?.stopped === true) };
|
|
665
|
+
}
|
|
666
|
+
|
|
560
667
|
async function deleteScopedCodexAppServerConversation(sessionId, input = {}, options = {}) {
|
|
561
668
|
const conversationId = normalizeCodexRunText(input.conversationId);
|
|
562
669
|
const sessionKey = namespace(sessionId);
|
|
563
670
|
const conversations = codexAppServerConversations.get(sessionKey);
|
|
564
|
-
|
|
565
|
-
|
|
671
|
+
// An ephemeral native thread lives only in the process that created it, so
|
|
672
|
+
// one this server no longer tracks has nothing left to delete.
|
|
673
|
+
const conversationExpired = input.ephemeral === true && !conversations?.has(conversationId);
|
|
566
674
|
let result;
|
|
567
675
|
if (conversationExpired) {
|
|
568
676
|
result = {
|
|
@@ -577,23 +685,7 @@ export function createCodexConversationCommands({
|
|
|
577
685
|
return { ...result, conversationId };
|
|
578
686
|
}
|
|
579
687
|
}
|
|
580
|
-
|
|
581
|
-
if (options.assistantScope) {
|
|
582
|
-
const context = await codexAppServerEphemeralScopeContext(
|
|
583
|
-
sessionId,
|
|
584
|
-
input,
|
|
585
|
-
options.assistantScope
|
|
586
|
-
);
|
|
587
|
-
if (context.ok === false) {
|
|
588
|
-
return context;
|
|
589
|
-
}
|
|
590
|
-
const connection = sessionConnection(sessionId, context.providerOptions);
|
|
591
|
-
providerExit = await providerSessions.owner.stopProvider(connection.providerKey, context.providerOptions, {
|
|
592
|
-
preserveProcessExitProof: false,
|
|
593
|
-
requireStopped: true,
|
|
594
|
-
get runtimeHost() { return connection.runtimeHost; }
|
|
595
|
-
});
|
|
596
|
-
}
|
|
688
|
+
const providerExit = options.assistantScope ? await stopCodexScopeRuntimes(sessionKey) : null;
|
|
597
689
|
return {
|
|
598
690
|
...result,
|
|
599
691
|
conversationId,
|
|
@@ -602,19 +694,59 @@ export function createCodexConversationCommands({
|
|
|
602
694
|
};
|
|
603
695
|
}
|
|
604
696
|
|
|
697
|
+
// Close stops each persistent conversation through the provider that observes
|
|
698
|
+
// it. A closing session refuses a rebuilt context, and a new connection could
|
|
699
|
+
// not see the running turn anyway. One conversation's failure does not skip
|
|
700
|
+
// the others; it stays registered so that a retried close stops it.
|
|
701
|
+
async function stopObservedCodexConversation(state) {
|
|
702
|
+
const { provider } = state;
|
|
703
|
+
if (provider.observationFailure) {
|
|
704
|
+
await provider.failObservation(provider.observationFailure);
|
|
705
|
+
if (codexAppServerConversationTurnIsActive(state.status)) {
|
|
706
|
+
throw new Error("Codex's stop is not yet confirmed.");
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
// A provider its owner has since closed observes nothing left to stop here:
|
|
711
|
+
// the owner stopped or detached its process, and reconnecting it would
|
|
712
|
+
// start a process nothing owns.
|
|
713
|
+
if (![...providerSessions.owner.providers].some(([, owned]) => owned === provider)) return;
|
|
714
|
+
// Closing is not the person stopping the turn; its status alone decides the outcome.
|
|
715
|
+
await stopPersistentCodexConversation({
|
|
716
|
+
conversationId: state.conversationId, conversationState: state, provider, turnOutcome: ""
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
605
720
|
async function closeCodexAppServerConversations(sessionId) {
|
|
606
721
|
const key = namespace(sessionId);
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
{}, { turnOutcome: "" });
|
|
612
|
-
state.unsubscribe?.();
|
|
613
|
-
} else {
|
|
722
|
+
const conversations = codexAppServerConversations.get(key);
|
|
723
|
+
const failed = [];
|
|
724
|
+
for (const state of [...(conversations?.values() || [])]) {
|
|
725
|
+
if (!state.persistent) {
|
|
614
726
|
await deleteScopedCodexAppServerConversation(sessionId, { conversationId: state.conversationId }).catch(() => null);
|
|
727
|
+
conversations.delete(state.conversationId);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
try {
|
|
731
|
+
await stopObservedCodexConversation(state);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
failed.push({ conversationId: state.conversationId, error: codexAppServerErrorMessage(error) });
|
|
734
|
+
continue;
|
|
615
735
|
}
|
|
736
|
+
forgetObservation(state);
|
|
737
|
+
conversations.delete(state.conversationId);
|
|
738
|
+
}
|
|
739
|
+
if (conversations && codexAppServerConversations.get(key) === conversations && !conversations.size) {
|
|
740
|
+
codexAppServerConversations.delete(key);
|
|
741
|
+
}
|
|
742
|
+
if (failed.length) {
|
|
743
|
+
const error = new Error(`Codex could not confirm that ${failed.length === 1 ? "a conversation" : `${failed.length} conversations`} stopped: ${
|
|
744
|
+
failed.map(({ conversationId, error: reason }) => `${conversationId} (${reason})`).join("; ")}. Retry closing the session.`);
|
|
745
|
+
error.code = `${errorPrefix}codex_conversation_close_failed`;
|
|
746
|
+
error.retryable = true;
|
|
747
|
+
error.details = { failed, sessionId: normalizeCodexRunText(sessionId) };
|
|
748
|
+
throw error;
|
|
616
749
|
}
|
|
617
|
-
codexAppServerConversations.delete(key);
|
|
618
750
|
}
|
|
619
751
|
|
|
620
752
|
async function codexAppServerConversationControl(sessionId, input, {
|
|
@@ -626,10 +758,12 @@ export function createCodexConversationCommands({
|
|
|
626
758
|
const prepared = conversationPreparation.control(sessionId, input, operation);
|
|
627
759
|
if (prepared.result) return prepared.result;
|
|
628
760
|
const { admission, threadId, turnId } = prepared;
|
|
761
|
+
// Stopping or deleting a thread is cleanup, not a use that keeps Codex running.
|
|
762
|
+
const borrow = providerBorrow();
|
|
629
763
|
try {
|
|
630
764
|
let context = null;
|
|
631
765
|
try {
|
|
632
|
-
context = await codexAppServerConversationContext(sessionId, input, { assistantScope, runtime, session });
|
|
766
|
+
context = await codexAppServerConversationContext(sessionId, input, { assistantScope, runtime, session }, borrow);
|
|
633
767
|
} catch (error) {
|
|
634
768
|
const unavailable = prepared.unavailable();
|
|
635
769
|
if (unavailable) return unavailable;
|
|
@@ -664,6 +798,7 @@ export function createCodexConversationCommands({
|
|
|
664
798
|
}
|
|
665
799
|
return await dispatch({ provider: context.provider, threadId, turnId });
|
|
666
800
|
} finally {
|
|
801
|
+
borrow.release();
|
|
667
802
|
admission.release();
|
|
668
803
|
}
|
|
669
804
|
});
|
|
@@ -942,6 +1077,7 @@ export function createCodexConversationCommands({
|
|
|
942
1077
|
status: "failed",
|
|
943
1078
|
watcher: null
|
|
944
1079
|
});
|
|
1080
|
+
holdWhileActive(conversationState);
|
|
945
1081
|
}
|
|
946
1082
|
throw error;
|
|
947
1083
|
}
|
|
@@ -1011,7 +1147,10 @@ export function createCodexConversationCommands({
|
|
|
1011
1147
|
});
|
|
1012
1148
|
void conversationState.completion.catch(() => null);
|
|
1013
1149
|
}
|
|
1014
|
-
if (input.persistent && conversationState)
|
|
1150
|
+
if (input.persistent && conversationState) {
|
|
1151
|
+
Object.assign(conversationState, { runId, status, messageId });
|
|
1152
|
+
holdWhileActive(conversationState);
|
|
1153
|
+
}
|
|
1015
1154
|
return { conversationId, messageId, ok: true, runId, status };
|
|
1016
1155
|
}
|
|
1017
1156
|
|
|
@@ -1039,6 +1178,7 @@ export function createCodexConversationCommands({
|
|
|
1039
1178
|
conversationState.status = "interrupted";
|
|
1040
1179
|
conversationState.watcher?.failNow(new Error("Temporary AI turn was stopped."));
|
|
1041
1180
|
conversationState.watcher = null;
|
|
1181
|
+
holdWhileActive(conversationState);
|
|
1042
1182
|
}
|
|
1043
1183
|
return result;
|
|
1044
1184
|
}
|
|
@@ -1069,8 +1209,9 @@ export function createCodexConversationCommands({
|
|
|
1069
1209
|
});
|
|
1070
1210
|
if (result.ok !== false && conversation) {
|
|
1071
1211
|
const { conversationId, sessionKey, conversations } = conversation;
|
|
1072
|
-
conversations?.get(conversationId)
|
|
1073
|
-
|
|
1212
|
+
const state = conversations?.get(conversationId);
|
|
1213
|
+
state?.watcher?.failNow(new Error("Temporary AI conversation was closed."));
|
|
1214
|
+
if (state) forgetObservation(state);
|
|
1074
1215
|
conversations?.delete(conversationId);
|
|
1075
1216
|
if (conversations?.size === 0) {
|
|
1076
1217
|
codexAppServerConversations.delete(sessionKey);
|
|
@@ -1103,6 +1244,7 @@ export function createCodexConversationCommands({
|
|
|
1103
1244
|
if (conversationState.goal?.status === "active") conversationState.goal = { ...conversationState.goal, status: "paused" };
|
|
1104
1245
|
conversationState.watcher?.failNow(new Error("Temporary AI turn was stopped."));
|
|
1105
1246
|
conversationState.watcher = null;
|
|
1247
|
+
holdWhileActive(conversationState);
|
|
1106
1248
|
}
|
|
1107
1249
|
return { ok: true, conversationId, status: "interrupted" };
|
|
1108
1250
|
}
|
|
@@ -241,18 +241,31 @@ function codexAppServerErrorText(value = null, seen = new Set()) {
|
|
|
241
241
|
return [...new Set(messages)].join(" ");
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// A running app-server keeps the credentials it started with. After a sign-out
|
|
245
|
+
// or account switch, OpenAI's rejection ends its turns with ChatGPT's refresh
|
|
246
|
+
// failure or an API key's raw 401; another provider's 401 keeps its own text.
|
|
247
|
+
const CODEX_SIGNED_OUT_MESSAGE = "Codex (OpenAI) isn't connected. Sign in to Codex, then send your message again.";
|
|
248
|
+
const CODEX_SIGNED_OUT_ERRORS = [
|
|
249
|
+
/\bcould not be refreshed because you have since logged out or signed in to another account\b/u,
|
|
250
|
+
/^unexpected status 401 Unauthorized\b.*\burl: https:\/\/(?:api\.openai\.com|chatgpt\.com)\//su
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
function codexAppServerSignedOutText(text = "") {
|
|
254
|
+
return CODEX_SIGNED_OUT_ERRORS.some((pattern) => pattern.test(text)) ? CODEX_SIGNED_OUT_MESSAGE : text;
|
|
255
|
+
}
|
|
256
|
+
|
|
244
257
|
function codexAppServerNotificationError(notification = {}) {
|
|
245
258
|
const params = codexAppServerNotificationParams(notification);
|
|
246
259
|
const status = isRecord(params.status) ? params.status : {};
|
|
247
260
|
const turn = isRecord(params.turn) ? params.turn : {};
|
|
248
|
-
return normalizeNullableText(
|
|
261
|
+
return codexAppServerSignedOutText(normalizeNullableText(
|
|
249
262
|
codexAppServerErrorText(params.error) ||
|
|
250
263
|
params.message ||
|
|
251
264
|
codexAppServerErrorText(status.error) ||
|
|
252
265
|
status.message ||
|
|
253
266
|
codexAppServerErrorText(turn.error) ||
|
|
254
267
|
turn.message
|
|
255
|
-
);
|
|
268
|
+
));
|
|
256
269
|
}
|
|
257
270
|
|
|
258
271
|
function codexAppServerNotificationUsageLimitExceeded(notification = {}) {
|
|
@@ -795,7 +808,7 @@ function codexAppServerProviderTurnText(thread = null, turnId = "") {
|
|
|
795
808
|
}
|
|
796
809
|
|
|
797
810
|
function codexAppServerProviderTurnError(turn = {}) {
|
|
798
|
-
return codexAppServerErrorText(turn.error || turn.status?.error || turn.state?.error);
|
|
811
|
+
return codexAppServerSignedOutText(codexAppServerErrorText(turn.error || turn.status?.error || turn.state?.error));
|
|
799
812
|
}
|
|
800
813
|
|
|
801
814
|
export {
|
|
@@ -211,13 +211,22 @@ export function createCodexHelperThreadRecovery(lifecycle, {
|
|
|
211
211
|
|
|
212
212
|
async function restoreCodexAppServerHelperThreads(projectRuntimeRoot = "", context = {}) {
|
|
213
213
|
const previous = codexAppServerHelperThreadRestores.get(projectRuntimeRoot) || Promise.resolve();
|
|
214
|
+
// Every failure, including an unreadable ledger or a refused host notice,
|
|
215
|
+
// is reported in the result. Callers that require restoration assert it;
|
|
216
|
+
// cleanup and reconciliation continue with the records that did restore.
|
|
214
217
|
const restore = previous.catch(() => null).then(async () => {
|
|
215
218
|
const ledger = codexAppServerHelperThreadLedger(projectRuntimeRoot);
|
|
216
|
-
const
|
|
217
|
-
|
|
219
|
+
const failed = [];
|
|
220
|
+
let listed = { failures: [], records: [] };
|
|
221
|
+
try {
|
|
222
|
+
listed = await ledger.readAll();
|
|
223
|
+
} catch (error) {
|
|
224
|
+
failed.push(codexAppServerHelperFailure({ projectRuntimeRoot }, error));
|
|
225
|
+
}
|
|
226
|
+
failed.push(...listed.failures.map((failure) => ({
|
|
218
227
|
...failure,
|
|
219
228
|
projectRuntimeRoot
|
|
220
|
-
}));
|
|
229
|
+
})));
|
|
221
230
|
const normalizedSessionId = normalizeText(context.sessionId);
|
|
222
231
|
const records = listed.records.filter((record) => {
|
|
223
232
|
return !normalizedSessionId || record.sessionId === normalizedSessionId;
|
|
@@ -238,7 +247,11 @@ export function createCodexHelperThreadRecovery(lifecycle, {
|
|
|
238
247
|
failed.push(codexAppServerHelperFailure(record, error));
|
|
239
248
|
}
|
|
240
249
|
}
|
|
241
|
-
|
|
250
|
+
try {
|
|
251
|
+
await context.publish({ records, failed, retiredBySession, normalizedSessionId });
|
|
252
|
+
} catch (error) {
|
|
253
|
+
failed.push(codexAppServerHelperFailure({ projectRuntimeRoot }, error));
|
|
254
|
+
}
|
|
242
255
|
return {
|
|
243
256
|
failed,
|
|
244
257
|
ok: failed.length === 0,
|
|
@@ -578,8 +578,17 @@ export function createCodexHelperThreadLifecycle({
|
|
|
578
578
|
if (result.ok !== false) {
|
|
579
579
|
return result;
|
|
580
580
|
}
|
|
581
|
+
// Name the first failing record and its cause; details keep every failure.
|
|
582
|
+
const [first, ...others] = Array.isArray(result.failed) ? result.failed : [];
|
|
583
|
+
const sessionId = normalizeText(first?.sessionId);
|
|
584
|
+
const threadId = normalizeText(first?.threadId);
|
|
585
|
+
const subject = sessionId
|
|
586
|
+
? `session ${sessionId}${threadId ? `, thread ${threadId}` : ""}`
|
|
587
|
+
: "the project's saved records";
|
|
588
|
+
const cause = first ? ` For ${subject}: ${normalizeText(first.error) || "restoration failed."}` : "";
|
|
589
|
+
const more = others.length ? ` (${others.length} more)` : "";
|
|
581
590
|
throw codexAppServerHelperOwnershipError(
|
|
582
|
-
`${applicationName} could not reconcile persisted low-cost assistant thread ownership
|
|
591
|
+
`${applicationName} could not reconcile persisted low-cost assistant thread ownership.${cause}${more}`,
|
|
583
592
|
{
|
|
584
593
|
failed: result.failed,
|
|
585
594
|
retryable: result.failed?.some((failure) => failure.retryable !== false) !== false,
|