@jskit-ai/assistant-core 0.1.240 → 0.1.241

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.
Files changed (101) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +5 -5
  3. package/src/client/conversation/AssistantTranscript.vue +26 -29
  4. package/src/client/conversation/LongTextPreviewBlocks.vue +11 -0
  5. package/src/client/conversation/messageDelivery.js +8 -2
  6. package/src/client/conversation/useAssistantAttachments.js +1 -1
  7. package/src/server/conversation/agentRun.js +12 -198
  8. package/src/server/conversation/attachments.js +6 -5
  9. package/src/server/conversation/boundedToolLoop.js +160 -0
  10. package/src/server/conversation/claudeHistory.js +20 -16
  11. package/src/server/conversation/claudeProcess.js +2 -2
  12. package/src/server/conversation/claudeTurn.js +41 -38
  13. package/src/server/conversation/codexAuthPreflight.js +104 -0
  14. package/src/server/conversation/codexClient.js +11 -16
  15. package/src/server/conversation/codexConfiguration.js +112 -98
  16. package/src/server/conversation/codexConversationStore.js +189 -0
  17. package/src/server/conversation/codexConversations.js +47 -44
  18. package/src/server/conversation/codexDetachedTurn.js +12 -11
  19. package/src/server/conversation/codexEvents.js +95 -99
  20. package/src/server/conversation/codexHelperExecution.js +3 -13
  21. package/src/server/conversation/codexHelperThreadLedger.js +7 -13
  22. package/src/server/conversation/codexHelperThreadRecovery.js +2 -16
  23. package/src/server/conversation/codexHelperThreads.js +5 -6
  24. package/src/server/conversation/codexHistoryAdapter.js +32 -26
  25. package/src/server/conversation/codexMessageCommands.js +22 -22
  26. package/src/server/conversation/codexMessageDelivery.js +12 -7
  27. package/src/server/conversation/codexModelCatalog.js +97 -0
  28. package/src/server/conversation/codexNotificationObserver.js +24 -40
  29. package/src/server/conversation/codexNotificationQueue.js +2 -3
  30. package/src/server/conversation/codexOutput.js +20 -27
  31. package/src/server/conversation/codexProcess.js +123 -127
  32. package/src/server/conversation/codexProcessLeader.js +3 -3
  33. package/src/server/conversation/codexProvider.js +31 -614
  34. package/src/server/conversation/codexProviderOwner.js +2 -2
  35. package/src/server/conversation/codexProviderSelection.js +38 -40
  36. package/src/server/conversation/codexRenewalCommands.js +13 -10
  37. package/src/server/conversation/codexRenewalThreads.js +412 -0
  38. package/src/server/conversation/codexRunJournal.js +24 -42
  39. package/src/server/conversation/codexRuntimeLifecycle.js +19 -15
  40. package/src/server/conversation/codexSessionCleanup.js +11 -10
  41. package/src/server/conversation/codexSessionReconciliation.js +6 -6
  42. package/src/server/conversation/codexThreadReadiness.js +19 -33
  43. package/src/server/conversation/codexThreadStatus.js +3 -3
  44. package/src/server/conversation/codexTurn.js +124 -297
  45. package/src/server/conversation/codexTurnRecovery.js +49 -28
  46. package/src/server/conversation/codexTurnState.js +34 -13
  47. package/src/server/conversation/commandWrapper.js +3 -0
  48. package/src/server/conversation/deliveryTiming.js +14 -5
  49. package/src/server/conversation/index.js +1 -0
  50. package/src/server/conversation/localExecution.js +13 -17
  51. package/src/server/conversation/normalize.js +8 -3
  52. package/src/server/conversation/openCodeClient.js +9 -10
  53. package/src/server/conversation/openCodeProcess.js +9 -5
  54. package/src/server/conversation/openCodeRuntime.js +10 -11
  55. package/src/server/conversation/openCodeTurn.js +43 -29
  56. package/src/server/conversation/providers/claudeDriver.js +8 -16
  57. package/src/server/conversation/providers/codexDriver.js +25 -41
  58. package/src/server/conversation/providers/opencodeDriver.js +17 -42
  59. package/src/server/conversation/providers/scoped.js +48 -4
  60. package/src/server/conversation/runtime.js +50 -49
  61. package/src/server/conversation/runtimeStateUpgrade.js +34 -22
  62. package/src/server/conversation/transcript.js +10 -10
  63. package/src/server/hosts/codexConfiguration.js +1 -0
  64. package/src/server/hosts/codexProvider.js +8 -6
  65. package/src/server/index.js +2 -1
  66. package/src/server/lib/assistantToolLoop.js +1 -153
  67. package/src/server/lib/serviceToolCatalog.js +17 -42
  68. package/src/shared/assistantResource.js +4 -2
  69. package/src/shared/conversation/attachments.js +4 -0
  70. package/src/shared/conversation/longTextBlocks.js +54 -19
  71. package/src/shared/conversation/numberedQuestionSugar.js +17 -10
  72. package/src/shared/conversation/pagination.js +4 -6
  73. package/src/shared/conversation/patches.js +1 -4
  74. package/src/shared/conversation/streaming.js +3 -11
  75. package/src/shared/conversation/turns.js +11 -0
  76. package/src/shared/index.js +1 -0
  77. package/test/assistantBoundedToolLoop.test.js +2 -2
  78. package/test/assistantResource.test.js +4 -4
  79. package/test/assistantSettingsResource.test.js +2 -2
  80. package/test/claudeConversationSession.test.js +11 -1
  81. package/test/claudeTurn.test.js +27 -7
  82. package/test/codexClient.test.js +20 -0
  83. package/test/codexConversationSession.test.js +2 -2
  84. package/test/codexConversationsCheckpoint.test.js +51 -0
  85. package/test/codexModelCatalog.test.js +1 -1
  86. package/test/codexProviderHome.test.js +51 -0
  87. package/test/codexProviderSelection.test.js +71 -0
  88. package/test/codexTurn.test.js +222 -6
  89. package/test/conversationAgentRun.test.js +2 -1
  90. package/test/conversationAttachments.test.js +27 -2
  91. package/test/conversationDeliveryTiming.test.js +21 -0
  92. package/test/conversationNormalize.test.js +11 -0
  93. package/test/fixtures/nestedListQuestionMessage.md +37 -0
  94. package/test/frameworkActions.test.js +2 -2
  95. package/test/longTextBlocks.test.js +102 -0
  96. package/test/messageDelivery.test.js +7 -1
  97. package/test/openCodeClient.test.js +20 -0
  98. package/test/openCodeRuntime.test.js +1 -1
  99. package/test/openCodeTurn.test.js +90 -0
  100. package/test/opencodeConversationSession.test.js +1 -1
  101. package/test/serviceToolCatalog.test.js +27 -0
@@ -2573,6 +2573,16 @@ async function completedReplyFixture(t, options = {}) {
2573
2573
  prepare: receipt => conversation.prepareCompletedResponse(receipt, { signal: controller.signal }) };
2574
2574
  }
2575
2575
 
2576
+ test("delivery inspection of a turn without runtime metadata returns its duplicate receipt", async t => {
2577
+ const f = await completedReplyFixture(t);
2578
+ try {
2579
+ const plain = await f.plain("pre-runtime-turn");
2580
+ assert.equal(plain.metadata?.runtime, undefined);
2581
+ assert.deepEqual(await f.conversation.inspectDelivery({ messageId: "pre-runtime-turn" }),
2582
+ { status: "accepted", messageId: "pre-runtime-turn", turnId: plain.turnId, duplicate: true });
2583
+ } finally { await f.close(); }
2584
+ });
2585
+
2576
2586
  test("completed Claude prepared reply atomically publishes one plain final across handles and restart", async t => {
2577
2587
  const f = await completedReplyFixture(t);
2578
2588
  try {
@@ -2820,7 +2830,7 @@ for (const kind of ["tool", "reply"]) test(`completed Claude ${kind} wire preser
2820
2830
  assert.equal(saved.assistant.text, raw);
2821
2831
  const prepared = await f.conversation.prepareCompletedResponse(receipt, { signal: new AbortController().signal });
2822
2832
  assert.equal(prepared.text, raw);
2823
- const { readAssistantResponseEnvelope } = await import("../src/server/lib/assistantToolLoop.js");
2833
+ const { readAssistantResponseEnvelope } = await import("../src/server/conversation/boundedToolLoop.js");
2824
2834
  const decoded = readAssistantResponseEnvelope(prepared.text);
2825
2835
  assert.deepEqual(decoded, value);
2826
2836
  if (kind === "tool") assert.equal(JSON.parse(decoded.arguments).value, handover);
@@ -1361,7 +1361,7 @@ test("Claude verified native cleanup retains interrupted checkpoint refusal with
1361
1361
 
1362
1362
  // Retain the original supplied-store/native process fixture and drive it through
1363
1363
  // the actual common runtime, including its completion-only admitted receipt.
1364
- async function suppliedCommonFixture(t, { tools = true, onDeliveryTiming, representation = "canonical", beforeToolWrite, signal, executeTool = async () => ({}) } = {}) {
1364
+ async function suppliedCommonFixture(t, { tools = true, onDeliveryTiming, hostDeliveryStages, representation = "canonical", beforeToolWrite, signal, executeTool = async () => ({}) } = {}) {
1365
1365
  const f = await publishedClaudeFixture(t, { canonicalAdmission: true });
1366
1366
  const storage = createMemoryConversationStorage();
1367
1367
  const transcript = createConversationTranscript({ storage });
@@ -1390,7 +1390,7 @@ async function suppliedCommonFixture(t, { tools = true, onDeliveryTiming, repres
1390
1390
  native: { owner: f.owner, preparation: { cleanup: () => ({ context: f.context, application: {
1391
1391
  readContext: async () => f.context, terminals: { close: async () => ({ ok: true }) }
1392
1392
  } }) } },
1393
- admission() {}, prepareInput: async input => input,
1393
+ admission() {}, prepareInput: async (input, _context, { deliveryTiming } = {}) => { deliveryTiming?.("host-prepared"); return input; },
1394
1394
  read: async () => ({ threadId: f.conversationId, configuration: { model: "native" },
1395
1395
  turn: f.owner.snapshot(f.entry), delivery }),
1396
1396
  readStream: async () => ({ messages: [] }),
@@ -1404,7 +1404,7 @@ async function suppliedCommonFixture(t, { tools = true, onDeliveryTiming, repres
1404
1404
  admissions.push(admission); return actual;
1405
1405
  } } } : {}) };
1406
1406
  const runtime = createConversationRuntime({ ...(tools ? { actions } : {}), authorize: () => true, onDeliveryTiming,
1407
- host: { conversation: () => binding } });
1407
+ hostDeliveryStages, host: { conversation: () => binding } });
1408
1408
  t.after(() => runtime.close());
1409
1409
  const conversation = await runtime.open({ id: "lesson", context, representation });
1410
1410
  f.setBeforeState((entry, event) => {
@@ -1511,20 +1511,20 @@ for (const failedSave of [false, true]) test(`Claude goal waits for common final
1511
1511
 
1512
1512
  test("Claude delivery timing separates native acknowledgement from the held canonical receipt", { timeout: 10_000 }, async t => {
1513
1513
  const timings = [];
1514
- const f = await suppliedCommonFixture(t, { onDeliveryTiming: event => timings.push(event) });
1514
+ const f = await suppliedCommonFixture(t, { onDeliveryTiming: event => timings.push(event), hostDeliveryStages: ["host-prepared"] });
1515
1515
  const writing = Promise.withResolvers();
1516
1516
  const release = Promise.withResolvers();
1517
1517
  f.behavior.beforeAdmit = async () => { writing.resolve(); await release.promise; };
1518
1518
  const sending = f.conversation.send({ messageId: "timed-request", text: "Private message" });
1519
1519
  await writing.promise;
1520
1520
  assert.deepEqual(timings.map(event => event.stage), [
1521
- "received", "preparation-started", "authorized", "history-read", "state-read", "request-saved", "input-prepared",
1522
- "provider-started", "duplicate-checked", "native-history-read", "process-ready", "prompt-rendered", "metadata-ready",
1521
+ "received", "preparation-started", "authorized", "history-read", "state-read", "request-saved", "host-prepared",
1522
+ "input-prepared", "provider-started", "duplicate-checked", "native-history-read", "process-ready", "prompt-rendered", "metadata-ready",
1523
1523
  "runtime-ready", "dispatch-started", "native-acknowledged"
1524
1524
  ]);
1525
1525
  release.resolve();
1526
1526
  assert.equal((await sending).status, "accepted");
1527
- const admitted = timings.map(event => event.stage).slice(16);
1527
+ const admitted = timings.map(event => event.stage).slice(17);
1528
1528
  assert.deepEqual(admitted, ["receipt-written", "state-active", "receipt-persisted", "admission-authorized", "turn-located", "accepted"]);
1529
1529
  await f.frame({ type: "result", subtype: "success", result: "Public answer" });
1530
1530
  await f.conversation.wait();
@@ -2394,6 +2394,26 @@ test("bound Main history budget transfers more than 30 complete messages through
2394
2394
  assert.equal(sent[1], "Prepare the question.");
2395
2395
  });
2396
2396
 
2397
+ test("bound Main treats a restart-interrupted attempt with a saved receipt as delivered, not uncertain", { timeout: 10_000 }, async t => {
2398
+ const f = await suppliedCommonFixture(t, { tools: false });
2399
+ // The application restarted after native admission saved the authored
2400
+ // message, but before the dispatch returned and cleared its attempt marker.
2401
+ const attempt = { messageId: "admitted-before-restart", message: "Check the Preview.", displayMessage: "Check the Preview.",
2402
+ attempted: true, threadId: f.conversationId, seen: {} };
2403
+ await f.binding.state.write({ lastEngine: "claude", engines: { claude: { seen: {}, pending: attempt } } });
2404
+ const unconfirmed = await f.conversation.read();
2405
+ assert.equal(unconfirmed.status, "unconfirmed", "An attempt without a saved receipt stays uncertain");
2406
+ assert.equal(unconfirmed.pendingRequest.messageId, attempt.messageId);
2407
+ await f.binding.transcript.writeUserMessage({ messageId: attempt.messageId, text: attempt.displayMessage });
2408
+ f.binding.transcript.history = async () => [{ id: "000001/user/", version: "v1", role: "user", engineId: "claude",
2409
+ messageId: attempt.messageId, text: attempt.displayMessage }];
2410
+ const settled = await f.conversation.read();
2411
+ assert.equal(settled.status, "ready", "The saved receipt proves delivery, so Send stays available");
2412
+ assert.equal(settled.pendingRequest, null);
2413
+ await sendCommon(f, "after-restart");
2414
+ assert.equal((await f.binding.state.read()).engines.claude.pending, undefined, "The next delivery settles the old marker");
2415
+ });
2416
+
2397
2417
  test("bound Main cannot dispatch or retain a pending prompt when its history budget is unavailable", { timeout: 10_000 }, async t => {
2398
2418
  const f = await suppliedCommonFixture(t, { tools: false, representation: "native" });
2399
2419
  f.binding.transcript.history = async () => [{ id: "earlier", version: "v1", role: "user", text: "Earlier work." }];
@@ -89,3 +89,23 @@ test("transport failure preserves its error code for pending requests", async ()
89
89
  await assert.rejects(result, { code: "assistant_codex_app_server_message_too_large" });
90
90
  assert.equal(client.isOpen(), false);
91
91
  });
92
+
93
+ test("outgoing messages over the limit fail with the transport-limit code", async () => {
94
+ const client = new CodexAppServerJsonRpcClient({ endpoint: "ws://fixture", WebSocketImpl: Socket, maxMessageBytes: 10 });
95
+ await client.connect();
96
+ assert.throws(() => client.send({ payload: "x".repeat(20) }), {
97
+ code: "assistant_codex_app_server_message_too_large",
98
+ message: "Codex app-server message exceeded the configured transport limit."
99
+ });
100
+ });
101
+
102
+ test("message and timeout limits accept only positive integers", () => {
103
+ for (const invalid of [0, -1, 1.5, "abc", Number.NaN]) {
104
+ const client = new CodexAppServerJsonRpcClient({ maxMessageBytes: invalid, requestTimeoutMs: invalid });
105
+ assert.equal(client.maxMessageBytes, Number.POSITIVE_INFINITY);
106
+ assert.equal(client.requestTimeoutMs, 60_000);
107
+ }
108
+ const client = new CodexAppServerJsonRpcClient({ maxMessageBytes: 512, requestTimeoutMs: 25 });
109
+ assert.equal(client.maxMessageBytes, 512);
110
+ assert.equal(client.requestTimeoutMs, 25);
111
+ });
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import { createHash } from "node:crypto";
7
7
  import { createConversationRuntime, createFileConversationStorage } from "../src/server/conversation/index.js";
8
- import { createCodexConversationStore } from "../src/server/conversation/agentRun.js";
8
+ import { createCodexConversationStore } from "../src/server/conversation/codexConversationStore.js";
9
9
  import { createReentrantConversationStorage } from "../src/server/conversation/storage.js";
10
10
  import { createConversationTranscript } from "../src/server/conversation/transcript.js";
11
11
  import { createConversationStreams } from "../src/server/conversation/streams.js";
@@ -3668,7 +3668,7 @@ for (const kind of ["tool", "reply"]) test(`completed Codex ${kind} wire preserv
3668
3668
  assert.equal(run.outerTurnId, receipt.messageId);
3669
3669
  const prepared = await f.conversation.prepareCompletedResponse(receipt, { signal: new AbortController().signal });
3670
3670
  assert.equal(prepared.text, raw);
3671
- const { readAssistantResponseEnvelope } = await import("../src/server/lib/assistantToolLoop.js");
3671
+ const { readAssistantResponseEnvelope } = await import("../src/server/conversation/boundedToolLoop.js");
3672
3672
  const decoded = readAssistantResponseEnvelope(prepared.text);
3673
3673
  assert.deepEqual(decoded, value);
3674
3674
  if (kind === "tool") assert.equal(JSON.parse(decoded.arguments).value, handover);
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createCodexConversationCommands } from "../src/server/conversation/codexConversations.js";
4
+
5
+ const turnOutcomes = { USER_CANCELLED: "user_cancelled" };
6
+
7
+ function persistentConversationFixture() {
8
+ const checkpoints = [];
9
+ const listeners = new Set();
10
+ const emit = (notification) => listeners.forEach((listener) => listener(notification));
11
+ const completed = (status) => emit({ method: "turn/completed", params: { threadId: "thread", turn: { id: "turn-1", status } } });
12
+ const provider = {
13
+ subscribe(listener) {
14
+ listeners.add(listener);
15
+ return () => listeners.delete(listener);
16
+ },
17
+ async readThreadStatus() { return {}; },
18
+ // Codex reports the interrupted turn while it is stopping.
19
+ async stopThreadForObservationLoss() { completed("interrupted"); }
20
+ };
21
+ const commands = createCodexConversationCommands({
22
+ async checkpoint(_sessionId, input) { checkpoints.push(input); },
23
+ conversationPreparation: { async context() { return { result: { provider } }; } },
24
+ namespace: (sessionId) => sessionId,
25
+ turnOutcomes
26
+ });
27
+ const state = commands.observeCodexConversation("session", "thread", provider);
28
+ const startTurn = () => emit({ method: "turn/started", params: { threadId: "thread", turn: { id: "turn-1", status: "inProgress" } } });
29
+ return { checkpoints, commands, completed, startTurn, state };
30
+ }
31
+
32
+ test("a temporary turn the person stops reaches its checkpoint as user cancelled", async () => {
33
+ const f = persistentConversationFixture();
34
+ f.startTurn();
35
+ await f.commands.stopCodexAppServerConversation("session", { conversationId: "thread", persistent: true });
36
+ assert.equal(f.checkpoints.length, 1);
37
+ assert.equal(f.checkpoints[0].turnOutcome, "user_cancelled");
38
+ assert.equal(f.checkpoints[0].status, "interrupted");
39
+ // The outcome belongs to the stopped turn only.
40
+ f.startTurn();
41
+ f.completed("completed");
42
+ assert.equal(f.checkpoints[1].turnOutcome, "");
43
+ });
44
+
45
+ test("closing temporary conversations leaves the turn outcome to its status", async () => {
46
+ const f = persistentConversationFixture();
47
+ f.startTurn();
48
+ await f.commands.closeCodexAppServerConversations("session");
49
+ assert.equal(f.checkpoints.length, 1);
50
+ assert.equal(f.checkpoints[0].turnOutcome, "");
51
+ });
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import test from "node:test";
7
7
  import { prepareCodexModelCatalog } from "../src/server/conversation/codexConfiguration.js";
8
- import { createCodexAppServerModelCatalogCache, codexAppServerProviderConnectionGeneration } from "../src/server/conversation/codexProvider.js";
8
+ import { createCodexAppServerModelCatalogCache, codexAppServerProviderConnectionGeneration } from "../src/server/conversation/codexModelCatalog.js";
9
9
  import { createCodexAppServerProviderOwner } from "../src/server/conversation/codexProviderOwner.js";
10
10
 
11
11
  async function fixture(t, source) {
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import {
7
+ codexProviderCatalogPath,
8
+ codexProviderFileConfiguration,
9
+ copyCodexProviderHome
10
+ } from "../src/server/conversation/codexConfiguration.js";
11
+
12
+ const provider = {
13
+ id: "fixture",
14
+ label: "Fixture",
15
+ baseUrl: "https://provider.invalid/v1",
16
+ webSearch: false,
17
+ models: [{ id: "fixture-model", defaultThinking: "medium" }]
18
+ };
19
+
20
+ async function homes(t) {
21
+ const root = await mkdtemp(path.join(os.tmpdir(), "codex-provider-home-"));
22
+ t.after(() => rm(root, { force: true, recursive: true }));
23
+ const sourceHome = path.join(root, "source");
24
+ const targetHome = path.join(root, "target");
25
+ await Promise.all([mkdir(sourceHome), mkdir(targetHome)]);
26
+ return { sourceHome, targetHome };
27
+ }
28
+
29
+ test("a copied provider home names its own catalogue", async t => {
30
+ const { sourceHome, targetHome } = await homes(t);
31
+ await writeFile(path.join(sourceHome, "config.toml"), codexProviderFileConfiguration(provider, "secret", sourceHome));
32
+ await writeFile(codexProviderCatalogPath(sourceHome), "{\"models\":[]}");
33
+
34
+ await copyCodexProviderHome(sourceHome, targetHome);
35
+
36
+ assert.equal(
37
+ await readFile(path.join(targetHome, "config.toml"), "utf8"),
38
+ codexProviderFileConfiguration(provider, "secret", targetHome)
39
+ );
40
+ assert.equal(await readFile(codexProviderCatalogPath(targetHome), "utf8"), "{\"models\":[]}");
41
+ });
42
+
43
+ test("a provider config that does not name its own catalogue is refused, not copied", async t => {
44
+ const { sourceHome, targetHome } = await homes(t);
45
+ const elsewhere = path.join(path.dirname(sourceHome), "elsewhere");
46
+ await writeFile(path.join(sourceHome, "config.toml"), codexProviderFileConfiguration(provider, "secret", elsewhere));
47
+ await writeFile(codexProviderCatalogPath(sourceHome), "{}");
48
+
49
+ await assert.rejects(copyCodexProviderHome(sourceHome, targetHome), /does not name its own model catalogue/);
50
+ await assert.rejects(readFile(path.join(targetHome, "config.toml")), { code: "ENOENT" });
51
+ });
@@ -0,0 +1,71 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createCodexProviderSelection } from "../src/server/conversation/codexProviderSelection.js";
4
+
5
+ function fixture() {
6
+ const calls = { contexts: [], ensured: [], remembered: [], acquired: [] };
7
+ const provider = {
8
+ ensureRuntime: () => "runtime"
9
+ };
10
+ const providerSessions = {
11
+ managed: new Map(),
12
+ owner: {
13
+ providers: new Map(),
14
+ async ensureSession(connection, threadId) {
15
+ calls.ensured.push({ sessionId: connection.sessionId, threadId });
16
+ return provider;
17
+ },
18
+ async acquireRuntime(input) {
19
+ calls.acquired.push(input.providerKey);
20
+ return input.operation();
21
+ }
22
+ },
23
+ context(sessionId, providerOptions) {
24
+ calls.contexts.push(sessionId);
25
+ return {
26
+ sessionId,
27
+ providerOptions,
28
+ providerKey: `key:${sessionId}`,
29
+ runOwner: {
30
+ runtimeLifecycle: {
31
+ rememberManagedSession: (providerKey, record) => calls.remembered.push({ providerKey, ...record })
32
+ }
33
+ }
34
+ };
35
+ }
36
+ };
37
+ const selection = createCodexProviderSelection({
38
+ providerSessions,
39
+ turnState: () => ({ active: false }),
40
+ helperLifecycle: null,
41
+ helperOwnershipError: null,
42
+ modelCatalogCacheMs: 1,
43
+ errorPrefix: ""
44
+ });
45
+ return { calls, selection };
46
+ }
47
+
48
+ test("session providers and managed records use the normalized session id", async () => {
49
+ const { calls, selection } = fixture();
50
+ await selection.ensureSessionProvider(" s1 ", { mode: "a" }, "thread-1");
51
+ selection.rememberManagedSession(" s1 ", { mode: "a" }, { executionRoot: "/root", workdir: "/work", threadId: "thread-1" });
52
+
53
+ assert.deepEqual(calls.ensured, [{ sessionId: "s1", threadId: "thread-1" }]);
54
+ assert.deepEqual(calls.remembered, [{
55
+ providerKey: "key:s1",
56
+ providerOptions: { mode: "a" },
57
+ sessionId: "s1",
58
+ executionRoot: "/root",
59
+ threadId: "thread-1",
60
+ workdir: "/work"
61
+ }]);
62
+ assert.equal(selection.codexAppServerSessionObserverOptions(" s1 ", {}).providerKey, "key:s1");
63
+ });
64
+
65
+ test("the visible terminal runtime acquires the provider key of the normalized session", async () => {
66
+ const { calls, selection } = fixture();
67
+ await selection.codexAppServerRuntimeForVisibleTerminal(" s1 ", {}, { workdir: "" }, {}, async () => ({}));
68
+ assert.deepEqual(calls.ensured, [{ sessionId: "s1", threadId: undefined }]);
69
+ assert.deepEqual(calls.acquired, ["key:s1"]);
70
+ assert.ok(calls.contexts.every(sessionId => sessionId === "s1"));
71
+ });
@@ -7,8 +7,10 @@ import test from "node:test";
7
7
  import { createMemoryConversationStorage } from "../src/server/conversation/memoryStorage.js";
8
8
  import { createConversationTranscript } from "../src/server/conversation/transcript.js";
9
9
  import { createConversationStreams } from "../src/server/conversation/streams.js";
10
- import { sendCodexAppServerPrompt, CodexAppServerAgentProvider } from "../src/server/conversation/codexProvider.js";
10
+ import { sendCodexAppServerPrompt, CodexAppServerAgentProvider, codexAppServerDeliveredTurn } from "../src/server/conversation/codexProvider.js";
11
11
  import { createCodexHelperThreadLedgerOwner } from "../src/server/conversation/codexHelperThreadLedger.js";
12
+ import { createCodexSessionReconciliation } from "../src/server/conversation/codexSessionReconciliation.js";
13
+ import { createCodexSessionCleanup } from "../src/server/conversation/codexSessionCleanup.js";
12
14
  import {
13
15
  waitForCodexAppServerTurn,
14
16
  createCodexAppServerRunOwner,
@@ -432,6 +434,120 @@ test("native phase changes require the current exact turn and retain its active
432
434
  assert.equal(f.writes.length, 1);
433
435
  });
434
436
 
437
+ test("a settling write for a predecessor turn cannot end the active successor run", async () => {
438
+ for (const state of ["active", "finalizing", "starting"]) {
439
+ const f = runFixture({ state, providerTurnId: "successor" });
440
+ const owner = createCodexAppServerRunOwner();
441
+ const successor = f.read();
442
+ for (const runState of [CODEX_APP_SERVER_RUN_STATE.COMPLETED, CODEX_APP_SERVER_RUN_STATE.FAILED,
443
+ CODEX_APP_SERVER_RUN_STATE.INTERRUPTED]) {
444
+ const result = await owner.writeAgentRun(f.runtime, "session-1", { runState, status: runState,
445
+ threadId: "thread-1", turnId: "turn-1", error: "Predecessor outcome." });
446
+ assert.equal(result.wrote, false);
447
+ assert.equal(result.stale.currentTurnId, "successor");
448
+ assert.equal(result.stale.patchTurnId, "turn-1");
449
+ }
450
+ assert.deepEqual(f.read(), successor);
451
+ assert.deepEqual(f.writes, []);
452
+ const settled = await owner.writeAgentRun(f.runtime, "session-1", { runState: CODEX_APP_SERVER_RUN_STATE.COMPLETED,
453
+ status: "completed", threadId: "thread-1", turnId: "successor" });
454
+ assert.equal(settled.wrote, true, "The successor's own outcome still settles it");
455
+ }
456
+ });
457
+
458
+ test("startup unsubscribe skips session records without an identity instead of inventing one", async () => {
459
+ const prepared = [];
460
+ const reconciliation = createCodexSessionReconciliation({
461
+ providerSessions: { owner: { async unsubscribeSessionThread(_key, sessionId) {
462
+ return { ok: true, sessionId, status: "unsubscribed" };
463
+ } } },
464
+ namespace: sessionId => `project:${sessionId}`,
465
+ debugLog() {}, debugError: error => error
466
+ });
467
+ const result = await reconciliation.unsubscribeSessions([{ title: "no identity" }, { id: "session-1" },
468
+ "session-1", { sessionId: "session-2" }], {
469
+ enabled: true,
470
+ async session(sessionId) { prepared.push(sessionId); return { threadId: `thread-${sessionId}` }; }
471
+ });
472
+ assert.deepEqual(prepared, ["session-1", "session-2"]);
473
+ assert.equal(JSON.stringify(result).includes("[object Object]"), false);
474
+ });
475
+
476
+ test("session cleanup owns its single-flight closures and restricted record without consumer state", async () => {
477
+ const sessionClosures = new Map();
478
+ const releases = [];
479
+ const held = [];
480
+ const cleanupOwner = createCodexSessionCleanup({
481
+ namespace: id => `project:${id}`,
482
+ runOwner: {},
483
+ sessionClosures,
484
+ providerSessions: { owner: {
485
+ async drainThreadEnvironmentTasks() {},
486
+ async releaseSession(sessionKey) {
487
+ const gate = Promise.withResolvers();
488
+ held.push(gate);
489
+ await gate.promise;
490
+ releases.push(sessionKey);
491
+ return { exitFailure: null };
492
+ }
493
+ } },
494
+ helperLifecycle: { assertRestored() {}, async restoreThreads() {}, assertRetired() {}, async retireAll() {} },
495
+ journal: { clearSessionRecoveryTimers() {} },
496
+ conversations: { async closeCodexAppServerConversations() {} },
497
+ debugLog() {}, debugError: error => error
498
+ });
499
+ const reads = [];
500
+ const publicChecks = [];
501
+ const cleanup = (label, restricted = false) => ({
502
+ sessionKey: "project:session-1",
503
+ restricted,
504
+ async assertPublicAccess() { publicChecks.push(label); },
505
+ async read() {
506
+ reads.push(label);
507
+ return { helpers: {}, projectRuntimeRoot: "", session: {}, async complete() {} };
508
+ }
509
+ });
510
+ const flush = () => new Promise(resolve => setImmediate(resolve));
511
+
512
+ const first = cleanupOwner.closeSession("session-1", cleanup("public"));
513
+ const joined = cleanupOwner.closeSession("session-1", cleanup("joined"));
514
+ assert.equal(sessionClosures.size, 1, "One closure per session key");
515
+ const restricted = cleanupOwner.closeSession("session-1", cleanup("restricted", true));
516
+ await flush();
517
+ held.shift().resolve();
518
+ await Promise.all([first, joined]);
519
+ await flush();
520
+ assert.deepEqual(reads, ["public", "restricted"], "A restricted close waits for the public one, then runs its own");
521
+ const publicAfterRestricted = cleanupOwner.closeSession("session-1", cleanup("late-public"));
522
+ await flush();
523
+ assert.deepEqual(publicChecks, ["late-public"], "Joining a restricted close proves public access first");
524
+ held.shift().resolve();
525
+ await Promise.all([restricted, publicAfterRestricted]);
526
+ assert.deepEqual(reads, ["public", "restricted"]);
527
+ assert.deepEqual(releases, ["project:session-1", "project:session-1"]);
528
+ assert.equal(sessionClosures.size, 0);
529
+ });
530
+
531
+ test("the run owner's session closures are the ones its runtime lifecycle drains", async () => {
532
+ const draining = Promise.withResolvers();
533
+ const owner = createCodexAppServerRunOwner({ providerSessions: { owner: { drainThreadEnvironmentTasks: () => draining.promise } } });
534
+ const closing = owner.closeSession("session-1", { sessionKey: "project:session-1",
535
+ async read() { throw new Error("Controlled preparation stop"); } });
536
+ assert.ok(owner.runtimeLifecycle.sessionClosures.get("project:session-1") instanceof Promise);
537
+ draining.resolve();
538
+ await assert.rejects(closing, /Controlled preparation stop/);
539
+ assert.equal(owner.runtimeLifecycle.sessionClosures.size, 0);
540
+ });
541
+
542
+ test("the run owner exposes only operations its consumers use, not facet recovery state", () => {
543
+ const owner = createCodexAppServerRunOwner();
544
+ for (const internal of ["completedTurns", "automaticHookThreads", "conversationTurnStarts", "reasoningPersistQueues",
545
+ "threadReconciliations", "reasoningTurns", "finalAssistantResults", "mirroredTerminalItems", "clearActiveTimer",
546
+ "clearFinalizingTimer", "adoptSuccessorTurn", "markTurnActive", "markTurnFinalizing", "checkManagedConnection"]) {
547
+ assert.equal(Object.hasOwn(owner, internal), false, internal);
548
+ }
549
+ });
550
+
435
551
  test("original pending ownership and terminal outer identity are preserved without weakening run-state errors", () => {
436
552
  const run = { inputSource: "chat", pendingUserMessageClientIds: [0, false, " first ", "second"] };
437
553
  assert.deepEqual(codexAppServerPendingUserMessageOwnership(run, "first"), { clientId: "first", inputSource: "chat" });
@@ -978,9 +1094,9 @@ test("original settlement clears the same recovery timers and skips stale idle c
978
1094
  t.after(() => timers.forEach(clearTimeout));
979
1095
  owner.activeTimers.set(`project:${sessionId}`, timer());
980
1096
  owner.activeTimers.set("project:other", timer());
981
- owner.finalizingTimers.set(owner.resultFinalizationKey(sessionId, threadId, turnId), timer());
982
- owner.finalizingTimers.set(owner.resultFinalizationKey(sessionId, threadId, "older"), timer());
983
- owner.finalizingTimers.set(owner.resultFinalizationKey("other", threadId, turnId), timer());
1097
+ owner.finalizingTimers.set(owner.resultFinalizationKey(sessionId, threadId, turnId), { timer: timer() });
1098
+ owner.finalizingTimers.set(owner.resultFinalizationKey(sessionId, threadId, "older"), { timer: timer() });
1099
+ owner.finalizingTimers.set(owner.resultFinalizationKey("other", threadId, turnId), { timer: timer() });
984
1100
  f.run = { ...f.run, state: "interrupted", providerStatus: "interrupted" };
985
1101
  const result = await owner.markTurnIdle(sessionId, { threadId, turnId, status: "completed" });
986
1102
  assert.equal(result.processed, false);
@@ -2373,6 +2489,72 @@ for (const [status, cause, explicit, expected] of [
2373
2489
  });
2374
2490
  }
2375
2491
 
2492
+ test("a native stop outcome rereads the run after its history read and leaves an adopted successor running", async () => {
2493
+ const notices = [];
2494
+ const checkpoints = [];
2495
+ const f = await receiptFixture({
2496
+ outcomeNotice(_runtime, sessionId, threadId, turnId, outcome) { notices.push({ sessionId, threadId, turnId, outcome }); },
2497
+ checkpoint(sessionId, value) { checkpoints.push({ sessionId, ...value }); }
2498
+ });
2499
+ f.history = [turn(f.turnId, "interrupted")];
2500
+ const reading = Promise.withResolvers(); const release = Promise.withResolvers();
2501
+ const list = f.provider.listThreadTurns;
2502
+ f.provider.listThreadTurns = async (...args) => {
2503
+ const snapshot = await list(...args);
2504
+ reading.resolve(); await release.promise;
2505
+ return snapshot;
2506
+ };
2507
+ try {
2508
+ const pending = f.owner.stopTurnWithProviderFailure(f.sessionId, f.threadId, f.turnId, {
2509
+ status: "interrupted", provider: f.provider, verifyInactive: false
2510
+ });
2511
+ await reading.promise;
2512
+ f.run = { ...f.run, providerTurnId: "successor", state: "active", providerStatus: "inProgress" };
2513
+ const successorRun = structuredClone(f.run);
2514
+ release.resolve();
2515
+ assert.equal((await pending).reason, "stale_turn_state");
2516
+ assert.deepEqual(f.run, successorRun);
2517
+ assert.deepEqual(notices, []);
2518
+ assert.deepEqual(checkpoints, []);
2519
+ } finally {
2520
+ f.owner.clearSessionRecoveryTimers(f.sessionId);
2521
+ }
2522
+ });
2523
+
2524
+ test("a delivered native turn reads its identity, status and provider error from the one sendTurn shape", async () => {
2525
+ const provider = new CodexAppServerAgentProvider({}, { credentials: { assertCurrent() {} } });
2526
+ const responses = [
2527
+ { turn: { id: "turn-failed", status: "failed", error: { message: "You've hit your usage limit." } } },
2528
+ { turnId: "turn-flat", status: "failed", error: "Flat provider failure." },
2529
+ { turn: { id: "turn-running", status: "inProgress" } }
2530
+ ];
2531
+ provider.client = { isOpen: () => true, close() {}, async request(method) {
2532
+ assert.equal(method, "turn/start");
2533
+ return responses.shift();
2534
+ } };
2535
+ try {
2536
+ const delivered = [];
2537
+ for (let index = 0; index < 3; index += 1) {
2538
+ delivered.push(codexAppServerDeliveredTurn(await sendCodexAppServerPrompt({ provider, prompt: "Go", threadId: "thread-1" })));
2539
+ }
2540
+ assert.deepEqual(delivered, [
2541
+ { turnId: "turn-failed", status: "failed", error: "You've hit your usage limit." },
2542
+ { turnId: "turn-flat", status: "failed", error: "Flat provider failure." },
2543
+ { turnId: "turn-running", status: "inProgress", error: "" }
2544
+ ]);
2545
+ } finally {
2546
+ provider.close();
2547
+ }
2548
+ });
2549
+
2550
+ test("a host provider that returns its raw turn unnormalized still delivers its status and error", async () => {
2551
+ const provider = { async sendTurn() {
2552
+ return { id: "t", raw: { status: "failed", error: { message: "boom" } } };
2553
+ } };
2554
+ const delivered = codexAppServerDeliveredTurn(await sendCodexAppServerPrompt({ provider, prompt: "Go", threadId: "thread-1" }));
2555
+ assert.deepEqual(delivered, { turnId: "t", status: "failed", error: "boom" });
2556
+ });
2557
+
2376
2558
  test("user Stop records its exact cause before queued native completion while the interrupt reply is held", async () => {
2377
2559
  const notices = [];
2378
2560
  const f = await receiptFixture({ outcomeNotice(_runtime, sessionId, threadId, turnId, outcome) {
@@ -2595,7 +2777,7 @@ for (const change of ["concurrent", "cleanup", "successor"]) {
2595
2777
  t.mock.timers.tick(20);
2596
2778
  await reading.promise;
2597
2779
  assert.equal(owner.finalizingTimers.size, 1, "Firing does not discard the first post-read clock");
2598
- const expiry = [...owner.finalizingTimers.values()][0].finalizingRecovery;
2780
+ const expiry = [...owner.finalizingTimers.values()][0].recovery;
2599
2781
  assert.ok(expiry instanceof Promise);
2600
2782
  let pending;
2601
2783
  if (change === "concurrent") pending = owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
@@ -2612,6 +2794,40 @@ for (const change of ["concurrent", "cleanup", "successor"]) {
2612
2794
  });
2613
2795
  }
2614
2796
 
2797
+ test("native delivery failure rereads the run after its last history read and cannot settle an adopted successor", async t => {
2798
+ t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-10-07T00:00:00Z") });
2799
+ const f = await settlementFixture();
2800
+ const { sessionId, threadId, turnId } = f;
2801
+ const owner = createCodexAppServerRunOwner({ finalizingGraceMs: 500,
2802
+ createRuntime: async () => ({ store: f.store, getSession: async () => ({ sessionId, agentRuns: [structuredClone(f.run)] }) }),
2803
+ createStore: async () => f.store, acquireProvider: async () => f.provider,
2804
+ checkpoint: async (id, input) => { f.checkpoints.push({ sessionId: id, ...input }); }
2805
+ });
2806
+ t.after(() => owner.clearSessionRecoveryTimers(sessionId));
2807
+ f.run = { ...f.run, state: "finalizing", providerStatus: "completed", finishedAt: new Date().toISOString() };
2808
+ f.history = [turn(turnId, "completed")];
2809
+ t.mock.timers.tick(501);
2810
+ const reading = Promise.withResolvers(); const release = Promise.withResolvers();
2811
+ const list = f.provider.listThreadTurns;
2812
+ let reads = 0;
2813
+ f.provider.listThreadTurns = async (...args) => {
2814
+ const snapshot = await list(...args);
2815
+ reads += 1;
2816
+ if (reads === 2) { reading.resolve(); await release.promise; }
2817
+ return snapshot;
2818
+ };
2819
+ const pending = owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
2820
+ await reading.promise;
2821
+ f.run = { ...f.run, providerTurnId: "successor", state: "active", providerStatus: "inProgress" };
2822
+ const successorRun = structuredClone(f.run);
2823
+ release.resolve();
2824
+ const result = await pending;
2825
+ assert.equal(result.reason, "stale_turn_state");
2826
+ assert.deepEqual(f.run, successorRun, "The predecessor's delivery failure cannot settle the successor");
2827
+ assert.deepEqual(f.checkpoints, []);
2828
+ assert.deepEqual(await f.replies(), []);
2829
+ });
2830
+
2615
2831
 
2616
2832
  test("native after-history grace retries a rejected expiry read without granting another interval", async t => {
2617
2833
  t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-10-07T00:00:00Z") });
@@ -2643,7 +2859,7 @@ test("native after-history grace retries a rejected expiry read without granting
2643
2859
  t.mock.timers.tick(20);
2644
2860
  await failedRead.promise;
2645
2861
  const timer = [...owner.finalizingTimers.values()][0];
2646
- const expiry = timer.finalizingRecovery;
2862
+ const expiry = timer.recovery;
2647
2863
  const rejected = assert.rejects(expiry, /Controlled transient session read failure/);
2648
2864
  release.resolve();
2649
2865
  await rejected;
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { conversationAgentRunEvent, conversationAgentRunRecord, createCodexConversationRunStore, createCodexConversationStore } from "../src/server/conversation/agentRun.js";
3
+ import { conversationAgentRunEvent, conversationAgentRunRecord } from "../src/server/conversation/agentRun.js";
4
+ import { createCodexConversationRunStore, createCodexConversationStore } from "../src/server/conversation/codexConversationStore.js";
4
5
  import { createMemoryConversationStorage } from "../src/server/conversation/memoryStorage.js";
5
6
  import { createReentrantConversationStorage } from "../src/server/conversation/storage.js";
6
7
  import { createConversationTranscript } from "../src/server/conversation/transcript.js";