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
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
95 changes: 51 additions & 44 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ApprovalRequestId,
ProviderItemId,
ProviderRuntimeEvent,
type RuntimeMode,
ThreadId,
} from "@t3tools/contracts";
import { assert, describe, it } from "@effect/vitest";
Expand Down Expand Up @@ -2496,57 +2497,63 @@ 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;
it.effect.each<{ runtimeMode: RuntimeMode; expectedBase: PermissionMode }>([
{ runtimeMode: "full-access", expectedBase: "bypassPermissions" },
{ runtimeMode: "approval-required", expectedBase: "default" },
{ runtimeMode: "auto-accept-edits", expectedBase: "acceptEdits" },
])(
"restores $expectedBase permission mode after plan turn ($runtimeMode)",
({ runtimeMode, expectedBase }) => {
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];
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.

Implicit undefined for approval-required lacks exhaustiveness safety

Low Severity

The runtimeModeToPermission map is typed as Record<string, PermissionMode> and intentionally omits "approval-required", relying on undefined to propagate through truthy checks and ?? "default" fallbacks. Unlike the Codex adapter's mapCodexRuntimeMode switch statement which gets TypeScript exhaustiveness checking, this string-keyed Record provides no compile-time guarantee that all RuntimeMode values are accounted for. If a new runtime mode is ever added, it would silently fall through to undefined / "default" behavior without any compiler warning. Making this a complete map over RuntimeMode (mapping "approval-required" explicitly to "default") would make the intent clear and get exhaustiveness checking from TypeScript.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6707b61. Configure here.

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
36 changes: 36 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,16 @@ async function waitForButtonContainingText(text: string): Promise<HTMLButtonElem
);
}

async function waitForSelectItemContainingText(text: string): Promise<HTMLElement> {
return waitForElement(
() =>
Array.from(document.querySelectorAll<HTMLElement>('[data-slot="select-item"]')).find((item) =>
item.textContent?.includes(text),
) ?? null,
`Unable to find select item containing "${text}".`,
);
}

async function expectComposerActionsContained(): Promise<void> {
const footer = await waitForElement(
() => document.querySelector<HTMLElement>('[data-chat-composer-footer="true"]'),
Expand Down Expand Up @@ -2326,6 +2336,32 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("shows runtime mode descriptions in the desktop composer access select", async () => {
setDraftThreadWithoutWorktree();

const mounted = await mountChatView({
viewport: WIDE_FOOTER_VIEWPORT,
snapshot: createDraftOnlySnapshot(),
});

try {
const runtimeModeSelect = await waitForButtonByText("Full access");
runtimeModeSelect.click();

expect((await waitForSelectItemContainingText("Supervised")).textContent).toContain(
"Ask before commands and file changes",
);

const autoAcceptItem = await waitForSelectItemContainingText("Auto-accept edits");
expect(autoAcceptItem.textContent).toContain("Auto-approve edits");
expect((await waitForSelectItemContainingText("Full access")).textContent).toContain(
"Allow commands and edits without prompts",
);
} finally {
await mounted.cleanup();
}
});

it("keeps removed terminal context pills removed when a new one is added", async () => {
const removedLabel = "Terminal 1 lines 1-2";
const addedLabel = "Terminal 2 lines 9-10";
Expand Down
89 changes: 61 additions & 28 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,13 @@ import {
ListTodoIcon,
LockIcon,
LockOpenIcon,
type LucideIcon,
PenLineIcon,
XIcon,
} from "lucide-react";
import { Button } from "./ui/button";
import { Separator } from "./ui/separator";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
import { cn, randomUUID } from "~/lib/utils";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { toastManager } from "./ui/toast";
Expand Down Expand Up @@ -410,6 +413,29 @@ interface TerminalLaunchContext {
worktreePath: string | null;
}

const runtimeModeConfig: Record<
RuntimeMode,
{ label: string; description: string; icon: LucideIcon }
> = {
"approval-required": {
label: "Supervised",
description: "Ask before commands and file changes.",
icon: LockIcon,
},
"auto-accept-edits": {
label: "Auto-accept edits",
description: "Auto-approve edits, ask before other actions.",
icon: PenLineIcon,
},
"full-access": {
label: "Full access",
description: "Allow commands and edits without prompts.",
icon: LockOpenIcon,
},
};

const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];

type PersistentTerminalLaunchContext = Pick<TerminalLaunchContext, "cwd" | "worktreePath">;

function useLocalDispatchState(input: {
Expand Down Expand Up @@ -960,6 +986,8 @@ export default function ChatView(props: ChatViewProps) {
composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
const interactionMode =
composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
const runtimeModeOption = runtimeModeConfig[runtimeMode];
const RuntimeModeIcon = runtimeModeOption.icon;
const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
const canCheckoutPullRequestIntoThread = isLocalDraftThread;
const diffOpen = rawSearch.diff === "1";
Expand Down Expand Up @@ -2350,11 +2378,6 @@ export default function ChatView(props: ChatViewProps) {
const toggleInteractionMode = useCallback(() => {
handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
}, [handleInteractionModeChange, interactionMode]);
const toggleRuntimeMode = useCallback(() => {
void handleRuntimeModeChange(
runtimeMode === "full-access" ? "approval-required" : "full-access",
);
}, [handleRuntimeModeChange, runtimeMode]);
const togglePlanSidebar = useCallback(() => {
setPlanSidebarOpen((open) => {
if (open) {
Expand Down Expand Up @@ -4651,7 +4674,7 @@ export default function ChatView(props: ChatViewProps) {
traitsMenuContent={providerTraitsMenuContent}
onToggleInteractionMode={toggleInteractionMode}
onTogglePlanSidebar={togglePlanSidebar}
onToggleRuntimeMode={toggleRuntimeMode}
onRuntimeModeChange={handleRuntimeModeChange}
/>
) : (
<>
Expand Down Expand Up @@ -4693,29 +4716,39 @@ export default function ChatView(props: ChatViewProps) {
className="mx-0.5 hidden h-4 sm:block"
/>

<Button
variant="ghost"
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"
}
<Select
value={runtimeMode}
onValueChange={(value) => handleRuntimeModeChange(value!)}
>
{runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
<span className="sr-only sm:not-sr-only">
{runtimeMode === "full-access" ? "Full access" : "Supervised"}
</span>
</Button>
<SelectTrigger
variant="ghost"
size="sm"
aria-label="Runtime mode"
title={runtimeModeOption.description}
>
<RuntimeModeIcon className="size-4" />
<SelectValue>{runtimeModeOption.label}</SelectValue>
</SelectTrigger>
<SelectPopup alignItemWithTrigger={false}>
{runtimeModeOptions.map((mode) => {
const option = runtimeModeConfig[mode];
const OptionIcon = option.icon;
return (
<SelectItem key={mode} value={mode} className="min-w-64 py-2">
<div className="grid min-w-0 gap-0.5">
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
<OptionIcon className="size-3.5 shrink-0 text-muted-foreground" />
{option.label}
</span>
<span className="text-muted-foreground text-xs leading-4">
{option.description}
</span>
</div>
</SelectItem>
);
})}
</SelectPopup>
</Select>

{activePlan || sidebarProposedPlan || planSidebarOpen ? (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,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
Loading
Loading