Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion packages/agent/src/adapters/codex/codex-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ describe("CodexAcpAgent", () => {
configOptions: [],
} satisfies Partial<NewSessionResponse>);

await agent.newSession({
const response = await agent.newSession({
cwd: process.cwd(),
_meta: { permissionMode: "read-only" },
} as never);
Expand All @@ -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<NewSessionResponse>);

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 () => {
Expand Down Expand Up @@ -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<NewSessionResponse>);
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({
Expand Down
48 changes: 43 additions & 5 deletions packages/agent/src/adapters/codex/codex-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
RequestError,
type ResumeSessionRequest,
type ResumeSessionResponse,
type SessionConfigOption,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type SetSessionModeRequest,
Expand Down Expand Up @@ -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.`;

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -671,16 +706,16 @@ export class CodexAcpAgent extends BaseAcpAgent {
sessionId: string,
permissionMode?: string,
currentModeId?: string,
): Promise<void> {
): Promise<CodexNativeMode | undefined> {
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({
Expand All @@ -689,6 +724,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
});
this.sessionState.modeId = nativeMode;
this.sessionState.permissionMode = toCodexPermissionMode(permissionMode);
return nativeMode;
}

async listSessions(
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/sessions/cloudSessionConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/sessions/cloudSessionConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function seedSessionContext(taskId: string | undefined) {

function seedSessionAvailableCommands(
commands: { name: string; description: string }[],
adapter?: "claude" | "codex",
) {
const events: AcpMessage[] = [
{
Expand All @@ -40,6 +41,7 @@ function seedSessionAvailableCommands(
state.sessions[TASK_RUN_ID] = {
taskId: TASK_ID,
taskRunId: TASK_RUN_ID,
adapter,
events,
processedLineCount: 0,
configOptions: [],
Expand All @@ -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[];
Expand Down Expand Up @@ -107,15 +110,32 @@ 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" },
],
sessionCommands: [],
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", () => {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -20,6 +23,19 @@ import type {
IssueSuggestionItem,
} from "../types";

function getTaskCommandContext(taskId: string | undefined): {
adapter: string | undefined;
commands: ReturnType<typeof getAvailableCommandsForTask>;
} {
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,
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading