fix codex stalled runs on idle thread status

This commit is contained in:
thevortex8
2026-03-21 19:28:45 -04:00
parent ff89c85255
commit 954046f671
2 changed files with 125 additions and 0 deletions
@@ -118,4 +118,27 @@ describe("Codex app-server provider", () => {
}),
).toBe(true);
});
test("only synthesizes completion from idle thread status while a run is active", () => {
expect(
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: "idle",
hasActiveRun: true,
}),
).toBe(true);
expect(
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: "idle",
hasActiveRun: false,
}),
).toBe(false);
expect(
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: "running",
hasActiveRun: true,
}),
).toBe(false);
});
});
@@ -1195,6 +1195,18 @@ const ThreadStartedNotificationSchema = z
})
.passthrough();
const ThreadStatusChangedNotificationSchema = z
.object({
threadId: z.string().optional(),
status: z
.object({
type: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const TurnStartedNotificationSchema = z
.object({
turn: z.object({ id: z.string() }).passthrough(),
@@ -1402,6 +1414,16 @@ const ItemFileChangeOutputDeltaNotificationSchema = z
})
.passthrough();
const ItemCommandExecutionOutputDeltaNotificationSchema = z
.object({
itemId: z.string().optional(),
callId: z.string().optional(),
stream: z.string().optional(),
delta: z.string().optional(),
chunk: z.string().optional(),
})
.passthrough();
const CodexEventTurnDiffNotificationSchema = z
.object({
msg: z
@@ -1416,6 +1438,7 @@ const CodexEventTurnDiffNotificationSchema = z
type ParsedCodexNotification =
| { kind: "thread_started"; threadId: string }
| { kind: "thread_status_changed"; threadId: string | null; statusType: string | null }
| { kind: "turn_started"; turnId: string }
| { kind: "turn_completed"; status: string; errorMessage: string | null }
| { kind: "plan_updated"; plan: Array<{ step: string | null; status: string | null }> }
@@ -1499,6 +1522,25 @@ const CodexNotificationSchema = z.union([
params,
}),
),
z
.object({
method: z.literal("thread/status/changed"),
params: ThreadStatusChangedNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "thread_status_changed",
threadId: params.threadId ?? null,
statusType: params.status?.type ?? null,
}),
),
z.object({ method: z.literal("thread/status/changed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("turn/started"), params: TurnStartedNotificationSchema })
.transform(
@@ -1872,6 +1914,28 @@ const CodexNotificationSchema = z.union([
params,
}),
),
z
.object({
method: z.literal("item/commandExecution/outputDelta"),
params: ItemCommandExecutionOutputDeltaNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "exec_command_output_delta",
callId: params.callId ?? params.itemId ?? null,
stream: params.stream ?? null,
chunk: params.delta ?? params.chunk ?? null,
}),
),
z
.object({ method: z.literal("item/commandExecution/outputDelta"), params: z.unknown() })
.transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("codex/event/turn_diff"),
@@ -2035,6 +2099,7 @@ export async function codexAppServerTurnInputFromPrompt(
export const __codexAppServerInternals = {
mapCodexPatchNotificationToToolCall,
shouldBufferCodexEventUntilTurnStarted,
shouldSynthesizeTurnCompletionFromThreadStatus,
};
function isTerminalAgentStreamEvent(event: AgentStreamEvent): boolean {
@@ -2057,6 +2122,13 @@ function shouldBufferCodexEventUntilTurnStarted(event: AgentStreamEvent): boolea
return true;
}
function shouldSynthesizeTurnCompletionFromThreadStatus(input: {
statusType: string | null;
hasActiveRun: boolean;
}): boolean {
return input.hasActiveRun && input.statusType === "idle";
}
class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
@@ -2095,6 +2167,7 @@ class CodexAppServerAgentSession implements AgentSession {
private warnedInvalidNotificationPayloads = new Set<string>();
private warnedIncompleteEditToolCallIds = new Set<string>();
private latestUsage: AgentUsage | undefined;
private runAwaitingTerminal = false;
private connected = false;
private collaborationModes: Array<{
name: string;
@@ -2521,6 +2594,7 @@ class CodexAppServerAgentSession implements AgentSession {
}
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
this.runAwaitingTerminal = true;
let sawTurnStarted = false;
for await (const event of queue) {
@@ -2542,6 +2616,7 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
} finally {
this.runAwaitingTerminal = false;
if (this.eventQueue === queue) {
this.eventQueue = null;
}
@@ -2875,6 +2950,31 @@ class CodexAppServerAgentSession implements AgentSession {
return;
}
if (parsed.kind === "thread_status_changed") {
if (
shouldSynthesizeTurnCompletionFromThreadStatus({
statusType: parsed.statusType,
hasActiveRun: this.runAwaitingTerminal,
})
) {
this.runAwaitingTerminal = false;
this.currentTurnId = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.emittedExecCommandStartedCallIds.clear();
this.emittedExecCommandCompletedCallIds.clear();
this.pendingCommandOutputDeltas.clear();
this.pendingFileChangeOutputDeltas.clear();
this.warnedIncompleteEditToolCallIds.clear();
this.emitEvent({
type: "turn_completed",
provider: CODEX_PROVIDER,
usage: this.latestUsage,
});
}
return;
}
if (parsed.kind === "turn_started") {
this.currentTurnId = parsed.turnId;
this.emittedItemStartedIds.clear();
@@ -2889,6 +2989,8 @@ class CodexAppServerAgentSession implements AgentSession {
}
if (parsed.kind === "turn_completed") {
this.runAwaitingTerminal = false;
this.currentTurnId = null;
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",