fix(codex): reject rewinds inside a steered turn

This commit is contained in:
3ae3ae
2026-09-29 13:41:45 +09:00
parent 68e722ec2e
commit 59009a2c2a
2 changed files with 67 additions and 0 deletions
@@ -1976,6 +1976,60 @@ describe("Codex app-server provider", () => {
await session.close();
});
test.each(["legacy", "paginated"] as const)(
"rejects rewinding a steer message in a %s thread before forking",
async (historyMode) => {
const appServer = createFakeCodexAppServer({
"thread/read": () => ({ thread: { id: "thread-1", historyMode, turns: [] } }),
"turn/steer": () => ({ turnId: "turn-first" }),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
const started = await session.startTurn("original prompt");
appServer.startsTurn({ threadId: "thread-1", turnId: "turn-first" });
const originalMessage = waitForNextTimelineItem(session, "user_message");
emitCodexUserMessage(appServer, {
id: "codex-first",
text: "original prompt",
turnId: "turn-first",
});
await originalMessage;
await expect(
session.steerActiveTurn("steer prompt", { expectedTurnId: started.turnId }),
).resolves.toEqual({ status: "accepted" });
const steerMessage = waitForNextTimelineItem(session, "user_message");
emitCodexUserMessage(appServer, {
id: "codex-steer",
text: "steer prompt",
turnId: "turn-first",
});
await steerMessage;
appServer.completeTurn();
await expect(session.revertConversation({ messageId: "codex-steer" })).rejects.toThrow(
"Select the first message in the turn instead",
);
expect(appServer.requests().filter((request) => request.method === "thread/fork")).toEqual(
[],
);
expect(appServer.recordedRollbacks).toEqual([]);
expect(session.id).toBe("thread-1");
await session.revertConversation({ messageId: "codex-first" });
expect(session.id).toBe("forked-thread");
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each(["legacy", "paginated"] as const)(
"rewinds a %s thread onto a fork that keeps the custom provider and runtime MCP servers",
async (historyMode) => {
@@ -4842,6 +4842,19 @@ export class CodexAppServerAgentSession implements AgentSession {
await this.ensureThread();
}
const targetTurnId = this.userMessageProviderTurnIds.get(input.messageId);
if (targetTurnId) {
const firstMessageId = this.userMessageTurnIds.find(
(messageId) => this.userMessageProviderTurnIds.get(messageId) === targetTurnId,
);
// Codex forks at turn boundaries; a steer cannot be removed independently.
if (firstMessageId !== input.messageId) {
throw new Error(
"Codex cannot rewind a message sent during a turn without removing earlier messages. Select the first message in the turn instead.",
);
}
}
await revertCodexConversation({
client: this.client,
threadId: this.currentThreadId,