@jskit-ai/assistant-core 0.2.4 → 0.2.6
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/package.json +5 -5
- package/src/client/conversation/AssistantTranscript.vue +10 -4
- package/src/server/conversation/claudeTurn.js +2 -2
- package/src/server/conversation/codexConversations.js +41 -51
- package/src/server/conversation/codexProvider.js +13 -7
- package/src/server/conversation/codexProviderOwner.js +15 -10
- package/src/server/conversation/openCodeRuntime.js +9 -11
- package/src/server/conversation/providers/scoped.js +7 -3
- package/src/server/conversation/runtime.js +5 -10
- package/test/assistantScroll.browser.test.js +40 -43
- package/test/claudeProcess.test.js +31 -43
- package/test/codexAccountChange.test.js +31 -5
- package/test/codexLifecycleIsolation.test.js +6 -9
- package/test/codexTemporaryProviderRelease.test.js +4 -14
- package/test/conversationNativeStorage.test.js +8 -14
- package/test/fixtures/eventually.js +11 -0
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.2.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
13
|
-
"@jskit-ai/kernel": "0.2.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.2.6",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.2.3",
|
|
13
|
+
"@jskit-ai/kernel": "0.2.3",
|
|
14
14
|
"@mdi/js": "^7.4.47",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
16
|
"vuetify": "^4.0.0",
|
|
17
|
-
"@jskit-ai/connectors-core": "0.2.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.2.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.2.3",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.2.3"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"@ai-sdk/perplexity": "^4.0.43",
|
|
49
49
|
"@ai-sdk/togetherai": "^3.0.49",
|
|
50
50
|
"@ai-sdk/xai": "^4.0.58",
|
|
51
|
-
"@jskit-ai/resource-core": "0.1.
|
|
52
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
51
|
+
"@jskit-ai/resource-core": "0.1.194",
|
|
52
|
+
"@jskit-ai/resource-crud-core": "0.1.194",
|
|
53
53
|
"@mdi/js": "^7.4.47",
|
|
54
54
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
55
55
|
"ai": "^7.0.101",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
"ws": "^8.20.1"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
62
|
-
"@jskit-ai/kernel": "0.2.
|
|
61
|
+
"@jskit-ai/http-runtime": "0.2.3",
|
|
62
|
+
"@jskit-ai/kernel": "0.2.3",
|
|
63
63
|
"vue": "^3.5.13",
|
|
64
64
|
"vuetify": "^4.0.0"
|
|
65
65
|
},
|
|
@@ -180,7 +180,7 @@
|
|
|
180
180
|
</div>
|
|
181
181
|
<slot name="user-message-actions" :message="entry.message" :turn="entry.turn" />
|
|
182
182
|
<div
|
|
183
|
-
v-if="entry.delivery || entry.turn.optimistic?.status === 'failed' || entry.turn
|
|
183
|
+
v-if="entry.delivery || entry.turn.optimistic?.status === 'failed' || cancelRequested(entry.turn)"
|
|
184
184
|
class="assistant-transcript__optimistic-failure"
|
|
185
185
|
>
|
|
186
186
|
<span role="status">{{ entry.turn.optimistic?.cancelling ? t('jskit.assistant.delivery.cancelling')
|
|
@@ -188,7 +188,7 @@
|
|
|
188
188
|
: entry.delivery ? entry.delivery.delivery.error || t('jskit.assistant.delivery.unconfirmedShort') : t('jskit.assistant.transcript.failed', { error: entry.turn.optimistic.error || t('jskit.assistant.delivery.notSent') }) }}</span>
|
|
189
189
|
<div v-if="!entry.turn.preview" class="assistant-transcript__optimistic-actions">
|
|
190
190
|
<v-btn
|
|
191
|
-
v-if="!entry.turn
|
|
191
|
+
v-if="!cancelRequested(entry.turn) && (entry.delivery || entry.turn.optimistic?.status === 'failed')"
|
|
192
192
|
class="assistant-transcript__delivery-action"
|
|
193
193
|
color="error"
|
|
194
194
|
size="x-small"
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
{{ entry.delivery?.delivery.checking ? t('jskit.assistant.delivery.checking') : t('jskit.assistant.delivery.retry') }}
|
|
203
203
|
</v-btn>
|
|
204
204
|
<v-btn
|
|
205
|
-
v-if="!entry.delivery || cancellableDelivery || entry.turn
|
|
205
|
+
v-if="!entry.delivery || cancellableDelivery || cancelRequested(entry.turn)"
|
|
206
206
|
class="assistant-transcript__delivery-action"
|
|
207
207
|
size="x-small"
|
|
208
208
|
type="button"
|
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
{{ t("jskit.assistant.transcript.cancel") }}
|
|
213
213
|
</v-btn>
|
|
214
214
|
<v-btn
|
|
215
|
-
v-if="!entry.delivery && entry.turn.optimistic?.status === 'failed' && !entry.turn
|
|
215
|
+
v-if="!entry.delivery && entry.turn.optimistic?.status === 'failed' && !cancelRequested(entry.turn)"
|
|
216
216
|
class="assistant-transcript__delivery-action"
|
|
217
217
|
size="x-small"
|
|
218
218
|
type="button"
|
|
@@ -660,6 +660,12 @@ function handleLongTextLinkClick(payload) {
|
|
|
660
660
|
emit("link-click", payload);
|
|
661
661
|
}
|
|
662
662
|
|
|
663
|
+
// Cancel was pressed and the server has not yet agreed: it is in progress or
|
|
664
|
+
// could not reach the server.
|
|
665
|
+
function cancelRequested(turn = {}) {
|
|
666
|
+
return Boolean(turn.optimistic?.cancelling || turn.optimistic?.cancelError);
|
|
667
|
+
}
|
|
668
|
+
|
|
663
669
|
function userMessageCollapsible(message = null) {
|
|
664
670
|
return Array.isArray(message?.previewBlocks);
|
|
665
671
|
}
|
|
@@ -841,10 +841,10 @@ export function createClaudeConversationOwner({
|
|
|
841
841
|
return closeRetained(await application.readContext(context), application, { recover: false, forget: true });
|
|
842
842
|
}
|
|
843
843
|
|
|
844
|
-
async function closeRetained(ctx, application, { recover, forget: forgetEntries }) {
|
|
844
|
+
async function closeRetained(ctx, application, { recover: recoverCustody, forget: forgetEntries }) {
|
|
845
845
|
closingSessions.add(ctx.key);
|
|
846
846
|
try {
|
|
847
|
-
if (
|
|
847
|
+
if (recoverCustody) await recoverSavedCustody(ctx);
|
|
848
848
|
const proofs = await drain(ctx.key);
|
|
849
849
|
const terminal = await application.terminals.close(ctx.sessionId);
|
|
850
850
|
// A forgotten entry is drained: everything it held is in its saved record.
|
|
@@ -29,9 +29,14 @@ export function createCodexConversationCommands({
|
|
|
29
29
|
|
|
30
30
|
// Reads and Temporary chat commands borrow their provider instead of claiming
|
|
31
31
|
// it: the operation returns its borrow when it finishes, and an unused
|
|
32
|
-
// provider stops a short idle later.
|
|
33
|
-
function
|
|
34
|
-
|
|
32
|
+
// provider stops a short idle later. Without `borrowing`, it claims one.
|
|
33
|
+
async function withProviderBorrow(borrowing, operation) {
|
|
34
|
+
const borrow = borrowing ? { release() {} } : null;
|
|
35
|
+
try {
|
|
36
|
+
return await operation(borrow);
|
|
37
|
+
} finally {
|
|
38
|
+
borrow?.release();
|
|
39
|
+
}
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
// A persistent conversation holds its borrowed provider only while it
|
|
@@ -325,22 +330,17 @@ export function createCodexConversationCommands({
|
|
|
325
330
|
}
|
|
326
331
|
|
|
327
332
|
async function createCodexAppServerConversation(sessionId, input = {}, options = {}) {
|
|
328
|
-
return codexAppServerConversationResult(async () => {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
});
|
|
340
|
-
} finally {
|
|
341
|
-
borrow?.release();
|
|
342
|
-
}
|
|
343
|
-
});
|
|
333
|
+
return codexAppServerConversationResult(() => withProviderBorrow(input.persistent === true, async (borrow) => {
|
|
334
|
+
const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
|
|
335
|
+
if (context.ok === false) return context;
|
|
336
|
+
const execution = conversationPreparation.execution(sessionId, input, options, context, null, "create");
|
|
337
|
+
return createPreparedCodexAppServerConversation(sessionId, input, {
|
|
338
|
+
provider: context.provider,
|
|
339
|
+
executionProfile: execution.executionProfile,
|
|
340
|
+
helperIsolation: conversationPreparation.isolation,
|
|
341
|
+
threadPreparation: () => execution.threadPreparation
|
|
342
|
+
});
|
|
343
|
+
}));
|
|
344
344
|
}
|
|
345
345
|
|
|
346
346
|
async function runDetachedCodexAppServerConversation(sessionId, input = {}, options = {}) {
|
|
@@ -443,8 +443,7 @@ export function createCodexConversationCommands({
|
|
|
443
443
|
ok: false
|
|
444
444
|
};
|
|
445
445
|
}
|
|
446
|
-
|
|
447
|
-
try {
|
|
446
|
+
return withProviderBorrow(input.persistent === true, async (borrow) => {
|
|
448
447
|
const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
|
|
449
448
|
if (context.ok === false) return context;
|
|
450
449
|
// A persistent turn is observed on the provider it runs on, which its
|
|
@@ -462,7 +461,7 @@ export function createCodexConversationCommands({
|
|
|
462
461
|
}
|
|
463
462
|
// Do not evaluate native preparation until the original duplicate/active
|
|
464
463
|
// checks and Helper isolation capability checks have run.
|
|
465
|
-
return
|
|
464
|
+
return startPreparedCodexAppServerConversationTurn(sessionId, input, {
|
|
466
465
|
executionProfile: execution.executionProfile,
|
|
467
466
|
helperIsolation: conversationPreparation.isolation,
|
|
468
467
|
prepareTurn: execution.prepareTurn,
|
|
@@ -478,9 +477,7 @@ export function createCodexConversationCommands({
|
|
|
478
477
|
conversationState,
|
|
479
478
|
threadPreparation: () => execution.threadPreparation
|
|
480
479
|
});
|
|
481
|
-
}
|
|
482
|
-
borrow?.release();
|
|
483
|
-
}
|
|
480
|
+
});
|
|
484
481
|
}));
|
|
485
482
|
}
|
|
486
483
|
|
|
@@ -514,13 +511,12 @@ export function createCodexConversationCommands({
|
|
|
514
511
|
}
|
|
515
512
|
}
|
|
516
513
|
// Reading history or status is not a use that keeps Codex running.
|
|
517
|
-
|
|
518
|
-
try {
|
|
514
|
+
return withProviderBorrow(true, async (borrow) => {
|
|
519
515
|
const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
|
|
520
516
|
if (context.ok === false) {
|
|
521
517
|
return context;
|
|
522
518
|
}
|
|
523
|
-
if (input.persistent) return
|
|
519
|
+
if (input.persistent) return readPreparedPersistentCodexConversation(sessionId, input, context);
|
|
524
520
|
const thread = await context.provider.readThread(conversationId);
|
|
525
521
|
const runId = normalizeCodexRunText(input.runId) || codexAppServerThreadTurnId(thread);
|
|
526
522
|
const status = codexAppServerThreadStatus(thread);
|
|
@@ -537,9 +533,7 @@ export function createCodexConversationCommands({
|
|
|
537
533
|
status,
|
|
538
534
|
...conversationPreparation.response(text)
|
|
539
535
|
};
|
|
540
|
-
}
|
|
541
|
-
borrow.release();
|
|
542
|
-
}
|
|
536
|
+
});
|
|
543
537
|
});
|
|
544
538
|
}
|
|
545
539
|
|
|
@@ -563,8 +557,7 @@ export function createCodexConversationCommands({
|
|
|
563
557
|
const result = await scoped.completion;
|
|
564
558
|
return { ...result, conversationId, ok: true, runId };
|
|
565
559
|
}
|
|
566
|
-
|
|
567
|
-
try {
|
|
560
|
+
return withProviderBorrow(input.persistent === true, async (borrow) => {
|
|
568
561
|
const context = await codexAppServerConversationContext(sessionId, input, {
|
|
569
562
|
assistantScope
|
|
570
563
|
}, borrow);
|
|
@@ -576,22 +569,25 @@ export function createCodexConversationCommands({
|
|
|
576
569
|
onEvent
|
|
577
570
|
});
|
|
578
571
|
return { ...result, ...conversationPreparation.response(result.rawText) };
|
|
579
|
-
}
|
|
580
|
-
borrow?.release();
|
|
581
|
-
}
|
|
572
|
+
});
|
|
582
573
|
});
|
|
583
574
|
}
|
|
584
575
|
|
|
576
|
+
// A conversation whose provider lost its observation stops through that failure.
|
|
577
|
+
async function stopThroughObservationFailure(state) {
|
|
578
|
+
await state.provider.failObservation(state.provider.observationFailure);
|
|
579
|
+
if (codexAppServerConversationTurnIsActive(state.status)) {
|
|
580
|
+
throw new Error("Codex's stop is not yet confirmed.");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
585
584
|
async function stopCodexAppServerConversation(sessionId, input = {}, options = {}, {
|
|
586
585
|
turnOutcome = turnOutcomes.USER_CANCELLED
|
|
587
586
|
} = {}) {
|
|
588
587
|
const conversationId = normalizeCodexRunText(input.conversationId);
|
|
589
588
|
const conversationState = codexAppServerConversation(sessionId, conversationId);
|
|
590
589
|
if (conversationState?.provider.observationFailure) {
|
|
591
|
-
await
|
|
592
|
-
if (codexAppServerConversationTurnIsActive(conversationState.status)) {
|
|
593
|
-
throw new Error("Codex's stop is not yet confirmed.");
|
|
594
|
-
}
|
|
590
|
+
await stopThroughObservationFailure(conversationState);
|
|
595
591
|
return { conversationId, ok: true, runId: conversationState.runId, status: "interrupted" };
|
|
596
592
|
}
|
|
597
593
|
if (input.ephemeral === true && !conversationState) {
|
|
@@ -604,19 +600,16 @@ export function createCodexConversationCommands({
|
|
|
604
600
|
};
|
|
605
601
|
}
|
|
606
602
|
if (input.persistent) {
|
|
607
|
-
|
|
608
|
-
try {
|
|
603
|
+
return withProviderBorrow(true, async (borrow) => {
|
|
609
604
|
const context = await codexAppServerConversationContext(sessionId, input, options, borrow);
|
|
610
605
|
if (context.ok === false) return context;
|
|
611
|
-
return
|
|
606
|
+
return stopPersistentCodexConversation({
|
|
612
607
|
conversationId,
|
|
613
608
|
conversationState,
|
|
614
609
|
provider: context.provider,
|
|
615
610
|
turnOutcome
|
|
616
611
|
});
|
|
617
|
-
}
|
|
618
|
-
borrow.release();
|
|
619
|
-
}
|
|
612
|
+
});
|
|
620
613
|
}
|
|
621
614
|
const result = await interruptDetachedCodexAppServerConversationTurn(sessionId, {
|
|
622
615
|
agentSettings: input.agentSettings,
|
|
@@ -701,10 +694,7 @@ export function createCodexConversationCommands({
|
|
|
701
694
|
async function stopObservedCodexConversation(state) {
|
|
702
695
|
const { provider } = state;
|
|
703
696
|
if (provider.observationFailure) {
|
|
704
|
-
await
|
|
705
|
-
if (codexAppServerConversationTurnIsActive(state.status)) {
|
|
706
|
-
throw new Error("Codex's stop is not yet confirmed.");
|
|
707
|
-
}
|
|
697
|
+
await stopThroughObservationFailure(state);
|
|
708
698
|
return;
|
|
709
699
|
}
|
|
710
700
|
// A provider its owner has since closed observes nothing left to stop here:
|
|
@@ -759,7 +749,7 @@ export function createCodexConversationCommands({
|
|
|
759
749
|
if (prepared.result) return prepared.result;
|
|
760
750
|
const { admission, threadId, turnId } = prepared;
|
|
761
751
|
// Stopping or deleting a thread is cleanup, not a use that keeps Codex running.
|
|
762
|
-
const borrow =
|
|
752
|
+
const borrow = { release() {} };
|
|
763
753
|
try {
|
|
764
754
|
let context = null;
|
|
765
755
|
try {
|
|
@@ -567,6 +567,18 @@ function codexAppServerThreadInventoryError(errorCode, label, detail) {
|
|
|
567
567
|
return error;
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
+
// Why a listed inventory entry is refused, or "" for a valid thread.
|
|
571
|
+
function codexAppServerThreadInventoryRefusal(entry, threadId, { cwd, verifyCwd }) {
|
|
572
|
+
if (!isRecord(entry)) return "listed an entry that is not a thread";
|
|
573
|
+
if (!threadId) return "listed a thread without an id";
|
|
574
|
+
if (threadId.length > CODEX_APP_SERVER_THREAD_INVENTORY_ID_MAX_LENGTH) {
|
|
575
|
+
return `listed a thread id longer than ${CODEX_APP_SERVER_THREAD_INVENTORY_ID_MAX_LENGTH} characters`;
|
|
576
|
+
}
|
|
577
|
+
if (codexAppServerTextHasControlCharacters(threadId)) return "listed a thread id with control characters";
|
|
578
|
+
if (verifyCwd && normalizeAgentText(entry.cwd) !== cwd) return `listed thread ${threadId} outside the requested directory`;
|
|
579
|
+
return "";
|
|
580
|
+
}
|
|
581
|
+
|
|
570
582
|
async function listBoundedCodexAppServerThreadIds({
|
|
571
583
|
ancestorThreadId = "",
|
|
572
584
|
archived = false,
|
|
@@ -618,13 +630,7 @@ async function listBoundedCodexAppServerThreadIds({
|
|
|
618
630
|
}
|
|
619
631
|
for (const entry of response.data) {
|
|
620
632
|
const threadId = isRecord(entry) ? normalizeAgentText(entry.id) : "";
|
|
621
|
-
const refusal =
|
|
622
|
-
: !threadId ? "listed a thread without an id"
|
|
623
|
-
: threadId.length > CODEX_APP_SERVER_THREAD_INVENTORY_ID_MAX_LENGTH
|
|
624
|
-
? `listed a thread id longer than ${CODEX_APP_SERVER_THREAD_INVENTORY_ID_MAX_LENGTH} characters`
|
|
625
|
-
: codexAppServerTextHasControlCharacters(threadId) ? "listed a thread id with control characters"
|
|
626
|
-
: verifyCwd && normalizeAgentText(entry.cwd) !== cwd
|
|
627
|
-
? `listed thread ${threadId} outside the requested directory` : "";
|
|
633
|
+
const refusal = codexAppServerThreadInventoryRefusal(entry, threadId, { cwd, verifyCwd });
|
|
628
634
|
if (refusal) {
|
|
629
635
|
throw codexAppServerThreadInventoryError(errorCode, label, refusal);
|
|
630
636
|
}
|
|
@@ -50,8 +50,7 @@ export function releaseSupersededCodexRuntime(runtimeDir = "") {
|
|
|
50
50
|
stale.superseded = true;
|
|
51
51
|
return { deferred: true, removed: false };
|
|
52
52
|
}
|
|
53
|
-
if (codexAppServerOwnedRuntimes.has(runtimeKey) ||
|
|
54
|
-
codexAppServerOwnedRuntimeKey(record.providerKey, record.providerOptions) === runtimeKey)) {
|
|
53
|
+
if (codexAppServerOwnedRuntimes.has(runtimeKey) || runtimePeers(runtimeKey).length) {
|
|
55
54
|
return { removed: false };
|
|
56
55
|
}
|
|
57
56
|
const metadata = await readCodexAppServerMetadata(runtimeDir);
|
|
@@ -83,6 +82,12 @@ function* providerRecords() {
|
|
|
83
82
|
for (const records of codexAppServerProviders.values()) yield* records.values();
|
|
84
83
|
}
|
|
85
84
|
|
|
85
|
+
// Every owner's provider records on one native runtime.
|
|
86
|
+
function runtimePeers(runtimeKey) {
|
|
87
|
+
return [...providerRecords()].filter(record =>
|
|
88
|
+
codexAppServerOwnedRuntimeKey(record.providerKey, record.providerOptions) === runtimeKey);
|
|
89
|
+
}
|
|
90
|
+
|
|
86
91
|
function nativeRequest(client, method, params) {
|
|
87
92
|
return client.request(method, params, { signal: AbortSignal.timeout(5_000) });
|
|
88
93
|
}
|
|
@@ -482,8 +487,7 @@ export function createCodexAppServerProviderOwner({
|
|
|
482
487
|
// Another stop (a session close or idle release) ended a stale runtime.
|
|
483
488
|
const runtimeKey = codexAppServerOwnedRuntimeKey(providerKey, record.providerOptions);
|
|
484
489
|
const stale = codexAppServerStaleRuntimes.get(runtimeKey);
|
|
485
|
-
if (stale && !stale.retiring && !
|
|
486
|
-
codexAppServerOwnedRuntimeKey(peer.providerKey, peer.providerOptions) === runtimeKey)) {
|
|
490
|
+
if (stale && !stale.retiring && !runtimePeers(runtimeKey).length) {
|
|
487
491
|
codexAppServerStaleRuntimes.delete(runtimeKey);
|
|
488
492
|
}
|
|
489
493
|
}
|
|
@@ -824,18 +828,20 @@ export function createCodexAppServerProviderOwner({
|
|
|
824
828
|
|
|
825
829
|
// A runtime with a running turn keeps the credentials it started with until
|
|
826
830
|
// that turn ends. Its active goals are paused so it starts no further turn;
|
|
827
|
-
// retireStaleRuntime stops it once it is idle.
|
|
831
|
+
// retireStaleRuntime stops it once it is idle. Any other runtime returns
|
|
832
|
+
// null and stops at once, like an idle one.
|
|
828
833
|
async function deferBusyRuntime(runtimeKey, peers) {
|
|
829
834
|
let running;
|
|
830
835
|
try {
|
|
831
836
|
running = await runningNativeThreads(peers);
|
|
832
837
|
} catch (error) {
|
|
833
|
-
// An unreadable runtime cannot show a running turn
|
|
838
|
+
// An unreadable runtime cannot show a running turn.
|
|
834
839
|
debugLog("appServerRuntime.staleInspection.error", { error: debugError(error), runtimeKey });
|
|
835
840
|
return null;
|
|
836
841
|
}
|
|
837
|
-
|
|
838
|
-
const client = peers.find(record => record.provider.client?.isOpen?.())
|
|
842
|
+
// Nor can one whose control connection closed while it was read.
|
|
843
|
+
const client = peers.find(record => record.provider.client?.isOpen?.())?.provider.client;
|
|
844
|
+
if (!running.length || !client) return null;
|
|
839
845
|
const pausedGoals = [];
|
|
840
846
|
for (const threadId of running) {
|
|
841
847
|
try {
|
|
@@ -864,8 +870,7 @@ export function createCodexAppServerProviderOwner({
|
|
|
864
870
|
if (!stale) return Promise.resolve(null);
|
|
865
871
|
if (stale.retiring) return stale.retiring;
|
|
866
872
|
const retiring = withRuntimeLifecycle(runtimeKey, async () => {
|
|
867
|
-
const peers =
|
|
868
|
-
codexAppServerOwnedRuntimeKey(record.providerKey, record.providerOptions) === runtimeKey);
|
|
873
|
+
const peers = runtimePeers(runtimeKey);
|
|
869
874
|
const running = await runningNativeThreads(peers).catch(() => []);
|
|
870
875
|
// A Temporary turn or goal holding a borrowed provider also keeps it.
|
|
871
876
|
if (running.length || peers.some(peer => peer.scope.isHeld?.(peer.providerKey))) {
|
|
@@ -1537,11 +1537,14 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1537
1537
|
return monitor;
|
|
1538
1538
|
}
|
|
1539
1539
|
|
|
1540
|
-
// A
|
|
1541
|
-
|
|
1540
|
+
// A turn's failure and whether it is a credential failure. A removed
|
|
1541
|
+
// connection's models are gone from its reloaded directory.
|
|
1542
|
+
function turnFailure(failure) {
|
|
1542
1543
|
const providerId = /\bModel not found: ([^/\s]+)\//u.exec(failure)?.[1];
|
|
1543
|
-
|
|
1544
|
-
|
|
1544
|
+
if (providerId && sharedProcess && !sharedProcess.connections.has(providerId)) {
|
|
1545
|
+
return { failure: `${providerId} isn't connected. Connect it again, then send your message again.`, credentialFailure: true };
|
|
1546
|
+
}
|
|
1547
|
+
return { failure, credentialFailure: openCodeCredentialFailure(failure) };
|
|
1545
1548
|
}
|
|
1546
1549
|
|
|
1547
1550
|
function beginMonitor(key, target, turn, {
|
|
@@ -1626,9 +1629,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1626
1629
|
failure = "OpenCode finished without a user-facing final response. Please send your message again.";
|
|
1627
1630
|
finalState = "failed";
|
|
1628
1631
|
} else if (failure) {
|
|
1629
|
-
|
|
1630
|
-
failure = disconnected || failure;
|
|
1631
|
-
credentialFailure = Boolean(disconnected) || openCodeCredentialFailure(failure);
|
|
1632
|
+
({ failure, credentialFailure } = turnFailure(failure));
|
|
1632
1633
|
finalState = "failed";
|
|
1633
1634
|
} else if (turn.interruptRequested) {
|
|
1634
1635
|
finalState = "interrupted";
|
|
@@ -1647,10 +1648,7 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope, storageI
|
|
|
1647
1648
|
finalState = "interrupted";
|
|
1648
1649
|
} else {
|
|
1649
1650
|
const cause = signal.aborted ? signal.reason : error;
|
|
1650
|
-
failure = text(cause?.message) || "OpenCode turn failed.";
|
|
1651
|
-
const disconnected = disconnectedProviderFailure(failure);
|
|
1652
|
-
failure = disconnected || failure;
|
|
1653
|
-
credentialFailure = Boolean(disconnected) || openCodeCredentialFailure(failure);
|
|
1651
|
+
({ failure, credentialFailure } = turnFailure(text(cause?.message) || "OpenCode turn failed."));
|
|
1654
1652
|
providerApiFailure = openCodeProviderApiFailure(cause);
|
|
1655
1653
|
finalState = target.abortController.signal.aborted
|
|
1656
1654
|
? "cancelled"
|
|
@@ -341,6 +341,12 @@ export async function preparePersistentConversationChangeover(options) {
|
|
|
341
341
|
await createConversationChangeover(continuity).remember({ engineId, messages });
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
// The receipt of a message proven never to have reached its conversation.
|
|
345
|
+
export function notSentDeliveryReceipt(messageId) {
|
|
346
|
+
return { status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message.",
|
|
347
|
+
messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
|
|
348
|
+
}
|
|
349
|
+
|
|
344
350
|
export async function inspectPersistentConversationDelivery(options) {
|
|
345
351
|
const { sessionId, input, context, store, operations, prepare, receipts } = options;
|
|
346
352
|
let turns = await store.readConversationLog(sessionId);
|
|
@@ -354,9 +360,7 @@ export async function inspectPersistentConversationDelivery(options) {
|
|
|
354
360
|
inspectMessageAdmission: request => inspectMessageAdmission(sessionId, request, context),
|
|
355
361
|
sendMessage() { throw new Error("Receipt inspection cannot submit another native prompt."); }
|
|
356
362
|
} }).send({ engineId, messages, input: { messageId: input.messageId }, turnMetadata, inspectOnly: true });
|
|
357
|
-
if (result?.notSent) return
|
|
358
|
-
error: "This message was not sent. Review it before sending a new message.",
|
|
359
|
-
messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
|
|
363
|
+
if (result?.notSent) return notSentDeliveryReceipt(input.messageId);
|
|
360
364
|
accepted = result?.delivered === true;
|
|
361
365
|
} else if (!accepted) {
|
|
362
366
|
if (row && row.user?.receipt !== false) return { status: "accepted", messageId: input.messageId, turnId: row.turnId, duplicate: true };
|
|
@@ -3,7 +3,7 @@ import { createConversationDeliveryTiming, normalizeConversationDeliveryHostStag
|
|
|
3
3
|
import { normalizeConversationConfiguration } from "./configuration.js";
|
|
4
4
|
import { runScopedConversationTurn, readPersistentConversation, projectStoredPersistentConversation, startPersistentConversationTurn,
|
|
5
5
|
inspectPersistentConversationAdmission, preparePersistentConversationChangeover,
|
|
6
|
-
inspectPersistentConversationDelivery, stopPersistentConversation, deletePersistentConversation } from "./providers/scoped.js";
|
|
6
|
+
inspectPersistentConversationDelivery, notSentDeliveryReceipt, stopPersistentConversation, deletePersistentConversation } from "./providers/scoped.js";
|
|
7
7
|
import { isDeepStrictEqual } from "node:util";
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { createConversationTranscript, isCompletedEnvelopeTurn } from "./transcript.js";
|
|
@@ -1927,11 +1927,6 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1927
1927
|
return { saved: true };
|
|
1928
1928
|
}
|
|
1929
1929
|
|
|
1930
|
-
function notSentReceipt(messageId) {
|
|
1931
|
-
return { status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message.",
|
|
1932
|
-
messageCode: "assistant.conversation.messageNotSent", messageParams: {} };
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
1930
|
async function recoverDelivery(entry, context, messageId, { observation: suppliedObservation, allowActive = false } = {}) {
|
|
1936
1931
|
await access(context, entry.id, "inspectDelivery", entry);
|
|
1937
1932
|
if (entry.active && !allowActive) throw failure("Wait for the current submission before checking delivery.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForSubmission" });
|
|
@@ -1960,7 +1955,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1960
1955
|
["codex", "claude", "opencode"].includes(segment.engine) && segment.request.attempted === false && !segment.request.inspectionOnly &&
|
|
1961
1956
|
isDeepStrictEqual(segment.request, savedRequests[0].request))) {
|
|
1962
1957
|
await access(context, entry.id, "inspectDelivery", entry);
|
|
1963
|
-
return
|
|
1958
|
+
return notSentDeliveryReceipt(messageId);
|
|
1964
1959
|
}
|
|
1965
1960
|
const request = existing ? submittedMessage(existing) : metadata.runtime.request;
|
|
1966
1961
|
if (!request || request.messageId !== messageId || !entry.provider.inspectAdmission) return receipt || { status: "unknown", messageId };
|
|
@@ -1978,7 +1973,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1978
1973
|
current.runtime.request.attempted = false;
|
|
1979
1974
|
await transaction.writeMetadata(current);
|
|
1980
1975
|
});
|
|
1981
|
-
return
|
|
1976
|
+
return notSentDeliveryReceipt(messageId);
|
|
1982
1977
|
}
|
|
1983
1978
|
if (!observation.accepted) return { ...(receipt || { status: "unknown", messageId }),
|
|
1984
1979
|
...(observation.recoveryLimitation ? { recoveryLimitation: observation.recoveryLimitation } : {}) };
|
|
@@ -2097,7 +2092,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2097
2092
|
});
|
|
2098
2093
|
const delivered = await sender.send({ engineId: entry.engine,
|
|
2099
2094
|
messages: await entry.conversation.transcript.history(), input: { messageId }, inspectOnly: true });
|
|
2100
|
-
if (delivered?.notSent) return
|
|
2095
|
+
if (delivered?.notSent) return notSentDeliveryReceipt(messageId);
|
|
2101
2096
|
if (!delivered?.delivered) throw deliveryFailure(delivered);
|
|
2102
2097
|
}
|
|
2103
2098
|
const row = (await readTranscript(entry)).find(turn => submittedMessage(turn)?.messageId === messageId);
|
|
@@ -2109,7 +2104,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
2109
2104
|
// Native-only acceptance is evidence of delivery, not permission to
|
|
2110
2105
|
// manufacture an authored row or use a native turn ID as its receipt.
|
|
2111
2106
|
if (observation.accepted) return { status: "accepted", messageId, duplicate: true };
|
|
2112
|
-
return observation.notAdmitted && !entry.active ?
|
|
2107
|
+
return observation.notAdmitted && !entry.active ? notSentDeliveryReceipt(messageId) : { status: "unknown", messageId };
|
|
2113
2108
|
}
|
|
2114
2109
|
const result = await recoverDelivery(entry, context, messageId);
|
|
2115
2110
|
if (result.status !== "accepted" || entry.engine === "api") return result;
|
|
@@ -75,6 +75,29 @@ async function pressScrollKey(body, key, target) {
|
|
|
75
75
|
await body.evaluate(element => element.keyScrolled);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
function fromBottom(body) {
|
|
79
|
+
return body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function appendReply(page, body) {
|
|
83
|
+
const replies = page.getByText("New reply.", { exact: false });
|
|
84
|
+
const count = await replies.count();
|
|
85
|
+
await page.getByRole("button", { name: "Append reply", exact: true }).evaluate(element => element.click());
|
|
86
|
+
await expect(replies).toHaveCount(count + 1);
|
|
87
|
+
await settleLayout(body);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Opens the whole conversation history with the reader at the latest message.
|
|
91
|
+
async function openAtLatest(page, baseURL) {
|
|
92
|
+
await page.goto(`${baseURL}/?controls=1&history=1`);
|
|
93
|
+
const body = page.locator(".assistant-transcript__body");
|
|
94
|
+
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
95
|
+
await page.getByRole("button", { name: "Exhaust history", exact: true }).evaluate(element => element.click());
|
|
96
|
+
await settleLayout(body);
|
|
97
|
+
expect(await fromBottom(body)).toBeLessThanOrEqual(1);
|
|
98
|
+
return body;
|
|
99
|
+
}
|
|
100
|
+
|
|
78
101
|
const VIEWPORTS = Object.freeze([
|
|
79
102
|
Object.freeze({ name: "phone", width: 390, height: 844 }),
|
|
80
103
|
Object.freeze({ name: "compact", width: 800, height: 900 }),
|
|
@@ -497,20 +520,7 @@ test("a reader's own upward scrolling leaves the latest message from its first f
|
|
|
497
520
|
const page = await browser.newPage({ viewport: { width, height: 900 }, hasTouch: touch, isMobile: touch });
|
|
498
521
|
const errors = [];
|
|
499
522
|
page.on("pageerror", error => errors.push(error.message));
|
|
500
|
-
await page
|
|
501
|
-
const body = page.locator(".assistant-transcript__body");
|
|
502
|
-
const fromBottom = () => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop);
|
|
503
|
-
const appendReply = async () => {
|
|
504
|
-
const replies = page.getByText("New reply.", { exact: false });
|
|
505
|
-
const count = await replies.count();
|
|
506
|
-
await page.getByRole("button", { name: "Append reply", exact: true }).evaluate(element => element.click());
|
|
507
|
-
await expect(replies).toHaveCount(count + 1);
|
|
508
|
-
await settleLayout(body);
|
|
509
|
-
};
|
|
510
|
-
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
511
|
-
await page.getByRole("button", { name: "Exhaust history", exact: true }).evaluate(element => element.click());
|
|
512
|
-
await settleLayout(body);
|
|
513
|
-
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
523
|
+
const body = await openAtLatest(page, vite.baseURL);
|
|
514
524
|
await expect.poll(async () => {
|
|
515
525
|
await body.focus();
|
|
516
526
|
return body.evaluate(element => document.activeElement === element);
|
|
@@ -532,29 +542,29 @@ test("a reader's own upward scrolling leaves the latest message from its first f
|
|
|
532
542
|
await body.hover();
|
|
533
543
|
await page.mouse.wheel(0, -20);
|
|
534
544
|
}
|
|
535
|
-
await expect.poll(fromBottom).toBeGreaterThan(1);
|
|
545
|
+
await expect.poll(() => fromBottom(body)).toBeGreaterThan(1);
|
|
536
546
|
await settleLayout(body);
|
|
537
|
-
await appendReply();
|
|
538
|
-
expect(await fromBottom()).toBeGreaterThan(1);
|
|
547
|
+
await appendReply(page, body);
|
|
548
|
+
expect(await fromBottom(body)).toBeGreaterThan(1);
|
|
539
549
|
|
|
540
550
|
// Returning to the end resumes following new replies.
|
|
541
551
|
await pressScrollKey(body, "End", "end");
|
|
542
|
-
await appendReply();
|
|
543
|
-
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
552
|
+
await appendReply(page, body);
|
|
553
|
+
expect(await fromBottom(body)).toBeLessThanOrEqual(1);
|
|
544
554
|
|
|
545
555
|
// The first frame of an upward keyboard scroll lands inside the follow
|
|
546
556
|
// threshold; the rest of it (a fling or smooth scroll) moves the reader
|
|
547
557
|
// into history without further input events.
|
|
548
558
|
await pressScrollKey(body, "ArrowUp", -10);
|
|
549
|
-
const firstFrame = await fromBottom();
|
|
559
|
+
const firstFrame = await fromBottom(body);
|
|
550
560
|
assert.ok(firstFrame > 1 && firstFrame <= 48, `first frame ${firstFrame}px from the end`);
|
|
551
561
|
await body.evaluate(element => { element.scrollTop = Math.round(element.scrollTop / 2); });
|
|
552
562
|
await settleLayout(body);
|
|
553
|
-
await appendReply();
|
|
554
|
-
expect(await fromBottom()).toBeGreaterThan(1_000);
|
|
563
|
+
await appendReply(page, body);
|
|
564
|
+
expect(await fromBottom(body)).toBeGreaterThan(1_000);
|
|
555
565
|
await pressScrollKey(body, "End", "end");
|
|
556
|
-
await appendReply();
|
|
557
|
-
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
566
|
+
await appendReply(page, body);
|
|
567
|
+
expect(await fromBottom(body)).toBeLessThanOrEqual(1);
|
|
558
568
|
|
|
559
569
|
// The scroll event of the transcript's own follow scroll can arrive after
|
|
560
570
|
// the key press, before the reader has moved; it must not cancel the
|
|
@@ -563,7 +573,7 @@ test("a reader's own upward scrolling leaves the latest message from its first f
|
|
|
563
573
|
element.addEventListener("keydown", () => element.dispatchEvent(new Event("scroll")), { once: true });
|
|
564
574
|
});
|
|
565
575
|
await pressScrollKey(body, "Home", "top");
|
|
566
|
-
await appendReply();
|
|
576
|
+
await appendReply(page, body);
|
|
567
577
|
expect(await body.evaluate(element => element.scrollTop)).toBe(0);
|
|
568
578
|
assert.deepEqual(errors, []);
|
|
569
579
|
await page.close();
|
|
@@ -584,25 +594,12 @@ test("a reader moved away from the end without a scroll direction stays there",
|
|
|
584
594
|
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
|
|
585
595
|
const errors = [];
|
|
586
596
|
page.on("pageerror", error => errors.push(error.message));
|
|
587
|
-
await page
|
|
588
|
-
const body = page.locator(".assistant-transcript__body");
|
|
589
|
-
const fromBottom = () => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop);
|
|
597
|
+
const body = await openAtLatest(page, vite.baseURL);
|
|
590
598
|
// Move the reader and wait until the transcript has handled the scroll.
|
|
591
599
|
const moveReader = target => body.evaluate((element, by) => new Promise(resolve => {
|
|
592
600
|
element.addEventListener("scroll", () => resolve(), { once: true });
|
|
593
601
|
element.scrollTop = by === "end" ? element.scrollHeight : element.scrollTop + by;
|
|
594
602
|
}), target);
|
|
595
|
-
const appendReply = async () => {
|
|
596
|
-
const replies = page.getByText("New reply.", { exact: false });
|
|
597
|
-
const count = await replies.count();
|
|
598
|
-
await page.getByRole("button", { name: "Append reply", exact: true }).evaluate(element => element.click());
|
|
599
|
-
await expect(replies).toHaveCount(count + 1);
|
|
600
|
-
await settleLayout(body);
|
|
601
|
-
};
|
|
602
|
-
await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
|
|
603
|
-
await page.getByRole("button", { name: "Exhaust history", exact: true }).evaluate(element => element.click());
|
|
604
|
-
await settleLayout(body);
|
|
605
|
-
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
606
603
|
|
|
607
604
|
// A scrollbar drag starts on the scroller itself and carries no scroll
|
|
608
605
|
// direction; its frames, the first inside the follow threshold, then move
|
|
@@ -611,13 +608,13 @@ test("a reader moved away from the end without a scroll direction stays there",
|
|
|
611
608
|
await body.evaluate(element => element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })));
|
|
612
609
|
await moveReader(-10);
|
|
613
610
|
await moveReader(-300);
|
|
614
|
-
await appendReply();
|
|
615
|
-
expect(await fromBottom()).toBeGreaterThan(300);
|
|
611
|
+
await appendReply(page, body);
|
|
612
|
+
expect(await fromBottom(body)).toBeGreaterThan(300);
|
|
616
613
|
|
|
617
614
|
// Returning to the end resumes following new replies.
|
|
618
615
|
await moveReader("end");
|
|
619
|
-
await appendReply();
|
|
620
|
-
expect(await fromBottom()).toBeLessThanOrEqual(1);
|
|
616
|
+
await appendReply(page, body);
|
|
617
|
+
expect(await fromBottom(body)).toBeLessThanOrEqual(1);
|
|
621
618
|
assert.deepEqual(errors, []);
|
|
622
619
|
} finally {
|
|
623
620
|
await browser.close();
|
|
@@ -32,6 +32,15 @@ test("native command construction preserves tool-free constraints without granti
|
|
|
32
32
|
// Account queries whose credentials are not the subject read them from nowhere.
|
|
33
33
|
const ABSENT_CLAUDE_CONFIG = join(tmpdir(), "jskit-absent-claude-config");
|
|
34
34
|
|
|
35
|
+
// Date.now reads `clock.now` until the test ends.
|
|
36
|
+
function fakeClock(t, now = Date.now()) {
|
|
37
|
+
const realNow = Date.now;
|
|
38
|
+
const clock = { now };
|
|
39
|
+
Date.now = () => clock.now;
|
|
40
|
+
t.after(() => { Date.now = realNow; });
|
|
41
|
+
return clock;
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
async function fixture(t, body) {
|
|
36
45
|
const directory = await mkdtemp(join(tmpdir(), "jskit-claude-process-"));
|
|
37
46
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
@@ -216,10 +225,7 @@ test("a Claude status answer holds until the credentials change, even across its
|
|
|
216
225
|
}, async (t) => {
|
|
217
226
|
const home = await mkdtemp(path.join(os.tmpdir(), "claude-auth-status-"));
|
|
218
227
|
t.after(() => rm(home, { recursive: true, force: true }));
|
|
219
|
-
const
|
|
220
|
-
let now = realNow();
|
|
221
|
-
Date.now = () => now;
|
|
222
|
-
t.after(() => { Date.now = realNow; });
|
|
228
|
+
const clock = fakeClock(t);
|
|
223
229
|
const configRoot = path.join(home, ".claude");
|
|
224
230
|
await mkdir(configRoot);
|
|
225
231
|
const credentials = path.join(configRoot, ".credentials.json");
|
|
@@ -234,7 +240,7 @@ test("a Claude status answer holds until the credentials change, even across its
|
|
|
234
240
|
return result;
|
|
235
241
|
} };
|
|
236
242
|
assert.equal((await readClaudeCodeAuthStatus(input)).email, "owner@example.test");
|
|
237
|
-
now += 10 * 60_000;
|
|
243
|
+
clock.now += 10 * 60_000;
|
|
238
244
|
assert.equal((await readClaudeCodeAuthStatus(input)).email, "owner@example.test");
|
|
239
245
|
assert.equal(calls, 1, "the answer is not read again on a timer or after the run's own rewrite");
|
|
240
246
|
// Sign-out removes the credentials: Claude is asked again at once.
|
|
@@ -246,11 +252,11 @@ test("a Claude status answer holds until the credentials change, even across its
|
|
|
246
252
|
await writeFile(credentials, "signed-in-again");
|
|
247
253
|
result = { ok: false, exitCode: 1, timedOut: true, error: "Command timed out." };
|
|
248
254
|
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
|
|
249
|
-
now += 29_000;
|
|
255
|
+
clock.now += 29_000;
|
|
250
256
|
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
|
|
251
257
|
assert.equal(calls, 3);
|
|
252
258
|
// A read that ran and failed is reported, and asked again next time.
|
|
253
|
-
now += 2_000;
|
|
259
|
+
clock.now += 2_000;
|
|
254
260
|
result = { ok: false, exitCode: 2, error: "Claude Code crashed." };
|
|
255
261
|
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Claude Code crashed.");
|
|
256
262
|
result = signedIn;
|
|
@@ -261,18 +267,15 @@ test("a Claude status answer holds until the credentials change, even across its
|
|
|
261
267
|
test("an unfinished Claude status read is reported without probing again at once", {
|
|
262
268
|
skip: process.platform === "darwin"
|
|
263
269
|
}, async (t) => {
|
|
264
|
-
const
|
|
265
|
-
let now = realNow();
|
|
266
|
-
Date.now = () => now;
|
|
267
|
-
t.after(() => { Date.now = realNow; });
|
|
270
|
+
const clock = fakeClock(t);
|
|
268
271
|
let calls = 0;
|
|
269
272
|
let result = { ok: false, exitCode: 1, retryable: true, error: "Memory-pressure spike." };
|
|
270
273
|
const input = { env: {}, credentialHome: { home: "/home/fixture-busy" }, commandRunner: async () => { calls += 1; return result; } };
|
|
271
274
|
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Memory-pressure spike.");
|
|
272
|
-
now += 29_000;
|
|
275
|
+
clock.now += 29_000;
|
|
273
276
|
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Memory-pressure spike.");
|
|
274
277
|
assert.equal(calls, 1);
|
|
275
|
-
now += 2_000;
|
|
278
|
+
clock.now += 2_000;
|
|
276
279
|
result = { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test" }) };
|
|
277
280
|
assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, true);
|
|
278
281
|
assert.equal(calls, 2);
|
|
@@ -623,10 +626,7 @@ test("Claude plan usage shows only an answer from the usage endpoint as current"
|
|
|
623
626
|
});
|
|
624
627
|
|
|
625
628
|
test("Claude plan usage asks a temporary Claude when a live process cannot prove its answer", async (t) => {
|
|
626
|
-
const
|
|
627
|
-
let now = Date.parse("2026-10-29T03:00:00Z");
|
|
628
|
-
Date.now = () => now;
|
|
629
|
-
t.after(() => { Date.now = realNow; });
|
|
629
|
+
const clock = fakeClock(t, Date.parse("2026-10-29T03:00:00Z"));
|
|
630
630
|
const resets_at = "2026-11-04T05:00:00Z";
|
|
631
631
|
const starts = [];
|
|
632
632
|
let stops = 0;
|
|
@@ -652,17 +652,17 @@ test("Claude plan usage asks a temporary Claude when a live process cannot prove
|
|
|
652
652
|
assert.equal(first.windows[0].remainingPercent, 77);
|
|
653
653
|
assert.deepEqual(starts, ["usage"]);
|
|
654
654
|
for (let poll = 1; poll <= 4; poll++) {
|
|
655
|
-
now += 60_000;
|
|
655
|
+
clock.now += 60_000;
|
|
656
656
|
assert.equal((await queries.readPlanUsage({})).windows[0].remainingPercent, 77);
|
|
657
657
|
}
|
|
658
658
|
assert.equal(liveReads, 5);
|
|
659
659
|
assert.deepEqual(starts, ["usage"], "a fallback started Claude inside the idle interval");
|
|
660
|
-
now += 61_000;
|
|
660
|
+
clock.now += 61_000;
|
|
661
661
|
await queries.readPlanUsage({});
|
|
662
662
|
assert.deepEqual(starts, ["usage", "usage"]);
|
|
663
663
|
assert.equal(stops, starts.length, "a temporary usage process outlived its read");
|
|
664
664
|
// A refused fallback keeps the last verified reading rather than failing the read.
|
|
665
|
-
now += 301_000;
|
|
665
|
+
clock.now += 301_000;
|
|
666
666
|
startRefused = true;
|
|
667
667
|
const kept = await queries.readPlanUsage({});
|
|
668
668
|
assert.equal(kept.windows[0].remainingPercent, 77);
|
|
@@ -670,10 +670,7 @@ test("Claude plan usage asks a temporary Claude when a live process cannot prove
|
|
|
670
670
|
});
|
|
671
671
|
|
|
672
672
|
test("Claude plan usage starts one temporary Claude per idle interval and marks an old reading stale", async (t) => {
|
|
673
|
-
const
|
|
674
|
-
let now = Date.parse("2026-10-29T03:00:00Z");
|
|
675
|
-
Date.now = () => now;
|
|
676
|
-
t.after(() => { Date.now = realNow; });
|
|
673
|
+
const clock = fakeClock(t, Date.parse("2026-10-29T03:00:00Z"));
|
|
677
674
|
const starts = [];
|
|
678
675
|
let stops = 0;
|
|
679
676
|
let answer = { rate_limits_available: true, rate_limits: {
|
|
@@ -690,16 +687,16 @@ test("Claude plan usage starts one temporary Claude per idle interval and marks
|
|
|
690
687
|
assert.equal(first.windows[0].remainingPercent, 77);
|
|
691
688
|
// Pages polling every minute share the account's reading.
|
|
692
689
|
for (let poll = 1; poll <= 4; poll++) {
|
|
693
|
-
now += 60_000;
|
|
690
|
+
clock.now += 60_000;
|
|
694
691
|
assert.equal((await queries.readPlanUsage({})).status, "available");
|
|
695
692
|
}
|
|
696
693
|
assert.deepEqual(starts, ["usage"]);
|
|
697
|
-
now += 61_000;
|
|
694
|
+
clock.now += 61_000;
|
|
698
695
|
answer = { rate_limits_available: true, rate_limits: { seven_day: { utilization: 7, resets_at: "2026-11-04T05:00:00Z" } } };
|
|
699
696
|
const unverified = await queries.readPlanUsage({});
|
|
700
697
|
assert.deepEqual(starts, ["usage", "usage"]);
|
|
701
698
|
assert.equal(unverified.windows[0].remainingPercent, 77);
|
|
702
|
-
now += 120_000;
|
|
699
|
+
clock.now += 120_000;
|
|
703
700
|
const stale = await queries.readPlanUsage({});
|
|
704
701
|
assert.equal(stale.status, "stale");
|
|
705
702
|
assert.equal(stale.windows[0].remainingPercent, 77);
|
|
@@ -711,10 +708,7 @@ test("Claude plan usage starts one temporary Claude per idle interval and marks
|
|
|
711
708
|
});
|
|
712
709
|
|
|
713
710
|
test("Claude plan usage starts one temporary Claude per account for every owner polling it", async (t) => {
|
|
714
|
-
const
|
|
715
|
-
let now = Date.parse("2026-10-29T03:00:00Z");
|
|
716
|
-
Date.now = () => now;
|
|
717
|
-
t.after(() => { Date.now = realNow; });
|
|
711
|
+
const clock = fakeClock(t, Date.parse("2026-10-29T03:00:00Z"));
|
|
718
712
|
const starts = [];
|
|
719
713
|
let stops = 0;
|
|
720
714
|
// Three account-query owners in one server process, none with a live conversation.
|
|
@@ -736,7 +730,7 @@ test("Claude plan usage starts one temporary Claude per account for every owner
|
|
|
736
730
|
assert.equal(concurrent[0].windows[0].remainingPercent, 77);
|
|
737
731
|
// An hour of every owner polling once a minute.
|
|
738
732
|
for (let minute = 1; minute < 60; minute++) {
|
|
739
|
-
now += 60_000;
|
|
733
|
+
clock.now += 60_000;
|
|
740
734
|
for (const queries of owners) assert.equal((await queries.readPlanUsage(account)).status, "available");
|
|
741
735
|
}
|
|
742
736
|
assert.equal(starts.length, 12, "the account started more than one temporary Claude per idle interval");
|
|
@@ -747,10 +741,7 @@ test("Claude plan usage starts one temporary Claude per account for every owner
|
|
|
747
741
|
});
|
|
748
742
|
|
|
749
743
|
test("Claude plan usage reuses another owner's live reading instead of starting a temporary Claude", async (t) => {
|
|
750
|
-
const
|
|
751
|
-
let now = Date.parse("2026-10-29T03:00:00Z");
|
|
752
|
-
Date.now = () => now;
|
|
753
|
-
t.after(() => { Date.now = realNow; });
|
|
744
|
+
const clock = fakeClock(t, Date.parse("2026-10-29T03:00:00Z"));
|
|
754
745
|
const account = "plan-usage-live-in-another-owner";
|
|
755
746
|
const answer = { rate_limits_available: true, rate_limits: {
|
|
756
747
|
seven_day: { utilization: 23, resets_at: "2026-11-04T05:00:00Z" }, limits: [] } };
|
|
@@ -769,13 +760,13 @@ test("Claude plan usage reuses another owner's live reading instead of starting
|
|
|
769
760
|
// Only the idle owner's page stays open: the account's verified reading serves it
|
|
770
761
|
// until that reading is an idle interval old.
|
|
771
762
|
for (let minute = 1; minute <= 4; minute++) {
|
|
772
|
-
now += 60_000;
|
|
763
|
+
clock.now += 60_000;
|
|
773
764
|
const reading = await idle.readPlanUsage({});
|
|
774
765
|
assert.equal(reading.checkedAt, live.checkedAt);
|
|
775
766
|
assert.equal(reading.status, "available");
|
|
776
767
|
}
|
|
777
768
|
assert.deepEqual(starts, [], "a temporary Claude started while the account had a recent verified reading");
|
|
778
|
-
now += 60_000;
|
|
769
|
+
clock.now += 60_000;
|
|
779
770
|
await idle.readPlanUsage({});
|
|
780
771
|
assert.deepEqual(starts, ["usage"]);
|
|
781
772
|
});
|
|
@@ -794,10 +785,7 @@ test("Claude model list starts a Claude only after its account, credentials or C
|
|
|
794
785
|
const command = join(root, "claude");
|
|
795
786
|
await replace(credentials, "signed in");
|
|
796
787
|
await replace(command, "2.1.1");
|
|
797
|
-
const
|
|
798
|
-
let now = realNow();
|
|
799
|
-
Date.now = () => now;
|
|
800
|
-
t.after(() => { Date.now = realNow; });
|
|
788
|
+
const clock = fakeClock(t);
|
|
801
789
|
let account = "account";
|
|
802
790
|
const starts = [];
|
|
803
791
|
const queries = createClaudeAccountQueries({ accountIdentity: async () => account, configRoot,
|
|
@@ -811,7 +799,7 @@ test("Claude model list starts a Claude only after its account, credentials or C
|
|
|
811
799
|
const model = async () => (await queries.readCatalogue({})).models[0].value;
|
|
812
800
|
// Reconnects, tab returns and closed sessions ask about four times an hour.
|
|
813
801
|
const askForADay = async (expected) => {
|
|
814
|
-
for (let read = 0; read < 96; read++, now += 15 * 60_000) assert.equal(await model(), expected);
|
|
802
|
+
for (let read = 0; read < 96; read++, clock.now += 15 * 60_000) assert.equal(await model(), expected);
|
|
815
803
|
};
|
|
816
804
|
await askForADay("read-1");
|
|
817
805
|
assert.deepEqual(starts, ["catalogue"]);
|
|
@@ -40,6 +40,9 @@ function nativeRuntime({ threads, goals = {} }) {
|
|
|
40
40
|
return { provider, requests, threads, goals };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// The invalidation the runtime lifecycle requests for an account change.
|
|
44
|
+
const ACCOUNT_CHANGE = Object.freeze({ deferBusy: true, includeOwned: true, requireVerifiedExit: true, stopOwnedRuntimes: true });
|
|
45
|
+
|
|
43
46
|
function ownerWith(t, runtimes, options = {}) {
|
|
44
47
|
const root = path.join(os.tmpdir(), `jskit-codex-account-change-${randomUUID()}`);
|
|
45
48
|
const owner = createCodexAppServerProviderOwner({ runtimeRoot: root, ...options });
|
|
@@ -59,9 +62,7 @@ test("an account change stops an idle Codex runtime at once and leaves a running
|
|
|
59
62
|
const busy = nativeRuntime({ threads: { "busy-thread": "active" }, goals: { "busy-thread": { status: "active" } } });
|
|
60
63
|
const { owner, keys } = ownerWith(t, { idle, busy });
|
|
61
64
|
|
|
62
|
-
const result = await owner.invalidateRuntimes(
|
|
63
|
-
deferBusy: true, includeOwned: true, requireVerifiedExit: true, stopOwnedRuntimes: true
|
|
64
|
-
});
|
|
65
|
+
const result = await owner.invalidateRuntimes(ACCOUNT_CHANGE);
|
|
65
66
|
|
|
66
67
|
assert.equal(result.ok, true);
|
|
67
68
|
assert.equal(idle.provider.stopped, 1, "An idle runtime stops at once");
|
|
@@ -83,11 +84,36 @@ test("an account change stops an idle Codex runtime at once and leaves a running
|
|
|
83
84
|
assert.equal(await owner.retireStaleRuntime(keys.busy), null);
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
test("an account change stops a Codex runtime whose control connection closed while its turns were read", async t => {
|
|
88
|
+
const closing = nativeRuntime({ threads: { "busy-thread": "active" }, goals: { "busy-thread": { status: "active" } } });
|
|
89
|
+
let open = true;
|
|
90
|
+
const read = closing.provider.client.request;
|
|
91
|
+
closing.provider.client = {
|
|
92
|
+
isOpen: () => open,
|
|
93
|
+
async request(method, params) {
|
|
94
|
+
const response = await read(method, params);
|
|
95
|
+
if (method === "thread/read") open = false;
|
|
96
|
+
return response;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const idle = nativeRuntime({ threads: { "idle-thread": "idle" } });
|
|
100
|
+
const { owner } = ownerWith(t, { closing, idle });
|
|
101
|
+
|
|
102
|
+
const result = await owner.invalidateRuntimes(ACCOUNT_CHANGE);
|
|
103
|
+
|
|
104
|
+
assert.equal(result.ok, true);
|
|
105
|
+
assert.deepEqual(result.deferred, [], "A closed connection leaves no running turn to keep");
|
|
106
|
+
assert.equal(closing.provider.stale, false);
|
|
107
|
+
assert.equal(closing.requests.some(request => request.startsWith("thread/goal/")), false);
|
|
108
|
+
assert.equal(closing.provider.stopped, 1, "It takes the verified stop of a runtime with no running turn");
|
|
109
|
+
assert.equal(idle.provider.stopped, 1, "The other runtimes still change account");
|
|
110
|
+
});
|
|
111
|
+
|
|
86
112
|
test("a session's next Codex runtime starts only after its finished stale runtime stopped", async t => {
|
|
87
113
|
const old = nativeRuntime({ threads: { "thread-1": "active" } });
|
|
88
114
|
const current = { client: null, close() {} };
|
|
89
115
|
const { owner, root } = ownerWith(t, { old }, { providerFactory: () => current });
|
|
90
|
-
await owner.invalidateRuntimes(
|
|
116
|
+
await owner.invalidateRuntimes(ACCOUNT_CHANGE);
|
|
91
117
|
assert.equal(old.provider.stale, true);
|
|
92
118
|
old.threads["thread-1"] = "idle";
|
|
93
119
|
|
|
@@ -251,7 +277,7 @@ test("recording a session's runtime for a new sign-in removes the previous sign-
|
|
|
251
277
|
test("a stale Codex runtime its session no longer records keeps no exit proof when it stops", async t => {
|
|
252
278
|
const busy = nativeRuntime({ threads: { "thread-1": "active" } });
|
|
253
279
|
const { owner, keys, root } = ownerWith(t, { busy });
|
|
254
|
-
await owner.invalidateRuntimes(
|
|
280
|
+
await owner.invalidateRuntimes(ACCOUNT_CHANGE);
|
|
255
281
|
|
|
256
282
|
assert.deepEqual(await releaseSupersededCodexRuntime(path.join(root, "busy")), { deferred: true, removed: false });
|
|
257
283
|
busy.threads["thread-1"] = "idle";
|
|
@@ -21,7 +21,8 @@ import {
|
|
|
21
21
|
// account was removed, or a project ledger that cannot be read, must not keep
|
|
22
22
|
// a closing session or project running, and close never reopens to stop.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Restoration retires a record whose `runtimeDir` is gone.
|
|
25
|
+
function savedHelperRecord(root, { runtimeDir = path.join(root, "codex-runtime"), ...overrides } = {}) {
|
|
25
26
|
const now = new Date().toISOString();
|
|
26
27
|
return defineCodexHelperThreadRecord({
|
|
27
28
|
createdAt: now,
|
|
@@ -36,7 +37,7 @@ function savedHelperRecord(root, overrides = {}) {
|
|
|
36
37
|
executionMode: "interactive",
|
|
37
38
|
executionContextHash: "c".repeat(12),
|
|
38
39
|
provider: "codex_app_server",
|
|
39
|
-
runtimeDir
|
|
40
|
+
runtimeDir,
|
|
40
41
|
runtimesHash: "d".repeat(12),
|
|
41
42
|
terminalEnvHash: "e".repeat(12),
|
|
42
43
|
toolHomeSource: "",
|
|
@@ -146,8 +147,7 @@ test("closing a Codex project stops providers past an unrestorable record and re
|
|
|
146
147
|
await mkdir(path.join(root, "codex-runtime"), { recursive: true });
|
|
147
148
|
await ledger.write(savedHelperRecord(root));
|
|
148
149
|
await ledger.write(savedHelperRecord(root, { ownershipId: "ownership-2", sessionId: "session-2", threadId: "thread-2",
|
|
149
|
-
|
|
150
|
-
runtimeDir: path.join(root, "removed-runtime") } },
|
|
150
|
+
runtimeDir: path.join(root, "removed-runtime"),
|
|
151
151
|
workdir: path.join(root, "sessions", "active", "session-2", "source") }));
|
|
152
152
|
const events = [];
|
|
153
153
|
const cleanup = createCodexSessionCleanup({
|
|
@@ -195,8 +195,7 @@ test("Helper restoration reports an unreadable ledger and a refused notice inste
|
|
|
195
195
|
await rm(ledger.root, { force: true });
|
|
196
196
|
|
|
197
197
|
await mkdir(path.join(root, "codex-runtime"), { recursive: true });
|
|
198
|
-
await ledger.write(savedHelperRecord(root, {
|
|
199
|
-
runtime: { ...savedHelperRecord(root).identity.runtime, runtimeDir: path.join(root, "removed-runtime") } } }));
|
|
198
|
+
await ledger.write(savedHelperRecord(root, { runtimeDir: path.join(root, "removed-runtime") }));
|
|
200
199
|
const refused = await helperLifecycle.restoreThreads(root, {
|
|
201
200
|
async prepareRecord(record, recordLedger) { return { ledger: recordLedger, retiring: true }; },
|
|
202
201
|
async publish() {
|
|
@@ -245,7 +244,6 @@ function conversationCommands({ context = async () => ({ result: { ok: false, co
|
|
|
245
244
|
helperLifecycle: { inspectControl: () => undefined },
|
|
246
245
|
namespace: (sessionId) => `codex:${String(sessionId || "").trim()}`,
|
|
247
246
|
providerSessions,
|
|
248
|
-
sessionConnection: (sessionId) => ({ providerKey: `codex-helper:${sessionId}`, runtimeHost: { stop: "prepared" } }),
|
|
249
247
|
ensureSessionProvider,
|
|
250
248
|
turnOutcomes: { USER_CANCELLED: "cancelled" }
|
|
251
249
|
});
|
|
@@ -386,8 +384,7 @@ test("an account-change invalidation stops runtimes without reopening saved Help
|
|
|
386
384
|
|
|
387
385
|
test("reconciliation keeps every session and prune retires past one failed provider", async (t) => {
|
|
388
386
|
const { root, helperLifecycle, ledger } = await withProjectLedger(t);
|
|
389
|
-
await ledger.write(savedHelperRecord(root, {
|
|
390
|
-
runtime: { ...savedHelperRecord(root).identity.runtime, runtimeDir: path.join(root, "removed-runtime") } } }));
|
|
387
|
+
await ledger.write(savedHelperRecord(root, { runtimeDir: path.join(root, "removed-runtime") }));
|
|
391
388
|
const retired = [];
|
|
392
389
|
const managed = new Map([
|
|
393
390
|
["provider-a", { sessionId: "stale-a", projectContext: { targetRoot: "/project" } }],
|
|
@@ -1,25 +1,15 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
4
|
import { createCodexAppServerProviderOwner } from "../src/server/conversation/codexProviderOwner.js";
|
|
4
5
|
import { createCodexConversationCommands } from "../src/server/conversation/codexConversations.js";
|
|
5
6
|
import { createCodexSessionCleanup } from "../src/server/conversation/codexSessionCleanup.js";
|
|
7
|
+
import { eventually } from "./fixtures/eventually.js";
|
|
6
8
|
|
|
7
9
|
// A Codex app-server runs only while it is used: reading a conversation's
|
|
8
10
|
// history or status, or stopping it, borrows the process; a running Temporary
|
|
9
11
|
// turn or goal holds it; and closing a project stops every one of its own.
|
|
10
12
|
|
|
11
|
-
async function eventually(check, message) {
|
|
12
|
-
const deadline = Date.now() + 2_000;
|
|
13
|
-
while (!check()) {
|
|
14
|
-
if (Date.now() > deadline) assert.fail(message);
|
|
15
|
-
await new Promise(resolve => setTimeout(resolve, 5));
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function settle(ms) {
|
|
20
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
13
|
// One native process per runtime directory, as the real app-server has per
|
|
24
14
|
// account and runtime scope. Each provider connection reports its own thread.
|
|
25
15
|
function codexFixture(t, { idleMs = 20 } = {}) {
|
|
@@ -130,7 +120,7 @@ test("a running Temporary turn keeps Codex; it stops a short idle after the turn
|
|
|
130
120
|
// A history read while the turn runs must not release its process either.
|
|
131
121
|
await f.commands.readCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
|
|
132
122
|
f.emit({ method: "turn/started", params: { threadId: "chat-1", turn: { id: "turn-2", status: "inProgress" } } });
|
|
133
|
-
await
|
|
123
|
+
await delay(80);
|
|
134
124
|
assert.equal(f.runtime.running, true, "the running turn's process was stopped");
|
|
135
125
|
assert.equal(f.runtime.stops, 0);
|
|
136
126
|
f.emit({ method: "turn/completed", params: { threadId: "chat-1", turn: { id: "turn-2", status: "completed" } } });
|
|
@@ -145,7 +135,7 @@ test("an active Temporary goal keeps Codex between its turns until the goal ends
|
|
|
145
135
|
});
|
|
146
136
|
f.emit({ method: "thread/goal/updated", params: { threadId: "chat-1", goal: { status: "active" } } });
|
|
147
137
|
f.emit({ method: "turn/completed", params: { threadId: "chat-1", turn: { id: "turn-2", status: "completed" } } });
|
|
148
|
-
await
|
|
138
|
+
await delay(80);
|
|
149
139
|
assert.equal(f.runtime.running, true, "the goal's process was stopped between its turns");
|
|
150
140
|
f.emit({ method: "thread/goal/updated", params: { threadId: "chat-1", goal: { status: "complete" } } });
|
|
151
141
|
await eventually(() => !f.runtime.running, "the finished goal kept Codex running");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
5
6
|
import { nativeConversationStoragePolicy } from "../src/server/conversation/nativeStorage.js";
|
|
@@ -7,6 +8,7 @@ import { createCodexAppServerProviderOwner } from "../src/server/conversation/co
|
|
|
7
8
|
import { createCodexConversationDriver } from "../src/server/conversation/providers/codexDriver.js";
|
|
8
9
|
import { createOpenCodeSharedRuntime, openCodeServerForDirectory } from "../src/server/conversation/openCodeRuntime.js";
|
|
9
10
|
import { createOpenCodeConversationDriver } from "../src/server/conversation/providers/opencodeDriver.js";
|
|
11
|
+
import { eventually } from "./fixtures/eventually.js";
|
|
10
12
|
|
|
11
13
|
test("native storage policy distinguishes locked deletion from host writer exclusion", () => {
|
|
12
14
|
assert.equal(nativeConversationStoragePolicy("codex").requiresWriterExclusion, false);
|
|
@@ -30,14 +32,6 @@ test("native writer recognition handles executables and node entry points withou
|
|
|
30
32
|
assert.equal(opencode.matchesProcess({ executable: "/tools/opencode-helper", commandLine: "node\0/notes/opencode.txt\0" }), false);
|
|
31
33
|
});
|
|
32
34
|
|
|
33
|
-
async function eventually(check, message) {
|
|
34
|
-
const deadline = Date.now() + 2_000;
|
|
35
|
-
while (!check()) {
|
|
36
|
-
if (Date.now() > deadline) assert.fail(message);
|
|
37
|
-
await new Promise(resolve => setTimeout(resolve, 5));
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
35
|
// One shared native process with independent provider connections, as the
|
|
42
36
|
// real Codex app-server has per account and runtime scope.
|
|
43
37
|
function codexStorageFixture(t, { idleMs = 20, listModelsMs = 0 } = {}) {
|
|
@@ -50,7 +44,7 @@ function codexStorageFixture(t, { idleMs = 20, listModelsMs = 0 } = {}) {
|
|
|
50
44
|
async currentRuntimeInfo() { return { runtimeDir }; },
|
|
51
45
|
async listModels() {
|
|
52
46
|
runtime.reads.push("catalogue");
|
|
53
|
-
await
|
|
47
|
+
await delay(listModelsMs);
|
|
54
48
|
return { running: runtime.running, data: [{ id: "gpt" }] };
|
|
55
49
|
},
|
|
56
50
|
async readGoal() { runtime.reads.push("goal"); return { goal: null }; },
|
|
@@ -113,7 +107,7 @@ test("native storage reads never stop a Codex process a conversation still uses"
|
|
|
113
107
|
await list("claimed-session");
|
|
114
108
|
await owner.ensureSession(context("claimed-session"));
|
|
115
109
|
await eventually(() => owner.providers.get("provider:archived-1") === undefined, "the storage provider was not released");
|
|
116
|
-
await
|
|
110
|
+
await delay(60);
|
|
117
111
|
assert.equal(runtime.running, true);
|
|
118
112
|
assert.equal(runtime.stops, 0);
|
|
119
113
|
assert.equal(owner.providers.get("provider:open-session"), conversation);
|
|
@@ -177,7 +171,7 @@ test("OpenCode storage reads never stop a server a conversation still uses", asy
|
|
|
177
171
|
const { owner, server, list, conversation } = openCodeStorageFixture();
|
|
178
172
|
const open = await conversation("open-session");
|
|
179
173
|
await list("archived-1");
|
|
180
|
-
await
|
|
174
|
+
await delay(60);
|
|
181
175
|
assert.equal(server.running, true);
|
|
182
176
|
assert.equal(server.stops, 0);
|
|
183
177
|
await owner.release(open);
|
|
@@ -220,7 +214,7 @@ test("real use that reaches a storage-created provider directly keeps it", async
|
|
|
220
214
|
await list(id);
|
|
221
215
|
const provider = owner.providers.get(`provider:${id}`);
|
|
222
216
|
await use(id);
|
|
223
|
-
await
|
|
217
|
+
await delay(60);
|
|
224
218
|
assert.equal(owner.providers.get(`provider:${id}`), provider);
|
|
225
219
|
}
|
|
226
220
|
assert.equal(runtime.running, true);
|
|
@@ -232,7 +226,7 @@ test("a storage read on a managed main thread's key leaves it to that thread", a
|
|
|
232
226
|
const managed = context("archived-1");
|
|
233
227
|
managed.runOwner.runtimeLifecycle.managedThreadId = () => "main-thread";
|
|
234
228
|
await owner.withTransientSession(managed, async () => {});
|
|
235
|
-
await
|
|
229
|
+
await delay(60);
|
|
236
230
|
assert.ok(owner.providers.get("provider:archived-1"));
|
|
237
231
|
assert.equal(runtime.stops, 0);
|
|
238
232
|
await list("archived-2");
|
|
@@ -244,7 +238,7 @@ test("a conversation that starts after an OpenCode storage read keeps the server
|
|
|
244
238
|
const { owner, server, list, conversation } = openCodeStorageFixture({ idleMs: 40 });
|
|
245
239
|
await list("archived-1");
|
|
246
240
|
const open = await conversation("open-session");
|
|
247
|
-
await
|
|
241
|
+
await delay(80);
|
|
248
242
|
assert.equal(server.running, true, "the storage release stopped a server a conversation uses");
|
|
249
243
|
assert.equal(server.starts, 1);
|
|
250
244
|
await owner.release(open);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
|
|
4
|
+
// Polls `check` until it holds, failing with `message` after two seconds.
|
|
5
|
+
export async function eventually(check, message) {
|
|
6
|
+
const deadline = Date.now() + 2_000;
|
|
7
|
+
while (!check()) {
|
|
8
|
+
if (Date.now() > deadline) assert.fail(message);
|
|
9
|
+
await delay(5);
|
|
10
|
+
}
|
|
11
|
+
}
|