@jskit-ai/assistant-core 0.1.209 → 0.1.211
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/messageDelivery.js +7 -1
- package/src/server/conversation/codexHistoryAdapter.js +55 -2
- package/src/server/conversation/codexMessageCommands.js +2 -0
- package/src/server/conversation/codexMessageDelivery.js +7 -2
- package/src/server/conversation/codexNotificationObserver.js +15 -2
- package/src/server/conversation/codexProvider.js +64 -7
- package/src/server/conversation/codexTurn.js +16 -2
- package/src/server/conversation/codexTurnRecovery.js +104 -18
- package/src/server/conversation/continuity.js +3 -0
- package/src/server/conversation/openCodeRuntime.js +4 -0
- package/src/server/conversation/providers/codexDriver.js +106 -36
- package/src/server/conversation/providers/opencodeDriver.js +94 -37
- package/src/server/conversation/runtime.js +67 -7
- package/src/server/conversation/tools.js +5 -2
- package/src/server/conversation/transcript.js +2 -2
- package/src/server/hosts/openCodeProcess.js +1 -0
- package/test/claudeConversationSession.test.js +49 -0
- package/test/codexConversationSession.test.js +346 -0
- package/test/codexHistoryAdapter.test.js +103 -0
- package/test/codexTurn.test.js +542 -0
- package/test/conversationSession.test.js +19 -0
- package/test/conversationTools.test.js +298 -0
- package/test/messageDelivery.test.js +18 -0
- package/test/openCodeRuntime.test.js +29 -0
- package/test/opencodeConversationSession.test.js +354 -0
package/test/codexTurn.test.js
CHANGED
|
@@ -2357,3 +2357,545 @@ test("user Stop records its exact cause before queued native completion while th
|
|
|
2357
2357
|
}
|
|
2358
2358
|
assert.equal(notices.length, 1);
|
|
2359
2359
|
});
|
|
2360
|
+
|
|
2361
|
+
test("native finalizing grace rejects invalid owner configuration", () => {
|
|
2362
|
+
for (const finalizingGraceMs of [0, -1, 0.5, NaN, Infinity, "500"]) {
|
|
2363
|
+
assert.throws(() => createCodexAppServerRunOwner({ finalizingGraceMs }), /Invalid Codex finalizing grace/);
|
|
2364
|
+
}
|
|
2365
|
+
});
|
|
2366
|
+
|
|
2367
|
+
for (const policy of ["short-late-final", "short-expired", "default-late-final"]) {
|
|
2368
|
+
test(`native finalizing grace preserves exact recovery for ${policy}`, async t => {
|
|
2369
|
+
t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-10-07T00:00:00Z") });
|
|
2370
|
+
const f = await settlementFixture();
|
|
2371
|
+
const { sessionId, threadId, turnId } = f;
|
|
2372
|
+
const owner = createCodexAppServerRunOwner({
|
|
2373
|
+
...(policy === "default-late-final" ? {} : { finalizingGraceMs: 500 }),
|
|
2374
|
+
createRuntime: async () => ({ store: f.store, getSession: async () => ({ sessionId, agentRuns: [structuredClone(f.run)] }) }),
|
|
2375
|
+
createStore: async () => f.store,
|
|
2376
|
+
acquireProvider: async () => f.provider,
|
|
2377
|
+
checkpoint: async (id, input) => { f.checkpoints.push({ sessionId: id, ...input }); }
|
|
2378
|
+
});
|
|
2379
|
+
t.after(() => owner.clearSessionRecoveryTimers(sessionId));
|
|
2380
|
+
f.run = { ...f.run, state: "finalizing", providerStatus: "completed", finishedAt: new Date().toISOString() };
|
|
2381
|
+
f.history = [turn(turnId, "completed")];
|
|
2382
|
+
t.mock.timers.tick(499);
|
|
2383
|
+
assert.equal((await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false })).processed, false);
|
|
2384
|
+
assert.equal(f.run.state, "finalizing");
|
|
2385
|
+
assert.equal(owner.finalizingTimers.size, 1);
|
|
2386
|
+
assert.deepEqual(await f.replies(), []);
|
|
2387
|
+
if (policy === "short-late-final") {
|
|
2388
|
+
f.history = [turn(turnId, "completed", "Exact late reply.")];
|
|
2389
|
+
assert.equal((await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false })).processed, true);
|
|
2390
|
+
} else {
|
|
2391
|
+
t.mock.timers.tick(1);
|
|
2392
|
+
await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
|
|
2393
|
+
if (policy === "short-expired") {
|
|
2394
|
+
assert.deepEqual(f.checkpoints, [{ sessionId, threadId, turnId,
|
|
2395
|
+
status: "completed", turnOutcome: "response_delivery_failure" }]);
|
|
2396
|
+
assert.equal(f.run.state, "completed");
|
|
2397
|
+
assert.deepEqual(await f.replies(), []);
|
|
2398
|
+
f.history = [turn(turnId, "completed", "Too late to revive this waiter.")];
|
|
2399
|
+
assert.equal((await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false })).processed, false);
|
|
2400
|
+
assert.deepEqual(await f.replies(), []);
|
|
2401
|
+
} else {
|
|
2402
|
+
assert.equal(f.run.state, "finalizing");
|
|
2403
|
+
assert.equal(owner.finalizingTimers.size, 1);
|
|
2404
|
+
t.mock.timers.tick(501);
|
|
2405
|
+
f.history = [turn(turnId, "completed", "Exact late reply.")];
|
|
2406
|
+
assert.equal((await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false })).processed, true);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
assert.equal(f.run.providerThreadId, threadId);
|
|
2410
|
+
assert.equal(f.run.providerTurnId, turnId);
|
|
2411
|
+
assert.equal(owner.finalizingTimers.size, 0);
|
|
2412
|
+
assert.deepEqual((await f.replies()).map(row => row.assistant.text),
|
|
2413
|
+
policy === "short-expired" ? [] : ["Exact late reply."]);
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
|
|
2418
|
+
test("native after-history grace rejects nonboolean owner policy", () => {
|
|
2419
|
+
for (const finalizingGraceAfterHistoryRead of [0, 1, "true", null]) {
|
|
2420
|
+
assert.throws(() => createCodexAppServerRunOwner({ finalizingGraceAfterHistoryRead }), /Invalid Codex finalizing grace clock/);
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
|
|
2424
|
+
for (const policy of ["late-final", "expiry", "read-error", "default-expiry"]) {
|
|
2425
|
+
test(`native after-history grace starts after the first held read for ${policy}`, async t => {
|
|
2426
|
+
t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-10-07T00:00:00Z") });
|
|
2427
|
+
const f = await settlementFixture();
|
|
2428
|
+
const { sessionId, threadId, turnId } = f;
|
|
2429
|
+
const owner = createCodexAppServerRunOwner({
|
|
2430
|
+
...(policy === "default-expiry" ? {} : { finalizingGraceMs: 500, finalizingGraceAfterHistoryRead: true }),
|
|
2431
|
+
createRuntime: async () => ({ store: f.store, getSession: async () => ({ sessionId, agentRuns: [structuredClone(f.run)] }) }),
|
|
2432
|
+
createStore: async () => f.store,
|
|
2433
|
+
acquireProvider: async () => f.provider,
|
|
2434
|
+
checkpoint: async (id, input) => { f.checkpoints.push({ sessionId: id, ...input }); }
|
|
2435
|
+
});
|
|
2436
|
+
t.after(() => owner.clearSessionRecoveryTimers(sessionId));
|
|
2437
|
+
const completedAt = new Date().toISOString();
|
|
2438
|
+
f.run = { ...f.run, state: "finalizing", providerStatus: "completed", finishedAt: completedAt };
|
|
2439
|
+
f.history = [turn(turnId, "completed")];
|
|
2440
|
+
const reading = Promise.withResolvers();
|
|
2441
|
+
const release = Promise.withResolvers();
|
|
2442
|
+
const list = f.provider.listThreadTurns;
|
|
2443
|
+
let first = true;
|
|
2444
|
+
f.provider.listThreadTurns = async (...args) => {
|
|
2445
|
+
const snapshot = await list(...args);
|
|
2446
|
+
if (first) {
|
|
2447
|
+
first = false; reading.resolve(); await release.promise;
|
|
2448
|
+
if (policy === "read-error") throw new Error("Controlled first history read failure");
|
|
2449
|
+
}
|
|
2450
|
+
return snapshot;
|
|
2451
|
+
};
|
|
2452
|
+
const pending = owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
|
|
2453
|
+
await reading.promise;
|
|
2454
|
+
t.mock.timers.tick(20_000);
|
|
2455
|
+
release.resolve();
|
|
2456
|
+
await pending;
|
|
2457
|
+
if (policy === "default-expiry") {
|
|
2458
|
+
assert.equal(f.run.state, "completed", "Main/default retains the completion-based clock");
|
|
2459
|
+
assert.equal(f.checkpoints.length, 1);
|
|
2460
|
+
assert.equal(f.checkpoints[0].turnOutcome, "response_delivery_failure");
|
|
2461
|
+
assert.equal(owner.finalizingTimers.size, 0);
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
assert.equal(f.run.state, "finalizing", "The held read must not consume Colleague's late-final grace");
|
|
2465
|
+
assert.equal(f.run.finishedAt, completedAt, "Do not rewrite native completion to start a local timer");
|
|
2466
|
+
assert.equal(owner.finalizingTimers.size, 1);
|
|
2467
|
+
assert.deepEqual(f.checkpoints, []);
|
|
2468
|
+
t.mock.timers.tick(499);
|
|
2469
|
+
await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
|
|
2470
|
+
assert.equal(f.run.state, "finalizing", "A repeated read must retain the first deadline");
|
|
2471
|
+
assert.deepEqual(f.checkpoints, []);
|
|
2472
|
+
if (policy === "late-final" || policy === "read-error") {
|
|
2473
|
+
f.history = [turn(turnId, "completed", "Late after the held history read.")];
|
|
2474
|
+
assert.equal((await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false })).processed, true);
|
|
2475
|
+
assert.deepEqual((await f.replies()).map(row => row.assistant.text), ["Late after the held history read."]);
|
|
2476
|
+
} else {
|
|
2477
|
+
t.mock.timers.tick(1);
|
|
2478
|
+
await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
|
|
2479
|
+
assert.equal(f.run.state, "completed");
|
|
2480
|
+
assert.equal(f.checkpoints.length, 1);
|
|
2481
|
+
assert.equal(f.checkpoints[0].turnOutcome, "response_delivery_failure");
|
|
2482
|
+
f.history = [turn(turnId, "completed", "Too late to revive the old waiter.")];
|
|
2483
|
+
assert.equal((await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false })).processed, false);
|
|
2484
|
+
assert.deepEqual(await f.replies(), []);
|
|
2485
|
+
}
|
|
2486
|
+
assert.equal(owner.finalizingTimers.size, 0);
|
|
2487
|
+
assert.equal(f.checkpoints.length, 1);
|
|
2488
|
+
assert.equal(f.run.providerThreadId, threadId);
|
|
2489
|
+
assert.equal(f.run.providerTurnId, turnId);
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
for (const change of ["stop", "successor", "goal"]) {
|
|
2494
|
+
test(`native after-history grace does not arm after ${change} during the first read`, async t => {
|
|
2495
|
+
const f = await settlementFixture();
|
|
2496
|
+
const { sessionId, threadId, turnId } = f;
|
|
2497
|
+
const owner = createCodexAppServerRunOwner({ finalizingGraceMs: 500, finalizingGraceAfterHistoryRead: true,
|
|
2498
|
+
createRuntime: async () => ({ store: f.store, getSession: async () => ({ sessionId, agentRuns: [structuredClone(f.run)] }) }),
|
|
2499
|
+
createStore: async () => f.store, acquireProvider: async () => f.provider,
|
|
2500
|
+
checkpoint: async (id, input) => { f.checkpoints.push({ sessionId: id, ...input }); }
|
|
2501
|
+
});
|
|
2502
|
+
t.after(() => owner.clearSessionRecoveryTimers(sessionId));
|
|
2503
|
+
f.run = { ...f.run, state: "finalizing", providerStatus: "completed", finishedAt: new Date().toISOString() };
|
|
2504
|
+
f.history = [turn(turnId, "completed")];
|
|
2505
|
+
const reading = Promise.withResolvers(); const release = Promise.withResolvers();
|
|
2506
|
+
const list = f.provider.listThreadTurns;
|
|
2507
|
+
f.provider.listThreadTurns = async (...args) => { const snapshot = await list(...args); reading.resolve(); await release.promise; return snapshot; };
|
|
2508
|
+
const pending = owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
|
|
2509
|
+
await reading.promise;
|
|
2510
|
+
if (change === "stop") f.run = { ...f.run, state: "interrupted", providerStatus: "interrupted" };
|
|
2511
|
+
if (change === "successor") f.run = { ...f.run, state: "active", providerStatus: "inProgress", providerTurnId: "successor" };
|
|
2512
|
+
if (change === "goal") f.run = { ...f.run, providerGoalStatus: "active", providerGoalThreadId: threadId };
|
|
2513
|
+
const protectedRun = structuredClone(f.run);
|
|
2514
|
+
release.resolve(); await pending;
|
|
2515
|
+
assert.equal(owner.finalizingTimers.size, 0, "The old read cannot arm recovery for obsolete or goal-owned work");
|
|
2516
|
+
assert.deepEqual(f.run, protectedRun);
|
|
2517
|
+
assert.deepEqual(f.checkpoints, []);
|
|
2518
|
+
assert.deepEqual(await f.replies(), []);
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
|
|
2523
|
+
for (const change of ["concurrent", "cleanup", "successor"]) {
|
|
2524
|
+
test(`native after-history grace keeps the fired expiry while its read is held for ${change}`, async t => {
|
|
2525
|
+
t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-10-07T00:00:00Z") });
|
|
2526
|
+
const f = await settlementFixture();
|
|
2527
|
+
const { sessionId, threadId, turnId } = f;
|
|
2528
|
+
const owner = createCodexAppServerRunOwner({ finalizingGraceMs: 20, finalizingGraceAfterHistoryRead: true,
|
|
2529
|
+
createRuntime: async () => ({ store: f.store, getSession: async () => ({ sessionId, agentRuns: [structuredClone(f.run)] }) }),
|
|
2530
|
+
createStore: async () => f.store, acquireProvider: async () => f.provider,
|
|
2531
|
+
checkpoint: async (id, input) => { f.checkpoints.push({ sessionId: id, ...input }); }
|
|
2532
|
+
});
|
|
2533
|
+
t.after(() => owner.clearSessionRecoveryTimers(sessionId));
|
|
2534
|
+
f.run = { ...f.run, state: "finalizing", providerStatus: "completed", finishedAt: new Date().toISOString() };
|
|
2535
|
+
f.history = [turn(turnId, "completed")];
|
|
2536
|
+
await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
|
|
2537
|
+
const reading = Promise.withResolvers(); const release = Promise.withResolvers();
|
|
2538
|
+
const list = f.provider.listThreadTurns;
|
|
2539
|
+
f.provider.listThreadTurns = async (...args) => { const snapshot = await list(...args); reading.resolve(); await release.promise; return snapshot; };
|
|
2540
|
+
t.mock.timers.tick(20);
|
|
2541
|
+
await reading.promise;
|
|
2542
|
+
assert.equal(owner.finalizingTimers.size, 1, "Firing does not discard the first post-read clock");
|
|
2543
|
+
const expiry = [...owner.finalizingTimers.values()][0].finalizingRecovery;
|
|
2544
|
+
assert.ok(expiry instanceof Promise);
|
|
2545
|
+
let pending;
|
|
2546
|
+
if (change === "concurrent") pending = owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
|
|
2547
|
+
if (change === "cleanup") owner.clearSessionRecoveryTimers(sessionId);
|
|
2548
|
+
if (change === "successor") f.run = { ...f.run, providerTurnId: "successor", state: "active", providerStatus: "inProgress" };
|
|
2549
|
+
t.mock.timers.tick(1000);
|
|
2550
|
+
release.resolve();
|
|
2551
|
+
await expiry;
|
|
2552
|
+
await pending;
|
|
2553
|
+
assert.equal(owner.finalizingTimers.size, 0, "A held expiry read cannot grant another interval or leave a predecessor timer");
|
|
2554
|
+
assert.deepEqual(await f.replies(), []);
|
|
2555
|
+
assert.equal(f.checkpoints.length, change === "concurrent" ? 1 : 0);
|
|
2556
|
+
if (change === "successor") assert.equal(f.run.providerTurnId, "successor");
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
|
|
2561
|
+
test("native after-history grace retries a rejected expiry read without granting another interval", async t => {
|
|
2562
|
+
t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-10-07T00:00:00Z") });
|
|
2563
|
+
const f = await settlementFixture();
|
|
2564
|
+
const { sessionId, threadId, turnId } = f;
|
|
2565
|
+
const failedRead = Promise.withResolvers();
|
|
2566
|
+
const release = Promise.withResolvers();
|
|
2567
|
+
let rejectNextRead = false;
|
|
2568
|
+
const errors = [];
|
|
2569
|
+
const owner = createCodexAppServerRunOwner({ finalizingGraceMs: 20, finalizingGraceAfterHistoryRead: true,
|
|
2570
|
+
createRuntime: async () => ({ store: f.store, getSession: async () => {
|
|
2571
|
+
if (rejectNextRead) {
|
|
2572
|
+
rejectNextRead = false;
|
|
2573
|
+
failedRead.resolve();
|
|
2574
|
+
await release.promise;
|
|
2575
|
+
throw new Error("Controlled transient session read failure");
|
|
2576
|
+
}
|
|
2577
|
+
return { sessionId, agentRuns: [structuredClone(f.run)] };
|
|
2578
|
+
} }),
|
|
2579
|
+
createStore: async () => f.store, acquireProvider: async () => f.provider,
|
|
2580
|
+
debugLog: (event, input) => { if (event === "appServerFinalizingRecovery.error") errors.push(input); },
|
|
2581
|
+
checkpoint: async (id, input) => { f.checkpoints.push({ sessionId: id, ...input }); }
|
|
2582
|
+
});
|
|
2583
|
+
t.after(() => owner.clearSessionRecoveryTimers(sessionId));
|
|
2584
|
+
f.run = { ...f.run, state: "finalizing", providerStatus: "completed", finishedAt: new Date().toISOString() };
|
|
2585
|
+
f.history = [turn(turnId, "completed")];
|
|
2586
|
+
await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
|
|
2587
|
+
rejectNextRead = true;
|
|
2588
|
+
t.mock.timers.tick(20);
|
|
2589
|
+
await failedRead.promise;
|
|
2590
|
+
const timer = [...owner.finalizingTimers.values()][0];
|
|
2591
|
+
const expiry = timer.finalizingRecovery;
|
|
2592
|
+
const rejected = assert.rejects(expiry, /Controlled transient session read failure/);
|
|
2593
|
+
release.resolve();
|
|
2594
|
+
await rejected;
|
|
2595
|
+
assert.equal(owner.finalizingTimers.size, 1, "A rejected attempt retains the original expired deadline");
|
|
2596
|
+
assert.deepEqual(f.checkpoints, []);
|
|
2597
|
+
assert.deepEqual(await f.replies(), []);
|
|
2598
|
+
t.mock.timers.tick(1000);
|
|
2599
|
+
await owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
|
|
2600
|
+
assert.equal(f.run.state, "completed", "A fresh read settles against the original expired clock");
|
|
2601
|
+
assert.equal(errors.length, 1, "The recovery error remains observable");
|
|
2602
|
+
assert.equal(f.checkpoints.length, 1);
|
|
2603
|
+
assert.equal(f.checkpoints[0].turnOutcome, "response_delivery_failure");
|
|
2604
|
+
assert.equal(owner.finalizingTimers.size, 0);
|
|
2605
|
+
assert.deepEqual(await f.replies(), []);
|
|
2606
|
+
});
|
|
2607
|
+
|
|
2608
|
+
for (const status of ["failed", "interrupted"]) {
|
|
2609
|
+
test(`original detached ${status} notification retains the late exact provider detail`, async t => {
|
|
2610
|
+
const f = fixture([turn("turn-1", "inProgress")]);
|
|
2611
|
+
const pending = waitForCodexAppServerTurn(f.provider, "thread-1", turn("turn-1"), { timeoutMs: 2000 });
|
|
2612
|
+
const rejected = assert.rejects(pending, { message: "Exact later native failure detail." });
|
|
2613
|
+
// Finish the original initial history reread before terminal notification.
|
|
2614
|
+
await Promise.resolve();
|
|
2615
|
+
const emit = notification => { for (const listener of f.listeners) listener(notification); };
|
|
2616
|
+
emit({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status } } });
|
|
2617
|
+
assert.equal(f.listeners.size, 1, "The original 500ms detail wait retains observation");
|
|
2618
|
+
const detail = setTimeout(() => emit({ method: "error", params: {
|
|
2619
|
+
threadId: "thread-1", turnId: "turn-1", error: { message: "Exact later native failure detail." }, willRetry: false
|
|
2620
|
+
} }), 100);
|
|
2621
|
+
t.after(() => clearTimeout(detail));
|
|
2622
|
+
await rejected;
|
|
2623
|
+
assert.equal(f.listeners.size, 0);
|
|
2624
|
+
assert.equal(f.operations.filter(operation => operation === "unsubscribe").length, 1);
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
test("native failure detail grace rejects invalid duration", () => {
|
|
2629
|
+
for (const failureDetailGraceMs of [-1, 0.5, NaN, Infinity, "500", null]) {
|
|
2630
|
+
assert.throws(() => createCodexAppServerRunOwner({ failureDetailGraceMs }), /Invalid Codex failure detail grace/);
|
|
2631
|
+
}
|
|
2632
|
+
});
|
|
2633
|
+
|
|
2634
|
+
for (const change of ["detail", "expiry", "repeat", "cleanup", "successor", "foreign-error", "user-stop"]) {
|
|
2635
|
+
test(`native failure detail grace preserves the existing notification owner for ${change}`, { timeout: 2000 }, async t => {
|
|
2636
|
+
const settled = Promise.withResolvers();
|
|
2637
|
+
const notices = [];
|
|
2638
|
+
const f = await receiptFixture({ failureDetailGraceMs: 30,
|
|
2639
|
+
checkpoint: async (_id, input) => { f.checkpoints.push(input); settled.resolve(); },
|
|
2640
|
+
outcomeNotice: async (_runtime, _id, _threadId, _turnId, _outcome, error) => { notices.push(error); }
|
|
2641
|
+
});
|
|
2642
|
+
t.after(() => f.owner.clearSessionRecoveryTimers(f.sessionId));
|
|
2643
|
+
f.run.providerStatus = change === "user-stop" ? "interrupted" : "failed";
|
|
2644
|
+
f.history = [turn(f.turnId, f.run.providerStatus)];
|
|
2645
|
+
if (change === "user-stop") f.provider.interruptionOutcome = () => "user_cancelled";
|
|
2646
|
+
f.observe();
|
|
2647
|
+
const notification = { method: "turn/completed", params: { threadId: f.threadId,
|
|
2648
|
+
turn: { id: f.turnId, status: f.run.providerStatus }, error: { message: "Original failure status." } } };
|
|
2649
|
+
f.emit(notification);
|
|
2650
|
+
await f.drain();
|
|
2651
|
+
if (change === "user-stop") {
|
|
2652
|
+
assert.equal(f.owner.finalizingTimers.size, 0);
|
|
2653
|
+
assert.equal(f.run.state, "interrupted");
|
|
2654
|
+
assert.equal(f.checkpoints.length, 1, "Explicit user cancellation never waits for provider detail");
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
assert.equal(f.owner.finalizingTimers.size, 1);
|
|
2658
|
+
assert.equal(f.run.state, "active", "Waiting for detail does not fabricate native completion");
|
|
2659
|
+
assert.equal(notices.length, 0, "The failure task returns so the following error can be processed");
|
|
2660
|
+
const timer = [...f.owner.finalizingTimers.values()][0];
|
|
2661
|
+
if (change === "repeat") {
|
|
2662
|
+
f.emit(notification); await f.drain();
|
|
2663
|
+
assert.equal([...f.owner.finalizingTimers.values()][0], timer, "Repeated status preserves the first deadline");
|
|
2664
|
+
}
|
|
2665
|
+
if (change === "foreign-error") {
|
|
2666
|
+
await f.owner.recoverActiveTurn(f.sessionId, { provider: f.provider, retryOnError: false,
|
|
2667
|
+
providerError: { threadId: f.threadId, turnId: "another-turn", error: "Foreign detail." } });
|
|
2668
|
+
assert.equal(f.owner.finalizingTimers.size, 1);
|
|
2669
|
+
assert.deepEqual(notices, []);
|
|
2670
|
+
}
|
|
2671
|
+
if (change === "cleanup") f.owner.clearSessionRecoveryTimers(f.sessionId);
|
|
2672
|
+
if (change === "successor") f.run = { ...f.run, providerTurnId: "successor", providerStatus: "inProgress" };
|
|
2673
|
+
if (["cleanup", "successor"].includes(change)) {
|
|
2674
|
+
await new Promise(resolve => setTimeout(resolve, 60));
|
|
2675
|
+
await f.drain();
|
|
2676
|
+
assert.equal(f.owner.finalizingTimers.size, 0);
|
|
2677
|
+
assert.deepEqual(f.checkpoints, []);
|
|
2678
|
+
assert.deepEqual(notices, []);
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
if (["detail", "foreign-error"].includes(change)) {
|
|
2682
|
+
await f.owner.recoverActiveTurn(f.sessionId, { provider: f.provider, retryOnError: false,
|
|
2683
|
+
providerError: { threadId: f.threadId, turnId: f.turnId, error: "Exact provider detail." } });
|
|
2684
|
+
} else await settled.promise;
|
|
2685
|
+
await f.drain();
|
|
2686
|
+
assert.equal(f.owner.finalizingTimers.size, 0);
|
|
2687
|
+
assert.equal(f.run.state, "failed");
|
|
2688
|
+
assert.equal(f.checkpoints.length, 1);
|
|
2689
|
+
assert.deepEqual(notices, [["detail", "foreign-error"].includes(change) ? "Exact provider detail." : "Original failure status."]);
|
|
2690
|
+
assert.deepEqual(await f.replies(), []);
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
for (const finalizingGraceAfterHistoryRead of [false, true]) for (const finalizingGraceMs of [500, 200]) for (const heldExpiry of [false, true]) {
|
|
2695
|
+
test(`native failure detail grace retains the first successful-final ${finalizingGraceMs}ms deadline with held expiry ${heldExpiry} and post-read clock ${finalizingGraceAfterHistoryRead}`, { timeout: 2000 }, async t => {
|
|
2696
|
+
t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-10-08T00:00:00Z") });
|
|
2697
|
+
const notices = [];
|
|
2698
|
+
const settled = Promise.withResolvers();
|
|
2699
|
+
const f = await receiptFixture({ finalizingGraceMs, finalizingGraceAfterHistoryRead,
|
|
2700
|
+
failureDetailGraceMs: 500,
|
|
2701
|
+
checkpoint: async (_id, input) => { f.checkpoints.push(input); settled.resolve(); },
|
|
2702
|
+
outcomeNotice: async (_runtime, _id, _thread, _turn, _outcome, error) => notices.push(error)
|
|
2703
|
+
});
|
|
2704
|
+
t.after(() => f.owner.clearSessionRecoveryTimers(f.sessionId));
|
|
2705
|
+
f.run = { ...f.run, state: "finalizing", providerStatus: "completed", finishedAt: new Date().toISOString() };
|
|
2706
|
+
f.history = [turn(f.turnId, "completed")];
|
|
2707
|
+
await f.owner.completeTurn(f.sessionId, f.threadId, f.turnId, { verifyInactive: false });
|
|
2708
|
+
assert.equal(f.owner.finalizingTimers.size, 1);
|
|
2709
|
+
const reading = Promise.withResolvers();
|
|
2710
|
+
const release = Promise.withResolvers();
|
|
2711
|
+
const list = f.provider.listThreadTurns;
|
|
2712
|
+
let recovery;
|
|
2713
|
+
if (heldExpiry) {
|
|
2714
|
+
f.provider.listThreadTurns = async (...args) => { const snapshot = await list(...args); reading.resolve(); await release.promise; return snapshot; };
|
|
2715
|
+
recovery = f.owner.completeTurn(f.sessionId, f.threadId, f.turnId, { verifyInactive: false });
|
|
2716
|
+
await reading.promise;
|
|
2717
|
+
f.provider.listThreadTurns = list;
|
|
2718
|
+
}
|
|
2719
|
+
t.mock.timers.tick(finalizingGraceMs - 1);
|
|
2720
|
+
f.run.providerStatus = "failed";
|
|
2721
|
+
f.history = [turn(f.turnId, "failed")];
|
|
2722
|
+
f.observe();
|
|
2723
|
+
f.emit({ method: "turn/completed", params: { threadId: f.threadId,
|
|
2724
|
+
turn: { id: f.turnId, status: "failed" }, error: { message: "First failure detail." } } });
|
|
2725
|
+
await f.drain();
|
|
2726
|
+
if (heldExpiry) { release.resolve(); await recovery; }
|
|
2727
|
+
await new Promise(resolve => { const timer = setTimeout(resolve, 60); t.after(() => clearTimeout(timer)); });
|
|
2728
|
+
await f.drain();
|
|
2729
|
+
assert.equal(f.run.state, "failed", "Failed status must not grant another 500ms or revive successful-final recovery");
|
|
2730
|
+
await settled.promise;
|
|
2731
|
+
assert.equal(f.owner.finalizingTimers.size, 0);
|
|
2732
|
+
assert.deepEqual(notices, ["First failure detail."]);
|
|
2733
|
+
assert.equal(f.checkpoints.length, 1);
|
|
2734
|
+
assert.deepEqual(await f.replies(), []);
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
for (const finalizingGraceMs of [500, 200]) {
|
|
2739
|
+
test(`native failure detail survives a held post-history state read with ${finalizingGraceMs}ms first deadline`, { timeout: 2000 }, async t => {
|
|
2740
|
+
t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-10-08T00:00:00Z") });
|
|
2741
|
+
const notices = [];
|
|
2742
|
+
const f = await receiptFixture({ finalizingGraceMs, finalizingGraceAfterHistoryRead: true,
|
|
2743
|
+
failureDetailGraceMs: 500,
|
|
2744
|
+
outcomeNotice: async (_runtime, _id, _thread, _turn, _outcome, error) => notices.push(error)
|
|
2745
|
+
});
|
|
2746
|
+
t.after(() => f.owner.clearSessionRecoveryTimers(f.sessionId));
|
|
2747
|
+
f.run = { ...f.run, state: "finalizing", providerStatus: "completed", finishedAt: new Date().toISOString() };
|
|
2748
|
+
f.history = [turn(f.turnId, "completed")];
|
|
2749
|
+
await f.owner.completeTurn(f.sessionId, f.threadId, f.turnId, { verifyInactive: false });
|
|
2750
|
+
const reading = Promise.withResolvers(); const release = Promise.withResolvers();
|
|
2751
|
+
const list = f.provider.listThreadTurns; const getSession = f.runtime.getSession;
|
|
2752
|
+
let holdState = false;
|
|
2753
|
+
f.provider.listThreadTurns = async (...args) => { const snapshot = await list(...args); holdState = true; return snapshot; };
|
|
2754
|
+
f.runtime.getSession = async (...args) => {
|
|
2755
|
+
const snapshot = await getSession(...args);
|
|
2756
|
+
if (holdState) { holdState = false; reading.resolve(); await release.promise; }
|
|
2757
|
+
return snapshot;
|
|
2758
|
+
};
|
|
2759
|
+
const recovery = f.owner.completeTurn(f.sessionId, f.threadId, f.turnId, { verifyInactive: false });
|
|
2760
|
+
await reading.promise;
|
|
2761
|
+
f.provider.listThreadTurns = list;
|
|
2762
|
+
t.mock.timers.tick(finalizingGraceMs - 1);
|
|
2763
|
+
f.run.providerStatus = "failed"; f.history = [turn(f.turnId, "failed")];
|
|
2764
|
+
f.observe();
|
|
2765
|
+
f.emit({ method: "turn/completed", params: { threadId: f.threadId,
|
|
2766
|
+
turn: { id: f.turnId, status: "failed" }, error: { message: "Failure during canonical read." } } });
|
|
2767
|
+
await f.drain();
|
|
2768
|
+
release.resolve(); await recovery;
|
|
2769
|
+
await new Promise(resolve => { const timer = setTimeout(resolve, 60); t.after(() => clearTimeout(timer)); });
|
|
2770
|
+
await f.drain();
|
|
2771
|
+
assert.equal(f.run.state, "failed", "The resumed canonical read must not overwrite the pending failure operation");
|
|
2772
|
+
assert.equal(f.owner.finalizingTimers.size, 0);
|
|
2773
|
+
assert.deepEqual(notices, ["Failure during canonical read."]);
|
|
2774
|
+
assert.deepEqual(await f.replies(), []);
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
for (const asynchronous of [false, true]) {
|
|
2779
|
+
test(`output acquisition validates host context before provider reuse (async ${asynchronous})`, async () => {
|
|
2780
|
+
const f = await outputFixture();
|
|
2781
|
+
const runtime = { store: f.store, getSession: async id => ({ sessionId: id, agentRuns: [structuredClone(f.run)] }) };
|
|
2782
|
+
const entered = Promise.withResolvers(); const release = Promise.withResolvers(); const order = [];
|
|
2783
|
+
const options = { threadExecutionRoot: "/trusted/private/native", threadWorkdir: "/trusted/private/native" };
|
|
2784
|
+
const prepared = { managedIdentity: { executionRoot: options.threadExecutionRoot, workdir: options.threadWorkdir },
|
|
2785
|
+
providerOptions: async () => { order.push("options"); return options; } };
|
|
2786
|
+
const owner = createCodexAppServerRunOwner({ createRuntime: async () => runtime, createStore: async () => f.store,
|
|
2787
|
+
providerSessions: { managed: new Map(), owner: { providers: new Map(), ensureSession: async context => {
|
|
2788
|
+
assert.deepEqual(context, { sessionId: f.sessionId, options }); order.push("provider"); return f.provider;
|
|
2789
|
+
} }, context: (sessionId, options) => ({ sessionId, options }), outputContext(context) {
|
|
2790
|
+
assert.equal(context.runtime, runtime); assert.equal(context.sessionId, f.sessionId); assert.equal(context.session.sessionId, f.sessionId);
|
|
2791
|
+
order.push("context"); entered.resolve();
|
|
2792
|
+
return asynchronous ? release.promise.then(() => { order.push("validated"); return prepared; }) : prepared;
|
|
2793
|
+
} }
|
|
2794
|
+
});
|
|
2795
|
+
const reading = owner.submitAssistantResult(f.sessionId, f.threadId, f.turnId, { recoverFromProvider: true });
|
|
2796
|
+
await entered.promise;
|
|
2797
|
+
if (asynchronous) {
|
|
2798
|
+
assert.deepEqual(order, ["context"]); assert.equal(f.pages.length, 0, "No native history read precedes trusted context validation"); release.resolve();
|
|
2799
|
+
}
|
|
2800
|
+
assert.equal((await reading).reason, "empty");
|
|
2801
|
+
assert.deepEqual(order, asynchronous ? ["context", "validated", "options", "provider"] : ["context", "options", "provider"]);
|
|
2802
|
+
assert.equal(f.pages.length, 1);
|
|
2803
|
+
});
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
test("a rejected async output context does not acquire a provider or read native history", async () => {
|
|
2807
|
+
const f = await outputFixture();
|
|
2808
|
+
const runtime = { store: f.store, getSession: async () => ({ sessionId: f.sessionId, agentRuns: [f.run] }) };
|
|
2809
|
+
let acquired = false;
|
|
2810
|
+
const owner = createCodexAppServerRunOwner({ createRuntime: async () => runtime, createStore: async () => f.store,
|
|
2811
|
+
providerSessions: { managed: new Map(), owner: { providers: new Map(), ensureSession() { acquired = true; throw new Error("Unexpected acquisition"); } },
|
|
2812
|
+
context: () => ({}), outputContext: async () => { throw new Error("The learning scope is no longer active"); } }
|
|
2813
|
+
});
|
|
2814
|
+
const result = await owner.submitAssistantResult(f.sessionId, f.threadId, f.turnId, { recoverFromProvider: true });
|
|
2815
|
+
assert.equal(result.reason, "error"); assert.match(result.error, /learning scope is no longer active/u);
|
|
2816
|
+
assert.equal(acquired, false); assert.equal(f.pages.length, 0);
|
|
2817
|
+
});
|
|
2818
|
+
|
|
2819
|
+
test("native receipt keeps admitted application data with the original authored message", async () => {
|
|
2820
|
+
const f = await receiptFixture();
|
|
2821
|
+
const messageId = "application-data-live";
|
|
2822
|
+
const data = { association: { reference: "issued-reference", delivery: { turnId: "question-turn" } } };
|
|
2823
|
+
f.run = { ...f.run, state: "starting", providerTurnId: "", pendingUserMessageClientIds: [messageId] };
|
|
2824
|
+
const receipt = Promise.withResolvers();
|
|
2825
|
+
f.owner.pendingUserMessages.set(`project:${f.sessionId}\0${messageId}`, {
|
|
2826
|
+
text: "These are my actual words.", attachments: [], authoredInput: { messageId, data }, receipt
|
|
2827
|
+
});
|
|
2828
|
+
f.observe();
|
|
2829
|
+
f.emit(f.receipt(messageId, "Prepared model context must not replace authored words"));
|
|
2830
|
+
await f.drain();
|
|
2831
|
+
await receipt.promise;
|
|
2832
|
+
const turns = (await f.store.readConversationLog(f.sessionId)).filter(row => row.user?.messageId === messageId);
|
|
2833
|
+
assert.equal(turns.length, 1);
|
|
2834
|
+
assert.equal(turns[0].user.messageId, messageId);
|
|
2835
|
+
assert.equal(turns[0].user.text, "These are my actual words.");
|
|
2836
|
+
assert.deepEqual(turns[0].user.data, data);
|
|
2837
|
+
assert.ok(f.operations.indexOf("authored-message") < f.operations.indexOf("event:codex-app-server-user-message-consumed"));
|
|
2838
|
+
data.association.reference = "later-mutated-reference";
|
|
2839
|
+
assert.equal((await f.store.readConversationLog(f.sessionId)).find(row => row.user?.messageId === messageId).user.data.association.reference, "issued-reference");
|
|
2840
|
+
});
|
|
2841
|
+
|
|
2842
|
+
test("native receipt recovery keeps saved application data without recapture or duplication", async () => {
|
|
2843
|
+
const f = await receiptFixture();
|
|
2844
|
+
const messageId = "application-data-restart";
|
|
2845
|
+
const data = { association: { reference: "original-saved-reference" } };
|
|
2846
|
+
f.run = { ...f.run, state: "starting", providerTurnId: "", pendingUserMessageClientIds: [] };
|
|
2847
|
+
f.metadata.set("authorized-delivery", JSON.stringify({ engines: { codex: { pending: {
|
|
2848
|
+
messageId, threadId: f.threadId, message: "Private prepared prompt", displayMessage: "My retained answer.",
|
|
2849
|
+
displayAttachments: [], data
|
|
2850
|
+
} } } }));
|
|
2851
|
+
f.observe();
|
|
2852
|
+
f.emit(f.receipt(messageId));
|
|
2853
|
+
await f.drain();
|
|
2854
|
+
f.emit(f.receipt(messageId));
|
|
2855
|
+
await f.drain();
|
|
2856
|
+
const rows = (await f.store.readConversationLog(f.sessionId)).filter(row => row.user?.messageId === messageId);
|
|
2857
|
+
assert.equal(rows.length, 1);
|
|
2858
|
+
assert.equal(rows[0].user.text, "My retained answer.");
|
|
2859
|
+
assert.deepEqual(rows[0].user.data, data);
|
|
2860
|
+
assert.equal(f.operations.filter(value => value === "authored-message").length, 1);
|
|
2861
|
+
assert.equal(JSON.stringify(rows).includes("Private prepared prompt"), false);
|
|
2862
|
+
});
|
|
2863
|
+
|
|
2864
|
+
test("native receipt does not borrow application data from another authored identity", async () => {
|
|
2865
|
+
const f = await receiptFixture();
|
|
2866
|
+
const messageId = "application-data-matching";
|
|
2867
|
+
f.run = { ...f.run, state: "starting", providerTurnId: "", pendingUserMessageClientIds: [messageId] };
|
|
2868
|
+
const receipt = Promise.withResolvers();
|
|
2869
|
+
f.owner.pendingUserMessages.set(`project:${f.sessionId}\0${messageId}`, {
|
|
2870
|
+
text: "Unassociated actual words.", attachments: [],
|
|
2871
|
+
authoredInput: { messageId: "other-message", data: { association: "foreign" } }, receipt
|
|
2872
|
+
});
|
|
2873
|
+
f.observe();
|
|
2874
|
+
f.emit(f.receipt(messageId));
|
|
2875
|
+
await f.drain();
|
|
2876
|
+
await receipt.promise;
|
|
2877
|
+
const rows = (await f.store.readConversationLog(f.sessionId)).filter(row => row.user?.messageId === messageId);
|
|
2878
|
+
assert.equal(rows.length, 1);
|
|
2879
|
+
assert.equal(Object.hasOwn(rows[0].user, "data"), false);
|
|
2880
|
+
});
|
|
2881
|
+
|
|
2882
|
+
test("native changeover retries retain original application data including its absence", async () => {
|
|
2883
|
+
const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
|
|
2884
|
+
for (const data of [undefined, { association: "original" }]) {
|
|
2885
|
+
const pending = { messageId: "retained-request", message: "Prepared original prompt", displayMessage: "Actual original words.",
|
|
2886
|
+
displayAttachments: [], attachmentIds: [], seen: {}, attempted: false,
|
|
2887
|
+
...(data !== undefined ? { data } : {}) };
|
|
2888
|
+
let sent;
|
|
2889
|
+
const changeover = createConversationChangeover({
|
|
2890
|
+
state: { async read() { return { lastEngine: "codex", engines: { codex: { seen: {}, pending } } }; }, async write() {} },
|
|
2891
|
+
transcript: { async hasMessage() { return false; } },
|
|
2892
|
+
agent: { async sendMessage(input) { sent = input; return { ok: false, delivered: false }; } }
|
|
2893
|
+
});
|
|
2894
|
+
await changeover.send({ engineId: "codex", messages: [], input: {
|
|
2895
|
+
messageId: pending.messageId, message: "Actual original words.", data: { association: "newer-reference" }
|
|
2896
|
+
} });
|
|
2897
|
+
assert.equal(sent.message, pending.message);
|
|
2898
|
+
assert.equal(sent.displayMessage, pending.displayMessage);
|
|
2899
|
+
assert.deepEqual(sent.data, data);
|
|
2900
|
+
}
|
|
2901
|
+
});
|
|
@@ -1217,3 +1217,22 @@ test("a failed answer commit preserves output for review and cannot start anothe
|
|
|
1217
1217
|
assert.equal((await reopened.send(input)).duplicate, true);
|
|
1218
1218
|
assert.equal(f.requests.length, 1);
|
|
1219
1219
|
});
|
|
1220
|
+
|
|
1221
|
+
test("final reply limit rejects invalid configured limits without changing transport defaults", () => {
|
|
1222
|
+
for (const maxFinalReplyCharacters of [0, -1, 0.5, "3", Infinity, null]) {
|
|
1223
|
+
assert.throws(() => createConversationRuntime({ storage: createMemoryConversationStorage(), authorize: () => true,
|
|
1224
|
+
limits: { maxFinalReplyCharacters } }), /Invalid conversation final reply limit/);
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
for (const text of ["abc", "abcd"]) {
|
|
1229
|
+
test(`decoded final reply limit preserves the complete boundary for ${text.length} characters`, async t => {
|
|
1230
|
+
const f = await fixture(t, { limits: { maxOutputCharacters: 32, maxFinalReplyCharacters: 3 }, fetch: () => response(text) });
|
|
1231
|
+
await f.conversation.send(input);
|
|
1232
|
+
const result = await f.conversation.wait();
|
|
1233
|
+
assert.equal(result.conversationLog[0].metadata.runtime.status, text.length === 3 ? "complete" : "failed");
|
|
1234
|
+
assert.equal(result.conversationLog[0].assistant?.text, text.length === 3 ? text : undefined);
|
|
1235
|
+
assert.equal(f.requests.length, 1);
|
|
1236
|
+
if (text.length > 3) assert.match(result.error, /final reply limit/);
|
|
1237
|
+
});
|
|
1238
|
+
}
|