@jskit-ai/assistant-core 0.1.240 → 0.1.242
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/examples/conversation/package.json +5 -5
- package/package.json +5 -5
- package/src/client/conversation/AssistantTranscript.vue +26 -29
- package/src/client/conversation/LongTextPreviewBlocks.vue +11 -0
- package/src/client/conversation/messageDelivery.js +8 -2
- package/src/client/conversation/useAssistantAttachments.js +1 -1
- package/src/server/conversation/agentRun.js +12 -198
- package/src/server/conversation/attachments.js +6 -5
- package/src/server/conversation/boundedToolLoop.js +160 -0
- package/src/server/conversation/claudeHistory.js +37 -23
- package/src/server/conversation/claudeProcess.js +2 -2
- package/src/server/conversation/claudeTurn.js +41 -38
- package/src/server/conversation/codexAuthPreflight.js +104 -0
- package/src/server/conversation/codexClient.js +11 -16
- package/src/server/conversation/codexConfiguration.js +112 -98
- package/src/server/conversation/codexConversationStore.js +189 -0
- package/src/server/conversation/codexConversations.js +47 -44
- package/src/server/conversation/codexDetachedTurn.js +12 -11
- package/src/server/conversation/codexEvents.js +95 -99
- package/src/server/conversation/codexHelperExecution.js +3 -13
- package/src/server/conversation/codexHelperThreadLedger.js +7 -13
- package/src/server/conversation/codexHelperThreadRecovery.js +2 -16
- package/src/server/conversation/codexHelperThreads.js +5 -6
- package/src/server/conversation/codexHistoryAdapter.js +32 -26
- package/src/server/conversation/codexMessageCommands.js +22 -22
- package/src/server/conversation/codexMessageDelivery.js +12 -7
- package/src/server/conversation/codexModelCatalog.js +97 -0
- package/src/server/conversation/codexNotificationObserver.js +24 -40
- package/src/server/conversation/codexNotificationQueue.js +2 -3
- package/src/server/conversation/codexOutput.js +20 -27
- package/src/server/conversation/codexProcess.js +123 -127
- package/src/server/conversation/codexProcessLeader.js +3 -3
- package/src/server/conversation/codexProvider.js +31 -614
- package/src/server/conversation/codexProviderOwner.js +2 -2
- package/src/server/conversation/codexProviderSelection.js +38 -40
- package/src/server/conversation/codexRenewalCommands.js +13 -10
- package/src/server/conversation/codexRenewalThreads.js +412 -0
- package/src/server/conversation/codexRunJournal.js +24 -42
- package/src/server/conversation/codexRuntimeLifecycle.js +19 -15
- package/src/server/conversation/codexSessionCleanup.js +11 -10
- package/src/server/conversation/codexSessionReconciliation.js +6 -6
- package/src/server/conversation/codexThreadReadiness.js +19 -33
- package/src/server/conversation/codexThreadStatus.js +3 -3
- package/src/server/conversation/codexTurn.js +124 -297
- package/src/server/conversation/codexTurnRecovery.js +49 -28
- package/src/server/conversation/codexTurnState.js +34 -13
- package/src/server/conversation/commandWrapper.js +3 -0
- package/src/server/conversation/deliveryTiming.js +14 -5
- package/src/server/conversation/index.js +1 -0
- package/src/server/conversation/localExecution.js +13 -17
- package/src/server/conversation/normalize.js +8 -3
- package/src/server/conversation/openCodeClient.js +9 -10
- package/src/server/conversation/openCodeProcess.js +9 -5
- package/src/server/conversation/openCodeRuntime.js +10 -11
- package/src/server/conversation/openCodeTurn.js +43 -29
- package/src/server/conversation/providers/claudeDriver.js +8 -16
- package/src/server/conversation/providers/codexDriver.js +25 -41
- package/src/server/conversation/providers/opencodeDriver.js +17 -42
- package/src/server/conversation/providers/scoped.js +48 -4
- package/src/server/conversation/runtime.js +50 -49
- package/src/server/conversation/runtimeStateUpgrade.js +34 -22
- package/src/server/conversation/transcript.js +10 -10
- package/src/server/hosts/codexConfiguration.js +1 -0
- package/src/server/hosts/codexProvider.js +8 -6
- package/src/server/index.js +2 -1
- package/src/server/lib/assistantToolLoop.js +1 -153
- package/src/server/lib/serviceToolCatalog.js +17 -42
- package/src/shared/assistantResource.js +4 -2
- package/src/shared/conversation/attachments.js +4 -0
- package/src/shared/conversation/longTextBlocks.js +54 -19
- package/src/shared/conversation/numberedQuestionSugar.js +17 -10
- package/src/shared/conversation/pagination.js +4 -6
- package/src/shared/conversation/patches.js +1 -4
- package/src/shared/conversation/streaming.js +3 -11
- package/src/shared/conversation/turns.js +11 -0
- package/src/shared/index.js +1 -0
- package/test/assistantBoundedToolLoop.test.js +2 -2
- package/test/assistantResource.test.js +4 -4
- package/test/assistantSettingsResource.test.js +2 -2
- package/test/claudeConversationSession.test.js +11 -1
- package/test/claudeTurn.test.js +35 -7
- package/test/codexClient.test.js +20 -0
- package/test/codexConversationSession.test.js +2 -2
- package/test/codexConversationsCheckpoint.test.js +51 -0
- package/test/codexModelCatalog.test.js +1 -1
- package/test/codexProviderHome.test.js +51 -0
- package/test/codexProviderSelection.test.js +71 -0
- package/test/codexTurn.test.js +222 -6
- package/test/conversationAgentRun.test.js +2 -1
- package/test/conversationAttachments.test.js +27 -2
- package/test/conversationDeliveryTiming.test.js +21 -0
- package/test/conversationNormalize.test.js +11 -0
- package/test/fixtures/nestedListQuestionMessage.md +37 -0
- package/test/frameworkActions.test.js +2 -2
- package/test/longTextBlocks.test.js +102 -0
- package/test/messageDelivery.test.js +7 -1
- package/test/openCodeClient.test.js +20 -0
- package/test/openCodeRuntime.test.js +1 -1
- package/test/openCodeTurn.test.js +90 -0
- package/test/opencodeConversationSession.test.js +1 -1
- 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/
|
|
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);
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -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", "
|
|
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(
|
|
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." }];
|
|
@@ -2692,6 +2712,14 @@ test("live Claude history parses only frames appended since the previous read",
|
|
|
2692
2712
|
parsed = 0;
|
|
2693
2713
|
assert.deepEqual((await readClaudeHistory(options)).userIds, ["u1", "u2", "u3"]);
|
|
2694
2714
|
assert.equal(parsed, 0);
|
|
2715
|
+
// An unfinished frame longer than one look-back block is still left for the next read.
|
|
2716
|
+
const long = JSON.stringify({ type: "user", uuid: "u4", message: { content: "x".repeat(200 * 1024) } });
|
|
2717
|
+
await appendFile(file, long.slice(0, 150 * 1024));
|
|
2718
|
+
assert.deepEqual((await readClaudeHistory(options)).userIds, ["u1", "u2", "u3"]);
|
|
2719
|
+
assert.equal(parsed, 0);
|
|
2720
|
+
await appendFile(file, long.slice(150 * 1024) + "\n");
|
|
2721
|
+
assert.deepEqual((await readClaudeHistory(options)).userIds, ["u1", "u2", "u3", "u4"]);
|
|
2722
|
+
assert.equal(parsed, 1);
|
|
2695
2723
|
// Replacement and truncation discard the parsed prefix.
|
|
2696
2724
|
await write(`${file}.next`, [user("r1"), answer("ra", "r1")].join("\n") + "\n");
|
|
2697
2725
|
await move(`${file}.next`, file);
|
package/test/codexClient.test.js
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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
|
+
});
|
package/test/codexTurn.test.js
CHANGED
|
@@ -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].
|
|
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.
|
|
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
|
|
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";
|