@jskit-ai/assistant-core 0.2.16 → 0.2.18

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 (33) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/examples/conversation/package.json +5 -5
  3. package/package.json +6 -6
  4. package/src/server/conversation/claudeNativeTurn.js +9 -1
  5. package/src/server/conversation/claudeSessionCleanup.js +9 -1
  6. package/src/server/conversation/claudeTurn.js +1 -0
  7. package/src/server/conversation/codexConversations.js +65 -6
  8. package/src/server/conversation/codexRunJournal.js +33 -1
  9. package/src/server/conversation/codexRuntimeLifecycle.js +20 -1
  10. package/src/server/conversation/codexTurn.js +41 -4
  11. package/src/server/conversation/codexTurnEndings.js +9 -6
  12. package/src/server/conversation/nativeConversationLifecycle.js +22 -0
  13. package/src/server/conversation/openCodeProcessOwner.js +11 -5
  14. package/src/server/conversation/openCodeRuntime.js +1 -0
  15. package/src/server/conversation/openCodeTurn.js +10 -5
  16. package/src/server/conversation/openCodeTurnMonitor.js +4 -2
  17. package/src/server/conversation/providers/claudeDriver.js +5 -0
  18. package/src/server/conversation/providers/codexDriver.js +5 -0
  19. package/src/server/conversation/providers/opencodeDriver.js +5 -0
  20. package/src/server/conversation/runtime.js +1 -0
  21. package/test/apiConversationDriver.test.js +8 -4
  22. package/test/claudeLifecycle.test.js +28 -0
  23. package/test/codexConversationsCheckpoint.test.js +2 -0
  24. package/test/codexLifecycleIsolation.test.js +2 -0
  25. package/test/codexShutdownStop.test.js +168 -0
  26. package/test/codexTemporaryProviderRelease.test.js +2 -0
  27. package/test/codexTurn.test.js +57 -0
  28. package/test/conversationApiFacilities.test.js +4 -3
  29. package/test/conversationAttachments.test.js +4 -4
  30. package/test/conversationReplacement.test.js +1 -1
  31. package/test/conversationSession.test.js +40 -10
  32. package/test/conversationTools.test.js +2 -2
  33. package/test/openCodeLifecycle.test.js +55 -3
@@ -478,9 +478,10 @@ export async function waitForOpenCodeFinalResponse(client, conversationId, turn,
478
478
  /**
479
479
  * Production detached-turn lifetime. Applications project the completed native
480
480
  * result before observation is closed, then publish/checkpoint their own state.
481
- * A turn the server's shutdown stopped (`turn.stoppedByShutdown`) ends
482
- * interrupted however its wait ended, and `afterClose` is told so: the
483
- * application leaves its checkpoint to the next server.
481
+ * A turn the server's shutdown stopped (`turn.stoppedByShutdown`), or that
482
+ * ends on its own once shutdown has begun, ends interrupted however its wait
483
+ * ended, and `afterClose` is told so: the application leaves its checkpoint to
484
+ * the next server.
484
485
  * @param {import("./openCodeProcessOwner.js").OpenCodeProcessOwner} processOwner
485
486
  */
486
487
  export async function observeOpenCodeTurnCompletion(processOwner, target, conversationId, turn, {
@@ -489,6 +490,10 @@ export async function observeOpenCodeTurnCompletion(processOwner, target, conver
489
490
  const turnAbort = turn.abortController;
490
491
  let stopped = true;
491
492
  let outcome = "completed";
493
+ const leftToNextServer = () => {
494
+ if (processOwner.closed) turn.stoppedByShutdown = true;
495
+ return turn.stoppedByShutdown === true;
496
+ };
492
497
  try {
493
498
  const options = { ...waitOptions, signal: AbortSignal.any([
494
499
  signal, ...(timeoutMs ? [AbortSignal.timeout(timeoutMs)] : [])
@@ -496,14 +501,14 @@ export async function observeOpenCodeTurnCompletion(processOwner, target, conver
496
501
  const completion = finalResponse
497
502
  ? await waitForOpenCodeFinalResponse(target.server.client, conversationId, turn, { ...finalResponse, ...options })
498
503
  : await waitForOpenCodeMessages(target.server.client, conversationId, () => turn.inputMessageId, options);
499
- if (turn.stoppedByShutdown) {
504
+ if (leftToNextServer()) {
500
505
  outcome = "interrupted";
501
506
  return { interrupted: true };
502
507
  }
503
508
  return await projectResult(completion);
504
509
  } catch (error) {
505
510
  const failure = signal.aborted ? signal.reason : error;
506
- outcome = turn.interrupted || turn.stoppedByShutdown ? "interrupted" : "failed";
511
+ outcome = leftToNextServer() || turn.interrupted ? "interrupted" : "failed";
507
512
  if (outcome === "interrupted") return { interrupted: true };
508
513
  if (!target.abortController.signal.aborted) {
509
514
  try {
@@ -355,8 +355,10 @@ export function createOpenCodeTurnMonitor({ processes, monitors, turns, processO
355
355
  // server: its saved run stays active and carries the shutdown's mark,
356
356
  // so that server's recovery records the turn interrupted and runs the
357
357
  // host's checkpoint. Run here, that checkpoint (a Git snapshot, say)
358
- // would hold the host's bounded shutdown for as long as it takes. A
359
- // user's Stop ends its turn as interrupted here.
358
+ // could be refused or hold the host's bounded shutdown for as long as
359
+ // it takes. A turn that ends on its own once shutdown has begun is left
360
+ // the same way. A user's Stop outside shutdown ends its turn here.
361
+ if (processOwner.closed && finalState !== "active") turn.stoppedByShutdown = true;
360
362
  const nextServerEnds = turn.stoppedByShutdown === true && finalState !== "active";
361
363
  // Claim retirement before awaited cleanup/checkpointing. A new Send
362
364
  // joins this monitor's cleanup instead of steering an unobserved turn.
@@ -346,6 +346,10 @@ export function createClaudeConversationDriver({
346
346
  return native.owner.closeProject(input, native.application);
347
347
  }
348
348
 
349
+ function beginShutdown({ native }) {
350
+ return native.owner.beginShutdown();
351
+ }
352
+
349
353
  function invalidateRuntimes({ native, input }) {
350
354
  return native.owner.invalidateRuntimes(input, native.application, native.account);
351
355
  }
@@ -568,6 +572,7 @@ export function createClaudeConversationDriver({
568
572
  listConversationStorage,
569
573
  retireConversationHistory,
570
574
  closeProject,
575
+ beginShutdown,
571
576
  invalidateRuntimes,
572
577
  reconcileSessions,
573
578
  createConversation,
@@ -63,6 +63,10 @@ function nativeGoalChanged() {
63
63
  });
64
64
  }
65
65
 
66
+ function beginShutdown({ native }) {
67
+ return native.runOwner.beginShutdown();
68
+ }
69
+
66
70
  /**
67
71
  * Conversations own threads and observers on the account's shared native runtime.
68
72
  * @param {object} dependencies
@@ -653,6 +657,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
653
657
  deleteDetachedConversation,
654
658
  listConversationStorage,
655
659
  retireConversationHistory,
660
+ beginShutdown,
656
661
  invalidateRuntimes,
657
662
  reconcileSessions,
658
663
  unsubscribeSessions,
@@ -126,6 +126,10 @@ export function createOpenCodeConversationDriver({
126
126
  return native.owner.closeProject(input, native.preparation.projectCleanup());
127
127
  }
128
128
 
129
+ function beginShutdown({ native }) {
130
+ return native.owner.beginShutdown();
131
+ }
132
+
129
133
  function invalidateRuntimes({ native, input }) {
130
134
  return native.owner.invalidateRuntimes(input, native.preparation);
131
135
  }
@@ -375,6 +379,7 @@ export function createOpenCodeConversationDriver({
375
379
  listConversationStorage,
376
380
  retireConversationHistory,
377
381
  closeProject,
382
+ beginShutdown,
378
383
  invalidateRuntimes,
379
384
  reconcileSessions,
380
385
  createConversation,
@@ -227,6 +227,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
227
227
  runNativeDetachedConversation: lifecycle.runNativeDetachedConversation,
228
228
  open: handles.open,
229
229
  closeNativeProject: lifecycle.closeNativeProject,
230
+ beginNativeShutdown: lifecycle.beginNativeShutdown,
230
231
  invalidateNativeRuntimes: lifecycle.invalidateNativeRuntimes,
231
232
  reconcileNativeSessions: lifecycle.reconcileNativeSessions,
232
233
  unsubscribeNativeSessions: lifecycle.unsubscribeNativeSessions,
@@ -6,7 +6,8 @@ import { createApiConversationDriver } from "../src/server/conversation/provider
6
6
  const CHECKS = checkTexts("the app");
7
7
  const frame = (delta, finish_reason) => `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason }] })}\n\n`;
8
8
  const schema = tool => ({ type: "function", function: { name: tool.name, parameters: tool.parameters } });
9
- const lastText = body => body.messages.at(-1).content;
9
+ // The engine's state note ends every request's conversation; what the app sent last comes before it.
10
+ const lastText = body => body.messages.at(-2).content;
10
11
 
11
12
  // One step of a streamed reply: words, an action call or both. Calls the engine
12
13
  // makes without streaming (accounts, checks, summaries) answer with `once`.
@@ -113,7 +114,10 @@ test("admission precedes every model call, the state note gives the time, and a
113
114
  await f.run("What day is it today?");
114
115
  assert.deepEqual(f.order, ["accept", "model"]);
115
116
  assert.deepEqual(f.executions, [], "No action runs before the model asks for one");
116
- assert.match(f.requests[0].messages[0].content, /^Use the supplied actions\.\n\nCurrent state, from the system\. It is the truth; earlier messages may be out of date:\n- The time: .+ UTC\. The person may be in another time zone\.$/u);
117
+ assert.equal(f.requests[0].messages[0].content, "Use the supplied actions.", "The state note is not in the system prompt");
118
+ assert.equal(f.requests[0].messages.at(-1).role, "user");
119
+ assert.match(f.requests[0].messages.at(-1).content, /^Current state, from the system\. It is the truth; earlier messages may be out of date:\n- The time: .+ UTC\. The person may be in another time zone\.$/u,
120
+ "The state note follows the conversation, so the history before it stays in the provider's prompt cache");
117
121
  const denied = fixture({ authorize: () => { throw new Error("Connection access revoked"); } });
118
122
  await assert.rejects(denied.run(), /Connection access revoked/);
119
123
  assert.deepEqual(denied.requests, []);
@@ -223,7 +227,7 @@ test("a long history is compacted: the summary and its last turn are saved, and
223
227
  assert.ok(kept.length > 0 && kept.length < turns.length);
224
228
  const [request] = f.streamed;
225
229
  assert.match(request.messages[0].content, /Summary of the earlier part of this conversation[^]*The person asked six long questions\./u);
226
- assert.deepEqual(request.messages.filter(message => message.role === "user").map(message => message.content),
230
+ assert.deepEqual(request.messages.slice(0, -1).filter(message => message.role === "user").map(message => message.content),
227
231
  [...kept.map(turn => turn.user.text), "Next question"]);
228
232
  assert.deepEqual(phases, ["compacting", "working"]);
229
233
  });
@@ -248,5 +252,5 @@ test("a short history is read whole with its stored summary, and nothing is save
248
252
  assert.deepEqual(saved, []);
249
253
  assert.equal(f.requests.length, 1);
250
254
  assert.match(f.requests[0].messages[0].content, /Summary of the earlier part of this conversation[^]*Earlier: hello\./u);
251
- assert.deepEqual(f.requests[0].messages.slice(1).map(message => message.content), ["Hi", "Hello", "Next question"]);
255
+ assert.deepEqual(f.requests[0].messages.slice(1, -1).map(message => message.content), ["Hi", "Hello", "Next question"]);
252
256
  });
@@ -242,6 +242,34 @@ test("Claude shutdown leaves a running turn's end and its checkpoint to the next
242
242
  assert.deepEqual(custody(JSON.parse(f.session("a").records.get(MAIN_IDS.a))), { executionId: "", state: "interrupted" });
243
243
  });
244
244
 
245
+ test("once Claude's shutdown has begun, a turn that ends on its own is left to the next server, which runs its one checkpoint", { timeout: 5_000 }, async t => {
246
+ const f = await lifecycleFixture(t);
247
+ const a = await f.running("a");
248
+ const saved = f.session("a").records.get(MAIN_IDS.a);
249
+ f.owner.beginShutdown();
250
+ assert.deepEqual(f.calls.stops, [], "beginning the shutdown stopped nothing");
251
+ // The turn ends on its own.
252
+ await f.processes[0].options.onEvent({ type: "result", subtype: "success", result: "Done." });
253
+ assert.deepEqual(f.calls.checkpoints, [], "the turn's end ran no checkpoint");
254
+ assert.equal(f.session("a").records.get(MAIN_IDS.a), saved, "the saved record keeps the active turn");
255
+ assert.equal((await f.owner.invalidateRuntimes({ reason: "server-shutdown" }, f.application, f.account)).ok, true);
256
+ assert.equal(a.process, null, "the full stop still stopped the process");
257
+ assert.deepEqual(f.calls.checkpoints, []);
258
+ // The next server's close proves that exit, then interrupts the turn through its checkpoint, once.
259
+ f.restart();
260
+ assert.equal((await f.owner.closeSession({ sessionId: "a" }, f.application)).ok, true);
261
+ assert.equal((await f.owner.closeSession({ sessionId: "a" }, f.application)).ok, true);
262
+ assert.deepEqual(f.calls.checkpoints, ["a:interrupted"]);
263
+ });
264
+
265
+ test("outside shutdown a Claude turn that ends on its own still runs its checkpoint at once", { timeout: 5_000 }, async t => {
266
+ const f = await lifecycleFixture(t);
267
+ await f.running("a");
268
+ await f.processes[0].options.onEvent({ type: "result", subtype: "success", result: "Done." });
269
+ assert.deepEqual(f.calls.checkpoints, ["a:completed"]);
270
+ assert.equal(JSON.parse(f.session("a").records.get(MAIN_IDS.a)).state, "completed");
271
+ });
272
+
245
273
  test("Claude project close releases its sessions from memory without reopening saved conversations", async t => {
246
274
  const f = await lifecycleFixture(t);
247
275
  await f.idle("a");
@@ -20,6 +20,8 @@ function persistentConversationFixture() {
20
20
  };
21
21
  const commands = createCodexConversationCommands({
22
22
  async checkpoint(_sessionId, input) { checkpoints.push(input); },
23
+ // Outside shutdown, with no request contexts.
24
+ isClosing: () => false, captureContext: () => null, runInContext: (_context, operation) => operation(),
23
25
  conversationPreparation: { async context() { return { result: { provider } }; } },
24
26
  namespace: (sessionId) => sessionId,
25
27
  // The provider owner still holds the provider observing the conversation;
@@ -231,6 +231,8 @@ function conversationCommands({ context = async () => ({ result: { ok: false, co
231
231
  providerSessions = {} } = {}) {
232
232
  return createCodexConversationCommands({
233
233
  checkpoint: async () => {},
234
+ // Outside shutdown, with no request contexts.
235
+ isClosing: () => false, captureContext: () => null, runInContext: (_context, operation) => operation(),
234
236
  conversationPreparation: {
235
237
  context,
236
238
  scope,
@@ -0,0 +1,168 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createCodexConversationCommands } from "../src/server/conversation/codexConversations.js";
4
+ import { createCodexRuntimeLifecycle } from "../src/server/conversation/codexRuntimeLifecycle.js";
5
+
6
+ // A Temporary chat's Codex turn that the server's shutdown stops, or that ends
7
+ // while the server is stopping, leaves its checkpoint to the next server: the
8
+ // application records it as owed, and that server's next read makes it once.
9
+
10
+ const turnOutcomes = { USER_CANCELLED: "user_cancelled", SERVICE_RESTART: "service_restart" };
11
+
12
+ // One server's observed conversation. `latest` is the turn Codex's history
13
+ // reports to a read; turn notifications reach the conversation's observer.
14
+ function server({ closing = false, latest = { id: "turn-1", status: "inProgress", items: [] }, threadError = null } = {}) {
15
+ const checkpoints = [];
16
+ const deferred = [];
17
+ const listeners = new Set();
18
+ const emit = notification => listeners.forEach(listener => listener(notification));
19
+ const provider = {
20
+ subscribe(listener) {
21
+ listeners.add(listener);
22
+ return () => listeners.delete(listener);
23
+ },
24
+ async readThreadStatus(threadId) {
25
+ return { raw: { id: threadId, historyMode: "paginated", status: "idle", ...(threadError ? { error: threadError } : {}) } };
26
+ },
27
+ async listThreadTurns() { return { data: [latest] }; },
28
+ async readGoal() { return { goal: null }; },
29
+ async stopThreadForObservationLoss() {}
30
+ };
31
+ const lifecycle = { closing };
32
+ const commands = createCodexConversationCommands({
33
+ async checkpoint(sessionId, input) { checkpoints.push({ sessionId, ...input }); },
34
+ async deferCheckpoint(sessionId, input) { deferred.push({ sessionId, ...input }); },
35
+ isClosing: () => lifecycle.closing,
36
+ captureContext: () => null,
37
+ runInContext: (_context, operation) => operation(),
38
+ codexAppServerConversationMessageId: (...parts) => parts.join(":"),
39
+ conversationPreparation: { async context() { return { result: { provider } }; } },
40
+ namespace: sessionId => sessionId,
41
+ providerSessions: { owner: { providers: new Map([["provider", provider]]), holdProvider: () => () => {} } },
42
+ turnOutcomes
43
+ });
44
+ const startTurn = () => {
45
+ commands.observeCodexConversation("session", "thread", provider);
46
+ emit({ method: "turn/started", params: { threadId: "thread", turn: { id: "turn-1", status: "inProgress" } } });
47
+ };
48
+ const complete = status => emit({ method: "turn/completed", params: { threadId: "thread", turn: { id: "turn-1", status } } });
49
+ const read = stoppedByShutdown => commands.readPersistentCodexConversation("session", { conversationId: "thread", stoppedByShutdown },
50
+ { provider }).then(({ status, error, runId }) => ({ status, error, runId }));
51
+ return { checkpoints, deferred, commands, lifecycle, startTurn, complete, read };
52
+ }
53
+
54
+ const OWED = { sessionId: "session", outerTurnId: "codex:thread:turn-1", threadId: "thread", turnId: "turn-1" };
55
+
56
+ test("shutdown leaves a running Temporary turn's interrupted checkpoint to the next server, which makes it once on its read", async () => {
57
+ const first = server();
58
+ first.startTurn();
59
+ first.lifecycle.closing = true;
60
+ await first.commands.leaveRunningTurnsToNextServer();
61
+ assert.deepEqual(first.checkpoints, [], "shutdown made no checkpoint");
62
+ assert.deepEqual(first.deferred, [{ ...OWED, status: "interrupted", turnOutcome: "service_restart" }]);
63
+
64
+ // The stopped process ends the turn natively as interrupted.
65
+ const next = server({ latest: { id: "turn-1", status: "interrupted", items: [] }, threadError: { message: "Turn aborted." } });
66
+ const ended = [];
67
+ const mark = runId => ({ runId, async ended() { ended.push(runId); } });
68
+ assert.deepEqual(await next.read(mark("turn-1")), { status: "interrupted", error: "", runId: "turn-1" });
69
+ assert.deepEqual(ended, ["turn-1"], "recording the stopped run lets the application make its checkpoint");
70
+ assert.deepEqual(await next.read(undefined), { status: "interrupted", error: "Turn aborted.", runId: "turn-1" },
71
+ "an unmarked interruption keeps Codex's own report");
72
+ await next.read(mark("turn-0"));
73
+ assert.deepEqual(ended, ["turn-1"], "only the marked run is recorded as ended");
74
+ assert.deepEqual(next.checkpoints, []);
75
+ });
76
+
77
+ test("a Temporary turn that ends on its own while the server stops leaves its checkpoint, with its own outcome, to the next server", async () => {
78
+ const f = server();
79
+ f.startTurn();
80
+ f.lifecycle.closing = true;
81
+ f.complete("completed");
82
+ await Promise.all(f.commands.deferredCheckpoints);
83
+ assert.deepEqual(f.checkpoints, []);
84
+ assert.deepEqual(f.deferred, [{ ...OWED, status: "completed", turnOutcome: "" }]);
85
+ assert.equal(f.commands.deferredCheckpoints.size, 0, "shutdown awaits each deferred record");
86
+
87
+ // The next server reads the completed turn as completed, and makes its checkpoint.
88
+ const next = server({ latest: { id: "turn-1", status: "completed", items: [] } });
89
+ const ended = [];
90
+ assert.equal((await next.read({ runId: "turn-1", async ended() { ended.push("turn-1"); } })).status, "completed");
91
+ assert.deepEqual(ended, ["turn-1"]);
92
+ });
93
+
94
+ test("outside shutdown a Temporary turn's end still makes its checkpoint at once", async () => {
95
+ const f = server();
96
+ f.startTurn();
97
+ f.complete("interrupted");
98
+ assert.deepEqual(f.checkpoints, [{ ...OWED, status: "interrupted", turnOutcome: "" }]);
99
+ assert.deepEqual(f.deferred, []);
100
+ await f.commands.leaveRunningTurnsToNextServer();
101
+ assert.deepEqual(f.deferred, [], "a finished turn owes nothing at shutdown");
102
+ });
103
+
104
+ // The run owner's runtime lifecycle, with the owner calls it makes recorded.
105
+ function runtimeLifecycle(calls, leaveTurnsToNextServer) {
106
+ return createCodexRuntimeLifecycle({
107
+ pendingWork: { conversationTurnStarts: new Map(), messageDeliveries: new Map(), reasoningPersistQueues: new Map(),
108
+ resultFinalizations: new Map(), threadReconciliations: new Map(), notificationTasks: new Map(), deferredCheckpoints: new Set() },
109
+ observer: { eventSubscriptions: new Map() },
110
+ checkManagedConnection: async () => {},
111
+ providerSessions: { managed: new Map(), assertOpen() {}, owner: {
112
+ lifecycleTasks: new Set(), runtimeAcquisitions: new Set(),
113
+ beginShutdown() { calls.push("owner-shutdown"); },
114
+ async invalidateRuntimes() { calls.push("stop-runtimes"); return { failed: [], providerCount: 0, results: [], stopped: 0 }; }
115
+ } },
116
+ helperLifecycle: { projectOperations: new Map(), cleanups: new Map(), mutations: new Map(), restores: new Map(), turnStarts: new Map() },
117
+ captureContext: () => null,
118
+ runInContext: (_context, operation) => operation(),
119
+ debugLog() {},
120
+ debugError: error => error?.message,
121
+ leaveTurnsToNextServer
122
+ });
123
+ }
124
+
125
+ test("a Codex shutdown leaves the running turns to the next server before any runtime stops, and waits for their records", async () => {
126
+ const calls = [];
127
+ const recorded = Promise.withResolvers();
128
+ const lifecycle = runtimeLifecycle(calls, async () => {
129
+ calls.push("leave-turns");
130
+ await recorded.promise;
131
+ calls.push("turns-recorded");
132
+ });
133
+ const shutdown = lifecycle.invalidateRuntimes({ reason: "server-shutdown" }, { enabled: true });
134
+ assert.equal(lifecycle.closing, true);
135
+ await Promise.resolve();
136
+ recorded.resolve();
137
+ await shutdown;
138
+ assert.equal(calls[0], "leave-turns");
139
+ assert.ok(calls.indexOf("turns-recorded") < calls.lastIndexOf("stop-runtimes"), "shutdown waited for the records");
140
+ });
141
+
142
+ test("a begun Codex shutdown stops nothing, and a Temporary turn that then ends on its own leaves its checkpoint to the next server", async () => {
143
+ const f = server();
144
+ f.startTurn();
145
+ const calls = [];
146
+ const lifecycle = runtimeLifecycle(calls, () => f.commands.leaveRunningTurnsToNextServer());
147
+ Object.defineProperty(f.lifecycle, "closing", { get: () => lifecycle.closing });
148
+ lifecycle.beginShutdown();
149
+ assert.equal(lifecycle.closing, true, "the closing fence is set before the call returns");
150
+ assert.deepEqual(calls, ["owner-shutdown"], "beginning the shutdown stopped nothing");
151
+ f.complete("completed");
152
+ await Promise.all(f.commands.deferredCheckpoints);
153
+ assert.deepEqual(f.checkpoints, [], "the turn's end made no checkpoint");
154
+ assert.deepEqual(f.deferred.at(-1), { ...OWED, status: "completed", turnOutcome: "" }, "its own outcome is owed");
155
+
156
+ // The full stop that follows still stops the runtimes, and leaves the turns only once.
157
+ const deferred = f.deferred.length;
158
+ await lifecycle.invalidateRuntimes({ reason: "server-shutdown" }, { enabled: true });
159
+ assert.ok(calls.includes("stop-runtimes"));
160
+ assert.equal(f.deferred.length, deferred);
161
+ assert.deepEqual(f.checkpoints, []);
162
+
163
+ // The next server's read makes the owed checkpoint once.
164
+ const next = server({ latest: { id: "turn-1", status: "completed", items: [] } });
165
+ const ended = [];
166
+ assert.equal((await next.read({ runId: "turn-1", async ended() { ended.push("turn-1"); } })).status, "completed");
167
+ assert.deepEqual(ended, ["turn-1"]);
168
+ });
@@ -53,6 +53,8 @@ function codexFixture(t, { idleMs = 20 } = {}) {
53
53
  });
54
54
  const commands = createCodexConversationCommands({
55
55
  async checkpoint() {},
56
+ // Outside shutdown, with no request contexts.
57
+ isClosing: () => false, captureContext: () => null, runInContext: (_context, operation) => operation(),
56
58
  codexAppServerConversationMessageId: (...parts) => parts.join(":"),
57
59
  codexAppServerStreamMessage: () => ({}),
58
60
  conversationPreparation: {
@@ -3856,3 +3856,60 @@ test("native history budget rejects unavailable dynamic limits before pending de
3856
3856
  assert.equal(sends, 0);
3857
3857
  }
3858
3858
  });
3859
+
3860
+ test("a Main turn the previous server's shutdown stopped reads as interrupted by a restart, with one interrupted checkpoint", async () => {
3861
+ const notices = [];
3862
+ const checkpoints = [];
3863
+ const f = await receiptFixture({
3864
+ outcomeNotice(_runtime, _sessionId, _threadId, _turnId, outcome) { notices.push(outcome); },
3865
+ checkpoint(_sessionId, input) { checkpoints.push(input); }
3866
+ });
3867
+ f.provider.interruptionOutcome = () => "";
3868
+ // The shutdown marks the running turn and leaves the rest of its saved run as it was.
3869
+ assert.deepEqual(await f.owner.markTurnStoppedByShutdown(f.sessionId, f.threadId), { marked: true });
3870
+ assert.deepEqual({ state: f.run.state, stoppedByShutdownTurnId: f.run.stoppedByShutdownTurnId },
3871
+ { state: "active", stoppedByShutdownTurnId: f.turnId });
3872
+ assert.deepEqual(await f.owner.markTurnStoppedByShutdown(f.sessionId, "another-thread"), { marked: false });
3873
+ // The next server reads the native turn as interrupted.
3874
+ const result = await f.owner.stopTurnWithProviderFailure(f.sessionId, f.threadId, f.turnId, {
3875
+ status: "interrupted", provider: f.provider, verifyInactive: false
3876
+ });
3877
+ assert.equal(result.status, "interrupted");
3878
+ assert.match(result.error, /interrupted by a restart/u);
3879
+ assert.deepEqual(notices, ["service_restart"]);
3880
+ assert.deepEqual(checkpoints.map(({ status, turnOutcome }) => ({ status, turnOutcome })),
3881
+ [{ status: "interrupted", turnOutcome: "service_restart" }]);
3882
+ assert.equal(f.run.state, "interrupted");
3883
+ });
3884
+
3885
+ test("an interrupted Main turn without the shutdown's mark keeps the plain interrupted outcome", async () => {
3886
+ const notices = [];
3887
+ const checkpoints = [];
3888
+ const f = await receiptFixture({
3889
+ outcomeNotice(_runtime, _sessionId, _threadId, _turnId, outcome) { notices.push(outcome); },
3890
+ checkpoint(_sessionId, input) { checkpoints.push(input); }
3891
+ });
3892
+ f.provider.interruptionOutcome = () => "";
3893
+ // A mark left by an earlier turn does not apply to this one.
3894
+ f.run = { ...f.run, stoppedByShutdownTurnId: "an-earlier-turn" };
3895
+ await f.owner.stopTurnWithProviderFailure(f.sessionId, f.threadId, f.turnId, {
3896
+ status: "interrupted", provider: f.provider, verifyInactive: false
3897
+ });
3898
+ assert.deepEqual(notices, ["interrupted"]);
3899
+ assert.deepEqual(checkpoints.map(({ turnOutcome }) => turnOutcome), ["interrupted"]);
3900
+ });
3901
+
3902
+ test("a Main turn that ends once shutdown has begun is left to the next server, with no checkpoint now", async () => {
3903
+ const f = await settlementFixture();
3904
+ f.owner.runtimeLifecycle = { closing: true };
3905
+ const before = structuredClone(f.run);
3906
+ assert.deepEqual(await f.owner.markTurnIdle(f.sessionId, { status: "completed", threadId: f.threadId, turnId: f.turnId }),
3907
+ { ok: true, processed: false, reason: "left_to_next_server" });
3908
+ assert.deepEqual(f.run, before, "the saved run is unchanged");
3909
+ assert.deepEqual(f.checkpoints, []);
3910
+ // Outside shutdown the same end is recorded and checkpointed at once.
3911
+ f.owner.runtimeLifecycle = { closing: false };
3912
+ await f.owner.markTurnIdle(f.sessionId, { status: "completed", threadId: f.threadId, turnId: f.turnId });
3913
+ assert.equal(f.run.state, "completed");
3914
+ assert.deepEqual(f.checkpoints.map(({ status }) => status), ["completed"]);
3915
+ });
@@ -65,7 +65,7 @@ test("an existing API client keeps opaque receipts path-safe and selected histor
65
65
  assert.deepEqual(requests[0][1], { role: "user", content: [
66
66
  { type: "text", text: "Selected prior context" }, { type: "text", text: "Authorized historical bytes" }
67
67
  ] });
68
- assert.deepEqual(requests[0].at(-1), { role: "user", content: [
68
+ assert.deepEqual(requests[0].at(-2), { role: "user", content: [
69
69
  { type: "text", text: "Current question" }, { type: "text", text: "Authorized current bytes" }
70
70
  ] });
71
71
  const files = await readdir(directory);
@@ -84,8 +84,9 @@ test("an existing API client keeps opaque receipts path-safe and selected histor
84
84
  context.history = [];
85
85
  await conversation.send({ messageId: "empty-context", text: "No prior model context" });
86
86
  const last = await conversation.wait();
87
- assert.match(requests[2][0].content, /^Existing instructions\n\nCurrent state, from the system\./u);
88
- assert.deepEqual(requests[2].slice(1).map(message => message.content), ["No prior model context"]);
87
+ assert.equal(requests[2][0].content, "Existing instructions");
88
+ assert.deepEqual(requests[2].slice(1, -1).map(message => message.content), ["No prior model context"]);
89
+ assert.match(requests[2].at(-1).content, /^Current state, from the system\./u, "The engine's state note follows the conversation");
89
90
  assert.equal(last.conversationLog.length, 3, "Explicitly empty model context never removes saved conversation history");
90
91
  await assert.rejects(conversation.send({ messageId: "x".repeat(129), text: "Must not dispatch" }), { code: "conversation_invalid_message" });
91
92
  assert.equal(requests.length, 3);
@@ -147,7 +147,7 @@ test("authorized image bytes reach the API once and only safe receipts enter sto
147
147
  assert.equal(f.reads[0].context, context);
148
148
  assert.equal(f.reads[0].conversationId, "one");
149
149
  assert.deepEqual(f.reads[0].attachmentIds, [receipt.attachmentId]);
150
- assert.deepEqual(f.requests[0].messages.at(-1).content, [
150
+ assert.deepEqual(f.requests[0].messages.at(-2).content, [
151
151
  { type: "text", text: input.text }, { type: "image_url", image_url: { url: `data:image/png;base64,${image.toString("base64")}` } }
152
152
  ]);
153
153
  assert.deepEqual(state.conversationLog[0].user.attachments, [receipt]);
@@ -174,7 +174,7 @@ test("API history and continuity reauthorize files after reopen and renewal", as
174
174
  await resumed.conversation.replace({ operationId: "renew", reason: "renewal", expectedSegmentId: first.segmentId });
175
175
  await resumed.conversation.send({ messageId: "third", text: "Look again" });
176
176
  await resumed.conversation.wait();
177
- assert.equal(resumed.requests[1].messages.at(-1).content[1].type, "image_url");
177
+ assert.equal(resumed.requests[1].messages.at(-2).content[1].type, "image_url");
178
178
  allowed = false;
179
179
  await assert.rejects(resumed.conversation.send({ messageId: "denied", text: "Look again" }), /attachment access/);
180
180
  assert.equal(resumed.requests.length, 2);
@@ -185,8 +185,8 @@ test("an image-only request is accepted without invented user text", async t =>
185
185
  const f = await fixture(t);
186
186
  await f.conversation.send({ ...input, text: "" });
187
187
  const state = await f.conversation.wait();
188
- assert.equal(f.requests[0].messages.at(-1).content.length, 1);
189
- assert.equal(f.requests[0].messages.at(-1).content[0].type, "image_url");
188
+ assert.equal(f.requests[0].messages.at(-2).content.length, 1);
189
+ assert.equal(f.requests[0].messages.at(-2).content[0].type, "image_url");
190
190
  assert.equal(state.conversationLog[0].user.text, "");
191
191
  });
192
192
 
@@ -60,7 +60,7 @@ test("renewal preserves logical history and receipts while subsequent inference
60
60
  assert.equal(f.requests.length, 1, "Replacement itself does not perform inference");
61
61
  await f.say("Continue", "second");
62
62
  const sent = f.requests[1].messages;
63
- assert.equal(sent.at(-1).content, "Continue");
63
+ assert.equal(sent.at(-2).content, "Continue", "The request comes last, before the engine's state note");
64
64
  assert.match(sent[1].content, /Retain the agreed colour: blue/);
65
65
  assert.match(sent[1].content, /Keep my context/);
66
66
  assert.match(sent[1].content, /Reply 1/);
@@ -82,9 +82,9 @@ test("saved history and current instructions use the existing authorized model c
82
82
  await f.conversation.configure({ systemPrompt: "Use the new instructions." });
83
83
  await f.conversation.send({ messageId: "two", text: "Continue" });
84
84
  await f.conversation.wait();
85
- assert.match(f.requests[1].messages[0].content, /^Use the new instructions\.\n\nCurrent state, from the system\./u,
86
- "The current instructions lead the system prompt, before the engine's state note");
87
- assert.deepEqual(f.requests[1].messages.slice(1).map(message => message.content), ["Hello", "Hello there.", "Continue"]);
85
+ assert.equal(f.requests[1].messages[0].content, "Use the new instructions.", "The current instructions are the system prompt");
86
+ assert.deepEqual(f.requests[1].messages.slice(1, -1).map(message => message.content), ["Hello", "Hello there.", "Continue"]);
87
+ assert.match(f.requests[1].messages.at(-1).content, /^Current state, from the system\./u, "The engine's state note follows the conversation");
88
88
  f.state.modelAllowed = false;
89
89
  await assert.rejects(f.conversation.send({ messageId: "three", text: "No longer authorized" }));
90
90
  assert.equal((await f.conversation.read()).conversationLog.length, 2);
@@ -114,7 +114,7 @@ test("opening an existing application transcript retains its context before and
114
114
  const f = await fixture(t, { storage });
115
115
  await f.conversation.send(input);
116
116
  const first = await f.conversation.wait();
117
- assert.deepEqual(f.requests[0].messages.slice(1).map(message => message.content), [
117
+ assert.deepEqual(f.requests[0].messages.slice(1, -1).map(message => message.content), [
118
118
  "Our chosen colour is blue.", "I will remember blue.", "Hello"
119
119
  ]);
120
120
  assert.equal((await f.conversation.send({ messageId: "earlier", text: "Our chosen colour is blue." })).duplicate, true);
@@ -125,7 +125,7 @@ test("opening an existing application transcript retains its context before and
125
125
  assert.match(messages[1].content, /Our chosen colour is blue/);
126
126
  assert.match(messages[1].content, /I will remember blue/);
127
127
  assert.equal(messages.filter(message => message.content.includes("Our chosen colour is blue")).length, 1);
128
- assert.equal(messages.at(-1).content, "Continue");
128
+ assert.equal(messages.at(-2).content, "Continue", "The request comes last, before the engine's state note");
129
129
  assert.equal((await transcript.readConversationLog("one"))[0].metadata?.runtime, undefined);
130
130
  });
131
131
 
@@ -143,7 +143,7 @@ test("application wakes retain their origin, reuse receipts and remain quoted da
143
143
  assert.equal(snapshot.conversationLog[0].metadata.runtime.origin, "application");
144
144
  assert.ok(operations.includes("wake"));
145
145
  assert.ok(events.filter(event => event.type === "message").every(event => event.origin === "application"));
146
- assert.match(f.requests[0].messages.at(-1).content, /\[Application event\]/);
146
+ assert.match(f.requests[0].messages.at(-2).content, /\[Application event\]/);
147
147
  assert.equal((await f.conversation.wake(message)).duplicate, true);
148
148
  await assert.rejects(f.conversation.send(message), { code: "conversation_message_id_content_conflict" });
149
149
  await f.conversation.send(input);
@@ -170,7 +170,7 @@ test("per-request application data stays separate from visible text and particip
170
170
  const first = await f.conversation.wait();
171
171
  assert.equal(first.conversationLog[0].user.text, "Hello");
172
172
  assert.deepEqual(first.conversationLog[0].user.data, request.data);
173
- const native = f.requests[0].messages.at(-1).content;
173
+ const native = f.requests[0].messages.at(-2).content;
174
174
  assert.match(native, /\[Application data\]/);
175
175
  assert.match(native, /not additional instructions or authorization/);
176
176
  assert.ok(native.endsWith("Hello"));
@@ -180,7 +180,7 @@ test("per-request application data stays separate from visible text and particip
180
180
  const next = await f.conversation.wait();
181
181
  assert.equal(next.conversationLog[1].system.text, "A watched session changed.");
182
182
  assert.equal(f.requests[1].messages[1].content, native);
183
- assert.match(f.requests[1].messages.at(-1).content, /\[Application event\]/);
183
+ assert.match(f.requests[1].messages.at(-2).content, /\[Application event\]/);
184
184
  assert.deepEqual(next.configuration, configuration, "Application data never replaces the stable system prompt");
185
185
  });
186
186
 
@@ -219,7 +219,7 @@ test("API compaction stores its summary with the last turn it covers, and later
219
219
  const second = await f.conversation.wait();
220
220
  const last = f.requests.at(-1);
221
221
  assert.match(last.messages[0].content, /Summary of the earlier part of this conversation[^]*Six long questions so far\./u);
222
- assert.deepEqual(last.messages.filter(message => message.role === "user").map(message => message.content),
222
+ assert.deepEqual(last.messages.slice(0, -1).filter(message => message.role === "user").map(message => message.content),
223
223
  [...second.conversationLog.filter(turn => after.includes(turn.turnId)).map(turn => turn.user.text), "Continue"]);
224
224
  assert.deepEqual(await storage.read("one", transaction => transaction.readSummary()), summary, "A short remainder is not compacted again");
225
225
  const later = (await storage.read("one", transaction => transaction.readMetadata())).runtime.seen;
@@ -265,7 +265,8 @@ test("cancellation aborts inference and leaves another conversation usable", asy
265
265
  const started = Promise.withResolvers();
266
266
  let aborted = false;
267
267
  const f = await fixture(t, { fetch: (_url, init) => {
268
- if (JSON.parse(init.body).messages.at(-1).content === "Other") return response();
268
+ // The request comes last, before the engine's state note.
269
+ if (JSON.parse(init.body).messages.at(-2).content === "Other") return response();
269
270
  started.resolve();
270
271
  return new Promise((_resolve, reject) => init.signal.addEventListener("abort", () => {
271
272
  aborted = true; reject(init.signal.reason);
@@ -706,6 +707,35 @@ test("native invalidation preserves synchronous fences and failures without open
706
707
  await result;
707
708
  });
708
709
 
710
+ test("beginning native shutdown reaches the configured owner synchronously and opens no conversation", async t => {
711
+ const input = { reason: "server-shutdown" };
712
+ const calls = [];
713
+ let asynchronousHost = true;
714
+ const native = { runOwner: {
715
+ beginShutdown() { calls.push("begin"); return "begun"; },
716
+ invalidateRuntimes() { assert.fail("Beginning shutdown stops nothing."); }
717
+ } };
718
+ const runtime = createConversationRuntime({
719
+ authorize() { assert.fail("Trusted shutdown must not acquire session authority."); },
720
+ storage: {
721
+ read() { assert.fail("Shutdown must not read a conversation."); },
722
+ write() { assert.fail("Shutdown must not allocate a conversation."); }
723
+ },
724
+ host: { conversation(request) {
725
+ calls.push("host");
726
+ assert.deepEqual(request, { context, input, operation: "beginShutdown" });
727
+ const prepared = { engine: "codex", native, context, input };
728
+ return asynchronousHost ? Promise.resolve(prepared) : prepared;
729
+ } }
730
+ });
731
+ t.after(() => runtime.close());
732
+ assert.throws(() => runtime.beginNativeShutdown({ context, input }), /synchronous configured host/);
733
+ assert.deepEqual(calls, ["host"]);
734
+ asynchronousHost = false;
735
+ assert.equal(runtime.beginNativeShutdown({ context, input }), "begun");
736
+ assert.deepEqual(calls, ["host", "host", "begin"], "the owner's fence is set before the call returns");
737
+ });
738
+
709
739
  test("renewal proof cleanup preserves OpenCode failure, consumption and retry through the configured owner", async t => {
710
740
  const owner = createOpenCodeSharedRuntime({});
711
741
  const calls = [];