diff --git a/packages/agent/src/adapters/codex/codex-agent.test.ts b/packages/agent/src/adapters/codex/codex-agent.test.ts index 382fe61319..697da7dc0e 100644 --- a/packages/agent/src/adapters/codex/codex-agent.test.ts +++ b/packages/agent/src/adapters/codex/codex-agent.test.ts @@ -103,7 +103,7 @@ describe("CodexAcpAgent", () => { configOptions: [], } satisfies Partial); - await agent.newSession({ + const response = await agent.newSession({ cwd: process.cwd(), _meta: { permissionMode: "read-only" }, } as never); @@ -116,6 +116,47 @@ describe("CodexAcpAgent", () => { (agent as unknown as { sessionState: { permissionMode: string } }) .sessionState.permissionMode, ).toBe("read-only"); + expect(response.modes?.currentModeId).toBe("read-only"); + }); + + it("returns the applied initial mode in config options", async () => { + const { agent } = createAgent(); + mockCodexConnection.newSession.mockResolvedValue({ + sessionId: "session-1", + modes: { currentModeId: "read-only", availableModes: [] }, + configOptions: [ + { + id: "mode", + name: "Mode", + type: "select", + category: "mode", + currentValue: "read-only", + options: [ + { value: "read-only", name: "Read Only" }, + { value: "auto", name: "Auto" }, + { value: "full-access", name: "Full Access" }, + ], + }, + ], + } satisfies Partial); + + const response = await agent.newSession({ + cwd: process.cwd(), + _meta: { permissionMode: "full-access" }, + } as never); + + expect(mockCodexConnection.setSessionMode).toHaveBeenCalledWith({ + sessionId: "session-1", + modeId: "full-access", + }); + expect(response.modes?.currentModeId).toBe("full-access"); + expect(response.configOptions?.find((o) => o.id === "mode")).toEqual( + expect.objectContaining({ currentValue: "full-access" }), + ); + expect( + (agent as unknown as { sessionState: { configOptions: unknown[] } }) + .sessionState.configOptions, + ).toEqual(response.configOptions); }); it("propagates taskRunId and fires SDK_SESSION when loading a cloud session", async () => { @@ -180,6 +221,53 @@ describe("CodexAcpAgent", () => { ).toBe("read-only"); }); + it("updates local permission state when changing codex mode config", async () => { + const { agent, client } = createAgent(); + mockCodexConnection.newSession.mockResolvedValue({ + sessionId: "session-1", + modes: { currentModeId: "auto", availableModes: [] }, + configOptions: [], + } satisfies Partial); + mockCodexConnection.setSessionConfigOption.mockResolvedValue({ + configOptions: [ + { + id: "mode", + name: "Mode", + type: "select", + category: "mode", + currentValue: "full-access", + options: [ + { value: "read-only", name: "Read Only" }, + { value: "auto", name: "Auto" }, + { value: "full-access", name: "Full Access" }, + ], + }, + ], + }); + + await agent.newSession({ + cwd: process.cwd(), + _meta: { permissionMode: "auto" }, + } as never); + await agent.setSessionConfigOption({ + sessionId: "session-1", + configId: "mode", + value: "full-access", + }); + + expect( + (agent as unknown as { sessionState: { permissionMode: string } }) + .sessionState.permissionMode, + ).toBe("full-access"); + expect(client.sessionUpdate).toHaveBeenCalledWith({ + sessionId: "session-1", + update: { + sessionUpdate: "current_mode_update", + currentModeId: "full-access", + }, + }); + }); + it("prepends _meta.prContext to the forwarded prompt but not to the broadcast", async () => { const { agent, client } = createAgent(); mockCodexConnection.newSession.mockResolvedValue({ diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index a047f23dcd..f1f053fdd9 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -33,6 +33,7 @@ import { RequestError, type ResumeSessionRequest, type ResumeSessionResponse, + type SessionConfigOption, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, type SetSessionModeRequest, @@ -222,6 +223,32 @@ function getCurrentPermissionMode( return toCodexPermissionMode(fallbackMode); } +function withCurrentMode( + configOptions: SessionConfigOption[] | null | undefined, + mode: CodexNativeMode, +): SessionConfigOption[] | null | undefined { + if (!configOptions) return configOptions; + return configOptions.map((option) => + option.category === "mode" && option.type === "select" + ? ({ ...option, currentValue: mode } as SessionConfigOption) + : option, + ); +} + +function syncInitialModeResponse( + response: NewSessionResponse | ForkSessionResponse, + mode: CodexNativeMode | undefined, +): void { + if (!mode) return; + if (response.modes) { + response.modes = { ...response.modes, currentModeId: mode }; + } + response.configOptions = withCurrentMode( + response.configOptions, + mode, + ) as typeof response.configOptions; +} + const STRUCTURED_OUTPUT_INSTRUCTIONS = `\n\nWhen you have completed the task, call the \`${STRUCTURED_OUTPUT_TOOL_NAME}\` tool with the final structured result. The tool's input schema matches the required output format for this task. Do not describe the result in a plain message — submitting it via the tool is required for the task to be considered complete.`; /** @@ -440,11 +467,15 @@ export class CodexAcpAgent extends BaseAcpAgent { this.sessionState.configOptions = response.configOptions ?? []; this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta); - await this.applyInitialPermissionMode( + const appliedMode = await this.applyInitialPermissionMode( response.sessionId, meta?.permissionMode, response.modes?.currentModeId, ); + syncInitialModeResponse(response, appliedMode); + if (appliedMode) { + this.sessionState.configOptions = response.configOptions ?? []; + } // Emit _posthog/sdk_session so the app can track the session if (meta?.taskRunId) { @@ -586,11 +617,15 @@ export class CodexAcpAgent extends BaseAcpAgent { this.sessionState.configOptions = newResponse.configOptions ?? []; this.sessionState.contextBreakdownBaseline = buildCodexBaseline(meta); - await this.applyInitialPermissionMode( + const appliedMode = await this.applyInitialPermissionMode( newResponse.sessionId, meta?.permissionMode, newResponse.modes?.currentModeId, ); + syncInitialModeResponse(newResponse, appliedMode); + if (appliedMode) { + this.sessionState.configOptions = newResponse.configOptions ?? []; + } return newResponse; } @@ -671,16 +706,16 @@ export class CodexAcpAgent extends BaseAcpAgent { sessionId: string, permissionMode?: string, currentModeId?: string, - ): Promise { + ): Promise { if (!permissionMode) { - return; + return undefined; } const nativeMode = toCodexNativeMode(permissionMode); if (nativeMode === currentModeId) { this.sessionState.modeId = nativeMode; this.sessionState.permissionMode = toCodexPermissionMode(permissionMode); - return; + return nativeMode; } await this.codexConnection.setSessionMode({ @@ -689,6 +724,7 @@ export class CodexAcpAgent extends BaseAcpAgent { }); this.sessionState.modeId = nativeMode; this.sessionState.permissionMode = toCodexPermissionMode(permissionMode); + return nativeMode; } async listSessions( @@ -956,6 +992,8 @@ export class CodexAcpAgent extends BaseAcpAgent { this.sessionState.configOptions = response.configOptions; } if (params.configId === "mode" && typeof params.value === "string") { + this.sessionState.modeId = toCodexNativeMode(params.value); + this.sessionState.permissionMode = toCodexPermissionMode(params.value); // Signal the mode change to agent-server so its session.permissionMode // cache (used by shouldRelayPermissionToClient) stays in sync with the // real Codex mode. Claude emits the same signal from its equivalent diff --git a/packages/core/src/sessions/cloudSessionConfig.test.ts b/packages/core/src/sessions/cloudSessionConfig.test.ts index 4890cc3551..d0712992ec 100644 --- a/packages/core/src/sessions/cloudSessionConfig.test.ts +++ b/packages/core/src/sessions/cloudSessionConfig.test.ts @@ -58,6 +58,20 @@ describe("buildCloudDefaultConfigOptions", () => { expect(codex.find((o) => o.id === "mode")?.currentValue).toBe("auto"); }); + it.each([ + { initialMode: "auto", expected: "auto" }, + { initialMode: "full-access", expected: "full-access" }, + { initialMode: "plan", expected: "auto" }, + { initialMode: "default", expected: "auto" }, + ])( + "validates codex initial mode $initialMode", + ({ initialMode, expected }) => { + const options = buildCloudDefaultConfigOptions(initialMode, "codex"); + + expect(options.find((o) => o.id === "mode")?.currentValue).toBe(expected); + }, + ); + it("appends extra options after the mode option", () => { const extra = [ { diff --git a/packages/core/src/sessions/cloudSessionConfig.ts b/packages/core/src/sessions/cloudSessionConfig.ts index 2f7ae927f9..cc6c92811f 100644 --- a/packages/core/src/sessions/cloudSessionConfig.ts +++ b/packages/core/src/sessions/cloudSessionConfig.ts @@ -54,12 +54,12 @@ export function buildCloudDefaultConfigOptions( ): SessionConfigOption[] { const modes = adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); + const fallbackMode = adapter === "codex" ? "auto" : "plan"; const currentMode = - typeof initialMode === "string" + typeof initialMode === "string" && + modes.some((mode) => mode.id === initialMode) ? initialMode - : adapter === "codex" - ? "auto" - : "plan"; + : fallbackMode; return [ { id: "mode", diff --git a/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts b/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts index 2603e548b0..b931cc55fa 100644 --- a/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts +++ b/packages/ui/src/features/message-editor/suggestions/getSuggestions.test.ts @@ -18,6 +18,7 @@ function seedSessionContext(taskId: string | undefined) { function seedSessionAvailableCommands( commands: { name: string; description: string }[], + adapter?: "claude" | "codex", ) { const events: AcpMessage[] = [ { @@ -40,6 +41,7 @@ function seedSessionAvailableCommands( state.sessions[TASK_RUN_ID] = { taskId: TASK_ID, taskRunId: TASK_RUN_ID, + adapter, events, processedLineCount: 0, configOptions: [], @@ -66,6 +68,7 @@ interface Scenario { name: string; contextTaskId?: string; sessionCommands?: { name: string; description: string }[]; + adapter?: "claude" | "codex"; draftCommands?: { name: string; description: string }[]; expectContains: string[]; expectNotContains?: string[]; @@ -107,8 +110,9 @@ const SCENARIOS: Scenario[] = [ expectContains: ["my-skill"], }, { - name: "agent reporting an empty list suppresses the draft-store fallback", + name: "claude reporting an empty list suppresses the draft-store fallback", contextTaskId: TASK_ID, + adapter: "claude", draftCommands: [ { name: "fallback-only", description: "Should not appear" }, ], @@ -116,6 +120,22 @@ const SCENARIOS: Scenario[] = [ expectContains: ["good", "bad", "feedback"], expectNotContains: ["fallback-only"], }, + { + name: "codex keeps draft-store skills when agent commands are empty", + contextTaskId: TASK_ID, + adapter: "codex", + draftCommands: [{ name: "fallback-skill", description: "User skill" }], + sessionCommands: [], + expectContains: ["fallback-skill"], + }, + { + name: "codex merges agent commands and draft-store skills", + contextTaskId: TASK_ID, + adapter: "codex", + draftCommands: [{ name: "fallback-skill", description: "User skill" }], + sessionCommands: [{ name: "agent-cmd", description: "From agent" }], + expectContains: ["agent-cmd", "fallback-skill"], + }, ]; describe("getCommandSuggestions", () => { @@ -126,13 +146,15 @@ describe("getCommandSuggestions", () => { ({ contextTaskId, sessionCommands, + adapter, draftCommands, expectContains, expectNotContains, }) => { if (contextTaskId) seedSessionContext(contextTaskId); if (draftCommands) seedDraftCommands(draftCommands); - if (sessionCommands) seedSessionAvailableCommands(sessionCommands); + if (sessionCommands) + seedSessionAvailableCommands(sessionCommands, adapter); const names = getCommandSuggestions(SESSION_ID, "").map( (s) => s.command.name, diff --git a/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts b/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts index 951252ebb2..daf9d427ef 100644 --- a/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts +++ b/packages/ui/src/features/message-editor/suggestions/getSuggestions.ts @@ -9,7 +9,10 @@ import { shapeCommandSuggestions, shapeFileSuggestions, } from "@posthog/core/message-editor/suggestions"; -import { getAvailableCommandsForTask } from "@posthog/ui/features/sessions/sessionStore"; +import { + getAvailableCommandsForTask, + useSessionStore, +} from "@posthog/ui/features/sessions/sessionStore"; import { fetchRepoFiles, searchFiles } from "../../repo-files/useRepoFiles"; import { CODE_COMMANDS } from "../commands"; import { useDraftStore } from "../draftStore"; @@ -20,6 +23,19 @@ import type { IssueSuggestionItem, } from "../types"; +function getTaskCommandContext(taskId: string | undefined): { + adapter: string | undefined; + commands: ReturnType; +} { + if (!taskId) return { adapter: undefined, commands: null }; + const state = useSessionStore.getState(); + const taskRunId = state.taskIdIndex[taskId]; + return { + adapter: taskRunId ? state.sessions[taskRunId]?.adapter : undefined, + commands: getAvailableCommandsForTask(taskId), + }; +} + export async function getFileSuggestions( sessionId: string, query: string, @@ -83,15 +99,17 @@ export function getCommandSuggestions( ): CommandSuggestionItem[] { const store = useDraftStore.getState(); const taskId = store.contexts[sessionId]?.taskId; - // Agent commands (from `available_commands_update`) are authoritative once a - // session has reported them, but they arrive async after session startup — - // fall back to the trpc-fetched skills list so users don't see only the - // built-in /good /bad /feedback commands during that window. `null` means - // "agent hasn't reported yet"; an empty array means "agent reported empty" - // and we respect it. - const sessionCommands = taskId ? getAvailableCommandsForTask(taskId) : null; + // Agent commands (from `available_commands_update`) are authoritative for + // Claude once a session has reported them. Codex does not emit skill slash + // commands, so keep merging the trpc-fetched skills fallback for GPT tasks. + // `null` means "agent hasn't reported yet"; an empty array means "agent + // reported empty". + const { adapter, commands: sessionCommands } = getTaskCommandContext(taskId); const draftCommands = store.commands[sessionId] ?? []; - const agentCommands = sessionCommands ?? draftCommands; + const agentCommands = + adapter === "codex" && sessionCommands + ? mergeCommands(sessionCommands, draftCommands) + : (sessionCommands ?? draftCommands); const commands = mergeCommands(CODE_COMMANDS, agentCommands); const filtered = searchCommands(commands, query); diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 275c65724a..59d5dd8428 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -563,8 +563,10 @@ export function TaskInput({ modelOption?.type === "select" ? modelOption.currentValue : undefined; const adapterDefault = adapter === "codex" ? "auto" : "plan"; const modeFallback = - defaultInitialTaskMode === "last_used" - ? (lastUsedInitialTaskMode ?? adapterDefault) + defaultInitialTaskMode === "last_used" && + lastUsedInitialTaskMode && + isValidConfigValue(modeOption, lastUsedInitialTaskMode) + ? lastUsedInitialTaskMode : adapterDefault; const currentExecutionMode = getCurrentModeFromConfigOptions(modeOption ? [modeOption] : undefined) ??