diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index d4a96b45e6..d39268b047 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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) => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 55d7886df8..cfeb8863ee 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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,