diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift new file mode 100644 index 0000000000..d42b18fbdc --- /dev/null +++ b/CLI/FeedEventClassifier.swift @@ -0,0 +1,244 @@ +import Foundation + +/// Classifies a raw agent hook event into our wire `hook_event_name` plus an +/// `isActionable` flag. +/// +/// This is the single source of truth behind both the running `cmux` CLI +/// (`cmux hooks feed …`) and the `FeedEventClassificationTests` regression +/// suite — the file is compiled into the `cmux-cli` target and the +/// `cmuxTests` target so the pure decision can be unit-tested without +/// launching the app or running the CLI as a subprocess. +/// +/// The mapping is driven by an explicit, typed registry +/// (``feedEventSemantic(source:event:)``) keyed on `(source, event)` rather +/// than by pattern-matching raw event-name strings. Notification eligibility +/// is derived only from the resolved ``FeedEventSemantic``, so a +/// tool-*starting* lifecycle event can never be mistaken for an approval +/// request — and unknown / future event names default to non-actionable +/// telemetry that never notifies. Conflating a tool-start with an approval +/// is the bug behind https://github.com/manaflow-ai/cmux/issues/4985. +struct FeedEventClassifier { + /// Classifies a raw agent hook event into our wire `hook_event_name` + /// plus an `isActionable` flag that drives whether the Feed bridge + /// blocks waiting for a user decision (and whether `FeedCoordinator` + /// posts a "needs approval" notification). + /// + /// - Parameters: + /// - source: The agent id that emitted the event (`claude`, `codex`, + /// `hermes-agent`, …). Unregistered sources use the generic table. + /// - event: The agent's raw hook event name. + /// - toolName: The tool the event refers to, used only for the two + /// tool-dependent semantics. + /// - Returns: The wire `hook_event_name` and whether the event is + /// Feed-actionable (blocks + may notify). + static func classify( + source: String, + event: String, + toolName: String + ) -> (String, Bool) { + let semantic = feedEventSemantic(source: source, event: event) + return wireMapping(for: semantic, toolName: toolName) + } + + /// User-attention semantic of a hook/feed event, independent of the + /// agent-specific raw event name. Notifications and blocking waits are + /// keyed off this — never off raw event-name string matching — so the + /// same misclassification cannot recur as new event names are added. + private enum FeedEventSemantic { + /// A real approval is pending; the user must approve/deny. Drives + /// the blocking Feed wait and the "needs approval" notification. + /// Resolved against the tool name so Claude's `ExitPlanMode` / + /// `AskUserQuestion` approvals route to their dedicated kinds. + case approvalRequest + /// A tool is about to run but no approval is pending. Telemetry + /// only. Used by agents that expose a *separate* approval event + /// (Claude, Codex, Hermes) so their pre-tool hook never escalates. + case toolStart + /// A tool is about to run and the agent has *no* dedicated approval + /// event, so a side-effecting tool is escalated to an approval and + /// read-only tools stay telemetry. Resolved against the tool name. + case toolStartMaybeApproval + /// A tool finished. Telemetry only. + case toolEnd + /// A new turn / prompt started. Telemetry only. + case promptSubmit + /// The agent finished responding. Telemetry only. + case response + /// A subagent finished responding. Telemetry only. + case subagentResponse + case sessionStart + case sessionEnd + /// A generic status/notification event. Telemetry only — real + /// approval banners for these agents fire through the dedicated + /// `notification` hook subcommand, not the feed path. + case statusNotification + /// Unknown / unregistered event. Safe default: telemetry only, + /// never actionable, never notifies. + case unknown + } + + /// Resolves the semantic for a `(source, event)` pair. A registered + /// source uses its own table (unmatched events fall to ``FeedEventSemantic/unknown``); + /// unregistered sources use the generic table. + private static func feedEventSemantic( + source: String, + event: String + ) -> FeedEventSemantic { + let table = feedEventSemanticRegistry[source] ?? genericFeedEventSemantics + return table[event] ?? .unknown + } + + /// Tool names that carry their own dedicated approval wire event rather + /// than the generic `PermissionRequest`. Returns the actionable wire + /// mapping for such a tool, or `nil` for ordinary tools. + private static func dedicatedApprovalEvent(for toolName: String) -> (String, Bool)? { + switch toolName { + case "ExitPlanMode": return ("ExitPlanMode", true) + case "AskUserQuestion": return ("AskUserQuestion", true) + default: return nil + } + } + + /// Maps a resolved semantic to the wire `hook_event_name` plus the + /// `isActionable` flag, using `toolName` for the two tool-dependent + /// semantics. + private static func wireMapping( + for semantic: FeedEventSemantic, + toolName: String + ) -> (String, Bool) { + switch semantic { + case .approvalRequest: + return dedicatedApprovalEvent(for: toolName) ?? ("PermissionRequest", true) + case .toolStartMaybeApproval: + if let dedicated = dedicatedApprovalEvent(for: toolName) { + return dedicated + } + // Any tool that can mutate the environment surfaces as a + // permission request so the user can approve/deny from the + // Feed sidebar. Read-only tools stay non-actionable + // telemetry so we don't flood the Actionable view. + if Self.sideEffectingTools.contains(toolName) { + return ("PermissionRequest", true) + } + return ("PreToolUse", false) + case .toolStart: + return ("PreToolUse", false) + case .toolEnd: + return ("PostToolUse", false) + case .promptSubmit: + return ("UserPromptSubmit", false) + case .response: + return ("Stop", false) + case .subagentResponse: + return ("SubagentStop", false) + case .sessionStart: + return ("SessionStart", false) + case .sessionEnd: + return ("SessionEnd", false) + case .statusNotification: + return ("Notification", false) + case .unknown: + // Safe default: telemetry, no approval, no notification. + return ("PreToolUse", false) + } + } + + /// Per-agent event-semantic tables. Each entry is the source of truth + /// for that agent's `(event) -> semantic` mapping; events absent here + /// resolve to ``FeedEventSemantic/unknown``. + /// + /// The key distinction the registry encodes: agents with a *dedicated* + /// approval event (Claude `PermissionRequest`, Codex `PermissionRequest`, + /// Hermes `pre_approval_request`) classify their pre-tool event as + /// ``FeedEventSemantic/toolStart`` (always telemetry). Agents whose only + /// signal is the pre-tool event (gemini, copilot, …, handled by + /// ``genericFeedEventSemantics``) use + /// ``FeedEventSemantic/toolStartMaybeApproval`` so side-effecting tools + /// still escalate. Conflating the two is the bug behind #4985. + private static let feedEventSemanticRegistry: [String: [String: FeedEventSemantic]] = [ + "claude": [ + "PermissionRequest": .approvalRequest, + "PreToolUse": .toolStart, + "PostToolUse": .toolEnd, + "UserPromptSubmit": .promptSubmit, + "SessionStart": .sessionStart, + "SessionEnd": .sessionEnd, + "Stop": .response, + "SubagentStop": .subagentResponse, + "Notification": .statusNotification, + ], + "codex": [ + "PermissionRequest": .approvalRequest, + "PreToolUse": .toolStart, + "beforeShellExecution": .toolStart, + "PostToolUse": .toolEnd, + "UserPromptSubmit": .promptSubmit, + "SessionStart": .sessionStart, + "SessionEnd": .sessionEnd, + "Stop": .response, + "SubagentStop": .subagentResponse, + "Notification": .statusNotification, + ], + "hermes-agent": [ + // `pre_tool_call` is a tool *starting* — Hermes raises a + // separate `pre_approval_request` for real approvals, so this + // must stay telemetry even for side-effecting tools (#4985). + "pre_tool_call": .toolStart, + "post_tool_call": .toolEnd, + // The approval banner for Hermes fires through the dedicated + // `notification` hook subcommand; on the feed path this stays a + // non-blocking notification to avoid a duplicate banner. + "pre_approval_request": .statusNotification, + "post_approval_response": .statusNotification, + "pre_llm_call": .promptSubmit, + "post_llm_call": .response, + "on_session_start": .sessionStart, + "on_session_reset": .sessionStart, + "on_session_end": .sessionEnd, + "on_session_finalize": .sessionEnd, + ], + ] + + /// Fallback table for agents without a dedicated entry in + /// ``feedEventSemanticRegistry``. These agents expose only a pre-tool + /// event, so it carries ``FeedEventSemantic/toolStartMaybeApproval``. + private static let genericFeedEventSemantics: [String: FeedEventSemantic] = [ + "PreToolUse": .toolStartMaybeApproval, + "beforeShellExecution": .toolStartMaybeApproval, + "PermissionRequest": .approvalRequest, + "PostToolUse": .toolEnd, + "UserPromptSubmit": .promptSubmit, + "SessionStart": .sessionStart, + "SessionEnd": .sessionEnd, + "Stop": .response, + "SubagentStop": .subagentResponse, + "Notification": .statusNotification, + ] + + /// Tools that mutate state and deserve a user-visible approve/ + /// deny prompt in Feed. Keyed on the canonical tool names Claude, + /// Codex, and similar agents emit. Read-only tools (Read, Grep, + /// Glob, Task, WebFetch, WebSearch, LS, TodoWrite, …) are + /// intentionally excluded. + private static let sideEffectingTools: Set = [ + "Bash", + "Write", + "Edit", + "MultiEdit", + "NotebookEdit", + "apply_patch", // Codex + "shell", // Codex / other agents + "terminal", // Hermes Agent + "run_command", // Antigravity + "write_to_file", + "replace_file_content", + "multi_replace_file_content", + "manage_task", + "schedule", + "ask_permission", + "invoke_subagent", + "define_subagent", + "manage_subagents", + "generate_image", + ] +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 0158b64168..eb9dfe848d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -30042,7 +30042,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { // Decide whether this event is Feed-actionable. Non-actionable // events are forwarded as telemetry (non-blocking) and exit `{}` // so the agent proceeds without a decision. - let (hookEventName, isActionable) = Self.classifyFeedEvent( + let (hookEventName, isActionable) = FeedEventClassifier.classify( source: source, event: rawEvent, toolName: toolName @@ -30159,139 +30159,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { print("{}") } - /// Classifies a raw agent hook event into our wire `hook_event_name` - /// plus an `isActionable` flag that drives whether the Feed bridge - /// blocks waiting for a user decision. Claude Code owns decisions - /// through its native PermissionRequest hook. Its PreToolUse hook is - /// telemetry/status only. - private static func classifyFeedEvent( - source: String, - event: String, - toolName: String - ) -> (String, Bool) { - if source == "claude" { - switch event { - case "PermissionRequest": - switch toolName { - case "ExitPlanMode": - return ("ExitPlanMode", true) - case "AskUserQuestion": - return ("AskUserQuestion", true) - default: - return ("PermissionRequest", true) - } - case "PostToolUse": - return ("PostToolUse", false) - case "UserPromptSubmit": - return ("UserPromptSubmit", false) - case "SessionStart": - return ("SessionStart", false) - case "SessionEnd": - return ("SessionEnd", false) - case "Stop": - return ("Stop", false) - case "SubagentStop": - return ("SubagentStop", false) - case "Notification": - return ("Notification", false) - default: - return ("PreToolUse", false) - } - } - - if source == "hermes-agent" { - switch event { - case "pre_tool_call": - if Self.sideEffectingTools.contains(toolName) { - return ("PermissionRequest", true) - } - return ("PreToolUse", false) - case "post_tool_call": - return ("PostToolUse", false) - case "pre_approval_request": - return ("Notification", false) - case "post_approval_response": - return ("Notification", false) - case "pre_llm_call": - return ("UserPromptSubmit", false) - case "post_llm_call": - return ("Stop", false) - case "on_session_start", "on_session_reset": - return ("SessionStart", false) - case "on_session_end", "on_session_finalize": - return ("SessionEnd", false) - default: - return ("PreToolUse", false) - } - } - - switch event { - case "PreToolUse", "beforeShellExecution": - if source == "codex" { return ("PreToolUse", false) } - switch toolName { - case "ExitPlanMode": - return ("ExitPlanMode", true) - case "AskUserQuestion": - return ("AskUserQuestion", true) - default: - // Any tool that can mutate the environment surfaces as - // a permission request so the user can approve/deny - // from the Feed sidebar. Read-only tools stay as - // non-actionable telemetry so we don't flood the - // Actionable view with every file read. - if Self.sideEffectingTools.contains(toolName) { - return ("PermissionRequest", true) - } - return ("PreToolUse", false) - } - case "PermissionRequest": - return ("PermissionRequest", true) - case "PostToolUse": - return ("PostToolUse", false) - case "UserPromptSubmit": - return ("UserPromptSubmit", false) - case "SessionStart": - return ("SessionStart", false) - case "SessionEnd": - return ("SessionEnd", false) - case "Stop": - return ("Stop", false) - case "SubagentStop": - return ("SubagentStop", false) - case "Notification": - return ("Notification", false) - default: - return ("PreToolUse", false) - } - } - - /// Tools that mutate state and deserve a user-visible approve/ - /// deny prompt in Feed. Keyed on the canonical tool names Claude, - /// Codex, and similar agents emit. Read-only tools (Read, Grep, - /// Glob, Task, WebFetch, WebSearch, LS, TodoWrite, …) are - /// intentionally excluded. - private static let sideEffectingTools: Set = [ - "Bash", - "Write", - "Edit", - "MultiEdit", - "NotebookEdit", - "apply_patch", // Codex - "shell", // Codex / other agents - "terminal", // Hermes Agent - "run_command", // Antigravity - "write_to_file", - "replace_file_content", - "multi_replace_file_content", - "manage_task", - "schedule", - "ask_permission", - "invoke_subagent", - "define_subagent", - "manage_subagents", - "generate_image", - ] - private static let skipInterviewAndPlanAnswer = "Skip interview and plan immediately" /// Encodes the user's decision in the agent's expected hook stdout diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 78e35574de..7d8304728c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -204,6 +204,9 @@ FEED0000000000000000F00D /* FeedButtonStyleDebugWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */; }; FEED0000000000000000F002 /* FeedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F001 /* FeedCoordinator.swift */; }; FEEDC0DEC0DEC0DEC0DE0001 /* FeedCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDC0DEC0DEC0DEC0DE0002 /* FeedCoordinatorTests.swift */; }; + FEED49850000000000000001 /* FeedEventClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED49850000000000000002 /* FeedEventClassificationTests.swift */; }; + FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDC1A50000000000000002 /* FeedEventClassifier.swift */; }; + FEEDC1A50000000000000003 /* FeedEventClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDC1A50000000000000002 /* FeedEventClassifier.swift */; }; FEED0000000000000000F005 /* FeedPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F004 /* FeedPanelView.swift */; }; FEED0000000000000000F011 /* FeedPanelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F010 /* FeedPanelViewModel.swift */; }; FEED0000000000000000F013 /* FeedPermissionActionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */; }; @@ -821,6 +824,8 @@ FEED0000000000000000F00C /* FeedButtonStyleDebugWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedButtonStyleDebugWindowController.swift; sourceTree = ""; }; FEED0000000000000000F001 /* FeedCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedCoordinator.swift; sourceTree = ""; }; FEEDC0DEC0DEC0DEC0DE0002 /* FeedCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCoordinatorTests.swift; sourceTree = ""; }; + FEED49850000000000000002 /* FeedEventClassificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEventClassificationTests.swift; sourceTree = ""; }; + FEEDC1A50000000000000002 /* FeedEventClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedEventClassifier.swift; sourceTree = ""; }; FEED0000000000000000F004 /* FeedPanelView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelView.swift; sourceTree = ""; }; FEED0000000000000000F010 /* FeedPanelViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPanelViewModel.swift; sourceTree = ""; }; FEED0000000000000000F012 /* FeedPermissionActionPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedPermissionActionPolicy.swift; sourceTree = ""; }; @@ -1685,6 +1690,7 @@ isa = PBXGroup; children = ( B9000001A1B2C3D4E5F60719 /* cmux.swift */, + FEEDC1A50000000000000002 /* FeedEventClassifier.swift */, B900004BA1B2C3D4E5F60719 /* CLISocketPathResolver.swift */, C510C1E00000000000000001 /* SocketOperationTelemetry.swift */, B9000030A1B2C3D4E5F60719 /* cmux_open.swift */, @@ -1808,6 +1814,7 @@ D2C075029771815DD5DA1332 /* NotificationAndMenuBarTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */, + FEED49850000000000000002 /* FeedEventClassificationTests.swift */, 42D69572C8D276745E502B94 /* SessionIndexViewTests.swift */, F1C3F1DBF6BF5D7223C4A30C /* SidebarMarkdownRendererTests.swift */, 14A7DC53B9CA33BE2A421711 /* WorkspacePullRequestSidebarTests.swift */, @@ -2506,6 +2513,7 @@ B9000048A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift in Sources */, B9000044A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift in Sources */, B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */, + FEEDC1A50000000000000001 /* FeedEventClassifier.swift in Sources */, C0DEF0B10000000000000003 /* JSONCParser.swift in Sources */, C47110020000000000000003 /* ProcessPipeReader.swift in Sources */, B9000027A1B2C3D4E5F60719 /* RemoteRelayZshBootstrap.swift in Sources */, @@ -2611,6 +2619,8 @@ C0DEF4120000000000000001 /* CommandPaletteSettingsToggleTests.swift in Sources */, C1713006C1713006C1713006 /* CommandPaletteShortcutCustomizationTests.swift in Sources */, FEEDC0DEC0DEC0DEC0DE0001 /* FeedCoordinatorTests.swift in Sources */, + FEED49850000000000000001 /* FeedEventClassificationTests.swift in Sources */, + FEEDC1A50000000000000003 /* FeedEventClassifier.swift in Sources */, D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */, FE002101 /* FileExplorerRootResolverTests.swift in Sources */, B37A0000000000000000000B /* FileExplorerStateModePersistenceTests.swift in Sources */, diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift new file mode 100644 index 0000000000..01675158c8 --- /dev/null +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -0,0 +1,121 @@ +import Testing + +// `FeedEventClassifier` lives in `CLI/FeedEventClassifier.swift`, which is +// compiled into both the `cmux-cli` target and this test target — so the pure +// classification decision can be unit-tested directly, without `@testable` +// importing the `cmux_cli` executable module (whose symbols the app-hosted +// test bundle cannot link). + +/// Regression coverage for the feed-event → user-attention classification. +/// +/// The "Terminal needs approval" notification (see `FeedCoordinator`) fires +/// only for events that `classifyFeedEvent` marks actionable and whose wire +/// `hook_event_name` is `PermissionRequest` / `ExitPlanMode` / +/// `AskUserQuestion`. The class of bug this guards against is broad +/// pattern-matching that maps a *tool-starting* lifecycle event to an +/// approval, over-triggering the notification. +/// +/// https://github.com/manaflow-ai/cmux/issues/4985 +@Suite("Feed event classification") +struct FeedEventClassificationTests { + private func classify(_ source: String, _ event: String, tool: String = "") + -> (name: String, actionable: Bool) + { + let result = FeedEventClassifier.classify(source: source, event: event, toolName: tool) + return (result.0, result.1) + } + + // MARK: Hermes Agent (the reported bug) + + /// Hermes emits `pre_tool_call` when a tool *starts* — no approval is + /// pending. It has a distinct `pre_approval_request` event for real + /// approvals. `pre_tool_call` must never be actionable, even for a + /// side-effecting tool like `terminal`, or the user sees a spurious + /// "Terminal needs approval" banner with nothing pending in the TUI. + @Test func hermesPreToolCallIsTelemetryEvenForSideEffectingTools() { + #expect(classify("hermes-agent", "pre_tool_call", tool: "terminal").actionable == false) + #expect(classify("hermes-agent", "pre_tool_call", tool: "Bash").actionable == false) + #expect(classify("hermes-agent", "pre_tool_call", tool: "Write").actionable == false) + #expect(classify("hermes-agent", "pre_tool_call", tool: "Read").actionable == false) + #expect(classify("hermes-agent", "pre_tool_call", tool: "terminal").name == "PreToolUse") + } + + /// Lifecycle bookends are telemetry only. + @Test func hermesLifecycleEventsAreNotActionable() { + #expect(classify("hermes-agent", "post_tool_call").actionable == false) + #expect(classify("hermes-agent", "pre_llm_call").actionable == false) + #expect(classify("hermes-agent", "post_llm_call").actionable == false) + #expect(classify("hermes-agent", "on_session_start").actionable == false) + #expect(classify("hermes-agent", "on_session_end").actionable == false) + } + + /// `pre_approval_request` carries the real approval semantic. The + /// "needs approval" notification fires for it via the dedicated + /// `notification` hook subcommand, so on the feed path it stays a + /// non-blocking `Notification` (avoids a double banner). + @Test func hermesApprovalRequestStaysNonBlockingOnFeedPath() { + let approval = classify("hermes-agent", "pre_approval_request") + #expect(approval.name == "Notification") + #expect(approval.actionable == false) + } + + /// Future Hermes event names must be safe by default: unknown → no + /// notification (non-actionable telemetry). + @Test func hermesUnknownEventIsSafeByDefault() { + let unknown = classify("hermes-agent", "some_future_event", tool: "terminal") + #expect(unknown.actionable == false) + } + + // MARK: Claude (dedicated-approval agent — must not regress) + + /// Claude owns approvals through its `PermissionRequest` hook; its + /// `PreToolUse` is telemetry and must not escalate side-effecting tools. + @Test func claudePreToolUseDoesNotEscalate() { + #expect(classify("claude", "PreToolUse", tool: "Bash").actionable == false) + #expect(classify("claude", "PreToolUse", tool: "Write").actionable == false) + } + + @Test func claudePermissionRequestIsActionable() { + #expect(classify("claude", "PermissionRequest", tool: "Bash").name == "PermissionRequest") + #expect(classify("claude", "PermissionRequest", tool: "Bash").actionable == true) + #expect(classify("claude", "PermissionRequest", tool: "ExitPlanMode").name == "ExitPlanMode") + #expect(classify("claude", "PermissionRequest", tool: "AskUserQuestion").name == "AskUserQuestion") + } + + // MARK: Generic agents without a dedicated approval event + + /// Agents whose only signal is `PreToolUse` (gemini, copilot, …) still + /// escalate side-effecting tools to an approval — that path is correct + /// and must be preserved. + @Test func genericPreToolUseEscalatesSideEffectingTools() { + #expect(classify("gemini", "PreToolUse", tool: "Bash").name == "PermissionRequest") + #expect(classify("gemini", "PreToolUse", tool: "Bash").actionable == true) + #expect(classify("gemini", "PreToolUse", tool: "Read").actionable == false) + } + + /// Even on the maybe-approval (generic pre-tool) path, the two dedicated + /// approval tool names route to their own wire kinds — they are never + /// collapsed into a generic `PermissionRequest`. Guards the shared + /// `dedicatedApprovalEvent(for:)` branch inside `.toolStartMaybeApproval`. + @Test func genericPreToolUseRoutesDedicatedApprovalTools() { + #expect(classify("gemini", "PreToolUse", tool: "ExitPlanMode").name == "ExitPlanMode") + #expect(classify("gemini", "PreToolUse", tool: "ExitPlanMode").actionable == true) + #expect(classify("gemini", "PreToolUse", tool: "AskUserQuestion").name == "AskUserQuestion") + #expect(classify("gemini", "PreToolUse", tool: "AskUserQuestion").actionable == true) + } + + /// Codex has a dedicated `PermissionRequest` feed event, so its + /// pre-tool events (`PreToolUse` and the Codex-specific + /// `beforeShellExecution`) are telemetry only. + @Test func codexPreToolUseIsTelemetry() { + #expect(classify("codex", "PreToolUse", tool: "shell").actionable == false) + #expect(classify("codex", "beforeShellExecution", tool: "shell").actionable == false) + #expect(classify("codex", "beforeShellExecution", tool: "shell").name == "PreToolUse") + #expect(classify("codex", "PermissionRequest", tool: "shell").actionable == true) + } + + /// Unknown source + unknown event is safe by default. + @Test func unknownSourceUnknownEventIsSafe() { + #expect(classify("totally-new-agent", "some_future_event", tool: "Bash").actionable == false) + } +}