diff --git a/packages/agent/src/adapters/codex/codex-agent.test.ts b/packages/agent/src/adapters/codex/codex-agent.test.ts index c5bbc656d..38240e4cb 100644 --- a/packages/agent/src/adapters/codex/codex-agent.test.ts +++ b/packages/agent/src/adapters/codex/codex-agent.test.ts @@ -127,6 +127,46 @@ describe("CodexAcpAgent", () => { ).toBe("read-only"); }); + it("prepends _meta.prContext to the forwarded prompt but not to the broadcast", async () => { + const { agent, client } = createAgent(); + mockCodexConnection.newSession.mockResolvedValue({ + sessionId: "session-1", + modes: { currentModeId: "auto", availableModes: [] }, + configOptions: [], + } satisfies Partial); + await agent.newSession({ + cwd: process.cwd(), + } as never); + + mockCodexConnection.prompt.mockResolvedValue({ stopReason: "end_turn" }); + + await agent.prompt({ + sessionId: "session-1", + prompt: [{ type: "text", text: "ship the fix" }], + _meta: { prContext: "PR #123 is open; review before editing." }, + } as never); + + // codex-acp receives the PR context prepended as a text block. + expect(mockCodexConnection.prompt).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: [ + { type: "text", text: "PR #123 is open; review before editing." }, + { type: "text", text: "ship the fix" }, + ], + }), + ); + // The broadcast shows only the real user turn — the prContext prefix + // is internal routing and should not render as a user message. + expect(client.sessionUpdate).toHaveBeenCalledTimes(1); + expect(client.sessionUpdate).toHaveBeenCalledWith({ + sessionId: "session-1", + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "ship the fix" }, + }, + }); + }); + it("broadcasts user prompt as user_message_chunk before delegating to codex-acp", async () => { const { agent, client } = createAgent(); // Seed an active session so prompt() has the state it expects. diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index 1b01d4bb9..4dda791eb 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -93,6 +93,24 @@ function toCodexPermissionMode(mode?: string): PermissionMode { return "auto"; } +/** + * Prepend `_meta.prContext` (set by the agent-server on Slack-originated + * follow-up runs) to the prompt as a text block, mirroring Claude's + * `promptToClaude` behavior. Without this, codex cloud runs lose the + * PR-review context that follow-up flows rely on. + */ +function prependPrContext(params: PromptRequest): PromptRequest { + const prContext = (params._meta as Record | undefined) + ?.prContext; + if (typeof prContext !== "string" || prContext.length === 0) { + return params; + } + return { + ...params, + prompt: [{ type: "text", text: prContext }, ...params.prompt], + }; +} + const CODEX_NATIVE_MODE: Record = { default: "auto", acceptEdits: "auto", @@ -373,9 +391,13 @@ export class CodexAcpAgent extends BaseAcpAgent { // channel, so without this broadcast the tapped stream (persisted to S3 // and rendered by the PostHog web UI) never sees a user turn and only // the assistant reply shows up. Mirrors ClaudeAcpAgent.broadcastUserMessage. + // The original params (no _meta.prContext prefix) is broadcast so the + // injected PR context is not rendered as a user message. await this.broadcastUserMessage(params); - const response = await this.codexConnection.prompt(params); + const response = await this.codexConnection.prompt( + prependPrContext(params), + ); // Usage is already accumulated via sessionUpdate notifications in // codex-client.ts. Do NOT also add response.usage here or tokens