@jskit-ai/assistant-core 0.2.20 → 0.2.21

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/CHANGELOG.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - A delivered Codex turn is held as not yet started only until Codex reports it: a `turn/started` that arrives before the `turn/start` answer counts, and a thread read listing exactly that turn as completed or failed settles a turn whose notifications were missed, so its answer is no longer kept waiting for a notification that never comes.
5
6
  - The API driver's state note, which carries the current time, follows the conversation as its last message instead of ending the system prompt, so the provider's prompt cache keeps the whole history from one turn to the next instead of charging it uncached on each turn's first model call (`@jskit-ai/agent` `runLoop`).
6
7
  - Assistant failures that reach a person translate: 63 more undotted codes render from `jskit.errors.<code>`, and 98 sentences that share a code with others, or are sent without one, carry a `messageCode` (`assistant.claude.*`, `assistant.codex.*`, `assistant.opencode.*`, `assistant.conversation.*`, `assistant.turn.*`). The English messages are unchanged.
7
8
  - The OpenCode version mismatch, Claude control-request timeout and unreadable saved Claude conversation errors carry `params` for their catalog English.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.20",
11
+ "@jskit-ai/assistant-core": "0.2.21",
12
12
  "@jskit-ai/http-runtime": "0.2.9",
13
13
  "@jskit-ai/kernel": "0.2.9",
14
14
  "@mdi/js": "^7.4.47",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerTurnKey, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
2
2
  import { codexAppServerProviderConnectionGeneration } from "./codexModelCatalog.js";
3
3
  import {
4
4
  codexAppServerAssistantItemText,
@@ -239,8 +239,8 @@ export function createCodexNotificationObserver({
239
239
  threadId: normalizedThreadId,
240
240
  turnId: codexAppServerNotificationTurnId(notification)
241
241
  };
242
- // Anything Codex reports about a turn means it has started that turn.
243
- journal.unstartedTurns.delete(codexAppServerTurnKey(normalizedThreadId, notificationContext.turnId));
242
+ // Anything Codex reports about a turn means it has started that turn, even before its answer to turn/start.
243
+ journal.noteTurnReported(normalizedThreadId, notificationContext.turnId);
244
244
  if (method === "thread/tokenUsage/updated") {
245
245
  notificationQueue.run(notificationContext, async () => {
246
246
  const store = await createStore(normalizedSessionId);
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerSteerFailure, codexAppServerMessageRequiresNewTurn, codexAppServerMessageDeferred, codexAppServerTurnKey } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerSteerFailure, codexAppServerMessageRequiresNewTurn, codexAppServerMessageDeferred } from "./codexTurnState.js";
2
2
  import { codexAppServerRequestIsInvalid } from "./codexProviderThreads.js";
3
3
  import { codexLocalImageInput, codexAppServerDeliveredTurn, sendCodexAppServerPrompt } from "./codexProviderTurns.js";
4
4
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
@@ -74,9 +74,9 @@ export function createCodexPromptDispatch({
74
74
  if (!deliveredTurnId) {
75
75
  throw new Error("Codex app-server accepted the prompt without returning a turn id.");
76
76
  }
77
- // Codex answers turn/start before it starts the turn; its notifications about the turn say when it has.
77
+ // Codex usually answers turn/start before it starts the turn; its notifications about the turn say when it has.
78
78
  if (!codexAppServerTurnStatusIsComplete(deliveredTurnStatus)) {
79
- journal.unstartedTurns.add(codexAppServerTurnKey(threadId, deliveredTurnId));
79
+ journal.markTurnUnstarted(threadId, deliveredTurnId);
80
80
  }
81
81
  delivery.pendingUserMessage(promptSessionId, clientUserMessageId)?.onNativeReceipt();
82
82
  await recovery.markTurnActive(sessionId, {
@@ -1,4 +1,4 @@
1
- import { CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerProcessedResultEvent, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanAdoptSuccessor, codexAppServerAgentRun, CODEX_APP_SERVER_RUN_STATE, codexAppServerTurnKey } from "./codexTurnState.js";
1
+ import { CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerProcessedResultEvent, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanAdoptSuccessor, codexAppServerAgentRun, CODEX_APP_SERVER_RUN_STATE } from "./codexTurnState.js";
2
2
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
 
4
4
  /**
@@ -325,10 +325,7 @@ export function createCodexResultSettlement({ journal, output, createRuntime, pu
325
325
  normalizedThreadId,
326
326
  previousTurnId
327
327
  );
328
- journal.completedTurns.add(codexAppServerTurnKey(
329
- normalizedThreadId,
330
- previousTurnId
331
- ));
328
+ journal.markTurnCompleted(normalizedThreadId, previousTurnId);
332
329
  const previousResultKey = output.resultFinalizationKey(
333
330
  normalizedSessionId,
334
331
  normalizedThreadId,
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
3
3
  import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
4
 
5
5
  const CODEX_APP_SERVER_PROMPT_CLAIM_GRACE_MS = 15000;
6
+ const CODEX_APP_SERVER_REPORTED_TURN_LIMIT = 256;
6
7
  const CODEX_APP_SERVER_STOPPED_BY_SHUTDOWN_EVENT = "codex-app-server-stopped-by-shutdown";
7
8
 
8
9
  /**
@@ -46,6 +47,55 @@ function codexAppServerDateValueMs(value = "") {
46
47
  return Number.isFinite(parsed) ? parsed : 0;
47
48
  }
48
49
 
50
+ /**
51
+ * Which turns Codex accepted with turn/start but has not reported yet: until it reports one, its thread still reads
52
+ * idle, so an idle read cannot settle it. Codex's turn/started can also arrive before its answer to turn/start, so the
53
+ * turns it reported most recently are remembered and never marked as not started. Only a turn whose answer is still on
54
+ * its way needs that, so the newest few are enough.
55
+ * @param {Set<string>} completedTurns the journal's settled turn keys, which markTurnCompleted adds to
56
+ */
57
+ function createCodexAppServerTurnStarts(completedTurns) {
58
+ const unstartedTurns = new Set();
59
+ // Oldest first.
60
+ const reportedTurns = new Set();
61
+
62
+ /** Codex reported this turn, in a notification or by listing it as finished, so it has started it. */
63
+ function noteTurnReported(threadId = "", turnId = "") {
64
+ if (!normalizeText(turnId)) return;
65
+ const key = codexAppServerTurnKey(threadId, turnId);
66
+ unstartedTurns.delete(key);
67
+ reportedTurns.delete(key);
68
+ reportedTurns.add(key);
69
+ if (reportedTurns.size > CODEX_APP_SERVER_REPORTED_TURN_LIMIT) {
70
+ reportedTurns.delete(reportedTurns.values().next().value);
71
+ }
72
+ }
73
+
74
+ /** A turn Codex accepted waits for its report before an idle read can settle it, unless Codex already reported it. */
75
+ function markTurnUnstarted(threadId = "", turnId = "") {
76
+ const key = codexAppServerTurnKey(threadId, turnId);
77
+ if (!reportedTurns.has(key)) unstartedTurns.add(key);
78
+ }
79
+
80
+ function turnIsUnstarted(threadId = "", turnId = "") {
81
+ return unstartedTurns.has(codexAppServerTurnKey(threadId, turnId));
82
+ }
83
+
84
+ /** The run owner settled this turn: nothing waits for Codex to start it any more. */
85
+ function markTurnCompleted(threadId = "", turnId = "") {
86
+ const key = codexAppServerTurnKey(threadId, turnId);
87
+ completedTurns.add(key);
88
+ unstartedTurns.delete(key);
89
+ }
90
+
91
+ return Object.freeze({
92
+ noteTurnReported,
93
+ markTurnUnstarted,
94
+ turnIsUnstarted,
95
+ markTurnCompleted
96
+ });
97
+ }
98
+
49
99
  /**
50
100
  * Native run claims, guarded journal writes and status retirement. The containing run owner shares these exact Sets
51
101
  * and Maps by reference.
@@ -88,8 +138,7 @@ export function createCodexRunJournal({
88
138
  isClosing
89
139
  }) {
90
140
  const codexAppServerCompletedTurns = new Set();
91
- // Turns Codex accepted with turn/start but has not yet reported: until it does, its thread still reads idle.
92
- const codexAppServerUnstartedTurns = new Set();
141
+ const turnStarts = createCodexAppServerTurnStarts(codexAppServerCompletedTurns);
93
142
  const codexAppServerProcessedTurns = new Set();
94
143
  const codexAppServerActiveTimers = new Map();
95
144
  const codexAppServerFinalizingTimers = new Map();
@@ -639,7 +688,10 @@ export function createCodexRunJournal({
639
688
 
640
689
  return Object.freeze({
641
690
  completedTurns: codexAppServerCompletedTurns,
642
- unstartedTurns: codexAppServerUnstartedTurns,
691
+ noteTurnReported: turnStarts.noteTurnReported,
692
+ markTurnUnstarted: turnStarts.markTurnUnstarted,
693
+ turnIsUnstarted: turnStarts.turnIsUnstarted,
694
+ markTurnCompleted: turnStarts.markTurnCompleted,
643
695
  processedTurns: codexAppServerProcessedTurns,
644
696
  activeTimers: codexAppServerActiveTimers,
645
697
  finalizingTimers: codexAppServerFinalizingTimers,
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnCanAdoptSuccessor, codexAppServerErrorMessage, codexAppServerTurnKey } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerObservedTurnFinished, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnCanAdoptSuccessor, codexAppServerErrorMessage } from "./codexTurnState.js";
2
2
  import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
3
  import { codexAppServerStatusFromValue } from "./codexEvents.js";
4
4
  import { codexAppServerThreadRawValue, codexAppServerThreadTurnId, codexAppServerThreadError } from "./codexThreadSnapshot.js";
@@ -200,12 +200,16 @@ export function createCodexThreadStatus({ journal, settlement, recovery, output,
200
200
  turnId
201
201
  };
202
202
  }
203
+ // Codex lists the tracked turn as finished: it ran, even if its notifications about it were missed.
204
+ if (trackedProviderTurn && codexAppServerObservedTurnFinished(thread, trackedProviderTurn.turnId)) {
205
+ journal.noteTurnReported(trackedProviderTurn.threadId, trackedProviderTurn.turnId);
206
+ }
203
207
  // Codex has accepted the tracked turn but not started it. Until it does, its thread reads idle and lists the
204
208
  // turn as interrupted, so only Codex's own report about the turn settles it.
205
209
  if (
206
210
  trackedProviderTurn?.state === "active" &&
207
211
  !codexAppServerTurnStatusIsActive(status) &&
208
- journal.unstartedTurns.has(codexAppServerTurnKey(trackedProviderTurn.threadId, trackedProviderTurn.turnId))
212
+ journal.turnIsUnstarted(trackedProviderTurn.threadId, trackedProviderTurn.turnId)
209
213
  ) {
210
214
  debugLog("appServerThread.reconcile.awaitingProviderStart", {
211
215
  sessionId: normalizedSessionId,
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsActive, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnOwnsActiveGoal, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanReceiveProviderActivity, codexAppServerTurnKey } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerTurnOwnsActiveGoal, codexAppServerTurnCanReceiveProviderCompletion, codexAppServerTurnAwaitsProviderIdentity, codexAppServerTurnCanReceiveProviderActivity } from "./codexTurnState.js";
2
2
  import { codexAppServerThreadTurnId } from "./codexThreadSnapshot.js";
3
3
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
4
 
@@ -201,7 +201,7 @@ export function createCodexTurnEndings({
201
201
  };
202
202
  }
203
203
  if (journal.runInputSource(codexAppServerAgentRun(session)) === "terminal") {
204
- journal.completedTurns.add(codexAppServerTurnKey(normalizedThreadId, normalizedTurnId));
204
+ journal.markTurnCompleted(normalizedThreadId, normalizedTurnId);
205
205
  output.cleanupUntrackedTurn(normalizedThreadId, normalizedTurnId);
206
206
  await journal.markTurnIdle(normalizedSessionId, {
207
207
  status: normalizedStatus,
@@ -217,7 +217,7 @@ export function createCodexTurnEndings({
217
217
  const alreadyFinalizing = existingTurn.state === "finalizing" &&
218
218
  existingTurn.threadId === normalizedThreadId &&
219
219
  existingTurn.turnId === normalizedTurnId;
220
- journal.completedTurns.add(codexAppServerTurnKey(normalizedThreadId, normalizedTurnId));
220
+ journal.markTurnCompleted(normalizedThreadId, normalizedTurnId);
221
221
  if (!alreadyFinalizing) {
222
222
  await journal.markTurnFinalizing(normalizedSessionId, {
223
223
  status: normalizedStatus,
@@ -1,4 +1,4 @@
1
- import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerPendingUserMessageOwnership, codexAppServerTurnCanAdoptSuccessor, codexAppServerTurnKey } from "./codexTurnState.js";
1
+ import { codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerThreadStatus, codexAppServerObservedTurnFinished, codexAppServerReadThreadStatus, codexAppServerAgentRun, codexAppServerPendingUserMessageOwnership, codexAppServerTurnCanAdoptSuccessor } from "./codexTurnState.js";
2
2
  import { codexAppServerProviderTurnForOperation, codexAppServerThreadTurnId, codexAppServerThreadError } from "./codexThreadSnapshot.js";
3
3
  import { createCodexTurnActivity } from "./codexTurnActivity.js";
4
4
  import { createCodexTurnEndings } from "./codexTurnEndings.js";
@@ -270,16 +270,21 @@ export function createCodexTurnRecovery({
270
270
  if (!codexAppServerTurnStatusIsComplete(status)) {
271
271
  return currentSession;
272
272
  }
273
- if (journal.unstartedTurns.has(codexAppServerTurnKey(currentTurn.threadId, currentTurn.turnId))) {
274
- // Codex has accepted this turn but not started it, and its thread reads idle until it does.
275
- debugLog("appServerTurn.reconcile.awaitingProviderStart", {
276
- sessionId,
277
- status,
278
- threadId: currentTurn.threadId,
279
- turnId: currentTurn.turnId
280
- });
281
- scheduleCodexAppServerActiveRecovery(sessionId);
282
- return currentSession;
273
+ if (journal.turnIsUnstarted(currentTurn.threadId, currentTurn.turnId)) {
274
+ // Codex may have accepted this turn without starting it: its thread reads idle until it does. Only Codex's own
275
+ // listing of the turn as finished settles it before a notification about the turn arrives.
276
+ const observed = await codexAppServerReadThreadStatus(provider, currentTurn.threadId, { observeLatestTurn: true });
277
+ if (!codexAppServerObservedTurnFinished(observed, currentTurn.turnId)) {
278
+ debugLog("appServerTurn.reconcile.awaitingProviderStart", {
279
+ sessionId,
280
+ status,
281
+ threadId: currentTurn.threadId,
282
+ turnId: currentTurn.turnId
283
+ });
284
+ scheduleCodexAppServerActiveRecovery(sessionId);
285
+ return currentSession;
286
+ }
287
+ journal.noteTurnReported(currentTurn.threadId, currentTurn.turnId);
283
288
  }
284
289
  const completedTurnId = providerTurnId || currentTurn.turnId;
285
290
  debugLog("appServerTurn.reconcile.complete", {
@@ -38,6 +38,19 @@ function codexAppServerThreadStatus(thread = {}) {
38
38
  return liveStatus;
39
39
  }
40
40
 
41
+ /**
42
+ * Whether a thread read lists exactly this turn as finished, completed or failed. Codex lists a turn it has accepted
43
+ * but not yet started as interrupted, so only these two say that it ran.
44
+ * @param {Record<string, unknown> | null} thread a read with its latest turn observed
45
+ * @param {string} turnId
46
+ */
47
+ function codexAppServerObservedTurnFinished(thread = null, turnId = "") {
48
+ const observed = isRecord(thread?.observedTurn) ? thread.observedTurn : null;
49
+ const normalizedTurnId = normalizeNullableText(turnId);
50
+ if (!observed || !normalizedTurnId || normalizeNullableText(observed.id) !== normalizedTurnId) return false;
51
+ return ["completed", "failed"].includes(normalizeNullableText(observed.status));
52
+ }
53
+
41
54
  /**
42
55
  * Reads a native thread's status, and with observeLatestTurn its latest turn too.
43
56
  * @param {import("./codexProvider.js").CodexAppServerAgentProvider | null} provider
@@ -604,4 +617,4 @@ function codexPromptInputFromRequest(input = {}) {
604
617
  return codexAppServerMessageText(input);
605
618
  }
606
619
 
607
- export { codexAppServerRunInputSource, codexAppServerThreadStatus, codexAppServerReadThreadStatus, codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerMessageDeferred, codexPromptInputFromRequest, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_ACTIVE_RUN_STATES };
620
+ export { codexAppServerRunInputSource, codexAppServerThreadStatus, codexAppServerObservedTurnFinished, codexAppServerReadThreadStatus, codexAppServerTurnStatusIsActive, codexAppServerTurnStatusIsComplete, codexAppServerTurnStatusIsSuccessfulComplete, codexAppServerTurnStatusIsProviderFailure, codexAppServerErrorMessage, codexAppServerMessageDeferred, codexPromptInputFromRequest, CODEX_APP_SERVER_AGENT_RUN_ID, CODEX_APP_SERVER_RESULT_PROCESSED_EVENT, CODEX_AGENT_PROVIDER, CODEX_APP_SERVER_ACTIVE_RUN_STATES };
@@ -1955,6 +1955,82 @@ for (const read of ["starting watchdog", "status check"]) {
1955
1955
  });
1956
1956
  }
1957
1957
 
1958
+ // A turn Codex has accepted and not yet reported; `sendTurn` runs before Codex's answer to turn/start.
1959
+ async function unstartedTurnFixture(t, { sendTurn } = {}) {
1960
+ const notices = [];
1961
+ const f = await receiptFixture({
1962
+ finalizingGraceMs: 30,
1963
+ outcomeNotice(_runtime, _sessionId, _threadId, _turnId, outcome) { notices.push(outcome); }
1964
+ });
1965
+ t.after(() => f.owner.clearSessionRecoveryTimers(f.sessionId));
1966
+ const context = dispatchContext(f);
1967
+ f.codexStatus = "idle";
1968
+ f.notices = notices;
1969
+ f.started = { method: "turn/started", params: { threadId: f.threadId, turn: { id: f.turnId, status: "inProgress" } } };
1970
+ f.run = { ...f.run, state: "starting", providerTurnId: "", providerStatus: "", outerTurnId: context.messageId };
1971
+ f.provider.sendTurn = async () => {
1972
+ await sendTurn?.(f);
1973
+ return { id: f.turnId, status: "inProgress" };
1974
+ };
1975
+ f.provider.readThreadStatus = async () => ({ raw: { status: { type: f.codexStatus } } });
1976
+ f.history = [turn(f.turnId, "interrupted")];
1977
+ f.observe();
1978
+ assert.equal((await f.owner.startTurn(f.sessionId, {}, context)).providerFailure, "");
1979
+ assert.equal(f.run.state, "active");
1980
+ // Codex finishes the turn, and its turn/completed notification is lost: only its thread and history say so.
1981
+ f.finish = () => {
1982
+ f.codexStatus = "idle";
1983
+ f.history = [turn(f.turnId, "completed", "The complete reply.")];
1984
+ };
1985
+ return f;
1986
+ }
1987
+
1988
+ async function assertSettledWithReply(f) {
1989
+ await f.drain();
1990
+ assert.equal(f.run.state, "completed");
1991
+ assert.deepEqual((await f.replies()).map(row => row.assistant.text), ["The complete reply."]);
1992
+ assert.deepEqual(f.notices, []);
1993
+ }
1994
+
1995
+ // Codex usually answers turn/start before its turn/started notification, but may send turn/started first. Either way
1996
+ // Codex has reported the turn, so a thread status check after it finishes settles the turn with its answer.
1997
+ for (const order of ["after", "before"]) {
1998
+ test(`a turn Codex reports ${order} its turn/start answer is settled by a later idle status check`, async t => {
1999
+ const f = await unstartedTurnFixture(t, {
2000
+ sendTurn(fixture) { if (order === "before") fixture.emit(fixture.started); }
2001
+ });
2002
+ if (order === "after") f.emit(f.started);
2003
+ await f.drain();
2004
+ assert.equal(f.run.state, "active");
2005
+ f.finish();
2006
+ await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { source: "wellbeing" });
2007
+ await assertSettledWithReply(f);
2008
+ });
2009
+ }
2010
+
2011
+ // When turn/started is missed, the mark does not outlive Codex's next report about the turn: a later notification
2012
+ // naming it, or Codex listing it as completed, settles it like any other turn. No timer is involved.
2013
+ for (const report of ["a later notification naming the turn", "the starting watchdog reading it as completed",
2014
+ "a status check reading it as completed"]) {
2015
+ test(`a delivered turn whose turn/started is missed is settled by ${report}`, async t => {
2016
+ const f = await unstartedTurnFixture(t);
2017
+ await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { observeLatestTurn: true, source: "wellbeing" });
2018
+ await f.drain();
2019
+ assert.equal(f.run.state, "active", "Codex's idle thread and interrupted listing before it reports the turn do not end it");
2020
+ f.finish();
2021
+ if (report === "a later notification naming the turn") {
2022
+ f.emit({ method: "item/started", params: { threadId: f.threadId, turnId: f.turnId, item: { id: "thinking", type: "reasoning" } } });
2023
+ await f.drain();
2024
+ await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { source: "wellbeing" });
2025
+ } else if (report === "the starting watchdog reading it as completed") {
2026
+ await f.owner.recoverActiveTurn(f.sessionId, { provider: f.provider, retryOnError: false });
2027
+ } else {
2028
+ await f.owner.reconcileThreadStatus(f.sessionId, f.provider, f.threadId, { observeLatestTurn: true, source: "wellbeing" });
2029
+ }
2030
+ await assertSettledWithReply(f);
2031
+ });
2032
+ }
2033
+
1958
2034
  function controlContext(f, overrides = {}) {
1959
2035
  return {
1960
2036
  runtime: f.runtime,