fix(agents): add Cursor auto-approve and always-allow permission UI (#1198)
This commit is contained in:
@@ -226,6 +226,12 @@ func (c SessionConfig) SupportsRecovery() bool {
|
||||
// filter chain across agents, so the literal lives in exactly one place.
|
||||
const PermissionApplyMethodCLIFlag = "cli_flag"
|
||||
|
||||
// PermissionKeyAutoApprove is the PermissionSettings map key wired to the
|
||||
// profile "Auto approve" toggle (see PermissionValues in buildAgentCommand).
|
||||
// Centralised for the same reason as PermissionApplyMethodCLIFlag: a typo in
|
||||
// any one agent silently disables its auto-approve flag.
|
||||
const PermissionKeyAutoApprove = "auto_approve"
|
||||
|
||||
// PermissionSetting defines metadata for a permission setting option.
|
||||
type PermissionSetting struct {
|
||||
Supported bool `json:"supported"`
|
||||
|
||||
@@ -33,7 +33,7 @@ type CodexACP struct {
|
||||
// codexPassthroughPermSettings maps profile toggles to @openai/codex CLI flags.
|
||||
// The legacy --full-auto flag was removed; auto_approve uses --ask-for-approval never.
|
||||
var codexPassthroughPermSettings = map[string]PermissionSetting{
|
||||
"auto_approve": {
|
||||
PermissionKeyAutoApprove: {
|
||||
Supported: true,
|
||||
Default: true,
|
||||
Label: "Auto approve",
|
||||
|
||||
@@ -18,6 +18,23 @@ var cursorACPLogoDark []byte
|
||||
|
||||
const cursorACPBin = "cursor-agent"
|
||||
|
||||
// cursorPermSettings maps the profile "Auto approve" toggle to cursor-agent's
|
||||
// --force flag. In ACP mode Cursor emits a session/request_permission for every
|
||||
// command not on its internal allowlist; --force ("Run Everything") suppresses
|
||||
// those prompts entirely. Empirically --force is the only flag that bypasses
|
||||
// the allowlist over ACP — --yolo and --sandbox still prompt. Default off
|
||||
// because --force runs everything unsandboxed.
|
||||
var cursorPermSettings = map[string]PermissionSetting{
|
||||
PermissionKeyAutoApprove: {
|
||||
Supported: true,
|
||||
Default: false,
|
||||
Label: "Auto approve",
|
||||
Description: "Run all commands without approval prompts (cursor-agent --force)",
|
||||
ApplyMethod: PermissionApplyMethodCLIFlag,
|
||||
CLIFlag: "--force",
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
_ Agent = (*CursorACP)(nil)
|
||||
_ PassthroughAgent = (*CursorACP)(nil)
|
||||
@@ -34,7 +51,7 @@ type CursorACP struct {
|
||||
func NewCursorACP() *CursorACP {
|
||||
return &CursorACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
PermSettings: cursorPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
@@ -77,7 +94,9 @@ func (a *CursorACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
}
|
||||
|
||||
func (a *CursorACP) BuildCommand(opts CommandOptions) Command {
|
||||
return Cmd(cursorACPBin, "acp").Build()
|
||||
return Cmd(cursorACPBin, "acp").
|
||||
Settings(a.PermissionSettings(), opts.PermissionValues).
|
||||
Build()
|
||||
}
|
||||
|
||||
func (a *CursorACP) Runtime() *RuntimeConfig {
|
||||
@@ -123,7 +142,7 @@ grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null ||
|
||||
}
|
||||
|
||||
func (a *CursorACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
return cursorPermSettings
|
||||
}
|
||||
|
||||
func (a *CursorACP) InferenceConfig() *InferenceConfig {
|
||||
|
||||
@@ -22,6 +22,46 @@ func TestCursorACPRemoteAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorACPPermissionSettingsAutoApprove(t *testing.T) {
|
||||
settings := NewCursorACP().PermissionSettings()
|
||||
setting, ok := settings[PermissionKeyAutoApprove]
|
||||
if !ok {
|
||||
t.Fatal("PermissionSettings() missing auto_approve; cursor must expose an approval-bypass toggle")
|
||||
}
|
||||
if !setting.Supported {
|
||||
t.Error("auto_approve must be Supported")
|
||||
}
|
||||
if setting.ApplyMethod != PermissionApplyMethodCLIFlag {
|
||||
t.Errorf("ApplyMethod = %q, want %q", setting.ApplyMethod, PermissionApplyMethodCLIFlag)
|
||||
}
|
||||
if setting.CLIFlag != "--force" {
|
||||
t.Errorf("CLIFlag = %q, want --force (the only flag that bypasses Cursor's ACP allowlist)", setting.CLIFlag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorACPBuildCommandAutoApprove(t *testing.T) {
|
||||
c := NewCursorACP()
|
||||
|
||||
// Default: a bare `cursor-agent acp`, so Cursor keeps prompting per command.
|
||||
plain := strings.Join(c.BuildCommand(CommandOptions{}).Args(), " ")
|
||||
if plain != "cursor-agent acp" {
|
||||
t.Fatalf("default BuildCommand = %q, want %q", plain, "cursor-agent acp")
|
||||
}
|
||||
if strings.Contains(plain, "--force") {
|
||||
t.Error("default command must not include --force")
|
||||
}
|
||||
|
||||
// auto_approve enabled: --force is appended so Cursor stops sending a
|
||||
// session/request_permission for every non-allowlisted command.
|
||||
approved := strings.Join(
|
||||
c.BuildCommand(CommandOptions{PermissionValues: map[string]bool{PermissionKeyAutoApprove: true}}).Args(),
|
||||
" ",
|
||||
)
|
||||
if !strings.Contains(approved, "--force") {
|
||||
t.Errorf("auto_approve BuildCommand = %q, want it to contain --force", approved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorACPInstallScriptIsNativeInstaller(t *testing.T) {
|
||||
script := NewCursorACP().InstallScript()
|
||||
if !strings.Contains(script, "cursor.com/install") {
|
||||
|
||||
@@ -1269,7 +1269,7 @@ func (m *Manager) buildFreshAgentCommand(ctx context.Context, execution *AgentEx
|
||||
if profileInfo != nil {
|
||||
model = profileInfo.Model
|
||||
autoApprove = profileInfo.AutoApprove
|
||||
permissionValues["auto_approve"] = profileInfo.AutoApprove
|
||||
permissionValues[agents.PermissionKeyAutoApprove] = profileInfo.AutoApprove
|
||||
permissionValues["allow_indexing"] = profileInfo.AllowIndexing
|
||||
permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ func (m *Manager) buildAgentCommand(req *LaunchRequest, profileInfo *AgentProfil
|
||||
if profileInfo != nil {
|
||||
model = profileInfo.Model
|
||||
autoApprove = profileInfo.AutoApprove
|
||||
permissionValues["auto_approve"] = profileInfo.AutoApprove
|
||||
permissionValues[agents.PermissionKeyAutoApprove] = profileInfo.AutoApprove
|
||||
permissionValues["allow_indexing"] = profileInfo.AllowIndexing
|
||||
permissionValues["dangerously_skip_permissions"] = profileInfo.DangerouslySkipPermissions
|
||||
tokens, err := cliflags.Resolve(profileInfo.CLIFlags)
|
||||
|
||||
@@ -503,11 +503,12 @@ func profilePermissionValues(p *AgentProfileInfo) map[string]bool {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]bool{
|
||||
"auto_approve": p.AutoApprove,
|
||||
values := map[string]bool{
|
||||
"dangerously_skip_permissions": p.DangerouslySkipPermissions,
|
||||
"allow_indexing": p.AllowIndexing,
|
||||
}
|
||||
values[agents.PermissionKeyAutoApprove] = p.AutoApprove
|
||||
return values
|
||||
}
|
||||
|
||||
// freshPassthroughCommand resolves the agent config and profile, and builds a
|
||||
|
||||
@@ -150,7 +150,7 @@ func resolvePermissionDefaults(permSettings map[string]agents.PermissionSetting)
|
||||
if permSettings == nil {
|
||||
return
|
||||
}
|
||||
if s, exists := permSettings["auto_approve"]; exists {
|
||||
if s, exists := permSettings[agents.PermissionKeyAutoApprove]; exists {
|
||||
autoApprove = s.Default
|
||||
}
|
||||
if s, exists := permSettings["allow_indexing"]; exists {
|
||||
|
||||
@@ -77,10 +77,11 @@ export const KandevToolMessage = memo(function KandevToolMessage({
|
||||
const renderer = getKandevRenderer(kandevStemOf(comment));
|
||||
const { permissionMetadata, permissionStatus, isPermissionPending } =
|
||||
parsePermission(permissionMessage);
|
||||
const { isResponding, handleApprove, handleReject } = usePermissionResponseHandlers({
|
||||
permissionMetadata,
|
||||
permissionMessage,
|
||||
});
|
||||
const { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject } =
|
||||
usePermissionResponseHandlers({
|
||||
permissionMetadata,
|
||||
permissionMessage,
|
||||
});
|
||||
if (!renderer) return null;
|
||||
|
||||
// The ACP normalizer stores MCP tool args/result inside the Generic payload:
|
||||
@@ -114,6 +115,7 @@ export const KandevToolMessage = memo(function KandevToolMessage({
|
||||
<PermissionActionRow
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onAllowAlways={hasAllowAlways ? handleAllowAlways : undefined}
|
||||
isResponding={isResponding}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { PermissionActionRow } from "./permission-action-row";
|
||||
|
||||
describe("PermissionActionRow", () => {
|
||||
// This vitest config does not auto-clean the DOM between tests, so unmount
|
||||
// explicitly to avoid stale buttons leaking duplicate test ids.
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders only Approve and Deny when onAllowAlways is not provided", () => {
|
||||
render(<PermissionActionRow onApprove={vi.fn()} onReject={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("permission-approve")).toBeTruthy();
|
||||
expect(screen.getByTestId("permission-reject")).toBeTruthy();
|
||||
// The "Always allow" button is hidden for agents that don't offer it.
|
||||
expect(screen.queryByTestId("permission-allow-always")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the Always allow button and fires its callback when offered", () => {
|
||||
const onAllowAlways = vi.fn();
|
||||
render(
|
||||
<PermissionActionRow onApprove={vi.fn()} onReject={vi.fn()} onAllowAlways={onAllowAlways} />,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("permission-allow-always");
|
||||
expect(button.textContent).toContain("Always allow");
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(onAllowAlways).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("wires Approve and Deny to their handlers", () => {
|
||||
const onApprove = vi.fn();
|
||||
const onReject = vi.fn();
|
||||
render(
|
||||
<PermissionActionRow onApprove={onApprove} onReject={onReject} onAllowAlways={vi.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("permission-approve"));
|
||||
fireEvent.click(screen.getByTestId("permission-reject"));
|
||||
expect(onApprove).toHaveBeenCalledOnce();
|
||||
expect(onReject).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("disables every action while a response is in flight", () => {
|
||||
render(
|
||||
<PermissionActionRow
|
||||
onApprove={vi.fn()}
|
||||
onReject={vi.fn()}
|
||||
onAllowAlways={vi.fn()}
|
||||
isResponding
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId<HTMLButtonElement>("permission-approve").disabled).toBe(true);
|
||||
expect(screen.getByTestId<HTMLButtonElement>("permission-reject").disabled).toBe(true);
|
||||
expect(screen.getByTestId<HTMLButtonElement>("permission-allow-always").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
import { IconCheck, IconChecks, IconX } from "@tabler/icons-react";
|
||||
import { GridSpinner } from "@/components/grid-spinner";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
|
||||
type PermissionActionRowProps = {
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
// onAllowAlways is provided only when the agent offers an allow_always
|
||||
// option (e.g. Cursor). When undefined the "Always allow" button is hidden,
|
||||
// preserving the plain Approve/Deny row for agents without it.
|
||||
onAllowAlways?: () => void;
|
||||
isResponding?: boolean;
|
||||
};
|
||||
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"h-6 px-3 text-foreground border-border bg-background hover:bg-muted hover:border-foreground/40 transition-colors cursor-pointer";
|
||||
|
||||
export const PermissionActionRow = memo(function PermissionActionRow({
|
||||
onApprove,
|
||||
onReject,
|
||||
onAllowAlways,
|
||||
isResponding = false,
|
||||
}: PermissionActionRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-sm bg-amber-500/10"
|
||||
className="flex flex-wrap items-center gap-2 px-3 py-2 rounded-sm bg-amber-500/10"
|
||||
data-testid="permission-action-row"
|
||||
>
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400 flex-1">
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400 flex-1 min-w-0">
|
||||
Approve this action?
|
||||
</span>
|
||||
<Button
|
||||
@@ -30,7 +38,7 @@ export const PermissionActionRow = memo(function PermissionActionRow({
|
||||
onClick={onReject}
|
||||
disabled={isResponding}
|
||||
data-testid="permission-reject"
|
||||
className="h-6 px-3 text-foreground border-border bg-background hover:bg-muted hover:border-foreground/40 transition-colors cursor-pointer"
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
>
|
||||
<IconX className="h-4 w-4 mr-1 text-red-500" />
|
||||
Deny
|
||||
@@ -41,7 +49,7 @@ export const PermissionActionRow = memo(function PermissionActionRow({
|
||||
onClick={onApprove}
|
||||
disabled={isResponding}
|
||||
data-testid="permission-approve"
|
||||
className="h-6 px-3 text-foreground border-border bg-background hover:bg-muted hover:border-foreground/40 transition-colors cursor-pointer"
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
>
|
||||
{isResponding ? (
|
||||
<GridSpinner className="text-foreground mr-1" />
|
||||
@@ -50,6 +58,19 @@ export const PermissionActionRow = memo(function PermissionActionRow({
|
||||
)}
|
||||
Approve
|
||||
</Button>
|
||||
{onAllowAlways && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={onAllowAlways}
|
||||
disabled={isResponding}
|
||||
data-testid="permission-allow-always"
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
>
|
||||
<IconChecks className="h-4 w-4 mr-1 text-green-500" />
|
||||
Always allow
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,10 +46,11 @@ type PermissionRequestMessageProps = {
|
||||
|
||||
export function PermissionRequestMessage({ comment }: PermissionRequestMessageProps) {
|
||||
const { permissionMetadata, permissionStatus, isPermissionPending } = parsePermission(comment);
|
||||
const { isResponding, handleApprove, handleReject } = usePermissionResponseHandlers({
|
||||
permissionMetadata,
|
||||
permissionMessage: comment,
|
||||
});
|
||||
const { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject } =
|
||||
usePermissionResponseHandlers({
|
||||
permissionMetadata,
|
||||
permissionMessage: comment,
|
||||
});
|
||||
|
||||
const statusBadge = getPermissionStatusBadge(permissionStatus);
|
||||
const titleText = comment.content || "Permission Required";
|
||||
@@ -96,6 +97,7 @@ export function PermissionRequestMessage({ comment }: PermissionRequestMessagePr
|
||||
<PermissionActionRow
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onAllowAlways={hasAllowAlways ? handleAllowAlways : undefined}
|
||||
isResponding={isResponding}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,7 @@ type ToolCallExpandedContentProps = {
|
||||
isPermissionPending: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onAllowAlways?: () => void;
|
||||
isResponding: boolean;
|
||||
};
|
||||
|
||||
@@ -80,6 +81,7 @@ function ToolCallExpandedContent({
|
||||
isPermissionPending,
|
||||
onApprove,
|
||||
onReject,
|
||||
onAllowAlways,
|
||||
isResponding,
|
||||
}: ToolCallExpandedContentProps) {
|
||||
return (
|
||||
@@ -99,6 +101,7 @@ function ToolCallExpandedContent({
|
||||
<PermissionActionRow
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onAllowAlways={onAllowAlways}
|
||||
isResponding={isResponding}
|
||||
/>
|
||||
)}
|
||||
@@ -179,10 +182,11 @@ export const ToolCallMessage = memo(function ToolCallMessage({
|
||||
inlineOutput,
|
||||
isSuccess,
|
||||
} = parseToolCallMetadata(comment, permissionMessage);
|
||||
const { isResponding, handleApprove, handleReject } = usePermissionResponseHandlers({
|
||||
permissionMetadata,
|
||||
permissionMessage,
|
||||
});
|
||||
const { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject } =
|
||||
usePermissionResponseHandlers({
|
||||
permissionMetadata,
|
||||
permissionMessage,
|
||||
});
|
||||
const autoExpanded = status === "running" || isPermissionPending;
|
||||
const { isExpanded, handleToggle } = useExpandState(status, autoExpanded);
|
||||
|
||||
@@ -234,6 +238,7 @@ export const ToolCallMessage = memo(function ToolCallMessage({
|
||||
isPermissionPending={isPermissionPending}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
onAllowAlways={hasAllowAlways ? handleAllowAlways : undefined}
|
||||
isResponding={isResponding}
|
||||
/>
|
||||
</ExpandableRow>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { renderHook, act, type RenderHookResult } from "@testing-library/react";
|
||||
import { sessionId as toSessionId } from "@/lib/types/http";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import type { PermissionRequestMetadata } from "./use-permission-handlers";
|
||||
import {
|
||||
usePermissionResponseHandlers,
|
||||
type PermissionOption,
|
||||
type PermissionRequestMetadata,
|
||||
} from "./use-permission-handlers";
|
||||
|
||||
const requestMock = vi.fn().mockResolvedValue({});
|
||||
|
||||
@@ -10,104 +14,137 @@ vi.mock("@/lib/ws/connection", () => ({
|
||||
getWebSocketClient: () => ({ request: requestMock }),
|
||||
}));
|
||||
|
||||
describe("usePermissionResponseHandlers", () => {
|
||||
let usePermissionResponseHandlers: typeof import("./use-permission-handlers").usePermissionResponseHandlers;
|
||||
beforeEach(() => {
|
||||
requestMock.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
requestMock.mockReset().mockResolvedValue({});
|
||||
({ usePermissionResponseHandlers } = await import("./use-permission-handlers"));
|
||||
const ALLOW_ONCE: PermissionOption = { option_id: "allow", name: "Allow", kind: "allow_once" };
|
||||
const REJECT_ONCE: PermissionOption = { option_id: "deny", name: "Deny", kind: "reject_once" };
|
||||
const ALLOW_ALWAYS: PermissionOption = {
|
||||
option_id: "always",
|
||||
name: "Allow always",
|
||||
kind: "allow_always",
|
||||
};
|
||||
|
||||
function makePermissionMessage(options: PermissionOption[] = [ALLOW_ONCE, REJECT_ONCE]): Message {
|
||||
const meta: PermissionRequestMetadata = {
|
||||
pending_id: "pend-1",
|
||||
tool_call_id: "tc-1",
|
||||
action_type: "mcp_tool",
|
||||
action_details: {},
|
||||
options,
|
||||
};
|
||||
return {
|
||||
id: "msg-1",
|
||||
session_id: toSessionId("sess-1"),
|
||||
task_id: "task-1" as ReturnType<typeof import("@/lib/types/http").taskId>,
|
||||
author_type: "agent",
|
||||
content: "",
|
||||
type: "permission_request",
|
||||
created_at: "2026-05-25T00:00:00Z",
|
||||
metadata: meta as unknown as Record<string, unknown>,
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
type Handlers = ReturnType<typeof usePermissionResponseHandlers>;
|
||||
|
||||
function renderHandlers(message: Message): RenderHookResult<Handlers, unknown>["result"] {
|
||||
return renderHook(() =>
|
||||
usePermissionResponseHandlers({
|
||||
permissionMetadata: message.metadata as unknown as PermissionRequestMetadata,
|
||||
permissionMessage: message,
|
||||
}),
|
||||
).result;
|
||||
}
|
||||
|
||||
function firstPayload(): Record<string, unknown> {
|
||||
return requestMock.mock.calls[0][1] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("handleApprove", () => {
|
||||
it("sends cancelled=false and rejected=false", async () => {
|
||||
const result = renderHandlers(makePermissionMessage());
|
||||
await act(async () => {
|
||||
result.current.handleApprove();
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledOnce();
|
||||
expect(firstPayload().option_id).toBe("allow");
|
||||
expect(firstPayload().cancelled).toBeFalsy();
|
||||
expect(firstPayload().rejected).toBeFalsy();
|
||||
});
|
||||
|
||||
function makePermissionMessage(overrides: Partial<PermissionRequestMetadata> = {}): Message {
|
||||
const meta: PermissionRequestMetadata = {
|
||||
pending_id: "pend-1",
|
||||
tool_call_id: "tc-1",
|
||||
action_type: "mcp_tool",
|
||||
action_details: {},
|
||||
options: [
|
||||
{ option_id: "allow", name: "Allow", kind: "allow_once" },
|
||||
{ option_id: "deny", name: "Deny", kind: "reject_once" },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
id: "msg-1",
|
||||
session_id: toSessionId("sess-1"),
|
||||
task_id: "task-1" as ReturnType<typeof import("@/lib/types/http").taskId>,
|
||||
author_type: "agent",
|
||||
content: "",
|
||||
type: "permission_request",
|
||||
created_at: "2026-05-25T00:00:00Z",
|
||||
metadata: meta as unknown as Record<string, unknown>,
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
describe("handleApprove", () => {
|
||||
it("sends cancelled=false and rejected=false", async () => {
|
||||
const permissionMessage = makePermissionMessage();
|
||||
const { result } = renderHook(() =>
|
||||
usePermissionResponseHandlers({
|
||||
permissionMetadata: permissionMessage.metadata as unknown as PermissionRequestMetadata,
|
||||
permissionMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleApprove();
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledOnce();
|
||||
const payload = requestMock.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(payload.option_id).toBe("allow");
|
||||
expect(payload.cancelled).toBeFalsy();
|
||||
expect(payload.rejected).toBeFalsy();
|
||||
it("prefers allow_once over allow_always so 'Always allow' stays distinct", async () => {
|
||||
const once: PermissionOption = { option_id: "once", name: "Allow once", kind: "allow_once" };
|
||||
const result = renderHandlers(makePermissionMessage([ALLOW_ALWAYS, once, REJECT_ONCE]));
|
||||
await act(async () => {
|
||||
result.current.handleApprove();
|
||||
});
|
||||
|
||||
expect(firstPayload().option_id).toBe("once");
|
||||
});
|
||||
|
||||
describe("handleReject", () => {
|
||||
it("sends rejected=true and cancelled=false when a reject option exists", async () => {
|
||||
const permissionMessage = makePermissionMessage();
|
||||
const { result } = renderHook(() =>
|
||||
usePermissionResponseHandlers({
|
||||
permissionMetadata: permissionMessage.metadata as unknown as PermissionRequestMetadata,
|
||||
permissionMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleReject();
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledOnce();
|
||||
const payload = requestMock.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(payload.option_id).toBe("deny");
|
||||
expect(payload.rejected).toBe(true);
|
||||
// Must NOT set cancelled=true: that triggers EventTypePermissionCancelled
|
||||
// which races against the orchestrator's UpdatePermissionMessage("rejected")
|
||||
// and would overwrite the status to "expired".
|
||||
expect(payload.cancelled).toBeFalsy();
|
||||
it("falls back to allow_always when no allow_once is offered", async () => {
|
||||
const result = renderHandlers(makePermissionMessage([ALLOW_ALWAYS]));
|
||||
await act(async () => {
|
||||
result.current.handleApprove();
|
||||
});
|
||||
|
||||
it("falls back to cancelled=true when no reject option exists", async () => {
|
||||
const permissionMessage = makePermissionMessage({
|
||||
options: [{ option_id: "allow", name: "Allow", kind: "allow_once" }],
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
usePermissionResponseHandlers({
|
||||
permissionMetadata: permissionMessage.metadata as unknown as PermissionRequestMetadata,
|
||||
permissionMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleReject();
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledOnce();
|
||||
const payload = requestMock.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(payload.cancelled).toBe(true);
|
||||
expect(payload.option_id).toBeUndefined();
|
||||
});
|
||||
expect(firstPayload().option_id).toBe("always");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAllowAlways", () => {
|
||||
it("hasAllowAlways is true and responds with the allow_always option (not rejected)", async () => {
|
||||
const result = renderHandlers(makePermissionMessage([ALLOW_ONCE, ALLOW_ALWAYS, REJECT_ONCE]));
|
||||
expect(result.current.hasAllowAlways).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleAllowAlways();
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledOnce();
|
||||
expect(firstPayload().option_id).toBe("always");
|
||||
expect(firstPayload().cancelled).toBeFalsy();
|
||||
expect(firstPayload().rejected).toBeFalsy();
|
||||
});
|
||||
|
||||
it("hasAllowAlways is false and handleAllowAlways is a no-op when not offered", async () => {
|
||||
const result = renderHandlers(makePermissionMessage([ALLOW_ONCE, REJECT_ONCE]));
|
||||
expect(result.current.hasAllowAlways).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleAllowAlways();
|
||||
});
|
||||
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleReject", () => {
|
||||
it("sends rejected=true and cancelled=false when a reject option exists", async () => {
|
||||
const result = renderHandlers(makePermissionMessage());
|
||||
await act(async () => {
|
||||
result.current.handleReject();
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledOnce();
|
||||
expect(firstPayload().option_id).toBe("deny");
|
||||
expect(firstPayload().rejected).toBe(true);
|
||||
// Must NOT set cancelled=true: that triggers EventTypePermissionCancelled
|
||||
// which races against the orchestrator's UpdatePermissionMessage("rejected")
|
||||
// and would overwrite the status to "expired".
|
||||
expect(firstPayload().cancelled).toBeFalsy();
|
||||
});
|
||||
|
||||
it("falls back to cancelled=true when no reject option exists", async () => {
|
||||
const result = renderHandlers(makePermissionMessage([ALLOW_ONCE]));
|
||||
await act(async () => {
|
||||
result.current.handleReject();
|
||||
});
|
||||
|
||||
expect(requestMock).toHaveBeenCalledOnce();
|
||||
expect(firstPayload().cancelled).toBe(true);
|
||||
expect(firstPayload().option_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import type { PermissionActionType, PermissionOptionKind } from "@/lib/types/permission";
|
||||
@@ -85,13 +85,29 @@ export function usePermissionResponseHandlers({
|
||||
[permissionMessage, permissionMetadata],
|
||||
);
|
||||
|
||||
// "Approve" is the one-shot allow. Prefer an explicit allow_once option and
|
||||
// only fall back to allow_always when the agent offers nothing else, so the
|
||||
// dedicated "Always allow" button (handleAllowAlways) stays distinct.
|
||||
const handleApprove = useCallback(() => {
|
||||
const allowOption = permissionMetadata?.options.find(
|
||||
(opt) => opt.kind === "allow_once" || opt.kind === "allow_always",
|
||||
);
|
||||
const options = permissionMetadata?.options ?? [];
|
||||
const allowOption =
|
||||
options.find((opt) => opt.kind === "allow_once") ??
|
||||
options.find((opt) => opt.kind === "allow_always");
|
||||
if (allowOption) handleRespond(allowOption.option_id);
|
||||
}, [permissionMetadata, handleRespond]);
|
||||
|
||||
// "Always allow" maps to the agent's allow_always option, telling the agent
|
||||
// to persist the decision so the same action is not re-prompted. Only some
|
||||
// agents offer it (Cursor does); hasAllowAlways gates the button.
|
||||
const allowAlwaysOption = useMemo(
|
||||
() => permissionMetadata?.options.find((opt) => opt.kind === "allow_always"),
|
||||
[permissionMetadata],
|
||||
);
|
||||
const hasAllowAlways = !!allowAlwaysOption;
|
||||
const handleAllowAlways = useCallback(() => {
|
||||
if (allowAlwaysOption) handleRespond(allowAlwaysOption.option_id);
|
||||
}, [allowAlwaysOption, handleRespond]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
const rejectOption = permissionMetadata?.options.find(
|
||||
(opt) => opt.kind === "reject_once" || opt.kind === "reject_always",
|
||||
@@ -106,5 +122,5 @@ export function usePermissionResponseHandlers({
|
||||
}
|
||||
}, [permissionMetadata, handleRespond]);
|
||||
|
||||
return { isResponding, handleApprove, handleReject };
|
||||
return { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user