Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] a2f0fb9e32 fix(codex): distinguish task acceptance from hook housekeeping
Stopped submit hooks reject input too. Pre-prompt compaction and hook context are housekeeping, so they must not turn a fully rejected task into a successful run.
2026-11-03 07:47:09 +00:00
paseo-bot[bot] bda2464142 fix(codex): fail fully rejected automation runs
A successful native turn can contain only a blocked prompt. Fail that attempt with its rejection reason while retaining successful outcomes once the native turn accepts input or starts work.
2026-11-03 07:36:22 +00:00
paseo-bot[bot] f3ad48dc46 Merge commit 'c679822164faceeae147e26c33802c1aa3d3d0dd' into fix/codex-rejected-automation-0d3d971d 2026-11-03 07:26:36 +00:00
paseo-bot[bot]andScott Zhang c679822164 fix(codex): show hook rejections without failing accepted work
A blocked queued submission can share a native turn with accepted input.
Show its feedback through the existing error notification instead of
replacing Codex's successful turn outcome. Keep protocol framing and
completion waits inside the existing test fixture.

Co-authored-by: Scott Zhang <scottzh@fireworks.ai>
2026-11-03 06:17:49 +00:00
Scott Zhang 470f4b3e8b fix(server): surface Codex prompt hook rejections
Codex can emit a blocked UserPromptSubmit hook followed by a completed
turn without accepting a user message. Capture the blocking feedback for
the current turn and report it through the existing failure timeline.

Ignore unrelated hook results and clear rejection state between turns so
corrected submissions can proceed without reloading the session.

Validated with 195 Codex adapter tests, server and CLI builds, server
typechecking, lint, and a real Codex blocked-prompt/retry check.

Refs: #6503
Co-authored-by: Scott Zhang <scottzh@fireworks.ai>
2026-11-03 05:59:44 +00:00
3 changed files with 544 additions and 6 deletions
@@ -32,6 +32,405 @@ import {
toAgentUsage,
} from "./codex-app-server-agent.js";
describe("Codex prompt hook rejection", () => {
test.each([
{ status: "blocked", entryKind: "feedback" },
{ status: "stopped", entryKind: "stop" },
])(
"shows a $status queued follow-up without failing accepted work in the same turn",
async ({ status, entryKind }) => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.steerActiveTurn!("Allowed follow-up", { expectedTurnId: paseoTurnId });
await session.steerActiveTurn!("Rejected follow-up", { expectedTurnId: paseoTurnId });
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "completed",
entries: [],
});
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status,
entries: [{ kind: entryKind, text: "Follow-up rejected" }],
});
appServer.says({ threadId: "thread-1", text: "Accepted work completed" });
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({ type: "turn_completed" });
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
type: "assistant_message",
text: "Accepted work completed",
}),
}),
);
expect(events).toContainEqual({
type: "timeline",
provider: "codex",
item: { type: "notification", level: "error", message: "Follow-up rejected" },
turnId: paseoTurnId,
});
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each(["before", "after"])(
"keeps an accepted input successful when rejected input arrives %s it, even without a reply",
async (order) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
function rejectInput(): void {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected follow-up" }],
});
}
if (order === "before") rejectInput();
appServer.acceptsUserMessage({
threadId: "thread-1",
turnId: "native-A",
itemId: "accepted-input",
text: "Accepted prompt",
});
if (order === "after") rejectInput();
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({ type: "turn_completed" });
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each([
{ status: "interrupted", expected: { type: "turn_canceled", reason: "interrupted" } },
{ status: "failed", expected: { type: "turn_failed", error: "Native failure" } },
] as const)(
"preserves native $status outcomes after a rejected prompt",
async ({ status, expected }) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected prompt" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn({ status, error: { message: "Native failure" } });
await expect(completion).resolves.toMatchObject(expected);
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each([
{ threadId: "thread-1", turnId: "older-turn" },
{ threadId: "child-thread", turnId: "native-A" },
])("does not count foreign input as accepted work: $threadId $turnId", async (input) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
appServer.acceptsUserMessage({ ...input, itemId: "foreign-input", text: "Unrelated input" });
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected prompt" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({
type: "turn_failed",
error: "Rejected prompt",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test.each(["compaction", "hook context"])(
"does not count pre-prompt %s as accepted task work",
async (housekeeping) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
if (housekeeping === "compaction") {
appServer.startsCompaction({ threadId: "thread-1", itemId: "pre-prompt-compaction" });
appServer.completesCompaction({ threadId: "thread-1", itemId: "pre-prompt-compaction" });
} else {
appServer.addsHookContext({
threadId: "thread-1",
turnId: "native-A",
itemId: "hook-context",
text: "Housekeeping context",
});
}
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected after compaction" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({
type: "turn_failed",
error: "Rejected after compaction",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each([
{ status: "blocked", entryKind: "feedback" },
{ status: "stopped", entryKind: "stop" },
])(
"fails a fully $status manager run and permits a corrected submission",
async ({ status, entryKind }) => {
const appServer = createFakeCodexAppServer();
const provider = createProviderWithFakeAppServer(appServer);
const client: AgentClient = {
provider: "codex",
capabilities: provider.capabilities,
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
createSession: (...args) => provider.createSession(...args),
resumeSession: (...args) => provider.resumeSession(...args),
};
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
const agent = await manager.resumeAgentFromPersistence(
{ provider: "codex", sessionId: "thread-1" },
createConfig({ cwd: process.cwd() }),
);
try {
const run = manager.runAgent(agent.id, "Disallowed prompt", {
clientMessageId: "blocked-prompt",
});
const failure = expect(run).rejects.toThrow("Remove the disallowed text and retry.");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" });
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status,
entries: [{ kind: entryKind, text: "Remove the disallowed text and retry." }],
});
appServer.completeTurn();
await failure;
expect(manager.getTimeline(agent.id)).toEqual([
{
type: "user_message",
text: "Disallowed prompt",
messageId: "blocked-prompt",
clientMessageId: "blocked-prompt",
},
{
type: "notification",
level: "error",
message: "Remove the disallowed text and retry.",
},
{
type: "assistant_message",
text: "[System Error] Remove the disallowed text and retry.",
},
]);
expect(manager.hasInFlightRun(agent.id)).toBe(false);
expect(manager.getAgent(agent.id)?.lastError).toBe("Remove the disallowed text and retry.");
const retry = manager.runAgent(agent.id, "Corrected prompt", {
clientMessageId: "corrected-prompt",
});
await appServer.waitForTurnStart(2);
appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" });
appServer.says({ threadId: "thread-1", text: "Accepted" });
appServer.completeTurn();
await expect(retry).resolves.toMatchObject({ finalText: "Accepted", canceled: false });
expect(manager.hasInFlightRun(agent.id)).toBe(false);
appServer.assertNoErrors();
} finally {
await manager.closeAgent(agent.id);
}
},
);
test("shows blocking feedback and accepts the next turn", async () => {
const appServer = createFakeCodexAppServer();
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Remove the disallowed text and retry." }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await completion;
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
item: {
type: "notification",
level: "error",
message: "Remove the disallowed text and retry.",
},
turnId: paseoTurnId,
},
{
type: "turn_failed",
provider: "codex",
error: "Remove the disallowed text and retry.",
turnId: paseoTurnId,
},
]);
events.length = 0;
const replacement = await session.startTurn("Allowed replacement prompt");
appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" });
appServer.says({ threadId: "thread-1", text: "Accepted" });
const replacementCompletion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await replacementCompletion;
expect(events.filter((event) => event.type.startsWith("turn_"))).toEqual([
{ type: "turn_started", provider: "codex", turnId: replacement.turnId },
{ type: "turn_completed", provider: "codex", usage: undefined, turnId: replacement.turnId },
]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test.each([
{ eventName: "preToolUse", status: "blocked", turnId: "native-A", threadId: "thread-1" },
{
eventName: "userPromptSubmit",
status: "completed",
turnId: "native-A",
threadId: "thread-1",
},
{ eventName: "userPromptSubmit", status: "failed", turnId: "native-A", threadId: "thread-1" },
{
eventName: "userPromptSubmit",
status: "blocked",
turnId: "older-turn",
threadId: "thread-1",
},
{ eventName: "userPromptSubmit", status: "blocked", turnId: null, threadId: "thread-1" },
{
eventName: "userPromptSubmit",
status: "blocked",
turnId: "native-A",
threadId: "child-thread",
},
])(
"does not fail the foreground turn for an unrelated hook: $eventName $status $turnId $threadId",
async ({ eventName, status, turnId, threadId }) => {
const appServer = createFakeCodexAppServer();
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
appServer.completesHook({
threadId,
turnId,
eventName,
status,
entries: [{ kind: "feedback", text: "Unrelated feedback" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await completion;
expect(events).toEqual([
{ type: "turn_completed", provider: "codex", turnId: paseoTurnId, usage: undefined },
]);
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test("provides an actionable failure when the blocking hook has no feedback", async () => {
const appServer = createFakeCodexAppServer();
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await completion;
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
item: {
type: "notification",
level: "error",
message: "Prompt rejected by a UserPromptSubmit hook.",
},
turnId: paseoTurnId,
},
{
type: "turn_failed",
provider: "codex",
error: "Prompt rejected by a UserPromptSubmit hook.",
turnId: paseoTurnId,
},
]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
});
describe("mapCodexPlanUpdateToTodo", () => {
test("preserves checklist progress without creating a plan card", () => {
expect(
@@ -91,6 +490,7 @@ import {
fakeCodexThreadHistory,
type FakeCodexAppServer,
waitForNextEvent,
waitForNextTurnCompletion,
waitForNextPermission,
waitForNextTimelineItem,
waitForProviderSubagent,
@@ -2574,6 +2574,14 @@ const CodexEventThreadRolledBackNotificationSchema = z
type ParsedCodexNotification =
| { kind: "thread_started"; threadId: string }
| { kind: "turn_started"; turnId: string; threadId: string | null }
| {
kind: "hook_completed";
threadId: string;
turnId: string | null;
eventName: string;
status: string;
entries: Array<{ kind: string; text: string }>;
}
| {
kind: "turn_completed";
status: string;
@@ -2694,6 +2702,36 @@ function isCodexDeltaNotification(
}
const CodexNotificationSchema = z.union([
z
.object({
method: z.literal("hook/completed"),
params: z.object({
threadId: z.string(),
turnId: z.string().nullish(),
run: z.object({
eventName: z.string(),
status: z.string(),
entries: z.array(z.object({ kind: z.string(), text: z.string() })),
}),
}),
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "hook_completed",
threadId: params.threadId,
turnId: params.turnId ?? null,
eventName: params.run.eventName,
status: params.run.status,
entries: params.run.entries,
}),
),
z.object({ method: z.literal("hook/completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("thread/started"), params: ThreadStartedNotificationSchema })
.transform(
@@ -3483,6 +3521,7 @@ export class CodexAppServerAgentSession implements AgentSession {
// in this app-server, so no other app-server can resume it and it is not reported.
private savedThreadId: string | null = null;
private currentTurnId: string | null = null;
private turnPromptOutcome: "unobserved" | "accepted" | { rejection: string } = "unobserved";
private pendingForegroundTurnIdentification: {
foregroundTurnId: string;
promise: Promise<string | null>;
@@ -5778,6 +5817,9 @@ export class CodexAppServerAgentSession implements AgentSession {
case "turn_completed":
this.handleTurnCompletedNotification(parsed);
return;
case "hook_completed":
this.handleHookCompletedNotification(parsed);
return;
case "plan_updated":
this.handlePlanUpdatedNotification(parsed);
return;
@@ -5805,9 +5847,11 @@ export class CodexAppServerAgentSession implements AgentSession {
this.handlePatchApplyCompletedNotification(parsed);
return;
case "item_completed":
this.recordAcceptedTurnWork(parsed);
await this.handleItemCompletedNotification(parsed);
return;
case "item_started":
this.recordAcceptedTurnWork(parsed);
await this.handleItemStartedNotification(parsed);
return;
case "invalid_payload":
@@ -6393,6 +6437,34 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: current });
}
private handleHookCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "hook_completed" }>,
): void {
const rejectsCurrentPrompt =
parsed.eventName === "userPromptSubmit" &&
(parsed.status === "blocked" || parsed.status === "stopped") &&
this.currentTurnId !== null &&
parsed.turnId === this.currentTurnId;
if (!rejectsCurrentPrompt) return;
const feedback = parsed.entries
.filter(
(entry) => entry.kind === "feedback" || entry.kind === "stop" || entry.kind === "error",
)
.map((entry) => entry.text.trim())
.filter((text) => text.length > 0)
.join("\n\n");
const message = feedback || "Prompt rejected by a UserPromptSubmit hook.";
if (this.turnPromptOutcome !== "accepted") {
this.turnPromptOutcome = { rejection: message };
}
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "notification", level: "error", message },
});
}
private handleTurnCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "turn_completed" }>,
): void {
@@ -6423,6 +6495,12 @@ export class CodexAppServerAgentSession implements AgentSession {
} else if (parsed.status === "interrupted") {
this.dismissInterruptedAsyncQuestions();
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else if (typeof this.turnPromptOutcome === "object") {
this.emitEvent({
type: "turn_failed",
provider: CODEX_PROVIDER,
error: this.turnPromptOutcome.rejection,
});
} else {
if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
@@ -6442,7 +6520,24 @@ export class CodexAppServerAgentSession implements AgentSession {
this.resetTurnTrackingState();
}
private recordAcceptedTurnWork(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
// Pre-prompt housekeeping can precede a rejected input. Only admitted input or
// task work keeps a rejected follow-up from failing the native turn.
const itemType = normalizeCodexThreadItemType(parsed.item.type);
if (itemType === CODEX_CONTEXT_COMPACTION_TYPE || itemType === "hookPrompt") return;
if (
this.currentTurnId !== null &&
parsed.threadId === this.currentThreadId &&
(parsed.turnId === null || parsed.turnId === this.currentTurnId)
) {
this.turnPromptOutcome = "accepted";
}
}
private resetTurnTrackingState(): void {
this.turnPromptOutcome = "unobserved";
this.latestPlanResult = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
@@ -56,6 +56,13 @@ interface FakeLegacyPatch {
path: string;
diff: string;
}
interface FakeHookCompletion {
threadId: string;
turnId: string | null;
eventName: string;
status: string;
entries: Array<{ kind: string; text: string }>;
}
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
@@ -67,13 +74,21 @@ export interface FakeCodexAppServer {
readonly recordedRollbacks: JsonObject[];
requests(): readonly JsonObject[];
assertNoErrors(): void;
waitForTurnStart(): Promise<JsonObject>;
waitForTurnStart(occurrence?: number): Promise<JsonObject>;
waitForRequest(method: string): Promise<JsonObject>;
disconnect(): void;
nextResponse(): Promise<string>;
startsTurn(params: { threadId: string; turnId?: string }): void;
startsCompaction(params: { threadId: string; itemId: string }): void;
updatesPlan(params: { threadId: string; steps: string[] }): void;
acceptsUserMessage(params: {
threadId: string;
turnId: string;
itemId: string;
text: string;
}): void;
addsHookContext(params: { threadId: string; turnId: string; itemId: string; text: string }): void;
completesHook(params: FakeHookCompletion): void;
completeTurn(params?: {
threadId?: string;
status?: "completed" | "failed" | "interrupted";
@@ -368,9 +383,11 @@ export function createFakeCodexAppServer(
throw errors[0];
}
},
async waitForTurnStart() {
async waitForTurnStart(occurrence = 1) {
const message = await waitForMessage(
(candidate) => candidate.method === "turn/start",
(candidate) =>
messages.filter((request) => request.method === "turn/start")[occurrence - 1] ===
candidate,
"turn start request",
);
return toJsonObject(message.params);
@@ -418,6 +435,27 @@ export function createFakeCodexAppServer(
})}\n`,
);
},
acceptsUserMessage({ threadId, turnId, itemId, text }) {
const params = {
threadId,
turnId,
item: { type: "userMessage", id: itemId, content: [{ type: "text", text }] },
};
writeNotification("item/started", params);
writeNotification("item/completed", params);
},
addsHookContext({ threadId, turnId, itemId, text }) {
const params = {
threadId,
turnId,
item: { type: "hookPrompt", id: itemId, fragments: [{ text, hookRunId: "hook-run" }] },
};
writeNotification("item/started", params);
writeNotification("item/completed", params);
},
completesHook({ threadId, turnId, ...run }) {
writeNotification("hook/completed", { threadId, turnId, run });
},
completeTurn(params = {}) {
child.stdout.write(
`${JSON.stringify({
@@ -689,16 +727,17 @@ type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent
export function waitForNextEvent<TType extends StreamEventType>(
session: AgentSession,
type: TType,
type: TType | readonly TType[],
accepts?: (event: StreamEventOfType<TType>) => boolean,
): Promise<StreamEventOfType<TType>> {
const types: readonly StreamEventType[] = typeof type === "string" ? [type] : type;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out waiting for ${type}`));
reject(new Error(`Timed out waiting for ${types.join(" or ")}`));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (event.type !== type) {
if (!types.includes(event.type)) {
return;
}
const typedEvent = event as StreamEventOfType<TType>;
@@ -712,6 +751,10 @@ export function waitForNextEvent<TType extends StreamEventType>(
});
}
export function waitForNextTurnCompletion(session: AgentSession) {
return waitForNextEvent(session, ["turn_completed", "turn_failed", "turn_canceled"]);
}
type TimelineEvent = StreamEventOfType<"timeline">;
type ProviderSubagentEvent = StreamEventOfType<"provider_subagent">;