Skip to content
Open
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
30 changes: 18 additions & 12 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the
</collaboration_mode>`;

function mapCodexRuntimeMode(runtimeMode: RuntimeMode): {
readonly approvalPolicy: "on-request" | "never";
readonly sandbox: "workspace-write" | "danger-full-access";
readonly approvalPolicy: "untrusted" | "on-request" | "never";
readonly sandbox: "read-only" | "workspace-write" | "danger-full-access";
} {
if (runtimeMode === "approval-required") {
return {
approvalPolicy: "on-request",
sandbox: "workspace-write",
};
switch (runtimeMode) {
case "approval-required":
return {
approvalPolicy: "untrusted",
sandbox: "read-only",
};
case "auto-accept-edits":
return {
approvalPolicy: "on-request",
sandbox: "workspace-write",
};
case "full-access":
return {
approvalPolicy: "never",
sandbox: "danger-full-access",
};
}

return {
approvalPolicy: "never",
sandbox: "danger-full-access",
};
}

/**
Expand Down
93 changes: 49 additions & 44 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2493,57 +2493,62 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("restores base permission mode on sendTurn when interactionMode is default", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
for (const [runtimeMode, expectedBase] of [
["full-access", "bypassPermissions"],
["approval-required", "default"],
["auto-accept-edits", "acceptEdits"],
] as const) {
it.effect(`restores ${expectedBase} permission mode after plan turn (${runtimeMode})`, () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;

const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: "claudeAgent",
runtimeMode: "full-access",
});
const session = yield* adapter.startSession({
threadId: THREAD_ID,
provider: "claudeAgent",
runtimeMode,
});

// First turn in plan mode
yield* adapter.sendTurn({
threadId: session.threadId,
input: "plan this",
interactionMode: "plan",
attachments: [],
});
// First turn in plan mode
yield* adapter.sendTurn({
threadId: session.threadId,
input: "plan this",
interactionMode: "plan",
attachments: [],
});

// Complete the turn so we can send another
const turnCompletedFiber = yield* Stream.filter(
adapter.streamEvents,
(event) => event.type === "turn.completed",
).pipe(Stream.runHead, Effect.forkChild);
// Complete the turn so we can send another
const turnCompletedFiber = yield* Stream.filter(
adapter.streamEvents,
(event) => event.type === "turn.completed",
).pipe(Stream.runHead, Effect.forkChild);

harness.query.emit({
type: "result",
subtype: "success",
is_error: false,
errors: [],
session_id: "sdk-session-plan-restore",
uuid: "result-plan",
} as unknown as SDKMessage);
harness.query.emit({
type: "result",
subtype: "success",
is_error: false,
errors: [],
session_id: `sdk-session-${runtimeMode}`,
uuid: `result-${runtimeMode}`,
} as unknown as SDKMessage);

yield* Fiber.join(turnCompletedFiber);
yield* Fiber.join(turnCompletedFiber);

// Second turn back to default
yield* adapter.sendTurn({
threadId: session.threadId,
input: "now do it",
interactionMode: "default",
attachments: [],
});
// Second turn back to default
yield* adapter.sendTurn({
threadId: session.threadId,
input: "now do it",
interactionMode: "default",
attachments: [],
});

// First call sets "plan", second call restores "bypassPermissions" (the base for full-access)
assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", "bypassPermissions"]);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});
assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", expectedBase]);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});
}

it.effect("does not call setPermissionMode when interactionMode is absent", () => {
const harness = makeHarness();
Expand Down
9 changes: 6 additions & 3 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2693,7 +2693,11 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
? modelSelection.options.thinking
: undefined;
const effectiveEffort = getEffectiveClaudeCodeEffort(effort);
const permissionMode = input.runtimeMode === "full-access" ? "bypassPermissions" : undefined;
const runtimeModeToPermission: Record<string, PermissionMode> = {
"auto-accept-edits": "acceptEdits",
"full-access": "bypassPermissions",
};
const permissionMode = runtimeModeToPermission[input.runtimeMode];
const settings = {
...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
...(fastMode ? { fastMode: true } : {}),
Expand Down Expand Up @@ -2881,8 +2885,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
});
} else if (input.interactionMode === "default") {
yield* Effect.tryPromise({
try: () =>
context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"),
try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"),
catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause),
});
}
Expand Down
51 changes: 32 additions & 19 deletions apps/web/src/components/ChatView.tsx
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical

"auto-accept-edits": {

The new "auto-accept-edits" runtime mode is silently discarded by setRuntimeMode in composerDraftStore.ts, which only recognizes "approval-required" and "full-access" as valid values. When cycleRuntimeMode transitions to "auto-accept-edits", the call setComposerDraftRuntimeMode(threadId, "auto-accept-edits") converts the value to null, causing the mode to fall back to the thread default instead of persisting the user's selection.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/web/src/components/ChatView.tsx around line 259:

The new `"auto-accept-edits"` runtime mode is silently discarded by `setRuntimeMode` in `composerDraftStore.ts`, which only recognizes `"approval-required"` and `"full-access"` as valid values. When `cycleRuntimeMode` transitions to `"auto-accept-edits"`, the call `setComposerDraftRuntimeMode(threadId, "auto-accept-edits")` converts the value to `null`, causing the mode to fall back to the thread default instead of persisting the user's selection.

Evidence trail:
packages/contracts/src/orchestration.ts lines 65-69: RuntimeMode is defined with three literal values: "approval-required", "auto-accept-edits", "full-access"
apps/web/src/components/ChatView.tsx lines 250-270: RUNTIME_MODE_CONFIG includes all three modes in the UI cycle
apps/web/src/composerDraftStore.ts lines 1800-1805: setRuntimeMode function implementation only recognizes "approval-required" and "full-access" in the condition `runtimeMode === "approval-required" || runtimeMode === "full-access"`, converting any other value (including "auto-accept-edits") to null

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit fixes that

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
ListTodoIcon,
LockIcon,
LockOpenIcon,
PenLineIcon,
XIcon,
} from "lucide-react";
import { Button } from "./ui/button";
Expand Down Expand Up @@ -332,6 +333,30 @@ interface PendingPullRequestSetupRequest {
scriptId: string;
}

const runtimeModeConfig: Record<
RuntimeMode,
{ label: string; title: string; icon: React.ReactNode; next: RuntimeMode }
> = {
"approval-required": {
label: "Supervised",
title: "Supervised - click for auto-accept edits",
icon: <LockIcon />,
next: "auto-accept-edits",
},
"auto-accept-edits": {
label: "Auto-accept edits",
title: "Auto-accept edits - click for full access",
icon: <PenLineIcon />,
next: "full-access",
},
"full-access": {
label: "Full access",
title: "Full access - click for supervised",
icon: <LockOpenIcon />,
next: "approval-required",
},
};

function useLocalDispatchState(input: {
activeThread: Thread | undefined;
activeLatestTurn: Thread["latestTurn"] | null;
Expand Down Expand Up @@ -1811,10 +1836,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
const toggleInteractionMode = useCallback(() => {
handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
}, [handleInteractionModeChange, interactionMode]);
const toggleRuntimeMode = useCallback(() => {
void handleRuntimeModeChange(
runtimeMode === "full-access" ? "approval-required" : "full-access",
);
const cycleRuntimeMode = useCallback(() => {
void handleRuntimeModeChange(runtimeModeConfig[runtimeMode].next);
}, [handleRuntimeModeChange, runtimeMode]);
const togglePlanSidebar = useCallback(() => {
setPlanSidebarOpen((open) => {
Expand Down Expand Up @@ -4015,7 +4038,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
traitsMenuContent={providerTraitsMenuContent}
onToggleInteractionMode={toggleInteractionMode}
onTogglePlanSidebar={togglePlanSidebar}
onToggleRuntimeMode={toggleRuntimeMode}
onRuntimeModeChange={handleRuntimeModeChange}
/>
) : (
<>
Expand Down Expand Up @@ -4062,22 +4085,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
size="sm"
type="button"
onClick={() =>
void handleRuntimeModeChange(
runtimeMode === "full-access"
? "approval-required"
: "full-access",
)
}
title={
runtimeMode === "full-access"
? "Full access — click to require approvals"
: "Approval required — click for full access"
}
onClick={cycleRuntimeMode}
title={runtimeModeConfig[runtimeMode].title}
>
{runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
{runtimeModeConfig[runtimeMode].icon}
<span className="sr-only sm:not-sr-only">
{runtimeMode === "full-access" ? "Full access" : "Supervised"}
{runtimeModeConfig[runtimeMode].label}
</span>
</Button>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str
}
onToggleInteractionMode={vi.fn()}
onTogglePlanSidebar={vi.fn()}
onToggleRuntimeMode={vi.fn()}
onRuntimeModeChange={vi.fn()}
/>,
{ container: host },
);
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
traitsMenuContent?: ReactNode;
onToggleInteractionMode: () => void;
onTogglePlanSidebar: () => void;
onToggleRuntimeMode: () => void;
onRuntimeModeChange: (mode: RuntimeMode) => void;
}) {
return (
<Menu>
Expand Down Expand Up @@ -60,10 +60,11 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
value={props.runtimeMode}
onValueChange={(value) => {
if (!value || value === props.runtimeMode) return;
props.onToggleRuntimeMode();
props.onRuntimeModeChange(value as RuntimeMode);
}}
>
<MenuRadioItem value="approval-required">Supervised</MenuRadioItem>
<MenuRadioItem value="auto-accept-edits">Auto-accept edits</MenuRadioItem>
<MenuRadioItem value="full-access">Full access</MenuRadioItem>
</MenuRadioGroup>
{props.activePlan ? (
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1801,8 +1801,7 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
if (threadId.length === 0) {
return;
}
const nextRuntimeMode =
runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null;
const nextRuntimeMode = runtimeMode ?? null;
set((state) => {
const existing = state.draftsByThreadId[threadId];
if (!existing && nextRuntimeMode === null) {
Expand Down
6 changes: 5 additions & 1 deletion packages/contracts/src/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]);
export type ModelSelection = typeof ModelSelection.Type;

export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
export const RuntimeMode = Schema.Literals([
"approval-required",
"auto-accept-edits",
"full-access",
]);
export type RuntimeMode = typeof RuntimeMode.Type;
export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
export const ProviderInteractionMode = Schema.Literals(["default", "plan"]);
Expand Down
Loading