@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.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.4",
12
- "@jskit-ai/http-runtime": "0.2.1",
13
- "@jskit-ai/kernel": "0.2.1",
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.1",
18
- "@jskit-ai/connectors-catalog": "0.2.1"
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.4",
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.192",
52
- "@jskit-ai/resource-crud-core": "0.1.192",
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.1",
62
- "@jskit-ai/kernel": "0.2.1",
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.optimistic?.cancelling || entry.turn.optimistic?.cancelError"
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.optimistic?.cancelling && !entry.turn.optimistic?.cancelError && (entry.delivery || entry.turn.optimistic?.status === 'failed')"
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.optimistic?.cancelling || entry.turn.optimistic?.cancelError"
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.optimistic.cancelling && !entry.turn.optimistic.cancelError"
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 (recover) await recoverSavedCustody(ctx);
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 providerBorrow() {
34
- return { release() {} };
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 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
- }
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
- const borrow = input.persistent === true ? providerBorrow() : null;
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 await startPreparedCodexAppServerConversationTurn(sessionId, input, {
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
- } finally {
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
- const borrow = providerBorrow();
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 await readPreparedPersistentCodexConversation(sessionId, input, context);
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
- } finally {
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
- const borrow = input.persistent === true ? providerBorrow() : null;
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
- } finally {
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 conversationState.provider.failObservation(conversationState.provider.observationFailure);
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
- const borrow = providerBorrow();
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 await stopPersistentCodexConversation({
606
+ return stopPersistentCodexConversation({
612
607
  conversationId,
613
608
  conversationState,
614
609
  provider: context.provider,
615
610
  turnOutcome
616
611
  });
617
- } finally {
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 provider.failObservation(provider.observationFailure);
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 = providerBorrow();
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 = !isRecord(entry) ? "listed an entry that is not a thread"
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) || [...providerRecords()].some(record =>
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 && ![...providerRecords()].some(peer =>
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: it stops as before.
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
- if (!running.length) return null;
838
- const client = peers.find(record => record.provider.client?.isOpen?.()).provider.client;
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 = [...providerRecords()].filter(record =>
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 removed connection's models are gone from its reloaded directory.
1541
- function disconnectedProviderFailure(failure) {
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
- return providerId && sharedProcess && !sharedProcess.connections.has(providerId)
1544
- ? `${providerId} isn't connected. Connect it again, then send your message again.` : "";
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
- const disconnected = disconnectedProviderFailure(failure);
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 { status: "not-sent", messageId: input.messageId,
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 notSentReceipt(messageId);
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 notSentReceipt(messageId);
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 notSentReceipt(messageId);
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 ? notSentReceipt(messageId) : { status: "unknown", messageId };
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.goto(`${vite.baseURL}/?controls=1&history=1`);
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.goto(`${vite.baseURL}/?controls=1&history=1`);
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 realNow = Date.now;
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 realNow = Date.now;
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 realNow = Date.now;
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 realNow = Date.now;
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 realNow = Date.now;
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 realNow = Date.now;
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 realNow = Date.now;
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({ deferBusy: true, includeOwned: true, requireVerifiedExit: true, stopOwnedRuntimes: true });
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({ deferBusy: true, includeOwned: true, requireVerifiedExit: true, stopOwnedRuntimes: true });
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
- function savedHelperRecord(root, overrides = {}) {
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: path.join(root, "codex-runtime"),
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
- identity: { ...savedHelperRecord(root).identity, runtime: { ...savedHelperRecord(root).identity.runtime,
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, { identity: { ...savedHelperRecord(root).identity,
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, { identity: { ...savedHelperRecord(root).identity,
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 settle(80);
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 settle(80);
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 new Promise(resolve => setTimeout(resolve, listModelsMs));
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 new Promise(resolve => setTimeout(resolve, 60));
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 new Promise(resolve => setTimeout(resolve, 60));
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 new Promise(resolve => setTimeout(resolve, 60));
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 new Promise(resolve => setTimeout(resolve, 60));
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 new Promise(resolve => setTimeout(resolve, 80));
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
+ }