Skip to content
Closed
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
199 changes: 199 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type OrchestrationV2ProviderTurn,
type OrchestrationV2TurnItem,
ProjectId,
type ProviderApprovalDecision,
ProviderInstanceId,
ProviderSessionId,
ProviderThreadId,
Expand Down Expand Up @@ -2786,6 +2787,204 @@ describe("CodexAdapterV2 post-settle continuation", () => {
),
);

it.effect("stops asking for commands the user allowed for the session", () =>
Effect.scoped(
Effect.gen(function* () {
const nativeThreadId = "session-approval-thread";
const turnIds = [
"session-approval-turn-1",
"session-approval-turn-2",
"session-approval-turn-3",
];
const turnPolicies = {
"approval-required": {
approvalPolicy: "untrusted",
approvalsReviewer: "user",
sandboxPolicy: { type: "readOnly" },
},
"full-access": {
approvalPolicy: "never",
approvalsReviewer: "user",
sandboxPolicy: { type: "dangerFullAccess" },
},
} as const;
const turnModes = ["approval-required", "full-access", "approval-required"] as const;
const turnText = (index: number) => `Review round ${index + 1}.`;
const startTurn = (index: number): ReadonlyArray<CodexReplay.CodexAppServerReplayEntry> => [
{
type: "expect_outbound",
label: `turn ${index + 1} turn/start`,
frame: {
id: 3 + index,
method: "turn/start",
params: {
threadId: nativeThreadId,
input: [{ type: "text", text: turnText(index) }],
cwd: "/workspace",
model: "gpt-5.4",
...turnPolicies[turnModes[index]!],
summary: "detailed",
},
},
},
{
type: "emit_inbound",
label: `turn ${index + 1} turn/start`,
frame: {
id: 3 + index,
result: { turn: makeCodexReplayTurn({ id: turnIds[index]!, status: "inProgress" }) },
},
},
turnEvent("turn/started", turnIds[index]!),
];
const approval = (input: {
readonly id: number;
readonly turnId: string;
readonly kind: "commandExecution" | "fileChange";
readonly itemId: string;
readonly decision: string;
}): ReadonlyArray<CodexReplay.CodexAppServerReplayEntry> => [
{
type: "emit_inbound",
label: `${input.itemId} approval`,
frame: {
id: input.id,
method: `item/${input.kind}/requestApproval`,
params: {
threadId: nativeThreadId,
turnId: input.turnId,
itemId: input.itemId,
startedAtMs: 1782622441000,
...(input.kind === "commandExecution" ? { command: `cat ${input.itemId}` } : {}),
},
},
},
{
type: "expect_outbound",
label: `${input.itemId} decision`,
frame: { id: input.id, result: { decision: input.decision } },
},
];
const turnEvent = (
method: "turn/started" | "turn/completed",
turnId: string,
): CodexReplay.CodexAppServerReplayEntry => ({
type: "emit_inbound",
label: `${turnId} ${method}`,
frame: {
method,
params: {
threadId: nativeThreadId,
turn: makeCodexReplayTurn({
id: turnId,
status: method === "turn/started" ? "inProgress" : "completed",
}),
},
},
});
const transcript = makeCodexReplayTranscript({
scenario: "codex-session-approvals",
entries: [
...codexReplayPreamble({
nativeThreadId,
nativeTurnId: turnIds[0]!,
prompt: turnText(0),
}).slice(0, -3),
...startTurn(0),
...approval({
id: 100,
turnId: turnIds[0]!,
kind: "commandExecution",
itemId: "first-command",
decision: "acceptForSession",
}),
// Answered without asking: Codex alone would ask for every new command.
...approval({
id: 101,
turnId: turnIds[0]!,
kind: "commandExecution",
itemId: "second-command",
decision: "acceptForSession",
}),
// Only commands were allowed, so a file change still asks.
...approval({
id: 102,
turnId: turnIds[0]!,
kind: "fileChange",
itemId: "first-patch",
decision: "accept",
}),
turnEvent("turn/completed", turnIds[0]!),
...startTurn(1),
turnEvent("turn/completed", turnIds[1]!),
...startTurn(2),
// Back in the first mode, the allowance does not come back.
...approval({
id: 103,
turnId: turnIds[2]!,
kind: "commandExecution",
itemId: "third-command",
decision: "accept",
}),
turnEvent("turn/completed", turnIds[2]!),
],
});
const harness = yield* makeCodexReplayHarness(transcript);
const requests = () => [
...new Map(
harness.events.flatMap((event) =>
event.type === "runtime_request.updated"
? [[event.runtimeRequest.id, event.runtimeRequest] as const]
: [],
),
).values(),
];
const answer = (
count: number,
kind: "command" | "file-change",
decision: ProviderApprovalDecision,
) =>
Effect.gen(function* () {
yield* awaitUntil(() => requests().length === count, `approval request ${count}`);
const request = requests()[count - 1]!;
assert.equal(request.kind, kind);
yield* harness.runtime.respondToRuntimeRequest({ requestId: request.id, decision });
});

const runTurn = (index: number) =>
Effect.gen(function* () {
yield* harness.runtime.startTurn({
...makeCodexTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make(`session-approvals-${index + 1}`),
text: turnText(index),
}),
runOrdinal: index + 1,
providerTurnOrdinal: index + 1,
runtimePolicy: { ...CODEX_TEST_RUNTIME_POLICY, runtimeMode: turnModes[index]! },
});
});

yield* runTurn(0);
yield* answer(1, "command", "acceptForSession");
yield* answer(2, "file-change", "accept");
yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn 1");
yield* runTurn(1);
yield* awaitUntil(() => harness.terminalEvents().length === 2, "turn 2");
yield* runTurn(2);
yield* answer(3, "command", "accept");
yield* awaitUntil(() => harness.terminalEvents().length === 3, "turn 3");
assert.lengthOf(requests(), 3);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);

it.effect("preserves T3 context on the wire and restores it after compaction", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
68 changes: 68 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1880,6 +1880,43 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
const pendingRuntimeRequests = yield* Ref.make(
new Map<string, PendingCodexRuntimeRequest>(),
);
/**
* Approval kinds the user chose "Always allow this session" for, by
* native thread. Codex's own acceptForSession only skips the exact
* command or patch it answered, so every new command would ask again.
* A turn in another runtime mode ends the allowance, and so does the
* thread unloading, like Codex's own session approvals.
*/
const sessionApprovals = new Map<
string,
{ readonly runtimeMode: RuntimeMode; readonly kinds: Set<ProviderRequestKind> }
>();
const allowedForSession = (
nativeThreadId: string,
context: ActiveCodexTurnContext,
kind: ProviderRequestKind,
) => {
const allowed = sessionApprovals.get(nativeThreadId);
return (
allowed?.runtimeMode === context.input.runtimePolicy.runtimeMode &&
allowed.kinds.has(kind)
);
};
const rememberSessionApproval = (
nativeThreadId: string,
context: ActiveCodexTurnContext,
kind: ProviderRequestKind,
decision: ProviderApprovalDecision,
) => {
if (decision !== "acceptForSession" && decision !== "acceptAlways") return;
const runtimeMode = context.input.runtimePolicy.runtimeMode;
const allowed = sessionApprovals.get(nativeThreadId);
if (allowed?.runtimeMode === runtimeMode) {
allowed.kinds.add(kind);
} else {
sessionApprovals.set(nativeThreadId, { runtimeMode, kinds: new Set([kind]) });
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Turn contexts retained past turn/completed while background command
* items started in that turn are still running, so late item events
Expand Down Expand Up @@ -5076,6 +5113,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
payload,
);
}
if (allowedForSession(payload.threadId, context, "command")) {
return {
decision: "acceptForSession",
} satisfies CodexSchema.CommandExecutionRequestApprovalResponse;
}

const nativeRequestId = payload.approvalId ?? payload.itemId;
const artifacts = yield* buildApprovalRequestArtifacts({
Expand Down Expand Up @@ -5124,6 +5166,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
}),
),
);
rememberSessionApproval(payload.threadId, context, "command", resolved);
return {
decision: resolved === "acceptAlways" ? "acceptForSession" : resolved,
} satisfies CodexSchema.CommandExecutionRequestApprovalResponse;
Expand All @@ -5139,6 +5182,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
payload,
);
}
if (allowedForSession(payload.threadId, context, "file-change")) {
return {
decision: "acceptForSession",
} satisfies CodexSchema.FileChangeRequestApprovalResponse;
}

const artifacts = yield* buildApprovalRequestArtifacts({
context,
Expand Down Expand Up @@ -5184,6 +5232,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
}),
),
);
rememberSessionApproval(payload.threadId, context, "file-change", resolved);
return {
decision: resolved === "acceptAlways" ? "acceptForSession" : resolved,
} satisfies CodexSchema.FileChangeRequestApprovalResponse;
Expand Down Expand Up @@ -5343,6 +5392,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
payload,
);
}
if (allowedForSession(payload.conversationId, context, "command")) {
return {
decision: "approved_for_session",
} satisfies CodexSchema.ExecCommandApprovalResponse;
}

const nativeRequestId = payload.approvalId ?? payload.callId;
const artifacts = yield* buildApprovalRequestArtifacts({
Expand Down Expand Up @@ -5389,6 +5443,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
}),
),
);
rememberSessionApproval(payload.conversationId, context, "command", resolved);
return {
decision: approvalDecisionToLegacyReviewDecision(resolved),
} satisfies CodexSchema.ExecCommandApprovalResponse;
Expand All @@ -5404,6 +5459,11 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
payload,
);
}
if (allowedForSession(payload.conversationId, context, "file-change")) {
return {
decision: "approved_for_session",
} satisfies CodexSchema.ApplyPatchApprovalResponse;
}

const artifacts = yield* buildApprovalRequestArtifacts({
context,
Expand Down Expand Up @@ -5449,6 +5509,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
}),
),
);
rememberSessionApproval(payload.conversationId, context, "file-change", resolved);
return {
decision: approvalDecisionToLegacyReviewDecision(resolved),
} satisfies CodexSchema.ApplyPatchApprovalResponse;
Expand Down Expand Up @@ -6204,6 +6265,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
) =>
Effect.gen(function* () {
const threadId = yield* getNativeThreadId(turnInput.providerThread);
// Returning to the earlier mode must not bring the allowance back.
if (
sessionApprovals.get(threadId)?.runtimeMode !== turnInput.runtimePolicy.runtimeMode
) {
sessionApprovals.delete(threadId);
}
const mcpSession = yield* mcpSessions.read(turnInput.threadId);
const turnStartParams = yield* buildCodexTurnStartParams({
nativeThreadId: threadId,
Expand Down Expand Up @@ -6706,6 +6773,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
unloadThread: (unloadInput) =>
Effect.gen(function* () {
const nativeThreadId = yield* getNativeThreadId(unloadInput.providerThread);
sessionApprovals.delete(nativeThreadId);
yield* client.request("thread/unsubscribe", { threadId: nativeThreadId });
}).pipe(
Effect.mapError((cause) =>
Expand Down
Loading