diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 6c6d9b710c..fca8b13100 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -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"` diff --git a/apps/backend/internal/agent/agents/codex_acp.go b/apps/backend/internal/agent/agents/codex_acp.go index bfe06de7be..fe018c4896 100644 --- a/apps/backend/internal/agent/agents/codex_acp.go +++ b/apps/backend/internal/agent/agents/codex_acp.go @@ -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", diff --git a/apps/backend/internal/agent/agents/cursor_acp.go b/apps/backend/internal/agent/agents/cursor_acp.go index 05c96066e3..eaef0b56dd 100644 --- a/apps/backend/internal/agent/agents/cursor_acp.go +++ b/apps/backend/internal/agent/agents/cursor_acp.go @@ -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 { diff --git a/apps/backend/internal/agent/agents/cursor_acp_test.go b/apps/backend/internal/agent/agents/cursor_acp_test.go index 3d9407ff15..71cfd178ac 100644 --- a/apps/backend/internal/agent/agents/cursor_acp_test.go +++ b/apps/backend/internal/agent/agents/cursor_acp_test.go @@ -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") { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go index 68190edecc..3441bff776 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go @@ -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 } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 132cc62857..0aa3c220ac 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -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) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go index 7f7cb2af5a..5656e8736a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go @@ -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 diff --git a/apps/backend/internal/agent/settings/controller/agent_config.go b/apps/backend/internal/agent/settings/controller/agent_config.go index d2aed016d4..9b7f9ef8d9 100644 --- a/apps/backend/internal/agent/settings/controller/agent_config.go +++ b/apps/backend/internal/agent/settings/controller/agent_config.go @@ -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 { diff --git a/apps/web/components/task/chat/messages/kandev-tool-message.tsx b/apps/web/components/task/chat/messages/kandev-tool-message.tsx index 2294f0dbfd..5c665cdd67 100644 --- a/apps/web/components/task/chat/messages/kandev-tool-message.tsx +++ b/apps/web/components/task/chat/messages/kandev-tool-message.tsx @@ -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({ diff --git a/apps/web/components/task/chat/messages/permission-action-row.test.tsx b/apps/web/components/task/chat/messages/permission-action-row.test.tsx new file mode 100644 index 0000000000..6068ee6843 --- /dev/null +++ b/apps/web/components/task/chat/messages/permission-action-row.test.tsx @@ -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(); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getByTestId("permission-approve").disabled).toBe(true); + expect(screen.getByTestId("permission-reject").disabled).toBe(true); + expect(screen.getByTestId("permission-allow-always").disabled).toBe(true); + }); +}); diff --git a/apps/web/components/task/chat/messages/permission-action-row.tsx b/apps/web/components/task/chat/messages/permission-action-row.tsx index 7236562e26..f010b67602 100644 --- a/apps/web/components/task/chat/messages/permission-action-row.tsx +++ b/apps/web/components/task/chat/messages/permission-action-row.tsx @@ -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 (
- + Approve this action? + {onAllowAlways && ( + + )}
); }); diff --git a/apps/web/components/task/chat/messages/permission-request-message.tsx b/apps/web/components/task/chat/messages/permission-request-message.tsx index 224b996ce2..a7b665e8f9 100644 --- a/apps/web/components/task/chat/messages/permission-request-message.tsx +++ b/apps/web/components/task/chat/messages/permission-request-message.tsx @@ -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 diff --git a/apps/web/components/task/chat/messages/tool-call-message.tsx b/apps/web/components/task/chat/messages/tool-call-message.tsx index 4619805548..bac85ac5cd 100644 --- a/apps/web/components/task/chat/messages/tool-call-message.tsx +++ b/apps/web/components/task/chat/messages/tool-call-message.tsx @@ -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({ )} @@ -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} /> diff --git a/apps/web/components/task/chat/messages/use-permission-handlers.test.ts b/apps/web/components/task/chat/messages/use-permission-handlers.test.ts index 317256d9df..a5c95baf5c 100644 --- a/apps/web/components/task/chat/messages/use-permission-handlers.test.ts +++ b/apps/web/components/task/chat/messages/use-permission-handlers.test.ts @@ -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, + author_type: "agent", + content: "", + type: "permission_request", + created_at: "2026-05-25T00:00:00Z", + metadata: meta as unknown as Record, + } as unknown as Message; +} + +type Handlers = ReturnType; + +function renderHandlers(message: Message): RenderHookResult["result"] { + return renderHook(() => + usePermissionResponseHandlers({ + permissionMetadata: message.metadata as unknown as PermissionRequestMetadata, + permissionMessage: message, + }), + ).result; +} + +function firstPayload(): Record { + return requestMock.mock.calls[0][1] as Record; +} + +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 = {}): 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, - author_type: "agent", - content: "", - type: "permission_request", - created_at: "2026-05-25T00:00:00Z", - metadata: meta as unknown as Record, - } 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; - 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; - 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; - 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(); }); }); diff --git a/apps/web/components/task/chat/messages/use-permission-handlers.ts b/apps/web/components/task/chat/messages/use-permission-handlers.ts index 581a382f53..7e882f6fb2 100644 --- a/apps/web/components/task/chat/messages/use-permission-handlers.ts +++ b/apps/web/components/task/chat/messages/use-permission-handlers.ts @@ -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 }; }