From 5a4632d12804aa4ee6c561a80b214204bb144169 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 01:08:51 -0700 Subject: [PATCH 01/39] test: cover Copilot PreToolUse feed gating --- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/CLICopilotHookFeedTests.swift | 140 +++++++++++++++++++ cmuxTests/FeedEventClassificationTests.swift | 10 ++ 3 files changed, 154 insertions(+) create mode 100644 cmuxTests/CLICopilotHookFeedTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 4ae277c093a8..cf374a37f69b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -160,6 +160,7 @@ A9F200000000000000000015 /* ClaudeStreamJSONAccumulator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */; }; A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */; }; C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */; }; + C6574C0A0000000000000001 /* CLICopilotHookFeedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6574C0A0000000000000002 /* CLICopilotHookFeedTests.swift */; }; C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000002 /* CLIForwardingLaunchArgumentTests.swift */; }; C46790000000000000000003 /* CLIForwardingLaunchRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */; }; A5D41207A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41208A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift */; }; @@ -1266,6 +1267,7 @@ A9F100000000000000000015 /* ClaudeStreamJSONAccumulator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ClaudeStreamJSONAccumulator.swift; sourceTree = ""; }; A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIAuthAliasTests.swift; sourceTree = ""; }; C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICodexHookTimeoutRegressionTests.swift; sourceTree = ""; }; + C6574C0A0000000000000002 /* CLICopilotHookFeedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLICopilotHookFeedTests.swift; sourceTree = ""; }; C46790000000000000000002 /* CLIForwardingLaunchArgumentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIForwardingLaunchArgumentTests.swift; sourceTree = ""; }; C46790000000000000000004 /* CLIForwardingLaunchRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CLIForwardingLaunchRouter.swift; sourceTree = ""; }; A5D41208A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIGenericHookPersistenceTests.swift; sourceTree = ""; }; @@ -3220,6 +3222,7 @@ C12984000000000000000003 /* CLIStdioSIGPIPERegressionTests.swift */, B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */, C0D3F1F00000000000000104 /* CLICodexHookTimeoutRegressionTests.swift */, + C6574C0A0000000000000002 /* CLICopilotHookFeedTests.swift */, A5D41208A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift */, A5D41212A1B2C3D4E5F60718 /* CLIHookNoResponseTests.swift */, A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */, @@ -4427,6 +4430,7 @@ A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */, A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */, C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */, + C6574C0A0000000000000001 /* CLICopilotHookFeedTests.swift in Sources */, C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */, A5D41207A1B2C3D4E5F60718 /* CLIGenericHookPersistenceTests.swift in Sources */, A5D41211A1B2C3D4E5F60718 /* CLIHookNoResponseTests.swift in Sources */, diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift new file mode 100644 index 000000000000..b486379e4484 --- /dev/null +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -0,0 +1,140 @@ +import Darwin +import XCTest + +extension CLINotifyProcessIntegrationRegressionTests { + func testCopilotHookInstallWritesToHooksSubdirectory() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-copilot-hook-install-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "copilot", "install", "--yes"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + + let hookURL = root + .appendingPathComponent(".copilot", isDirectory: true) + .appendingPathComponent("hooks", isDirectory: true) + .appendingPathComponent("cmux.json", isDirectory: false) + let json = try XCTUnwrap( + JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any], + "Expected hook file at ~/.copilot/hooks/cmux.json" + ) + + XCTAssertEqual(json["version"] as? Int, 1) + let hooks = try XCTUnwrap(json["hooks"] as? [String: Any]) + + XCTAssertNotNil(hooks["SessionStart"], "Missing SessionStart hook") + XCTAssertNotNil(hooks["Stop"], "Missing Stop hook") + XCTAssertNotNil(hooks["Notification"], "Missing Notification hook") + XCTAssertNotNil(hooks["SessionEnd"], "Missing SessionEnd hook") + let preToolUse = try XCTUnwrap(hooks["PreToolUse"] as? [[String: Any]]) + let preToolUseCommands = preToolUse + .compactMap { $0["hooks"] as? [[String: Any]] } + .flatMap { $0 } + XCTAssertTrue( + preToolUseCommands.contains { + ($0["command"] as? String)?.contains("hooks feed --source copilot --event PreToolUse") == true + && ($0["timeout"] as? Int) == 120_000 + }, + "Expected PreToolUse feed hook with 120 000 ms timeout, saw \(preToolUseCommands)" + ) + } + + func testCopilotFeedDecisionEmitsPreToolUsePermissionDecision() throws { + func runCopilotDecision(mode: String) throws -> (ProcessRunResult, [String: Any]) { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("copilot-feed-decision") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-copilot-feed-decision-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "33333333-3333-3333-3333-333333333333" + let surfaceId = "44444444-4444-4444-4444-444444444444" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = self.jsonObject(line) else { + return self.malformedRequestResponse(raw: line) + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + XCTAssertEqual(method, "feed.push") + return self.v2Response( + id: id, + ok: true, + result: [ + "status": "resolved", + "decision": ["kind": "permission", "mode": mode], + ] + ) + } + + let result = runProcess( + executablePath: cliPath, + arguments: ["hooks", "feed", "--source", "copilot", "--event", "PreToolUse"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_COPILOT_PID": "525252", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + standardInput: #"{"hook_event_name":"PreToolUse","session_id":"copilot-session-123","cwd":"\#(root.path)","tool_name":"Bash","tool_input":{"command":"touch \#(root.appendingPathComponent("README.md").path)"}}"#, + timeout: 5 + ) + wait(for: [serverHandled], timeout: 5) + + let feedEvents = state.commands.compactMap { command -> [String: Any]? in + guard let payload = self.jsonObject(command), + payload["method"] as? String == "feed.push", + let params = payload["params"] as? [String: Any], + let event = params["event"] as? [String: Any] else { + return nil + } + return event + } + XCTAssertEqual(feedEvents.count, 1, "Expected one Copilot Feed event, saw \(state.commands)") + return (result, try XCTUnwrap(feedEvents.first)) + } + + let (allow, allowEvent) = try runCopilotDecision(mode: "once") + XCTAssertFalse(allow.timedOut, allow.stderr) + XCTAssertEqual(allow.status, 0, allow.stderr) + XCTAssertEqual(allowEvent["hook_event_name"] as? String, "PermissionRequest") + XCTAssertEqual(allowEvent["_source"] as? String, "copilot") + XCTAssertEqual(allowEvent["_ppid"] as? Int, 525252) + let allowOutput = try XCTUnwrap(jsonObject(allow.stdout)) + XCTAssertEqual(allowOutput["permissionDecision"] as? String, "allow") + XCTAssertNil(allowOutput["hookSpecificOutput"]) + + let (deny, _) = try runCopilotDecision(mode: "deny") + XCTAssertFalse(deny.timedOut, deny.stderr) + XCTAssertEqual(deny.status, 0, deny.stderr) + let denyOutput = try XCTUnwrap(jsonObject(deny.stdout)) + XCTAssertEqual(denyOutput["permissionDecision"] as? String, "deny") + XCTAssertEqual(denyOutput["permissionDecisionReason"] as? String, "User denied permission via cmux Feed.") + XCTAssertNil(denyOutput["hookSpecificOutput"]) + } +} diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index fbfb10beef02..714823084ff9 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -93,6 +93,16 @@ struct FeedEventClassificationTests { #expect(classify("gemini", "PreToolUse", tool: "Read").actionable == false) } + /// Copilot's `PreToolUse` hook is the approval gate itself. It must + /// produce a blocking `PermissionRequest` even for tools that generic + /// telemetry-only agents would keep non-actionable. + @Test func copilotPreToolUseIsApprovalRequest() { + #expect(classify("copilot", "PreToolUse", tool: "Bash").name == "PermissionRequest") + #expect(classify("copilot", "PreToolUse", tool: "Bash").actionable == true) + #expect(classify("copilot", "PreToolUse", tool: "Read").name == "PermissionRequest") + #expect(classify("copilot", "PreToolUse", tool: "Read").actionable == true) + } + /// 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 From 899cca3bda64a85187990d5b155ff5fc75db5c3e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 01:08:56 -0700 Subject: [PATCH 02/39] fix: gate Copilot PreToolUse feed hooks --- CLI/CMUXCLI+AgentHookDefinitions.swift | 2 +- CLI/FeedEventClassifier.swift | 5 +++++ CLI/cmux.swift | 4 ++-- docs/agent-hooks.md | 2 +- docs/feed.md | 2 +- docs/notifications.md | 2 +- web/app/[locale]/docs/notifications/page.tsx | 2 +- 7 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 6e0e4ba7875c..1b59ef561c6c 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -311,7 +311,7 @@ extension CMUXCLI { ), AgentHookDef( name: "copilot", displayName: "Copilot", statusKey: "copilot", - configDir: ".copilot", configFile: "config.json", configDirEnvOverride: "COPILOT_HOME", + configDir: ".copilot/hooks", configFile: "cmux.json", configDirEnvOverride: "COPILOT_HOME", configDirEnvOverrideSubpath: "hooks", createConfigDirIfMissing: true, sessionStoreSuffix: "copilot", disableEnvVar: "CMUX_COPILOT_HOOKS_DISABLED", hookMarker: "cmux hooks copilot", format: .nested(timeoutMs: 5000), events: [ diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 7f6de0613f6d..c9e3e22c9174 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -216,6 +216,11 @@ struct FeedEventClassifier { "agentSpawn": .sessionStart, "stop": .response, ], + "copilot": [ + // Copilot's PreToolUse hook is the permission gate itself: + // the hook output directly controls whether the tool runs. + "PreToolUse": .approvalRequest, + ], ] /// Fallback table for agents without a dedicated entry in diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6217e60f27a4..c03edab641d1 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28234,7 +28234,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } existing["hooks"] = hooks - if case .flat = def.format { existing["version"] = 1 } + if case .flat = def.format { existing["version"] = 1 } else if def.name == "copilot" { existing["version"] = 1 } if case .kiroAgentJSON = def.format { if existing["name"] == nil { existing["name"] = "cmux" @@ -33210,7 +33210,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } return out } - func hermesAgentBlock(_ message: String) -> String { encode(["action": "block", "message": message]) } @@ -33243,6 +33242,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } return encode(permissionRequestHookDecision(behavior: "allow")) } + if source == "copilot" { return encode(mode == "deny" ? ["permissionDecision": "deny", "permissionDecisionReason": "User denied permission via cmux Feed."] : ["permissionDecision": "allow"]) } if source == "hermes-agent" { if mode == "deny" { return hermesAgentBlock("User denied permission via cmux Feed.") diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index e918800425cb..2b4b530f2682 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -28,7 +28,7 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `amp`, `curs | Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --resume ` | PreToolUse | | Kiro CLI | `kiro-cli` | `~/.kiro/agents/cmux.json` or `$KIRO_HOME/agents/cmux.json` | `kiro-cli chat --resume-id ` | preToolUse, postToolUse | | Rovo Dev | `acli` | `~/.rovodev/config.yml` | `acli rovodev run --restore ` | none | -| Copilot | `copilot` | `~/.copilot/config.json` | `copilot --resume ` | PreToolUse | +| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | PreToolUse | | CodeBuddy | `codebuddy` | `~/.codebuddy/settings.json` | `codebuddy --resume ` | PreToolUse | | Factory | `droid` | `~/.factory/settings.json` | `droid --resume ` | PreToolUse | | Qoder | `qodercli` | `~/.qoder/settings.json` | `qodercli --resume ` | PreToolUse | diff --git a/docs/feed.md b/docs/feed.md index 43d77f1a87c2..9ea233b7098f 100644 --- a/docs/feed.md +++ b/docs/feed.md @@ -80,7 +80,7 @@ Installs supported agent hooks whose binaries are on `PATH`. See [Agent hook int | OpenCode | `~/.config/opencode/plugins/cmux-feed.js` | plugin event bus | | Cursor CLI | `~/.cursor/hooks.json` | beforeShellExecution | | Gemini | `~/.gemini/settings.json` | PreToolUse | -| Copilot | `~/.copilot/config.json` | PreToolUse | +| Copilot | `~/.copilot/hooks/cmux.json` | PreToolUse | | CodeBuddy | `~/.codebuddy/settings.json` | PreToolUse | | Factory | `~/.factory/settings.json` | PreToolUse | | Qoder | `~/.qoder/settings.json` | PreToolUse | diff --git a/docs/notifications.md b/docs/notifications.md index 8240448e2976..23f15fd55acc 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -117,7 +117,7 @@ See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-co ### GitHub Copilot CLI -Copilot CLI supports [hooks](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks) that run shell commands at key lifecycle events. Add to `~/.copilot/config.json`: +Copilot CLI supports [hooks](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks) that run shell commands at key lifecycle events. Add to `~/.copilot/hooks/cmux.json`: ```json { diff --git a/web/app/[locale]/docs/notifications/page.tsx b/web/app/[locale]/docs/notifications/page.tsx index 0a90069c27ec..82dd6bb436ac 100644 --- a/web/app/[locale]/docs/notifications/page.tsx +++ b/web/app/[locale]/docs/notifications/page.tsx @@ -266,7 +266,7 @@ esac`} ), })}

- {`{ + {`{ "hooks": { "userPromptSubmitted": [ { From af195109b0ebebc1a212e0c5f153420f75dec4e7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 01:17:51 -0700 Subject: [PATCH 03/39] fix: address Copilot hook review feedback --- CLI/CMUXCLI+AgentHookDefinitions.swift | 8 ++++---- CLI/cmux.swift | 4 ++-- Resources/Localizable.xcstrings | 17 +++++++++++++++++ docs/notifications.md | 1 + web/app/[locale]/docs/notifications/page.tsx | 1 + 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 1b59ef561c6c..dcc49bc732e2 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -20,7 +20,7 @@ extension CMUXCLI { let binaryName: String let format: HookFormat let events: [HookEvent] - let aliases: Set + let aliases: Set; let requiresVersionKey: Bool let publishesStopNotification: Bool /// Whether this agent's `SessionEnd`/`session-end` hook fires once per /// conversation turn rather than at a true session teardown. @@ -106,7 +106,7 @@ extension CMUXCLI { binaryName: String? = nil, sessionStoreSuffix: String, disableEnvVar: String, hookMarker: String, format: HookFormat, events: [HookEvent], - aliases: Set = [], + aliases: Set = [], requiresVersionKey: Bool = false, publishesStopNotification: Bool = true, sessionEndIsTurnBoundary: Bool = false, feedHookEvents: [String] = [], @@ -127,7 +127,7 @@ extension CMUXCLI { let normalized = alias.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return normalized.isEmpty ? nil : normalized }) - self.feedHookEvents = feedHookEvents + self.feedHookEvents = feedHookEvents; self.requiresVersionKey = requiresVersionKey self.postInstallAction = postInstallAction self.postInstallNote = postInstallNote } @@ -320,7 +320,7 @@ extension CMUXCLI { .init(agentEvent: "Notification", cmuxSubcommand: "stop"), .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), ], - feedHookEvents: ["PreToolUse"] + requiresVersionKey: true, feedHookEvents: ["PreToolUse"] ), AgentHookDef( name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy", diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c03edab641d1..1b9e14314479 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28234,7 +28234,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } existing["hooks"] = hooks - if case .flat = def.format { existing["version"] = 1 } else if def.name == "copilot" { existing["version"] = 1 } + if case .flat = def.format { existing["version"] = 1 }; if def.requiresVersionKey { existing["version"] = 1 } if case .kiroAgentJSON = def.format { if existing["name"] == nil { existing["name"] = "cmux" @@ -33242,7 +33242,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } return encode(permissionRequestHookDecision(behavior: "allow")) } - if source == "copilot" { return encode(mode == "deny" ? ["permissionDecision": "deny", "permissionDecisionReason": "User denied permission via cmux Feed."] : ["permissionDecision": "allow"]) } + if source == "copilot" { return encode(mode == "deny" ? ["permissionDecision": "deny", "permissionDecisionReason": String(localized: "cli.hooks.feed.permissionDeniedReason", defaultValue: "User denied permission via cmux Feed.")] : ["permissionDecision": "allow"]) } if source == "hermes-agent" { if mode == "deny" { return hermesAgentBlock("User denied permission via cmux Feed.") diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4859958b0be4..6b1671116f8a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -672,6 +672,23 @@ } } }, + "cli.hooks.feed.permissionDeniedReason": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "User denied permission via cmux Feed." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユーザーが cmux Feed で権限を拒否しました。" + } + } + } + }, "cli.hooks.omp.aborted": { "extractionState": "manual", "localizations": { diff --git a/docs/notifications.md b/docs/notifications.md index 23f15fd55acc..ebd5bedd7638 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -121,6 +121,7 @@ Copilot CLI supports [hooks](https://docs.github.com/en/copilot/how-tos/use-copi ```json { + "version": 1, "hooks": { "userPromptSubmitted": [ { diff --git a/web/app/[locale]/docs/notifications/page.tsx b/web/app/[locale]/docs/notifications/page.tsx index 82dd6bb436ac..e91b09be2f8a 100644 --- a/web/app/[locale]/docs/notifications/page.tsx +++ b/web/app/[locale]/docs/notifications/page.tsx @@ -267,6 +267,7 @@ esac`} })}

{`{ + "version": 1, "hooks": { "userPromptSubmitted": [ { From 2e41944a3626605d8cf9fbf7630fb91fd2427e1d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 01:29:35 -0700 Subject: [PATCH 04/39] fix: write Copilot hooks with direct schema --- CLI/CMUXCLI+AgentHookDefinitions.swift | 12 +++++------ CLI/cmux.swift | 28 ++++++++++++------------- cmuxTests/CLICopilotHookFeedTests.swift | 11 +++++----- 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index dcc49bc732e2..ab29d5ab7f1c 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -20,7 +20,7 @@ extension CMUXCLI { let binaryName: String let format: HookFormat let events: [HookEvent] - let aliases: Set; let requiresVersionKey: Bool + let aliases: Set let publishesStopNotification: Bool /// Whether this agent's `SessionEnd`/`session-end` hook fires once per /// conversation turn rather than at a true session teardown. @@ -53,7 +53,7 @@ extension CMUXCLI { enum HookFormat { case flat // Cursor: {"hooks": {"event": [{"command": "..."}]}, "version": 1} - case nested(timeoutMs: Int) // Nested type/command/timeout hooks; timeout unit is agent-specific. + case nested(timeoutMs: Int), copilotJSON(timeoutSeconds: Int) // Agent-specific command hook JSON. case kiroAgentJSON(timeoutMs: Int) // ~/.kiro/agents/*.json flat command entries with timeout_ms case antigravityJSON(timeoutSeconds: Int) // ~/.gemini/config/hooks.json named hook groups case rovoDevYAML @@ -106,7 +106,7 @@ extension CMUXCLI { binaryName: String? = nil, sessionStoreSuffix: String, disableEnvVar: String, hookMarker: String, format: HookFormat, events: [HookEvent], - aliases: Set = [], requiresVersionKey: Bool = false, + aliases: Set = [], publishesStopNotification: Bool = true, sessionEndIsTurnBoundary: Bool = false, feedHookEvents: [String] = [], @@ -127,7 +127,7 @@ extension CMUXCLI { let normalized = alias.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return normalized.isEmpty ? nil : normalized }) - self.feedHookEvents = feedHookEvents; self.requiresVersionKey = requiresVersionKey + self.feedHookEvents = feedHookEvents self.postInstallAction = postInstallAction self.postInstallNote = postInstallNote } @@ -313,14 +313,14 @@ extension CMUXCLI { name: "copilot", displayName: "Copilot", statusKey: "copilot", configDir: ".copilot/hooks", configFile: "cmux.json", configDirEnvOverride: "COPILOT_HOME", configDirEnvOverrideSubpath: "hooks", createConfigDirIfMissing: true, sessionStoreSuffix: "copilot", disableEnvVar: "CMUX_COPILOT_HOOKS_DISABLED", - hookMarker: "cmux hooks copilot", format: .nested(timeoutMs: 5000), + hookMarker: "cmux hooks copilot", format: .copilotJSON(timeoutSeconds: 5), events: [ .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), .init(agentEvent: "Stop", cmuxSubcommand: "stop"), .init(agentEvent: "Notification", cmuxSubcommand: "stop"), .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), ], - requiresVersionKey: true, feedHookEvents: ["PreToolUse"] + feedHookEvents: ["PreToolUse"] ), AgentHookDef( name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy", diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 1b9e14314479..4a049f00824a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -26965,10 +26965,7 @@ struct CMUXCLI { Self.hookCommandString(for: def, event: event) } - /// Shell command the agent runs for a Feed bridge event. 120s timeout - /// inside the shell is applied via the agent's `timeout` field in the - /// nested hook config (see `buildHooksDict`); the shell command - /// itself just dispatches. + /// Shell command the agent runs for a Feed bridge event. func feedHookCommand(for def: AgentHookDef, agentEvent: String) -> String { Self.feedHookCommandString(for: def, agentEvent: agentEvent) } @@ -26996,6 +26993,8 @@ struct CMUXCLI { "hooks": [["type": "command", "command": cmd, "timeout": timeout] as [String: Any]] ] as [String: Any]) result[event.agentEvent] = groups + case .copilotJSON(let timeoutSeconds): + var entries = result[event.agentEvent] as? [[String: Any]] ?? []; entries.append(Self.copilotHookEntry(command: cmd, timeoutSeconds: timeoutSeconds)); result[event.agentEvent] = entries case .antigravityJSON(let timeoutSeconds): var entries = result[event.agentEvent] as? [[String: Any]] ?? [] entries.append(Self.antigravityHookEntry( @@ -27008,10 +27007,7 @@ struct CMUXCLI { break } } - // Layer in Feed bridge entries. Blocking approval bridges get a long - // timeout; Codex telemetry stays short so it never delays Codex's own - // approval reviewer. Most nested agents use milliseconds. Codex, Grok, - // and Antigravity hook schemas use seconds, so normalize before writing. + // Layer in Feed bridge entries; blocking approvals get a long timeout. for agentEvent in def.feedHookEvents { let feedCmd = feedHookCommand(for: def, agentEvent: agentEvent) let feedTimeoutMs = feedHookTimeoutMs(for: def, agentEvent: agentEvent) @@ -27034,6 +27030,8 @@ struct CMUXCLI { "hooks": [["type": "command", "command": feedCmd, "timeout": timeout] as [String: Any]] ] as [String: Any]) result[agentEvent] = groups + case .copilotJSON: + var entries = result[agentEvent] as? [[String: Any]] ?? []; entries.append(Self.copilotHookEntry(command: feedCmd, timeoutSeconds: Self.timeoutSecondsFromMilliseconds(feedTimeoutMs))); result[agentEvent] = entries case .antigravityJSON: var entries = result[agentEvent] as? [[String: Any]] ?? [] entries.append(Self.antigravityHookEntry( @@ -27071,6 +27069,8 @@ struct CMUXCLI { return ((positiveTimeoutMs - 1) / 1000) + 1 } + private static func copilotHookEntry(command: String, timeoutSeconds: Int) -> [String: Any] { ["type": "command", "command": command, "timeoutSec": max(timeoutSeconds, 1)] } + private static func antigravityHookEntry( command: String, timeoutSeconds: Int, @@ -28151,11 +28151,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { var cmuxInsertionIndexes: [String: [Int]] = [:] for (event, value) in hooks { switch def.format { - case .flat, .kiroAgentJSON: + case .flat, .kiroAgentJSON, .copilotJSON: guard let entries = value as? [[String: Any]] else { continue } var rewrittenEntries: [[String: Any]] = [] for entry in entries { - if isCmuxOwnedCommand(entry["command"] as? String ?? "") { + if Self.jsonHookValueContainsCmuxOwnedCommand(entry, for: def) { Self.appendCmuxHookInsertionIndex( rewrittenEntries.count, for: event, @@ -28208,7 +28208,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { // Add new cmux entries for (event, value) in newHooks { switch def.format { - case .flat, .kiroAgentJSON: + case .flat, .kiroAgentJSON, .copilotJSON: var entries = hooks[event] as? [[String: Any]] ?? [] if let newEntries = value as? [[String: Any]] { if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { @@ -28234,7 +28234,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } existing["hooks"] = hooks - if case .flat = def.format { existing["version"] = 1 }; if def.requiresVersionKey { existing["version"] = 1 } + if case .flat = def.format { existing["version"] = 1 }; if case .copilotJSON = def.format { existing["version"] = 1 } if case .kiroAgentJSON = def.format { if existing["name"] == nil { existing["name"] = "cmux" @@ -28488,10 +28488,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } for (event, value) in hooks { switch def.format { - case .flat, .kiroAgentJSON: + case .flat, .kiroAgentJSON, .copilotJSON: guard var entries = value as? [[String: Any]] else { continue } let before = entries.count - entries.removeAll { isCmuxOwnedCommand($0["command"] as? String ?? "") } + entries.removeAll { Self.jsonHookValueContainsCmuxOwnedCommand($0, for: def) } removed += before - entries.count if entries.isEmpty { hooks.removeValue(forKey: event) diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index b486379e4484..a9ccec95c32a 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -40,15 +40,14 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNotNil(hooks["Notification"], "Missing Notification hook") XCTAssertNotNil(hooks["SessionEnd"], "Missing SessionEnd hook") let preToolUse = try XCTUnwrap(hooks["PreToolUse"] as? [[String: Any]]) - let preToolUseCommands = preToolUse - .compactMap { $0["hooks"] as? [[String: Any]] } - .flatMap { $0 } XCTAssertTrue( - preToolUseCommands.contains { + preToolUse.contains { ($0["command"] as? String)?.contains("hooks feed --source copilot --event PreToolUse") == true - && ($0["timeout"] as? Int) == 120_000 + && ($0["type"] as? String) == "command" + && ($0["timeoutSec"] as? Int) == 120 + && $0["hooks"] == nil }, - "Expected PreToolUse feed hook with 120 000 ms timeout, saw \(preToolUseCommands)" + "Expected direct PreToolUse command hook with 120s timeout, saw \(preToolUse)" ) } From b759c638fdb4d5718ae920cea270a8ea1c810276 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 01:31:57 -0700 Subject: [PATCH 05/39] fix: address Copilot decision review feedback --- CLI/cmux.swift | 26 ++++---- Resources/Localizable.xcstrings | 108 ++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 13 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4a049f00824a..3e7d4027054b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -33106,12 +33106,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { private static func emitKiroDecisionIfHandled(decision: [String: Any]) -> Bool { guard (decision["kind"] as? String) == "permission" else { return false } let mode = (decision["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - // Feed permission decisions carry a WorkstreamPermissionMode raw value: - // `once` / `always` / `all` / `bypass` all allow the tool; `deny` - // blocks. Fail closed on anything else — missing, empty, or an - // unrecognized/typo mode — so a malformed decision blocks the tool - // (exit 2 is Kiro's preToolUse deny signal) rather than silently - // allowing work the user never approved. + // Fail closed on unknown Feed permission modes so malformed Kiro decisions never allow unapproved work. let allowModes: Set = ["once", "always", "all", "bypass"] if let mode, allowModes.contains(mode) { print("{}") @@ -33126,9 +33121,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } private static let skipInterviewAndPlanAnswer = "Skip interview and plan immediately" - - /// Encodes the user's decision in the agent's expected hook stdout - /// shape so the agent honors it. + /// Encodes the user's decision in the agent's expected hook stdout shape. private static func renderAgentDecision( source: String, hookEventName: String, @@ -33172,9 +33165,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ] } - // PreToolUse output for non-Claude agents that still use a - // PreToolUse-compatible permission bridge. Claude Code does not - // use this path. + // PreToolUse-compatible permission bridge output for non-Claude agents. func nonClaudePreToolDecision( permission: String, reason: String?, @@ -33242,7 +33233,16 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } return encode(permissionRequestHookDecision(behavior: "allow")) } - if source == "copilot" { return encode(mode == "deny" ? ["permissionDecision": "deny", "permissionDecisionReason": String(localized: "cli.hooks.feed.permissionDeniedReason", defaultValue: "User denied permission via cmux Feed.")] : ["permissionDecision": "allow"]) } + if source == "copilot" { + if mode == "deny" { + let reason = String( + localized: "cli.hooks.feed.permissionDeniedReason", + defaultValue: "User denied permission via cmux Feed." + ) + return encode(["permissionDecision": "deny", "permissionDecisionReason": reason]) + } + return encode(["permissionDecision": "allow"]) + } if source == "hermes-agent" { if mode == "deny" { return hermesAgentBlock("User denied permission via cmux Feed.") diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 6b1671116f8a..72f46a758c73 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -675,17 +675,125 @@ "cli.hooks.feed.permissionDeniedReason": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "رفض المستخدم الإذن عبر cmux Feed." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Korisnik je odbio dozvolu putem cmux Feeda." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brugeren afviste tilladelsen via cmux Feed." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Benutzer hat die Berechtigung über cmux Feed verweigert." + } + }, "en": { "stringUnit": { "state": "translated", "value": "User denied permission via cmux Feed." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El usuario denegó el permiso mediante cmux Feed." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’utilisateur a refusé l’autorisation via cmux Feed." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L’utente ha negato l’autorizzazione tramite cmux Feed." + } + }, "ja": { "stringUnit": { "state": "translated", "value": "ユーザーが cmux Feed で権限を拒否しました。" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អ្នកប្រើបានបដិសេធការអនុញ្ញាតតាមរយៈ cmux Feed។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자가 cmux Feed에서 권한을 거부했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Brukeren avslo tillatelsen via cmux Feed." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użytkownik odmówił uprawnienia przez cmux Feed." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O usuário negou a permissão pelo cmux Feed." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользователь отклонил разрешение через cmux Feed." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ผู้ใช้ปฏิเสธสิทธิ์ผ่าน cmux Feed" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanıcı izni cmux Feed üzerinden reddetti." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Користувач відхилив дозвіл через cmux Feed." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户已通过 cmux Feed 拒绝权限。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用者已透過 cmux Feed 拒絕權限。" + } } } }, From 07c39ad3f796cbd33035497a994467560cabe6c6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 01:33:09 -0700 Subject: [PATCH 06/39] fix: clean up Copilot hook version logic --- CLI/cmux.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 3e7d4027054b..3928a688c0b4 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28234,7 +28234,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } existing["hooks"] = hooks - if case .flat = def.format { existing["version"] = 1 }; if case .copilotJSON = def.format { existing["version"] = 1 } + switch def.format { + case .flat, .copilotJSON: existing["version"] = 1 + default: break + } if case .kiroAgentJSON = def.format { if existing["name"] == nil { existing["name"] = "cmux" @@ -33235,10 +33238,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } if source == "copilot" { if mode == "deny" { - let reason = String( - localized: "cli.hooks.feed.permissionDeniedReason", - defaultValue: "User denied permission via cmux Feed." - ) + let reason = String(localized: "cli.hooks.feed.permissionDeniedReason", defaultValue: "User denied permission via cmux Feed.") return encode(["permissionDecision": "deny", "permissionDecisionReason": reason]) } return encode(["permissionDecision": "allow"]) From b28221d47ce83ef9e6c7ba41c57968a3ed5e75ad Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 01:41:09 -0700 Subject: [PATCH 07/39] fix: add Copilot hook timeout slack --- CLI/cmux.swift | 2 +- cmuxTests/CLICopilotHookFeedTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 3928a688c0b4..40f99fff3893 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -27031,7 +27031,7 @@ struct CMUXCLI { ] as [String: Any]) result[agentEvent] = groups case .copilotJSON: - var entries = result[agentEvent] as? [[String: Any]] ?? []; entries.append(Self.copilotHookEntry(command: feedCmd, timeoutSeconds: Self.timeoutSecondsFromMilliseconds(feedTimeoutMs))); result[agentEvent] = entries + var entries = result[agentEvent] as? [[String: Any]] ?? []; entries.append(Self.copilotHookEntry(command: feedCmd, timeoutSeconds: Self.timeoutSecondsFromMilliseconds(feedTimeoutMs) + 5)); result[agentEvent] = entries case .antigravityJSON: var entries = result[agentEvent] as? [[String: Any]] ?? [] entries.append(Self.antigravityHookEntry( diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index a9ccec95c32a..1aaf44bd6e3e 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -44,10 +44,10 @@ extension CLINotifyProcessIntegrationRegressionTests { preToolUse.contains { ($0["command"] as? String)?.contains("hooks feed --source copilot --event PreToolUse") == true && ($0["type"] as? String) == "command" - && ($0["timeoutSec"] as? Int) == 120 + && ($0["timeoutSec"] as? Int) == 125 && $0["hooks"] == nil }, - "Expected direct PreToolUse command hook with 120s timeout, saw \(preToolUse)" + "Expected direct PreToolUse command hook with timeout slack, saw \(preToolUse)" ) } From bba9f387f191d4df5f7c15c5f5a0b06a40066230 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 01:55:22 -0700 Subject: [PATCH 08/39] fix: keep Copilot PreToolUse on permission schema --- CLI/FeedEventClassifier.swift | 7 +++++-- cmuxTests/FeedEventClassificationTests.swift | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index c9e3e22c9174..25bb192cb721 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -47,8 +47,8 @@ struct FeedEventClassifier { 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. + /// Resolved against the tool name for agents whose response schema + /// supports dedicated `ExitPlanMode` / `AskUserQuestion` decisions. case approvalRequest /// A tool is about to run but no approval is pending. Telemetry /// only. Used by agents that expose a *separate* approval event @@ -109,6 +109,9 @@ struct FeedEventClassifier { ) -> (String, Bool) { switch semantic { case .approvalRequest: + if source == "copilot" { + return ("PermissionRequest", true) + } return dedicatedApprovalEvent(for: toolName) ?? ("PermissionRequest", true) case .toolStartMaybeApproval: if let dedicated = dedicatedApprovalEvent(for: toolName) { diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index 714823084ff9..daeacfdab60f 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -101,6 +101,8 @@ struct FeedEventClassificationTests { #expect(classify("copilot", "PreToolUse", tool: "Bash").actionable == true) #expect(classify("copilot", "PreToolUse", tool: "Read").name == "PermissionRequest") #expect(classify("copilot", "PreToolUse", tool: "Read").actionable == true) + #expect(classify("copilot", "PreToolUse", tool: "AskUserQuestion").name == "PermissionRequest") + #expect(classify("copilot", "PreToolUse", tool: "ExitPlanMode").name == "PermissionRequest") } /// Even on the maybe-approval (generic pre-tool) path, the two dedicated From 2ec1d7b1b9276b639bfcd0ae04748d814406bcb7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 02:04:54 -0700 Subject: [PATCH 09/39] fix: restrict Copilot feed permission modes --- CLI/CodexTeamsApprovalBridge.swift | 4 +- CLI/cmux.swift | 8 ++-- Sources/Feed/FeedPermissionActionPolicy.swift | 4 +- cmuxTests/CLICopilotHookFeedTests.swift | 8 ++++ cmuxTests/FeedCoordinatorTests.swift | 42 +++++++++---------- 5 files changed, 36 insertions(+), 30 deletions(-) diff --git a/CLI/CodexTeamsApprovalBridge.swift b/CLI/CodexTeamsApprovalBridge.swift index ce6e99c0f8b9..c60a5e903fbe 100644 --- a/CLI/CodexTeamsApprovalBridge.swift +++ b/CLI/CodexTeamsApprovalBridge.swift @@ -197,7 +197,7 @@ enum CodexTeamsApprovalBridge { } static func feedSourceSupportsPersistentPermissionModes(_ source: String) -> Bool { - source != "hermes-agent" + source != "copilot" && source != "hermes-agent" } static func feedSourceSupportsOncePermissionMode(_ source: String, toolInputJSON: String?) -> Bool { @@ -218,7 +218,7 @@ enum CodexTeamsApprovalBridge { } static func feedSourceSupportsBypassPermissions(_ source: String) -> Bool { - source != "codex" && source != "claude" && source != "hermes-agent" + source != "codex" && source != "claude" && source != "copilot" && source != "hermes-agent" } static func requestIdString(_ requestId: Any) -> String { diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 40f99fff3893..d950012fc5d3 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -33237,11 +33237,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return encode(permissionRequestHookDecision(behavior: "allow")) } if source == "copilot" { - if mode == "deny" { - let reason = String(localized: "cli.hooks.feed.permissionDeniedReason", defaultValue: "User denied permission via cmux Feed.") - return encode(["permissionDecision": "deny", "permissionDecisionReason": reason]) + if mode == "once" { + return encode(["permissionDecision": "allow"]) } - return encode(["permissionDecision": "allow"]) + let reason = String(localized: "cli.hooks.feed.permissionDeniedReason", defaultValue: "User denied permission via cmux Feed.") + return encode(["permissionDecision": "deny", "permissionDecisionReason": reason]) } if source == "hermes-agent" { if mode == "deny" { diff --git a/Sources/Feed/FeedPermissionActionPolicy.swift b/Sources/Feed/FeedPermissionActionPolicy.swift index 8aaa62054b8a..a9f176d64728 100644 --- a/Sources/Feed/FeedPermissionActionPolicy.swift +++ b/Sources/Feed/FeedPermissionActionPolicy.swift @@ -5,7 +5,7 @@ enum FeedPermissionActionPolicy { private typealias CodexPermissionCapabilities = (supportsOnce: Bool, supportsAlways: Bool, supportsAll: Bool) static func supportsPersistentPermissionModes(source: WorkstreamSource) -> Bool { - source != .hermesAgent + source != .copilot && source != .hermesAgent } static func supportsOncePermissionMode(source: WorkstreamSource, toolInputJSON: String?) -> Bool { @@ -26,7 +26,7 @@ enum FeedPermissionActionPolicy { } static func supportsBypassPermissions(source: WorkstreamSource) -> Bool { - source != .codex && source != .claude && source != .hermesAgent + source != .codex && source != .claude && source != .copilot && source != .hermesAgent } static func codexCapabilityToolInputJSON(source: WorkstreamSource, toolInputJSON: String) -> String? { diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 1aaf44bd6e3e..1384d33afd84 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -135,5 +135,13 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(denyOutput["permissionDecision"] as? String, "deny") XCTAssertEqual(denyOutput["permissionDecisionReason"] as? String, "User denied permission via cmux Feed.") XCTAssertNil(denyOutput["hookSpecificOutput"]) + + let (unsupported, _) = try runCopilotDecision(mode: "always") + XCTAssertFalse(unsupported.timedOut, unsupported.stderr) + XCTAssertEqual(unsupported.status, 0, unsupported.stderr) + let unsupportedOutput = try XCTUnwrap(jsonObject(unsupported.stdout)) + XCTAssertEqual(unsupportedOutput["permissionDecision"] as? String, "deny") + XCTAssertEqual(unsupportedOutput["permissionDecisionReason"] as? String, "User denied permission via cmux Feed.") + XCTAssertNil(unsupportedOutput["hookSpecificOutput"]) } } diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index c18dd8744133..ed041eb76a2d 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -10,6 +10,15 @@ import CMUXAgentLaunch @Suite("Feed coordinator", .serialized) struct FeedCoordinatorTests { + private func expectPermissionCapabilities(source: WorkstreamSource, cliSource: String, toolInputJSON: String?, once: Bool, always: Bool, all: Bool) { + #expect(FeedPermissionActionPolicy.supportsOncePermissionMode(source: source, toolInputJSON: toolInputJSON) == once) + #expect(FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: source, toolInputJSON: toolInputJSON) == always) + #expect(FeedPermissionActionPolicy.supportsAllPermissionMode(source: source, toolInputJSON: toolInputJSON) == all) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsOncePermissionMode(cliSource, toolInputJSON: toolInputJSON) == once) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode(cliSource, toolInputJSON: toolInputJSON) == always) + #expect(CodexTeamsApprovalBridge.feedSourceSupportsAllPermissionMode(cliSource, toolInputJSON: toolInputJSON) == all) + } + @Test func codexTeamsResolvesExplicitWorkingDirectoryFlags() { let base = "/tmp/cmux-base" @@ -70,45 +79,34 @@ struct FeedCoordinatorTests { #expect(CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes("codex")) #expect(!CodexTeamsApprovalBridge.feedSourceSupportsBypassPermissions("codex")) + expectPermissionCapabilities(source: .copilot, cliSource: "copilot", toolInputJSON: nil, once: true, always: false, all: false) + #expect(!FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .copilot)) + #expect(!FeedPermissionActionPolicy.supportsBypassPermissions(source: .copilot)) + #expect(!CodexTeamsApprovalBridge.feedSourceSupportsPersistentPermissionModes("copilot")) + #expect(!CodexTeamsApprovalBridge.feedSourceSupportsBypassPermissions("copilot")) + let codexOneShotOnly = #""" {"app_server_method":"item/commandExecution/requestApproval","available_decisions":["accept","decline"]} """# - #expect(FeedPermissionActionPolicy.supportsOncePermissionMode(source: .codex, toolInputJSON: codexOneShotOnly)) - #expect(!FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexOneShotOnly)) - #expect(!FeedPermissionActionPolicy.supportsAllPermissionMode(source: .codex, toolInputJSON: codexOneShotOnly)) - #expect(CodexTeamsApprovalBridge.feedSourceSupportsOncePermissionMode("codex", toolInputJSON: codexOneShotOnly)) - #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexOneShotOnly)) - #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAllPermissionMode("codex", toolInputJSON: codexOneShotOnly)) + expectPermissionCapabilities(source: .codex, cliSource: "codex", toolInputJSON: codexOneShotOnly, once: true, always: false, all: false) let codexSession = #""" {"app_server_method":"item/commandExecution/requestApproval","available_decisions":["accept","acceptForSession","decline"]} """# - #expect(FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexSession)) - #expect(CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexSession)) + expectPermissionCapabilities(source: .codex, cliSource: "codex", toolInputJSON: codexSession, once: true, always: true, all: false) let truncatedCodexToolInput = #"{"app_server_method":"item/commandExecution/requestApproval","available_decisions":["accept"]"# - #expect(!FeedPermissionActionPolicy.supportsOncePermissionMode(source: .codex, toolInputJSON: truncatedCodexToolInput)) - #expect(!FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: truncatedCodexToolInput)) - #expect(!FeedPermissionActionPolicy.supportsAllPermissionMode(source: .codex, toolInputJSON: truncatedCodexToolInput)) - #expect(!CodexTeamsApprovalBridge.feedSourceSupportsOncePermissionMode("codex", toolInputJSON: truncatedCodexToolInput)) - #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: truncatedCodexToolInput)) - #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAllPermissionMode("codex", toolInputJSON: truncatedCodexToolInput)) + expectPermissionCapabilities(source: .codex, cliSource: "codex", toolInputJSON: truncatedCodexToolInput, once: false, always: false, all: false) let codexFileChangeFallback = #""" {"app_server_method":"item/fileChange/requestApproval"} """# - #expect(FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexFileChangeFallback)) - #expect(CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexFileChangeFallback)) + expectPermissionCapabilities(source: .codex, cliSource: "codex", toolInputJSON: codexFileChangeFallback, once: true, always: true, all: false) let codexAmendment = #""" {"app_server_method":"item/commandExecution/requestApproval","available_decisions":[{"acceptWithExecpolicyAmendment":{}}],"proposed_execpolicy_amendment":[{"kind":"prefix","value":"npm test"}]} """# - #expect(!FeedPermissionActionPolicy.supportsOncePermissionMode(source: .codex, toolInputJSON: codexAmendment)) - #expect(!FeedPermissionActionPolicy.supportsAlwaysPermissionMode(source: .codex, toolInputJSON: codexAmendment)) - #expect(FeedPermissionActionPolicy.supportsAllPermissionMode(source: .codex, toolInputJSON: codexAmendment)) - #expect(!CodexTeamsApprovalBridge.feedSourceSupportsOncePermissionMode("codex", toolInputJSON: codexAmendment)) - #expect(!CodexTeamsApprovalBridge.feedSourceSupportsAlwaysPermissionMode("codex", toolInputJSON: codexAmendment)) - #expect(CodexTeamsApprovalBridge.feedSourceSupportsAllPermissionMode("codex", toolInputJSON: codexAmendment)) + expectPermissionCapabilities(source: .codex, cliSource: "codex", toolInputJSON: codexAmendment, once: false, always: false, all: true) #expect(FeedPermissionActionPolicy.supportsPersistentPermissionModes(source: .opencode)) #expect(FeedPermissionActionPolicy.supportsBypassPermissions(source: .opencode)) From bd85d15c9c29d7f912119bba6a6a3ad41459de00 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 02:14:27 -0700 Subject: [PATCH 10/39] test: convert Copilot hook feed coverage to Swift Testing --- cmuxTests/CLICopilotHookFeedTests.swift | 353 ++++++++++++++++++++---- 1 file changed, 301 insertions(+), 52 deletions(-) diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 1384d33afd84..58f9f38c3d80 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -1,15 +1,52 @@ import Darwin -import XCTest +import Foundation +import Testing -extension CLINotifyProcessIntegrationRegressionTests { - func testCopilotHookInstallWritesToHooksSubdirectory() throws { - let cliPath = try bundledCLIPath() +@Suite("CLI Copilot hook feed") +struct CLICopilotHookFeedTests { + final class BundleProbe {} + + struct ProcessRunResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + final class MockSocketServerState: @unchecked Sendable { + private let lock = NSLock() + private var commands: [String] = [] + + func append(_ command: String) { + lock.lock() + commands.append(command) + lock.unlock() + } + + func snapshot() -> [String] { + lock.lock() + let value = commands + lock.unlock() + return value + } + } + + struct MockSocketServer { + let handled: DispatchSemaphore + + func wait(timeout: TimeInterval) -> Bool { + handled.wait(timeout: .now() + timeout) == .success + } + } + + @Test func copilotHookInstallWritesToHooksSubdirectory() throws { + let cliPath = try Self.bundledCLIPath() let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-copilot-hook-install-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: root) } - let result = runProcess( + let result = Self.runProcess( executablePath: cliPath, arguments: ["hooks", "copilot", "install", "--yes"], environment: [ @@ -20,27 +57,27 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) let hookURL = root .appendingPathComponent(".copilot", isDirectory: true) .appendingPathComponent("hooks", isDirectory: true) .appendingPathComponent("cmux.json", isDirectory: false) - let json = try XCTUnwrap( + let json = try #require( JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any], "Expected hook file at ~/.copilot/hooks/cmux.json" ) - XCTAssertEqual(json["version"] as? Int, 1) - let hooks = try XCTUnwrap(json["hooks"] as? [String: Any]) + #expect(json["version"] as? Int == 1) + let hooks = try #require(json["hooks"] as? [String: Any]) - XCTAssertNotNil(hooks["SessionStart"], "Missing SessionStart hook") - XCTAssertNotNil(hooks["Stop"], "Missing Stop hook") - XCTAssertNotNil(hooks["Notification"], "Missing Notification hook") - XCTAssertNotNil(hooks["SessionEnd"], "Missing SessionEnd hook") - let preToolUse = try XCTUnwrap(hooks["PreToolUse"] as? [[String: Any]]) - XCTAssertTrue( + #expect(hooks["SessionStart"] != nil, "Missing SessionStart hook") + #expect(hooks["Stop"] != nil, "Missing Stop hook") + #expect(hooks["Notification"] != nil, "Missing Notification hook") + #expect(hooks["SessionEnd"] != nil, "Missing SessionEnd hook") + let preToolUse = try #require(hooks["PreToolUse"] as? [[String: Any]]) + #expect( preToolUse.contains { ($0["command"] as? String)?.contains("hooks feed --source copilot --event PreToolUse") == true && ($0["type"] as? String) == "command" @@ -51,11 +88,11 @@ extension CLINotifyProcessIntegrationRegressionTests { ) } - func testCopilotFeedDecisionEmitsPreToolUsePermissionDecision() throws { + @Test func copilotFeedDecisionEmitsPreToolUsePermissionDecision() throws { func runCopilotDecision(mode: String) throws -> (ProcessRunResult, [String: Any]) { - let cliPath = try bundledCLIPath() - let socketPath = makeSocketPath("copilot-feed-decision") - let listenerFD = try bindUnixSocket(at: socketPath) + let cliPath = try Self.bundledCLIPath() + let socketPath = Self.makeSocketPath("copilot-feed-decision") + let listenerFD = try Self.bindUnixSocket(at: socketPath) let state = MockSocketServerState() let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-copilot-feed-decision-\(UUID().uuidString)", isDirectory: true) @@ -69,15 +106,15 @@ extension CLINotifyProcessIntegrationRegressionTests { try? FileManager.default.removeItem(at: root) } - let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - guard let payload = self.jsonObject(line) else { - return self.malformedRequestResponse(raw: line) + let server = Self.startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = Self.jsonObject(line) else { + return Self.malformedRequestResponse(raw: line) } guard let id = payload["id"] as? String, let method = payload["method"] as? String else { - return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + return Self.malformedRequestResponse(id: payload["id"] as? String, raw: line) } - XCTAssertEqual(method, "feed.push") - return self.v2Response( + #expect(method == "feed.push") + return Self.v2Response( id: id, ok: true, result: [ @@ -87,7 +124,7 @@ extension CLINotifyProcessIntegrationRegressionTests { ) } - let result = runProcess( + let result = Self.runProcess( executablePath: cliPath, arguments: ["hooks", "feed", "--source", "copilot", "--event", "PreToolUse"], environment: [ @@ -103,10 +140,10 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"hook_event_name":"PreToolUse","session_id":"copilot-session-123","cwd":"\#(root.path)","tool_name":"Bash","tool_input":{"command":"touch \#(root.appendingPathComponent("README.md").path)"}}"#, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + #expect(server.wait(timeout: 5), "socket server did not observe feed.push") - let feedEvents = state.commands.compactMap { command -> [String: Any]? in - guard let payload = self.jsonObject(command), + let feedEvents = state.snapshot().compactMap { command -> [String: Any]? in + guard let payload = Self.jsonObject(command), payload["method"] as? String == "feed.push", let params = payload["params"] as? [String: Any], let event = params["event"] as? [String: Any] else { @@ -114,34 +151,246 @@ extension CLINotifyProcessIntegrationRegressionTests { } return event } - XCTAssertEqual(feedEvents.count, 1, "Expected one Copilot Feed event, saw \(state.commands)") - return (result, try XCTUnwrap(feedEvents.first)) + #expect(feedEvents.count == 1, "Expected one Copilot Feed event, saw \(state.snapshot())") + return (result, try #require(feedEvents.first)) } let (allow, allowEvent) = try runCopilotDecision(mode: "once") - XCTAssertFalse(allow.timedOut, allow.stderr) - XCTAssertEqual(allow.status, 0, allow.stderr) - XCTAssertEqual(allowEvent["hook_event_name"] as? String, "PermissionRequest") - XCTAssertEqual(allowEvent["_source"] as? String, "copilot") - XCTAssertEqual(allowEvent["_ppid"] as? Int, 525252) - let allowOutput = try XCTUnwrap(jsonObject(allow.stdout)) - XCTAssertEqual(allowOutput["permissionDecision"] as? String, "allow") - XCTAssertNil(allowOutput["hookSpecificOutput"]) + #expect(!allow.timedOut, Comment(rawValue: allow.stderr)) + #expect(allow.status == 0, Comment(rawValue: allow.stderr)) + #expect(allowEvent["hook_event_name"] as? String == "PermissionRequest") + #expect(allowEvent["_source"] as? String == "copilot") + #expect(allowEvent["_ppid"] as? Int == 525252) + let allowOutput = try #require(Self.jsonObject(allow.stdout)) + #expect(allowOutput["permissionDecision"] as? String == "allow") + #expect(allowOutput["hookSpecificOutput"] == nil) let (deny, _) = try runCopilotDecision(mode: "deny") - XCTAssertFalse(deny.timedOut, deny.stderr) - XCTAssertEqual(deny.status, 0, deny.stderr) - let denyOutput = try XCTUnwrap(jsonObject(deny.stdout)) - XCTAssertEqual(denyOutput["permissionDecision"] as? String, "deny") - XCTAssertEqual(denyOutput["permissionDecisionReason"] as? String, "User denied permission via cmux Feed.") - XCTAssertNil(denyOutput["hookSpecificOutput"]) + #expect(!deny.timedOut, Comment(rawValue: deny.stderr)) + #expect(deny.status == 0, Comment(rawValue: deny.stderr)) + let denyOutput = try #require(Self.jsonObject(deny.stdout)) + #expect(denyOutput["permissionDecision"] as? String == "deny") + #expect(denyOutput["permissionDecisionReason"] as? String == "User denied permission via cmux Feed.") + #expect(denyOutput["hookSpecificOutput"] == nil) let (unsupported, _) = try runCopilotDecision(mode: "always") - XCTAssertFalse(unsupported.timedOut, unsupported.stderr) - XCTAssertEqual(unsupported.status, 0, unsupported.stderr) - let unsupportedOutput = try XCTUnwrap(jsonObject(unsupported.stdout)) - XCTAssertEqual(unsupportedOutput["permissionDecision"] as? String, "deny") - XCTAssertEqual(unsupportedOutput["permissionDecisionReason"] as? String, "User denied permission via cmux Feed.") - XCTAssertNil(unsupportedOutput["hookSpecificOutput"]) + #expect(!unsupported.timedOut, Comment(rawValue: unsupported.stderr)) + #expect(unsupported.status == 0, Comment(rawValue: unsupported.stderr)) + let unsupportedOutput = try #require(Self.jsonObject(unsupported.stdout)) + #expect(unsupportedOutput["permissionDecision"] as? String == "deny") + #expect(unsupportedOutput["permissionDecisionReason"] as? String == "User denied permission via cmux Feed.") + #expect(unsupportedOutput["hookSpecificOutput"] == nil) + } + + private static func bundledCLIPath() throws -> String { + try BundledCLITestSupport.bundledCLIPath(for: BundleProbe.self) + } + + private static func makeSocketPath(_ name: String) -> String { + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + return URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("cli-\(name.prefix(6))-\(shortID).sock") + .path + } + + private static func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw posixError("failed to create Unix socket") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) + let utf8 = Array(path.utf8) + guard utf8.count < maxPathLength else { + Darwin.close(fd) + throw NSError(domain: "cmux.tests", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "socket path too long: \(path)", + ]) + } + _ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in + for index in 0...size)) + } + } + guard bindResult == 0 else { + Darwin.close(fd) + throw posixError("failed to bind Unix socket") + } + guard Darwin.listen(fd, 1) == 0 else { + Darwin.close(fd) + throw posixError("failed to listen on Unix socket") + } + return fd + } + + private static func startMockServer( + listenerFD: Int32, + state: MockSocketServerState, + handler: @escaping @Sendable (String) -> String + ) -> MockSocketServer { + let handled = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + handled.signal() + return + } + defer { Darwin.close(clientFD) } + + readLines(from: clientFD) { line in + state.append(line) + writeLine(handler(line), to: clientFD) + handled.signal() + } + } + return MockSocketServer(handled: handled) + } + + private static func readLines(from fd: Int32, handle: (String) -> Void) { + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(fd, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + return + } + if count == 0 { return } + pending.append(buffer, count: count) + + while let newlineRange = pending.firstRange(of: Data([0x0A])) { + let lineData = pending.subdata(in: 0.. String { + var payload: [String: Any] = ["id": id, "ok": ok] + if let result { payload["result"] = result } + if let error { payload["error"] = error } + let data = try? JSONSerialization.data(withJSONObject: payload, options: []) + return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}" + } + + private static func malformedRequestResponse(id: String? = nil, raw: String) -> String { + v2Response( + id: id ?? "unknown", + ok: false, + error: ["code": "malformed_request", "message": "invalid or non-JSON payload", "raw": raw] + ) + } + + private static func jsonObject(_ line: String) -> [String: Any]? { + guard let data = line.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + private static func runProcess( + executablePath: String, + arguments: [String], + environment: [String: String], + standardInput: String? = nil, + timeout: TimeInterval + ) -> ProcessRunResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + let stdinHandle: FileHandle? + let stdinURL: URL? + if let standardInput { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-test-stdin-\(UUID().uuidString).json") + do { + try Data(standardInput.utf8).write(to: url) + let handle = try FileHandle(forReadingFrom: url) + process.standardInput = handle + stdinHandle = handle + stdinURL = url + } catch { + try? FileManager.default.removeItem(at: url) + return ProcessRunResult(status: -1, stdout: "", stderr: "\(error)", timedOut: false) + } + } else { + stdinHandle = nil + stdinURL = nil + } + defer { + try? stdinHandle?.close() + if let stdinURL { + try? FileManager.default.removeItem(at: stdinURL) + } + } + + let finished = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in finished.signal() } + + do { + try process.run() + } catch { + return ProcessRunResult(status: -1, stdout: "", stderr: "\(error)", timedOut: false) + } + + let timedOut = finished.wait(timeout: .now() + timeout) != .success + if timedOut { + process.terminate() + _ = finished.wait(timeout: .now() + 1) + } + + let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderr.fileHandleForReading.readDataToEndOfFile() + return ProcessRunResult( + status: process.terminationStatus, + stdout: String(data: stdoutData, encoding: .utf8) ?? "", + stderr: String(data: stderrData, encoding: .utf8) ?? "", + timedOut: timedOut + ) + } + + private static func posixError(_ message: String) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "\(message): \(String(cString: strerror(errno)))"] + ) } } From 95fc809ed9b809d559fcc7664e611ae3ba5e2836 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 02:16:21 -0700 Subject: [PATCH 11/39] test: address Copilot classification review notes --- CLI/FeedEventClassifier.swift | 3 +++ cmuxTests/FeedEventClassificationTests.swift | 2 ++ 2 files changed, 5 insertions(+) diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 25bb192cb721..7ef367773ce5 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -109,6 +109,9 @@ struct FeedEventClassifier { ) -> (String, Bool) { switch semantic { case .approvalRequest: + // Copilot's hook response schema only understands top-level + // permissionDecision values, so keep every Copilot tool on the + // generic PermissionRequest wire kind. if source == "copilot" { return ("PermissionRequest", true) } diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index daeacfdab60f..cc8efe1b363c 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -102,7 +102,9 @@ struct FeedEventClassificationTests { #expect(classify("copilot", "PreToolUse", tool: "Read").name == "PermissionRequest") #expect(classify("copilot", "PreToolUse", tool: "Read").actionable == true) #expect(classify("copilot", "PreToolUse", tool: "AskUserQuestion").name == "PermissionRequest") + #expect(classify("copilot", "PreToolUse", tool: "AskUserQuestion").actionable == true) #expect(classify("copilot", "PreToolUse", tool: "ExitPlanMode").name == "PermissionRequest") + #expect(classify("copilot", "PreToolUse", tool: "ExitPlanMode").actionable == true) } /// Even on the maybe-approval (generic pre-tool) path, the two dedicated From 0fc56e31b2189b3863e2538e77f1312f5cc10250 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 03:08:00 -0700 Subject: [PATCH 12/39] fix: avoid blocking Copilot read-only tools --- CLI/FeedEventClassifier.swift | 17 ++++++++++++++--- cmuxTests/FeedEventClassificationTests.swift | 13 ++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 7ef367773ce5..52d84d83685b 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -117,6 +117,16 @@ struct FeedEventClassifier { } return dedicatedApprovalEvent(for: toolName) ?? ("PermissionRequest", true) case .toolStartMaybeApproval: + if source == "copilot" { + // Copilot's PreToolUse fires for every tool. Gate only tools + // that need a decision, but keep Copilot on PermissionRequest + // so renderAgentDecision emits top-level permissionDecision. + if toolName == "ExitPlanMode" || toolName == "AskUserQuestion" || + Self.isSideEffectingTool(toolName, source: source) { + return ("PermissionRequest", true) + } + return ("PreToolUse", false) + } if let dedicated = dedicatedApprovalEvent(for: toolName) { return dedicated } @@ -223,9 +233,10 @@ struct FeedEventClassifier { "stop": .response, ], "copilot": [ - // Copilot's PreToolUse hook is the permission gate itself: - // the hook output directly controls whether the tool runs. - "PreToolUse": .approvalRequest, + // Copilot's PreToolUse hook is the permission gate itself, but it + // also fires before read-only tools. Resolve against the tool name + // so read-only tools fall through immediately with `{}`. + "PreToolUse": .toolStartMaybeApproval, ], ] diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index cc8efe1b363c..10dcb445d6f5 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -93,14 +93,17 @@ struct FeedEventClassificationTests { #expect(classify("gemini", "PreToolUse", tool: "Read").actionable == false) } - /// Copilot's `PreToolUse` hook is the approval gate itself. It must - /// produce a blocking `PermissionRequest` even for tools that generic - /// telemetry-only agents would keep non-actionable. + /// Copilot's `PreToolUse` hook is the approval gate itself, but it also + /// fires before read-only tools. Mutating tools must block with Copilot's + /// top-level permission-decision path; read-only tools must fall through. @Test func copilotPreToolUseIsApprovalRequest() { #expect(classify("copilot", "PreToolUse", tool: "Bash").name == "PermissionRequest") #expect(classify("copilot", "PreToolUse", tool: "Bash").actionable == true) - #expect(classify("copilot", "PreToolUse", tool: "Read").name == "PermissionRequest") - #expect(classify("copilot", "PreToolUse", tool: "Read").actionable == true) + #expect(classify("copilot", "PreToolUse", tool: "Read").name == "PreToolUse") + #expect(classify("copilot", "PreToolUse", tool: "Read").actionable == false) + #expect(classify("copilot", "PreToolUse", tool: "Grep").actionable == false) + #expect(classify("copilot", "PreToolUse", tool: "Glob").actionable == false) + #expect(classify("copilot", "PreToolUse", tool: "WebFetch").actionable == false) #expect(classify("copilot", "PreToolUse", tool: "AskUserQuestion").name == "PermissionRequest") #expect(classify("copilot", "PreToolUse", tool: "AskUserQuestion").actionable == true) #expect(classify("copilot", "PreToolUse", tool: "ExitPlanMode").name == "PermissionRequest") From 9f79ced49d83c1f40c7b113f5526e136afdd6227 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 03:22:40 -0700 Subject: [PATCH 13/39] fix: avoid Copilot notification stop hooks --- CLI/CMUXCLI+AgentHookDefinitions.swift | 1 - cmuxTests/CLICopilotHookFeedTests.swift | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index ab29d5ab7f1c..7046d444b7a7 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -317,7 +317,6 @@ extension CMUXCLI { events: [ .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), .init(agentEvent: "Stop", cmuxSubcommand: "stop"), - .init(agentEvent: "Notification", cmuxSubcommand: "stop"), .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), ], feedHookEvents: ["PreToolUse"] diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 58f9f38c3d80..5c8148fa08e9 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -74,7 +74,7 @@ struct CLICopilotHookFeedTests { #expect(hooks["SessionStart"] != nil, "Missing SessionStart hook") #expect(hooks["Stop"] != nil, "Missing Stop hook") - #expect(hooks["Notification"] != nil, "Missing Notification hook") + #expect(hooks["Notification"] == nil, "Copilot notifications must not be installed as stop hooks") #expect(hooks["SessionEnd"] != nil, "Missing SessionEnd hook") let preToolUse = try #require(hooks["PreToolUse"] as? [[String: Any]]) #expect( From 35db201556d876ec5d57763a6579f5391df583be Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 03:39:12 -0700 Subject: [PATCH 14/39] docs: fix Copilot CLI hooks link --- docs/notifications.md | 2 +- web/app/[locale]/docs/notifications/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/notifications.md b/docs/notifications.md index ebd5bedd7638..90c9742b1365 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -117,7 +117,7 @@ See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-co ### GitHub Copilot CLI -Copilot CLI supports [hooks](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/use-hooks) that run shell commands at key lifecycle events. Add to `~/.copilot/hooks/cmux.json`: +Copilot CLI supports [hooks](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-hooks) that run shell commands at key lifecycle events. Add to `~/.copilot/hooks/cmux.json`: ```json { diff --git a/web/app/[locale]/docs/notifications/page.tsx b/web/app/[locale]/docs/notifications/page.tsx index e91b09be2f8a..4d5621da89e4 100644 --- a/web/app/[locale]/docs/notifications/page.tsx +++ b/web/app/[locale]/docs/notifications/page.tsx @@ -262,7 +262,7 @@ esac`}

{t.rich("copilotCliHooksDesc", { link: (chunks) => ( - {chunks} + {chunks} ), })}

From 3bb1e4d630bc174334597ec5b67ea17ea908ef14 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 04:28:22 -0700 Subject: [PATCH 15/39] fix: use canonical Copilot hook schema --- CLI/CMUXCLI+AgentHookDefinitions.swift | 10 ++++--- CLI/FeedEventClassifier.swift | 28 +++++++++++++++----- CLI/cmux.swift | 21 ++++++++++++--- cmuxTests/CLICopilotHookFeedTests.swift | 23 ++++++++++------ cmuxTests/FeedEventClassificationTests.swift | 24 ++++++++++------- docs/agent-hooks.md | 2 +- docs/feed.md | 2 +- 7 files changed, 76 insertions(+), 34 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 7046d444b7a7..2eb2610f6a87 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -315,11 +315,13 @@ extension CMUXCLI { sessionStoreSuffix: "copilot", disableEnvVar: "CMUX_COPILOT_HOOKS_DISABLED", hookMarker: "cmux hooks copilot", format: .copilotJSON(timeoutSeconds: 5), events: [ - .init(agentEvent: "SessionStart", cmuxSubcommand: "session-start"), - .init(agentEvent: "Stop", cmuxSubcommand: "stop"), - .init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"), + .init(agentEvent: "sessionStart", cmuxSubcommand: "session-start"), + .init(agentEvent: "userPromptSubmitted", cmuxSubcommand: "prompt-submit"), + .init(agentEvent: "agentStop", cmuxSubcommand: "stop"), + .init(agentEvent: "errorOccurred", cmuxSubcommand: "stop"), + .init(agentEvent: "sessionEnd", cmuxSubcommand: "session-end"), ], - feedHookEvents: ["PreToolUse"] + feedHookEvents: ["preToolUse"] ), AgentHookDef( name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy", diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 52d84d83685b..c1f22d3e50fb 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -118,7 +118,7 @@ struct FeedEventClassifier { return dedicatedApprovalEvent(for: toolName) ?? ("PermissionRequest", true) case .toolStartMaybeApproval: if source == "copilot" { - // Copilot's PreToolUse fires for every tool. Gate only tools + // Copilot's preToolUse fires for every tool. Gate only tools // that need a decision, but keep Copilot on PermissionRequest // so renderAgentDecision emits top-level permissionDecision. if toolName == "ExitPlanMode" || toolName == "AskUserQuestion" || @@ -233,9 +233,10 @@ struct FeedEventClassifier { "stop": .response, ], "copilot": [ - // Copilot's PreToolUse hook is the permission gate itself, but it - // also fires before read-only tools. Resolve against the tool name - // so read-only tools fall through immediately with `{}`. + // Copilot's canonical hook config uses camelCase event names. Keep + // the PascalCase spelling for already-installed VS Code compatible + // hook files. + "preToolUse": .toolStartMaybeApproval, "PreToolUse": .toolStartMaybeApproval, ], ] @@ -313,9 +314,21 @@ struct FeedEventClassifier { "generate_image", ] + /// Copilot's canonical hook payloads use lower-case runtime tool names. + /// Keep these source-scoped so another agent's lower-case read telemetry + /// is not accidentally broadened into approval traffic. + private static let copilotSideEffectingToolAliases: Set = [ + "bash", + "powershell", + "create", + "edit", + "str_replace_editor", + "apply_patch", + ] + /// Whether a tool mutates state and deserves an approval prompt. Exact - /// match against ``sideEffectingTools`` for every source; the `kiro` - /// source additionally matches its case-insensitive internal aliases. + /// match against ``sideEffectingTools`` for every source; `copilot` and + /// `kiro` additionally match their case-insensitive internal aliases. /// Kept source-scoped so another agent's lowercase tool name is not /// escalated into an approval. static func isSideEffectingTool(_ toolName: String, source: String) -> Bool { @@ -323,6 +336,9 @@ struct FeedEventClassifier { if sideEffectingTools.contains(toolName) { return true } + if source == "copilot" { + return copilotSideEffectingToolAliases.contains(toolName.lowercased()) + } if source == "kiro" { return kiroSideEffectingToolAliases.contains(toolName.lowercased()) } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 56580c32b2ea..396126e7ddce 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -26947,7 +26947,9 @@ struct CMUXCLI { ] as [String: Any]) result[event.agentEvent] = groups case .copilotJSON(let timeoutSeconds): - var entries = result[event.agentEvent] as? [[String: Any]] ?? []; entries.append(Self.copilotHookEntry(command: cmd, timeoutSeconds: timeoutSeconds)); result[event.agentEvent] = entries + var entries = result[event.agentEvent] as? [[String: Any]] ?? [] + entries.append(Self.copilotHookEntry(command: cmd, timeoutSeconds: timeoutSeconds)) + result[event.agentEvent] = entries case .antigravityJSON(let timeoutSeconds): var entries = result[event.agentEvent] as? [[String: Any]] ?? [] entries.append(Self.antigravityHookEntry( @@ -26984,7 +26986,12 @@ struct CMUXCLI { ] as [String: Any]) result[agentEvent] = groups case .copilotJSON: - var entries = result[agentEvent] as? [[String: Any]] ?? []; entries.append(Self.copilotHookEntry(command: feedCmd, timeoutSeconds: Self.timeoutSecondsFromMilliseconds(feedTimeoutMs) + 5)); result[agentEvent] = entries + var entries = result[agentEvent] as? [[String: Any]] ?? [] + entries.append(Self.copilotHookEntry( + command: feedCmd, + timeoutSeconds: Self.timeoutSecondsFromMilliseconds(feedTimeoutMs) + 5 + )) + result[agentEvent] = entries case .antigravityJSON: var entries = result[agentEvent] as? [[String: Any]] ?? [] entries.append(Self.antigravityHookEntry( @@ -27022,7 +27029,13 @@ struct CMUXCLI { return ((positiveTimeoutMs - 1) / 1000) + 1 } - private static func copilotHookEntry(command: String, timeoutSeconds: Int) -> [String: Any] { ["type": "command", "command": command, "timeoutSec": max(timeoutSeconds, 1)] } + private static func copilotHookEntry(command: String, timeoutSeconds: Int) -> [String: Any] { + [ + "type": "command", + "bash": command, + "timeoutSec": max(timeoutSeconds, 1), + ] + } private static func antigravityHookEntry( command: String, @@ -32895,7 +32908,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if let workspaceId = feedWorkspaceId(rawObject: stdinObj, fallback: env["CMUX_WORKSPACE_ID"]) { eventDict["workspace_id"] = workspaceId } - let toolInput = stdinObj["tool_input"] ?? stdinObj["toolInput"] ?? toolCall?["args"] + let toolInput = stdinObj["tool_input"] ?? stdinObj["toolInput"] ?? stdinObj["toolArgs"] ?? toolCall?["args"] if let cwd = firstString(in: stdinObj, keys: ["cwd", "working_directory", "workingDirectory"]) ?? firstWorkspacePath(in: stdinObj) ?? (toolInput as? [String: Any]).flatMap({ firstString(in: $0, keys: ["Cwd", "cwd"]) }) { diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 5c8148fa08e9..bec9697b683d 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -72,19 +72,26 @@ struct CLICopilotHookFeedTests { #expect(json["version"] as? Int == 1) let hooks = try #require(json["hooks"] as? [String: Any]) - #expect(hooks["SessionStart"] != nil, "Missing SessionStart hook") - #expect(hooks["Stop"] != nil, "Missing Stop hook") + #expect(hooks["sessionStart"] != nil, "Missing sessionStart hook") + #expect(hooks["userPromptSubmitted"] != nil, "Missing userPromptSubmitted hook") + #expect(hooks["agentStop"] != nil, "Missing agentStop hook") + #expect(hooks["errorOccurred"] != nil, "Missing errorOccurred hook") #expect(hooks["Notification"] == nil, "Copilot notifications must not be installed as stop hooks") - #expect(hooks["SessionEnd"] != nil, "Missing SessionEnd hook") - let preToolUse = try #require(hooks["PreToolUse"] as? [[String: Any]]) + #expect(hooks["sessionEnd"] != nil, "Missing sessionEnd hook") + #expect(hooks["SessionStart"] == nil, "Copilot must use canonical camelCase hook names") + #expect(hooks["Stop"] == nil, "Copilot must use canonical agentStop/errorOccurred hook names") + #expect(hooks["SessionEnd"] == nil, "Copilot must use canonical camelCase hook names") + #expect(hooks["PreToolUse"] == nil, "Copilot must install canonical preToolUse hooks") + let preToolUse = try #require(hooks["preToolUse"] as? [[String: Any]]) #expect( preToolUse.contains { - ($0["command"] as? String)?.contains("hooks feed --source copilot --event PreToolUse") == true + ($0["bash"] as? String)?.contains("hooks feed --source copilot --event preToolUse") == true && ($0["type"] as? String) == "command" && ($0["timeoutSec"] as? Int) == 125 + && $0["command"] == nil && $0["hooks"] == nil }, - "Expected direct PreToolUse command hook with timeout slack, saw \(preToolUse)" + "Expected direct preToolUse bash hook with timeout slack, saw \(preToolUse)" ) } @@ -126,7 +133,7 @@ struct CLICopilotHookFeedTests { let result = Self.runProcess( executablePath: cliPath, - arguments: ["hooks", "feed", "--source", "copilot", "--event", "PreToolUse"], + arguments: ["hooks", "feed", "--source", "copilot", "--event", "preToolUse"], environment: [ "HOME": root.path, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", @@ -137,7 +144,7 @@ struct CLICopilotHookFeedTests { "CMUX_COPILOT_PID": "525252", "CMUX_CLI_SENTRY_DISABLED": "1", ], - standardInput: #"{"hook_event_name":"PreToolUse","session_id":"copilot-session-123","cwd":"\#(root.path)","tool_name":"Bash","tool_input":{"command":"touch \#(root.appendingPathComponent("README.md").path)"}}"#, + standardInput: #"{"sessionId":"copilot-session-123","cwd":"\#(root.path)","toolName":"bash","toolArgs":{"command":"touch \#(root.appendingPathComponent("README.md").path)"}}"#, timeout: 5 ) #expect(server.wait(timeout: 5), "socket server did not observe feed.push") diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index 10dcb445d6f5..a4ff79711559 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -93,21 +93,25 @@ struct FeedEventClassificationTests { #expect(classify("gemini", "PreToolUse", tool: "Read").actionable == false) } - /// Copilot's `PreToolUse` hook is the approval gate itself, but it also + /// Copilot's `preToolUse` hook is the approval gate itself, but it also /// fires before read-only tools. Mutating tools must block with Copilot's /// top-level permission-decision path; read-only tools must fall through. @Test func copilotPreToolUseIsApprovalRequest() { + #expect(classify("copilot", "preToolUse", tool: "bash").name == "PermissionRequest") + #expect(classify("copilot", "preToolUse", tool: "bash").actionable == true) + #expect(classify("copilot", "preToolUse", tool: "create").actionable == true) + #expect(classify("copilot", "preToolUse", tool: "edit").actionable == true) + #expect(classify("copilot", "preToolUse", tool: "view").name == "PreToolUse") + #expect(classify("copilot", "preToolUse", tool: "view").actionable == false) + #expect(classify("copilot", "preToolUse", tool: "grep").actionable == false) + #expect(classify("copilot", "preToolUse", tool: "glob").actionable == false) + #expect(classify("copilot", "preToolUse", tool: "web_fetch").actionable == false) + #expect(classify("copilot", "preToolUse", tool: "AskUserQuestion").name == "PermissionRequest") + #expect(classify("copilot", "preToolUse", tool: "AskUserQuestion").actionable == true) + #expect(classify("copilot", "preToolUse", tool: "ExitPlanMode").name == "PermissionRequest") + #expect(classify("copilot", "preToolUse", tool: "ExitPlanMode").actionable == true) #expect(classify("copilot", "PreToolUse", tool: "Bash").name == "PermissionRequest") #expect(classify("copilot", "PreToolUse", tool: "Bash").actionable == true) - #expect(classify("copilot", "PreToolUse", tool: "Read").name == "PreToolUse") - #expect(classify("copilot", "PreToolUse", tool: "Read").actionable == false) - #expect(classify("copilot", "PreToolUse", tool: "Grep").actionable == false) - #expect(classify("copilot", "PreToolUse", tool: "Glob").actionable == false) - #expect(classify("copilot", "PreToolUse", tool: "WebFetch").actionable == false) - #expect(classify("copilot", "PreToolUse", tool: "AskUserQuestion").name == "PermissionRequest") - #expect(classify("copilot", "PreToolUse", tool: "AskUserQuestion").actionable == true) - #expect(classify("copilot", "PreToolUse", tool: "ExitPlanMode").name == "PermissionRequest") - #expect(classify("copilot", "PreToolUse", tool: "ExitPlanMode").actionable == true) } /// Even on the maybe-approval (generic pre-tool) path, the two dedicated diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index 2b4b530f2682..b644092e6fff 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -28,7 +28,7 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `amp`, `curs | Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --resume ` | PreToolUse | | Kiro CLI | `kiro-cli` | `~/.kiro/agents/cmux.json` or `$KIRO_HOME/agents/cmux.json` | `kiro-cli chat --resume-id ` | preToolUse, postToolUse | | Rovo Dev | `acli` | `~/.rovodev/config.yml` | `acli rovodev run --restore ` | none | -| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | PreToolUse | +| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | preToolUse | | CodeBuddy | `codebuddy` | `~/.codebuddy/settings.json` | `codebuddy --resume ` | PreToolUse | | Factory | `droid` | `~/.factory/settings.json` | `droid --resume ` | PreToolUse | | Qoder | `qodercli` | `~/.qoder/settings.json` | `qodercli --resume ` | PreToolUse | diff --git a/docs/feed.md b/docs/feed.md index 9ea233b7098f..f3e2a9f602b7 100644 --- a/docs/feed.md +++ b/docs/feed.md @@ -80,7 +80,7 @@ Installs supported agent hooks whose binaries are on `PATH`. See [Agent hook int | OpenCode | `~/.config/opencode/plugins/cmux-feed.js` | plugin event bus | | Cursor CLI | `~/.cursor/hooks.json` | beforeShellExecution | | Gemini | `~/.gemini/settings.json` | PreToolUse | -| Copilot | `~/.copilot/hooks/cmux.json` | PreToolUse | +| Copilot | `~/.copilot/hooks/cmux.json` | preToolUse | | CodeBuddy | `~/.codebuddy/settings.json` | PreToolUse | | Factory | `~/.factory/settings.json` | PreToolUse | | Qoder | `~/.qoder/settings.json` | PreToolUse | From d50ee57e1a0f1a775297d91332877d0d5abf95f8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 04:32:53 -0700 Subject: [PATCH 16/39] chore: refresh Swift file length budget --- .github/swift-file-length-budget.tsv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 4bd3eb1331bb..70752f918165 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34289 CLI/cmux.swift +34302 CLI/cmux.swift 17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift @@ -129,7 +129,7 @@ 716 Sources/TaskManagerSnapshot.swift 715 Sources/AppleScriptSupport.swift 710 Sources/TerminalSSHSessionDetector.swift -707 CLI/CMUXCLI+AgentHookDefinitions.swift +708 CLI/CMUXCLI+AgentHookDefinitions.swift 706 CLI/CMUXCLI+Config.swift 705 Sources/Panels/BrowserPopupWindowController.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift @@ -143,8 +143,8 @@ 681 Sources/Panels/AgentSessionProcessStore.swift 680 Sources/FileExplorerSearchController.swift 677 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Bootstrap.swift -668 cmuxTests/FeedCoordinatorTests.swift 668 cmuxTests/SettingsWindowPresenterTests.swift +666 cmuxTests/FeedCoordinatorTests.swift 664 Sources/CmuxTopSnapshot.swift 663 Sources/PortScanner.swift 663 cmuxTests/SessionIndexViewTests.swift From e302406e787970b608518963543df5434092c4f8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 04:40:26 -0700 Subject: [PATCH 17/39] fix: gate Copilot ask_user hooks --- CLI/FeedEventClassifier.swift | 2 +- cmuxTests/FeedEventClassificationTests.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index c1f22d3e50fb..6eaf2968f07b 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -121,7 +121,7 @@ struct FeedEventClassifier { // Copilot's preToolUse fires for every tool. Gate only tools // that need a decision, but keep Copilot on PermissionRequest // so renderAgentDecision emits top-level permissionDecision. - if toolName == "ExitPlanMode" || toolName == "AskUserQuestion" || + if toolName == "ExitPlanMode" || toolName == "AskUserQuestion" || toolName == "ask_user" || Self.isSideEffectingTool(toolName, source: source) { return ("PermissionRequest", true) } diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index a4ff79711559..ca0c72307af9 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -106,6 +106,8 @@ struct FeedEventClassificationTests { #expect(classify("copilot", "preToolUse", tool: "grep").actionable == false) #expect(classify("copilot", "preToolUse", tool: "glob").actionable == false) #expect(classify("copilot", "preToolUse", tool: "web_fetch").actionable == false) + #expect(classify("copilot", "preToolUse", tool: "ask_user").name == "PermissionRequest") + #expect(classify("copilot", "preToolUse", tool: "ask_user").actionable == true) #expect(classify("copilot", "preToolUse", tool: "AskUserQuestion").name == "PermissionRequest") #expect(classify("copilot", "preToolUse", tool: "AskUserQuestion").actionable == true) #expect(classify("copilot", "preToolUse", tool: "ExitPlanMode").name == "PermissionRequest") From 87130266cbc45cc77fd35ab561205ead008bbee1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 04:49:36 -0700 Subject: [PATCH 18/39] fix: route Copilot errors as notifications --- CLI/CMUXCLI+AgentHookDefinitions.swift | 2 +- cmuxTests/CLICopilotHookFeedTests.swift | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 2eb2610f6a87..00fdeb814623 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -318,7 +318,7 @@ extension CMUXCLI { .init(agentEvent: "sessionStart", cmuxSubcommand: "session-start"), .init(agentEvent: "userPromptSubmitted", cmuxSubcommand: "prompt-submit"), .init(agentEvent: "agentStop", cmuxSubcommand: "stop"), - .init(agentEvent: "errorOccurred", cmuxSubcommand: "stop"), + .init(agentEvent: "errorOccurred", cmuxSubcommand: "notification"), .init(agentEvent: "sessionEnd", cmuxSubcommand: "session-end"), ], feedHookEvents: ["preToolUse"] diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index bec9697b683d..eb73bf4947f1 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -82,6 +82,15 @@ struct CLICopilotHookFeedTests { #expect(hooks["Stop"] == nil, "Copilot must use canonical agentStop/errorOccurred hook names") #expect(hooks["SessionEnd"] == nil, "Copilot must use canonical camelCase hook names") #expect(hooks["PreToolUse"] == nil, "Copilot must install canonical preToolUse hooks") + let errorOccurred = try #require(hooks["errorOccurred"] as? [[String: Any]]) + #expect( + errorOccurred.contains { + ($0["bash"] as? String)?.contains("hooks copilot notification") == true + && ($0["type"] as? String) == "command" + && $0["command"] == nil + }, + "Expected errorOccurred to route through notification handling, saw \(errorOccurred)" + ) let preToolUse = try #require(hooks["preToolUse"] as? [[String: Any]]) #expect( preToolUse.contains { From 3fdc675cf4c563192b24cee51afbe4e8aab5222c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 05:01:13 -0700 Subject: [PATCH 19/39] docs: update Copilot error hook payload sample --- docs/notifications.md | 2 +- web/app/[locale]/docs/notifications/page.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/notifications.md b/docs/notifications.md index 90c9742b1365..95f482a6c692 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -140,7 +140,7 @@ Copilot CLI supports [hooks](https://docs.github.com/en/copilot/how-tos/copilot- "errorOccurred": [ { "type": "command", - "bash": "if command -v cmux &>/dev/null; then cmux notify --title 'Copilot CLI' --subtitle 'Error' --body \"$(cat | jq -r '.errorMessage // \"An error occurred\"' 2>/dev/null | head -c 100)\"; cmux set-status copilot_cli Error; else osascript -e 'display notification \"An error occurred\" with title \"Copilot CLI\"'; fi", + "bash": "if command -v cmux &>/dev/null; then cmux notify --title 'Copilot CLI' --subtitle 'Error' --body \"$(cat | jq -r '.error.message // .error.name // \"An error occurred\"' 2>/dev/null | head -c 100)\"; cmux set-status copilot_cli Error; else osascript -e 'display notification \"An error occurred\" with title \"Copilot CLI\"'; fi", "timeoutSec": 5 } ], diff --git a/web/app/[locale]/docs/notifications/page.tsx b/web/app/[locale]/docs/notifications/page.tsx index 4d5621da89e4..090aa9c5df36 100644 --- a/web/app/[locale]/docs/notifications/page.tsx +++ b/web/app/[locale]/docs/notifications/page.tsx @@ -286,7 +286,7 @@ esac`}
"errorOccurred": [ { "type": "command", - "bash": "if command -v cmux &>/dev/null; then cmux notify --title 'Copilot CLI' --subtitle 'Error' --body 'An error occurred'; cmux set-status copilot_cli Error; fi", + "bash": "if command -v cmux &>/dev/null; then cmux notify --title 'Copilot CLI' --subtitle 'Error' --body \\\"$(cat | jq -r '.error.message // .error.name // \\\"An error occurred\\\"' 2>/dev/null | head -c 100)\\\"; cmux set-status copilot_cli Error; fi", "timeoutSec": 5 } ], From 5afbfeed14f13cd9c33d66889bdab5a432caabf1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 05:38:44 -0700 Subject: [PATCH 20/39] fix: add Copilot permissionRequest feed hook --- .github/swift-file-length-budget.tsv | 6 ++-- CLI/CMUXCLI+AgentHookDefinitions.swift | 2 +- CLI/FeedEventClassifier.swift | 2 ++ CLI/cmux.swift | 13 +++++++- cmuxTests/CLICopilotHookFeedTests.swift | 34 ++++++++++++++++++-- cmuxTests/FeedEventClassificationTests.swift | 8 +++-- docs/agent-hooks.md | 2 +- docs/feed.md | 2 +- 8 files changed, 57 insertions(+), 12 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 70752f918165..f1ebc56eaac3 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34302 CLI/cmux.swift +34313 CLI/cmux.swift 17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift @@ -24,7 +24,7 @@ 4487 Sources/Panels/FilePreviewPanel.swift 4478 Sources/cmuxApp.swift 4401 cmuxTests/BrowserPanelTests.swift -4225 Sources/BrowserWindowPortal.swift +4187 Sources/BrowserWindowPortal.swift 3934 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift 3896 cmuxTests/WindowAndDragTests.swift @@ -42,7 +42,7 @@ 2395 Sources/Mobile/MobileHostService.swift 2328 cmuxTests/CJKIMEInputTests.swift 2257 Sources/TerminalNotificationStore.swift -2257 Sources/TerminalWindowPortal.swift +2233 Sources/TerminalWindowPortal.swift 2126 cmuxTests/CmuxConfigTests.swift 2103 Sources/SessionPersistence.swift 2091 cmuxTests/ShortcutAndCommandPaletteTests.swift diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 00fdeb814623..5f1510e91064 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -321,7 +321,7 @@ extension CMUXCLI { .init(agentEvent: "errorOccurred", cmuxSubcommand: "notification"), .init(agentEvent: "sessionEnd", cmuxSubcommand: "session-end"), ], - feedHookEvents: ["preToolUse"] + feedHookEvents: ["permissionRequest", "preToolUse"] ), AgentHookDef( name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy", diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 6eaf2968f07b..888947d8226b 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -236,6 +236,8 @@ struct FeedEventClassifier { // Copilot's canonical hook config uses camelCase event names. Keep // the PascalCase spelling for already-installed VS Code compatible // hook files. + "permissionRequest": .approvalRequest, + "PermissionRequest": .approvalRequest, "preToolUse": .toolStartMaybeApproval, "PreToolUse": .toolStartMaybeApproval, ], diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 396126e7ddce..c6d334d8f0d9 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -33032,6 +33032,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } let out = Self.renderAgentDecision( source: source, + rawEventName: rawEvent, hookEventName: hookEventName, toolName: toolName, toolInput: eventDict["tool_input"], @@ -33093,6 +33094,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { /// Encodes the user's decision in the agent's expected hook stdout shape. private static func renderAgentDecision( source: String, + rawEventName: String, hookEventName: String, toolName: String, toolInput: Any?, @@ -33203,10 +33205,19 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return encode(permissionRequestHookDecision(behavior: "allow")) } if source == "copilot" { + let reason = String( + localized: "cli.hooks.feed.permissionDeniedReason", + defaultValue: "User denied permission via cmux Feed." + ) + if rawEventName == "permissionRequest" || rawEventName == "PermissionRequest" { + if mode == "once" { + return encode(["behavior": "allow"]) + } + return encode(["behavior": "deny", "message": reason]) + } if mode == "once" { return encode(["permissionDecision": "allow"]) } - let reason = String(localized: "cli.hooks.feed.permissionDeniedReason", defaultValue: "User denied permission via cmux Feed.") return encode(["permissionDecision": "deny", "permissionDecisionReason": reason]) } if source == "hermes-agent" { diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index eb73bf4947f1..2826ea6d2409 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -82,6 +82,7 @@ struct CLICopilotHookFeedTests { #expect(hooks["Stop"] == nil, "Copilot must use canonical agentStop/errorOccurred hook names") #expect(hooks["SessionEnd"] == nil, "Copilot must use canonical camelCase hook names") #expect(hooks["PreToolUse"] == nil, "Copilot must install canonical preToolUse hooks") + #expect(hooks["PermissionRequest"] == nil, "Copilot must install canonical permissionRequest hooks") let errorOccurred = try #require(hooks["errorOccurred"] as? [[String: Any]]) #expect( errorOccurred.contains { @@ -102,10 +103,21 @@ struct CLICopilotHookFeedTests { }, "Expected direct preToolUse bash hook with timeout slack, saw \(preToolUse)" ) + let permissionRequest = try #require(hooks["permissionRequest"] as? [[String: Any]]) + #expect( + permissionRequest.contains { + ($0["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true + && ($0["type"] as? String) == "command" + && ($0["timeoutSec"] as? Int) == 125 + && $0["command"] == nil + && $0["hooks"] == nil + }, + "Expected direct permissionRequest bash hook with timeout slack, saw \(permissionRequest)" + ) } @Test func copilotFeedDecisionEmitsPreToolUsePermissionDecision() throws { - func runCopilotDecision(mode: String) throws -> (ProcessRunResult, [String: Any]) { + func runCopilotDecision(mode: String, event: String = "preToolUse") throws -> (ProcessRunResult, [String: Any]) { let cliPath = try Self.bundledCLIPath() let socketPath = Self.makeSocketPath("copilot-feed-decision") let listenerFD = try Self.bindUnixSocket(at: socketPath) @@ -142,7 +154,7 @@ struct CLICopilotHookFeedTests { let result = Self.runProcess( executablePath: cliPath, - arguments: ["hooks", "feed", "--source", "copilot", "--event", "preToolUse"], + arguments: ["hooks", "feed", "--source", "copilot", "--event", event], environment: [ "HOME": root.path, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", @@ -196,6 +208,24 @@ struct CLICopilotHookFeedTests { #expect(unsupportedOutput["permissionDecision"] as? String == "deny") #expect(unsupportedOutput["permissionDecisionReason"] as? String == "User denied permission via cmux Feed.") #expect(unsupportedOutput["hookSpecificOutput"] == nil) + + let (permissionAllow, permissionAllowEvent) = try runCopilotDecision(mode: "once", event: "permissionRequest") + #expect(!permissionAllow.timedOut, Comment(rawValue: permissionAllow.stderr)) + #expect(permissionAllow.status == 0, Comment(rawValue: permissionAllow.stderr)) + #expect(permissionAllowEvent["hook_event_name"] as? String == "PermissionRequest") + let permissionAllowOutput = try #require(Self.jsonObject(permissionAllow.stdout)) + #expect(permissionAllowOutput["behavior"] as? String == "allow") + #expect(permissionAllowOutput["permissionDecision"] == nil) + #expect(permissionAllowOutput["hookSpecificOutput"] == nil) + + let (permissionDeny, _) = try runCopilotDecision(mode: "deny", event: "permissionRequest") + #expect(!permissionDeny.timedOut, Comment(rawValue: permissionDeny.stderr)) + #expect(permissionDeny.status == 0, Comment(rawValue: permissionDeny.stderr)) + let permissionDenyOutput = try #require(Self.jsonObject(permissionDeny.stdout)) + #expect(permissionDenyOutput["behavior"] as? String == "deny") + #expect(permissionDenyOutput["message"] as? String == "User denied permission via cmux Feed.") + #expect(permissionDenyOutput["permissionDecision"] == nil) + #expect(permissionDenyOutput["hookSpecificOutput"] == nil) } private static func bundledCLIPath() throws -> String { diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index ca0c72307af9..98360204af0a 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -93,10 +93,12 @@ struct FeedEventClassificationTests { #expect(classify("gemini", "PreToolUse", tool: "Read").actionable == false) } - /// Copilot's `preToolUse` hook is the approval gate itself, but it also - /// fires before read-only tools. Mutating tools must block with Copilot's - /// top-level permission-decision path; read-only tools must fall through. + /// Copilot's `permissionRequest` hook blocks before the native permission + /// service, and `preToolUse` still gates side-effecting tools before they + /// execute. Read-only pre-tool events must fall through as telemetry. @Test func copilotPreToolUseIsApprovalRequest() { + #expect(classify("copilot", "permissionRequest", tool: "bash").name == "PermissionRequest") + #expect(classify("copilot", "permissionRequest", tool: "bash").actionable == true) #expect(classify("copilot", "preToolUse", tool: "bash").name == "PermissionRequest") #expect(classify("copilot", "preToolUse", tool: "bash").actionable == true) #expect(classify("copilot", "preToolUse", tool: "create").actionable == true) diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index b644092e6fff..70395bb5fda6 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -28,7 +28,7 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `amp`, `curs | Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --resume ` | PreToolUse | | Kiro CLI | `kiro-cli` | `~/.kiro/agents/cmux.json` or `$KIRO_HOME/agents/cmux.json` | `kiro-cli chat --resume-id ` | preToolUse, postToolUse | | Rovo Dev | `acli` | `~/.rovodev/config.yml` | `acli rovodev run --restore ` | none | -| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | preToolUse | +| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | permissionRequest, preToolUse | | CodeBuddy | `codebuddy` | `~/.codebuddy/settings.json` | `codebuddy --resume ` | PreToolUse | | Factory | `droid` | `~/.factory/settings.json` | `droid --resume ` | PreToolUse | | Qoder | `qodercli` | `~/.qoder/settings.json` | `qodercli --resume ` | PreToolUse | diff --git a/docs/feed.md b/docs/feed.md index f3e2a9f602b7..0083ff25a367 100644 --- a/docs/feed.md +++ b/docs/feed.md @@ -80,7 +80,7 @@ Installs supported agent hooks whose binaries are on `PATH`. See [Agent hook int | OpenCode | `~/.config/opencode/plugins/cmux-feed.js` | plugin event bus | | Cursor CLI | `~/.cursor/hooks.json` | beforeShellExecution | | Gemini | `~/.gemini/settings.json` | PreToolUse | -| Copilot | `~/.copilot/hooks/cmux.json` | preToolUse | +| Copilot | `~/.copilot/hooks/cmux.json` | permissionRequest, preToolUse | | CodeBuddy | `~/.codebuddy/settings.json` | PreToolUse | | Factory | `~/.factory/settings.json` | PreToolUse | | Qoder | `~/.qoder/settings.json` | PreToolUse | From 70ec159572311be28dea18f4442d25922598bc11 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 05:48:16 -0700 Subject: [PATCH 21/39] fix: avoid duplicate Copilot feed approvals --- .github/swift-file-length-budget.tsv | 4 ++-- CLI/CMUXCLI+AgentHookDefinitions.swift | 8 ++++++-- CLI/cmux.swift | 6 +++++- cmuxTests/CLICopilotHookFeedTests.swift | 1 + 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index f1ebc56eaac3..2b3659043e54 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34313 CLI/cmux.swift +34317 CLI/cmux.swift 17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift @@ -128,8 +128,8 @@ 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift 715 Sources/AppleScriptSupport.swift +712 CLI/CMUXCLI+AgentHookDefinitions.swift 710 Sources/TerminalSSHSessionDetector.swift -708 CLI/CMUXCLI+AgentHookDefinitions.swift 706 CLI/CMUXCLI+Config.swift 705 Sources/Panels/BrowserPopupWindowController.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 5f1510e91064..2bc3423ab726 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -381,14 +381,18 @@ extension CMUXCLI { } static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String { + let telemetryOnlyFlag = def.name == "copilot" && agentEvent == "preToolUse" + ? " --telemetry-only" + : "" + let command = "cmux hooks feed --source \(def.name) --event \(agentEvent)\(telemetryOnlyFlag)" switch def.format { case .kiroAgentJSON: return exitTwoPropagatingAgentHookShellCommand( - "cmux hooks feed --source \(def.name) --event \(agentEvent)", + command, for: def ) default: - return agentHookShellCommand("cmux hooks feed --source \(def.name) --event \(agentEvent)", for: def) + return agentHookShellCommand(command, for: def) } } diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c6d334d8f0d9..a2be5387aa71 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -32871,11 +32871,15 @@ 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) = FeedEventClassifier.classify( + var (hookEventName, isActionable) = FeedEventClassifier.classify( source: source, event: rawEvent, toolName: toolName ) + if commandArgs.contains("--telemetry-only") { + hookEventName = rawEvent.isEmpty ? hookEventName : rawEvent + isActionable = false + } let env = ProcessInfo.processInfo.environment if Self.shouldSuppressKiroFeedEvent( source: source, diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 2826ea6d2409..0ae68026a0e1 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -96,6 +96,7 @@ struct CLICopilotHookFeedTests { #expect( preToolUse.contains { ($0["bash"] as? String)?.contains("hooks feed --source copilot --event preToolUse") == true + && ($0["bash"] as? String)?.contains("--telemetry-only") == true && ($0["type"] as? String) == "command" && ($0["timeoutSec"] as? Int) == 125 && $0["command"] == nil From 5c9950f3a3e892b2af2bb69c9517a35f3b3eb816 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 06:15:42 -0700 Subject: [PATCH 22/39] fix: preserve Copilot preToolUse wire name --- .github/swift-file-length-budget.tsv | 2 +- CLI/cmux.swift | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 2b3659043e54..5c611e05e6f9 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34317 CLI/cmux.swift +34319 CLI/cmux.swift 17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a2be5387aa71..6017de2eecc1 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -32877,7 +32877,9 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { toolName: toolName ) if commandArgs.contains("--telemetry-only") { - hookEventName = rawEvent.isEmpty ? hookEventName : rawEvent + if rawEvent == "preToolUse" || rawEvent == "PreToolUse" { + hookEventName = "PreToolUse" + } isActionable = false } let env = ProcessInfo.processInfo.environment From a27730cf232737df966d19dd36d4f7dc0539ac8c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 06:26:15 -0700 Subject: [PATCH 23/39] fix: make Copilot preToolUse gate actionable --- CLI/CMUXCLI+AgentHookDefinitions.swift | 7 ++----- cmuxTests/CLICopilotHookFeedTests.swift | 14 ++------------ docs/agent-hooks.md | 2 +- docs/feed.md | 2 +- 4 files changed, 6 insertions(+), 19 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 2bc3423ab726..06bcd716c192 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -321,7 +321,7 @@ extension CMUXCLI { .init(agentEvent: "errorOccurred", cmuxSubcommand: "notification"), .init(agentEvent: "sessionEnd", cmuxSubcommand: "session-end"), ], - feedHookEvents: ["permissionRequest", "preToolUse"] + feedHookEvents: ["preToolUse"] ), AgentHookDef( name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy", @@ -381,10 +381,7 @@ extension CMUXCLI { } static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String { - let telemetryOnlyFlag = def.name == "copilot" && agentEvent == "preToolUse" - ? " --telemetry-only" - : "" - let command = "cmux hooks feed --source \(def.name) --event \(agentEvent)\(telemetryOnlyFlag)" + let command = "cmux hooks feed --source \(def.name) --event \(agentEvent)" switch def.format { case .kiroAgentJSON: return exitTwoPropagatingAgentHookShellCommand( diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 0ae68026a0e1..fd4f89878e48 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -83,6 +83,7 @@ struct CLICopilotHookFeedTests { #expect(hooks["SessionEnd"] == nil, "Copilot must use canonical camelCase hook names") #expect(hooks["PreToolUse"] == nil, "Copilot must install canonical preToolUse hooks") #expect(hooks["PermissionRequest"] == nil, "Copilot must install canonical permissionRequest hooks") + #expect(hooks["permissionRequest"] == nil, "Copilot uses preToolUse for the installed approval gate") let errorOccurred = try #require(hooks["errorOccurred"] as? [[String: Any]]) #expect( errorOccurred.contains { @@ -96,7 +97,7 @@ struct CLICopilotHookFeedTests { #expect( preToolUse.contains { ($0["bash"] as? String)?.contains("hooks feed --source copilot --event preToolUse") == true - && ($0["bash"] as? String)?.contains("--telemetry-only") == true + && ($0["bash"] as? String)?.contains("--telemetry-only") == false && ($0["type"] as? String) == "command" && ($0["timeoutSec"] as? Int) == 125 && $0["command"] == nil @@ -104,17 +105,6 @@ struct CLICopilotHookFeedTests { }, "Expected direct preToolUse bash hook with timeout slack, saw \(preToolUse)" ) - let permissionRequest = try #require(hooks["permissionRequest"] as? [[String: Any]]) - #expect( - permissionRequest.contains { - ($0["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true - && ($0["type"] as? String) == "command" - && ($0["timeoutSec"] as? Int) == 125 - && $0["command"] == nil - && $0["hooks"] == nil - }, - "Expected direct permissionRequest bash hook with timeout slack, saw \(permissionRequest)" - ) } @Test func copilotFeedDecisionEmitsPreToolUsePermissionDecision() throws { diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index 70395bb5fda6..b644092e6fff 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -28,7 +28,7 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `amp`, `curs | Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --resume ` | PreToolUse | | Kiro CLI | `kiro-cli` | `~/.kiro/agents/cmux.json` or `$KIRO_HOME/agents/cmux.json` | `kiro-cli chat --resume-id ` | preToolUse, postToolUse | | Rovo Dev | `acli` | `~/.rovodev/config.yml` | `acli rovodev run --restore ` | none | -| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | permissionRequest, preToolUse | +| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | preToolUse | | CodeBuddy | `codebuddy` | `~/.codebuddy/settings.json` | `codebuddy --resume ` | PreToolUse | | Factory | `droid` | `~/.factory/settings.json` | `droid --resume ` | PreToolUse | | Qoder | `qodercli` | `~/.qoder/settings.json` | `qodercli --resume ` | PreToolUse | diff --git a/docs/feed.md b/docs/feed.md index 0083ff25a367..f3e2a9f602b7 100644 --- a/docs/feed.md +++ b/docs/feed.md @@ -80,7 +80,7 @@ Installs supported agent hooks whose binaries are on `PATH`. See [Agent hook int | OpenCode | `~/.config/opencode/plugins/cmux-feed.js` | plugin event bus | | Cursor CLI | `~/.cursor/hooks.json` | beforeShellExecution | | Gemini | `~/.gemini/settings.json` | PreToolUse | -| Copilot | `~/.copilot/hooks/cmux.json` | permissionRequest, preToolUse | +| Copilot | `~/.copilot/hooks/cmux.json` | preToolUse | | CodeBuddy | `~/.codebuddy/settings.json` | PreToolUse | | Factory | `~/.factory/settings.json` | PreToolUse | | Qoder | `~/.qoder/settings.json` | PreToolUse | From 64340ca7b8d5852d208d41fab7fe52fce635b77c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 06:33:56 -0700 Subject: [PATCH 24/39] fix: install Copilot permissionRequest gate --- CLI/CMUXCLI+AgentHookDefinitions.swift | 6 ++++-- cmuxTests/CLICopilotHookFeedTests.swift | 19 ++++++++++++++++--- cmuxTests/FeedEventClassificationTests.swift | 7 ++++--- docs/agent-hooks.md | 2 +- docs/feed.md | 2 +- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 06bcd716c192..8b819a3c525e 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -321,7 +321,7 @@ extension CMUXCLI { .init(agentEvent: "errorOccurred", cmuxSubcommand: "notification"), .init(agentEvent: "sessionEnd", cmuxSubcommand: "session-end"), ], - feedHookEvents: ["preToolUse"] + feedHookEvents: ["permissionRequest", "preToolUse"] ), AgentHookDef( name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy", @@ -381,7 +381,9 @@ extension CMUXCLI { } static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String { - let command = "cmux hooks feed --source \(def.name) --event \(agentEvent)" + // Copilot approval uses permissionRequest; preToolUse is telemetry to avoid duplicate prompts. + let telemetryOnlyFlag = def.name == "copilot" && agentEvent == "preToolUse" ? " --telemetry-only" : "" + let command = "cmux hooks feed --source \(def.name) --event \(agentEvent)\(telemetryOnlyFlag)" switch def.format { case .kiroAgentJSON: return exitTwoPropagatingAgentHookShellCommand( diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index fd4f89878e48..7ddc1857cdbd 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -83,7 +83,6 @@ struct CLICopilotHookFeedTests { #expect(hooks["SessionEnd"] == nil, "Copilot must use canonical camelCase hook names") #expect(hooks["PreToolUse"] == nil, "Copilot must install canonical preToolUse hooks") #expect(hooks["PermissionRequest"] == nil, "Copilot must install canonical permissionRequest hooks") - #expect(hooks["permissionRequest"] == nil, "Copilot uses preToolUse for the installed approval gate") let errorOccurred = try #require(hooks["errorOccurred"] as? [[String: Any]]) #expect( errorOccurred.contains { @@ -97,7 +96,7 @@ struct CLICopilotHookFeedTests { #expect( preToolUse.contains { ($0["bash"] as? String)?.contains("hooks feed --source copilot --event preToolUse") == true - && ($0["bash"] as? String)?.contains("--telemetry-only") == false + && ($0["bash"] as? String)?.contains("--telemetry-only") == true && ($0["type"] as? String) == "command" && ($0["timeoutSec"] as? Int) == 125 && $0["command"] == nil @@ -105,9 +104,20 @@ struct CLICopilotHookFeedTests { }, "Expected direct preToolUse bash hook with timeout slack, saw \(preToolUse)" ) + let permissionRequest = try #require(hooks["permissionRequest"] as? [[String: Any]]) + #expect( + permissionRequest.contains { + ($0["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true + && ($0["type"] as? String) == "command" + && ($0["timeoutSec"] as? Int) == 125 + && $0["command"] == nil + && $0["hooks"] == nil + }, + "Expected direct permissionRequest bash hook with timeout slack, saw \(permissionRequest)" + ) } - @Test func copilotFeedDecisionEmitsPreToolUsePermissionDecision() throws { + @Test func copilotFeedDecisionEmitsCopilotPermissionDecisions() throws { func runCopilotDecision(mode: String, event: String = "preToolUse") throws -> (ProcessRunResult, [String: Any]) { let cliPath = try Self.bundledCLIPath() let socketPath = Self.makeSocketPath("copilot-feed-decision") @@ -174,6 +184,9 @@ struct CLICopilotHookFeedTests { return (result, try #require(feedEvents.first)) } + // Fresh installs answer Copilot's permission service through + // permissionRequest. Keep unmarked preToolUse decision support for + // already-installed hook files and manual invocations. let (allow, allowEvent) = try runCopilotDecision(mode: "once") #expect(!allow.timedOut, Comment(rawValue: allow.stderr)) #expect(allow.status == 0, Comment(rawValue: allow.stderr)) diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index 98360204af0a..02b64a209c69 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -93,9 +93,10 @@ struct FeedEventClassificationTests { #expect(classify("gemini", "PreToolUse", tool: "Read").actionable == false) } - /// Copilot's `permissionRequest` hook blocks before the native permission - /// service, and `preToolUse` still gates side-effecting tools before they - /// execute. Read-only pre-tool events must fall through as telemetry. + /// Copilot's installed `permissionRequest` hook blocks before the native + /// permission service. Unmarked `preToolUse` still gates side-effecting + /// tools for legacy hook files and manual invocations; read-only pre-tool + /// events must fall through as telemetry. @Test func copilotPreToolUseIsApprovalRequest() { #expect(classify("copilot", "permissionRequest", tool: "bash").name == "PermissionRequest") #expect(classify("copilot", "permissionRequest", tool: "bash").actionable == true) diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index b644092e6fff..cc67652fe8db 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -28,7 +28,7 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `amp`, `curs | Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --resume ` | PreToolUse | | Kiro CLI | `kiro-cli` | `~/.kiro/agents/cmux.json` or `$KIRO_HOME/agents/cmux.json` | `kiro-cli chat --resume-id ` | preToolUse, postToolUse | | Rovo Dev | `acli` | `~/.rovodev/config.yml` | `acli rovodev run --restore ` | none | -| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | preToolUse | +| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | permissionRequest, preToolUse telemetry | | CodeBuddy | `codebuddy` | `~/.codebuddy/settings.json` | `codebuddy --resume ` | PreToolUse | | Factory | `droid` | `~/.factory/settings.json` | `droid --resume ` | PreToolUse | | Qoder | `qodercli` | `~/.qoder/settings.json` | `qodercli --resume ` | PreToolUse | diff --git a/docs/feed.md b/docs/feed.md index f3e2a9f602b7..22b93def88d5 100644 --- a/docs/feed.md +++ b/docs/feed.md @@ -80,7 +80,7 @@ Installs supported agent hooks whose binaries are on `PATH`. See [Agent hook int | OpenCode | `~/.config/opencode/plugins/cmux-feed.js` | plugin event bus | | Cursor CLI | `~/.cursor/hooks.json` | beforeShellExecution | | Gemini | `~/.gemini/settings.json` | PreToolUse | -| Copilot | `~/.copilot/hooks/cmux.json` | preToolUse | +| Copilot | `~/.copilot/hooks/cmux.json` | permissionRequest, preToolUse telemetry | | CodeBuddy | `~/.codebuddy/settings.json` | PreToolUse | | Factory | `~/.factory/settings.json` | PreToolUse | | Qoder | `~/.qoder/settings.json` | PreToolUse | From ff852935eb16b0debd681d32fa0843a6e4526c31 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 06:44:42 -0700 Subject: [PATCH 25/39] fix: tighten Copilot telemetry hooks --- .github/swift-file-length-budget.tsv | 6 ++--- CLI/cmux.swift | 33 +++++++++++++++++++++++-- cmuxTests/CLICopilotHookFeedTests.swift | 4 +-- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 5c611e05e6f9..0a32819280cb 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34319 CLI/cmux.swift +34348 CLI/cmux.swift 17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift @@ -44,8 +44,8 @@ 2257 Sources/TerminalNotificationStore.swift 2233 Sources/TerminalWindowPortal.swift 2126 cmuxTests/CmuxConfigTests.swift -2103 Sources/SessionPersistence.swift 2091 cmuxTests/ShortcutAndCommandPaletteTests.swift +2078 Sources/SessionPersistence.swift 1952 Sources/KeyboardShortcutSettingsFileStore.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1945 Sources/RestorableAgentSession.swift @@ -128,7 +128,7 @@ 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift 715 Sources/AppleScriptSupport.swift -712 CLI/CMUXCLI+AgentHookDefinitions.swift +711 CLI/CMUXCLI+AgentHookDefinitions.swift 710 Sources/TerminalSSHSessionDetector.swift 706 CLI/CMUXCLI+Config.swift 705 Sources/Panels/BrowserPopupWindowController.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6017de2eecc1..ce6366d0f984 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -25728,6 +25728,31 @@ struct CMUXCLI { String(localized: "agent.generic.notification.body.sentNotification", defaultValue: "%@ sent a notification"), def.displayName ) + if def.name == "copilot", let error = object["error"] as? [String: Any] { + let label = firstString(in: error, keys: ["name", "code", "status", "type"]) + let details = firstString(in: error, keys: ["message", "description"]) + ?? firstString(in: object, keys: ["message", "description"]) + let body: String + switch (label.map(normalizedSingleLine), details.map(normalizedSingleLine)) { + case let (label?, details?) where !label.isEmpty && !details.isEmpty && details != label: + body = "\(label): \(details)" + case let (_, details?) where !details.isEmpty: + body = details + case let (label?, _) where !label.isEmpty: + body = label + default: + body = String.localizedStringWithFormat( + String(localized: "agent.generic.notification.body.reportedError", defaultValue: "%@ reported an error"), + def.displayName + ) + } + return AgentHookNotificationSummary( + subtitle: String(localized: "agent.generic.notification.subtitle.error", defaultValue: "Error"), + body: truncate(body, maxLength: 180), + status: .error, + isFallback: false + ) + } let message = messageCandidates.compactMap { $0 }.first ?? fallbackBody let signal = signalParts.compactMap { $0 }.joined(separator: " ") let normalizedMessage = normalizedSingleLine(message) @@ -26987,9 +27012,10 @@ struct CMUXCLI { result[agentEvent] = groups case .copilotJSON: var entries = result[agentEvent] as? [[String: Any]] ?? [] + let timeoutSeconds = Self.timeoutSecondsFromMilliseconds(feedTimeoutMs) entries.append(Self.copilotHookEntry( command: feedCmd, - timeoutSeconds: Self.timeoutSecondsFromMilliseconds(feedTimeoutMs) + 5 + timeoutSeconds: timeoutSeconds + (feedTimeoutMs >= 120_000 ? 5 : 0) )) result[agentEvent] = entries case .antigravityJSON: @@ -27017,10 +27043,13 @@ struct CMUXCLI { return Self.timeoutSecondsFromMilliseconds(timeoutMs) } - private func feedHookTimeoutMs(for def: AgentHookDef, agentEvent _: String) -> Int { + private func feedHookTimeoutMs(for def: AgentHookDef, agentEvent: String) -> Int { if def.name == "codex" { return 5_000 } + if def.name == "copilot", agentEvent == "preToolUse" { + return 5_000 + } return 120_000 } diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 7ddc1857cdbd..5accfab27b39 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -98,11 +98,11 @@ struct CLICopilotHookFeedTests { ($0["bash"] as? String)?.contains("hooks feed --source copilot --event preToolUse") == true && ($0["bash"] as? String)?.contains("--telemetry-only") == true && ($0["type"] as? String) == "command" - && ($0["timeoutSec"] as? Int) == 125 + && ($0["timeoutSec"] as? Int) == 5 && $0["command"] == nil && $0["hooks"] == nil }, - "Expected direct preToolUse bash hook with timeout slack, saw \(preToolUse)" + "Expected telemetry preToolUse bash hook with short timeout, saw \(preToolUse)" ) let permissionRequest = try #require(hooks["permissionRequest"] as? [[String: Any]]) #expect( From 7d5c019862ff0e24f4bf95f48902cfc59b25428b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 06:55:17 -0700 Subject: [PATCH 26/39] fix: remove Copilot preToolUse telemetry hook --- .github/swift-file-length-budget.tsv | 5 +- CLI/CMUXCLI+AgentHookDefinitions.swift | 7 +- CLI/cmux.swift | 19 +- Resources/feed-tui/index.ts | 10 +- cmuxTests/CLICopilotHookFeedTests.swift | 196 +++++++++++++++++-- docs/agent-hooks.md | 2 +- docs/feed.md | 2 +- docs/notifications.md | 7 + web/app/[locale]/docs/notifications/page.tsx | 7 + 9 files changed, 225 insertions(+), 30 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 0a32819280cb..80f9e4039f8c 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34348 CLI/cmux.swift +34355 CLI/cmux.swift 17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift @@ -128,7 +128,7 @@ 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift 715 Sources/AppleScriptSupport.swift -711 CLI/CMUXCLI+AgentHookDefinitions.swift +710 CLI/CMUXCLI+AgentHookDefinitions.swift 710 Sources/TerminalSSHSessionDetector.swift 706 CLI/CMUXCLI+Config.swift 705 Sources/Panels/BrowserPopupWindowController.swift @@ -159,6 +159,7 @@ 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift +616 cmuxTests/CLICopilotHookFeedTests.swift 615 cmuxTests/RemoteTmuxControlParserTests.swift 612 cmuxUITests/FeedSidebarUITests.swift 608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 8b819a3c525e..34ee50bacb8e 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -319,9 +319,10 @@ extension CMUXCLI { .init(agentEvent: "userPromptSubmitted", cmuxSubcommand: "prompt-submit"), .init(agentEvent: "agentStop", cmuxSubcommand: "stop"), .init(agentEvent: "errorOccurred", cmuxSubcommand: "notification"), + .init(agentEvent: "notification", cmuxSubcommand: "notification"), .init(agentEvent: "sessionEnd", cmuxSubcommand: "session-end"), ], - feedHookEvents: ["permissionRequest", "preToolUse"] + feedHookEvents: ["permissionRequest"] ), AgentHookDef( name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy", @@ -381,9 +382,7 @@ extension CMUXCLI { } static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String { - // Copilot approval uses permissionRequest; preToolUse is telemetry to avoid duplicate prompts. - let telemetryOnlyFlag = def.name == "copilot" && agentEvent == "preToolUse" ? " --telemetry-only" : "" - let command = "cmux hooks feed --source \(def.name) --event \(agentEvent)\(telemetryOnlyFlag)" + let command = "cmux hooks feed --source \(def.name) --event \(agentEvent)" switch def.format { case .kiroAgentJSON: return exitTwoPropagatingAgentHookShellCommand( diff --git a/CLI/cmux.swift b/CLI/cmux.swift index ce6366d0f984..62e956e79f32 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -25728,10 +25728,11 @@ struct CMUXCLI { String(localized: "agent.generic.notification.body.sentNotification", defaultValue: "%@ sent a notification"), def.displayName ) - if def.name == "copilot", let error = object["error"] as? [String: Any] { + let rawObject = parsedInput.rawObject ?? object + if def.name == "copilot", let error = rawObject["error"] as? [String: Any] { let label = firstString(in: error, keys: ["name", "code", "status", "type"]) let details = firstString(in: error, keys: ["message", "description"]) - ?? firstString(in: object, keys: ["message", "description"]) + ?? firstString(in: rawObject, keys: ["message", "description"]) let body: String switch (label.map(normalizedSingleLine), details.map(normalizedSingleLine)) { case let (label?, details?) where !label.isEmpty && !details.isEmpty && details != label: @@ -27047,9 +27048,6 @@ struct CMUXCLI { if def.name == "codex" { return 5_000 } - if def.name == "copilot", agentEvent == "preToolUse" { - return 5_000 - } return 120_000 } @@ -28059,6 +28057,13 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return false } + private static func shouldPrependCmuxHookEntries(def: AgentHookDef, event: String) -> Bool { + // Copilot merges permissionRequest outputs with later hooks overriding + // earlier hooks. Keep existing policy hooks after cmux so their deny + // output cannot be overwritten by a Feed approval. + def.name == "copilot" && event == "permissionRequest" + } + private func installAgentHooks(_ def: AgentHookDef) throws { if def.name == "opencode" { try installOpenCodePluginHooks(def) @@ -28206,7 +28211,9 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { case .flat, .kiroAgentJSON, .copilotJSON: var entries = hooks[event] as? [[String: Any]] ?? [] if let newEntries = value as? [[String: Any]] { - if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { + if Self.shouldPrependCmuxHookEntries(def: def, event: event) { + entries.insert(contentsOf: newEntries, at: 0) + } else if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { Self.insertCmuxHookValues(newEntries, into: &entries, atOriginalIndexes: insertionIndexes) } else { entries.append(contentsOf: newEntries) diff --git a/Resources/feed-tui/index.ts b/Resources/feed-tui/index.ts index 7ced21bfde26..aa7f63d14bed 100644 --- a/Resources/feed-tui/index.ts +++ b/Resources/feed-tui/index.ts @@ -1123,7 +1123,7 @@ function sourceSupportsOncePermissionMode(source: string, toolInputCapabilities? } function sourceSupportsAlwaysPermissionMode(source: string, toolInputCapabilities?: string): boolean { - if (source === "hermes-agent") { + if (!sourceSupportsPersistentPermissionModes(source)) { return false; } if (source !== "codex") { @@ -1133,7 +1133,7 @@ function sourceSupportsAlwaysPermissionMode(source: string, toolInputCapabilitie } function sourceSupportsAllPermissionMode(source: string, toolInputCapabilities?: string): boolean { - if (source === "hermes-agent") { + if (!sourceSupportsPersistentPermissionModes(source)) { return false; } if (source !== "codex") { @@ -1223,8 +1223,12 @@ function codexDecisionAvailableOrUnspecified(decision: string, decisions: Set String { try BundledCLITestSupport.bundledCLIPath(for: BundleProbe.self) } + private static func runCopilotNotificationHook( + socketName: String, + payloadFields: [String: Any] + ) throws -> (result: ProcessRunResult, commands: [String], workspaceId: String, surfaceId: String) { + let cliPath = try Self.bundledCLIPath() + let socketPath = Self.makeSocketPath(socketName) + let listenerFD = try Self.bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-\(socketName)-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "55555555-5555-5555-5555-555555555555" + let surfaceId = "66666666-6666-6666-6666-666666666666" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let server = Self.startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = Self.jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return Self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return Self.v2Response( + id: id, + ok: true, + result: [ + "surfaces": [ + ["id": surfaceId, "ref": "surface:1", "focused": true], + ], + ] + ) + case "feed.push": + return Self.v2Response(id: id, ok: true, result: [:]) + default: + return Self.v2Response(id: id, ok: true, result: [:]) + } + } + + var inputPayload = payloadFields + inputPayload["sessionId"] = inputPayload["sessionId"] ?? "copilot-notification-session" + inputPayload["cwd"] = inputPayload["cwd"] ?? root.path + let inputData = try JSONSerialization.data(withJSONObject: inputPayload, options: [.sortedKeys]) + let input = try #require(String(data: inputData, encoding: .utf8)) + + let result = Self.runProcess( + executablePath: cliPath, + arguments: ["hooks", "copilot", "notification"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + standardInput: input, + timeout: 5 + ) + #expect(server.wait(timeout: 5), "socket server did not observe notification commands") + + return (result, state.snapshot(), workspaceId, surfaceId) + } + private static func makeSocketPath(_ name: String) -> String { let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) return URL(fileURLWithPath: NSTemporaryDirectory()) diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index cc67652fe8db..cc63fd97be53 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -28,7 +28,7 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `amp`, `curs | Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --resume ` | PreToolUse | | Kiro CLI | `kiro-cli` | `~/.kiro/agents/cmux.json` or `$KIRO_HOME/agents/cmux.json` | `kiro-cli chat --resume-id ` | preToolUse, postToolUse | | Rovo Dev | `acli` | `~/.rovodev/config.yml` | `acli rovodev run --restore ` | none | -| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | permissionRequest, preToolUse telemetry | +| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | permissionRequest | | CodeBuddy | `codebuddy` | `~/.codebuddy/settings.json` | `codebuddy --resume ` | PreToolUse | | Factory | `droid` | `~/.factory/settings.json` | `droid --resume ` | PreToolUse | | Qoder | `qodercli` | `~/.qoder/settings.json` | `qodercli --resume ` | PreToolUse | diff --git a/docs/feed.md b/docs/feed.md index 22b93def88d5..133c6c168413 100644 --- a/docs/feed.md +++ b/docs/feed.md @@ -80,7 +80,7 @@ Installs supported agent hooks whose binaries are on `PATH`. See [Agent hook int | OpenCode | `~/.config/opencode/plugins/cmux-feed.js` | plugin event bus | | Cursor CLI | `~/.cursor/hooks.json` | beforeShellExecution | | Gemini | `~/.gemini/settings.json` | PreToolUse | -| Copilot | `~/.copilot/hooks/cmux.json` | permissionRequest, preToolUse telemetry | +| Copilot | `~/.copilot/hooks/cmux.json` | permissionRequest | | CodeBuddy | `~/.codebuddy/settings.json` | PreToolUse | | Factory | `~/.factory/settings.json` | PreToolUse | | Qoder | `~/.qoder/settings.json` | PreToolUse | diff --git a/docs/notifications.md b/docs/notifications.md index 95f482a6c692..ec4faf9da740 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -144,6 +144,13 @@ Copilot CLI supports [hooks](https://docs.github.com/en/copilot/how-tos/copilot- "timeoutSec": 5 } ], + "notification": [ + { + "type": "command", + "bash": "if command -v cmux &>/dev/null; then cmux hooks copilot notification; fi", + "timeoutSec": 5 + } + ], "sessionEnd": [ { "type": "command", diff --git a/web/app/[locale]/docs/notifications/page.tsx b/web/app/[locale]/docs/notifications/page.tsx index 090aa9c5df36..e6ee44373a6c 100644 --- a/web/app/[locale]/docs/notifications/page.tsx +++ b/web/app/[locale]/docs/notifications/page.tsx @@ -290,6 +290,13 @@ esac`} "timeoutSec": 5 } ], + "notification": [ + { + "type": "command", + "bash": "if command -v cmux &>/dev/null; then cmux hooks copilot notification; fi", + "timeoutSec": 5 + } + ], "sessionEnd": [ { "type": "command", From f4086b02d16e456c6c42dfac06ffd60ef8629770 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 07:20:46 -0700 Subject: [PATCH 27/39] fix: preserve Copilot permission policies --- .github/swift-file-length-budget.tsv | 6 +++--- CLI/cmux.swift | 17 ++++------------- cmuxTests/CLICopilotHookFeedTests.swift | 12 +++++------- 3 files changed, 12 insertions(+), 23 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index e7a7343b9585..2a48ab22166d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,12 +1,12 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34355 CLI/cmux.swift +34346 CLI/cmux.swift 17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift 12772 Sources/Workspace.swift -12257 Sources/GhosttyTerminalView.swift +12238 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift 11924 Sources/Panels/BrowserPanel.swift 9335 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -159,8 +159,8 @@ 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift -616 cmuxTests/CLICopilotHookFeedTests.swift 615 cmuxTests/RemoteTmuxControlParserTests.swift +614 cmuxTests/CLICopilotHookFeedTests.swift 612 cmuxUITests/FeedSidebarUITests.swift 608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 606 Sources/SettingsNavigation.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 62e956e79f32..b25a1ac875b6 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28057,13 +28057,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return false } - private static func shouldPrependCmuxHookEntries(def: AgentHookDef, event: String) -> Bool { - // Copilot merges permissionRequest outputs with later hooks overriding - // earlier hooks. Keep existing policy hooks after cmux so their deny - // output cannot be overwritten by a Feed approval. - def.name == "copilot" && event == "permissionRequest" - } - private func installAgentHooks(_ def: AgentHookDef) throws { if def.name == "opencode" { try installOpenCodePluginHooks(def) @@ -28211,9 +28204,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { case .flat, .kiroAgentJSON, .copilotJSON: var entries = hooks[event] as? [[String: Any]] ?? [] if let newEntries = value as? [[String: Any]] { - if Self.shouldPrependCmuxHookEntries(def: def, event: event) { - entries.insert(contentsOf: newEntries, at: 0) - } else if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { + if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { Self.insertCmuxHookValues(newEntries, into: &entries, atOriginalIndexes: insertionIndexes) } else { entries.append(contentsOf: newEntries) @@ -33252,10 +33243,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { defaultValue: "User denied permission via cmux Feed." ) if rawEventName == "permissionRequest" || rawEventName == "PermissionRequest" { - if mode == "once" { - return encode(["behavior": "allow"]) + if mode == "deny" { + return encode(["behavior": "deny", "message": reason]) } - return encode(["behavior": "deny", "message": reason]) + return "{}" } if mode == "once" { return encode(["permissionDecision": "allow"]) diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 300d5ef93d2d..c23469927100 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -140,14 +140,12 @@ struct CLICopilotHookFeedTests { ) let permissionRequest = try #require(hooks["permissionRequest"] as? [[String: Any]]) #expect( - (permissionRequest.first?["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true, - "Expected cmux permissionRequest hook to run before existing policy hooks, saw \(permissionRequest)" + (permissionRequest.first?["bash"] as? String)?.contains("\"behavior\":\"deny\"") == true, + "Expected existing permissionRequest policy hook to stay before cmux, saw \(permissionRequest)" ) #expect( - permissionRequest.dropFirst().contains { - ($0["bash"] as? String)?.contains("\"behavior\":\"deny\"") == true - }, - "Expected existing permissionRequest policy hook to be preserved after cmux, saw \(permissionRequest)" + (permissionRequest.last?["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true, + "Expected cmux permissionRequest hook to be final within the user hooks file, saw \(permissionRequest)" ) #expect( permissionRequest.contains { @@ -262,7 +260,7 @@ struct CLICopilotHookFeedTests { #expect(permissionAllow.status == 0, Comment(rawValue: permissionAllow.stderr)) #expect(permissionAllowEvent["hook_event_name"] as? String == "PermissionRequest") let permissionAllowOutput = try #require(Self.jsonObject(permissionAllow.stdout)) - #expect(permissionAllowOutput["behavior"] as? String == "allow") + #expect(permissionAllowOutput.isEmpty) #expect(permissionAllowOutput["permissionDecision"] == nil) #expect(permissionAllowOutput["hookSpecificOutput"] == nil) From 5c3013600fcb72dead9de4411cf22df2838a32ba Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 07:25:59 -0700 Subject: [PATCH 28/39] fix: approve Copilot permission requests --- CLI/cmux.swift | 32 +++++++++++++++++++------ cmuxTests/CLICopilotHookFeedTests.swift | 19 +++++++++++---- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b25a1ac875b6..b64e41a17f3b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -28149,11 +28149,13 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { var rewrittenEntries: [[String: Any]] = [] for entry in entries { if Self.jsonHookValueContainsCmuxOwnedCommand(entry, for: def) { - Self.appendCmuxHookInsertionIndex( - rewrittenEntries.count, - for: event, - to: &cmuxInsertionIndexes - ) + if Self.shouldPreserveCmuxHookInsertionIndex(def: def, event: event) { + Self.appendCmuxHookInsertionIndex( + rewrittenEntries.count, + for: event, + to: &cmuxInsertionIndexes + ) + } continue } rewrittenEntries.append(entry) @@ -28173,7 +28175,8 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { rewrittenGroups.append(group) continue } - if hookList.contains(where: { isCmuxOwnedCommand($0["command"] as? String ?? "") }) { + if Self.shouldPreserveCmuxHookInsertionIndex(def: def, event: event), + hookList.contains(where: { isCmuxOwnedCommand($0["command"] as? String ?? "") }) { Self.appendCmuxHookInsertionIndex( rewrittenGroups.count, for: event, @@ -28206,6 +28209,8 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if let newEntries = value as? [[String: Any]] { if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { Self.insertCmuxHookValues(newEntries, into: &entries, atOriginalIndexes: insertionIndexes) + } else if Self.shouldPrependNewCmuxHookValues(def: def, event: event) { + entries.insert(contentsOf: newEntries, at: 0) } else { entries.append(contentsOf: newEntries) } @@ -28216,6 +28221,8 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if let newGroups = value as? [[String: Any]] { if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { Self.insertCmuxHookValues(newGroups, into: &groups, atOriginalIndexes: insertionIndexes) + } else if Self.shouldPrependNewCmuxHookValues(def: def, event: event) { + groups.insert(contentsOf: newGroups, at: 0) } else { groups.append(contentsOf: newGroups) } @@ -28559,6 +28566,14 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { indexes[event, default: []].append(index) } + private static func shouldPreserveCmuxHookInsertionIndex(def: AgentHookDef, event: String) -> Bool { + !(def.name == "copilot" && event == "permissionRequest") + } + + private static func shouldPrependNewCmuxHookValues(def: AgentHookDef, event: String) -> Bool { + def.name == "copilot" && event == "permissionRequest" + } + private static func insertCmuxHookValues(_ values: [T], into target: inout [T], atOriginalIndexes indexes: [Int]) { var insertedCount = 0 for originalIndex in indexes { @@ -33246,7 +33261,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if mode == "deny" { return encode(["behavior": "deny", "message": reason]) } - return "{}" + if mode == "once" { + return encode(["behavior": "allow"]) + } + return encode(["behavior": "deny", "message": reason]) } if mode == "once" { return encode(["permissionDecision": "allow"]) diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index c23469927100..3b8105398440 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -140,12 +140,12 @@ struct CLICopilotHookFeedTests { ) let permissionRequest = try #require(hooks["permissionRequest"] as? [[String: Any]]) #expect( - (permissionRequest.first?["bash"] as? String)?.contains("\"behavior\":\"deny\"") == true, - "Expected existing permissionRequest policy hook to stay before cmux, saw \(permissionRequest)" + (permissionRequest.first?["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true, + "Expected cmux permissionRequest hook to run before preserved policy hooks, saw \(permissionRequest)" ) #expect( - (permissionRequest.last?["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true, - "Expected cmux permissionRequest hook to be final within the user hooks file, saw \(permissionRequest)" + (permissionRequest.last?["bash"] as? String)?.contains("\"behavior\":\"deny\"") == true, + "Expected existing permissionRequest policy hook to stay after cmux so Copilot can merge it later, saw \(permissionRequest)" ) #expect( permissionRequest.contains { @@ -260,7 +260,7 @@ struct CLICopilotHookFeedTests { #expect(permissionAllow.status == 0, Comment(rawValue: permissionAllow.stderr)) #expect(permissionAllowEvent["hook_event_name"] as? String == "PermissionRequest") let permissionAllowOutput = try #require(Self.jsonObject(permissionAllow.stdout)) - #expect(permissionAllowOutput.isEmpty) + #expect(permissionAllowOutput["behavior"] as? String == "allow") #expect(permissionAllowOutput["permissionDecision"] == nil) #expect(permissionAllowOutput["hookSpecificOutput"] == nil) @@ -272,6 +272,15 @@ struct CLICopilotHookFeedTests { #expect(permissionDenyOutput["message"] as? String == "User denied permission via cmux Feed.") #expect(permissionDenyOutput["permissionDecision"] == nil) #expect(permissionDenyOutput["hookSpecificOutput"] == nil) + + let (permissionUnsupported, _) = try runCopilotDecision(mode: "always", event: "permissionRequest") + #expect(!permissionUnsupported.timedOut, Comment(rawValue: permissionUnsupported.stderr)) + #expect(permissionUnsupported.status == 0, Comment(rawValue: permissionUnsupported.stderr)) + let permissionUnsupportedOutput = try #require(Self.jsonObject(permissionUnsupported.stdout)) + #expect(permissionUnsupportedOutput["behavior"] as? String == "deny") + #expect(permissionUnsupportedOutput["message"] as? String == "User denied permission via cmux Feed.") + #expect(permissionUnsupportedOutput["permissionDecision"] == nil) + #expect(permissionUnsupportedOutput["hookSpecificOutput"] == nil) } @Test func copilotErrorNotificationUsesStructuredErrorPayload() throws { From 8acf4223eff3a49033190c0691d8a2c2907e8c4e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 07:31:58 -0700 Subject: [PATCH 29/39] chore: refresh Swift file length budget --- .github/swift-file-length-budget.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 2a48ab22166d..7f0eacec8825 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34346 CLI/cmux.swift +34364 CLI/cmux.swift 17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift @@ -156,11 +156,11 @@ 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift 629 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +623 cmuxTests/CLICopilotHookFeedTests.swift 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift 615 cmuxTests/RemoteTmuxControlParserTests.swift -614 cmuxTests/CLICopilotHookFeedTests.swift 612 cmuxUITests/FeedSidebarUITests.swift 608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 606 Sources/SettingsNavigation.swift From db9f6168e7d58a834084d5ec631e7e355c494314 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 07:43:56 -0700 Subject: [PATCH 30/39] fix: preserve Copilot denial authority --- .github/swift-file-length-budget.tsv | 4 +- CLI/cmux.swift | 178 +++++++++++++++++++++++- cmuxTests/CLICopilotHookFeedTests.swift | 122 +++++++++++++++- 3 files changed, 289 insertions(+), 15 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 7f0eacec8825..48a9064c4307 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34364 CLI/cmux.swift +34530 CLI/cmux.swift 17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift @@ -123,6 +123,7 @@ 754 Sources/TerminalController+ControlWorkspaceContext.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift +731 cmuxTests/CLICopilotHookFeedTests.swift 726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 722 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift @@ -156,7 +157,6 @@ 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift 629 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift -623 cmuxTests/CLICopilotHookFeedTests.swift 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b64e41a17f3b..4d2ca04b0a24 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -27018,6 +27018,12 @@ struct CMUXCLI { command: feedCmd, timeoutSeconds: timeoutSeconds + (feedTimeoutMs >= 120_000 ? 5 : 0) )) + if def.name == "copilot", agentEvent == "permissionRequest" { + entries.append(Self.copilotHookEntry( + command: "\(feedCmd) --copilot-deny-guard", + timeoutSeconds: Self.timeoutSecondsFromMilliseconds(5_000) + )) + } result[agentEvent] = entries case .antigravityJSON: var entries = result[agentEvent] as? [[String: Any]] ?? [] @@ -28207,7 +28213,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { case .flat, .kiroAgentJSON, .copilotJSON: var entries = hooks[event] as? [[String: Any]] ?? [] if let newEntries = value as? [[String: Any]] { - if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { + if Self.shouldBracketCmuxHookValues(def: def, event: event), !newEntries.isEmpty { + entries.insert(newEntries[0], at: 0) + entries.append(contentsOf: newEntries.dropFirst()) + } else if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { Self.insertCmuxHookValues(newEntries, into: &entries, atOriginalIndexes: insertionIndexes) } else if Self.shouldPrependNewCmuxHookValues(def: def, event: event) { entries.insert(contentsOf: newEntries, at: 0) @@ -28574,6 +28583,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { def.name == "copilot" && event == "permissionRequest" } + private static func shouldBracketCmuxHookValues(def: AgentHookDef, event: String) -> Bool { + def.name == "copilot" && event == "permissionRequest" + } + private static func insertCmuxHookValues(_ values: [T], into target: inout [T], atOriginalIndexes indexes: [Int]) { var insertedCount = 0 for originalIndex in indexes { @@ -31127,6 +31140,120 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return "fallback-\(String(digest.prefix(16)))" } + private func fallbackFeedRequestId( + source: String, + sessionId: String, + rawEvent: String, + toolName: String, + rawObject: [String: Any] + ) -> String { + guard source == "copilot" else { + return "\(source)-\(sessionId)-\(rawEvent)-\(toolName)-\(Int(Date().timeIntervalSince1970 * 1000))" + } + var stableObject = rawObject + stableObject.removeValue(forKey: "timestamp") + let seedData = (try? JSONSerialization.data( + withJSONObject: stableObject, + options: [.sortedKeys] + )) ?? Data(String(describing: stableObject).utf8) + var seed = Data() + seed.append(Data(source.utf8)) + seed.append(0) + seed.append(Data(sessionId.utf8)) + seed.append(0) + seed.append(Data(rawEvent.utf8)) + seed.append(0) + seed.append(Data(toolName.utf8)) + seed.append(0) + seed.append(seedData) + let digest = SHA256.hash(data: seed) + .map { String(format: "%02x", $0) } + .joined() + return "\(source)-permission-\(String(digest.prefix(24)))" + } + + private func copilotPermissionDecisionFileURL( + requestId: String, + env: [String: String] + ) -> URL { + let baseDirectory: URL + if let overrideDirectory = normalizedHookValue(env["CMUX_AGENT_HOOK_STATE_DIR"]) { + baseDirectory = URL( + fileURLWithPath: NSString(string: overrideDirectory).expandingTildeInPath, + isDirectory: true + ) + } else { + baseDirectory = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cmuxterm", isDirectory: true) + } + let digest = SHA256.hash(data: Data(requestId.utf8)) + .map { String(format: "%02x", $0) } + .joined() + return baseDirectory + .appendingPathComponent("copilot-permission-decisions", isDirectory: true) + .appendingPathComponent("\(digest).json", isDirectory: false) + } + + private func recordCopilotPermissionDecisionIfNeeded( + source: String, + rawEventName: String, + requestId: String, + decision: [String: Any], + env: [String: String] + ) { + guard Self.isCopilotPermissionRequest(source: source, rawEventName: rawEventName) else { + return + } + let fileURL = copilotPermissionDecisionFileURL(requestId: requestId, env: env) + let mode = ((decision["mode"] as? String) ?? "deny") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if mode == "once" { + try? FileManager.default.removeItem(at: fileURL) + return + } + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let payload: [String: Any] = [ + "mode": "deny", + "updatedAt": Date().timeIntervalSince1970, + ] + let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + try data.write(to: fileURL, options: .atomic) + } catch { + try? FileManager.default.removeItem(at: fileURL) + } + } + + private func copilotPermissionDenyGuardOutput( + source: String, + rawEventName: String, + requestId: String, + env: [String: String] + ) -> String { + guard Self.isCopilotPermissionRequest(source: source, rawEventName: rawEventName) else { + return "{}" + } + let fileURL = copilotPermissionDecisionFileURL(requestId: requestId, env: env) + defer { try? FileManager.default.removeItem(at: fileURL) } + guard let data = try? Data(contentsOf: fileURL), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + (payload["mode"] as? String) == "deny" else { + return "{}" + } + let updatedAt = payload["updatedAt"] as? TimeInterval ?? 0 + guard Date().timeIntervalSince1970 - updatedAt <= 600 else { + return "{}" + } + return Self.encodeHookOutput([ + "behavior": "deny", + "message": Self.copilotPermissionDeniedReason(), + ]) + } + private func enrichUserPromptSubmitFeedEvent( _ event: inout [String: Any], hookEventName: String, @@ -32984,9 +33111,25 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ) let requestId = stdinObj["_opencode_request_id"] as? String ?? firstString(in: stdinObj, keys: ["request_id", "tool_use_id", "toolUseID"]) - ?? "\(source)-\(sessionId)-\(rawEvent)-\(toolName)-\(Int(Date().timeIntervalSince1970 * 1000))" + ?? fallbackFeedRequestId( + source: source, + sessionId: sessionId, + rawEvent: rawEvent, + toolName: toolName, + rawObject: stdinObj + ) eventDict["_opencode_request_id"] = requestId + if commandArgs.contains("--copilot-deny-guard") { + print(copilotPermissionDenyGuardOutput( + source: source, + rawEventName: rawEvent, + requestId: requestId, + env: env + )) + return + } + // Sync. For actionable events we block up to 120s waiting // for the user's Feed click; the hook's stdout is then a // proper hookSpecificOutput that Claude honors directly @@ -33078,6 +33221,13 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if source == "kiro", Self.emitKiroDecisionIfHandled(decision: decision) { return } + recordCopilotPermissionDecisionIfNeeded( + source: source, + rawEventName: rawEvent, + requestId: requestId, + decision: decision, + env: env + ) let out = Self.renderAgentDecision( source: source, rawEventName: rawEvent, @@ -33138,6 +33288,25 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { exit(2) } + private static func isCopilotPermissionRequest(source: String, rawEventName: String) -> Bool { + source == "copilot" && (rawEventName == "permissionRequest" || rawEventName == "PermissionRequest") + } + + private static func copilotPermissionDeniedReason() -> String { + String( + localized: "cli.hooks.feed.permissionDeniedReason", + defaultValue: "User denied permission via cmux Feed." + ) + } + + private static func encodeHookOutput(_ obj: [String: Any]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]), + let string = String(data: data, encoding: .utf8) else { + return "{}" + } + return string + } + private static let skipInterviewAndPlanAnswer = "Skip interview and plan immediately" /// Encodes the user's decision in the agent's expected hook stdout shape. private static func renderAgentDecision( @@ -33253,10 +33422,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return encode(permissionRequestHookDecision(behavior: "allow")) } if source == "copilot" { - let reason = String( - localized: "cli.hooks.feed.permissionDeniedReason", - defaultValue: "User denied permission via cmux Feed." - ) + let reason = Self.copilotPermissionDeniedReason() if rawEventName == "permissionRequest" || rawEventName == "PermissionRequest" { if mode == "deny" { return encode(["behavior": "deny", "message": reason]) diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 3b8105398440..b6220959b7f4 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -140,16 +140,22 @@ struct CLICopilotHookFeedTests { ) let permissionRequest = try #require(hooks["permissionRequest"] as? [[String: Any]]) #expect( - (permissionRequest.first?["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true, - "Expected cmux permissionRequest hook to run before preserved policy hooks, saw \(permissionRequest)" + (permissionRequest.first?["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true + && (permissionRequest.first?["bash"] as? String)?.contains("--copilot-deny-guard") == false, + "Expected cmux permissionRequest Feed hook to run before preserved policy hooks, saw \(permissionRequest)" ) #expect( - (permissionRequest.last?["bash"] as? String)?.contains("\"behavior\":\"deny\"") == true, - "Expected existing permissionRequest policy hook to stay after cmux so Copilot can merge it later, saw \(permissionRequest)" + permissionRequest.contains { ($0["bash"] as? String)?.contains("\"behavior\":\"deny\"") == true }, + "Expected existing permissionRequest policy hook to be preserved between cmux hooks, saw \(permissionRequest)" + ) + #expect( + (permissionRequest.last?["bash"] as? String)?.contains("--copilot-deny-guard") == true, + "Expected cmux permissionRequest denial guard to run after preserved policy hooks, saw \(permissionRequest)" ) #expect( permissionRequest.contains { ($0["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true + && ($0["bash"] as? String)?.contains("--copilot-deny-guard") == false && ($0["type"] as? String) == "command" && ($0["timeoutSec"] as? Int) == 125 && $0["command"] == nil @@ -157,6 +163,16 @@ struct CLICopilotHookFeedTests { }, "Expected direct permissionRequest bash hook with timeout slack, saw \(permissionRequest)" ) + #expect( + permissionRequest.contains { + ($0["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest --copilot-deny-guard") == true + && ($0["type"] as? String) == "command" + && ($0["timeoutSec"] as? Int) == 5 + && $0["command"] == nil + && $0["hooks"] == nil + }, + "Expected direct permissionRequest denial guard hook with short timeout, saw \(permissionRequest)" + ) } @Test func copilotFeedDecisionEmitsCopilotPermissionDecisions() throws { @@ -206,6 +222,7 @@ struct CLICopilotHookFeedTests { "CMUX_WORKSPACE_ID": workspaceId, "CMUX_SURFACE_ID": surfaceId, "CMUX_COPILOT_PID": "525252", + "CMUX_AGENT_HOOK_STATE_DIR": root.path, "CMUX_CLI_SENTRY_DISABLED": "1", ], standardInput: #"{"sessionId":"copilot-session-123","cwd":"\#(root.path)","toolName":"bash","toolArgs":{"command":"touch \#(root.appendingPathComponent("README.md").path)"}}"#, @@ -226,6 +243,83 @@ struct CLICopilotHookFeedTests { return (result, try #require(feedEvents.first)) } + func runCopilotPermissionRequestWithGuard(mode: String) throws -> (ProcessRunResult, ProcessRunResult, [String: Any]) { + let cliPath = try Self.bundledCLIPath() + let socketPath = Self.makeSocketPath("copilot-feed-guard") + let listenerFD = try Self.bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-copilot-feed-guard-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "33333333-3333-3333-3333-333333333333" + let surfaceId = "44444444-4444-4444-4444-444444444444" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + let server = Self.startMockServer(listenerFD: listenerFD, state: state) { line in + guard let payload = Self.jsonObject(line) else { + return Self.malformedRequestResponse(raw: line) + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return Self.malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + #expect(method == "feed.push") + return Self.v2Response( + id: id, + ok: true, + result: [ + "status": "resolved", + "decision": ["kind": "permission", "mode": mode], + ] + ) + } + + let input = #"{"sessionId":"copilot-session-123","cwd":"\#(root.path)","toolName":"bash","toolArgs":{"command":"touch \#(root.appendingPathComponent("README.md").path)"}}"# + let environment = [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_COPILOT_PID": "525252", + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + let first = Self.runProcess( + executablePath: cliPath, + arguments: ["hooks", "feed", "--source", "copilot", "--event", "permissionRequest"], + environment: environment, + standardInput: input, + timeout: 5 + ) + #expect(server.wait(timeout: 5), "socket server did not observe feed.push") + + let feedEvents = state.snapshot().compactMap { command -> [String: Any]? in + guard let payload = Self.jsonObject(command), + payload["method"] as? String == "feed.push", + let params = payload["params"] as? [String: Any], + let event = params["event"] as? [String: Any] else { + return nil + } + return event + } + let firstEvent = try #require(feedEvents.first) + + let guardResult = Self.runProcess( + executablePath: cliPath, + arguments: ["hooks", "feed", "--source", "copilot", "--event", "permissionRequest", "--copilot-deny-guard"], + environment: environment, + standardInput: input, + timeout: 5 + ) + return (first, guardResult, firstEvent) + } + // Fresh installs answer Copilot's permission service through // permissionRequest. Keep unmarked preToolUse decision support for // already-installed hook files and manual invocations. @@ -255,7 +349,7 @@ struct CLICopilotHookFeedTests { #expect(unsupportedOutput["permissionDecisionReason"] as? String == "User denied permission via cmux Feed.") #expect(unsupportedOutput["hookSpecificOutput"] == nil) - let (permissionAllow, permissionAllowEvent) = try runCopilotDecision(mode: "once", event: "permissionRequest") + let (permissionAllow, permissionAllowGuard, permissionAllowEvent) = try runCopilotPermissionRequestWithGuard(mode: "once") #expect(!permissionAllow.timedOut, Comment(rawValue: permissionAllow.stderr)) #expect(permissionAllow.status == 0, Comment(rawValue: permissionAllow.stderr)) #expect(permissionAllowEvent["hook_event_name"] as? String == "PermissionRequest") @@ -263,8 +357,12 @@ struct CLICopilotHookFeedTests { #expect(permissionAllowOutput["behavior"] as? String == "allow") #expect(permissionAllowOutput["permissionDecision"] == nil) #expect(permissionAllowOutput["hookSpecificOutput"] == nil) + #expect(!permissionAllowGuard.timedOut, Comment(rawValue: permissionAllowGuard.stderr)) + #expect(permissionAllowGuard.status == 0, Comment(rawValue: permissionAllowGuard.stderr)) + let permissionAllowGuardOutput = try #require(Self.jsonObject(permissionAllowGuard.stdout)) + #expect(permissionAllowGuardOutput.isEmpty) - let (permissionDeny, _) = try runCopilotDecision(mode: "deny", event: "permissionRequest") + let (permissionDeny, permissionDenyGuard, _) = try runCopilotPermissionRequestWithGuard(mode: "deny") #expect(!permissionDeny.timedOut, Comment(rawValue: permissionDeny.stderr)) #expect(permissionDeny.status == 0, Comment(rawValue: permissionDeny.stderr)) let permissionDenyOutput = try #require(Self.jsonObject(permissionDeny.stdout)) @@ -272,8 +370,13 @@ struct CLICopilotHookFeedTests { #expect(permissionDenyOutput["message"] as? String == "User denied permission via cmux Feed.") #expect(permissionDenyOutput["permissionDecision"] == nil) #expect(permissionDenyOutput["hookSpecificOutput"] == nil) + #expect(!permissionDenyGuard.timedOut, Comment(rawValue: permissionDenyGuard.stderr)) + #expect(permissionDenyGuard.status == 0, Comment(rawValue: permissionDenyGuard.stderr)) + let permissionDenyGuardOutput = try #require(Self.jsonObject(permissionDenyGuard.stdout)) + #expect(permissionDenyGuardOutput["behavior"] as? String == "deny") + #expect(permissionDenyGuardOutput["message"] as? String == "User denied permission via cmux Feed.") - let (permissionUnsupported, _) = try runCopilotDecision(mode: "always", event: "permissionRequest") + let (permissionUnsupported, permissionUnsupportedGuard, _) = try runCopilotPermissionRequestWithGuard(mode: "always") #expect(!permissionUnsupported.timedOut, Comment(rawValue: permissionUnsupported.stderr)) #expect(permissionUnsupported.status == 0, Comment(rawValue: permissionUnsupported.stderr)) let permissionUnsupportedOutput = try #require(Self.jsonObject(permissionUnsupported.stdout)) @@ -281,6 +384,11 @@ struct CLICopilotHookFeedTests { #expect(permissionUnsupportedOutput["message"] as? String == "User denied permission via cmux Feed.") #expect(permissionUnsupportedOutput["permissionDecision"] == nil) #expect(permissionUnsupportedOutput["hookSpecificOutput"] == nil) + #expect(!permissionUnsupportedGuard.timedOut, Comment(rawValue: permissionUnsupportedGuard.stderr)) + #expect(permissionUnsupportedGuard.status == 0, Comment(rawValue: permissionUnsupportedGuard.stderr)) + let permissionUnsupportedGuardOutput = try #require(Self.jsonObject(permissionUnsupportedGuard.stdout)) + #expect(permissionUnsupportedGuardOutput["behavior"] as? String == "deny") + #expect(permissionUnsupportedGuardOutput["message"] as? String == "User denied permission via cmux Feed.") } @Test func copilotErrorNotificationUsesStructuredErrorPayload() throws { From a5ef39447d0ebf648cf07f4c6c69e356755f23ca Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 07:56:44 -0700 Subject: [PATCH 31/39] fix: gate Copilot after native permissions --- .github/swift-file-length-budget.tsv | 4 +- CLI/CMUXCLI+AgentHookDefinitions.swift | 2 +- CLI/cmux.swift | 188 +++---------------- cmuxTests/CLICopilotHookFeedTests.swift | 178 ++++-------------- cmuxTests/FeedEventClassificationTests.swift | 8 +- docs/agent-hooks.md | 2 +- docs/feed.md | 2 +- 7 files changed, 70 insertions(+), 314 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 48a9064c4307..a48ff663add4 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34530 CLI/cmux.swift +34392 CLI/cmux.swift 17719 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift @@ -123,7 +123,6 @@ 754 Sources/TerminalController+ControlWorkspaceContext.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift -731 cmuxTests/CLICopilotHookFeedTests.swift 726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 722 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift @@ -157,6 +156,7 @@ 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift 629 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +625 cmuxTests/CLICopilotHookFeedTests.swift 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 34ee50bacb8e..8489099595fc 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -322,7 +322,7 @@ extension CMUXCLI { .init(agentEvent: "notification", cmuxSubcommand: "notification"), .init(agentEvent: "sessionEnd", cmuxSubcommand: "session-end"), ], - feedHookEvents: ["permissionRequest"] + feedHookEvents: ["preToolUse"] ), AgentHookDef( name: "codebuddy", displayName: "CodeBuddy", statusKey: "codebuddy", diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4d2ca04b0a24..94f5dee7558e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -27018,12 +27018,6 @@ struct CMUXCLI { command: feedCmd, timeoutSeconds: timeoutSeconds + (feedTimeoutMs >= 120_000 ? 5 : 0) )) - if def.name == "copilot", agentEvent == "permissionRequest" { - entries.append(Self.copilotHookEntry( - command: "\(feedCmd) --copilot-deny-guard", - timeoutSeconds: Self.timeoutSecondsFromMilliseconds(5_000) - )) - } result[agentEvent] = entries case .antigravityJSON: var entries = result[agentEvent] as? [[String: Any]] ?? [] @@ -28213,10 +28207,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { case .flat, .kiroAgentJSON, .copilotJSON: var entries = hooks[event] as? [[String: Any]] ?? [] if let newEntries = value as? [[String: Any]] { - if Self.shouldBracketCmuxHookValues(def: def, event: event), !newEntries.isEmpty { - entries.insert(newEntries[0], at: 0) - entries.append(contentsOf: newEntries.dropFirst()) - } else if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { + if let insertionIndexes = cmuxInsertionIndexes[event], !insertionIndexes.isEmpty { Self.insertCmuxHookValues(newEntries, into: &entries, atOriginalIndexes: insertionIndexes) } else if Self.shouldPrependNewCmuxHookValues(def: def, event: event) { entries.insert(contentsOf: newEntries, at: 0) @@ -28583,10 +28574,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { def.name == "copilot" && event == "permissionRequest" } - private static func shouldBracketCmuxHookValues(def: AgentHookDef, event: String) -> Bool { - def.name == "copilot" && event == "permissionRequest" - } - private static func insertCmuxHookValues(_ values: [T], into target: inout [T], atOriginalIndexes indexes: [Int]) { var insertedCount = 0 for originalIndex in indexes { @@ -31140,120 +31127,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return "fallback-\(String(digest.prefix(16)))" } - private func fallbackFeedRequestId( - source: String, - sessionId: String, - rawEvent: String, - toolName: String, - rawObject: [String: Any] - ) -> String { - guard source == "copilot" else { - return "\(source)-\(sessionId)-\(rawEvent)-\(toolName)-\(Int(Date().timeIntervalSince1970 * 1000))" - } - var stableObject = rawObject - stableObject.removeValue(forKey: "timestamp") - let seedData = (try? JSONSerialization.data( - withJSONObject: stableObject, - options: [.sortedKeys] - )) ?? Data(String(describing: stableObject).utf8) - var seed = Data() - seed.append(Data(source.utf8)) - seed.append(0) - seed.append(Data(sessionId.utf8)) - seed.append(0) - seed.append(Data(rawEvent.utf8)) - seed.append(0) - seed.append(Data(toolName.utf8)) - seed.append(0) - seed.append(seedData) - let digest = SHA256.hash(data: seed) - .map { String(format: "%02x", $0) } - .joined() - return "\(source)-permission-\(String(digest.prefix(24)))" - } - - private func copilotPermissionDecisionFileURL( - requestId: String, - env: [String: String] - ) -> URL { - let baseDirectory: URL - if let overrideDirectory = normalizedHookValue(env["CMUX_AGENT_HOOK_STATE_DIR"]) { - baseDirectory = URL( - fileURLWithPath: NSString(string: overrideDirectory).expandingTildeInPath, - isDirectory: true - ) - } else { - baseDirectory = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".cmuxterm", isDirectory: true) - } - let digest = SHA256.hash(data: Data(requestId.utf8)) - .map { String(format: "%02x", $0) } - .joined() - return baseDirectory - .appendingPathComponent("copilot-permission-decisions", isDirectory: true) - .appendingPathComponent("\(digest).json", isDirectory: false) - } - - private func recordCopilotPermissionDecisionIfNeeded( - source: String, - rawEventName: String, - requestId: String, - decision: [String: Any], - env: [String: String] - ) { - guard Self.isCopilotPermissionRequest(source: source, rawEventName: rawEventName) else { - return - } - let fileURL = copilotPermissionDecisionFileURL(requestId: requestId, env: env) - let mode = ((decision["mode"] as? String) ?? "deny") - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - if mode == "once" { - try? FileManager.default.removeItem(at: fileURL) - return - } - do { - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - let payload: [String: Any] = [ - "mode": "deny", - "updatedAt": Date().timeIntervalSince1970, - ] - let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) - try data.write(to: fileURL, options: .atomic) - } catch { - try? FileManager.default.removeItem(at: fileURL) - } - } - - private func copilotPermissionDenyGuardOutput( - source: String, - rawEventName: String, - requestId: String, - env: [String: String] - ) -> String { - guard Self.isCopilotPermissionRequest(source: source, rawEventName: rawEventName) else { - return "{}" - } - let fileURL = copilotPermissionDecisionFileURL(requestId: requestId, env: env) - defer { try? FileManager.default.removeItem(at: fileURL) } - guard let data = try? Data(contentsOf: fileURL), - let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - (payload["mode"] as? String) == "deny" else { - return "{}" - } - let updatedAt = payload["updatedAt"] as? TimeInterval ?? 0 - guard Date().timeIntervalSince1970 - updatedAt <= 600 else { - return "{}" - } - return Self.encodeHookOutput([ - "behavior": "deny", - "message": Self.copilotPermissionDeniedReason(), - ]) - } - private func enrichUserPromptSubmitFeedEvent( _ event: inout [String: Any], hookEventName: String, @@ -33111,23 +32984,25 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ) let requestId = stdinObj["_opencode_request_id"] as? String ?? firstString(in: stdinObj, keys: ["request_id", "tool_use_id", "toolUseID"]) - ?? fallbackFeedRequestId( - source: source, - sessionId: sessionId, - rawEvent: rawEvent, - toolName: toolName, - rawObject: stdinObj - ) + ?? "\(source)-\(sessionId)-\(rawEvent)-\(toolName)-\(Int(Date().timeIntervalSince1970 * 1000))" eventDict["_opencode_request_id"] = requestId - if commandArgs.contains("--copilot-deny-guard") { - print(copilotPermissionDenyGuardOutput( + func emitCopilotPreToolUseDenyIfNeeded() -> Bool { + guard source == "copilot", + isActionable, + rawEvent == "preToolUse" || rawEvent == "PreToolUse" else { + return false + } + print(Self.renderAgentDecision( source: source, rawEventName: rawEvent, - requestId: requestId, - env: env + hookEventName: hookEventName, + toolName: toolName, + toolInput: eventDict["tool_input"], + rawObject: stdinObj, + decision: ["kind": "permission", "mode": "deny"] )) - return + return true } // Sync. For actionable events we block up to 120s waiting @@ -33203,6 +33078,9 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { responseTimeout: waitTimeout + 5 ) } catch { + if emitCopilotPreToolUseDenyIfNeeded() { + return + } print("{}") return } @@ -33212,6 +33090,9 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { let ok = respObj["ok"] as? Bool, ok, let result = respObj["result"] as? [String: Any] else { + if emitCopilotPreToolUseDenyIfNeeded() { + return + } print("{}") return } @@ -33221,13 +33102,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if source == "kiro", Self.emitKiroDecisionIfHandled(decision: decision) { return } - recordCopilotPermissionDecisionIfNeeded( - source: source, - rawEventName: rawEvent, - requestId: requestId, - decision: decision, - env: env - ) let out = Self.renderAgentDecision( source: source, rawEventName: rawEvent, @@ -33240,6 +33114,9 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { print(out) return } + if emitCopilotPreToolUseDenyIfNeeded() { + return + } print("{}") } @@ -33288,10 +33165,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { exit(2) } - private static func isCopilotPermissionRequest(source: String, rawEventName: String) -> Bool { - source == "copilot" && (rawEventName == "permissionRequest" || rawEventName == "PermissionRequest") - } - private static func copilotPermissionDeniedReason() -> String { String( localized: "cli.hooks.feed.permissionDeniedReason", @@ -33299,14 +33172,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ) } - private static func encodeHookOutput(_ obj: [String: Any]) -> String { - guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]), - let string = String(data: data, encoding: .utf8) else { - return "{}" - } - return string - } - private static let skipInterviewAndPlanAnswer = "Skip interview and plan immediately" /// Encodes the user's decision in the agent's expected hook stdout shape. private static func renderAgentDecision( @@ -33427,10 +33292,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { if mode == "deny" { return encode(["behavior": "deny", "message": reason]) } - if mode == "once" { - return encode(["behavior": "allow"]) - } - return encode(["behavior": "deny", "message": reason]) + return "{}" } if mode == "once" { return encode(["permissionDecision": "allow"]) diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index b6220959b7f4..f7d26256d0e4 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -117,8 +117,19 @@ struct CLICopilotHookFeedTests { "Expected user preToolUse hook to be preserved, saw \(preservedPreToolUse)" ) #expect( - !preservedPreToolUse.contains { ($0["bash"] as? String)?.contains("cmux hooks feed --source copilot --event preToolUse") == true }, - "Copilot command preToolUse hooks fail closed, so cmux must remove stale telemetry hooks, saw \(preservedPreToolUse)" + !preservedPreToolUse.contains { ($0["bash"] as? String)?.contains("--telemetry-only") == true }, + "Expected stale cmux telemetry-only preToolUse hook to be removed, saw \(preservedPreToolUse)" + ) + #expect( + preservedPreToolUse.contains { + ($0["bash"] as? String)?.contains("hooks feed --source copilot --event preToolUse") == true + && ($0["bash"] as? String)?.contains("--telemetry-only") == false + && ($0["type"] as? String) == "command" + && ($0["timeoutSec"] as? Int) == 125 + && $0["command"] == nil + && $0["hooks"] == nil + }, + "Expected direct preToolUse Feed hook with timeout slack, saw \(preservedPreToolUse)" ) let errorOccurred = try #require(hooks["errorOccurred"] as? [[String: Any]]) #expect( @@ -140,43 +151,18 @@ struct CLICopilotHookFeedTests { ) let permissionRequest = try #require(hooks["permissionRequest"] as? [[String: Any]]) #expect( - (permissionRequest.first?["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true - && (permissionRequest.first?["bash"] as? String)?.contains("--copilot-deny-guard") == false, - "Expected cmux permissionRequest Feed hook to run before preserved policy hooks, saw \(permissionRequest)" - ) - #expect( - permissionRequest.contains { ($0["bash"] as? String)?.contains("\"behavior\":\"deny\"") == true }, - "Expected existing permissionRequest policy hook to be preserved between cmux hooks, saw \(permissionRequest)" - ) - #expect( - (permissionRequest.last?["bash"] as? String)?.contains("--copilot-deny-guard") == true, - "Expected cmux permissionRequest denial guard to run after preserved policy hooks, saw \(permissionRequest)" - ) - #expect( - permissionRequest.contains { - ($0["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest") == true - && ($0["bash"] as? String)?.contains("--copilot-deny-guard") == false - && ($0["type"] as? String) == "command" - && ($0["timeoutSec"] as? Int) == 125 - && $0["command"] == nil - && $0["hooks"] == nil - }, - "Expected direct permissionRequest bash hook with timeout slack, saw \(permissionRequest)" - ) - #expect( - permissionRequest.contains { - ($0["bash"] as? String)?.contains("hooks feed --source copilot --event permissionRequest --copilot-deny-guard") == true - && ($0["type"] as? String) == "command" - && ($0["timeoutSec"] as? Int) == 5 - && $0["command"] == nil - && $0["hooks"] == nil - }, - "Expected direct permissionRequest denial guard hook with short timeout, saw \(permissionRequest)" + permissionRequest.count == 1 + && (permissionRequest.first?["bash"] as? String)?.contains("\"behavior\":\"deny\"") == true, + "Expected existing permissionRequest policy hook to be preserved without cmux permissionRequest hooks, saw \(permissionRequest)" ) } @Test func copilotFeedDecisionEmitsCopilotPermissionDecisions() throws { - func runCopilotDecision(mode: String, event: String = "preToolUse") throws -> (ProcessRunResult, [String: Any]) { + func runCopilotDecision( + mode: String, + event: String = "preToolUse", + status: String = "resolved" + ) throws -> (ProcessRunResult, [String: Any]) { let cliPath = try Self.bundledCLIPath() let socketPath = Self.makeSocketPath("copilot-feed-decision") let listenerFD = try Self.bindUnixSocket(at: socketPath) @@ -205,7 +191,7 @@ struct CLICopilotHookFeedTests { id: id, ok: true, result: [ - "status": "resolved", + "status": status, "decision": ["kind": "permission", "mode": mode], ] ) @@ -243,86 +229,9 @@ struct CLICopilotHookFeedTests { return (result, try #require(feedEvents.first)) } - func runCopilotPermissionRequestWithGuard(mode: String) throws -> (ProcessRunResult, ProcessRunResult, [String: Any]) { - let cliPath = try Self.bundledCLIPath() - let socketPath = Self.makeSocketPath("copilot-feed-guard") - let listenerFD = try Self.bindUnixSocket(at: socketPath) - let state = MockSocketServerState() - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-copilot-feed-guard-\(UUID().uuidString)", isDirectory: true) - let workspaceId = "33333333-3333-3333-3333-333333333333" - let surfaceId = "44444444-4444-4444-4444-444444444444" - - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { - Darwin.close(listenerFD) - unlink(socketPath) - try? FileManager.default.removeItem(at: root) - } - - let server = Self.startMockServer(listenerFD: listenerFD, state: state) { line in - guard let payload = Self.jsonObject(line) else { - return Self.malformedRequestResponse(raw: line) - } - guard let id = payload["id"] as? String, let method = payload["method"] as? String else { - return Self.malformedRequestResponse(id: payload["id"] as? String, raw: line) - } - #expect(method == "feed.push") - return Self.v2Response( - id: id, - ok: true, - result: [ - "status": "resolved", - "decision": ["kind": "permission", "mode": mode], - ] - ) - } - - let input = #"{"sessionId":"copilot-session-123","cwd":"\#(root.path)","toolName":"bash","toolArgs":{"command":"touch \#(root.appendingPathComponent("README.md").path)"}}"# - let environment = [ - "HOME": root.path, - "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", - "PWD": root.path, - "CMUX_SOCKET_PATH": socketPath, - "CMUX_WORKSPACE_ID": workspaceId, - "CMUX_SURFACE_ID": surfaceId, - "CMUX_COPILOT_PID": "525252", - "CMUX_AGENT_HOOK_STATE_DIR": root.path, - "CMUX_CLI_SENTRY_DISABLED": "1", - ] - let first = Self.runProcess( - executablePath: cliPath, - arguments: ["hooks", "feed", "--source", "copilot", "--event", "permissionRequest"], - environment: environment, - standardInput: input, - timeout: 5 - ) - #expect(server.wait(timeout: 5), "socket server did not observe feed.push") - - let feedEvents = state.snapshot().compactMap { command -> [String: Any]? in - guard let payload = Self.jsonObject(command), - payload["method"] as? String == "feed.push", - let params = payload["params"] as? [String: Any], - let event = params["event"] as? [String: Any] else { - return nil - } - return event - } - let firstEvent = try #require(feedEvents.first) - - let guardResult = Self.runProcess( - executablePath: cliPath, - arguments: ["hooks", "feed", "--source", "copilot", "--event", "permissionRequest", "--copilot-deny-guard"], - environment: environment, - standardInput: input, - timeout: 5 - ) - return (first, guardResult, firstEvent) - } - - // Fresh installs answer Copilot's permission service through - // permissionRequest. Keep unmarked preToolUse decision support for - // already-installed hook files and manual invocations. + // Fresh installs gate after Copilot's native permission service through + // preToolUse. Keep permissionRequest denial support for already-installed + // hook files and manual invocations, but approvals fall through there. let (allow, allowEvent) = try runCopilotDecision(mode: "once") #expect(!allow.timedOut, Comment(rawValue: allow.stderr)) #expect(allow.status == 0, Comment(rawValue: allow.stderr)) @@ -349,20 +258,24 @@ struct CLICopilotHookFeedTests { #expect(unsupportedOutput["permissionDecisionReason"] as? String == "User denied permission via cmux Feed.") #expect(unsupportedOutput["hookSpecificOutput"] == nil) - let (permissionAllow, permissionAllowGuard, permissionAllowEvent) = try runCopilotPermissionRequestWithGuard(mode: "once") + let (timeout, _) = try runCopilotDecision(mode: "once", status: "timeout") + #expect(!timeout.timedOut, Comment(rawValue: timeout.stderr)) + #expect(timeout.status == 0, Comment(rawValue: timeout.stderr)) + let timeoutOutput = try #require(Self.jsonObject(timeout.stdout)) + #expect(timeoutOutput["permissionDecision"] as? String == "deny") + #expect(timeoutOutput["permissionDecisionReason"] as? String == "User denied permission via cmux Feed.") + #expect(timeoutOutput["hookSpecificOutput"] == nil) + + let (permissionAllow, permissionAllowEvent) = try runCopilotDecision(mode: "once", event: "permissionRequest") #expect(!permissionAllow.timedOut, Comment(rawValue: permissionAllow.stderr)) #expect(permissionAllow.status == 0, Comment(rawValue: permissionAllow.stderr)) #expect(permissionAllowEvent["hook_event_name"] as? String == "PermissionRequest") let permissionAllowOutput = try #require(Self.jsonObject(permissionAllow.stdout)) - #expect(permissionAllowOutput["behavior"] as? String == "allow") + #expect(permissionAllowOutput.isEmpty) #expect(permissionAllowOutput["permissionDecision"] == nil) #expect(permissionAllowOutput["hookSpecificOutput"] == nil) - #expect(!permissionAllowGuard.timedOut, Comment(rawValue: permissionAllowGuard.stderr)) - #expect(permissionAllowGuard.status == 0, Comment(rawValue: permissionAllowGuard.stderr)) - let permissionAllowGuardOutput = try #require(Self.jsonObject(permissionAllowGuard.stdout)) - #expect(permissionAllowGuardOutput.isEmpty) - let (permissionDeny, permissionDenyGuard, _) = try runCopilotPermissionRequestWithGuard(mode: "deny") + let (permissionDeny, _) = try runCopilotDecision(mode: "deny", event: "permissionRequest") #expect(!permissionDeny.timedOut, Comment(rawValue: permissionDeny.stderr)) #expect(permissionDeny.status == 0, Comment(rawValue: permissionDeny.stderr)) let permissionDenyOutput = try #require(Self.jsonObject(permissionDeny.stdout)) @@ -370,25 +283,6 @@ struct CLICopilotHookFeedTests { #expect(permissionDenyOutput["message"] as? String == "User denied permission via cmux Feed.") #expect(permissionDenyOutput["permissionDecision"] == nil) #expect(permissionDenyOutput["hookSpecificOutput"] == nil) - #expect(!permissionDenyGuard.timedOut, Comment(rawValue: permissionDenyGuard.stderr)) - #expect(permissionDenyGuard.status == 0, Comment(rawValue: permissionDenyGuard.stderr)) - let permissionDenyGuardOutput = try #require(Self.jsonObject(permissionDenyGuard.stdout)) - #expect(permissionDenyGuardOutput["behavior"] as? String == "deny") - #expect(permissionDenyGuardOutput["message"] as? String == "User denied permission via cmux Feed.") - - let (permissionUnsupported, permissionUnsupportedGuard, _) = try runCopilotPermissionRequestWithGuard(mode: "always") - #expect(!permissionUnsupported.timedOut, Comment(rawValue: permissionUnsupported.stderr)) - #expect(permissionUnsupported.status == 0, Comment(rawValue: permissionUnsupported.stderr)) - let permissionUnsupportedOutput = try #require(Self.jsonObject(permissionUnsupported.stdout)) - #expect(permissionUnsupportedOutput["behavior"] as? String == "deny") - #expect(permissionUnsupportedOutput["message"] as? String == "User denied permission via cmux Feed.") - #expect(permissionUnsupportedOutput["permissionDecision"] == nil) - #expect(permissionUnsupportedOutput["hookSpecificOutput"] == nil) - #expect(!permissionUnsupportedGuard.timedOut, Comment(rawValue: permissionUnsupportedGuard.stderr)) - #expect(permissionUnsupportedGuard.status == 0, Comment(rawValue: permissionUnsupportedGuard.stderr)) - let permissionUnsupportedGuardOutput = try #require(Self.jsonObject(permissionUnsupportedGuard.stdout)) - #expect(permissionUnsupportedGuardOutput["behavior"] as? String == "deny") - #expect(permissionUnsupportedGuardOutput["message"] as? String == "User denied permission via cmux Feed.") } @Test func copilotErrorNotificationUsesStructuredErrorPayload() throws { diff --git a/cmuxTests/FeedEventClassificationTests.swift b/cmuxTests/FeedEventClassificationTests.swift index 02b64a209c69..e0a153ae8b14 100644 --- a/cmuxTests/FeedEventClassificationTests.swift +++ b/cmuxTests/FeedEventClassificationTests.swift @@ -93,10 +93,10 @@ struct FeedEventClassificationTests { #expect(classify("gemini", "PreToolUse", tool: "Read").actionable == false) } - /// Copilot's installed `permissionRequest` hook blocks before the native - /// permission service. Unmarked `preToolUse` still gates side-effecting - /// tools for legacy hook files and manual invocations; read-only pre-tool - /// events must fall through as telemetry. + /// Copilot's installed `preToolUse` hook gates side-effecting tools after + /// the native permission service; read-only pre-tool events must fall + /// through as telemetry. `permissionRequest` remains classifiable for + /// already-installed hook files and manual invocations. @Test func copilotPreToolUseIsApprovalRequest() { #expect(classify("copilot", "permissionRequest", tool: "bash").name == "PermissionRequest") #expect(classify("copilot", "permissionRequest", tool: "bash").actionable == true) diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index cc63fd97be53..b644092e6fff 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -28,7 +28,7 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `amp`, `curs | Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --resume ` | PreToolUse | | Kiro CLI | `kiro-cli` | `~/.kiro/agents/cmux.json` or `$KIRO_HOME/agents/cmux.json` | `kiro-cli chat --resume-id ` | preToolUse, postToolUse | | Rovo Dev | `acli` | `~/.rovodev/config.yml` | `acli rovodev run --restore ` | none | -| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | permissionRequest | +| Copilot | `copilot` | `~/.copilot/hooks/cmux.json` | `copilot --resume ` | preToolUse | | CodeBuddy | `codebuddy` | `~/.codebuddy/settings.json` | `codebuddy --resume ` | PreToolUse | | Factory | `droid` | `~/.factory/settings.json` | `droid --resume ` | PreToolUse | | Qoder | `qodercli` | `~/.qoder/settings.json` | `qodercli --resume ` | PreToolUse | diff --git a/docs/feed.md b/docs/feed.md index 133c6c168413..f3e2a9f602b7 100644 --- a/docs/feed.md +++ b/docs/feed.md @@ -80,7 +80,7 @@ Installs supported agent hooks whose binaries are on `PATH`. See [Agent hook int | OpenCode | `~/.config/opencode/plugins/cmux-feed.js` | plugin event bus | | Cursor CLI | `~/.cursor/hooks.json` | beforeShellExecution | | Gemini | `~/.gemini/settings.json` | PreToolUse | -| Copilot | `~/.copilot/hooks/cmux.json` | permissionRequest | +| Copilot | `~/.copilot/hooks/cmux.json` | preToolUse | | CodeBuddy | `~/.codebuddy/settings.json` | PreToolUse | | Factory | `~/.factory/settings.json` | PreToolUse | | Qoder | `~/.qoder/settings.json` | PreToolUse | From 4e89e4ca355b24a1f657c369ad586ce65051c033 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 08:05:26 -0700 Subject: [PATCH 32/39] fix: fail closed for Copilot hook wrapper --- .github/swift-file-length-budget.tsv | 4 ++-- CLI/CMUXCLI+AgentHookDefinitions.swift | 25 +++++++++++++++++++++++++ cmuxTests/CLICopilotHookFeedTests.swift | 24 ++++++++++++++++++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a48ff663add4..d588b288b726 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -123,12 +123,12 @@ 754 Sources/TerminalController+ControlWorkspaceContext.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift +735 CLI/CMUXCLI+AgentHookDefinitions.swift 726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 722 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift 715 Sources/AppleScriptSupport.swift -710 CLI/CMUXCLI+AgentHookDefinitions.swift 710 Sources/TerminalSSHSessionDetector.swift 706 CLI/CMUXCLI+Config.swift 705 Sources/Panels/BrowserPopupWindowController.swift @@ -152,11 +152,11 @@ 655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift +645 cmuxTests/CLICopilotHookFeedTests.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift 629 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift -625 cmuxTests/CLICopilotHookFeedTests.swift 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 8489099595fc..79e9e19dc356 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -383,6 +383,9 @@ extension CMUXCLI { static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String { let command = "cmux hooks feed --source \(def.name) --event \(agentEvent)" + if def.name == "copilot", agentEvent == "preToolUse" { + return copilotPreToolUseAgentHookShellCommand(command, for: def) + } switch def.format { case .kiroAgentJSON: return exitTwoPropagatingAgentHookShellCommand( @@ -403,6 +406,28 @@ extension CMUXCLI { return "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi; if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then { if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments); else \"$cmux_cli\" \(routedArguments); fi; } || echo '{}'; else echo '{}'; fi" } + private static func copilotPreToolUseAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String { + let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command + let denyOutput = shellSingleQuote(copilotPreToolUseDenyHookOutput()) + return "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi; if [ -z \"$CMUX_SURFACE_ID\" ] || [ \"$\(def.disableEnvVar)\" = \"1\" ]; then echo '{}'; elif [ -z \"$cmux_cli\" ]; then echo \(denyOutput); else { if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments); else \"$cmux_cli\" \(routedArguments); fi; } || echo \(denyOutput); fi" + } + + private static func copilotPreToolUseDenyHookOutput() -> String { + let reason = String( + localized: "cli.hooks.feed.permissionDeniedReason", + defaultValue: "User denied permission via cmux Feed." + ) + let payload = [ + "permissionDecision": "deny", + "permissionDecisionReason": reason, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), + let output = String(data: data, encoding: .utf8) else { + return #"{"permissionDecision":"deny","permissionDecisionReason":"User denied permission via cmux Feed."}"# + } + return output + } + private static func exitTwoPropagatingAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String { let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command return "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi; if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments); else \"$cmux_cli\" \(routedArguments); fi; status=$?; if [ \"$status\" -eq 2 ]; then exit 2; fi; if [ \"$status\" -ne 0 ]; then echo '{}'; fi; else echo '{}'; fi" diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index f7d26256d0e4..354e6cbd425b 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -120,8 +120,8 @@ struct CLICopilotHookFeedTests { !preservedPreToolUse.contains { ($0["bash"] as? String)?.contains("--telemetry-only") == true }, "Expected stale cmux telemetry-only preToolUse hook to be removed, saw \(preservedPreToolUse)" ) - #expect( - preservedPreToolUse.contains { + let cmuxPreToolUseEntry = try #require( + preservedPreToolUse.first { ($0["bash"] as? String)?.contains("hooks feed --source copilot --event preToolUse") == true && ($0["bash"] as? String)?.contains("--telemetry-only") == false && ($0["type"] as? String) == "command" @@ -131,6 +131,26 @@ struct CLICopilotHookFeedTests { }, "Expected direct preToolUse Feed hook with timeout slack, saw \(preservedPreToolUse)" ) + let cmuxPreToolUseCommand = try #require(cmuxPreToolUseEntry["bash"] as? String) + let unavailableCmuxRun = Self.runProcess( + executablePath: "/bin/sh", + arguments: ["-c", cmuxPreToolUseCommand], + environment: [ + "HOME": root.path, + "PATH": root.path, + "CMUX_BUNDLED_CLI_PATH": root.appendingPathComponent("missing-cmux").path, + "CMUX_SURFACE_ID": "surface:copilot-feed", + "CMUX_COPILOT_HOOKS_DISABLED": "0", + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + standardInput: #"{"sessionId":"copilot-session-123","toolName":"bash","toolArgs":{"command":"touch README.md"}}"#, + timeout: 5 + ) + #expect(!unavailableCmuxRun.timedOut, Comment(rawValue: unavailableCmuxRun.stderr)) + #expect(unavailableCmuxRun.status == 0, Comment(rawValue: unavailableCmuxRun.stderr)) + let unavailableCmuxOutput = try #require(Self.jsonObject(unavailableCmuxRun.stdout)) + #expect(unavailableCmuxOutput["permissionDecision"] as? String == "deny") + #expect(unavailableCmuxOutput["permissionDecisionReason"] as? String == "User denied permission via cmux Feed.") let errorOccurred = try #require(hooks["errorOccurred"] as? [[String: Any]]) #expect( errorOccurred.contains { From 1c12804507ea22a9c1904d7c511d4f494cc00586 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 08:14:04 -0700 Subject: [PATCH 33/39] fix: deny Copilot when Feed socket is unavailable --- .github/swift-file-length-budget.tsv | 4 +-- CLI/cmux.swift | 6 ++++ cmuxTests/CLICopilotHookFeedTests.swift | 37 +++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index ed9646385c24..bd0cf2980a74 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -34392 CLI/cmux.swift +34398 CLI/cmux.swift 17827 Sources/AppDelegate.swift 16112 Sources/ContentView.swift 13952 Sources/TerminalController.swift @@ -140,6 +140,7 @@ 688 cmuxTests/KeyboardShortcutContextTests.swift 683 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/SwiftViewInterpreter.swift 683 Sources/Panels/CodexAppServerSession.swift +682 cmuxTests/CLICopilotHookFeedTests.swift 681 Sources/Panels/AgentSessionProcessStore.swift 680 Sources/FileExplorerSearchController.swift 677 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Bootstrap.swift @@ -152,7 +153,6 @@ 655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 653 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift 650 Sources/Panels/MarkdownRemoteImageLoader.swift -645 cmuxTests/CLICopilotHookFeedTests.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift 636 Sources/TerminalController+ControlPaneContext.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 94f5dee7558e..84b92561ee9a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -33061,12 +33061,18 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { ) } catch { feedClient.close() + if emitCopilotPreToolUseDenyIfNeeded() { + return + } print("{}") return } ownedClient = feedClient activeClient = feedClient } else { + if emitCopilotPreToolUseDenyIfNeeded() { + return + } print("{}") return } diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 354e6cbd425b..0b357700a735 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -249,6 +249,35 @@ struct CLICopilotHookFeedTests { return (result, try #require(feedEvents.first)) } + func runCopilotDecisionWithUnavailableSocket() throws -> ProcessRunResult { + let cliPath = try Self.bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-copilot-feed-unavailable-\(UUID().uuidString)", isDirectory: true) + let workspaceId = "33333333-3333-3333-3333-333333333333" + let surfaceId = "44444444-4444-4444-4444-444444444444" + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + return Self.runProcess( + executablePath: cliPath, + arguments: ["hooks", "feed", "--source", "copilot", "--event", "preToolUse"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": root.appendingPathComponent("missing.sock").path, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_COPILOT_PID": "525252", + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ], + standardInput: #"{"sessionId":"copilot-session-123","cwd":"\#(root.path)","toolName":"bash","toolArgs":{"command":"touch \#(root.appendingPathComponent("README.md").path)"}}"#, + timeout: 5 + ) + } + // Fresh installs gate after Copilot's native permission service through // preToolUse. Keep permissionRequest denial support for already-installed // hook files and manual invocations, but approvals fall through there. @@ -286,6 +315,14 @@ struct CLICopilotHookFeedTests { #expect(timeoutOutput["permissionDecisionReason"] as? String == "User denied permission via cmux Feed.") #expect(timeoutOutput["hookSpecificOutput"] == nil) + let unavailable = try runCopilotDecisionWithUnavailableSocket() + #expect(!unavailable.timedOut, Comment(rawValue: unavailable.stderr)) + #expect(unavailable.status == 0, Comment(rawValue: unavailable.stderr)) + let unavailableOutput = try #require(Self.jsonObject(unavailable.stdout)) + #expect(unavailableOutput["permissionDecision"] as? String == "deny") + #expect(unavailableOutput["permissionDecisionReason"] as? String == "User denied permission via cmux Feed.") + #expect(unavailableOutput["hookSpecificOutput"] == nil) + let (permissionAllow, permissionAllowEvent) = try runCopilotDecision(mode: "once", event: "permissionRequest") #expect(!permissionAllow.timedOut, Comment(rawValue: permissionAllow.stderr)) #expect(permissionAllow.status == 0, Comment(rawValue: permissionAllow.stderr)) From 663f1b6775609964e5e9435b2fc91cae04440e8d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 09:07:16 -0700 Subject: [PATCH 34/39] test: stabilize SSH CLI output capture --- ...otifyProcessIntegrationRegressionTests.swift | 10 ++++------ cmuxTests/WorkspaceRemoteConnectionTests.swift | 17 ++++++++--------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 9651e0f5be0e..d97c8f05941f 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -3518,12 +3518,10 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { initialScript.contains("required terminal context missing for SSH PTY attach"), initialScript ) - XCTAssertTrue( - initialScript.contains("ssh-$cmux_ssh_pty_workspace_id-$cmux_ssh_pty_surface_id"), - initialScript - ) + XCTAssertTrue(initialScript.contains("ssh-$cmux_ssh_pty_workspace_id-$cmux_ssh_pty_surface_id"), initialScript) + let attachIDFlag = "--attachment-id \"$cmux_ssh_pty_surface_id\"" XCTAssertTrue(initialScript.contains("254|255"), initialScript) - XCTAssertFalse(initialScript.contains("-surface"), initialScript) + XCTAssertTrue(initialScript.split(separator: "\n").first { $0.contains("ssh-pty-attach") }.map { $0.contains(attachIDFlag) && !$0.contains("--surface") } == true, initialScript) XCTAssertTrue( initialScript.contains("--workspace \"$cmux_ssh_pty_workspace_id\""), initialScript @@ -3550,7 +3548,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { terminalStartupScript ) XCTAssertTrue(terminalStartupScript.contains("254|255"), terminalStartupScript) - XCTAssertFalse(terminalStartupScript.contains("-surface"), terminalStartupScript) + XCTAssertTrue(terminalStartupScript.split(separator: "\n").first { $0.contains("ssh-pty-attach") }.map { $0.contains(attachIDFlag) && !$0.contains("--surface") } == true, terminalStartupScript) XCTAssertTrue( terminalStartupScript.contains("--workspace \"$cmux_ssh_pty_workspace_id\""), terminalStartupScript diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 5ca9fa61f08f..a0c3cc1d5322 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -3885,25 +3885,23 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { timeout: TimeInterval ) -> ProcessRunResult { let process = Process() - let stdoutPipe = Pipe() + let stdoutURL = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-cli-stdout-\(UUID().uuidString)") + FileManager.default.createFile(atPath: stdoutURL.path, contents: nil) + guard let stdoutHandle = try? FileHandle(forWritingTo: stdoutURL) else { return ProcessRunResult(status: -1, stdout: "", stderr: "failed to create process output capture file", timedOut: false) } + defer { try? stdoutHandle.close(); try? FileManager.default.removeItem(at: stdoutURL) } let stderrPipe = Pipe() let stdinPipe = standardInput == nil ? nil : Pipe() process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments process.environment = environment process.standardInput = stdinPipe ?? FileHandle.nullDevice - process.standardOutput = stdoutPipe + process.standardOutput = stdoutHandle process.standardError = stderrPipe do { try process.run() } catch { - return ProcessRunResult( - status: -1, - stdout: "", - stderr: String(describing: error), - timedOut: false - ) + return ProcessRunResult(status: -1, stdout: "", stderr: String(describing: error), timedOut: false) } if let standardInput, let stdinPipe { stdinPipe.fileHandleForWriting.write(Data(standardInput.utf8)) @@ -3921,8 +3919,9 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { process.terminate() _ = exitSignal.wait(timeout: .now() + 1) } + try? stdoutHandle.close() - let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stdout = (try? String(contentsOf: stdoutURL, encoding: .utf8)) ?? "" let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" return ProcessRunResult( status: process.terminationStatus, From fb3e64b1fd9dff607d67a303860b3f1e36745eb3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 09:10:07 -0700 Subject: [PATCH 35/39] fix: scope Copilot preToolUse hook matcher --- CLI/FeedEventClassifier.swift | 3 +++ CLI/cmux.swift | 13 ++++++------- cmuxTests/CLICopilotHookFeedTests.swift | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CLI/FeedEventClassifier.swift b/CLI/FeedEventClassifier.swift index 888947d8226b..f0fc666faff2 100644 --- a/CLI/FeedEventClassifier.swift +++ b/CLI/FeedEventClassifier.swift @@ -328,6 +328,9 @@ struct FeedEventClassifier { "apply_patch", ] + static let copilotPreToolUseApprovalToolMatcher = + copilotSideEffectingToolAliases.union(["ask_user"]).sorted().joined(separator: "|") + /// Whether a tool mutates state and deserves an approval prompt. Exact /// match against ``sideEffectingTools`` for every source; `copilot` and /// `kiro` additionally match their case-insensitive internal aliases. diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 84b92561ee9a..52678d80a917 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -27016,7 +27016,8 @@ struct CMUXCLI { let timeoutSeconds = Self.timeoutSecondsFromMilliseconds(feedTimeoutMs) entries.append(Self.copilotHookEntry( command: feedCmd, - timeoutSeconds: timeoutSeconds + (feedTimeoutMs >= 120_000 ? 5 : 0) + timeoutSeconds: timeoutSeconds + (feedTimeoutMs >= 120_000 ? 5 : 0), + matcher: agentEvent == "preToolUse" ? FeedEventClassifier.copilotPreToolUseApprovalToolMatcher : nil )) result[agentEvent] = entries case .antigravityJSON: @@ -27056,12 +27057,10 @@ struct CMUXCLI { return ((positiveTimeoutMs - 1) / 1000) + 1 } - private static func copilotHookEntry(command: String, timeoutSeconds: Int) -> [String: Any] { - [ - "type": "command", - "bash": command, - "timeoutSec": max(timeoutSeconds, 1), - ] + private static func copilotHookEntry(command: String, timeoutSeconds: Int, matcher: String? = nil) -> [String: Any] { + var entry: [String: Any] = ["type": "command", "bash": command, "timeoutSec": max(timeoutSeconds, 1)] + if let matcher { entry["matcher"] = matcher } + return entry } private static func antigravityHookEntry( diff --git a/cmuxTests/CLICopilotHookFeedTests.swift b/cmuxTests/CLICopilotHookFeedTests.swift index 0b357700a735..49c93eab5439 100644 --- a/cmuxTests/CLICopilotHookFeedTests.swift +++ b/cmuxTests/CLICopilotHookFeedTests.swift @@ -125,7 +125,7 @@ struct CLICopilotHookFeedTests { ($0["bash"] as? String)?.contains("hooks feed --source copilot --event preToolUse") == true && ($0["bash"] as? String)?.contains("--telemetry-only") == false && ($0["type"] as? String) == "command" - && ($0["timeoutSec"] as? Int) == 125 + && ($0["timeoutSec"] as? Int) == 125 && ($0["matcher"] as? String) == FeedEventClassifier.copilotPreToolUseApprovalToolMatcher && $0["command"] == nil && $0["hooks"] == nil }, From b612558cb244307bd9ed5e432bc8e0dfb093dce5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 09:24:32 -0700 Subject: [PATCH 36/39] test: migrate touched legacy suites to Swift Testing --- .github/swift-file-length-budget.tsv | 4 +- ...ifyProcessIntegrationRegressionTests.swift | 255 +++++++++++++++- .../WorkspaceRemoteConnectionTests.swift | 274 +++++++++++++++++- 3 files changed, 526 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index d3006603ee16..24db48c94d9e 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -9,11 +9,11 @@ 12240 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift 11929 Sources/Panels/BrowserPanel.swift -9333 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +9584 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8017 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift +7485 cmuxTests/WorkspaceRemoteConnectionTests.swift 7354 cmuxTests/WorkspaceUnitTests.swift -7217 cmuxTests/WorkspaceRemoteConnectionTests.swift 6317 cmuxTests/SessionPersistenceTests.swift 6217 cmuxTests/GhosttyConfigTests.swift 6178 Sources/TabManager.swift diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index d97c8f05941f..d9a8b28d5ab2 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -1,12 +1,131 @@ -import XCTest import Darwin +import Foundation +import Testing #if canImport(cmux_DEV) @testable import cmux_DEV #elseif canImport(cmux) @testable import cmux #endif -final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { +private typealias XCTestExpectation = DispatchSemaphore + +private extension DispatchSemaphore { + func fulfill() { + signal() + } +} + +private func legacyTimeout(_ timeout: TimeInterval) -> DispatchTime { + .now() + .milliseconds(Int((timeout * 1_000).rounded(.up))) +} + +private func legacyFailure(_ message: String) { + Issue.record(Comment(rawValue: message.isEmpty ? "Assertion failed" : message)) +} + +private func expectation(description _: String) -> XCTestExpectation { + DispatchSemaphore(value: 0) +} + +private func wait(for expectations: [XCTestExpectation], timeout: TimeInterval) { + for expectation in expectations { + if expectation.wait(timeout: legacyTimeout(timeout)) == .timedOut { + legacyFailure("Timed out waiting for expectation") + } + } +} + +private func XCTFail(_ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + legacyFailure(message()) +} + +private func XCTAssertTrue(_ expression: @autoclosure () throws -> Bool, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + if try !expression() { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertFalse(_ expression: @autoclosure () throws -> Bool, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + if try expression() { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertEqual(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs != rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertNotEqual(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs == rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertNil(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + if try expression() != nil { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertNotNil(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + if try expression() == nil { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTUnwrap(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) throws -> T { + guard let value = try expression() else { + legacyFailure(message()) + throw NSError(domain: "CLINotifyProcessIntegrationRegressionTests", code: 1) + } + return value +} + +@Suite(.serialized) +final class CLINotifyProcessIntegrationRegressionTests { + private var teardownBlocks: [() -> Void] = [] + + deinit { + for block in teardownBlocks.reversed() { + block() + } + } + + private func addTeardownBlock(_ block: @escaping () -> Void) { + teardownBlocks.append(block) + } + + @Test func testClaudeClearSessionStartMarksWorkspaceRunning() throws { let context = try makeClaudeHookContext(name: "claude-clear-running") defer { context.cleanup() } @@ -33,6 +152,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeSessionStartRecordIsNotRestorableUntilPrompt() throws { let context = try makeClaudeHookContext(name: "claude-session-restorable") defer { context.cleanup() } @@ -73,6 +193,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudePreToolUseFeedContextReadsOnlyRecentTranscriptTail() throws { let context = try makeClaudeHookContext(name: "claude-pretool-tail") defer { context.cleanup() } @@ -112,6 +233,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(String(describing: feedContext).contains("ancient"), "\(feedContext)") } + @Test func testClaudePreToolUseFeedContextKeepsOversizedFinalTranscriptLine() throws { let context = try makeClaudeHookContext(name: "claude-pretool-oversized-final") defer { context.cleanup() } @@ -144,6 +266,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(assistantPreamble.hasPrefix("recent assistant response"), "\(feedContext)") } + @Test func testCodexStopReadsOversizedFinalTranscriptLine() throws { let context = try makeClaudeHookContext(name: "codex-oversized-final-transcript") defer { context.cleanup() } @@ -178,6 +301,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexPromptSubmitDoesNotRefreshTerminalLastTurnDiffBaseline() throws { let context = try makeClaudeHookContext(name: "codex-prompt-baseline") defer { context.cleanup() } @@ -324,6 +448,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(refreshedBaseCommit, promptCommit) } + @Test func testClaudeStopFromPreviousSessionDoesNotClobberClearRunningStatus() throws { let context = try makeClaudeHookContext(name: "claude-clear-stale-stop") defer { context.cleanup() } @@ -385,6 +510,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(resumeBindingRequests.first?["auto_resume"] as? Bool, true) } + @Test func testClaudePromptSubmitFromNewSessionCanReplaceStoppedSession() throws { let context = try makeClaudeHookContext(name: "claude-new-session-after-stop") defer { context.cleanup() } @@ -682,6 +808,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { return result } + @Test func testClaudeForkSessionStartKeepsParentSessionBoundToOriginalSurface() throws { let context = try makeClaudeHookContext(name: "claude-fork-session-start") defer { context.cleanup() } @@ -714,6 +841,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeForkSessionStartWithoutSurfaceIdentityDoesNotRegisterPIDOnFallbackPane() throws { let context = try makeClaudeHookContext(name: "claude-fork-no-surface") defer { context.cleanup() } @@ -751,6 +879,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeForkSessionStartRecognizesEqualsFlagForm() throws { let context = try makeClaudeHookContext(name: "claude-fork-equals-form") defer { context.cleanup() } @@ -788,6 +917,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeLegacyStoreBackfillsPaneBoundaryFromWorkspaceActiveSlot() throws { let context = try makeClaudeHookContext(name: "claude-legacy-backfill") defer { context.cleanup() } @@ -869,6 +999,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeForkedSessionPromptSubmitRecordsWhileParentTurnActive() throws { let context = try makeClaudeHookContext(name: "claude-fork-prompt-submit") defer { context.cleanup() } @@ -921,6 +1052,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(request["surface_id"] as? String, context.surfaceId) } + @Test func testClaudeForkSessionEndBeforeFirstPromptDoesNotConsumeParentSession() throws { let context = try makeClaudeHookContext(name: "claude-fork-session-end") defer { context.cleanup() } @@ -972,6 +1104,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeForkedSessionPromptSubmitRecordsWithSurfaceRefForm() throws { let context = try makeClaudeHookContext(name: "claude-fork-surface-ref") defer { context.cleanup() } @@ -1008,6 +1141,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeStaleStopFromClosedPaneStaysStaleWhenSurfaceResolutionFallsBack() throws { let context = try makeClaudeHookContext(name: "claude-stale-stop-fallback") defer { context.cleanup() } @@ -1079,6 +1213,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeStaleStopStaysStaleAfterAnotherPaneBecomesWorkspaceActive() throws { let context = try makeClaudeHookContext(name: "claude-stale-stop-multi-pane") defer { context.cleanup() } @@ -1143,6 +1278,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeNewSessionReplacesStoppedSessionInPaneAfterAnotherPaneBecameActive() throws { let context = try makeClaudeHookContext(name: "claude-replace-multi-pane") defer { context.cleanup() } @@ -1202,6 +1338,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeStaleTurnSessionEndDoesNotConsumeSessionAfterAnotherPaneBecameActive() throws { let context = try makeClaudeHookContext(name: "claude-stale-end-multi-pane") defer { context.cleanup() } @@ -1255,6 +1392,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeParentPaneStopAppliesAfterForkedSessionPromoted() throws { let context = try makeClaudeHookContext(name: "claude-fork-parent-stop") defer { context.cleanup() } @@ -1290,6 +1428,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudePromptSubmitResumeBindingPersistsSafeAuthSelectionValues() throws { let context = try makeClaudeHookContext(name: "claude-resume-env-redaction") defer { context.cleanup() } @@ -1360,6 +1499,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeSessionEndChecksConsumedWorkspaceBeforeClearingVisibleState() throws { let context = try makeClaudeHookContext(name: "claude-stale-session-end-workspace") defer { context.cleanup() } @@ -1429,6 +1569,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClaudeSessionEndDoesNotConsumeSameSessionStaleTurn() throws { let context = try makeClaudeHookContext(name: "claude-stale-session-end-turn") defer { context.cleanup() } @@ -1487,6 +1628,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(active["turnId"] as? String, "turn-2") } + @Test func testClaudeSessionEndClearsMatchingSurfaceResumeBinding() throws { let context = try makeClaudeHookContext(name: "claude-session-end-resume-clear") defer { context.cleanup() } @@ -1532,6 +1674,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(request["source"] as? String, "agent-hook") } + @Test func testNestedCodexPromptAndStopDoNotReplaceParentResumeBinding() throws { let context = try makeClaudeHookContext(name: "codex-nested-resume-guard") defer { context.cleanup() } @@ -1619,6 +1762,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testGenericAgentNotificationUpdatesLifecycleForNeedsInput() throws { let context = try makeClaudeHookContext(name: "codex-notification-lifecycle") defer { context.cleanup() } @@ -1676,6 +1820,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(record["agentLifecycle"] as? String, "needsInput") } + @Test func testGenericAgentStaleIdleStopDoesNotOverwriteNewerRunningLifecycle() throws { let context = try makeClaudeHookContext(name: "codex-stale-idle-stop-lifecycle") defer { context.cleanup() } @@ -1736,6 +1881,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(newRecord["agentLifecycle"] as? String, "running") } + @Test func testGenericAgentStaleIdleNotificationDoesNotOverwriteNewerRunningLifecycle() throws { let context = try makeClaudeHookContext(name: "codex-stale-idle-notification-lifecycle") defer { context.cleanup() } @@ -1796,6 +1942,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(newRecord["agentLifecycle"] as? String, "running") } + @Test func testCodexLegacyStopWithoutTurnIdPopsStoredTurnStack() throws { let context = try makeClaudeHookContext(name: "codex-legacy-stop-turn-stack") defer { context.cleanup() } @@ -1857,6 +2004,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testGenericAgentTurnIdsStayNestedAcrossTurnChanges() throws { let context = try makeClaudeHookContext(name: "generic-turn-stack") defer { context.cleanup() } @@ -1932,6 +2080,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexTurnStackPreservesAnonymousDepthBetweenKnownTurns() throws { let context = try makeClaudeHookContext(name: "codex-mixed-anonymous-depth") defer { context.cleanup() } @@ -2037,6 +2186,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexStopWithNewTurnIdPopsAnonymousDepthUnderKnownParent() throws { let context = try makeClaudeHookContext(name: "codex-anonymous-depth-turn-stop") defer { context.cleanup() } @@ -2098,6 +2248,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexStopAfterInterruptedPriorTurnStillNotifies() throws { let context = try makeClaudeHookContext(name: "codex-interrupted-turn-depth") defer { context.cleanup() } @@ -2151,6 +2302,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexStopPrunesLateTerminalPriorTurnBeforeCurrentStop() throws { let context = try makeClaudeHookContext(name: "codex-late-terminal-prior-turn") defer { context.cleanup() } @@ -2217,6 +2369,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexTerminalNestedChildDoesNotClearParentTurnStack() throws { let context = try makeClaudeHookContext(name: "codex-terminal-child-stack") defer { context.cleanup() } @@ -2314,6 +2467,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexTerminalInterruptedStackClearsBeforeCurrentPrompt() throws { let context = try makeClaudeHookContext(name: "codex-terminal-stack-reset") defer { context.cleanup() } @@ -2378,6 +2532,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexDepthOnlyLegacyNestingRemainsNestedWhenTurnIdsAppear() throws { let context = try makeClaudeHookContext(name: "codex-depth-only-nested") defer { context.cleanup() } @@ -2441,6 +2596,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexDepthOnlyHistoricalTerminalDoesNotResetActiveParent() throws { let context = try makeClaudeHookContext(name: "codex-depth-only-history") defer { context.cleanup() } @@ -2512,6 +2668,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexDepthOnlyTurnContextOnlyParentDoesNotResetActiveParent() throws { let context = try makeClaudeHookContext(name: "codex-depth-only-context-parent") defer { context.cleanup() } @@ -2582,6 +2739,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexDepthOnlyParentStopNotifiesAfterChildTurnStops() throws { let context = try makeClaudeHookContext(name: "codex-depth-only-parent-stop") defer { context.cleanup() } @@ -2654,6 +2812,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexDepthOnlySiblingStaysNestedAfterChildTerminalTranscript() throws { let context = try makeClaudeHookContext(name: "codex-depth-only-child-terminal") defer { context.cleanup() } @@ -2741,6 +2900,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexDepthOnlyTerminalHistoryDoesNotResetUnknownActiveDepth() throws { let context = try makeClaudeHookContext(name: "codex-depth-only-terminal-history") defer { context.cleanup() } @@ -2812,6 +2972,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexStopFromStaleOlderTurnDoesNotNotifyWhileNewerTurnIsActive() throws { let context = try makeClaudeHookContext(name: "codex-stale-turn-stop") defer { context.cleanup() } @@ -2877,6 +3038,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexLateStopFromOlderTurnDoesNotNotifyAfterNewerTurnCompleted() throws { let context = try makeClaudeHookContext(name: "codex-late-stale-turn-stop") defer { context.cleanup() } @@ -2941,6 +3103,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexStopWithMissedPromptSubmitClearsTerminalStaleTurn() throws { let context = try makeClaudeHookContext(name: "codex-missed-prompt-stale-turn") defer { context.cleanup() } @@ -2985,6 +3148,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexStopWithMissedPromptSubmitClearsFullyTerminalStack() throws { let context = try makeClaudeHookContext(name: "codex-missed-prompt-terminal-stack") defer { context.cleanup() } @@ -3048,6 +3212,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexStopWithUnseenTurnIdNotSuppressedAtIdleDepth() throws { let context = try makeClaudeHookContext(name: "codex-unseen-turn-stop") defer { context.cleanup() } @@ -3095,6 +3260,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testManagedCodexSubagentStopDoesNotReplaceResumeBinding() throws { let context = try makeClaudeHookContext(name: "codex-managed-resume-guard") defer { context.cleanup() } @@ -3130,6 +3296,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexStopIgnoresStaleSubagentRelayFromCompletedTurnWithoutTurnId() throws { let context = try makeClaudeHookContext(name: "codex-stale-relay") defer { context.cleanup() } @@ -3171,6 +3338,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testManagedCodexSubagentSessionEndDoesNotClearParentResumeBinding() throws { let context = try makeClaudeHookContext(name: "codex-managed-end-resume-guard") defer { context.cleanup() } @@ -3223,6 +3391,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertNotNil(savedSessions[sessionId], "Suppressed SessionEnd should leave the stored parent session intact") } + @Test func testRightSidebarCLIForwardsV1SocketCommandsQuietly() throws { let cliPath = try bundledCLIPath() let cases: [(name: String, arguments: [String], expectedCommand: String, response: String, stdout: String)] = [ @@ -3276,6 +3445,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } } + @Test func testRightSidebarInvalidCommandValidatesBeforeTargetResolution() throws { let cliPath = try bundledCLIPath() let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" @@ -3297,6 +3467,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) } + @Test func testRightSidebarInvalidSetModeValidatesBeforeTargetResolution() throws { let cliPath = try bundledCLIPath() let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" @@ -3318,16 +3489,19 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) } + @Test func testSSHPersistentPTYUsesReusableForegroundAuthControlConnection() throws { let run = try runMockedSSH(arguments: []) try assertSSHPersistentPTYUsesReusableForegroundAuthControlConnection(run: run) } + @Test func testSSHPersistentPTYTreatsControlPersistZeroAsReusable() throws { let run = try runMockedSSH(arguments: ["--ssh-option", "ControlPersist=0"]) try assertSSHPersistentPTYUsesReusableForegroundAuthControlConnection(run: run) } + @Test func testSSHPersistentPTYJSONReportsResolvedSessionID() throws { let run = try runMockedSSH(arguments: [], jsonOutput: true) let payload = try jsonPayload(from: run.stdout) @@ -3341,6 +3515,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } + @Test func testSSHPersistentPTYJSONResolvesSessionIDWhenWorkspaceCreateOmitsSurfaceID() throws { let run = try runMockedSSH(arguments: [], jsonOutput: true, omitWorkspaceCreateSurfaceID: true) let payload = try jsonPayload(from: run.stdout) @@ -3352,6 +3527,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } + @Test func testSSHForwardAgentFlagPropagatesCallerAgentSocket() throws { let agentSocketPath = try makeExistingAgentSocketPath() let run = try runMockedSSH( @@ -3368,6 +3544,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) } + @Test func testSSHForwardAgentOptionPropagatesCallerAgentSocket() throws { let agentSocketPath = try makeExistingAgentSocketPath() let run = try runMockedSSH( @@ -3384,6 +3561,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) } + @Test func testSSHForwardAgentRepeatedOptionUsesLastValue() throws { let run = try runMockedSSH( arguments: [ @@ -3406,6 +3584,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertNil(configureParams["ssh_auth_sock"]) } + @Test func testSSHPreservesCallerAgentSocketForOpenSSHConfigResolution() throws { let agentSocketPath = try makeExistingAgentSocketPath() let run = try runMockedSSH( @@ -3423,6 +3602,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) } + @Test func testSSHForwardAgentLiteralSocketPathPropagatesSocketPath() throws { let agentSocketPath = try makeExistingAgentSocketPath() let run = try runMockedSSH( @@ -3438,6 +3618,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) } + @Test func testSSHForwardAgentTildeSocketPathExpandsSocketPath() throws { let homeURL = try makeTemporaryDirectory(prefix: "cmux-ssh-home") let tildeSocketPath = "~/.ssh/cmux-test-agent.sock" @@ -3459,6 +3640,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, expandedSocketURL.path) } + @Test func testSSHForwardAgentAskDoesNotPropagateInvalidSocketPath() throws { let run = try runMockedSSH( arguments: ["--ssh-option", "ForwardAgent=ask"], @@ -3475,6 +3657,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertNil(configureParams["ssh_auth_sock"]) } + @Test func testSSHNoForwardAgentFlagOverridesConfig() throws { let agentSocketPath = try makeExistingAgentSocketPath() let run = try runMockedSSH( @@ -3566,6 +3749,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } + @Test func testSSHPersistentPTYFallsBackWhenForegroundAuthCannotBeReused() throws { let cases: [(name: String, arguments: [String])] = [ ("control-master-no", ["--ssh-option", "ControlMaster=no"]), @@ -3601,6 +3785,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } } + @Test func testSSHPTYAttachBridgeErrorClearsLocalStateBeforeReady() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshpty") @@ -3693,6 +3878,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) } + @Test func testSSHPTYAttachBridgeEOFWhileSessionRunsExitsWithoutSSHRetryStatus() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshptyeof") @@ -3802,6 +3988,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_detach"]) } + @Test func testSSHPTYAttachBridgeEOFWhenSessionGoneClearsLocalState() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshptygone") @@ -3901,6 +4088,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) } + @Test func testSSHPTYAttachWithoutSurfaceDoesNotSendLocalAttachEnd() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshptynosurface") @@ -3990,6 +4178,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions"]) } + @Test func testSSHPTYAttachBridgeResetWhenSessionGoneClearsLocalState() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshptyrst") @@ -4089,6 +4278,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) } + @Test func testSSHPTYAttachWaitUsesCurrentTerminalSizeForBridgeHandshake() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshptysize") @@ -4270,6 +4460,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(capturedHandshake?["rows"] as? Int, 43) } + @Test func testSSHPTYAttachSendsResizeWithoutBlockingEOFLocalCleanup() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshptyresize") @@ -4438,6 +4629,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(methods.filter { $0 == "workspace.remote.pty_attach_end" }.count, 1) } + @Test func testSSHSessionAttachCreatesSurfaceWithPersistedPTYSessionID() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshattach") @@ -4508,6 +4700,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(state.snapshot().count, 1) } + @Test func testSSHPTYAttachRequireExistingPassesBridgeFlag() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshreq") @@ -4599,6 +4792,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) } + @Test func testSSHPTYAttachRequireExistingSessionNotFoundFailsWithoutWaitRetry() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshreqmissing") @@ -4683,6 +4877,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) } + @Test func testSSHSessionListAllWorkspacesReportsQueryErrors() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshlist") @@ -4743,6 +4938,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stderr.contains("remote connection is not active"), result.stderr) } + @Test func testSSHSessionCleanupAllReportsPartialFailures() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshclean") @@ -4834,6 +5030,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.stderr.contains("close failed"), result.stderr) } + @Test func testSSHSessionCleanupAllWorkspacesReportsListErrors() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshcleanall") @@ -4913,6 +5110,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stderr.contains("remote connection is not active"), result.stderr) } + @Test func testSSHSessionCleanupAllWorkspacesAllRejectsMissingWorkspaceID() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshcleanallmissing") @@ -4987,6 +5185,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stderr.contains("missing workspace_id in SSH PTY session list response"), result.stderr) } + @Test func testSSHSessionCleanupAllWorkspacesSessionIDRejectsMissingWorkspaceID() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshcleansessionmissing") @@ -5061,6 +5260,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stderr.contains("missing workspace_id in SSH PTY session list response"), result.stderr) } + @Test func testSSHSessionCleanupAllWorkspacesSessionIDReportsNotFound() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshcleansessiongone") @@ -5127,6 +5327,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stderr.contains("persistent SSH PTY session is no longer running"), result.stderr) } + @Test func testSSHSessionCleanupAllWorkspacesSessionIDCountsDuplicateIDsPerWorkspace() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshcleandup") @@ -5213,6 +5414,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(Set(closedWorkspaces), Set([workspaceA, workspaceB])) } + @Test func testRightSidebarCLIResolvesWindowAndWorkspaceHandlesBeforeForwarding() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("rs-target") @@ -5291,6 +5493,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(state.commands.last, "right_sidebar set find --tab=\(workspaceId) --window=\(windowId)") } + @Test func testRightSidebarCLIRejectsUnresolvedWorkspaceHandleBeforeForwarding() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("rs-miss") @@ -5347,6 +5550,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } @MainActor + @Test func testNotifyWithUUIDSurfaceDoesNotRequireCallerWorkspaceOrWindow() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("notify-uuid-surface") @@ -5413,6 +5617,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testNotificationCLIActionsUseSocketAPIAndParseExtendedFields() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("notif-actions") @@ -5618,6 +5823,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testListNotificationsKeepsOldServerPipeBodiesAsBody() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("notif-old-pipe") @@ -5662,6 +5868,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(row["tab_title"] is NSNull) } + @Test func testCodexPromptSubmitRebindsRestoredSessionToCurrentCallerSurface() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-rebind") @@ -5771,6 +5978,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testNewPaneWindowFlagScopesWorkspaceIndex() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("pane-window") @@ -5850,6 +6058,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testFocusPaneWindowFlagRejectsPaneFromOtherWindow() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("pane-other-window") @@ -5932,6 +6141,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testReorderSurfaceWindowFlagRejectsSurfaceFromOtherWindow() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("surface-other-window") @@ -6014,6 +6224,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testSendWindowFlagRejectsUnknownWindowRefBeforeMutation() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("send-window-ref") @@ -6075,6 +6286,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testVMNewWindowFlagValidatesBeforeCreate() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("vm-window-validate") @@ -6122,6 +6334,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testVMNewWindowFlagAcceptsCaseInsensitiveUUID() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("vm-window-case") @@ -6194,6 +6407,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testPipePaneWindowFlagDoesNotBecomePositionalCommandText() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("pipe-window") @@ -6275,6 +6489,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testPipePaneWindowWorkspaceOmittedSurfaceDoesNotUseSelectedWorkspaceSurface() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("pipe-window-workspace") @@ -6371,6 +6586,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testRespawnPaneWindowFlagDoesNotBecomePositionalCommandText() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("respawn-window") @@ -6452,6 +6668,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testMoveSurfaceWindowFlagKeepsIndexedSourceInCallerContext() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("move-surface-window") @@ -6531,6 +6748,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testMoveSurfaceWindowFlagAllowsSourceSurfaceRefFromOtherWindow() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("move-surface-cross-window") @@ -6589,6 +6807,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testSidebarMetadataWindowFlagTargetsSelectedWorkspaceInWindow() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("status-window") @@ -6639,6 +6858,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stderr.isEmpty, result.stderr) } + @Test func testSidebarMetadataWindowFlagAfterSeparatorStaysMessageText() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("log-separator") @@ -6687,6 +6907,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stderr.isEmpty, result.stderr) } + @Test func testSidebarMetadataWindowFlagFailsWhenWindowHasNoCurrentWorkspace() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("status-window-empty") @@ -6735,6 +6956,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testNotifyWindowFlagResolvesCurrentWorkspaceInWindow() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("notify-window") @@ -6798,6 +7020,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testNotifyWindowSurfaceRefResolvesAcrossTargetWindowWorkspaces() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("notify-window-surface") @@ -6919,6 +7142,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(methods, ["window.list", "workspace.list", "surface.list", "surface.list"]) } + @Test func testNotifyWindowSurfaceIndexUsesCurrentWorkspaceInTargetWindow() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("notify-window-surface-index") @@ -7002,6 +7226,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(methods, ["window.list", "workspace.current", "surface.list"]) } + @Test func testWorkspaceActionWindowFlagResolvesCurrentWorkspaceInWindow() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("action-window") @@ -7064,6 +7289,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testClearNotificationsWindowFlagFailsWhenWindowHasNoCurrentWorkspace() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("clear-window-empty") @@ -7112,6 +7338,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testTreeCommandForwardsWindowFlag() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("tree-window") @@ -7166,6 +7393,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertTrue(result.stderr.isEmpty, result.stderr) } + @Test func testTreeCommandWindowFlagSurvivesLegacyFallback() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("tree-legacy-window") @@ -7296,6 +7524,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.stdout.contains(otherWindowId), result.stdout) } + @Test func testCodexPromptSubmitWithForeignCmuxEnvDoesNotFallbackToSelectedWorkspace() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-foreign-env") @@ -7359,6 +7588,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexPromptSubmitWithForeignCmuxEnvIgnoresStaleMappedSession() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-foreign-mapped") @@ -7445,6 +7675,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexPromptSubmitWithInvalidSurfaceDoesNotFallbackToFocusedSurface() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-invalid-surface") @@ -7512,6 +7743,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexPromptSubmitWithInvalidMappedWorkspaceDoesNotFallbackToSelectedWorkspace() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-invalid-mapped") @@ -7598,6 +7830,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testCodexTeamsForkPromptPublishesResumeBinding() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-team-resume") @@ -7739,6 +7972,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testAgentPromptClearsSurfaceResumeBindingWhenResumeCommandUnavailable() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("agent-resume-unavailable") @@ -7845,6 +8079,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(request["checkpoint_id"] as? String, sessionId) } + @Test func testGenericAgentSessionEndClearsMatchingSurfaceResumeBinding() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("agent-resume-clear") @@ -7928,6 +8163,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(request["source"] as? String, "agent-hook") } + @Test func testSurfaceResumeClearCLIForwardsCheckpointAndSourceGuards() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("resume-clear-guards") @@ -7987,6 +8223,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(request["source"] as? String, "agent-hook") } + @Test func testSurfaceResumeSetCLIPreservesQuotedShellCommand() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("resume-set-shell") @@ -8046,6 +8283,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(request["command"] as? String, "tmux attach -t work") } + @Test func testSurfaceResumeSetCLIStopsParsingOptionsAfterTerminator() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("resume-set-terminator") @@ -8112,6 +8350,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testSurfaceResumeSetCLIDoesNotScopeExplicitSurfaceToEnvWorkspace() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("resume-set-surface") @@ -8167,6 +8406,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(request["surface_id"] as? String, movedSurfaceId) } + @Test func testSurfaceResumeSetCLIRejectsTrailingShellTokens() throws { let cliPath = try bundledCLIPath() let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" @@ -8196,6 +8436,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) } + @Test func testSurfaceResumeSetCLIRejectsPreTerminatorCommandTokens() throws { let cliPath = try bundledCLIPath() let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" @@ -8223,6 +8464,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) } + @Test func testSurfaceResumeSetCLIRejectsDanglingValueOptionsBeforeSocketRequest() throws { let cliPath = try bundledCLIPath() let workspaceId = "11111111-1111-1111-1111-111111111111" @@ -8277,6 +8519,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } } + @Test func testSurfaceResumeClearCLIRejectsMalformedGuardsBeforeClearing() throws { let cliPath = try bundledCLIPath() let missingSocketPath = "/tmp/cmux-test-missing-\(UUID().uuidString).sock" @@ -8303,6 +8546,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) } + @Test func testSurfaceResumeClearCLINormalizesWindowIndex() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("resume-clear-window") @@ -8381,6 +8625,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(request["surface_id"] as? String, surfaceId) } + @Test func testSurfaceResumeClearCLIParsesLocalWindowOption() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("resume-clear-local-window") @@ -8705,6 +8950,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { } } + @Test func testBrowserImportDefaultsNonInteractiveInCodingAgent() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("browser-import-agent") @@ -8788,6 +9034,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testBrowserImportUsesInteractiveDialogOutsideCodingAgent() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("browser-import-human") @@ -8854,6 +9101,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testBrowserImportInteractiveFlagForcesDialogInCodingAgent() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("browser-import-agent-interactive") @@ -8908,6 +9156,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testBrowserProfilesListRoutesToSocketMethod() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("browser-profile-list") @@ -8964,6 +9213,7 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { ) } + @Test func testBrowserProfilesCreateClearAndDeleteRouteToSocketMethods() throws { let cliPath = try bundledCLIPath() let cases: [(name: String, arguments: [String], expectedMethod: String, expectedParams: [String], responseResult: [String: Any])] = [ @@ -9262,6 +9512,7 @@ extension CLINotifyProcessIntegrationRegressionTests { // inherited env), not the operator's focused pane returned by system.identify. Without the fix it // stamped the focused surface (A), desyncing CMUX_SURFACE_ID from CMUX_PANEL_ID and jumbling codex // into the wrong surface on reload. + @Test func testTmuxCompatEnvStampsLaunchSurfaceNotFocusedPane() throws { let cliPath = try bundledCLIPath() let tmpDir = FileManager.default.temporaryDirectory diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index a0c3cc1d5322..36461aac85da 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -1,6 +1,7 @@ import Darwin import Combine -import XCTest +import Foundation +import Testing import CmuxCore import CmuxRemoteDaemon import CmuxRemoteSession @@ -13,6 +14,151 @@ import CmuxTerminal @testable import cmux #endif +private typealias XCTestExpectation = DispatchSemaphore + +private extension DispatchSemaphore { + func fulfill() { + signal() + } +} + +private func legacyTimeout(_ timeout: TimeInterval) -> DispatchTime { + .now() + .milliseconds(Int((timeout * 1_000).rounded(.up))) +} + +private func legacyFailure(_ message: String) { + Issue.record(Comment(rawValue: message.isEmpty ? "Assertion failed" : message)) +} + +private func expectation(description _: String) -> XCTestExpectation { + DispatchSemaphore(value: 0) +} + +private func wait(for expectations: [XCTestExpectation], timeout: TimeInterval) { + for expectation in expectations { + if expectation.wait(timeout: legacyTimeout(timeout)) == .timedOut { + legacyFailure("Timed out waiting for expectation") + } + } +} + +private func XCTFail(_ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + legacyFailure(message()) +} + +private func XCTAssertTrue(_ expression: @autoclosure () throws -> Bool, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + if try !expression() { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertFalse(_ expression: @autoclosure () throws -> Bool, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + if try expression() { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertEqual(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs != rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertNotEqual(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs == rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertGreaterThan(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs <= rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertGreaterThanOrEqual(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs < rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertNil(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + if try expression() != nil { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertNotNil(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + if try expression() == nil { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertNoThrow(_ expression: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { + do { + _ = try expression() + } catch { + legacyFailure("\(message()) \(error)") + } +} + +private func XCTAssertThrowsError(_ expression: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line, _ errorHandler: (Error) -> Void = { _ in }) { + do { + _ = try expression() + legacyFailure(message()) + } catch { + errorHandler(error) + } +} + +private func XCTUnwrap(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) throws -> T { + guard let value = try expression() else { + legacyFailure(message()) + throw NSError(domain: "WorkspaceRemoteConnectionTests", code: 1) + } + return value +} + /// Closure shape shared by the scripted process-runner tests; mirrors the /// legacy `runProcessOverrideForTesting` static signature so the scripted /// bodies stay byte-identical. @@ -30,7 +176,8 @@ private struct ScriptedRemoteProcessRunner: RemoteSessionProcessRunning, @unchec } } -final class WorkspaceRemoteConnectionTests: XCTestCase { +@Suite(.serialized) +final class WorkspaceRemoteConnectionTests { private struct ProcessRunResult { let status: Int32, stdout: String, stderr: String let timedOut: Bool @@ -199,6 +346,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { .filter { !$0.isEmpty } } + @Test func testGeneratedBashBootstrapSourcesLoginFilesInBashPrecedenceOrder() throws { XCTAssertEqual( try runGeneratedBashBootstrapMarkers(startupFiles: [ @@ -226,6 +374,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @Test func testGeneratedFallbackShellBootstrapPrependsCmuxBinOnce() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -275,6 +424,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(cmuxBinEntries.count, 1, path) } + @Test func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() { let fileManager = FileManager.default let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)") @@ -313,6 +463,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) } + @Test func testRemoteRelayMetadataCleanupScriptPreservesDifferentSocketAddr() { let fileManager = FileManager.default let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-preserve-\(UUID().uuidString)") @@ -351,6 +502,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(fileManager.fileExists(atPath: ttyURL.path)) } + @Test func testRemoteStaleRelayListenerCleanupScriptKillsMatchingPersistentRelayListener() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-cleanup-\(UUID().uuidString)") @@ -414,6 +566,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(killOutput.contains("-KILL 34057"), killOutput) } + @Test func testRemoteStaleRelayListenerCleanupScriptPreservesDifferentPersistentSlot() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-preserve-\(UUID().uuidString)") @@ -472,6 +625,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(try String(contentsOf: killLog, encoding: .utf8), "") } + @Test func testRemoteStaleRelayListenerCleanupScriptMatchesPersistentSlotExactly() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-slot-prefix-\(UUID().uuidString)") @@ -530,6 +684,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(try String(contentsOf: killLog, encoding: .utf8), "") } + @Test func testRemoteStaleRelayListenerCleanupScriptKillsMetadataMatchedListenerWithoutChild() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-metadata-\(UUID().uuidString)") @@ -606,6 +761,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(killOutput.contains("-KILL 33681"), killOutput) } + @Test func testRemoteStaleRelayListenerCleanupScriptPreservesMetadataMatchedDifferentPersistentSlot() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent("cmux-stale-relay-metadata-preserve-\(UUID().uuidString)") @@ -676,6 +832,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(try String(contentsOf: killLog, encoding: .utf8), "") } + @Test func testRelayZshBootstrapUsesRealHomeHistoryByDefault() throws { let histfile = try runRelayZshHistfile { home in try ":\n".write(to: home.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8) @@ -686,6 +843,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(histfile.hasSuffix("/.zsh_history")) } + @Test func testRelayZshBootstrapUsesUserUpdatedZdotdirHistory() throws { let histfile = try runRelayZshHistfile { home in let altZdotdir = home.appendingPathComponent("dotfiles") @@ -702,6 +860,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(histfile.contains("/dotfiles/.zsh_history")) } + @Test func testRemoteUTF8LocaleSetupLinesSeedUTF8LocaleWhenMissing() { let script = (RemoteShellEnvironment.utf8LocaleSetupLines() + [ #"printf '%s' "${LANG}|${LC_CTYPE}|${LC_ALL}""#, @@ -726,6 +885,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "C.UTF-8|C.UTF-8|C.UTF-8") } + @Test func testRemoteUTF8LocaleSetupLinesPreserveExistingUTF8Locale() { let script = (RemoteShellEnvironment.utf8LocaleSetupLines() + [ #"printf '%s' "${LANG}|${LC_CTYPE}|${LC_ALL}""#, @@ -750,6 +910,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "ja_JP.UTF-8||") } + @Test func testDaemonSocketForwardArgumentsTargetBakedVMSocket() { let configuration = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -782,6 +943,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(arguments.last, "cmux-macmini") } + @Test func testProxyBrokerTransportKeySeparatesVMBakedSSHFromStandardSSH() { let standard = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -871,6 +1033,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testWebSocketVMWithoutDaemonEndpointSkipsProxyStartup() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -895,6 +1058,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testSkipBootstrapPersistentPTYDoesNotFailBakedCapabilityPreflight() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -925,6 +1089,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse((daemon?["detail"] as? String)?.contains("pty.session") == true) } + @Test func testRemoteDaemonCapabilityErrorsUseUserFacingMessage() { let message = RemoteDaemonStrings.appLocalized.missingRequiredCapabilitiesMessage([ "pty.session", @@ -956,6 +1121,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testWebSocketVMWithDaemonEndpointStartsProxyCapableConnection() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -986,6 +1152,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { workspace.disconnectRemoteConnection(clearConfiguration: true) } + @Test func testReverseRelayStartupFailureDetailCapturesImmediateForwardingFailure() throws { let process = Process() let stderrPipe = Pipe() @@ -1006,6 +1173,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(detail, "remote port forwarding failed for listen port 64009") } + @Test func testExecutableSearchPathsIncludesHomebrewAndHomeFallbacks() { let paths = RemoteSessionCoordinator.executableSearchPaths( environment: [ @@ -1033,6 +1201,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @Test func testParsePathHelperPathsExtractsPathEntries() { XCTAssertEqual( RemoteSessionCoordinator.parsePathHelperPaths( @@ -1046,6 +1215,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @Test func testParsePathHelperPathsIgnoresMANPATHAssignments() { XCTAssertEqual( RemoteSessionCoordinator.parsePathHelperPaths( @@ -1063,6 +1233,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testRemoteTerminalSurfaceLookupTracksOnlyActiveSSHSurfaces() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1088,6 +1259,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testWebSocketRemoteTerminalEndLeavesConnectedStateWithinBoundedTime() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1135,6 +1307,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testWebSocketRemoteTerminalEndWithoutStartupCommandStillDisconnects() throws { let workspace = Workspace() let initialConfig = WorkspaceRemoteConfiguration( @@ -1198,6 +1371,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testRemoteTerminalSessionEndPublishesDisconnectedDetailWithoutTransientNil() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1238,6 +1412,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testRemoteTerminalSessionEndIgnoresDuplicateRelayCallbackAfterSurfaceProcessed() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1267,6 +1442,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testForegroundSSHAuthReadyBeforeRemoteConfigureStartsDeferredConnect() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1291,6 +1467,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testForegroundSSHAuthReadyReconnectsConfiguredDisconnectedRemoteWorkspace() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1317,6 +1494,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testForegroundSSHAuthReadyBufferedTokenDoesNotReconnectDifferentConfiguration() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1340,6 +1518,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testRemoteReconnectingStateIsExposedInStatusPayload() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1367,6 +1546,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testForegroundSSHAuthReadyIgnoresMismatchedConfiguredToken() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1390,6 +1570,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testRemoteTerminalSessionEndRequestsControlMasterCleanupAndLeavesWorkspaceDisconnected() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1445,6 +1626,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testRemoteTerminalSessionEndWithoutCallbackRelayPortStillCleansControlMaster() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1495,6 +1677,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testRemoteTerminalSessionEndPreservesPersistentPTYWorkspace() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1549,6 +1732,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testTeardownRemoteConnectionRequestsControlMasterCleanupWhileStillConnecting() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1601,6 +1785,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testTeardownRemoteConnectionRequestsControlMasterCleanupWithoutExplicitControlPath() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1648,6 +1833,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testClosingRemoteWorkspaceRequestsControlMasterCleanup() throws { let manager = TabManager() let remainingWorkspace = try XCTUnwrap(manager.selectedWorkspace) @@ -1704,6 +1890,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testDetachLastRemoteSurfacePreservesRemoteSessionWithoutCleanup() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -1751,6 +1938,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testClosingSourceWorkspaceAfterDetachingRemoteSurfaceSkipsControlMasterCleanup() throws { let manager = TabManager() let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) @@ -1804,6 +1992,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testClosingMixedSourceWorkspaceAfterDetachingLastRemoteSurfaceSkipsControlMasterCleanup() throws { let manager = TabManager() let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) @@ -1859,6 +2048,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testTransferredRemoteSurfaceCleansUpControlMasterWhenSessionEndsInLocalWorkspace() throws { let manager = TabManager() let sourceWorkspace = try XCTUnwrap(manager.selectedWorkspace) @@ -1914,6 +2104,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testRemoteTerminalSessionEndDisconnectsWorkspaceWhenBrowserPanelsRemain() throws { let workspace = Workspace() let paneID = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) @@ -1955,6 +2146,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testClosingInitialRemoteTerminalPaneKeepsSiblingRemotePaneAlive() throws { let workspace = Workspace() let initialTerminalID = try XCTUnwrap(workspace.focusedTerminalPanel?.id) @@ -2005,6 +2197,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(cleanupArguments.isEmpty) } + @Test func testRemoteDropPathUsesLowercasedExtensionAndProvidedUUID() throws { let fileURL = URL(fileURLWithPath: "/Users/test/Screen Shot.PNG") let uuid = try XCTUnwrap(UUID(uuidString: "12345678-1234-1234-1234-1234567890AB")) @@ -2015,6 +2208,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testDaemonBootstrapUploadUsesAbsoluteHomePathForScpDestination() throws { let fileManager = FileManager.default let directoryURL = fileManager.temporaryDirectory.appendingPathComponent( @@ -2114,6 +2308,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testPersistentPTYBootstrapReinstallsOldDaemonMissingPTYCapability() throws { let previousAllowLocalBuild = getenv("CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD").map { String(cString: $0) } let previousDaemonBinary = getenv("CMUX_REMOTE_DAEMON_BINARY").map { String(cString: $0) } @@ -2219,6 +2414,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testPersistentReverseRelayCancelsStaleControlMasterForwardBeforeReusingRelayPort() throws { let forwardInvoked = DispatchSemaphore(value: 0) let lock = NSLock() @@ -2309,6 +2505,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testPersistentReverseRelayCleansStaleRemoteListenerAndRetriesControlMasterForward() throws { let retryForwardInvoked = DispatchSemaphore(value: 0) let lock = NSLock() @@ -2431,6 +2628,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testDetachAttachPreservesRemoteTerminalSurfaceTracking() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -2472,6 +2670,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testDetachAttachPreservesPersistentPTYSessionIDAcrossWorkspaces() throws { let source = Workspace() let destination = Workspace() @@ -2521,6 +2720,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testDetachAttachDoesNotAdoptPersistentPTYSessionIDAcrossNilRelayWorkspaces() throws { let source = Workspace() let destination = Workspace() @@ -2590,6 +2790,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testExplicitRemotePTYSessionSurfaceTracksRemoteTerminalWithoutDefaultStartup() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -2634,6 +2835,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testRemoteDisconnectClearsExplicitRemotePTYSessionIDBeforeReseed() throws { let workspace = Workspace() let explicitSessionConfig = WorkspaceRemoteConfiguration( @@ -2690,6 +2892,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testRemoteReconfigureClearsExplicitRemotePTYSessionIDForTrackedSurface() throws { let workspace = Workspace() let originalConfig = WorkspaceRemoteConfiguration( @@ -2738,6 +2941,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testExplicitRemotePTYSessionSplitTracksRemoteTerminalWithoutDefaultStartup() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -2776,6 +2980,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testPersistentRemoteTerminalSeedsDefaultPTYSessionIDForSnapshot() throws { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -2806,6 +3011,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testDetachAttachPreservesSurfaceTTYMetadata() throws { let source = Workspace() let destination = Workspace() @@ -2829,6 +3035,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(source.bonsplitController.tabs(inPane: sourcePaneID).count, 0) } + @Test func testDetectedSSHUploadFailureCleansUpEarlierRemoteUploads() throws { let fileManager = FileManager.default let directoryURL = fileManager.temporaryDirectory.appendingPathComponent( @@ -2895,6 +3102,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(cleanupCommand.contains(String(uploadedRemotePath))) } + @Test func testDetectsForegroundSSHSessionForTTY() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -2934,6 +3142,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @Test func testDetectsForegroundSSHSessionWithShortControlPathFlag() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -2959,6 +3168,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(scpArgs.contains("-S")) } + @Test func testDetectsForegroundEternalTerminalSessionForTTY() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -2991,6 +3201,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @Test func testDetectsEternalTerminalSessionWithoutTreatingETPortAsSSHPort() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -3019,6 +3230,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(scpArgs.last, "lawrence@example.com:/tmp/cmux-drop-123.png") } + @Test func testDetectsEternalTerminalSessionWithBracketedIPv6ServerPortForSCP() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -3045,6 +3257,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(scpArgs.last, "lawrence@[2001:db8::1]:/tmp/cmux-drop-123.png") } + @Test func testDetectsEternalTerminalSessionWithFullIPv6ServerPortForSCP() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -3071,6 +3284,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(scpArgs.last, "lawrence@[2001:db8:0:0:0:0:0:1]:/tmp/cmux-drop-123.png") } + @Test func testDetectsEternalTerminalSessionPreservesAmbiguousCompressedIPv6LiteralForSCP() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -3097,6 +3311,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(scpArgs.last, "lawrence@[2001:db8::1:2022]:/tmp/cmux-drop-123.png") } + @Test func testDetectsEternalTerminalSessionIgnoresOptionsAfterDestination() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -3123,6 +3338,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(scpArgs.last, "lawrence@example.com:/tmp/cmux-drop-123.png") } + @Test func testDetectsEternalTerminalSessionStripsNativeJumpHostServerPortForSCP() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -3152,6 +3368,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(scpArgs.last, "lawrence@example.com:/tmp/cmux-drop-123.png") } + @Test func testDetectsEternalTerminalSessionSSHOptionsForSCP() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -3193,6 +3410,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { ) } + @Test func testDaemonTransportArgumentsReuseConfiguredControlPath() { let configuration = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -3223,6 +3441,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(arguments.last?.contains("/remote/cmuxd-remote") ?? false) } + @Test func testDaemonTransportArgumentsReuseWhitespaceConfiguredControlPath() { let configuration = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -3251,6 +3470,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(arguments.contains(where: { $0 == "ControlPath /tmp/cmux-ssh-%C" || $0 == "ControlPath=/tmp/cmux-ssh-%C" })) } + @Test func testReverseRelayControlMasterArgumentsReuseConfiguredControlSocket() throws { let configuration = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -3287,6 +3507,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(arguments.contains("cmux-macmini")) } + @Test func testReverseRelayControlMasterCancelArgumentsUseRemoteListenPortOnly() throws { let configuration = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -3323,6 +3544,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(arguments.contains("cmux-macmini")) } + @Test func testReverseRelayControlMasterArgumentsReuseWhitespaceConfiguredControlSocket() throws { let configuration = WorkspaceRemoteConfiguration( destination: "cmux-macmini", @@ -3356,6 +3578,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertTrue(arguments.contains("forward")) } + @Test func testDetectedSSHSessionBracketsIPv6LiteralSCPDestination() { let session = DetectedSSHSession( destination: "lawrence@2001:db8::1", @@ -3379,6 +3602,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(scpArgs.last, "lawrence@[2001:db8::1]:/tmp/cmux-drop-123.png") } + @Test func testDetectsForegroundSSHSessionWithLowercaseAgentFlag() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -3398,6 +3622,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertFalse(session?.forwardAgent ?? true) } + @Test func testDetectsForegroundSSHSessionIgnoringBindInterfaceValue() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "/dev/ttys004", @@ -3416,6 +3641,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { XCTAssertEqual(session?.destination, "lawrence@example.com") } + @Test func testIgnoresBackgroundSSHProcessForTTY() { let session = TerminalSSHSessionDetector.detectForTesting( ttyName: "ttys004", @@ -3431,6 +3657,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testProxyOnlyErrorsKeepSSHWorkspaceConnectedAndLoggedInSidebar() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -3488,6 +3715,7 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } @MainActor + @Test func testWebSocketDaemonTransportErrorClearsConnectedSidebarState() { let workspace = Workspace() let config = WorkspaceRemoteConfiguration( @@ -3532,7 +3760,8 @@ final class WorkspaceRemoteConnectionTests: XCTestCase { } } -final class CLINotifyProcessIntegrationTests: XCTestCase { +@Suite(.serialized) +final class CLINotifyProcessIntegrationTests { private struct ProcessRunResult { let status: Int32 let stdout: String @@ -3931,6 +4160,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testAgentHookLaunchEnvironmentDoesNotPersistPathOrShell() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("hook") @@ -4006,6 +4236,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(persistedEnvironment, ["CODEX_HOME": "/tmp/codex home"]) } + @Test func testCodexHookStopSetsRateLimitStatusFromTranscript() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4084,6 +4315,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopTreatsTypedCodexErrorEventAsFailure() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4158,6 +4390,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopFallsBackToDiscoveredTranscriptWhenProvidedPathUnavailable() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4236,6 +4469,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexPromptSubmitRetiresPreviousMonitorLeaseForSameSession() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4314,6 +4548,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopTreatsCodexErrorInfoPayloadAsFailure() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4375,6 +4610,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopTreatsStructuredCodexErrorInfoPayloadAsFailure() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4436,6 +4672,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopTreatsCamelCaseCodexErrorInfoPayloadAsFailure() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4497,6 +4734,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopTreatsTypedHookPayloadAsFailure() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4558,6 +4796,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopTreatsExplicitErrorFieldAsFailureSignal() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4625,6 +4864,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopDoesNotKeepOldTranscriptErrorAfterSuccessfulTurn() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4703,6 +4943,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopPrefersExplicitErrorPayloadOverHealthyTranscript() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4777,6 +5018,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopTreatsCompletedTurnWithoutAssistantAsFailure() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4851,6 +5093,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopDoesNotSynthesizeNoFinalResponseAfterScopedAssistantMessage() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4923,6 +5166,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookStopIgnoresUnscopedTranscriptErrorWithoutTurnEvidence() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -4993,6 +5237,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookMonitorSetsErrorStatusFromCompletedTranscriptWithoutAssistant() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -5073,6 +5318,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookMonitorReportsExplicitErrorBeforeTerminalCompletion() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -5153,6 +5399,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookMonitorNotifiesOnRequestUserInput() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -5253,6 +5500,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue(process.isRunning, "Monitor should keep watching the turn after publishing input notification") } + @Test func testCodexHookMonitorNotifiesOnResponseItemRequestUserInput() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-response-item") @@ -5353,6 +5601,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue(process.isRunning, "Monitor should keep watching the turn after publishing input notification") } + @Test func testCodexHookMonitorReResolvesUnavailableTranscriptPath() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -5437,6 +5686,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testCodexHookMonitorIgnoresUnscopedTerminalForTurnScopedMonitor() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex") @@ -5554,6 +5804,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { ) } + @Test func testPTYBridgeFlushesReadyBeforeImmediateExit() throws { let rpcClient = ImmediateExitPTYBridgeRPC() let stopped = DispatchSemaphore(value: 0) @@ -5601,6 +5852,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(rpcClient.attachCalls.first?.requireExisting, true) } + @Test func testPTYBridgeBuffersOutputUntilReadyFrame() throws { let rpcClient = ImmediateOutputThenExitPTYBridgeRPC() let stopped = DispatchSemaphore(value: 0) @@ -5643,6 +5895,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertTrue(responseText.contains("early-output"), responseText) } + @Test func testPTYBridgeForwardsInputWithoutWaitingForWriteCompletion() throws { let rpcClient = DeferredWriteCompletionPTYBridgeRPC() let server = RemotePTYBridgeServer( @@ -5685,6 +5938,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { rpcClient.completeWrites() } + @Test func testPTYBridgeStopRetainsServerUntilCleanupRuns() throws { let rpcClient = ImmediateExitPTYBridgeRPC() let stopped = DispatchSemaphore(value: 0) @@ -5709,6 +5963,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(stopped.wait(timeout: .now() + 2), .success) } + @Test func testPTYBridgeKeepsOutputOpenAfterClientHalfClose() throws { let rpcClient = DelayedOutputPTYBridgeRPC() let stopped = DispatchSemaphore(value: 0) @@ -5748,6 +6003,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(rpcClient.detachSemaphore.wait(timeout: .now() + 0.1), .timedOut) } + @Test func testPTYBridgeKeepsOutputOpenAfterClientHalfCloseWithoutPID() throws { let rpcClient = DelayedOutputPTYBridgeRPC() let stopped = DispatchSemaphore(value: 0) @@ -5787,6 +6043,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(rpcClient.detachSemaphore.wait(timeout: .now() + 0.1), .timedOut) } + @Test func testPTYBridgeDefersHalfCloseUntilAttachCompletes() throws { let attachStarted = DispatchSemaphore(value: 0) let attachGate = DispatchSemaphore(value: 0) @@ -5832,6 +6089,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(rpcClient.detachSemaphore.wait(timeout: .now() + 0.1), .timedOut) } + @Test func testPTYBridgeDetachesWhenClientSocketClosesAfterAttach() throws { let rpcClient = DelayedOutputPTYBridgeRPC() let stopped = DispatchSemaphore(value: 0) @@ -5870,6 +6128,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { XCTAssertEqual(stopped.wait(timeout: .now() + 2), .success) } + @Test func testPTYBridgeClosesBackpressuredOutput() throws { let rpcClient = FloodPTYBridgeRPC() let stopped = DispatchSemaphore(value: 0) @@ -6273,6 +6532,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { } @MainActor + @Test func testNotifyWithWorkspaceHandleKeepsCallerSurfaceFallback() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("notify") @@ -6341,6 +6601,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { } @MainActor + @Test func testNotifyWithWorkspaceHandlePreservesSyncTargetValidation() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("notify-handle") @@ -6415,6 +6676,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { } @MainActor + @Test func testTriggerFlashFallsBackFromStaleCallerWorkspaceAndSurfaceIDs() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("flash") @@ -6528,6 +6790,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { } @MainActor + @Test func testSSHCommandCreatesConfiguresAndSelectsRemoteWorkspaceViaCLI() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("ssh") @@ -6668,6 +6931,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { } @MainActor + @Test func testSSHCommandDoesNotDeferReconnectWhenWhitespaceControlMasterDisablesMultiplexing() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("ssh-controlmaster-no") @@ -6768,6 +7032,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { } @MainActor + @Test func testNotifyPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("notify-tty") @@ -6843,6 +7108,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { } @MainActor + @Test func testNotifyInTmuxPrefersCallerTTYOverStaleValidSurfaceID() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("notify-tmux-tty") @@ -6917,6 +7183,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { } @MainActor + @Test func testTriggerFlashPrefersCallerTTYOverFocusedSurfaceWhenCallerIDsAreStale() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("flash-tty") @@ -7073,6 +7340,7 @@ final class CLINotifyProcessIntegrationTests: XCTestCase { } @MainActor + @Test func testTriggerFlashInTmuxPrefersCallerTTYOverStaleValidSurfaceID() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("flash-tmux-tty") From 9ef971e675ca29da63f384d09d787c83491f9692 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 09:31:01 -0700 Subject: [PATCH 37/39] test: avoid blocking main actor in migrated waits --- .github/swift-file-length-budget.tsv | 4 ++-- ...INotifyProcessIntegrationRegressionTests.swift | 15 ++++++++++----- cmuxTests/WorkspaceRemoteConnectionTests.swift | 15 ++++++++++----- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 24db48c94d9e..201702c81c28 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -9,10 +9,10 @@ 12240 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift 11929 Sources/Panels/BrowserPanel.swift -9584 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +9589 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8017 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift -7485 cmuxTests/WorkspaceRemoteConnectionTests.swift +7490 cmuxTests/WorkspaceRemoteConnectionTests.swift 7354 cmuxTests/WorkspaceUnitTests.swift 6317 cmuxTests/SessionPersistenceTests.swift 6217 cmuxTests/GhosttyConfigTests.swift diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index d9a8b28d5ab2..156a913529ad 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -15,10 +15,6 @@ private extension DispatchSemaphore { } } -private func legacyTimeout(_ timeout: TimeInterval) -> DispatchTime { - .now() + .milliseconds(Int((timeout * 1_000).rounded(.up))) -} - private func legacyFailure(_ message: String) { Issue.record(Comment(rawValue: message.isEmpty ? "Assertion failed" : message)) } @@ -29,7 +25,16 @@ private func expectation(description _: String) -> XCTestExpectation { private func wait(for expectations: [XCTestExpectation], timeout: TimeInterval) { for expectation in expectations { - if expectation.wait(timeout: legacyTimeout(timeout)) == .timedOut { + let deadline = Date().addingTimeInterval(timeout) + var fulfilled = false + while Date() < deadline { + if expectation.wait(timeout: .now()) == .success { + fulfilled = true + break + } + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + if !fulfilled, expectation.wait(timeout: .now()) == .timedOut { legacyFailure("Timed out waiting for expectation") } } diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift index 36461aac85da..b950dd0a64ba 100644 --- a/cmuxTests/WorkspaceRemoteConnectionTests.swift +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -22,10 +22,6 @@ private extension DispatchSemaphore { } } -private func legacyTimeout(_ timeout: TimeInterval) -> DispatchTime { - .now() + .milliseconds(Int((timeout * 1_000).rounded(.up))) -} - private func legacyFailure(_ message: String) { Issue.record(Comment(rawValue: message.isEmpty ? "Assertion failed" : message)) } @@ -36,7 +32,16 @@ private func expectation(description _: String) -> XCTestExpectation { private func wait(for expectations: [XCTestExpectation], timeout: TimeInterval) { for expectation in expectations { - if expectation.wait(timeout: legacyTimeout(timeout)) == .timedOut { + let deadline = Date().addingTimeInterval(timeout) + var fulfilled = false + while Date() < deadline { + if expectation.wait(timeout: .now()) == .success { + fulfilled = true + break + } + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + if !fulfilled, expectation.wait(timeout: .now()) == .timedOut { legacyFailure("Timed out waiting for expectation") } } From 9097e2cb77191d0602b2831b8479e2d65cc45771 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 09:49:58 -0700 Subject: [PATCH 38/39] test: finish Swift Testing migration for hook suite extensions --- .github/swift-file-length-budget.tsv | 8 +- cmuxTests/CLIAuthAliasTests.swift | 6 +- .../CLIGenericHookPersistenceTests.swift | 57 ++-- cmuxTests/CLILegacyHookAliasTests.swift | 6 +- ...ifyProcessIntegrationRegressionTests.swift | 109 ------- cmuxTests/CLINotifyProcessTestSupport.swift | 283 +++++++++++++++++- .../CLIRovoDevHookPersistenceTests.swift | 18 +- ...SHPTYAttachProbeReplyRegressionTests.swift | 1 + .../SSHStartupSignalLifecycleTests.swift | 22 +- cmuxTests/VMSSHCommandTests.swift | 8 +- 10 files changed, 349 insertions(+), 169 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 201702c81c28..21ba04837eb7 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -9,7 +9,7 @@ 12240 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift 11929 Sources/Panels/BrowserPanel.swift -9589 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +9480 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8017 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift 7490 cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -29,7 +29,7 @@ 3926 cmuxTests/TabManagerUnitTests.swift 3896 cmuxTests/WindowAndDragTests.swift 3734 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift -3699 cmuxTests/CLIGenericHookPersistenceTests.swift +3706 cmuxTests/CLIGenericHookPersistenceTests.swift 3397 Sources/CmuxConfig.swift 3364 cmuxTests/TabManagerSessionSnapshotTests.swift 3058 Sources/Update/UpdateTitlebarAccessory.swift @@ -106,12 +106,13 @@ 865 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift -845 cmuxTests/SSHStartupSignalLifecycleTests.swift +847 cmuxTests/SSHStartupSignalLifecycleTests.swift 830 Sources/TaskManagerTypes.swift 825 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift 824 Sources/MainWindowFocusController.swift 810 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift 802 Sources/WorkspaceContentView.swift +799 cmuxTests/CLINotifyProcessTestSupport.swift 797 Sources/ClosedItemHistory.swift 779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift 769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift @@ -201,7 +202,6 @@ 531 Sources/App/WorkspaceRuntimeSettings.swift 530 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift 529 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift -528 cmuxTests/CLINotifyProcessTestSupport.swift 528 cmuxUITests/AutomationSocketUITests.swift 527 CLI/CLISocketPathResolver.swift 526 Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift diff --git a/cmuxTests/CLIAuthAliasTests.swift b/cmuxTests/CLIAuthAliasTests.swift index 547da5518337..8dc1c0d3b3e2 100644 --- a/cmuxTests/CLIAuthAliasTests.swift +++ b/cmuxTests/CLIAuthAliasTests.swift @@ -1,7 +1,9 @@ -import XCTest import Darwin +import Foundation +import Testing extension CLINotifyProcessIntegrationRegressionTests { + @Test func testTopLevelLoginAliasesAuthLogin() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("auth-login") @@ -61,7 +63,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected login alias to call auth.begin_sign_in, saw \(state.commands)" ) } - + @Test func testTopLevelLogoutAliasesAuthLogout() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("auth-logout") diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index e02145f1b932..110df2cf784d 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -1,5 +1,6 @@ -import XCTest import Darwin +import Foundation +import Testing extension CLINotifyProcessIntegrationRegressionTests { struct GenericHookPersistenceScenario { @@ -12,7 +13,7 @@ extension CLINotifyProcessIntegrationRegressionTests { let expectedArguments: [String] let expectedEnvironment: [String: String]? } - + @Test func testGenericHookAgentsPersistSanitizedLaunchCommandsForSessionRestore() throws { let scenarios: [GenericHookPersistenceScenario] = [ GenericHookPersistenceScenario( @@ -279,12 +280,12 @@ extension CLINotifyProcessIntegrationRegressionTests { ] for scenario in scenarios { - try XCTContext.runActivity(named: scenario.agent) { _ in + try runLegacyActivity(named: scenario.agent) { try runGenericHookPersistenceScenario(scenario) } } } - + @Test func testAntigravityStopAndNotificationsUseGenericNotificationPath() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("antigravity-notification") @@ -552,7 +553,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Antigravity error notifications to mark error status, saw \(errorCommands)" ) } - + @Test func testHermesAgentNotificationsUseShellHookExtraPayload() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("hermes-notification") @@ -707,7 +708,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNil(responseSession["lastNotificationStatus"]) XCTAssertEqual(responseSession["runtimeStatus"] as? String, "running") } - + @Test func testHermesAgentSessionEndIsTurnBoundaryButFinalizeTearsDown() throws { // Hermes fires the `on_session_end` plugin hook once per conversation turn // (end of every run_conversation()), not at the true session boundary, and a @@ -862,7 +863,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Hermes on_session_finalize is a true teardown and must consume the restore record" ) } - + @Test func testAntigravityHookInstallUsesNativeHooksJSONShape() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -947,7 +948,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNotNil(cmuxGroup["Notification"]) XCTAssertNotNil(cmuxGroup["PostToolUse"]) } - + @Test func testKiroHookInstallUsesAgentConfigShapeAndPreservesDenyExit() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -1002,7 +1003,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNotNil(hooks["postToolUse"]) XCTAssertNotNil(hooks["stop"]) } - + @Test func testKiroFeedDenyUsesPreToolUseExitCodeTwo() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("kiro-feed-deny") @@ -1083,6 +1084,7 @@ extension CLINotifyProcessIntegrationRegressionTests { /// / `bypass`, the WorkstreamPermissionMode raw values) must exit 0 so /// Kiro proceeds; an unrecognized/malformed mode must fail closed with /// exit 2 rather than silently allowing the tool. + @Test func testKiroFeedAllowModesProceedAndUnknownModeDenies() throws { func runKiroDecision(mode: String) throws -> ProcessRunResult { let cliPath = try bundledCLIPath() @@ -1149,6 +1151,7 @@ extension CLINotifyProcessIntegrationRegressionTests { /// tools (`fs_write`) still emit. Guards that suppression keys off the /// classified wire name (`PostToolUse`) rather than the raw camelCase hook /// event — i.e. the suppression actually triggers for real Kiro events. + @Test func testKiroStandardLevelSuppressesReadOnlyToolFeedEvents() throws { func feedPushCount(forTool tool: String) throws -> Int { let cliPath = try bundledCLIPath() @@ -1193,7 +1196,7 @@ extension CLINotifyProcessIntegrationRegressionTests { // server to record it (generous timeout to avoid flaking on the // socket/process round-trip under CI load). A suppressed event // sends nothing, so this wait simply times out silently. - _ = XCTWaiter().wait(for: [serverHandled], timeout: 5) + _ = waitForLegacyExpectations([serverHandled], timeout: 5, recordFailure: false) return state.commands.filter { $0.contains("feed.push") }.count } @@ -1202,7 +1205,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertGreaterThan(try feedPushCount(forTool: "fs_write"), 0, "mutating kiro tool at standard level must still emit telemetry") } - + @Test func testLowercaseGenericFeedToolsStayTelemetryOutsideKiro() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("generic-lowercase-feed-tool") @@ -1270,7 +1273,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(event["_ppid"] as? Int, 626262) XCTAssertEqual(waitTimeout.doubleValue, 0) } - + @Test func testAntigravityFeedHookMissingSessionIdUsesStableFallback() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("antigravity-feed-stable-session") @@ -1357,7 +1360,7 @@ extension CLINotifyProcessIntegrationRegressionTests { ) XCTAssertEqual(events.compactMap { $0["_ppid"] as? Int }, [424242, 424242, 424242]) } - + @Test func testGrokNotificationHookUsesPayloadMessageAndStopDoesNotSendGenericNotification() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("grok-notification") @@ -1935,7 +1938,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Fallback notifications should preserve the saved needs-input status, saw \(neutralFallbackCommands)" ) } - + @Test func testGrokStopFallbackCompletionsFireForTwoConcurrentThreads() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("grok-two-threads") @@ -2114,7 +2117,7 @@ extension CLINotifyProcessIntegrationRegressionTests { ) } } - + @Test func testGrokStopNotificationFallsBackWhenTranscriptCwdIsUnavailable() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("grok-stop-without-cwd") @@ -2215,7 +2218,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Generic Grok completion after Stop fallback must not double-notify, saw \(duplicateCompletionCommands)" ) } - + @Test func testGrokNotificationStillFiresOnRepeatedPromptWhenFeedTelemetryDoesNotReply() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("grok-repeat") @@ -2313,7 +2316,7 @@ extension CLINotifyProcessIntegrationRegressionTests { ) } } - + @Test func testGrokSessionEndDoesNotDropRoutingForLaterChatMessages() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("grok-turns") @@ -2491,7 +2494,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Grok route to remain available after multiple chat-message SessionEnd events" ) } - + @Test func testGrokCompletionDoesNotResetStatusWhileSiblingSessionRuns() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("grok-sibling-status") @@ -2630,7 +2633,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Completing Grok session must not reset the shared Grok status while a sibling session is running, saw \(completionCommands)" ) } - + @Test func testGrokCompletionResetsStatusWhenSiblingRunningRecordHasDeadPID() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("grok-stale-sibling-status") @@ -2774,7 +2777,7 @@ extension CLINotifyProcessIntegrationRegressionTests { } return encoded } - + @Test func testGrokHookInstallRoutesNotificationEventToNotificationSubcommand() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -2880,7 +2883,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected setup to remove legacy cmux-owned Grok hook file" ) } - + @Test func testGrokHookInstallPinsInstallingCLIAndSocketWithoutCMUXInterpolation() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -2940,7 +2943,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Grok hook commands must not depend on CMUX environment interpolation, saw \(allCommands)" ) } - + @Test func testGrokHookInstallPreservesUserWrappedLegacyCommands() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -3006,7 +3009,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected setup to remove only exact cmux-owned legacy commands, saw \(commands)" ) } - + @Test func testGrokHookInstallPreservesLegacyFileMetadataWhenPruningOwnedHooks() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -3057,7 +3060,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(legacyJSON["version"] as? Int, 1) XCTAssertNil(legacyJSON["hooks"]) } - + @Test func testCodexHookInstallPrefersLaunchingAppBundledCLI() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -3140,7 +3143,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Codex setup should collapse duplicate cmux-owned prompt hooks to one entry, saw \(allCommands)" ) } - + @Test func testGrokHookInstallRejectsFileAtHooksDirectory() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -3294,6 +3297,7 @@ extension CLINotifyProcessIntegrationRegressionTests { /// drop CODEX_HOME, so the resume/fork binding fell back to a bare `codex resume ` against the /// default home and failed with "No saved session found". The hook must still carry the captured /// CODEX_HOME into the resume binding's environment. + @Test func testCodexHookPreservesCodexHomeWhenLaunchCommandUnavailable() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-home") @@ -3427,6 +3431,7 @@ extension CLINotifyProcessIntegrationRegressionTests { /// When the agent process's controlling TTY is bound to a different, accessible surface in the same /// workspace, that TTY is ground truth and must override the leaked env surface — otherwise the /// session routes to the wrong pane and the no-pid-gate resume binding persists it across reload. + @Test func testCodexHookOverridesLeakedEnvSurfaceWithProcessTTYBinding() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-surface") @@ -3522,6 +3527,7 @@ extension CLINotifyProcessIntegrationRegressionTests { /// longer resolves to an accessible surface. That must NOT abort hook routing — the agent's own /// TTY-bound pane is valid, so the hook recovers and still publishes the resume binding there /// instead of no-op'ing (which would silently lose the session across reload). + @Test func testCodexHookRecoversFromStaleEnvSurfaceViaProcessTTYBinding() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-stale") @@ -3612,6 +3618,7 @@ extension CLINotifyProcessIntegrationRegressionTests { /// argv so they never get a resume/fork binding. The CODEX_HOME env-only fallback must NOT bypass /// that — a captured-but-rejected argv keeps returning nil even when CODEX_HOME is present, so no /// env-only record is persisted for the one-shot command. + @Test func testCodexHookDoesNotPersistEnvOnlyRecordForNonRestorableExec() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("codex-exec") diff --git a/cmuxTests/CLILegacyHookAliasTests.swift b/cmuxTests/CLILegacyHookAliasTests.swift index 1b8ecc7463c6..9a10b0174d29 100644 --- a/cmuxTests/CLILegacyHookAliasTests.swift +++ b/cmuxTests/CLILegacyHookAliasTests.swift @@ -1,7 +1,9 @@ -import XCTest import Darwin +import Foundation +import Testing extension CLINotifyProcessIntegrationRegressionTests { + @Test func testLegacyCodexHookAliasReturnsJSONWithoutHelpAndPersistsSession() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("legacy-codex") @@ -66,7 +68,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(session["surfaceId"] as? String, surfaceId) XCTAssertNotNil(session["launchCommand"] as? [String: Any]) } - + @Test func testLegacyFeedHookAliasReturnsJSONWithoutHelpOutsideCmuxTerminal() throws { let cliPath = try bundledCLIPath() var environment = ProcessInfo.processInfo.environment diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 156a913529ad..de93c706755b 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -7,115 +7,6 @@ import Testing @testable import cmux #endif -private typealias XCTestExpectation = DispatchSemaphore - -private extension DispatchSemaphore { - func fulfill() { - signal() - } -} - -private func legacyFailure(_ message: String) { - Issue.record(Comment(rawValue: message.isEmpty ? "Assertion failed" : message)) -} - -private func expectation(description _: String) -> XCTestExpectation { - DispatchSemaphore(value: 0) -} - -private func wait(for expectations: [XCTestExpectation], timeout: TimeInterval) { - for expectation in expectations { - let deadline = Date().addingTimeInterval(timeout) - var fulfilled = false - while Date() < deadline { - if expectation.wait(timeout: .now()) == .success { - fulfilled = true - break - } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - if !fulfilled, expectation.wait(timeout: .now()) == .timedOut { - legacyFailure("Timed out waiting for expectation") - } - } -} - -private func XCTFail(_ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { - legacyFailure(message()) -} - -private func XCTAssertTrue(_ expression: @autoclosure () throws -> Bool, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { - do { - if try !expression() { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") - } -} - -private func XCTAssertFalse(_ expression: @autoclosure () throws -> Bool, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { - do { - if try expression() { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") - } -} - -private func XCTAssertEqual(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { - do { - let lhs = try expression1() - let rhs = try expression2() - if lhs != rhs { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") - } -} - -private func XCTAssertNotEqual(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { - do { - let lhs = try expression1() - let rhs = try expression2() - if lhs == rhs { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") - } -} - -private func XCTAssertNil(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { - do { - if try expression() != nil { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") - } -} - -private func XCTAssertNotNil(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) { - do { - if try expression() == nil { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") - } -} - -private func XCTUnwrap(_ expression: @autoclosure () throws -> T?, _ message: @autoclosure () -> String = "", file _: StaticString = #filePath, line _: UInt = #line) throws -> T { - guard let value = try expression() else { - legacyFailure(message()) - throw NSError(domain: "CLINotifyProcessIntegrationRegressionTests", code: 1) - } - return value -} - @Suite(.serialized) final class CLINotifyProcessIntegrationRegressionTests { private var teardownBlocks: [() -> Void] = [] diff --git a/cmuxTests/CLINotifyProcessTestSupport.swift b/cmuxTests/CLINotifyProcessTestSupport.swift index 018ef47d2775..e7d583e91503 100644 --- a/cmuxTests/CLINotifyProcessTestSupport.swift +++ b/cmuxTests/CLINotifyProcessTestSupport.swift @@ -1,7 +1,278 @@ -import XCTest import Darwin +import Foundation +import Testing extension CLINotifyProcessIntegrationRegressionTests { + final class LegacyExpectation: @unchecked Sendable { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var fulfilled = false + private let description: String + + init(description: String) { + self.description = description + } + + func fulfill() { + lock.lock() + let shouldSignal = !fulfilled + fulfilled = true + lock.unlock() + + if shouldSignal { + semaphore.signal() + } + } + + func pollFulfilled() -> Bool { + lock.lock() + if fulfilled { + lock.unlock() + return true + } + lock.unlock() + + guard semaphore.wait(timeout: .now()) == .success else { + return false + } + + lock.lock() + fulfilled = true + lock.unlock() + return true + } + + var timeoutMessage: String { + "Timed out waiting for expectation: \(description)" + } + } + + func expectation(description: String) -> LegacyExpectation { + LegacyExpectation(description: description) + } + + func wait( + for expectations: [LegacyExpectation], + timeout: TimeInterval, + file: StaticString = #filePath, + line: UInt = #line + ) { + _ = waitForLegacyExpectations( + expectations, + timeout: timeout, + recordFailure: true, + file: file, + line: line + ) + } + + @discardableResult + func waitForLegacyExpectations( + _ expectations: [LegacyExpectation], + timeout: TimeInterval, + recordFailure: Bool, + file _: StaticString = #filePath, + line _: UInt = #line + ) -> Bool { + var allFulfilled = true + for expectation in expectations { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if expectation.pollFulfilled() { + break + } + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + + if !expectation.pollFulfilled() { + allFulfilled = false + if recordFailure { + legacyFailure(expectation.timeoutMessage) + } + } + } + return allFulfilled + } + + func runLegacyActivity(named _: String, _ body: () throws -> T) rethrows -> T { + try body() + } + + func legacyFailure(_ message: String) { + Issue.record(Comment(rawValue: message.isEmpty ? "Assertion failed" : message)) + } + + func XCTFail( + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) { + legacyFailure(message()) + } + + func XCTAssertTrue( + _ expression: @autoclosure () throws -> Bool, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) { + do { + if try !expression() { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } + } + + func XCTAssertFalse( + _ expression: @autoclosure () throws -> Bool, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) { + do { + if try expression() { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } + } + + func XCTAssertEqual( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs != rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } + } + + func XCTAssertNotEqual( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs == rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } + } + + func XCTAssertGreaterThan( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs <= rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } + } + + func XCTAssertGreaterThanOrEqual( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs < rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } + } + + func XCTAssertLessThan( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs >= rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } + } + + func XCTAssertNil( + _ expression: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) { + do { + if try expression() != nil { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } + } + + func XCTAssertNotNil( + _ expression: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) { + do { + if try expression() == nil { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") + } + } + + func XCTUnwrap( + _ expression: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line + ) throws -> T { + guard let value = try expression() else { + legacyFailure(message()) + throw NSError(domain: "CLINotifyProcessIntegrationRegressionTests", code: 1) + } + return value + } + struct ProcessRunResult { let status: Int32 let stdout: String @@ -135,7 +406,7 @@ extension CLINotifyProcessIntegrationRegressionTests { return FileManager.default.fileExists(atPath: path) } - func startBridgeErrorServer(listenerFD: Int32, message: String) -> XCTestExpectation { + func startBridgeErrorServer(listenerFD: Int32, message: String) -> LegacyExpectation { let handled = expectation(description: "pty bridge error server handled") DispatchQueue.global(qos: .userInitiated).async { defer { handled.fulfill() } @@ -185,7 +456,7 @@ extension CLINotifyProcessIntegrationRegressionTests { return handled } - func startBridgeReadyThenCloseServer(listenerFD: Int32) -> XCTestExpectation { + func startBridgeReadyThenCloseServer(listenerFD: Int32) -> LegacyExpectation { let handled = expectation(description: "pty bridge ready close server handled") DispatchQueue.global(qos: .userInitiated).async { defer { handled.fulfill() } @@ -235,7 +506,7 @@ extension CLINotifyProcessIntegrationRegressionTests { return handled } - func startBridgeReadyThenResetAfterClientEOFServer(listenerFD: Int32) -> XCTestExpectation { + func startBridgeReadyThenResetAfterClientEOFServer(listenerFD: Int32) -> LegacyExpectation { let handled = expectation(description: "pty bridge ready reset server handled") DispatchQueue.global(qos: .userInitiated).async { defer { handled.fulfill() } @@ -313,7 +584,7 @@ extension CLINotifyProcessIntegrationRegressionTests { state: MockSocketServerState, fulfillWhen: (@Sendable (String) -> Bool)? = nil, handler: @escaping @Sendable (String) -> String - ) -> XCTestExpectation { + ) -> LegacyExpectation { startMockServerAllowingNoResponse( listenerFD: listenerFD, state: state, @@ -328,7 +599,7 @@ extension CLINotifyProcessIntegrationRegressionTests { state: MockSocketServerState, fulfillWhen: (@Sendable (String) -> Bool)? = nil, handler: @escaping @Sendable (String) -> String? - ) -> XCTestExpectation { + ) -> LegacyExpectation { let handled = expectation(description: "cli mock socket handled") DispatchQueue.global(qos: .userInitiated).async { var didFulfill = false diff --git a/cmuxTests/CLIRovoDevHookPersistenceTests.swift b/cmuxTests/CLIRovoDevHookPersistenceTests.swift index 83d3912a493b..343f420a8c6f 100644 --- a/cmuxTests/CLIRovoDevHookPersistenceTests.swift +++ b/cmuxTests/CLIRovoDevHookPersistenceTests.swift @@ -1,7 +1,9 @@ -import XCTest import Darwin +import Foundation +import Testing extension CLINotifyProcessIntegrationRegressionTests { + @Test func testRovoDevPromptSubmitInfersSessionIdFromWorkspaceMetadataAndPersistsLaunchCommand() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("rovo-infer") @@ -94,7 +96,7 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Rovo Dev prompt status to target current workspace, saw \(state.commands)" ) } - + @Test func testRovoDevPromptSubmitInfersNewestMatchingWorkspaceMetadata() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("rovo-newest") @@ -181,7 +183,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNil(sessions["rovo-older-session"] as? [String: Any]) XCTAssertNotNil(sessions["rovo-newer-session"] as? [String: Any]) } - + @Test func testRovoDevPromptSubmitReadsConfiguredPersistenceDirWithCommentsHashAndApostrophePath() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("rovo-config") @@ -278,7 +280,7 @@ extension CLINotifyProcessIntegrationRegressionTests { let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) XCTAssertNotNil(sessions[sessionId] as? [String: Any]) } - + @Test func testRovoDevPromptSubmitWithoutCwdDoesNotInferUnrelatedSession() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("rovo-nocwd") @@ -350,7 +352,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNil(sessions[unrelatedSessionId] as? [String: Any]) } } - + @Test func testRovoDevInstallDoesNotReplaceUnreadableConfigPath() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -380,7 +382,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertTrue(result.stderr.contains("could not be read"), result.stderr) XCTAssertTrue(FileManager.default.fileExists(atPath: sentinelURL.path)) } - + @Test func testRovoAliasCreatesConfigDirAndInstallsRovoDevHooksFromSetup() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -417,7 +419,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertTrue(config.contains(#"hooks rovodev prompt-submit"#), config) XCTAssertTrue(config.contains(#"CMUX_BUNDLED_CLI_PATH"#), config) } - + @Test func testSetupHooksRejectsConflictingAgentFilters() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory @@ -440,7 +442,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNotEqual(result.status, 0) XCTAssertTrue(result.stderr.contains("Conflicting hooks target"), result.stderr) } - + @Test func testSetupHooksRejectsMultiplePositionalTargets() throws { let cliPath = try bundledCLIPath() let root = FileManager.default.temporaryDirectory diff --git a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift index 38a784c2c07a..fde55a613c91 100644 --- a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift +++ b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing extension CLINotifyProcessIntegrationRegressionTests { + @Test func testSSHPTYAttachPreservesPipedProbeLikeInputBeforeForwardingInput() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("sshptyprobe") diff --git a/cmuxTests/SSHStartupSignalLifecycleTests.swift b/cmuxTests/SSHStartupSignalLifecycleTests.swift index 6c825ecd62c3..d9ce94c1bf02 100644 --- a/cmuxTests/SSHStartupSignalLifecycleTests.swift +++ b/cmuxTests/SSHStartupSignalLifecycleTests.swift @@ -1,7 +1,9 @@ -import XCTest import Darwin +import Foundation +import Testing extension CLINotifyProcessIntegrationRegressionTests { + @Test func testSSHPaneCloseSignalDoesNotReportSessionEndToSharedTransport() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -64,7 +66,7 @@ extension CLINotifyProcessIntegrationRegressionTests { ) } } - + @Test func testSSHPaneCloseSignalDoesNotTerminateWrappedSSHChild() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -149,7 +151,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertTrue(sessionEndCalls.isEmpty, recordedCalls) } } - + @Test func testSSHStartupRetriesTransientSSHExitBeforeReportingSessionEnd() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -209,7 +211,7 @@ extension CLINotifyProcessIntegrationRegressionTests { .filter { $0.contains("ssh-session-end") } XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) } - + @Test func testSSHStartupRemovesStaleCmuxControlSocketBeforeLaunchingPaneSSH() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -290,7 +292,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertTrue(sshLog.contains("-G"), sshLog) XCTAssertTrue(sshLog.contains("-O check"), sshLog) } - + @Test func testSSHStartupStopsAtConfiguredReconnectLimit() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -346,7 +348,7 @@ extension CLINotifyProcessIntegrationRegressionTests { .filter { $0.contains("ssh-session-end") } XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) } - + @Test func testSSHStartupDoesNotRetryNonTransientSSHExit() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -401,7 +403,7 @@ extension CLINotifyProcessIntegrationRegressionTests { .filter { $0.contains("ssh-session-end") } XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) } - + @Test func testSSHSignalDerivedChildExitReportsSessionEnd() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -449,7 +451,7 @@ extension CLINotifyProcessIntegrationRegressionTests { .filter { $0.contains("ssh-session-end") } XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) } - + @Test func testSSHSignalDuringReconnectDelayDoesNotStartAnotherSSH() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -509,7 +511,7 @@ extension CLINotifyProcessIntegrationRegressionTests { .filter { $0.contains("ssh-session-end") } XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) } - + @Test func testSSHStartupPrintsFinalErrorBannerWhenStderrIsCaptured() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory @@ -554,7 +556,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertTrue(result.stderr.contains("[cmux] ssh exited with status 1."), result.stderr) XCTAssertTrue(result.stderr.contains("[cmux] press Enter to close this pane."), result.stderr) } - + @Test func testSSHStartupForwardsStdinToBackgroundedSSH() throws { // Regression test for cmux ssh sessions where output flowed back from // the remote (prompt rendered) but typed keystrokes never reached the diff --git a/cmuxTests/VMSSHCommandTests.swift b/cmuxTests/VMSSHCommandTests.swift index b4991cf87844..ef7ed90816f6 100644 --- a/cmuxTests/VMSSHCommandTests.swift +++ b/cmuxTests/VMSSHCommandTests.swift @@ -1,7 +1,9 @@ -import XCTest import Darwin +import Foundation +import Testing extension CLINotifyProcessIntegrationRegressionTests { + @Test func testVMSSHOpensManagedWorkspaceThroughSharedSSHPath() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("vm-ssh") @@ -120,7 +122,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertNotNil(configureParams["terminal_startup_command"] as? String) XCTAssertNotNil(configureParams["relay_port"] as? Int) } - + @Test func testSSHCommandGlobalWindowOverridesCallerEnvironment() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("ssh-global-window") @@ -218,7 +220,7 @@ extension CLINotifyProcessIntegrationRegressionTests { ["window.focus", "workspace.create", "workspace.remote.configure"] ) } - + @Test func testVMSSHInfoRemainsPrintOnly() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("vm-info") From cb1ea2d611f3e2707eedf850bce08300c2727466 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 22 Jun 2026 10:01:35 -0700 Subject: [PATCH 39/39] test: avoid suite captures in migrated hook assertions --- .github/swift-file-length-budget.tsv | 2 +- cmuxTests/CLIAuthAliasTests.swift | 20 +- .../CLIGenericHookPersistenceTests.swift | 1026 +++---- cmuxTests/CLILegacyHookAliasTests.swift | 34 +- ...ifyProcessIntegrationRegressionTests.swift | 2420 ++++++++--------- cmuxTests/CLINotifyProcessTestSupport.swift | 460 ++-- .../CLIRovoDevHookPersistenceTests.swift | 104 +- ...SHPTYAttachProbeReplyRegressionTests.swift | 2 +- .../SSHStartupSignalLifecycleTests.swift | 126 +- cmuxTests/VMSSHCommandTests.swift | 76 +- 10 files changed, 2136 insertions(+), 2134 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 21ba04837eb7..d33ce84990e8 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -112,7 +112,7 @@ 824 Sources/MainWindowFocusController.swift 810 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift 802 Sources/WorkspaceContentView.swift -799 cmuxTests/CLINotifyProcessTestSupport.swift +801 cmuxTests/CLINotifyProcessTestSupport.swift 797 Sources/ClosedItemHistory.swift 779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift 769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift diff --git a/cmuxTests/CLIAuthAliasTests.swift b/cmuxTests/CLIAuthAliasTests.swift index 8dc1c0d3b3e2..8b5d7001a2a8 100644 --- a/cmuxTests/CLIAuthAliasTests.swift +++ b/cmuxTests/CLIAuthAliasTests.swift @@ -54,11 +54,11 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "Opening sign-in popup on the cmux web app.\nSigned in as dev@example.com.\n") - XCTAssertTrue( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "Opening sign-in popup on the cmux web app.\nSigned in as dev@example.com.\n") + legacyAssertTrue( state.commands.contains { $0.contains(#""method":"auth.begin_sign_in""#) }, "Expected login alias to call auth.begin_sign_in, saw \(state.commands)" ) @@ -114,11 +114,11 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "Signed out.\n") - XCTAssertTrue( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "Signed out.\n") + legacyAssertTrue( state.commands.contains { $0.contains(#""method":"auth.sign_out""#) }, "Expected logout alias to call auth.sign_out, saw \(state.commands)" ) diff --git a/cmuxTests/CLIGenericHookPersistenceTests.swift b/cmuxTests/CLIGenericHookPersistenceTests.swift index 110df2cf784d..67611443d81c 100644 --- a/cmuxTests/CLIGenericHookPersistenceTests.swift +++ b/cmuxTests/CLIGenericHookPersistenceTests.swift @@ -339,7 +339,7 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: input, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } @@ -347,9 +347,9 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-start", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"# ) - XCTAssertFalse(start.timedOut, start.stderr) - XCTAssertEqual(start.status, 0, start.stderr) - XCTAssertEqual(start.stdout, "{}\n") + legacyAssertFalse(start.timedOut, start.stderr) + legacyAssertEqual(start.status, 0, start.stderr) + legacyAssertEqual(start.stdout, "{}\n") let backgroundMessage = "Antigravity is waiting on background work" let backgroundStopCommandStart = state.commands.count @@ -357,20 +357,20 @@ extension CLINotifyProcessIntegrationRegressionTests { "stop", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"Stop","last_assistant_message":"\#(backgroundMessage)","fullyIdle":false}"# ) - XCTAssertFalse(backgroundStop.timedOut, backgroundStop.stderr) - XCTAssertEqual(backgroundStop.status, 0, backgroundStop.stderr) - XCTAssertEqual(backgroundStop.stdout, "{}\n") + legacyAssertFalse(backgroundStop.timedOut, backgroundStop.stderr) + legacyAssertEqual(backgroundStop.status, 0, backgroundStop.stderr) + legacyAssertEqual(backgroundStop.stdout, "{}\n") let backgroundStopCommands = Array(state.commands.dropFirst(backgroundStopCommandStart)) - XCTAssertFalse( + legacyAssertFalse( backgroundStopCommands.contains { $0.hasPrefix("notify_target_async ") }, "Antigravity Stop with active background work must not publish idle notifications, saw \(backgroundStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( backgroundStopCommands.contains { $0.contains("set_status antigravity Running") }, "Antigravity Stop with active background work should keep the session running, saw \(backgroundStopCommands)" ) - XCTAssertFalse( + legacyAssertFalse( backgroundStopCommands.contains { $0.contains("set_status antigravity Idle") }, "Antigravity Stop with active background work must not mark idle, saw \(backgroundStopCommands)" ) @@ -380,16 +380,16 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"Notification","message":"Turn complete in 1.0s.","fullyIdle":false}"# ) - XCTAssertFalse(backgroundDuplicate.timedOut, backgroundDuplicate.stderr) - XCTAssertEqual(backgroundDuplicate.status, 0, backgroundDuplicate.stderr) - XCTAssertEqual(backgroundDuplicate.stdout, "{}\n") + legacyAssertFalse(backgroundDuplicate.timedOut, backgroundDuplicate.stderr) + legacyAssertEqual(backgroundDuplicate.status, 0, backgroundDuplicate.stderr) + legacyAssertEqual(backgroundDuplicate.stdout, "{}\n") let backgroundDuplicateCommands = Array(state.commands.dropFirst(backgroundDuplicateCommandStart)) - XCTAssertFalse( + legacyAssertFalse( backgroundDuplicateCommands.contains { $0.hasPrefix("notify_target_async ") }, "Idle-classified Antigravity notifications must not double-notify while background work is active, saw \(backgroundDuplicateCommands)" ) - XCTAssertFalse( + legacyAssertFalse( backgroundDuplicateCommands.contains { $0.contains("set_status antigravity Idle") }, "Idle-classified Antigravity notifications must not override the running status while background work is active, saw \(backgroundDuplicateCommands)" ) @@ -399,33 +399,33 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-start", input: #"{"session_id":"\#(missingFullyIdleSessionId)","cwd":"\#(root.path)","hook_event_name":"SessionStart"}"# ) - XCTAssertFalse(missingFullyIdleStart.timedOut, missingFullyIdleStart.stderr) - XCTAssertEqual(missingFullyIdleStart.status, 0, missingFullyIdleStart.stderr) - XCTAssertEqual(missingFullyIdleStart.stdout, "{}\n") + legacyAssertFalse(missingFullyIdleStart.timedOut, missingFullyIdleStart.stderr) + legacyAssertEqual(missingFullyIdleStart.status, 0, missingFullyIdleStart.stderr) + legacyAssertEqual(missingFullyIdleStart.stdout, "{}\n") let missingFullyIdleBackgroundStop = runAntigravityHook( "stop", input: #"{"session_id":"\#(missingFullyIdleSessionId)","cwd":"\#(root.path)","hook_event_name":"Stop","last_assistant_message":"Background work still running","fullyIdle":false}"# ) - XCTAssertFalse(missingFullyIdleBackgroundStop.timedOut, missingFullyIdleBackgroundStop.stderr) - XCTAssertEqual(missingFullyIdleBackgroundStop.status, 0, missingFullyIdleBackgroundStop.stderr) - XCTAssertEqual(missingFullyIdleBackgroundStop.stdout, "{}\n") + legacyAssertFalse(missingFullyIdleBackgroundStop.timedOut, missingFullyIdleBackgroundStop.stderr) + legacyAssertEqual(missingFullyIdleBackgroundStop.status, 0, missingFullyIdleBackgroundStop.stderr) + legacyAssertEqual(missingFullyIdleBackgroundStop.stdout, "{}\n") let missingFullyIdleNotificationCommandStart = state.commands.count let missingFullyIdleNotification = runAntigravityHook( "notification", input: #"{"session_id":"\#(missingFullyIdleSessionId)","cwd":"\#(root.path)","hook_event_name":"Notification","message":"Turn complete in 2.0s."}"# ) - XCTAssertFalse(missingFullyIdleNotification.timedOut, missingFullyIdleNotification.stderr) - XCTAssertEqual(missingFullyIdleNotification.status, 0, missingFullyIdleNotification.stderr) - XCTAssertEqual(missingFullyIdleNotification.stdout, "{}\n") + legacyAssertFalse(missingFullyIdleNotification.timedOut, missingFullyIdleNotification.stderr) + legacyAssertEqual(missingFullyIdleNotification.status, 0, missingFullyIdleNotification.stderr) + legacyAssertEqual(missingFullyIdleNotification.stdout, "{}\n") let missingFullyIdleNotificationCommands = Array(state.commands.dropFirst(missingFullyIdleNotificationCommandStart)) - XCTAssertTrue( + legacyAssertTrue( missingFullyIdleNotificationCommands.contains { $0.hasPrefix("notify_target_async ") }, "Antigravity idle notifications without fullyIdle must publish instead of staying suppressed, saw \(missingFullyIdleNotificationCommands)" ) - XCTAssertFalse( + legacyAssertFalse( missingFullyIdleNotificationCommands.contains { $0.contains("set_status antigravity Idle") }, "Antigravity idle notifications must not reset the shared status while another background session is running, saw \(missingFullyIdleNotificationCommands)" ) @@ -436,19 +436,19 @@ extension CLINotifyProcessIntegrationRegressionTests { "stop", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"AfterAgent","last_assistant_message":"\#(stopMessage)"}"# ) - XCTAssertFalse(stop.timedOut, stop.stderr) - XCTAssertEqual(stop.status, 0, stop.stderr) - XCTAssertEqual(stop.stdout, "{}\n") + legacyAssertFalse(stop.timedOut, stop.stderr) + legacyAssertEqual(stop.status, 0, stop.stderr) + legacyAssertEqual(stop.stdout, "{}\n") let stopCommands = Array(state.commands.dropFirst(stopCommandStart)) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Antigravity|Completed in ") && $0.contains(stopMessage) }, "Expected Antigravity stop to publish a turn-completion notification, saw \(stopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.contains("set_status antigravity Idle") }, "Expected Antigravity stop to leave the session idle, saw \(stopCommands)" ) @@ -458,16 +458,16 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-end", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"SessionEnd"}"# ) - XCTAssertFalse(sessionEnd.timedOut, sessionEnd.stderr) - XCTAssertEqual(sessionEnd.status, 0, sessionEnd.stderr) - XCTAssertEqual(sessionEnd.stdout, "{}\n") + legacyAssertFalse(sessionEnd.timedOut, sessionEnd.stderr) + legacyAssertEqual(sessionEnd.status, 0, sessionEnd.stderr) + legacyAssertEqual(sessionEnd.stdout, "{}\n") let sessionEndCommands = Array(state.commands.dropFirst(sessionEndCommandStart)) - XCTAssertTrue( + legacyAssertTrue( sessionEndCommands.contains { $0.contains("feed.push") }, "Expected Antigravity SessionEnd to emit feed telemetry, saw \(sessionEndCommands)" ) - XCTAssertFalse( + legacyAssertFalse( sessionEndCommands.contains { $0.hasPrefix("clear_agent_pid antigravity.") }, "Antigravity SessionEnd is a turn boundary and must not clear saved routing, saw \(sessionEndCommands)" ) @@ -477,12 +477,12 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"Notification","message":"Turn complete in 2.0s."}"# ) - XCTAssertFalse(duplicateCompletion.timedOut, duplicateCompletion.stderr) - XCTAssertEqual(duplicateCompletion.status, 0, duplicateCompletion.stderr) - XCTAssertEqual(duplicateCompletion.stdout, "{}\n") + legacyAssertFalse(duplicateCompletion.timedOut, duplicateCompletion.stderr) + legacyAssertEqual(duplicateCompletion.status, 0, duplicateCompletion.stderr) + legacyAssertEqual(duplicateCompletion.stdout, "{}\n") let duplicateCompletionCommands = Array(state.commands.dropFirst(duplicateCompletionCommandStart)) - XCTAssertFalse( + legacyAssertFalse( duplicateCompletionCommands.contains { $0.hasPrefix("notify_target_async ") }, "Antigravity turn-completion notification must not double-notify after stop already did, saw \(duplicateCompletionCommands)" ) @@ -493,18 +493,18 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"Notification","reason":"permission_prompt","message":"\#(permissionMessage)"}"# ) - XCTAssertFalse(permission.timedOut, permission.stderr) - XCTAssertEqual(permission.status, 0, permission.stderr) - XCTAssertEqual(permission.stdout, "{}\n") + legacyAssertFalse(permission.timedOut, permission.stderr) + legacyAssertEqual(permission.status, 0, permission.stderr) + legacyAssertEqual(permission.stdout, "{}\n") let permissionCommands = Array(state.commands.dropFirst(permissionCommandStart)) - XCTAssertTrue( + legacyAssertTrue( permissionCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Antigravity|Permission|\(permissionMessage)") }, "Expected Antigravity permission notifications to publish through cmux, saw \(permissionCommands)" ) - XCTAssertTrue( + legacyAssertTrue( permissionCommands.contains { $0.contains("set_status antigravity Antigravity needs input") }, "Expected Antigravity permission notifications to mark needs-input, saw \(permissionCommands)" ) @@ -515,18 +515,18 @@ extension CLINotifyProcessIntegrationRegressionTests { "stop", input: #"{"conversationId":"\#(sessionId)","workspacePaths":["\#(root.path)"],"hook_event_name":"Stop","terminationReason":"error","error":"\#(stopErrorMessage)","fullyIdle":true}"# ) - XCTAssertFalse(stopError.timedOut, stopError.stderr) - XCTAssertEqual(stopError.status, 0, stopError.stderr) - XCTAssertEqual(stopError.stdout, "{}\n") + legacyAssertFalse(stopError.timedOut, stopError.stderr) + legacyAssertEqual(stopError.status, 0, stopError.stderr) + legacyAssertEqual(stopError.stdout, "{}\n") let stopErrorCommands = Array(state.commands.dropFirst(stopErrorCommandStart)) - XCTAssertTrue( + legacyAssertTrue( stopErrorCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Antigravity|Error|\(stopErrorMessage)") }, "Expected Antigravity Stop errors to publish through cmux, saw \(stopErrorCommands)" ) - XCTAssertTrue( + legacyAssertTrue( stopErrorCommands.contains { $0.contains("set_status antigravity Antigravity error") }, "Expected Antigravity Stop errors to mark error status, saw \(stopErrorCommands)" ) @@ -537,18 +537,18 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"Notification","message":"\#(errorMessage)"}"# ) - XCTAssertFalse(error.timedOut, error.stderr) - XCTAssertEqual(error.status, 0, error.stderr) - XCTAssertEqual(error.stdout, "{}\n") + legacyAssertFalse(error.timedOut, error.stderr) + legacyAssertEqual(error.status, 0, error.stderr) + legacyAssertEqual(error.stdout, "{}\n") let errorCommands = Array(state.commands.dropFirst(errorCommandStart)) - XCTAssertTrue( + legacyAssertTrue( errorCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Antigravity|Error|\(errorMessage)") }, "Expected Antigravity error notifications to publish through cmux, saw \(errorCommands)" ) - XCTAssertTrue( + legacyAssertTrue( errorCommands.contains { $0.contains("set_status antigravity Antigravity error") }, "Expected Antigravity error notifications to mark error status, saw \(errorCommands)" ) @@ -607,24 +607,24 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: input, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } func storedHermesSession() throws -> [String: Any] { let storeURL = root.appendingPathComponent("hermes-agent-hook-sessions.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - return try XCTUnwrap(sessions[sessionId] as? [String: Any]) + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + return try legacyUnwrap(sessions[sessionId] as? [String: Any]) } let start = runHermesHook( "session-start", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"on_session_start"}"# ) - XCTAssertFalse(start.timedOut, start.stderr) - XCTAssertEqual(start.status, 0, start.stderr) - XCTAssertEqual(start.stdout, "{}\n") + legacyAssertFalse(start.timedOut, start.stderr) + legacyAssertEqual(start.status, 0, start.stderr) + legacyAssertEqual(start.stdout, "{}\n") let assistantResponse = "Updated README.md and added usage notes." let stopCommandStart = state.commands.count @@ -632,19 +632,19 @@ extension CLINotifyProcessIntegrationRegressionTests { "agent-response", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"post_llm_call","extra":{"user_message":"make the docs clearer","assistant_response":"\#(assistantResponse)","model":"gpt-4","platform":"cli"}}"# ) - XCTAssertFalse(stop.timedOut, stop.stderr) - XCTAssertEqual(stop.status, 0, stop.stderr) - XCTAssertEqual(stop.stdout, "{}\n") + legacyAssertFalse(stop.timedOut, stop.stderr) + legacyAssertEqual(stop.status, 0, stop.stderr) + legacyAssertEqual(stop.stdout, "{}\n") let stopCommands = Array(state.commands.dropFirst(stopCommandStart)) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Hermes Agent|Completed in ") && $0.contains("|\(assistantResponse)") }, "Expected Hermes completion notification to use extra.assistant_response, saw \(stopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.contains("set_status hermes-agent Idle") }, "Expected Hermes completion to leave status idle, saw \(stopCommands)" ) @@ -654,59 +654,59 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"pre_approval_request","extra":{"command":"rm -rf build","description":"recursive delete","pattern_key":"recursive delete","surface":"cli"}}"# ) - XCTAssertFalse(approval.timedOut, approval.stderr) - XCTAssertEqual(approval.status, 0, approval.stderr) - XCTAssertEqual(approval.stdout, "{}\n") + legacyAssertFalse(approval.timedOut, approval.stderr) + legacyAssertEqual(approval.status, 0, approval.stderr) + legacyAssertEqual(approval.stdout, "{}\n") let approvalCommands = Array(state.commands.dropFirst(approvalCommandStart)) - XCTAssertTrue( + legacyAssertTrue( approvalCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Hermes Agent|Permission|recursive delete: rm -rf build") }, "Expected Hermes approval notification to include description and command, saw \(approvalCommands)" ) - XCTAssertTrue( + legacyAssertTrue( approvalCommands.contains { $0.contains("set_status hermes-agent Hermes Agent needs input") }, "Expected Hermes approval notification to mark needs input, saw \(approvalCommands)" ) - XCTAssertFalse( + legacyAssertFalse( approvalCommands.contains { $0.contains(#""method":"feed.push""#) }, "Hermes approval notifications are also installed as feed hooks, so the generic notification handler must not push duplicate feed events. Saw \(approvalCommands)" ) let session = try storedHermesSession() - XCTAssertEqual(session["lastSubtitle"] as? String, "Permission") - XCTAssertEqual(session["lastBody"] as? String, "recursive delete: rm -rf build") - XCTAssertEqual(session["lastNotificationStatus"] as? String, "needsInput") + legacyAssertEqual(session["lastSubtitle"] as? String, "Permission") + legacyAssertEqual(session["lastBody"] as? String, "recursive delete: rm -rf build") + legacyAssertEqual(session["lastNotificationStatus"] as? String, "needsInput") let responseCommandStart = state.commands.count let response = runHermesHook( "approval-response", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"post_approval_response","extra":{"approved":true}}"# ) - XCTAssertFalse(response.timedOut, response.stderr) - XCTAssertEqual(response.status, 0, response.stderr) - XCTAssertEqual(response.stdout, "{}\n") + legacyAssertFalse(response.timedOut, response.stderr) + legacyAssertEqual(response.status, 0, response.stderr) + legacyAssertEqual(response.stdout, "{}\n") let responseCommands = Array(state.commands.dropFirst(responseCommandStart)) - XCTAssertTrue( + legacyAssertTrue( responseCommands.contains { $0.contains("clear_notifications --tab=\(workspaceId) --panel=\(surfaceId)") }, "Expected Hermes approval response to clear the approval notification, saw \(responseCommands)" ) - XCTAssertTrue( + legacyAssertTrue( responseCommands.contains { $0.contains("set_status hermes-agent Running") }, "Expected Hermes approval response to restore running status, saw \(responseCommands)" ) - XCTAssertFalse( + legacyAssertFalse( responseCommands.contains { $0.contains(#""method":"feed.push""#) }, "Hermes approval responses are also installed as feed hooks, so the generic approval handler must not push duplicate feed events. Saw \(responseCommands)" ) let responseSession = try storedHermesSession() - XCTAssertNil(responseSession["lastSubtitle"]) - XCTAssertNil(responseSession["lastBody"]) - XCTAssertNil(responseSession["lastNotificationStatus"]) - XCTAssertEqual(responseSession["runtimeStatus"] as? String, "running") + legacyAssertNil(responseSession["lastSubtitle"]) + legacyAssertNil(responseSession["lastBody"]) + legacyAssertNil(responseSession["lastNotificationStatus"]) + legacyAssertEqual(responseSession["runtimeStatus"] as? String, "running") } @Test func testHermesAgentSessionEndIsTurnBoundaryButFinalizeTearsDown() throws { @@ -772,7 +772,7 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: input, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } @@ -791,18 +791,18 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-start", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"on_session_start"}"# ) - XCTAssertFalse(start.timedOut, start.stderr) - XCTAssertEqual(start.status, 0, start.stderr) + legacyAssertFalse(start.timedOut, start.stderr) + legacyAssertEqual(start.status, 0, start.stderr) // Finish a turn so a restorable record exists for the session. let stop = runHermesHook( "agent-response", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"post_llm_call","extra":{"user_message":"do the thing","assistant_response":"done","model":"gpt-4","platform":"cli"}}"# ) - XCTAssertFalse(stop.timedOut, stop.stderr) - XCTAssertEqual(stop.status, 0, stop.stderr) + legacyAssertFalse(stop.timedOut, stop.stderr) + legacyAssertEqual(stop.status, 0, stop.stderr) - XCTAssertNotNil( + legacyAssertNotNil( try storedHermesSessionIfPresent(), "Expected a Hermes session record to exist before the per-turn session-end hook fires" ) @@ -814,24 +814,24 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-end", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"on_session_end"}"# ) - XCTAssertFalse(sessionEnd.timedOut, sessionEnd.stderr) - XCTAssertEqual(sessionEnd.status, 0, sessionEnd.stderr) - XCTAssertEqual(sessionEnd.stdout, "{}\n") + legacyAssertFalse(sessionEnd.timedOut, sessionEnd.stderr) + legacyAssertEqual(sessionEnd.status, 0, sessionEnd.stderr) + legacyAssertEqual(sessionEnd.stdout, "{}\n") let sessionEndCommands = Array(state.commands.dropFirst(sessionEndCommandStart)) - XCTAssertTrue( + legacyAssertTrue( sessionEndCommands.contains { $0.contains("feed.push") }, "Expected Hermes session-end to emit feed telemetry, saw \(sessionEndCommands)" ) - XCTAssertFalse( + legacyAssertFalse( sessionEndCommands.contains { $0.hasPrefix("clear_agent_pid hermes-agent.") }, "Hermes on_session_end fires per turn and must not clear saved routing, saw \(sessionEndCommands)" ) - XCTAssertFalse( + legacyAssertFalse( sessionEndCommands.contains { $0.contains("surface.resume.clear") }, "Hermes on_session_end fires per turn and must not clear the surface resume binding, saw \(sessionEndCommands)" ) - XCTAssertNotNil( + legacyAssertNotNil( try storedHermesSessionIfPresent(), "Hermes on_session_end fires per turn and must not consume the restore record, saw it removed from the store" ) @@ -845,20 +845,20 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-finalize", input: #"{"session_id":"\#(sessionId)","cwd":"\#(root.path)","hook_event_name":"on_session_finalize"}"# ) - XCTAssertFalse(finalize.timedOut, finalize.stderr) - XCTAssertEqual(finalize.status, 0, finalize.stderr) - XCTAssertEqual(finalize.stdout, "{}\n") + legacyAssertFalse(finalize.timedOut, finalize.stderr) + legacyAssertEqual(finalize.status, 0, finalize.stderr) + legacyAssertEqual(finalize.stdout, "{}\n") let finalizeCommands = Array(state.commands.dropFirst(finalizeCommandStart)) - XCTAssertTrue( + legacyAssertTrue( finalizeCommands.contains { $0.hasPrefix("clear_agent_pid hermes-agent.") }, "Hermes on_session_finalize is a true teardown and must clear agent PID routing, saw \(finalizeCommands)" ) - XCTAssertTrue( + legacyAssertTrue( finalizeCommands.contains { $0.contains("surface.resume.clear") }, "Hermes on_session_finalize is a true teardown and must clear the surface resume binding, saw \(finalizeCommands)" ) - XCTAssertNil( + legacyAssertNil( try storedHermesSessionIfPresent(), "Hermes on_session_finalize is a true teardown and must consume the restore record" ) @@ -883,17 +883,17 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let hookURL = root .appendingPathComponent(".gemini", isDirectory: true) .appendingPathComponent("config", isDirectory: true) .appendingPathComponent("hooks.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) - XCTAssertNil(json["hooks"]) + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) + legacyAssertNil(json["hooks"]) - let cmuxGroup = try XCTUnwrap(json["cmux"] as? [String: Any]) + let cmuxGroup = try legacyUnwrap(json["cmux"] as? [String: Any]) let allCommands = cmuxGroup.values .compactMap { $0 as? [[String: Any]] } .flatMap { entries in @@ -908,25 +908,25 @@ extension CLINotifyProcessIntegrationRegressionTests { return commands } } - XCTAssertFalse(allCommands.isEmpty) - XCTAssertTrue( + legacyAssertFalse(allCommands.isEmpty) + legacyAssertTrue( allCommands.allSatisfy { $0.contains("cmux-antigravity-hook-v2") }, "Expected Antigravity hooks to use the pinned dispatch path, saw \(allCommands)" ) - XCTAssertFalse( + legacyAssertFalse( allCommands.contains { $0.contains("'\(root.path)'") || $0.contains("\"\(root.path)\"") }, "Directory-valued CMUX_BUNDLED_CLI_PATH must not be embedded as a hook executable, saw \(allCommands)" ) - XCTAssertFalse( + legacyAssertFalse( allCommands.contains { $0.contains(#"[ -n "$CMUX_SURFACE_ID" ]"#) }, "Antigravity hooks must still dispatch when agy does not preserve CMUX_SURFACE_ID, saw \(allCommands)" ) - let preToolUse = try XCTUnwrap(cmuxGroup["PreToolUse"] as? [[String: Any]]) + let preToolUse = try legacyUnwrap(cmuxGroup["PreToolUse"] as? [[String: Any]]) let preToolCommands = preToolUse .compactMap { $0["hooks"] as? [[String: Any]] } .flatMap { $0 } - XCTAssertTrue( + legacyAssertTrue( preToolCommands.contains { ($0["command"] as? String)?.contains("hooks feed --source antigravity --event PreToolUse") == true && ($0["timeout"] as? Int) == 120 @@ -934,19 +934,19 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Antigravity PreToolUse feed hook with second-based timeout, saw \(preToolCommands)" ) - let stop = try XCTUnwrap(cmuxGroup["Stop"] as? [[String: Any]]) - XCTAssertTrue( + let stop = try legacyUnwrap(cmuxGroup["Stop"] as? [[String: Any]]) + legacyAssertTrue( stop.contains { ($0["command"] as? String)?.contains("hooks antigravity stop") == true && ($0["timeout"] as? Int) == 10 }, "Expected Antigravity Stop hook to be a direct command handler, saw \(stop)" ) - XCTAssertNotNil(cmuxGroup["SessionStart"]) - XCTAssertNotNil(cmuxGroup["SessionEnd"]) - XCTAssertNotNil(cmuxGroup["turn-completion"]) - XCTAssertNotNil(cmuxGroup["Notification"]) - XCTAssertNotNil(cmuxGroup["PostToolUse"]) + legacyAssertNotNil(cmuxGroup["SessionStart"]) + legacyAssertNotNil(cmuxGroup["SessionEnd"]) + legacyAssertNotNil(cmuxGroup["turn-completion"]) + legacyAssertNotNil(cmuxGroup["Notification"]) + legacyAssertNotNil(cmuxGroup["PostToolUse"]) } @Test func testKiroHookInstallUsesAgentConfigShapeAndPreservesDenyExit() throws { @@ -968,9 +968,9 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue( + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue( result.stdout.contains("kiro-cli chat --agent cmux"), "Expected Kiro install to print the --agent cmux activation hint, saw: \(result.stdout)" ) @@ -978,17 +978,17 @@ extension CLINotifyProcessIntegrationRegressionTests { let hookURL = root .appendingPathComponent("agents", isDirectory: true) .appendingPathComponent("cmux.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) - XCTAssertEqual(json["name"] as? String, "cmux") - XCTAssertNil(json["version"], "Kiro agent configs should not receive Cursor's hooks version field") - XCTAssertEqual( + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) + legacyAssertEqual(json["name"] as? String, "cmux") + legacyAssertNil(json["version"], "Kiro agent configs should not receive Cursor's hooks version field") + legacyAssertEqual( json["tools"] as? [String], ["*"], "Kiro cmux agent must grant the full tool set so `--agent cmux` can run tools and fire preToolUse hooks" ) - let hooks = try XCTUnwrap(json["hooks"] as? [String: Any]) - let preToolUse = try XCTUnwrap(hooks["preToolUse"] as? [[String: Any]]) - XCTAssertTrue( + let hooks = try legacyUnwrap(json["hooks"] as? [String: Any]) + let preToolUse = try legacyUnwrap(hooks["preToolUse"] as? [[String: Any]]) + legacyAssertTrue( preToolUse.contains { ($0["command"] as? String)?.contains("hooks feed --source kiro --event preToolUse") == true && ($0["timeout_ms"] as? Int) == 120_000 @@ -998,10 +998,10 @@ extension CLINotifyProcessIntegrationRegressionTests { }, "Expected Kiro preToolUse feed hook to preserve cmux's exit status for deny decisions, saw \(preToolUse)" ) - XCTAssertNotNil(hooks["agentSpawn"]) - XCTAssertNotNil(hooks["userPromptSubmit"]) - XCTAssertNotNil(hooks["postToolUse"]) - XCTAssertNotNil(hooks["stop"]) + legacyAssertNotNil(hooks["agentSpawn"]) + legacyAssertNotNil(hooks["userPromptSubmit"]) + legacyAssertNotNil(hooks["postToolUse"]) + legacyAssertNotNil(hooks["stop"]) } @Test func testKiroFeedDenyUsesPreToolUseExitCodeTwo() throws { @@ -1028,7 +1028,7 @@ extension CLINotifyProcessIntegrationRegressionTests { guard let id = payload["id"] as? String, let method = payload["method"] as? String else { return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) } - XCTAssertEqual(method, "feed.push") + legacyAssertEqual(method, "feed.push") return self.v2Response( id: id, ok: true, @@ -1059,11 +1059,11 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"hook_event_name":"preToolUse","session_id":"kiro-session-123","cwd":"\#(root.path)","tool_name":"fs_write","tool_input":{"operations":[{"mode":"Line","path":"\#(root.appendingPathComponent("README.md").path)"}]}}"#, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 2, result.stderr) - XCTAssertTrue(result.stderr.contains("User denied permission via cmux Feed."), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 2, result.stderr) + legacyAssertTrue(result.stderr.contains("User denied permission via cmux Feed."), result.stderr) let feedEvents = state.commands.compactMap { command -> [String: Any]? in guard let payload = self.jsonObject(command), @@ -1074,10 +1074,10 @@ extension CLINotifyProcessIntegrationRegressionTests { } return event } - XCTAssertEqual(feedEvents.count, 1, "Expected one Kiro Feed event, saw \(state.commands)") - XCTAssertEqual(feedEvents.first?["hook_event_name"] as? String, "PermissionRequest") - XCTAssertEqual(feedEvents.first?["_source"] as? String, "kiro") - XCTAssertEqual(feedEvents.first?["_ppid"] as? Int, 525252) + legacyAssertEqual(feedEvents.count, 1, "Expected one Kiro Feed event, saw \(state.commands)") + legacyAssertEqual(feedEvents.first?["hook_event_name"] as? String, "PermissionRequest") + legacyAssertEqual(feedEvents.first?["_source"] as? String, "kiro") + legacyAssertEqual(feedEvents.first?["_ppid"] as? Int, 525252) } /// The Feed permission modes that allow a tool (`once` / `always` / `all` @@ -1129,21 +1129,21 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"hook_event_name":"preToolUse","session_id":"kiro-session-mode","cwd":"\#(root.path)","tool_name":"fs_write","tool_input":{"operations":[{"mode":"Line","path":"\#(root.appendingPathComponent("README.md").path)"}]}}"#, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } for mode in ["once", "always", "all", "bypass"] { let result = try runKiroDecision(mode: mode) - XCTAssertFalse(result.timedOut, "\(mode): \(result.stderr)") - XCTAssertEqual(result.status, 0, "mode \(mode) should allow (exit 0): \(result.stderr)") - XCTAssertEqual(result.stdout, "{}\n", "mode \(mode) should print {}") + legacyAssertFalse(result.timedOut, "\(mode): \(result.stderr)") + legacyAssertEqual(result.status, 0, "mode \(mode) should allow (exit 0): \(result.stderr)") + legacyAssertEqual(result.stdout, "{}\n", "mode \(mode) should print {}") } let unknown = try runKiroDecision(mode: "totally-bogus-mode") - XCTAssertFalse(unknown.timedOut, unknown.stderr) - XCTAssertEqual(unknown.status, 2, "unrecognized mode must fail closed (exit 2): \(unknown.stderr)") - XCTAssertTrue(unknown.stderr.contains("unrecognized"), unknown.stderr) + legacyAssertFalse(unknown.timedOut, unknown.stderr) + legacyAssertEqual(unknown.status, 2, "unrecognized mode must fail closed (exit 2): \(unknown.stderr)") + legacyAssertTrue(unknown.stderr.contains("unrecognized"), unknown.stderr) } /// At the default `standard` notification level, Kiro read-only tool @@ -1189,20 +1189,20 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"hook_event_name":"postToolUse","session_id":"kiro-suppress","cwd":"\#(root.path)","tool_name":"\#(tool)"}"#, timeout: 5 ) - XCTAssertFalse(result.timedOut, "\(tool): \(result.stderr)") - XCTAssertEqual(result.status, 0, "\(tool): \(result.stderr)") - XCTAssertEqual(result.stdout, "{}\n", "\(tool) stdout") + legacyAssertFalse(result.timedOut, "\(tool): \(result.stderr)") + legacyAssertEqual(result.status, 0, "\(tool): \(result.stderr)") + legacyAssertEqual(result.stdout, "{}\n", "\(tool) stdout") // A non-suppressed event sends one feed.push, so wait for the // server to record it (generous timeout to avoid flaking on the // socket/process round-trip under CI load). A suppressed event // sends nothing, so this wait simply times out silently. - _ = waitForLegacyExpectations([serverHandled], timeout: 5, recordFailure: false) + _ = legacyWaitForExpectations([serverHandled], timeout: 5, recordFailure: false) return state.commands.filter { $0.contains("feed.push") }.count } - XCTAssertEqual(try feedPushCount(forTool: "fs_read"), 0, + legacyAssertEqual(try feedPushCount(forTool: "fs_read"), 0, "read-only kiro tool at standard level must be suppressed") - XCTAssertGreaterThan(try feedPushCount(forTool: "fs_write"), 0, + legacyAssertGreaterThan(try feedPushCount(forTool: "fs_write"), 0, "mutating kiro tool at standard level must still emit telemetry") } @Test @@ -1230,7 +1230,7 @@ extension CLINotifyProcessIntegrationRegressionTests { guard let id = payload["id"] as? String, let method = payload["method"] as? String else { return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) } - XCTAssertEqual(method, "feed.push") + legacyAssertEqual(method, "feed.push") return self.v2Response(id: id, ok: true, result: ["status": "acknowledged"]) } @@ -1250,11 +1250,11 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"hook_event_name":"PreToolUse","session_id":"gemini-session-123","cwd":"\#(root.path)","tool_name":"write","tool_input":{"path":"\#(root.appendingPathComponent("README.md").path)"}}"#, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") let feedPushes = state.commands.compactMap { command -> [String: Any]? in guard let payload = self.jsonObject(command), @@ -1264,14 +1264,14 @@ extension CLINotifyProcessIntegrationRegressionTests { } return params } - XCTAssertEqual(feedPushes.count, 1, "Expected one generic Feed event, saw \(state.commands)") - let event = try XCTUnwrap(feedPushes.first?["event"] as? [String: Any]) - let waitTimeout = try XCTUnwrap(feedPushes.first?["wait_timeout_seconds"] as? NSNumber) - XCTAssertEqual(event["hook_event_name"] as? String, "PreToolUse") - XCTAssertEqual(event["_source"] as? String, "gemini") - XCTAssertEqual(event["tool_name"] as? String, "write") - XCTAssertEqual(event["_ppid"] as? Int, 626262) - XCTAssertEqual(waitTimeout.doubleValue, 0) + legacyAssertEqual(feedPushes.count, 1, "Expected one generic Feed event, saw \(state.commands)") + let event = try legacyUnwrap(feedPushes.first?["event"] as? [String: Any]) + let waitTimeout = try legacyUnwrap(feedPushes.first?["wait_timeout_seconds"] as? NSNumber) + legacyAssertEqual(event["hook_event_name"] as? String, "PreToolUse") + legacyAssertEqual(event["_source"] as? String, "gemini") + legacyAssertEqual(event["tool_name"] as? String, "write") + legacyAssertEqual(event["_ppid"] as? Int, 626262) + legacyAssertEqual(waitTimeout.doubleValue, 0) } @Test func testAntigravityFeedHookMissingSessionIdUsesStableFallback() throws { @@ -1310,7 +1310,7 @@ extension CLINotifyProcessIntegrationRegressionTests { guard let id = payload["id"] as? String, let method = payload["method"] as? String else { return self.malformedRequestResponse(id: payload["id"] as? String, raw: line) } - XCTAssertEqual(method, "feed.push") + legacyAssertEqual(method, "feed.push") return self.v2Response(id: id, ok: true, result: ["status": "acknowledged"]) } let result = runProcess( @@ -1320,26 +1320,26 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: input, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } let input = #"{"hook_event_name":"PreToolUse","workspacePaths":["\#(root.path)"],"notification":{"transcript_path":"\#(root.appendingPathComponent("transcript-a.jsonl").path)"},"toolCall":{"name":"read_file","args":{"path":"README.md"}}}"# let first = runFeedHook(input: input) - XCTAssertFalse(first.timedOut, first.stderr) - XCTAssertEqual(first.status, 0, first.stderr) - XCTAssertEqual(first.stdout, "{}\n") + legacyAssertFalse(first.timedOut, first.stderr) + legacyAssertEqual(first.status, 0, first.stderr) + legacyAssertEqual(first.stdout, "{}\n") let second = runFeedHook(input: input) - XCTAssertFalse(second.timedOut, second.stderr) - XCTAssertEqual(second.status, 0, second.stderr) - XCTAssertEqual(second.stdout, "{}\n") + legacyAssertFalse(second.timedOut, second.stderr) + legacyAssertEqual(second.status, 0, second.stderr) + legacyAssertEqual(second.stdout, "{}\n") let differentTranscriptInput = #"{"hook_event_name":"PreToolUse","workspacePaths":["\#(root.path)"],"notification":{"transcript_path":"\#(root.appendingPathComponent("transcript-b.jsonl").path)"},"toolCall":{"name":"read_file","args":{"path":"README.md"}}}"# let third = runFeedHook(input: differentTranscriptInput) - XCTAssertFalse(third.timedOut, third.stderr) - XCTAssertEqual(third.status, 0, third.stderr) - XCTAssertEqual(third.stdout, "{}\n") + legacyAssertFalse(third.timedOut, third.stderr) + legacyAssertEqual(third.status, 0, third.stderr) + legacyAssertEqual(third.stdout, "{}\n") let events = state.commands.compactMap { command -> [String: Any]? in guard let payload = self.jsonObject(command), @@ -1351,14 +1351,14 @@ extension CLINotifyProcessIntegrationRegressionTests { return event } let sessionIds = events.compactMap { $0["session_id"] as? String } - XCTAssertEqual(sessionIds.count, 3, "Expected three feed events, saw \(state.commands)") - XCTAssertEqual(sessionIds[0], sessionIds[1]) - XCTAssertNotEqual(sessionIds[1], sessionIds[2]) - XCTAssertTrue( + legacyAssertEqual(sessionIds.count, 3, "Expected three feed events, saw \(state.commands)") + legacyAssertEqual(sessionIds[0], sessionIds[1]) + legacyAssertNotEqual(sessionIds[1], sessionIds[2]) + legacyAssertTrue( sessionIds[0].hasPrefix("antigravity-fallback-"), "Expected deterministic Antigravity fallback session id, saw \(sessionIds[0])" ) - XCTAssertEqual(events.compactMap { $0["_ppid"] as? Int }, [424242, 424242, 424242]) + legacyAssertEqual(events.compactMap { $0["_ppid"] as? Int }, [424242, 424242, 424242]) } @Test func testGrokNotificationHookUsesPayloadMessageAndStopDoesNotSendGenericNotification() throws { @@ -1416,7 +1416,7 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: input, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } @@ -1424,10 +1424,10 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-start", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"SessionStart"}"# ) - XCTAssertFalse(start.timedOut, start.stderr) - XCTAssertEqual(start.status, 0, start.stderr) - XCTAssertEqual(start.stdout, "{}\n") - XCTAssertFalse( + legacyAssertFalse(start.timedOut, start.stderr) + legacyAssertEqual(start.status, 0, start.stderr) + legacyAssertEqual(start.stdout, "{}\n") + legacyAssertFalse( state.commands.contains { $0.contains("set_status grok") || $0.hasPrefix("notify_target_async ") }, "Grok SessionStart should only establish routing state, saw \(state.commands)" ) @@ -1437,16 +1437,16 @@ extension CLINotifyProcessIntegrationRegressionTests { "stop", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Stop"}"# ) - XCTAssertFalse(stop.timedOut, stop.stderr) - XCTAssertEqual(stop.status, 0, stop.stderr) - XCTAssertEqual(stop.stdout, "{}\n") + legacyAssertFalse(stop.timedOut, stop.stderr) + legacyAssertEqual(stop.status, 0, stop.stderr) + legacyAssertEqual(stop.stdout, "{}\n") let stopCommands = Array(state.commands.dropFirst(stopCommandStart)) - XCTAssertFalse( + legacyAssertFalse( stopCommands.contains { $0.hasPrefix("notify_target_async ") }, "Grok Stop should not publish a generic completion notification; Notification carries the real message. Saw \(stopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.contains("set_status grok Idle") }, "Expected Grok Stop to keep task-manager status idle, saw \(stopCommands)" ) @@ -1456,41 +1456,41 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"Grok finished updating docs"}"# ) - XCTAssertFalse(notification.timedOut, notification.stderr) - XCTAssertEqual(notification.status, 0, notification.stderr) - XCTAssertEqual(notification.stdout, "{}\n") + legacyAssertFalse(notification.timedOut, notification.stderr) + legacyAssertEqual(notification.status, 0, notification.stderr) + legacyAssertEqual(notification.stdout, "{}\n") let notificationCommands = Array(state.commands.dropFirst(notificationCommandStart)) - XCTAssertTrue( + legacyAssertTrue( notificationCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed|Grok finished updating docs") }, "Expected Grok Notification to forward the payload message, saw \(notificationCommands)" ) - XCTAssertTrue( + legacyAssertTrue( notificationCommands.contains { $0.contains("set_status grok Idle") }, "Expected completion notification to leave Grok idle, saw \(notificationCommands)" ) let storeURL = root.appendingPathComponent("grok-hook-sessions.json", isDirectory: false) - var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - var sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - var session = try XCTUnwrap(sessions[sessionId] as? [String: Any]) - XCTAssertEqual(session["lastSubtitle"] as? String, "Completed") - XCTAssertEqual(session["lastBody"] as? String, "Grok finished updating docs") - XCTAssertEqual(session["lastNotificationStatus"] as? String, "idle") + var json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + var sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + var session = try legacyUnwrap(sessions[sessionId] as? [String: Any]) + legacyAssertEqual(session["lastSubtitle"] as? String, "Completed") + legacyAssertEqual(session["lastBody"] as? String, "Grok finished updating docs") + legacyAssertEqual(session["lastNotificationStatus"] as? String, "idle") let preAssistantInternalCommandStart = state.commands.count let preAssistantInternal = runGrokHook( "notification", input: #"{"sessionId":"grok-before-assistant","cwd":"\#(root.path)","hookEventName":"Notification","message":"SessionNotification { update: HookExecution { event_name: session_start } }"}"# ) - XCTAssertFalse(preAssistantInternal.timedOut, preAssistantInternal.stderr) - XCTAssertEqual(preAssistantInternal.status, 0, preAssistantInternal.stderr) - XCTAssertEqual(preAssistantInternal.stdout, "{}\n") + legacyAssertFalse(preAssistantInternal.timedOut, preAssistantInternal.stderr) + legacyAssertEqual(preAssistantInternal.status, 0, preAssistantInternal.stderr) + legacyAssertEqual(preAssistantInternal.stdout, "{}\n") let preAssistantInternalCommands = Array(state.commands.dropFirst(preAssistantInternalCommandStart)) - XCTAssertFalse( + legacyAssertFalse( preAssistantInternalCommands.contains { $0.hasPrefix("notify_target_async ") }, "Grok internal session notifications should not notify before there is an assistant response, saw \(preAssistantInternalCommands)" ) @@ -1500,36 +1500,36 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"grok-generic-before-assistant","cwd":"\#(root.path)","hookEventName":"Notification","message":"Turn complete in 3.8s."}"# ) - XCTAssertFalse(preAssistantGeneric.timedOut, preAssistantGeneric.stderr) - XCTAssertEqual(preAssistantGeneric.status, 0, preAssistantGeneric.stderr) - XCTAssertEqual(preAssistantGeneric.stdout, "{}\n") + legacyAssertFalse(preAssistantGeneric.timedOut, preAssistantGeneric.stderr) + legacyAssertEqual(preAssistantGeneric.status, 0, preAssistantGeneric.stderr) + legacyAssertEqual(preAssistantGeneric.stdout, "{}\n") let preAssistantGenericCommands = Array(state.commands.dropFirst(preAssistantGenericCommandStart)) - XCTAssertTrue( + legacyAssertTrue( preAssistantGenericCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed|Task completed") }, "Grok generic completion notifications should still fire before there is an assistant response, saw \(preAssistantGenericCommands)" ) - XCTAssertTrue( + legacyAssertTrue( preAssistantGenericCommands.contains { $0.contains("set_status grok Idle") }, "Expected generic completion without an assistant response to leave Grok idle, saw \(preAssistantGenericCommands)" ) - json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - let preAssistantGenericSession = try XCTUnwrap(sessions["grok-generic-before-assistant"] as? [String: Any]) - XCTAssertEqual(preAssistantGenericSession["lastSubtitle"] as? String, "Completed") - XCTAssertEqual(preAssistantGenericSession["lastBody"] as? String, "Task completed") - XCTAssertEqual(preAssistantGenericSession["lastNotificationStatus"] as? String, "idle") + json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + let preAssistantGenericSession = try legacyUnwrap(sessions["grok-generic-before-assistant"] as? [String: Any]) + legacyAssertEqual(preAssistantGenericSession["lastSubtitle"] as? String, "Completed") + legacyAssertEqual(preAssistantGenericSession["lastBody"] as? String, "Task completed") + legacyAssertEqual(preAssistantGenericSession["lastNotificationStatus"] as? String, "idle") let assistantMessage = "**42.** That's the answer, according to Deep Thought." let nextTurnPrompt = runGrokHook( "prompt-submit", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"UserPromptSubmit","prompt":"next turn"}"# ) - XCTAssertFalse(nextTurnPrompt.timedOut, nextTurnPrompt.stderr) - XCTAssertEqual(nextTurnPrompt.status, 0, nextTurnPrompt.stderr) - XCTAssertEqual(nextTurnPrompt.stdout, "{}\n") + legacyAssertFalse(nextTurnPrompt.timedOut, nextTurnPrompt.stderr) + legacyAssertEqual(nextTurnPrompt.status, 0, nextTurnPrompt.stderr) + legacyAssertEqual(nextTurnPrompt.stdout, "{}\n") try writeGrokAssistantTranscript( grokHome: grokHome, @@ -1542,19 +1542,19 @@ extension CLINotifyProcessIntegrationRegressionTests { "stop", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Stop"}"# ) - XCTAssertFalse(enrichedStop.timedOut, enrichedStop.stderr) - XCTAssertEqual(enrichedStop.status, 0, enrichedStop.stderr) - XCTAssertEqual(enrichedStop.stdout, "{}\n") + legacyAssertFalse(enrichedStop.timedOut, enrichedStop.stderr) + legacyAssertEqual(enrichedStop.status, 0, enrichedStop.stderr) + legacyAssertEqual(enrichedStop.stdout, "{}\n") let enrichedStopCommands = Array(state.commands.dropFirst(enrichedStopCommandStart)) - XCTAssertTrue( + legacyAssertTrue( enrichedStopCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed in ") && $0.contains(assistantMessage) }, "Expected Grok Stop fallback to publish the cwd-scoped assistant response when Grok only emits internal Notification events, saw \(enrichedStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( enrichedStopCommands.contains { $0.contains("set_status grok Idle") }, "Expected enriched Grok Stop to leave Grok idle, saw \(enrichedStopCommands)" ) @@ -1565,15 +1565,15 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-start", input: #"{"sessionId":"\#(oversizedSessionId)","cwd":"\#(root.path)","hookEventName":"SessionStart"}"# ) - XCTAssertFalse(oversizedStart.timedOut, oversizedStart.stderr) - XCTAssertEqual(oversizedStart.status, 0, oversizedStart.stderr) + legacyAssertFalse(oversizedStart.timedOut, oversizedStart.stderr) + legacyAssertEqual(oversizedStart.status, 0, oversizedStart.stderr) let oversizedPrompt = runGrokHook( "prompt-submit", input: #"{"sessionId":"\#(oversizedSessionId)","cwd":"\#(root.path)","hookEventName":"UserPromptSubmit","prompt":"oversized turn"}"# ) - XCTAssertFalse(oversizedPrompt.timedOut, oversizedPrompt.stderr) - XCTAssertEqual(oversizedPrompt.status, 0, oversizedPrompt.stderr) + legacyAssertFalse(oversizedPrompt.timedOut, oversizedPrompt.stderr) + legacyAssertEqual(oversizedPrompt.status, 0, oversizedPrompt.stderr) let oversizedSessionURL = grokHome .appendingPathComponent("sessions", isDirectory: true) @@ -1589,11 +1589,11 @@ extension CLINotifyProcessIntegrationRegressionTests { "stop", input: #"{"sessionId":"\#(oversizedSessionId)","cwd":"\#(root.path)","hookEventName":"Stop"}"# ) - XCTAssertFalse(oversizedStop.timedOut, oversizedStop.stderr) - XCTAssertEqual(oversizedStop.status, 0, oversizedStop.stderr) + legacyAssertFalse(oversizedStop.timedOut, oversizedStop.stderr) + legacyAssertEqual(oversizedStop.status, 0, oversizedStop.stderr) let oversizedStopCommands = Array(state.commands.dropFirst(oversizedStopCommandStart)) - XCTAssertTrue( + legacyAssertTrue( oversizedStopCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed in ") && $0.contains("Oversized Grok assistant response") @@ -1606,15 +1606,15 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-start", input: #"{"sessionId":"\#(multibyteSessionId)","cwd":"\#(root.path)","hookEventName":"SessionStart"}"# ) - XCTAssertFalse(multibyteStart.timedOut, multibyteStart.stderr) - XCTAssertEqual(multibyteStart.status, 0, multibyteStart.stderr) + legacyAssertFalse(multibyteStart.timedOut, multibyteStart.stderr) + legacyAssertEqual(multibyteStart.status, 0, multibyteStart.stderr) let multibytePrompt = runGrokHook( "prompt-submit", input: #"{"sessionId":"\#(multibyteSessionId)","cwd":"\#(root.path)","hookEventName":"UserPromptSubmit","prompt":"multibyte boundary"}"# ) - XCTAssertFalse(multibytePrompt.timedOut, multibytePrompt.stderr) - XCTAssertEqual(multibytePrompt.status, 0, multibytePrompt.stderr) + legacyAssertFalse(multibytePrompt.timedOut, multibytePrompt.stderr) + legacyAssertEqual(multibytePrompt.status, 0, multibytePrompt.stderr) let multibyteSessionURL = grokHome .appendingPathComponent("sessions", isDirectory: true) @@ -1641,7 +1641,7 @@ extension CLINotifyProcessIntegrationRegressionTests { break } } - let historyData = try XCTUnwrap(multibyteHistoryData) + let historyData = try legacyUnwrap(multibyteHistoryData) try historyData.write(to: multibyteSessionURL.appendingPathComponent("chat_history.jsonl", isDirectory: false)) let multibyteStopCommandStart = state.commands.count @@ -1649,11 +1649,11 @@ extension CLINotifyProcessIntegrationRegressionTests { "stop", input: #"{"sessionId":"\#(multibyteSessionId)","cwd":"\#(root.path)","hookEventName":"Stop"}"# ) - XCTAssertFalse(multibyteStop.timedOut, multibyteStop.stderr) - XCTAssertEqual(multibyteStop.status, 0, multibyteStop.stderr) + legacyAssertFalse(multibyteStop.timedOut, multibyteStop.stderr) + legacyAssertEqual(multibyteStop.status, 0, multibyteStop.stderr) let multibyteStopCommands = Array(state.commands.dropFirst(multibyteStopCommandStart)) - XCTAssertTrue( + legacyAssertTrue( multibyteStopCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed in ") && $0.contains(multibyteAssistantMessage) @@ -1666,16 +1666,16 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"Turn complete in 3.8s."}"# ) - XCTAssertFalse(genericCompletion.timedOut, genericCompletion.stderr) - XCTAssertEqual(genericCompletion.status, 0, genericCompletion.stderr) - XCTAssertEqual(genericCompletion.stdout, "{}\n") + legacyAssertFalse(genericCompletion.timedOut, genericCompletion.stderr) + legacyAssertEqual(genericCompletion.status, 0, genericCompletion.stderr) + legacyAssertEqual(genericCompletion.stdout, "{}\n") let genericCompletionCommands = Array(state.commands.dropFirst(genericCompletionCommandStart)) - XCTAssertFalse( + legacyAssertFalse( genericCompletionCommands.contains { $0.hasPrefix("notify_target_async ") }, "Grok completion Notification must not double-notify after Stop fallback already published the completion, saw \(genericCompletionCommands)" ) - XCTAssertTrue( + legacyAssertTrue( genericCompletionCommands.contains { $0.contains("set_status grok Idle") }, "Expected enriched completion notification to leave Grok idle, saw \(genericCompletionCommands)" ) @@ -1686,18 +1686,18 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sameCwdMissingSessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"Turn complete in 4.0s."}"# ) - XCTAssertFalse(sameCwdMissing.timedOut, sameCwdMissing.stderr) - XCTAssertEqual(sameCwdMissing.status, 0, sameCwdMissing.stderr) - XCTAssertEqual(sameCwdMissing.stdout, "{}\n") + legacyAssertFalse(sameCwdMissing.timedOut, sameCwdMissing.stderr) + legacyAssertEqual(sameCwdMissing.status, 0, sameCwdMissing.stderr) + legacyAssertEqual(sameCwdMissing.stdout, "{}\n") let sameCwdMissingCommands = Array(state.commands.dropFirst(sameCwdMissingCommandStart)) - XCTAssertTrue( + legacyAssertTrue( sameCwdMissingCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed|Task completed") }, "Grok completion without a matching session transcript should still fire a generic completion notification, saw \(sameCwdMissingCommands)" ) - XCTAssertFalse( + legacyAssertFalse( sameCwdMissingCommands.contains { $0.contains(assistantMessage) }, "Grok completion notifications must not read another session from the same cwd, saw \(sameCwdMissingCommands)" ) @@ -1730,18 +1730,18 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"cwd":"\#(root.path)","hookEventName":"Notification","message":"Turn complete in 4.2s."}"# ) - XCTAssertFalse(envResolved.timedOut, envResolved.stderr) - XCTAssertEqual(envResolved.status, 0, envResolved.stderr) - XCTAssertEqual(envResolved.stdout, "{}\n") + legacyAssertFalse(envResolved.timedOut, envResolved.stderr) + legacyAssertEqual(envResolved.status, 0, envResolved.stderr) + legacyAssertEqual(envResolved.stdout, "{}\n") let envResolvedCommands = Array(state.commands.dropFirst(envResolvedCommandStart)) - XCTAssertTrue( + legacyAssertTrue( envResolvedCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed|\(envResolvedMessage)") }, "Grok completion without a payload session id should use the resolved hook session id, saw \(envResolvedCommands)" ) - XCTAssertFalse( + legacyAssertFalse( envResolvedCommands.contains { $0.contains(unrelatedLatestMessage) }, "Grok completion without a payload session id must not fall back to the latest unrelated cwd session, saw \(envResolvedCommands)" ) @@ -1751,12 +1751,12 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"SessionNotification { update: MemoryFlushCompleted { result: written } }"}"# ) - XCTAssertFalse(hookExecution.timedOut, hookExecution.stderr) - XCTAssertEqual(hookExecution.status, 0, hookExecution.stderr) - XCTAssertEqual(hookExecution.stdout, "{}\n") + legacyAssertFalse(hookExecution.timedOut, hookExecution.stderr) + legacyAssertEqual(hookExecution.status, 0, hookExecution.stderr) + legacyAssertEqual(hookExecution.stdout, "{}\n") let hookExecutionCommands = Array(state.commands.dropFirst(hookExecutionCommandStart)) - XCTAssertFalse( + legacyAssertFalse( hookExecutionCommands.contains { $0.hasPrefix("notify_target_async ") }, "Grok internal session notifications must not replay the last assistant response as a fresh notification, saw \(hookExecutionCommands)" ) @@ -1777,22 +1777,22 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(scopedMissSessionId)","cwd":"\#(missingCwd.path)","hookEventName":"Notification","message":"Turn complete in 4.0s."}"# ) - XCTAssertFalse(scopedMiss.timedOut, scopedMiss.stderr) - XCTAssertEqual(scopedMiss.status, 0, scopedMiss.stderr) - XCTAssertEqual(scopedMiss.stdout, "{}\n") + legacyAssertFalse(scopedMiss.timedOut, scopedMiss.stderr) + legacyAssertEqual(scopedMiss.status, 0, scopedMiss.stderr) + legacyAssertEqual(scopedMiss.stdout, "{}\n") let scopedMissCommands = Array(state.commands.dropFirst(scopedMissCommandStart)) - XCTAssertTrue( + legacyAssertTrue( scopedMissCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed|Task completed") }, "Grok completion without a cwd-scoped transcript should still fire a generic completion notification, saw \(scopedMissCommands)" ) - XCTAssertFalse( + legacyAssertFalse( scopedMissCommands.contains { $0.contains(otherProjectMessage) }, "Grok completion notifications must not read another cwd's latest session, saw \(scopedMissCommands)" ) - XCTAssertTrue( + legacyAssertTrue( scopedMissCommands.contains { $0.contains("set_status grok Idle") }, "Expected scoped completion without transcript to leave Grok idle, saw \(scopedMissCommands)" ) @@ -1803,18 +1803,18 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","reason":"idle_prompt","message":"\#(waitingMessage)"}"# ) - XCTAssertFalse(waiting.timedOut, waiting.stderr) - XCTAssertEqual(waiting.status, 0, waiting.stderr) - XCTAssertEqual(waiting.stdout, "{}\n") + legacyAssertFalse(waiting.timedOut, waiting.stderr) + legacyAssertEqual(waiting.status, 0, waiting.stderr) + legacyAssertEqual(waiting.stdout, "{}\n") let waitingCommands = Array(state.commands.dropFirst(waitingCommandStart)) - XCTAssertTrue( + legacyAssertTrue( waitingCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Waiting|\(waitingMessage)") }, "Expected waiting notification to forward the payload message, saw \(waitingCommands)" ) - XCTAssertTrue( + legacyAssertTrue( waitingCommands.contains { $0.contains("set_status grok Grok needs input") }, "Expected waiting notification to mark Grok as needing input, saw \(waitingCommands)" ) @@ -1824,28 +1824,28 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification"}"# ) - XCTAssertFalse(fallback.timedOut, fallback.stderr) - XCTAssertEqual(fallback.status, 0, fallback.stderr) - XCTAssertEqual(fallback.stdout, "{}\n") + legacyAssertFalse(fallback.timedOut, fallback.stderr) + legacyAssertEqual(fallback.status, 0, fallback.stderr) + legacyAssertEqual(fallback.stdout, "{}\n") let fallbackCommands = Array(state.commands.dropFirst(fallbackCommandStart)) - XCTAssertTrue( + legacyAssertTrue( fallbackCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Waiting|\(waitingMessage)") }, "Expected empty Grok Notification payload to reuse the saved message, saw \(fallbackCommands)" ) - XCTAssertTrue( + legacyAssertTrue( fallbackCommands.contains { $0.contains("set_status grok Grok needs input") }, "Expected fallback notification to preserve the saved needs-input status, saw \(fallbackCommands)" ) - json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - session = try XCTUnwrap(sessions[sessionId] as? [String: Any]) - XCTAssertEqual(session["lastSubtitle"] as? String, "Waiting") - XCTAssertEqual(session["lastBody"] as? String, waitingMessage) - XCTAssertEqual(session["lastNotificationStatus"] as? String, "needsInput") + json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + session = try legacyUnwrap(sessions[sessionId] as? [String: Any]) + legacyAssertEqual(session["lastSubtitle"] as? String, "Waiting") + legacyAssertEqual(session["lastBody"] as? String, waitingMessage) + legacyAssertEqual(session["lastNotificationStatus"] as? String, "needsInput") for neutralMessage in ["Invalid input format", "Question mark rendered"] { let neutralCommandStart = state.commands.count @@ -1853,16 +1853,16 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"\#(neutralMessage)"}"# ) - XCTAssertFalse(neutral.timedOut, neutral.stderr) - XCTAssertEqual(neutral.status, 0, neutral.stderr) - XCTAssertEqual(neutral.stdout, "{}\n") + legacyAssertFalse(neutral.timedOut, neutral.stderr) + legacyAssertEqual(neutral.status, 0, neutral.stderr) + legacyAssertEqual(neutral.stdout, "{}\n") let neutralCommands = Array(state.commands.dropFirst(neutralCommandStart)) - XCTAssertFalse( + legacyAssertFalse( neutralCommands.contains { $0.hasPrefix("notify_target_async ") }, "Neutral classifier text should not alert as needs-input, saw \(neutralCommands)" ) - XCTAssertFalse( + legacyAssertFalse( neutralCommands.contains { $0.contains("set_status grok ") }, "Neutral classifier text should not replace the saved status, saw \(neutralCommands)" ) @@ -1874,18 +1874,18 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"\#(incompleteWaitingMessage)"}"# ) - XCTAssertFalse(incompleteWaiting.timedOut, incompleteWaiting.stderr) - XCTAssertEqual(incompleteWaiting.status, 0, incompleteWaiting.stderr) - XCTAssertEqual(incompleteWaiting.stdout, "{}\n") + legacyAssertFalse(incompleteWaiting.timedOut, incompleteWaiting.stderr) + legacyAssertEqual(incompleteWaiting.status, 0, incompleteWaiting.stderr) + legacyAssertEqual(incompleteWaiting.stdout, "{}\n") let incompleteWaitingCommands = Array(state.commands.dropFirst(incompleteWaitingCommandStart)) - XCTAssertTrue( + legacyAssertTrue( incompleteWaitingCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Waiting|\(incompleteWaitingMessage)") }, "Incomplete/undone waiting text should not be classified as a completion, saw \(incompleteWaitingCommands)" ) - XCTAssertFalse( + legacyAssertFalse( incompleteWaitingCommands.contains { $0.contains("Grok|Completed|") }, "Incomplete/undone waiting text must not emit a completed notification, saw \(incompleteWaitingCommands)" ) @@ -1896,44 +1896,44 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"\#(progressMessage)"}"# ) - XCTAssertFalse(progress.timedOut, progress.stderr) - XCTAssertEqual(progress.status, 0, progress.stderr) - XCTAssertEqual(progress.stdout, "{}\n") + legacyAssertFalse(progress.timedOut, progress.stderr) + legacyAssertEqual(progress.status, 0, progress.stderr) + legacyAssertEqual(progress.stdout, "{}\n") let progressCommands = Array(state.commands.dropFirst(progressCommandStart)) - XCTAssertFalse( + legacyAssertFalse( progressCommands.contains { $0.hasPrefix("notify_target_async ") }, "Unclassified Grok notifications are progress/bookkeeping and should not alert, saw \(progressCommands)" ) - XCTAssertFalse( + legacyAssertFalse( progressCommands.contains { $0.contains("set_status grok ") }, "Unclassified Grok notifications should not clear or replace active status, saw \(progressCommands)" ) - json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - session = try XCTUnwrap(sessions[sessionId] as? [String: Any]) - XCTAssertEqual(session["lastSubtitle"] as? String, "Waiting") - XCTAssertEqual(session["lastBody"] as? String, incompleteWaitingMessage) - XCTAssertEqual(session["lastNotificationStatus"] as? String, "needsInput") + json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + session = try legacyUnwrap(sessions[sessionId] as? [String: Any]) + legacyAssertEqual(session["lastSubtitle"] as? String, "Waiting") + legacyAssertEqual(session["lastBody"] as? String, incompleteWaitingMessage) + legacyAssertEqual(session["lastNotificationStatus"] as? String, "needsInput") let neutralFallbackCommandStart = state.commands.count let neutralFallback = runGrokHook( "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification"}"# ) - XCTAssertFalse(neutralFallback.timedOut, neutralFallback.stderr) - XCTAssertEqual(neutralFallback.status, 0, neutralFallback.stderr) - XCTAssertEqual(neutralFallback.stdout, "{}\n") + legacyAssertFalse(neutralFallback.timedOut, neutralFallback.stderr) + legacyAssertEqual(neutralFallback.status, 0, neutralFallback.stderr) + legacyAssertEqual(neutralFallback.stdout, "{}\n") let neutralFallbackCommands = Array(state.commands.dropFirst(neutralFallbackCommandStart)) - XCTAssertTrue( + legacyAssertTrue( neutralFallbackCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Waiting|\(incompleteWaitingMessage)") }, "Expected empty payload to reuse the last terminal saved notification, saw \(neutralFallbackCommands)" ) - XCTAssertTrue( + legacyAssertTrue( neutralFallbackCommands.contains { $0.contains("set_status grok Grok needs input") }, "Fallback notifications should preserve the saved needs-input status, saw \(neutralFallbackCommands)" ) @@ -2009,7 +2009,7 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: input, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } @@ -2028,18 +2028,18 @@ extension CLINotifyProcessIntegrationRegressionTests { input: #"{"sessionId":"\#(thread.sessionId)","cwd":"\#(root.path)","hookEventName":"SessionStart"}"#, surfaceId: thread.surfaceId ) - XCTAssertFalse(start.timedOut, start.stderr) - XCTAssertEqual(start.status, 0, start.stderr) - XCTAssertEqual(start.stdout, "{}\n") + legacyAssertFalse(start.timedOut, start.stderr) + legacyAssertEqual(start.status, 0, start.stderr) + legacyAssertEqual(start.stdout, "{}\n") let prompt = runGrokHook( "prompt-submit", input: #"{"sessionId":"\#(thread.sessionId)","cwd":"\#(root.path)","hookEventName":"UserPromptSubmit","prompt":"thread \#(thread.index) prompt"}"#, surfaceId: thread.surfaceId ) - XCTAssertFalse(prompt.timedOut, prompt.stderr) - XCTAssertEqual(prompt.status, 0, prompt.stderr) - XCTAssertEqual(prompt.stdout, "{}\n") + legacyAssertFalse(prompt.timedOut, prompt.stderr) + legacyAssertEqual(prompt.status, 0, prompt.stderr) + legacyAssertEqual(prompt.stdout, "{}\n") let internalCommandStart = state.commands.count let internalNotification = runGrokHook( @@ -2047,12 +2047,12 @@ extension CLINotifyProcessIntegrationRegressionTests { input: #"{"sessionId":"\#(thread.sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"SessionNotification { update: HookExecution { event_name: user_prompt_submit } }"}"#, surfaceId: thread.surfaceId ) - XCTAssertFalse(internalNotification.timedOut, internalNotification.stderr) - XCTAssertEqual(internalNotification.status, 0, internalNotification.stderr) - XCTAssertEqual(internalNotification.stdout, "{}\n") + legacyAssertFalse(internalNotification.timedOut, internalNotification.stderr) + legacyAssertEqual(internalNotification.status, 0, internalNotification.stderr) + legacyAssertEqual(internalNotification.stdout, "{}\n") let internalCommands = Array(state.commands.dropFirst(internalCommandStart)) - XCTAssertFalse( + legacyAssertFalse( internalCommands.contains { $0.hasPrefix("notify_target_async ") }, "Prompt-submit bookkeeping for Grok thread \(thread.index) must not notify, saw \(internalCommands)" ) @@ -2074,12 +2074,12 @@ extension CLINotifyProcessIntegrationRegressionTests { input: #"{"sessionId":"\#(thread.sessionId)","cwd":"\#(root.path)","hookEventName":"Stop"}"#, surfaceId: thread.surfaceId ) - XCTAssertFalse(stop.timedOut, stop.stderr) - XCTAssertEqual(stop.status, 0, stop.stderr) - XCTAssertEqual(stop.stdout, "{}\n") + legacyAssertFalse(stop.timedOut, stop.stderr) + legacyAssertEqual(stop.status, 0, stop.stderr) + legacyAssertEqual(stop.stdout, "{}\n") let stopCommands = Array(state.commands.dropFirst(stopCommandStart)) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.contains("notify_target_async \(workspaceId) \(thread.surfaceId) Grok|Completed in ") && $0.contains(thread.assistantMessage) @@ -2087,12 +2087,12 @@ extension CLINotifyProcessIntegrationRegressionTests { "Expected Grok Stop fallback to notify for thread \(thread.index), saw \(stopCommands)" ) if thread.index == 1 { - XCTAssertFalse( + legacyAssertFalse( stopCommands.contains { $0.contains("set_status grok Idle") }, "First Grok thread must not reset shared status while thread 2 is still running, saw \(stopCommands)" ) } else { - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.contains("set_status grok Idle") }, "Expected final Grok Stop to leave Grok idle, saw \(stopCommands)" ) @@ -2106,12 +2106,12 @@ extension CLINotifyProcessIntegrationRegressionTests { input: #"{"sessionId":"\#(thread.sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"SessionNotification { update: HookExecution { event_name: stop } }"}"#, surfaceId: thread.surfaceId ) - XCTAssertFalse(notification.timedOut, notification.stderr) - XCTAssertEqual(notification.status, 0, notification.stderr) - XCTAssertEqual(notification.stdout, "{}\n") + legacyAssertFalse(notification.timedOut, notification.stderr) + legacyAssertEqual(notification.status, 0, notification.stderr) + legacyAssertEqual(notification.stdout, "{}\n") let notificationCommands = Array(state.commands.dropFirst(notificationCommandStart)) - XCTAssertFalse( + legacyAssertFalse( notificationCommands.contains { $0.hasPrefix("notify_target_async ") }, "Internal Grok Notification after Stop fallback must not double-notify thread \(thread.index), saw \(notificationCommands)" ) @@ -2170,7 +2170,7 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: input, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } @@ -2178,27 +2178,27 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-start", input: #"{"sessionId":"\#(sessionId)","hookEventName":"SessionStart"}"# ) - XCTAssertFalse(start.timedOut, start.stderr) - XCTAssertEqual(start.status, 0, start.stderr) - XCTAssertEqual(start.stdout, "{}\n") + legacyAssertFalse(start.timedOut, start.stderr) + legacyAssertEqual(start.status, 0, start.stderr) + legacyAssertEqual(start.stdout, "{}\n") let stopCommandStart = state.commands.count let stop = runGrokHook( "stop", input: #"{"sessionId":"\#(sessionId)","hookEventName":"Stop"}"# ) - XCTAssertFalse(stop.timedOut, stop.stderr) - XCTAssertEqual(stop.status, 0, stop.stderr) - XCTAssertEqual(stop.stdout, "{}\n") + legacyAssertFalse(stop.timedOut, stop.stderr) + legacyAssertEqual(stop.status, 0, stop.stderr) + legacyAssertEqual(stop.stdout, "{}\n") let stopCommands = Array(state.commands.dropFirst(stopCommandStart)) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed|Grok session completed") }, "Expected Grok Stop without cwd to notify with a generic completion body, saw \(stopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.contains("set_status grok Idle") }, "Expected Grok Stop without cwd to leave Grok idle, saw \(stopCommands)" ) @@ -2208,12 +2208,12 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","hookEventName":"Notification","message":"Turn complete in 1.0s."}"# ) - XCTAssertFalse(duplicateCompletion.timedOut, duplicateCompletion.stderr) - XCTAssertEqual(duplicateCompletion.status, 0, duplicateCompletion.stderr) - XCTAssertEqual(duplicateCompletion.stdout, "{}\n") + legacyAssertFalse(duplicateCompletion.timedOut, duplicateCompletion.stderr) + legacyAssertEqual(duplicateCompletion.status, 0, duplicateCompletion.stderr) + legacyAssertEqual(duplicateCompletion.stdout, "{}\n") let duplicateCompletionCommands = Array(state.commands.dropFirst(duplicateCompletionCommandStart)) - XCTAssertFalse( + legacyAssertFalse( duplicateCompletionCommands.contains { $0.hasPrefix("notify_target_async ") }, "Generic Grok completion after Stop fallback must not double-notify, saw \(duplicateCompletionCommands)" ) @@ -2272,7 +2272,7 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: input, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } @@ -2280,16 +2280,16 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-start", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"SessionStart"}"# ) - XCTAssertFalse(start.timedOut, start.stderr) - XCTAssertEqual(start.status, 0, start.stderr) + legacyAssertFalse(start.timedOut, start.stderr) + legacyAssertEqual(start.status, 0, start.stderr) for index in 1...2 { let prompt = runGrokHook( "prompt-submit", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"UserPromptSubmit","prompt":"prompt \#(index)"}"# ) - XCTAssertFalse(prompt.timedOut, prompt.stderr) - XCTAssertEqual(prompt.status, 0, prompt.stderr) + legacyAssertFalse(prompt.timedOut, prompt.stderr) + legacyAssertEqual(prompt.status, 0, prompt.stderr) let message = "Turn complete in \(index).0s." let commandStart = state.commands.count @@ -2299,18 +2299,18 @@ extension CLINotifyProcessIntegrationRegressionTests { stallFeedTelemetry: index == 2 ) - XCTAssertFalse(notification.timedOut, notification.stderr) - XCTAssertEqual(notification.status, 0, notification.stderr) - XCTAssertEqual(notification.stdout, "{}\n") + legacyAssertFalse(notification.timedOut, notification.stderr) + legacyAssertEqual(notification.status, 0, notification.stderr) + legacyAssertEqual(notification.stdout, "{}\n") let notificationCommands = Array(state.commands.dropFirst(commandStart)) - XCTAssertTrue( + legacyAssertTrue( notificationCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed|Task completed") }, "Expected Grok completion notification for prompt \(index), saw \(notificationCommands)" ) - XCTAssertTrue( + legacyAssertTrue( notificationCommands.contains { $0.contains("set_status grok Idle") }, "Expected Grok completion for prompt \(index) to leave Grok idle, saw \(notificationCommands)" ) @@ -2376,7 +2376,7 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: input, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } @@ -2385,9 +2385,9 @@ extension CLINotifyProcessIntegrationRegressionTests { input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"SessionStart"}"#, environment: initialEnvironment ) - XCTAssertFalse(start.timedOut, start.stderr) - XCTAssertEqual(start.status, 0, start.stderr) - XCTAssertEqual(start.stdout, "{}\n") + legacyAssertFalse(start.timedOut, start.stderr) + legacyAssertEqual(start.status, 0, start.stderr) + legacyAssertEqual(start.stdout, "{}\n") for index in 1...2 { let promptCommandStart = state.commands.count @@ -2395,20 +2395,20 @@ extension CLINotifyProcessIntegrationRegressionTests { "prompt-submit", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"UserPromptSubmit","prompt":"message \#(index)"}"# ) - XCTAssertFalse(prompt.timedOut, prompt.stderr) - XCTAssertEqual(prompt.status, 0, prompt.stderr) - XCTAssertEqual(prompt.stdout, "{}\n") + legacyAssertFalse(prompt.timedOut, prompt.stderr) + legacyAssertEqual(prompt.status, 0, prompt.stderr) + legacyAssertEqual(prompt.stdout, "{}\n") let promptCommands = Array(state.commands.dropFirst(promptCommandStart)) - XCTAssertTrue( + legacyAssertTrue( promptCommands.contains { $0.contains("set_status grok Running") }, "Expected Grok prompt \(index) to reuse the saved target without CMUX env, saw \(promptCommands)" ) - XCTAssertTrue( + legacyAssertTrue( promptCommands.contains { $0 == "clear_notifications --tab=\(workspaceId) --panel=\(surfaceId)" }, "Expected Grok prompt \(index) to clear only its own surface notifications, saw \(promptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( promptCommands.contains { $0 == "clear_notifications --tab=\(workspaceId)" }, "Grok prompt \(index) must not clear sibling surface notifications, saw \(promptCommands)" ) @@ -2418,12 +2418,12 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"SessionNotification { update: HookExecution { event_name: user_prompt_submit } }"}"# ) - XCTAssertFalse(internalNotification.timedOut, internalNotification.stderr) - XCTAssertEqual(internalNotification.status, 0, internalNotification.stderr) - XCTAssertEqual(internalNotification.stdout, "{}\n") + legacyAssertFalse(internalNotification.timedOut, internalNotification.stderr) + legacyAssertEqual(internalNotification.status, 0, internalNotification.stderr) + legacyAssertEqual(internalNotification.stdout, "{}\n") let internalCommands = Array(state.commands.dropFirst(internalCommandStart)) - XCTAssertFalse( + legacyAssertFalse( internalCommands.contains { $0.hasPrefix("notify_target_async ") }, "Grok internal prompt bookkeeping for chat message \(index) must not notify, saw \(internalCommands)" ) @@ -2433,12 +2433,12 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"HookExecution { event_name: user_prompt_submit }"}"# ) - XCTAssertFalse(bareInternalNotification.timedOut, bareInternalNotification.stderr) - XCTAssertEqual(bareInternalNotification.status, 0, bareInternalNotification.stderr) - XCTAssertEqual(bareInternalNotification.stdout, "{}\n") + legacyAssertFalse(bareInternalNotification.timedOut, bareInternalNotification.stderr) + legacyAssertEqual(bareInternalNotification.status, 0, bareInternalNotification.stderr) + legacyAssertEqual(bareInternalNotification.stdout, "{}\n") let bareInternalCommands = Array(state.commands.dropFirst(bareInternalCommandStart)) - XCTAssertFalse( + legacyAssertFalse( bareInternalCommands.contains { $0.hasPrefix("notify_target_async ") }, "Grok bare hook execution bookkeeping for chat message \(index) must not notify, saw \(bareInternalCommands)" ) @@ -2448,18 +2448,18 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"Turn complete in \#(index).0s."}"# ) - XCTAssertFalse(notification.timedOut, notification.stderr) - XCTAssertEqual(notification.status, 0, notification.stderr) - XCTAssertEqual(notification.stdout, "{}\n") + legacyAssertFalse(notification.timedOut, notification.stderr) + legacyAssertEqual(notification.status, 0, notification.stderr) + legacyAssertEqual(notification.stdout, "{}\n") let notificationCommands = Array(state.commands.dropFirst(notificationCommandStart)) - XCTAssertTrue( + legacyAssertTrue( notificationCommands.contains { $0.contains("notify_target_async \(workspaceId) \(surfaceId) Grok|Completed|Task completed") }, "Expected Grok completion notification for chat message \(index), saw \(notificationCommands)" ) - XCTAssertTrue( + legacyAssertTrue( notificationCommands.contains { $0.contains("set_status grok Idle") }, "Expected Grok completion for chat message \(index) to leave Grok idle, saw \(notificationCommands)" ) @@ -2469,27 +2469,27 @@ extension CLINotifyProcessIntegrationRegressionTests { "session-end", input: #"{"sessionId":"\#(sessionId)","cwd":"\#(root.path)","hookEventName":"SessionEnd"}"# ) - XCTAssertFalse(sessionEnd.timedOut, sessionEnd.stderr) - XCTAssertEqual(sessionEnd.status, 0, sessionEnd.stderr) - XCTAssertEqual(sessionEnd.stdout, "{}\n") + legacyAssertFalse(sessionEnd.timedOut, sessionEnd.stderr) + legacyAssertEqual(sessionEnd.status, 0, sessionEnd.stderr) + legacyAssertEqual(sessionEnd.stdout, "{}\n") let sessionEndCommands = Array(state.commands.dropFirst(sessionEndCommandStart)) let sessionEndMethods = sessionEndCommands.compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual( + legacyAssertEqual( sessionEndMethods, ["feed.push"], "Grok SessionEnd should only emit feed telemetry from the saved route, saw \(sessionEndCommands)" ) - XCTAssertFalse( + legacyAssertFalse( sessionEndCommands.contains { $0.hasPrefix("clear_agent_pid grok.") }, "Grok SessionEnd is a chat-turn boundary and must not clear the saved route, saw \(sessionEndCommands)" ) } let storeURL = root.appendingPathComponent("grok-hook-sessions.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - XCTAssertNotNil( + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + legacyAssertNotNil( sessions[sessionId], "Expected Grok route to remain available after multiple chat-message SessionEnd events" ) @@ -2567,7 +2567,7 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: input, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } @@ -2576,38 +2576,38 @@ extension CLINotifyProcessIntegrationRegressionTests { input: #"{"sessionId":"\#(runningSessionId)","cwd":"\#(root.path)","hookEventName":"SessionStart"}"#, environment: environment(surfaceId: runningSurfaceId) ) - XCTAssertFalse(runningStart.timedOut, runningStart.stderr) - XCTAssertEqual(runningStart.status, 0, runningStart.stderr) + legacyAssertFalse(runningStart.timedOut, runningStart.stderr) + legacyAssertEqual(runningStart.status, 0, runningStart.stderr) let completingStart = runGrokHook( "session-start", input: #"{"sessionId":"\#(completingSessionId)","cwd":"\#(root.path)","hookEventName":"SessionStart"}"#, environment: environment(surfaceId: completingSurfaceId) ) - XCTAssertFalse(completingStart.timedOut, completingStart.stderr) - XCTAssertEqual(completingStart.status, 0, completingStart.stderr) + legacyAssertFalse(completingStart.timedOut, completingStart.stderr) + legacyAssertEqual(completingStart.status, 0, completingStart.stderr) let runningStop = runGrokHook( "stop", input: #"{"sessionId":"\#(runningSessionId)","cwd":"\#(root.path)","hookEventName":"Stop"}"# ) - XCTAssertFalse(runningStop.timedOut, runningStop.stderr) - XCTAssertEqual(runningStop.status, 0, runningStop.stderr) + legacyAssertFalse(runningStop.timedOut, runningStop.stderr) + legacyAssertEqual(runningStop.status, 0, runningStop.stderr) let promptCommandStart = state.commands.count let runningPrompt = runGrokHook( "prompt-submit", input: #"{"sessionId":"\#(runningSessionId)","cwd":"\#(root.path)","hookEventName":"UserPromptSubmit","prompt":"keep running"}"# ) - XCTAssertFalse(runningPrompt.timedOut, runningPrompt.stderr) - XCTAssertEqual(runningPrompt.status, 0, runningPrompt.stderr) + legacyAssertFalse(runningPrompt.timedOut, runningPrompt.stderr) + legacyAssertEqual(runningPrompt.status, 0, runningPrompt.stderr) let promptCommands = Array(state.commands.dropFirst(promptCommandStart)) - XCTAssertTrue( + legacyAssertTrue( promptCommands.contains { $0 == "clear_notifications --tab=\(workspaceId) --panel=\(runningSurfaceId)" }, "Expected running Grok prompt to clear only its own surface notifications, saw \(promptCommands)" ) - XCTAssertTrue( + legacyAssertTrue( promptCommands.contains { $0.contains("set_status grok Running") }, "Expected running Grok prompt to mark Grok running, saw \(promptCommands)" ) @@ -2617,18 +2617,18 @@ extension CLINotifyProcessIntegrationRegressionTests { "notification", input: #"{"sessionId":"\#(completingSessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"Turn complete in 1.0s."}"# ) - XCTAssertFalse(completingNotification.timedOut, completingNotification.stderr) - XCTAssertEqual(completingNotification.status, 0, completingNotification.stderr) - XCTAssertEqual(completingNotification.stdout, "{}\n") + legacyAssertFalse(completingNotification.timedOut, completingNotification.stderr) + legacyAssertEqual(completingNotification.status, 0, completingNotification.stderr) + legacyAssertEqual(completingNotification.stdout, "{}\n") let completionCommands = Array(state.commands.dropFirst(completionCommandStart)) - XCTAssertTrue( + legacyAssertTrue( completionCommands.contains { $0.contains("notify_target_async \(workspaceId) \(completingSurfaceId) Grok|Completed|Task completed") }, "Expected completing Grok session to notify its own surface, saw \(completionCommands)" ) - XCTAssertFalse( + legacyAssertFalse( completionCommands.contains { $0.contains("set_status grok Idle") }, "Completing Grok session must not reset the shared Grok status while a sibling session is running, saw \(completionCommands)" ) @@ -2718,21 +2718,21 @@ extension CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"sessionId":"\#(completingSessionId)","cwd":"\#(root.path)","hookEventName":"Notification","message":"Turn complete in 1.0s."}"#, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) - XCTAssertFalse(completion.timedOut, completion.stderr) - XCTAssertEqual(completion.status, 0, completion.stderr) - XCTAssertEqual(completion.stdout, "{}\n") + legacyAssertFalse(completion.timedOut, completion.stderr) + legacyAssertEqual(completion.status, 0, completion.stderr) + legacyAssertEqual(completion.stdout, "{}\n") - XCTAssertTrue( + legacyAssertTrue( state.commands.contains { $0.contains("set_status grok Idle") }, "Dead PID running records must not keep the shared Grok status running, saw \(state.commands)" ) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - let staleSession = try XCTUnwrap(sessions[staleSessionId] as? [String: Any]) - XCTAssertNil( + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + let staleSession = try legacyUnwrap(sessions[staleSessionId] as? [String: Any]) + legacyAssertNil( staleSession["runtimeStatus"], "Dead PID running records should be cleared when they are ignored" ) @@ -2830,16 +2830,16 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let hookURL = root .appendingPathComponent(".grok", isDirectory: true) .appendingPathComponent("hooks", isDirectory: true) .appendingPathComponent("cmux-session.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) - let hooks = try XCTUnwrap(json["hooks"] as? [String: Any]) - let notificationGroups = try XCTUnwrap(hooks["Notification"] as? [[String: Any]]) + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) + let hooks = try legacyUnwrap(json["hooks"] as? [String: Any]) + let notificationGroups = try legacyUnwrap(hooks["Notification"] as? [[String: Any]]) let notificationCommands = notificationGroups .compactMap { $0["hooks"] as? [[String: Any]] } .flatMap { $0 } @@ -2848,7 +2848,7 @@ extension CLINotifyProcessIntegrationRegressionTests { .compactMap { $0["hooks"] as? [[String: Any]] } .flatMap { $0 } .compactMap { $0["timeout"] as? Int } - let preToolUseGroups = try XCTUnwrap(hooks["PreToolUse"] as? [[String: Any]]) + let preToolUseGroups = try legacyUnwrap(hooks["PreToolUse"] as? [[String: Any]]) let preToolUseTimeouts = preToolUseGroups .compactMap { $0["hooks"] as? [[String: Any]] } .flatMap { $0 } @@ -2860,25 +2860,25 @@ extension CLINotifyProcessIntegrationRegressionTests { .flatMap { $0 } .compactMap { $0["command"] as? String } - XCTAssertTrue( + legacyAssertTrue( notificationCommands.contains { $0.contains("cmux hooks grok notification") }, "Expected Grok Notification to dispatch to the notification handler, saw \(notificationCommands)" ) - XCTAssertFalse( + legacyAssertFalse( notificationCommands.contains { $0.contains("cmux hooks grok stop") }, "Grok Notification should not use the generic stop handler, saw \(notificationCommands)" ) - XCTAssertEqual(notificationTimeouts, [5]) - XCTAssertEqual(preToolUseTimeouts, [120]) - XCTAssertFalse( + legacyAssertEqual(notificationTimeouts, [5]) + legacyAssertEqual(preToolUseTimeouts, [120]) + legacyAssertFalse( allCommands.contains { $0.contains("[ -n \"$CMUX_SURFACE_ID\" ]") }, "Grok strips CMUX_* from hook subprocesses, so installed commands must not gate on CMUX_SURFACE_ID. Saw \(allCommands)" ) - XCTAssertFalse( + legacyAssertFalse( allCommands.contains { $0.contains("$CMUX_") }, "Grok treats $VAR references as required hook environment, so installed commands must avoid CMUX variable interpolation. Saw \(allCommands)" ) - XCTAssertFalse( + legacyAssertFalse( FileManager.default.fileExists(atPath: legacyHookURL.path), "Expected setup to remove legacy cmux-owned Grok hook file" ) @@ -2909,15 +2909,15 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let hookURL = root .appendingPathComponent(".grok", isDirectory: true) .appendingPathComponent("hooks", isDirectory: true) .appendingPathComponent("cmux-session.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) - let hooks = try XCTUnwrap(json["hooks"] as? [String: Any]) + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) + let hooks = try legacyUnwrap(json["hooks"] as? [String: Any]) let allCommands = hooks.values .compactMap { $0 as? [[String: Any]] } .flatMap { $0 } @@ -2925,20 +2925,20 @@ extension CLINotifyProcessIntegrationRegressionTests { .flatMap { $0 } .compactMap { $0["command"] as? String } - XCTAssertFalse(allCommands.isEmpty) - XCTAssertTrue( + legacyAssertFalse(allCommands.isEmpty) + legacyAssertTrue( allCommands.allSatisfy { $0.contains("cmux-grok-hook-v2") }, "Expected installed Grok hooks to carry the owned-hook marker, saw \(allCommands)" ) - XCTAssertTrue( + legacyAssertTrue( allCommands.allSatisfy { $0.contains("'\(pinnedCLI.path)'") }, "Expected installed Grok hooks to pin the installing CLI path, saw \(allCommands)" ) - XCTAssertTrue( + legacyAssertTrue( allCommands.allSatisfy { $0.contains("--socket '\(socketPath)'") }, "Expected installed Grok hooks to pin the installing socket path, saw \(allCommands)" ) - XCTAssertFalse( + legacyAssertFalse( allCommands.contains { $0.contains("$CMUX_") }, "Grok hook commands must not depend on CMUX environment interpolation, saw \(allCommands)" ) @@ -2992,19 +2992,19 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) - let legacyJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: legacyHookURL)) as? [String: Any]) - let hooks = try XCTUnwrap(legacyJSON["hooks"] as? [String: Any]) - let notificationGroups = try XCTUnwrap(hooks["Notification"] as? [[String: Any]]) + let legacyJSON = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: legacyHookURL)) as? [String: Any]) + let hooks = try legacyUnwrap(legacyJSON["hooks"] as? [String: Any]) + let notificationGroups = try legacyUnwrap(hooks["Notification"] as? [[String: Any]]) let commands = notificationGroups .compactMap { $0["hooks"] as? [[String: Any]] } .flatMap { $0 } .compactMap { $0["command"] as? String } - XCTAssertEqual(commands, [preservedCommand]) - XCTAssertFalse( + legacyAssertEqual(commands, [preservedCommand]) + legacyAssertFalse( commands.contains { $0.hasPrefix("[ \"$CMUX_GROK_HOOKS_DISABLED\"") }, "Expected setup to remove only exact cmux-owned legacy commands, saw \(commands)" ) @@ -3053,12 +3053,12 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) - let legacyJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: legacyHookURL)) as? [String: Any]) - XCTAssertEqual(legacyJSON["version"] as? Int, 1) - XCTAssertNil(legacyJSON["hooks"]) + let legacyJSON = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: legacyHookURL)) as? [String: Any]) + legacyAssertEqual(legacyJSON["version"] as? Int, 1) + legacyAssertNil(legacyJSON["hooks"]) } @Test func testCodexHookInstallPrefersLaunchingAppBundledCLI() throws { @@ -3109,12 +3109,12 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let hookURL = codexHome.appendingPathComponent("hooks.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) - let hooks = try XCTUnwrap(json["hooks"] as? [String: Any]) + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: hookURL)) as? [String: Any]) + let hooks = try legacyUnwrap(json["hooks"] as? [String: Any]) let allCommands = hooks.values .compactMap { $0 as? [[String: Any]] } .flatMap { $0 } @@ -3122,22 +3122,22 @@ extension CLINotifyProcessIntegrationRegressionTests { .flatMap { $0 } .compactMap { $0["command"] as? String } - XCTAssertTrue( + legacyAssertTrue( allCommands.contains { $0.contains("CMUX_BUNDLED_CLI_PATH") && $0.contains("\"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" hooks codex prompt-submit") }, "Codex hooks should route through the launching app's bundled CLI, saw \(allCommands)" ) - XCTAssertFalse( + legacyAssertFalse( allCommands.contains { $0.contains("command -v cmux >/dev/null 2>&1 && cmux hooks codex") }, "Codex hooks must not use the reload-global cmux shim directly, saw \(allCommands)" ) - XCTAssertFalse( + legacyAssertFalse( allCommands.contains { $0 == previousBundledHookCommand }, "Codex setup should replace bundled-CLI hooks that did not pin CMUX_SOCKET_PATH, saw \(allCommands)" ) - XCTAssertEqual( + legacyAssertEqual( allCommands.filter { $0.contains("hooks codex prompt-submit") }.count, 1, "Codex setup should collapse duplicate cmux-owned prompt hooks to one entry, saw \(allCommands)" @@ -3166,19 +3166,19 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertNotEqual(result.status, 0, result.stdout) - XCTAssertTrue( + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertNotEqual(result.status, 0, result.stdout) + legacyAssertTrue( result.stderr.contains("cmux could not create the hooks directory: a file exists at \(hooksPath.path); remove or rename the conflicting file and re-run `cmux hooks setup`"), result.stderr ) - XCTAssertFalse( + legacyAssertFalse( result.stdout.contains("Required agent configuration is missing."), result.stdout ) var isDirectory: ObjCBool = true - XCTAssertTrue(FileManager.default.fileExists(atPath: hooksPath.path, isDirectory: &isDirectory)) - XCTAssertFalse(isDirectory.boolValue) + legacyAssertTrue(FileManager.default.fileExists(atPath: hooksPath.path, isDirectory: &isDirectory)) + legacyAssertFalse(isDirectory.boolValue) } func runGenericHookPersistenceScenario(_ scenario: GenericHookPersistenceScenario) throws { @@ -3244,25 +3244,25 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") let storeURL = root.appendingPathComponent("\(scenario.agent)-hook-sessions.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - let session = try XCTUnwrap(sessions[scenario.sessionId] as? [String: Any]) - XCTAssertEqual(session["workspaceId"] as? String, workspaceId) - XCTAssertEqual(session["surfaceId"] as? String, surfaceId) - XCTAssertEqual(session["cwd"] as? String, workspace.path) - - let launchCommand = try XCTUnwrap(session["launchCommand"] as? [String: Any]) - XCTAssertEqual(launchCommand["launcher"] as? String, scenario.agent) - XCTAssertEqual(launchCommand["executablePath"] as? String, scenario.executable) - XCTAssertEqual(launchCommand["arguments"] as? [String], scenario.expectedArguments) - XCTAssertEqual(launchCommand["workingDirectory"] as? String, workspace.path) - XCTAssertEqual(launchCommand["environment"] as? [String: String], scenario.expectedEnvironment) + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + let session = try legacyUnwrap(sessions[scenario.sessionId] as? [String: Any]) + legacyAssertEqual(session["workspaceId"] as? String, workspaceId) + legacyAssertEqual(session["surfaceId"] as? String, surfaceId) + legacyAssertEqual(session["cwd"] as? String, workspace.path) + + let launchCommand = try legacyUnwrap(session["launchCommand"] as? [String: Any]) + legacyAssertEqual(launchCommand["launcher"] as? String, scenario.agent) + legacyAssertEqual(launchCommand["executablePath"] as? String, scenario.executable) + legacyAssertEqual(launchCommand["arguments"] as? [String], scenario.expectedArguments) + legacyAssertEqual(launchCommand["workingDirectory"] as? String, workspace.path) + legacyAssertEqual(launchCommand["environment"] as? [String: String], scenario.expectedEnvironment) if scenario.agent == "kiro" { let resumeSetRequests = state.commands.compactMap { command -> [String: Any]? in @@ -3272,17 +3272,17 @@ extension CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - XCTAssertEqual(resumeSetRequests.count, 1, state.commands.joined(separator: "\n")) - let params = try XCTUnwrap(resumeSetRequests.first) - XCTAssertEqual(params["kind"] as? String, "kiro") - XCTAssertEqual(params["checkpoint_id"] as? String, scenario.sessionId) - XCTAssertEqual(params["auto_resume"] as? Bool, true) - XCTAssertEqual( + legacyAssertEqual(resumeSetRequests.count, 1, state.commands.joined(separator: "\n")) + let params = try legacyUnwrap(resumeSetRequests.first) + legacyAssertEqual(params["kind"] as? String, "kiro") + legacyAssertEqual(params["checkpoint_id"] as? String, scenario.sessionId) + legacyAssertEqual(params["auto_resume"] as? Bool, true) + legacyAssertEqual( params["command"] as? String, "{ cd -- '\(workspace.path)' 2>/dev/null || [ ! -d '\(workspace.path)' ]; } && '\(scenario.executable)' 'chat' '--resume-id' '\(scenario.sessionId)' '--agent' 'cmux' '--trust-tools' 'fs_read,fs_write'" ) - XCTAssertEqual(params["environment"] as? [String: String], scenario.expectedEnvironment) - XCTAssertFalse( + legacyAssertEqual(params["environment"] as? [String: String], scenario.expectedEnvironment) + legacyAssertFalse( state.commands.contains { command in self.jsonObject(command)?["method"] as? String == "surface.resume.clear" }, @@ -3390,37 +3390,37 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let resumeRequests = state.snapshot().compactMap { command -> [String: Any]? in guard let payload = self.jsonObject(command), payload["method"] as? String == "surface.resume.set" else { return nil } return payload["params"] as? [String: Any] } - let params = try XCTUnwrap(resumeRequests.last, "expected a surface.resume.set; saw \(state.snapshot())") + let params = try legacyUnwrap(resumeRequests.last, "expected a surface.resume.set; saw \(state.snapshot())") let boundEnvironment = params["environment"] as? [String: String] - XCTAssertEqual( + legacyAssertEqual( boundEnvironment?["CODEX_HOME"], codexHome, "resume binding must carry the captured CODEX_HOME; params=\(params)" ) - let command = try XCTUnwrap(params["command"] as? String) - XCTAssertTrue(command.contains("'resume' '\(sessionId)'"), command) + let command = try legacyUnwrap(params["command"] as? String) + legacyAssertTrue(command.contains("'resume' '\(sessionId)'"), command) // The env-only record must also be PERSISTED to the hook session store (its arguments are // empty, so the store's "only assign launchCommand when arguments is non-empty" gate would // otherwise drop it) — a later fork/resume that reads the store rather than re-deriving from a // live hook env still needs CODEX_HOME. let storeURL = root.appendingPathComponent("codex-hook-sessions.json") - let storeJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - let sessions = try XCTUnwrap(storeJSON["sessions"] as? [String: Any]) - let persisted = try XCTUnwrap(sessions[sessionId] as? [String: Any]) - let persistedLaunch = try XCTUnwrap( + let storeJSON = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try legacyUnwrap(storeJSON["sessions"] as? [String: Any]) + let persisted = try legacyUnwrap(sessions[sessionId] as? [String: Any]) + let persistedLaunch = try legacyUnwrap( persisted["launchCommand"] as? [String: Any], "env-only launchCommand must be persisted for the fork path" ) - XCTAssertEqual( + legacyAssertEqual( (persistedLaunch["environment"] as? [String: String])?["CODEX_HOME"], codexHome, "persisted launchCommand must carry CODEX_HOME" ) @@ -3506,17 +3506,17 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let resumeRequests = state.snapshot().compactMap { command -> [String: Any]? in guard let payload = self.jsonObject(command), payload["method"] as? String == "surface.resume.set" else { return nil } return payload["params"] as? [String: Any] } - let params = try XCTUnwrap(resumeRequests.last, "expected a surface.resume.set; saw \(state.snapshot())") - XCTAssertEqual( + let params = try legacyUnwrap(resumeRequests.last, "expected a surface.resume.set; saw \(state.snapshot())") + legacyAssertEqual( params["surface_id"] as? String, ttySurfaceId, "PID/TTY ground truth must override the leaked env CMUX_SURFACE_ID; params=\(params)" ) @@ -3595,20 +3595,20 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let resumeRequests = state.snapshot().compactMap { command -> [String: Any]? in guard let payload = self.jsonObject(command), payload["method"] as? String == "surface.resume.set" else { return nil } return payload["params"] as? [String: Any] } - let params = try XCTUnwrap( + let params = try legacyUnwrap( resumeRequests.last, "stale ambient surface must not drop the hook; expected a surface.resume.set, saw \(state.snapshot())" ) - XCTAssertEqual( + legacyAssertEqual( params["surface_id"] as? String, ttySurfaceId, "a stale ambient CMUX_SURFACE_ID must fall through to the TTY pane; params=\(params)" ) @@ -3686,9 +3686,9 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) // No env-only CODEX_HOME record may be persisted for the rejected non-restorable argv. let storeURL = root.appendingPathComponent("codex-hook-sessions.json") @@ -3697,7 +3697,7 @@ extension CLINotifyProcessIntegrationRegressionTests { let sessions = storeJSON["sessions"] as? [String: Any], let persisted = sessions[sessionId] as? [String: Any] { let env = (persisted["launchCommand"] as? [String: Any])?["environment"] as? [String: String] - XCTAssertNil( + legacyAssertNil( env?["CODEX_HOME"], "non-restorable codex exec must not persist an env-only CODEX_HOME record; launchCommand=\(persisted["launchCommand"] ?? "nil")" ) diff --git a/cmuxTests/CLILegacyHookAliasTests.swift b/cmuxTests/CLILegacyHookAliasTests.swift index 9a10b0174d29..7e2df12d0e3b 100644 --- a/cmuxTests/CLILegacyHookAliasTests.swift +++ b/cmuxTests/CLILegacyHookAliasTests.swift @@ -53,20 +53,20 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") - XCTAssertFalse(result.stdout.contains("Usage:"), result.stdout) - XCTAssertFalse(result.stderr.contains("Usage:"), result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") + legacyAssertFalse(result.stdout.contains("Usage:"), result.stdout) + legacyAssertFalse(result.stderr.contains("Usage:"), result.stderr) let storeURL = root.appendingPathComponent("codex-hook-sessions.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - let session = try XCTUnwrap(sessions[surfaceId] as? [String: Any]) - XCTAssertEqual(session["workspaceId"] as? String, workspaceId) - XCTAssertEqual(session["surfaceId"] as? String, surfaceId) - XCTAssertNotNil(session["launchCommand"] as? [String: Any]) + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + let session = try legacyUnwrap(sessions[surfaceId] as? [String: Any]) + legacyAssertEqual(session["workspaceId"] as? String, workspaceId) + legacyAssertEqual(session["surfaceId"] as? String, surfaceId) + legacyAssertNotNil(session["launchCommand"] as? [String: Any]) } @Test func testLegacyFeedHookAliasReturnsJSONWithoutHelpOutsideCmuxTerminal() throws { @@ -85,10 +85,10 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") - XCTAssertFalse(result.stdout.contains("Usage:"), result.stdout) - XCTAssertFalse(result.stderr.contains("Usage:"), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") + legacyAssertFalse(result.stdout.contains("Usage:"), result.stdout) + legacyAssertFalse(result.stderr.contains("Usage:"), result.stderr) } } diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index de93c706755b..1b5055b30d6a 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -32,14 +32,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"clear-session","source":"clear","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue( + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") + legacyAssertTrue( context.state.commands.contains { $0 == "clear_notifications --tab=\(context.workspaceId)" }, "Expected clear SessionStart to clear stale notifications, saw \(context.state.commands)" ) - XCTAssertTrue( + legacyAssertTrue( context.state.commands.contains { $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") && $0.contains("--panel=\(context.surfaceId)") @@ -59,16 +59,16 @@ final class CLINotifyProcessIntegrationRegressionTests { arguments: ["hooks", "claude", "session-start"], standardInput: #"{"session_id":"\#(sessionId)","source":"startup","cwd":"\#(context.root.path)","transcript_path":"\#(context.root.path)/projects/startup-only-session.jsonl","hook_event_name":"SessionStart"}"# ) - XCTAssertFalse(start.timedOut, start.stderr) - XCTAssertEqual(start.status, 0, start.stderr) + legacyAssertFalse(start.timedOut, start.stderr) + legacyAssertEqual(start.status, 0, start.stderr) var record = try readClaudeHookSession(sessionId, context: context) - XCTAssertEqual( + legacyAssertEqual( record["isRestorable"] as? Bool, false, "Startup SessionStart records are only routing state until Claude creates a conversation." ) - XCTAssertEqual( + legacyAssertEqual( record["transcriptPath"] as? String, "\(context.root.path)/projects/startup-only-session.jsonl" ) @@ -78,11 +78,11 @@ final class CLINotifyProcessIntegrationRegressionTests { arguments: ["hooks", "claude", "prompt-submit"], standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","transcript_path":"\#(context.root.path)/projects/startup-only-session.jsonl","hook_event_name":"UserPromptSubmit"}"# ) - XCTAssertFalse(prompt.timedOut, prompt.stderr) - XCTAssertEqual(prompt.status, 0, prompt.stderr) + legacyAssertFalse(prompt.timedOut, prompt.stderr) + legacyAssertEqual(prompt.status, 0, prompt.stderr) record = try readClaudeHookSession(sessionId, context: context) - XCTAssertEqual( + legacyAssertEqual( record["isRestorable"] as? Bool, true, "UserPromptSubmit marks the session eligible for resume." @@ -118,15 +118,15 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"tail-session","turn_id":"turn-1","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo recent"}}"# ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - let preToolEvent = try XCTUnwrap( + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + let preToolEvent = try legacyUnwrap( feedPushEvents(in: context).last { $0["hook_event_name"] as? String == "PreToolUse" } ) - let feedContext = try XCTUnwrap(preToolEvent["context"] as? [String: Any]) - XCTAssertEqual(feedContext["lastUserMessage"] as? String, "recent user message") - XCTAssertEqual(feedContext["assistantPreamble"] as? String, "recent assistant response") - XCTAssertFalse(String(describing: feedContext).contains("ancient"), "\(feedContext)") + let feedContext = try legacyUnwrap(preToolEvent["context"] as? [String: Any]) + legacyAssertEqual(feedContext["lastUserMessage"] as? String, "recent user message") + legacyAssertEqual(feedContext["assistantPreamble"] as? String, "recent assistant response") + legacyAssertFalse(String(describing: feedContext).contains("ancient"), "\(feedContext)") } @Test @@ -152,14 +152,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"oversized-final-session","turn_id":"turn-1","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"echo huge"}}"# ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - let preToolEvent = try XCTUnwrap( + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + let preToolEvent = try legacyUnwrap( feedPushEvents(in: context).last { $0["hook_event_name"] as? String == "PreToolUse" } ) - let feedContext = try XCTUnwrap(preToolEvent["context"] as? [String: Any]) - let assistantPreamble = try XCTUnwrap(feedContext["assistantPreamble"] as? String) - XCTAssertTrue(assistantPreamble.hasPrefix("recent assistant response"), "\(feedContext)") + let feedContext = try legacyUnwrap(preToolEvent["context"] as? [String: Any]) + let assistantPreamble = try legacyUnwrap(feedContext["assistantPreamble"] as? String) + legacyAssertTrue(assistantPreamble.hasPrefix("recent assistant response"), "\(feedContext)") } @Test @@ -187,9 +187,9 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"codex-oversized-final-session","turn_id":"\#(turnId)","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":null}"# ) - XCTAssertFalse(stop.timedOut, stop.stderr) - XCTAssertEqual(stop.status, 0, stop.stderr) - XCTAssertTrue( + legacyAssertFalse(stop.timedOut, stop.stderr) + legacyAssertEqual(stop.status, 0, stop.stderr) + legacyAssertTrue( context.state.commands.contains { command in command.contains("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|Error|Codex ended before sending a final response") }, @@ -210,8 +210,8 @@ final class CLINotifyProcessIntegrationRegressionTests { environment: ["PATH": "/usr/bin:/bin:/usr/sbin:/sbin"], timeout: 10 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) guard result.status == 0 else { throw NSError(domain: "CLINotifyProcessIntegrationRegressionTests.git", code: Int(result.status)) } @@ -219,8 +219,8 @@ final class CLINotifyProcessIntegrationRegressionTests { } func baselineRecords() throws -> [[String: Any]] { let storeURL = context.root.appendingPathComponent("agent-turn-diff-baselines.json") - let store = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - return try XCTUnwrap(store["records"] as? [[String: Any]]) + let store = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + return try legacyUnwrap(store["records"] as? [[String: Any]]) } _ = try runGit(["init"]) @@ -240,8 +240,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-0","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#, extraEnvironment: codexLaunchEnvironment(context: context, sessionId: sessionId) ) - XCTAssertFalse(sessionStart.timedOut, sessionStart.stderr) - XCTAssertEqual(sessionStart.status, 0, sessionStart.stderr) + legacyAssertFalse(sessionStart.timedOut, sessionStart.stderr) + legacyAssertEqual(sessionStart.status, 0, sessionStart.stderr) try "one\ntwo\n".write(to: storyURL, atomically: true, encoding: .utf8) _ = try runGit(["add", "story.txt"]) @@ -254,16 +254,16 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit"}"#, extraEnvironment: codexLaunchEnvironment(context: context, sessionId: sessionId) ) - XCTAssertFalse(promptSubmit.timedOut, promptSubmit.stderr) - XCTAssertEqual(promptSubmit.status, 0, promptSubmit.stderr) + legacyAssertFalse(promptSubmit.timedOut, promptSubmit.stderr) + legacyAssertEqual(promptSubmit.status, 0, promptSubmit.stderr) let records = try baselineRecords() - let startRecord = try XCTUnwrap(records.first { $0["turnId"] as? String == "turn-0" }) - let promptRecord = try XCTUnwrap(records.first { $0["turnId"] as? String == "turn-1" }) - XCTAssertEqual(startRecord["baseCommit"] as? String, initialCommit) - XCTAssertEqual(promptRecord["baseCommit"] as? String, promptCommit) - XCTAssertEqual(promptRecord["workspaceId"] as? String, context.workspaceId) - XCTAssertEqual(promptRecord["surfaceId"] as? String, context.surfaceId) + let startRecord = try legacyUnwrap(records.first { $0["turnId"] as? String == "turn-0" }) + let promptRecord = try legacyUnwrap(records.first { $0["turnId"] as? String == "turn-1" }) + legacyAssertEqual(startRecord["baseCommit"] as? String, initialCommit) + legacyAssertEqual(promptRecord["baseCommit"] as? String, promptCommit) + legacyAssertEqual(promptRecord["workspaceId"] as? String, context.workspaceId) + legacyAssertEqual(promptRecord["surfaceId"] as? String, context.surfaceId) try "one\ntwo\nnested\n".write(to: storyURL, atomically: true, encoding: .utf8) _ = try runGit(["add", "story.txt"]) @@ -274,16 +274,16 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit"}"#, extraEnvironment: codexLaunchEnvironment(context: context, sessionId: sessionId) ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let childRecords = try baselineRecords() - XCTAssertNil( + legacyAssertNil( childRecords.first { $0["turnId"] as? String == "child-turn" }, "Nested Codex prompts should not create a last-turn diff baseline." ) - let parentRecordAfterChild = try XCTUnwrap(childRecords.first { $0["turnId"] as? String == "turn-1" }) - XCTAssertEqual(parentRecordAfterChild["baseCommit"] as? String, promptCommit) + let parentRecordAfterChild = try legacyUnwrap(childRecords.first { $0["turnId"] as? String == "turn-1" }) + legacyAssertEqual(parentRecordAfterChild["baseCommit"] as? String, promptCommit) let childStop = runCodexHook( context: context, @@ -291,8 +291,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, extraEnvironment: codexLaunchEnvironment(context: context, sessionId: sessionId) ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) let parentStop = runCodexHook( context: context, @@ -300,8 +300,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, extraEnvironment: codexLaunchEnvironment(context: context, sessionId: sessionId) ) - XCTAssertFalse(parentStop.timedOut, parentStop.stderr) - XCTAssertEqual(parentStop.status, 0, parentStop.stderr) + legacyAssertFalse(parentStop.timedOut, parentStop.stderr) + legacyAssertEqual(parentStop.status, 0, parentStop.stderr) try "one\ntwo\nthree\n".write(to: storyURL, atomically: true, encoding: .utf8) let dirtyPromptSubmit = runCodexHook( @@ -310,13 +310,13 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit"}"#, extraEnvironment: codexLaunchEnvironment(context: context, sessionId: sessionId) ) - XCTAssertFalse(dirtyPromptSubmit.timedOut, dirtyPromptSubmit.stderr) - XCTAssertEqual(dirtyPromptSubmit.status, 0, dirtyPromptSubmit.stderr) + legacyAssertFalse(dirtyPromptSubmit.timedOut, dirtyPromptSubmit.stderr) + legacyAssertEqual(dirtyPromptSubmit.status, 0, dirtyPromptSubmit.stderr) let dirtyRecords = try baselineRecords() - let dirtyRecord = try XCTUnwrap(dirtyRecords.first { $0["turnId"] as? String == "turn-1" }) - let dirtyBaseCommit = try XCTUnwrap(dirtyRecord["baseCommit"] as? String) - XCTAssertEqual(dirtyBaseCommit, promptCommit) + let dirtyRecord = try legacyUnwrap(dirtyRecords.first { $0["turnId"] as? String == "turn-1" }) + let dirtyBaseCommit = try legacyUnwrap(dirtyRecord["baseCommit"] as? String) + legacyAssertEqual(dirtyBaseCommit, promptCommit) let dirtyStop = runCodexHook( context: context, @@ -324,8 +324,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"dirty done"}"#, extraEnvironment: codexLaunchEnvironment(context: context, sessionId: sessionId) ) - XCTAssertFalse(dirtyStop.timedOut, dirtyStop.stderr) - XCTAssertEqual(dirtyStop.status, 0, dirtyStop.stderr) + legacyAssertFalse(dirtyStop.timedOut, dirtyStop.stderr) + legacyAssertEqual(dirtyStop.status, 0, dirtyStop.stderr) try "one\ntwo\nthree\nfour\n".write(to: storyURL, atomically: true, encoding: .utf8) let refreshedDirtyPromptSubmit = runCodexHook( @@ -334,14 +334,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit"}"#, extraEnvironment: codexLaunchEnvironment(context: context, sessionId: sessionId) ) - XCTAssertFalse(refreshedDirtyPromptSubmit.timedOut, refreshedDirtyPromptSubmit.stderr) - XCTAssertEqual(refreshedDirtyPromptSubmit.status, 0, refreshedDirtyPromptSubmit.stderr) + legacyAssertFalse(refreshedDirtyPromptSubmit.timedOut, refreshedDirtyPromptSubmit.stderr) + legacyAssertEqual(refreshedDirtyPromptSubmit.status, 0, refreshedDirtyPromptSubmit.stderr) let refreshedRecords = try baselineRecords() - let refreshedRecord = try XCTUnwrap(refreshedRecords.first { $0["turnId"] as? String == "turn-1" }) - let refreshedBaseCommit = try XCTUnwrap(refreshedRecord["baseCommit"] as? String) - XCTAssertEqual(refreshedBaseCommit, dirtyBaseCommit) - XCTAssertEqual(refreshedBaseCommit, promptCommit) + let refreshedRecord = try legacyUnwrap(refreshedRecords.first { $0["turnId"] as? String == "turn-1" }) + let refreshedBaseCommit = try legacyUnwrap(refreshedRecord["baseCommit"] as? String) + legacyAssertEqual(refreshedBaseCommit, dirtyBaseCommit) + legacyAssertEqual(refreshedBaseCommit, promptCommit) } @Test @@ -354,41 +354,41 @@ final class CLINotifyProcessIntegrationRegressionTests { arguments: ["hooks", "claude", "session-start"], standardInput: #"{"session_id":"old-session","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# ) - XCTAssertFalse(oldStart.timedOut, oldStart.stderr) - XCTAssertEqual(oldStart.status, 0, oldStart.stderr) + legacyAssertFalse(oldStart.timedOut, oldStart.stderr) + legacyAssertEqual(oldStart.status, 0, oldStart.stderr) let clearStart = runClaudeHook( context: context, arguments: ["hooks", "claude", "session-start"], standardInput: #"{"session_id":"clear-session","source":"clear","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# ) - XCTAssertFalse(clearStart.timedOut, clearStart.stderr) - XCTAssertEqual(clearStart.status, 0, clearStart.stderr) + legacyAssertFalse(clearStart.timedOut, clearStart.stderr) + legacyAssertEqual(clearStart.status, 0, clearStart.stderr) let lateOldStart = runClaudeHook( context: context, arguments: ["hooks", "claude", "session-start"], standardInput: #"{"session_id":"old-session","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# ) - XCTAssertFalse(lateOldStart.timedOut, lateOldStart.stderr) - XCTAssertEqual(lateOldStart.status, 0, lateOldStart.stderr) + legacyAssertFalse(lateOldStart.timedOut, lateOldStart.stderr) + legacyAssertEqual(lateOldStart.status, 0, lateOldStart.stderr) let staleStop = runClaudeHook( context: context, arguments: ["hooks", "claude", "stop"], standardInput: #"{"session_id":"old-session","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"old turn finished late"}"# ) - XCTAssertFalse(staleStop.timedOut, staleStop.stderr) - XCTAssertEqual(staleStop.status, 0, staleStop.stderr) + legacyAssertFalse(staleStop.timedOut, staleStop.stderr) + legacyAssertEqual(staleStop.status, 0, staleStop.stderr) - XCTAssertTrue( + legacyAssertTrue( context.state.commands.contains { $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") && $0.contains("--panel=\(context.surfaceId)") }, "Expected clear SessionStart to mark Claude running, saw \(context.state.commands)" ) - XCTAssertFalse( + legacyAssertFalse( context.state.commands.contains { $0.hasPrefix("set_status claude_code Idle ") && $0.contains("--tab=\(context.workspaceId)") }, @@ -401,9 +401,9 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - XCTAssertEqual(resumeBindingRequests.count, 1, context.state.commands.joined(separator: "\n")) - XCTAssertEqual(resumeBindingRequests.first?["checkpoint_id"] as? String, "clear-session") - XCTAssertEqual(resumeBindingRequests.first?["auto_resume"] as? Bool, true) + legacyAssertEqual(resumeBindingRequests.count, 1, context.state.commands.joined(separator: "\n")) + legacyAssertEqual(resumeBindingRequests.first?["checkpoint_id"] as? String, "clear-session") + legacyAssertEqual(resumeBindingRequests.first?["auto_resume"] as? Bool, true) } @Test @@ -416,32 +416,32 @@ final class CLINotifyProcessIntegrationRegressionTests { arguments: ["hooks", "claude", "session-start"], standardInput: #"{"session_id":"old-session","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# ) - XCTAssertFalse(oldStart.timedOut, oldStart.stderr) - XCTAssertEqual(oldStart.status, 0, oldStart.stderr) + legacyAssertFalse(oldStart.timedOut, oldStart.stderr) + legacyAssertEqual(oldStart.status, 0, oldStart.stderr) let oldPrompt = runClaudeHook( context: context, arguments: ["hooks", "claude", "prompt-submit"], standardInput: #"{"session_id":"old-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"PromptSubmit"}"# ) - XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) - XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) + legacyAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) + legacyAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) let oldStop = runClaudeHook( context: context, arguments: ["hooks", "claude", "stop"], standardInput: #"{"session_id":"old-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"old turn finished"}"# ) - XCTAssertFalse(oldStop.timedOut, oldStop.stderr) - XCTAssertEqual(oldStop.status, 0, oldStop.stderr) + legacyAssertFalse(oldStop.timedOut, oldStop.stderr) + legacyAssertEqual(oldStop.status, 0, oldStop.stderr) let newStart = runClaudeHook( context: context, arguments: ["hooks", "claude", "session-start"], standardInput: #"{"session_id":"new-session","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"# ) - XCTAssertFalse(newStart.timedOut, newStart.stderr) - XCTAssertEqual(newStart.status, 0, newStart.stderr) + legacyAssertFalse(newStart.timedOut, newStart.stderr) + legacyAssertEqual(newStart.status, 0, newStart.stderr) let newPromptStart = context.state.commands.count let newPrompt = runClaudeHook( @@ -449,11 +449,11 @@ final class CLINotifyProcessIntegrationRegressionTests { arguments: ["hooks", "claude", "prompt-submit"], standardInput: #"{"session_id":"new-session","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"PromptSubmit"}"# ) - XCTAssertFalse(newPrompt.timedOut, newPrompt.stderr) - XCTAssertEqual(newPrompt.status, 0, newPrompt.stderr) + legacyAssertFalse(newPrompt.timedOut, newPrompt.stderr) + legacyAssertEqual(newPrompt.status, 0, newPrompt.stderr) let newPromptCommands = Array(context.state.commands.dropFirst(newPromptStart)) - XCTAssertTrue( + legacyAssertTrue( newPromptCommands.contains { $0.hasPrefix("set_status claude_code Running --icon=bolt.fill --color=#4C8DFF --tab=\(context.workspaceId)") }, @@ -700,7 +700,7 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: standardInput, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } @@ -726,11 +726,11 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(parentSessionId)","source":"resume","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#, extraEnvironment: claudeForkLaunchEnvironment(context: context, parentSessionId: parentSessionId) ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let parentRecord = try readClaudeHookSession(parentSessionId, context: context) - XCTAssertEqual( + legacyAssertEqual( parentRecord["surfaceId"] as? String, parentSurfaceId, "Fork-session SessionStart reports the parent session id and must not steal the parent record's surface binding for the fork pane" @@ -766,10 +766,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(parentSessionId)","source":"resume","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#, extraEnvironment: environment ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) - XCTAssertFalse( + legacyAssertFalse( context.state.commands.contains { $0.hasPrefix("set_agent_pid claude_code ") }, "A fork SessionStart without an authoritative surface must not register its PID on a borrowed fallback pane, saw \(context.state.commands)" ) @@ -802,11 +802,11 @@ final class CLINotifyProcessIntegrationRegressionTests { arguments: ["/usr/local/bin/claude", "--resume", parentSessionId, "--fork-session=true"] ) ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let parentRecord = try readClaudeHookSession(parentSessionId, context: context) - XCTAssertEqual( + legacyAssertEqual( parentRecord["surfaceId"] as? String, parentSurfaceId, "Fork detection must recognize the --fork-session=true flag form the launch sanitizer already accepts" @@ -872,8 +872,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"session-3","turn_id":"turn-3","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"three"}"#, extraEnvironment: ["CMUX_SURFACE_ID": paneB] ) - XCTAssertFalse(paneBPrompt.timedOut, paneBPrompt.stderr) - XCTAssertEqual(paneBPrompt.status, 0, paneBPrompt.stderr) + legacyAssertFalse(paneBPrompt.timedOut, paneBPrompt.stderr) + legacyAssertEqual(paneBPrompt.status, 0, paneBPrompt.stderr) // …then a late Stop from stale session-1 in pane A must stay stale: // the pane boundary (session-2 owns pane A) has to survive the upgrade @@ -884,11 +884,11 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"session-1","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"late"}"#, extraEnvironment: ["CMUX_SURFACE_ID": paneA] ) - XCTAssertFalse(lateStop.timedOut, lateStop.stderr) - XCTAssertEqual(lateStop.status, 0, lateStop.stderr) + legacyAssertFalse(lateStop.timedOut, lateStop.stderr) + legacyAssertEqual(lateStop.status, 0, lateStop.stderr) let staleRecord = try readClaudeHookSession("session-1", context: context) - XCTAssertEqual( + legacyAssertEqual( staleRecord["agentLifecycle"] as? String, "running", "A legacy store must backfill the pane boundary so pre-upgrade stale sessions stay stale after another pane promotes" @@ -919,16 +919,16 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(forkedSessionId)","turn_id":"fork-turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"diverge here"}"#, extraEnvironment: claudeForkLaunchEnvironment(context: context, parentSessionId: parentSessionId) ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let forkedRecord = try readClaudeHookSession(forkedSessionId, context: context) - XCTAssertEqual( + legacyAssertEqual( forkedRecord["surfaceId"] as? String, context.surfaceId, "The forked session's first prompt-submit must bind the forked session to the fork pane even while the parent session owns the workspace's active turn" ) - XCTAssertEqual( + legacyAssertEqual( forkedRecord["isRestorable"] as? Bool, true, "The forked session must become restorable so a cmux restart resumes the fork, not the parent" @@ -942,10 +942,10 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - XCTAssertEqual(resumeBindingRequests.count, 1, promptCommands.joined(separator: "\n")) - let request = try XCTUnwrap(resumeBindingRequests.first) - XCTAssertEqual(request["checkpoint_id"] as? String, forkedSessionId) - XCTAssertEqual(request["surface_id"] as? String, context.surfaceId) + legacyAssertEqual(resumeBindingRequests.count, 1, promptCommands.joined(separator: "\n")) + let request = try legacyUnwrap(resumeBindingRequests.first) + legacyAssertEqual(request["checkpoint_id"] as? String, forkedSessionId) + legacyAssertEqual(request["surface_id"] as? String, context.surfaceId) } @Test @@ -972,11 +972,11 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(parentSessionId)","cwd":"\#(context.root.path)","hook_event_name":"SessionEnd"}"#, extraEnvironment: claudeForkLaunchEnvironment(context: context, parentSessionId: parentSessionId) ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let parentRecord = try readClaudeHookSession(parentSessionId, context: context) - XCTAssertEqual( + legacyAssertEqual( parentRecord["surfaceId"] as? String, parentSurfaceId, "A pre-prompt fork exit must not consume the parent session record the original pane still owns" @@ -988,11 +988,11 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - XCTAssertTrue( + legacyAssertTrue( resumeClearRequests.isEmpty, "A pre-prompt fork exit must not clear the parent pane's resume binding, saw \(resumeClearRequests)" ) - XCTAssertTrue( + legacyAssertTrue( context.state.commands.contains { $0.hasPrefix("clear_agent_pid claude_code ") && $0.contains("--panel=\(context.surfaceId)") }, @@ -1026,11 +1026,11 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(forkedSessionId)","turn_id":"fork-turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"diverge here"}"#, extraEnvironment: ["CMUX_SURFACE_ID": "surface:2"] ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let forkedRecord = try readClaudeHookSession(forkedSessionId, context: context) - XCTAssertEqual( + legacyAssertEqual( forkedRecord["surfaceId"] as? String, context.surfaceId, "A forked session's first prompt-submit must record via the resolved surface when the hook supplies the surface as a ref" @@ -1093,16 +1093,16 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(staleSessionId)","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"late stop"}"#, extraEnvironment: ["CMUX_SURFACE_ID": closedSurfaceId] ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let staleRecord = try readClaudeHookSession(staleSessionId, context: context) - XCTAssertEqual( + legacyAssertEqual( staleRecord["surfaceId"] as? String, closedSurfaceId, "A stale hook resolved to a fallback surface must not retarget the session record to a pane it never owned" ) - XCTAssertEqual( + legacyAssertEqual( staleRecord["agentLifecycle"] as? String, "running", "A late Stop from a closed pane must stay stale when surface resolution fell back to another pane" @@ -1139,8 +1139,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ("prompt-submit", #"{"session_id":"session-2","turn_id":"turn-2","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"two"}"#), ] { let result = runHook(subcommand, stdin: stdin, surface: paneA) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) } // Pane B: a different session (e.g. a forked conversation) takes the @@ -1150,8 +1150,8 @@ final class CLINotifyProcessIntegrationRegressionTests { stdin: #"{"session_id":"session-3","turn_id":"turn-3","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"three"}"#, surface: paneB ) - XCTAssertFalse(paneBPrompt.timedOut, paneBPrompt.stderr) - XCTAssertEqual(paneBPrompt.status, 0, paneBPrompt.stderr) + legacyAssertFalse(paneBPrompt.timedOut, paneBPrompt.stderr) + legacyAssertEqual(paneBPrompt.status, 0, paneBPrompt.stderr) // A late Stop from the superseded session-1 in pane A must stay stale // even though the workspace-active session now lives in pane B. @@ -1160,14 +1160,14 @@ final class CLINotifyProcessIntegrationRegressionTests { stdin: #"{"session_id":"session-1","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"late"}"#, surface: paneA ) - XCTAssertFalse(lateStop.timedOut, lateStop.stderr) - XCTAssertEqual(lateStop.status, 0, lateStop.stderr) + legacyAssertFalse(lateStop.timedOut, lateStop.stderr) + legacyAssertEqual(lateStop.status, 0, lateStop.stderr) let stateURL = context.root.appendingPathComponent("claude-hook-sessions.json") - let savedState = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) - let activeSessions = try XCTUnwrap(savedState["activeSessionsByWorkspace"] as? [String: Any]) - let active = try XCTUnwrap(activeSessions[context.workspaceId] as? [String: Any]) - XCTAssertEqual( + let savedState = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let activeSessions = try legacyUnwrap(savedState["activeSessionsByWorkspace"] as? [String: Any]) + let active = try legacyUnwrap(activeSessions[context.workspaceId] as? [String: Any]) + legacyAssertEqual( active["sessionId"] as? String, "session-3", "A late Stop from a session replaced in its own pane must not re-promote it after another pane became the workspace-active session" @@ -1205,8 +1205,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ("prompt-submit", #"{"session_id":"session-3","turn_id":"turn-3","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"three"}"#, paneB), ] { let result = runHook(subcommand, stdin: stdin, surface: surface) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) } // Pane A: the user starts a fresh Claude session. It must replace the @@ -1217,17 +1217,17 @@ final class CLINotifyProcessIntegrationRegressionTests { ("prompt-submit", #"{"session_id":"session-4","turn_id":"turn-4","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"four"}"#), ] { let result = runHook(subcommand, stdin: stdin, surface: paneA) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) } let newRecord = try readClaudeHookSession("session-4", context: context) - XCTAssertEqual( + legacyAssertEqual( newRecord["surfaceId"] as? String, paneA, "A fresh session in an idle pane must record against its own pane" ) - XCTAssertEqual( + legacyAssertEqual( newRecord["isRestorable"] as? Bool, true, "A fresh session replacing a stopped session in its own pane must not be dropped as stale after another pane became workspace-active" @@ -1265,8 +1265,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ("prompt-submit", #"{"session_id":"session-3","turn_id":"turn-3","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"three"}"#, paneB), ] { let result = runHook(subcommand, stdin: stdin, surface: surface) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) } // A stale SessionEnd for session-1's finished turn-1 must not consume @@ -1277,11 +1277,11 @@ final class CLINotifyProcessIntegrationRegressionTests { stdin: #"{"session_id":"session-1","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"SessionEnd"}"#, surface: paneA ) - XCTAssertFalse(staleEnd.timedOut, staleEnd.stderr) - XCTAssertEqual(staleEnd.status, 0, staleEnd.stderr) + legacyAssertFalse(staleEnd.timedOut, staleEnd.stderr) + legacyAssertEqual(staleEnd.status, 0, staleEnd.stderr) let record = try readClaudeHookSession("session-1", context: context) - XCTAssertEqual( + legacyAssertEqual( record["surfaceId"] as? String, paneA, "A stale turn-mismatched SessionEnd must not consume a session that is still active in its own pane after another pane became workspace-active" @@ -1313,11 +1313,11 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(parentSessionId)","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent turn finished"}"#, extraEnvironment: ["CMUX_SURFACE_ID": parentSurfaceId] ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let parentRecord = try readClaudeHookSession(parentSessionId, context: context) - XCTAssertEqual( + legacyAssertEqual( parentRecord["agentLifecycle"] as? String, "idle", "The parent pane's Stop must keep applying after the forked session became the workspace's active session in another pane" @@ -1356,8 +1356,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","source":"startup","cwd":"\#(context.root.path)","hook_event_name":"SessionStart"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(start.timedOut, start.stderr) - XCTAssertEqual(start.status, 0, start.stderr) + legacyAssertFalse(start.timedOut, start.stderr) + legacyAssertEqual(start.status, 0, start.stderr) let commandStart = context.state.commands.count let prompt = runClaudeHookWithoutServer( @@ -1366,8 +1366,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(prompt.timedOut, prompt.stderr) - XCTAssertEqual(prompt.status, 0, prompt.stderr) + legacyAssertFalse(prompt.timedOut, prompt.stderr) + legacyAssertEqual(prompt.status, 0, prompt.stderr) let promptCommands = Array(context.state.commands.dropFirst(commandStart)) let resumeBindingRequests = promptCommands.compactMap { command -> [String: Any]? in @@ -1377,19 +1377,19 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - XCTAssertEqual(resumeBindingRequests.count, 1, promptCommands.joined(separator: "\n")) - let request = try XCTUnwrap(resumeBindingRequests.first) - XCTAssertEqual(request["auto_resume"] as? Bool, true) - let environment = try XCTUnwrap(request["environment"] as? [String: Any]) - XCTAssertEqual(environment["CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV"] as? String, "1") - XCTAssertEqual( + legacyAssertEqual(resumeBindingRequests.count, 1, promptCommands.joined(separator: "\n")) + let request = try legacyUnwrap(resumeBindingRequests.first) + legacyAssertEqual(request["auto_resume"] as? Bool, true) + let environment = try legacyUnwrap(request["environment"] as? [String: Any]) + legacyAssertEqual(environment["CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV"] as? String, "1") + legacyAssertEqual( environment["CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS"] as? String, "ANTHROPIC_BASE_URL,ANTHROPIC_MODEL,CLAUDE_CONFIG_DIR" ) - XCTAssertNil(environment["ANTHROPIC_API_KEY"]) - XCTAssertEqual(environment["ANTHROPIC_BASE_URL"] as? String, "https://api.example.test") - XCTAssertEqual(environment["ANTHROPIC_MODEL"] as? String, "claude-sonnet-test") - XCTAssertEqual( + legacyAssertNil(environment["ANTHROPIC_API_KEY"]) + legacyAssertEqual(environment["ANTHROPIC_BASE_URL"] as? String, "https://api.example.test") + legacyAssertEqual(environment["ANTHROPIC_MODEL"] as? String, "claude-sonnet-test") + legacyAssertEqual( environment["CLAUDE_CONFIG_DIR"] as? String, context.root.appendingPathComponent("claude-config", isDirectory: true).path ) @@ -1442,24 +1442,24 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"cwd":"\#(context.root.path)","hook_event_name":"SessionEnd"}"# ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - let savedState = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) - let savedSessions = try XCTUnwrap(savedState["sessions"] as? [String: Any]) - XCTAssertNil( + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") + let savedState = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let savedSessions = try legacyUnwrap(savedState["sessions"] as? [String: Any]) + legacyAssertNil( savedSessions[staleSessionId], "Expected fallback session-end handling to consume the seeded stale session" ) - XCTAssertFalse( + legacyAssertFalse( context.state.commands.contains { $0.hasPrefix("clear_status claude_code ") && $0.contains("--tab=\(staleWorkspaceId)") }, "Expected stale SessionEnd not to clear the consumed workspace, saw \(context.state.commands)" ) - XCTAssertFalse( + legacyAssertFalse( context.state.commands.contains { $0.hasPrefix("clear_agent_pid claude_code ") && $0.contains("--tab=\(staleWorkspaceId)") }, "Expected stale SessionEnd not to clear the consumed workspace PID, saw \(context.state.commands)" ) - XCTAssertFalse( + legacyAssertFalse( context.state.commands.contains { $0 == "clear_notifications --tab=\(staleWorkspaceId)" }, "Expected stale SessionEnd not to clear the consumed workspace notifications, saw \(context.state.commands)" ) @@ -1502,26 +1502,26 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"SessionEnd"}"# ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertFalse( + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse( context.state.commands.contains { $0.hasPrefix("clear_agent_pid claude_code ") && $0.contains("--tab=\(context.workspaceId)") }, "Expected stale same-session turn not to clear current PID, saw \(context.state.commands)" ) - XCTAssertFalse( + legacyAssertFalse( context.state.commands.contains { $0 == "clear_notifications --tab=\(context.workspaceId)" }, "Expected stale same-session turn not to clear current notifications, saw \(context.state.commands)" ) - let savedState = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) - let savedSessions = try XCTUnwrap(savedState["sessions"] as? [String: Any]) - XCTAssertNotNil( + let savedState = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let savedSessions = try legacyUnwrap(savedState["sessions"] as? [String: Any]) + legacyAssertNotNil( savedSessions[sessionId], "Expected stale same-session SessionEnd not to consume the active session" ) - let activeSessions = try XCTUnwrap(savedState["activeSessionsByWorkspace"] as? [String: Any]) - let active = try XCTUnwrap(activeSessions[context.workspaceId] as? [String: Any]) - XCTAssertEqual(active["turnId"] as? String, "turn-2") + let activeSessions = try legacyUnwrap(savedState["activeSessionsByWorkspace"] as? [String: Any]) + let active = try legacyUnwrap(activeSessions[context.workspaceId] as? [String: Any]) + legacyAssertEqual(active["turnId"] as? String, "turn-2") } @Test @@ -1554,8 +1554,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"SessionEnd"}"# ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let clearRequests = context.state.commands.compactMap { command -> [String: Any]? in guard let payload = jsonObject(command), payload["method"] as? String == "surface.resume.clear" else { @@ -1563,11 +1563,11 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - let request = try XCTUnwrap(clearRequests.first) - XCTAssertNil(request["workspace_id"]) - XCTAssertEqual(request["surface_id"] as? String, context.surfaceId) - XCTAssertEqual(request["checkpoint_id"] as? String, sessionId) - XCTAssertEqual(request["source"] as? String, "agent-hook") + let request = try legacyUnwrap(clearRequests.first) + legacyAssertNil(request["workspace_id"]) + legacyAssertEqual(request["surface_id"] as? String, context.surfaceId) + legacyAssertEqual(request["checkpoint_id"] as? String, sessionId) + legacyAssertEqual(request["source"] as? String, "agent-hook") } @Test @@ -1585,9 +1585,9 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"spawn subagent"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) - XCTAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) - XCTAssertTrue( + legacyAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) + legacyAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) + legacyAssertTrue( context.state.commands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "Parent Codex prompt should publish a resume binding, saw \(context.state.commands)" ) @@ -1599,14 +1599,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"return 1+1"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let childPromptCommands = Array(context.state.commands.dropFirst(childPromptStart)) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "Nested Codex prompt should not replace the parent resume binding, saw \(childPromptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { $0.hasPrefix("set_status codex Running ") }, "Nested Codex prompt should not rewrite parent Running status, saw \(childPromptCommands)" ) @@ -1618,18 +1618,18 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"2"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) - XCTAssertTrue( + legacyAssertTrue( childStopCommands.contains { $0.contains(#""method":"feed.push""#) && $0.contains(#""hook_event_name":"Stop""#) }, "Nested Codex Stop should remain Feed telemetry, saw \(childStopCommands)" ) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "Nested Codex Stop should not replace the parent resume binding, saw \(childStopCommands)" ) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "Nested Codex Stop should not notify or mark the parent idle, saw \(childStopCommands)" ) @@ -1641,18 +1641,18 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentStop.timedOut, parentStop.stderr) - XCTAssertEqual(parentStop.status, 0, parentStop.stderr) + legacyAssertFalse(parentStop.timedOut, parentStop.stderr) + legacyAssertEqual(parentStop.status, 0, parentStop.stderr) let parentStopCommands = Array(context.state.commands.dropFirst(parentStopStart)) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "Parent Codex Stop should still refresh the resume binding, saw \(parentStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "Parent Codex Stop should still notify, saw \(parentStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "Parent Codex Stop should mark Codex idle, saw \(parentStopCommands)" ) @@ -1673,8 +1673,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"continue"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(prompt.timedOut, prompt.stderr) - XCTAssertEqual(prompt.status, 0, prompt.stderr) + legacyAssertFalse(prompt.timedOut, prompt.stderr) + legacyAssertEqual(prompt.status, 0, prompt.stderr) let stop = runCodexHook( context: context, @@ -1682,14 +1682,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"turn-1","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(stop.timedOut, stop.stderr) - XCTAssertEqual(stop.status, 0, stop.stderr) + legacyAssertFalse(stop.timedOut, stop.stderr) + legacyAssertEqual(stop.status, 0, stop.stderr) let stateURL = context.root.appendingPathComponent("codex-hook-sessions.json") - var state = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) - var sessions = try XCTUnwrap(state["sessions"] as? [String: Any]) - var record = try XCTUnwrap(sessions[sessionId] as? [String: Any]) - XCTAssertEqual(record["agentLifecycle"] as? String, "idle") + var state = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + var sessions = try legacyUnwrap(state["sessions"] as? [String: Any]) + var record = try legacyUnwrap(sessions[sessionId] as? [String: Any]) + legacyAssertEqual(record["agentLifecycle"] as? String, "idle") let notificationStart = context.state.commands.count let notification = runCodexHook( @@ -1698,11 +1698,11 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"Notification","message":"permission approval required"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(notification.timedOut, notification.stderr) - XCTAssertEqual(notification.status, 0, notification.stderr) + legacyAssertFalse(notification.timedOut, notification.stderr) + legacyAssertEqual(notification.status, 0, notification.stderr) let notificationCommands = Array(context.state.commands.dropFirst(notificationStart)) - XCTAssertTrue( + legacyAssertTrue( notificationCommands.contains { $0.hasPrefix("set_agent_lifecycle codex needsInput --tab=\(context.workspaceId)") && $0.contains("--panel=\(context.surfaceId)") @@ -1710,10 +1710,10 @@ final class CLINotifyProcessIntegrationRegressionTests { "Notification requiring user input must correct the visible lifecycle, saw \(notificationCommands)" ) - state = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) - sessions = try XCTUnwrap(state["sessions"] as? [String: Any]) - record = try XCTUnwrap(sessions[sessionId] as? [String: Any]) - XCTAssertEqual(record["agentLifecycle"] as? String, "needsInput") + state = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + sessions = try legacyUnwrap(state["sessions"] as? [String: Any]) + record = try legacyUnwrap(sessions[sessionId] as? [String: Any]) + legacyAssertEqual(record["agentLifecycle"] as? String, "needsInput") } @Test @@ -1733,8 +1733,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(oldSessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"old"}"#, extraEnvironment: oldEnvironment ) - XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) - XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) + legacyAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) + legacyAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) let newPrompt = runCodexHook( context: context, @@ -1742,8 +1742,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(newSessionId)","turn_id":"new-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"new"}"#, extraEnvironment: newEnvironment ) - XCTAssertFalse(newPrompt.timedOut, newPrompt.stderr) - XCTAssertEqual(newPrompt.status, 0, newPrompt.stderr) + legacyAssertFalse(newPrompt.timedOut, newPrompt.stderr) + legacyAssertEqual(newPrompt.status, 0, newPrompt.stderr) let staleStopStart = context.state.commands.count let staleStop = runCodexHook( @@ -1752,29 +1752,29 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(oldSessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"old done"}"#, extraEnvironment: oldEnvironment ) - XCTAssertFalse(staleStop.timedOut, staleStop.stderr) - XCTAssertEqual(staleStop.status, 0, staleStop.stderr) + legacyAssertFalse(staleStop.timedOut, staleStop.stderr) + legacyAssertEqual(staleStop.status, 0, staleStop.stderr) let staleStopCommands = Array(context.state.commands.dropFirst(staleStopStart)) - XCTAssertFalse( + legacyAssertFalse( staleStopCommands.contains { $0.hasPrefix("set_agent_lifecycle codex idle ") }, "A stale Stop from an older session must not mark the surface idle, saw \(staleStopCommands)" ) - XCTAssertFalse( + legacyAssertFalse( staleStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "A stale Stop from an older session must not replace the newer Running status, saw \(staleStopCommands)" ) - XCTAssertFalse( + legacyAssertFalse( staleStopCommands.contains { $0.hasPrefix("notify_target") }, "A stale Stop from an older session must not publish a completion notification, saw \(staleStopCommands)" ) let stateURL = context.root.appendingPathComponent("codex-hook-sessions.json") - let state = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) - let sessions = try XCTUnwrap(state["sessions"] as? [String: Any]) - let newRecord = try XCTUnwrap(sessions[newSessionId] as? [String: Any]) - XCTAssertEqual(newRecord["runtimeStatus"] as? String, "running") - XCTAssertEqual(newRecord["agentLifecycle"] as? String, "running") + let state = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let sessions = try legacyUnwrap(state["sessions"] as? [String: Any]) + let newRecord = try legacyUnwrap(sessions[newSessionId] as? [String: Any]) + legacyAssertEqual(newRecord["runtimeStatus"] as? String, "running") + legacyAssertEqual(newRecord["agentLifecycle"] as? String, "running") } @Test @@ -1794,8 +1794,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(oldSessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"old"}"#, extraEnvironment: oldEnvironment ) - XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) - XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) + legacyAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) + legacyAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) let newPrompt = runCodexHook( context: context, @@ -1803,8 +1803,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(newSessionId)","turn_id":"new-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"new"}"#, extraEnvironment: newEnvironment ) - XCTAssertFalse(newPrompt.timedOut, newPrompt.stderr) - XCTAssertEqual(newPrompt.status, 0, newPrompt.stderr) + legacyAssertFalse(newPrompt.timedOut, newPrompt.stderr) + legacyAssertEqual(newPrompt.status, 0, newPrompt.stderr) let staleNotificationStart = context.state.commands.count let staleNotification = runCodexHook( @@ -1813,29 +1813,29 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(oldSessionId)","cwd":"\#(context.root.path)","hook_event_name":"Notification","message":"done"}"#, extraEnvironment: oldEnvironment ) - XCTAssertFalse(staleNotification.timedOut, staleNotification.stderr) - XCTAssertEqual(staleNotification.status, 0, staleNotification.stderr) + legacyAssertFalse(staleNotification.timedOut, staleNotification.stderr) + legacyAssertEqual(staleNotification.status, 0, staleNotification.stderr) let staleNotificationCommands = Array(context.state.commands.dropFirst(staleNotificationStart)) - XCTAssertFalse( + legacyAssertFalse( staleNotificationCommands.contains { $0.hasPrefix("set_agent_lifecycle codex idle ") }, "A stale idle notification must not mark the newer session idle, saw \(staleNotificationCommands)" ) - XCTAssertFalse( + legacyAssertFalse( staleNotificationCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "A stale idle notification must not replace the newer Running status, saw \(staleNotificationCommands)" ) - XCTAssertFalse( + legacyAssertFalse( staleNotificationCommands.contains { $0.hasPrefix("notify_target") }, "A stale idle notification must not publish a completion notification, saw \(staleNotificationCommands)" ) let stateURL = context.root.appendingPathComponent("codex-hook-sessions.json") - let state = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) - let sessions = try XCTUnwrap(state["sessions"] as? [String: Any]) - let newRecord = try XCTUnwrap(sessions[newSessionId] as? [String: Any]) - XCTAssertEqual(newRecord["runtimeStatus"] as? String, "running") - XCTAssertEqual(newRecord["agentLifecycle"] as? String, "running") + let state = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let sessions = try legacyUnwrap(state["sessions"] as? [String: Any]) + let newRecord = try legacyUnwrap(sessions[newSessionId] as? [String: Any]) + legacyAssertEqual(newRecord["runtimeStatus"] as? String, "running") + legacyAssertEqual(newRecord["agentLifecycle"] as? String, "running") } @Test @@ -1853,8 +1853,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"parent"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) - XCTAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) + legacyAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) + legacyAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) let childPrompt = runCodexHook( context: context, @@ -1862,8 +1862,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let legacyChildStopStart = context.state.commands.count let legacyChildStop = runCodexHook( @@ -1872,10 +1872,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(legacyChildStop.timedOut, legacyChildStop.stderr) - XCTAssertEqual(legacyChildStop.status, 0, legacyChildStop.stderr) + legacyAssertFalse(legacyChildStop.timedOut, legacyChildStop.stderr) + legacyAssertEqual(legacyChildStop.status, 0, legacyChildStop.stderr) let legacyChildStopCommands = Array(context.state.commands.dropFirst(legacyChildStopStart)) - XCTAssertFalse( + legacyAssertFalse( legacyChildStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "A legacy child Stop without a turn_id must stay nested, saw \(legacyChildStopCommands)" ) @@ -1887,14 +1887,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentStop.timedOut, parentStop.stderr) - XCTAssertEqual(parentStop.status, 0, parentStop.stderr) + legacyAssertFalse(parentStop.timedOut, parentStop.stderr) + legacyAssertEqual(parentStop.status, 0, parentStop.stderr) let parentStopCommands = Array(context.state.commands.dropFirst(parentStopStart)) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "The parent Stop must still notify after a legacy child Stop without a turn_id, saw \(parentStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "The parent Stop must still mark Codex idle after a legacy child Stop without a turn_id, saw \(parentStopCommands)" ) @@ -1916,8 +1916,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"BeforeAgent","prompt":"parent"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) - XCTAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) + legacyAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) + legacyAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) let childPromptStart = context.state.commands.count let childPrompt = runAgentHook( @@ -1927,14 +1927,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"BeforeAgent","prompt":"child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let childPromptCommands = Array(context.state.commands.dropFirst(childPromptStart)) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { (self.jsonObject($0)?["method"] as? String)?.hasPrefix("surface.resume.") == true }, "A generic nested turn_id prompt must not replace the parent resume binding, saw \(childPromptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { $0.hasPrefix("set_status gemini Running ") }, "A generic nested turn_id prompt must not rewrite parent Running status, saw \(childPromptCommands)" ) @@ -1947,10 +1947,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"AfterAgent","last_assistant_message":"child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status gemini ") }, "A generic nested turn_id Stop must not notify or mark the parent idle, saw \(childStopCommands)" ) @@ -1963,14 +1963,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"AfterAgent","last_assistant_message":"parent done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentStop.timedOut, parentStop.stderr) - XCTAssertEqual(parentStop.status, 0, parentStop.stderr) + legacyAssertFalse(parentStop.timedOut, parentStop.stderr) + legacyAssertEqual(parentStop.status, 0, parentStop.stderr) let parentStopCommands = Array(context.state.commands.dropFirst(parentStopStart)) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Gemini|") }, "The generic parent Stop must still notify after its nested child, saw \(parentStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("set_status gemini ") && $0.contains(" Idle ") }, "The generic parent Stop must still mark Gemini idle, saw \(parentStopCommands)" ) @@ -1991,8 +1991,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"parent"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) - XCTAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) + legacyAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) + legacyAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) let anonymousChildPromptStart = context.state.commands.count let anonymousChildPrompt = runCodexHook( @@ -2001,14 +2001,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"anonymous child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(anonymousChildPrompt.timedOut, anonymousChildPrompt.stderr) - XCTAssertEqual(anonymousChildPrompt.status, 0, anonymousChildPrompt.stderr) + legacyAssertFalse(anonymousChildPrompt.timedOut, anonymousChildPrompt.stderr) + legacyAssertEqual(anonymousChildPrompt.status, 0, anonymousChildPrompt.stderr) let anonymousChildPromptCommands = Array(context.state.commands.dropFirst(anonymousChildPromptStart)) - XCTAssertFalse( + legacyAssertFalse( anonymousChildPromptCommands.contains { (self.jsonObject($0)?["method"] as? String)?.hasPrefix("surface.resume.") == true }, "An anonymous child under a known parent must not replace the parent resume binding, saw \(anonymousChildPromptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( anonymousChildPromptCommands.contains { $0.hasPrefix("set_status codex Running ") }, "An anonymous child under a known parent must not rewrite parent Running status, saw \(anonymousChildPromptCommands)" ) @@ -2020,14 +2020,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"grandchild-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"grandchild"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(grandchildPrompt.timedOut, grandchildPrompt.stderr) - XCTAssertEqual(grandchildPrompt.status, 0, grandchildPrompt.stderr) + legacyAssertFalse(grandchildPrompt.timedOut, grandchildPrompt.stderr) + legacyAssertEqual(grandchildPrompt.status, 0, grandchildPrompt.stderr) let grandchildPromptCommands = Array(context.state.commands.dropFirst(grandchildPromptStart)) - XCTAssertFalse( + legacyAssertFalse( grandchildPromptCommands.contains { (self.jsonObject($0)?["method"] as? String)?.hasPrefix("surface.resume.") == true }, "A known grandchild after anonymous depth must stay nested, saw \(grandchildPromptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( grandchildPromptCommands.contains { $0.hasPrefix("set_status codex Running ") }, "A known grandchild after anonymous depth must not rewrite parent Running status, saw \(grandchildPromptCommands)" ) @@ -2039,10 +2039,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"grandchild-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"grandchild done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(grandchildStop.timedOut, grandchildStop.stderr) - XCTAssertEqual(grandchildStop.status, 0, grandchildStop.stderr) + legacyAssertFalse(grandchildStop.timedOut, grandchildStop.stderr) + legacyAssertEqual(grandchildStop.status, 0, grandchildStop.stderr) let grandchildStopCommands = Array(context.state.commands.dropFirst(grandchildStopStart)) - XCTAssertFalse( + legacyAssertFalse( grandchildStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "Grandchild Stop must not collapse anonymous child depth and notify, saw \(grandchildStopCommands)" ) @@ -2054,10 +2054,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"anonymous child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(anonymousChildStop.timedOut, anonymousChildStop.stderr) - XCTAssertEqual(anonymousChildStop.status, 0, anonymousChildStop.stderr) + legacyAssertFalse(anonymousChildStop.timedOut, anonymousChildStop.stderr) + legacyAssertEqual(anonymousChildStop.status, 0, anonymousChildStop.stderr) let anonymousChildStopCommands = Array(context.state.commands.dropFirst(anonymousChildStopStart)) - XCTAssertFalse( + legacyAssertFalse( anonymousChildStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "Anonymous child Stop must stay nested after a known grandchild Stop, saw \(anonymousChildStopCommands)" ) @@ -2069,14 +2069,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentStop.timedOut, parentStop.stderr) - XCTAssertEqual(parentStop.status, 0, parentStop.stderr) + legacyAssertFalse(parentStop.timedOut, parentStop.stderr) + legacyAssertEqual(parentStop.status, 0, parentStop.stderr) let parentStopCommands = Array(context.state.commands.dropFirst(parentStopStart)) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "The parent Stop must still notify after mixed anonymous depth, saw \(parentStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "The parent Stop must still mark Codex idle after mixed anonymous depth, saw \(parentStopCommands)" ) @@ -2097,8 +2097,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"parent"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) - XCTAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) + legacyAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) + legacyAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) let anonymousChildPrompt = runCodexHook( context: context, @@ -2106,8 +2106,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"anonymous child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(anonymousChildPrompt.timedOut, anonymousChildPrompt.stderr) - XCTAssertEqual(anonymousChildPrompt.status, 0, anonymousChildPrompt.stderr) + legacyAssertFalse(anonymousChildPrompt.timedOut, anonymousChildPrompt.stderr) + legacyAssertEqual(anonymousChildPrompt.status, 0, anonymousChildPrompt.stderr) let childStopStart = context.state.commands.count let childStop = runCodexHook( @@ -2116,10 +2116,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "The child Stop should pop anonymous depth without notifying, saw \(childStopCommands)" ) @@ -2131,14 +2131,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentStop.timedOut, parentStop.stderr) - XCTAssertEqual(parentStop.status, 0, parentStop.stderr) + legacyAssertFalse(parentStop.timedOut, parentStop.stderr) + legacyAssertEqual(parentStop.status, 0, parentStop.stderr) let parentStopCommands = Array(context.state.commands.dropFirst(parentStopStart)) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "The parent Stop must still notify after a child Stop supplies a new turn_id, saw \(parentStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "The parent Stop must still mark Codex idle after a child Stop supplies a new turn_id, saw \(parentStopCommands)" ) @@ -2165,8 +2165,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"interrupted"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(interruptedPrompt.timedOut, interruptedPrompt.stderr) - XCTAssertEqual(interruptedPrompt.status, 0, interruptedPrompt.stderr) + legacyAssertFalse(interruptedPrompt.timedOut, interruptedPrompt.stderr) + legacyAssertEqual(interruptedPrompt.status, 0, interruptedPrompt.stderr) let currentPrompt = runCodexHook( context: context, @@ -2174,8 +2174,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"finish now"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentPrompt.timedOut, currentPrompt.stderr) - XCTAssertEqual(currentPrompt.status, 0, currentPrompt.stderr) + legacyAssertFalse(currentPrompt.timedOut, currentPrompt.stderr) + legacyAssertEqual(currentPrompt.status, 0, currentPrompt.stderr) let stopStart = context.state.commands.count let currentStop = runCodexHook( @@ -2184,15 +2184,15 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"current done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentStop.timedOut, currentStop.stderr) - XCTAssertEqual(currentStop.status, 0, currentStop.stderr) + legacyAssertFalse(currentStop.timedOut, currentStop.stderr) + legacyAssertEqual(currentStop.status, 0, currentStop.stderr) let stopCommands = Array(context.state.commands.dropFirst(stopStart)) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "A stale prompt depth from an interrupted prior turn must not suppress the current top-level completion notification, saw \(stopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "A stale prompt depth from an interrupted prior turn must not leave Codex marked running, saw \(stopCommands)" ) @@ -2218,8 +2218,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"old"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) - XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) + legacyAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) + legacyAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) let currentPromptStart = context.state.commands.count let currentPrompt = runCodexHook( @@ -2228,10 +2228,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"current"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentPrompt.timedOut, currentPrompt.stderr) - XCTAssertEqual(currentPrompt.status, 0, currentPrompt.stderr) + legacyAssertFalse(currentPrompt.timedOut, currentPrompt.stderr) + legacyAssertEqual(currentPrompt.status, 0, currentPrompt.stderr) let currentPromptCommands = Array(context.state.commands.dropFirst(currentPromptStart)) - XCTAssertFalse( + legacyAssertFalse( currentPromptCommands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "Before the late terminal transcript update, the current prompt should still look nested, saw \(currentPromptCommands)" ) @@ -2251,15 +2251,15 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"current done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentStop.timedOut, currentStop.stderr) - XCTAssertEqual(currentStop.status, 0, currentStop.stderr) + legacyAssertFalse(currentStop.timedOut, currentStop.stderr) + legacyAssertEqual(currentStop.status, 0, currentStop.stderr) let currentStopCommands = Array(context.state.commands.dropFirst(currentStopStart)) - XCTAssertTrue( + legacyAssertTrue( currentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "A late terminal prior turn must not suppress the current top-level completion notification, saw \(currentStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( currentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "A late terminal prior turn must not leave Codex marked running after the current Stop, saw \(currentStopCommands)" ) @@ -2286,8 +2286,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"spawn child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) - XCTAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) + legacyAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) + legacyAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) let childPrompt = runCodexHook( context: context, @@ -2295,8 +2295,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(terminalChildTranscript.path)","hook_event_name":"UserPromptSubmit","prompt":"first child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let siblingPromptStart = context.state.commands.count let siblingPrompt = runCodexHook( @@ -2305,14 +2305,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"sibling-turn","cwd":"\#(context.root.path)","transcript_path":"\#(terminalChildTranscript.path)","hook_event_name":"UserPromptSubmit","prompt":"second child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(siblingPrompt.timedOut, siblingPrompt.stderr) - XCTAssertEqual(siblingPrompt.status, 0, siblingPrompt.stderr) + legacyAssertFalse(siblingPrompt.timedOut, siblingPrompt.stderr) + legacyAssertEqual(siblingPrompt.status, 0, siblingPrompt.stderr) let siblingPromptCommands = Array(context.state.commands.dropFirst(siblingPromptStart)) - XCTAssertFalse( + legacyAssertFalse( siblingPromptCommands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "A sibling child prompt after a terminal child transcript must stay nested, saw \(siblingPromptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( siblingPromptCommands.contains { $0.hasPrefix("set_status codex Running ") }, "A sibling child prompt after a terminal child transcript must not rewrite parent Running status, saw \(siblingPromptCommands)" ) @@ -2324,10 +2324,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(terminalChildTranscript.path)","hook_event_name":"Stop","last_assistant_message":"first child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "A late terminal child Stop must remain nested after a sibling child prompt, saw \(childStopCommands)" ) @@ -2339,10 +2339,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"sibling-turn","cwd":"\#(context.root.path)","transcript_path":"\#(terminalChildTranscript.path)","hook_event_name":"Stop","last_assistant_message":"second child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(siblingStop.timedOut, siblingStop.stderr) - XCTAssertEqual(siblingStop.status, 0, siblingStop.stderr) + legacyAssertFalse(siblingStop.timedOut, siblingStop.stderr) + legacyAssertEqual(siblingStop.status, 0, siblingStop.stderr) let siblingStopCommands = Array(context.state.commands.dropFirst(siblingStopStart)) - XCTAssertFalse( + legacyAssertFalse( siblingStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "The sibling child Stop must not notify while the parent is active, saw \(siblingStopCommands)" ) @@ -2354,10 +2354,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentStop.timedOut, parentStop.stderr) - XCTAssertEqual(parentStop.status, 0, parentStop.stderr) + legacyAssertFalse(parentStop.timedOut, parentStop.stderr) + legacyAssertEqual(parentStop.status, 0, parentStop.stderr) let parentStopCommands = Array(context.state.commands.dropFirst(parentStopStart)) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "The parent Stop should still notify after terminal nested children, saw \(parentStopCommands)" ) @@ -2385,8 +2385,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"parent"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) - XCTAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) + legacyAssertFalse(parentPrompt.timedOut, parentPrompt.stderr) + legacyAssertEqual(parentPrompt.status, 0, parentPrompt.stderr) let childPrompt = runCodexHook( context: context, @@ -2394,8 +2394,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let currentPromptStart = context.state.commands.count let currentPrompt = runCodexHook( @@ -2404,10 +2404,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"current"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentPrompt.timedOut, currentPrompt.stderr) - XCTAssertEqual(currentPrompt.status, 0, currentPrompt.stderr) + legacyAssertFalse(currentPrompt.timedOut, currentPrompt.stderr) + legacyAssertEqual(currentPrompt.status, 0, currentPrompt.stderr) let currentPromptCommands = Array(context.state.commands.dropFirst(currentPromptStart)) - XCTAssertTrue( + legacyAssertTrue( currentPromptCommands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "A current prompt after a fully terminal interrupted stack must become top-level, saw \(currentPromptCommands)" ) @@ -2419,10 +2419,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"current done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentStop.timedOut, currentStop.stderr) - XCTAssertEqual(currentStop.status, 0, currentStop.stderr) + legacyAssertFalse(currentStop.timedOut, currentStop.stderr) + legacyAssertEqual(currentStop.status, 0, currentStop.stderr) let currentStopCommands = Array(context.state.commands.dropFirst(currentStopStart)) - XCTAssertTrue( + legacyAssertTrue( currentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "A current Stop after a fully terminal interrupted stack must notify, saw \(currentStopCommands)" ) @@ -2464,14 +2464,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let childPromptCommands = Array(context.state.commands.dropFirst(childPromptStart)) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "A legacy depth-only nested prompt that first gains a turn_id must remain nested, saw \(childPromptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { $0.hasPrefix("set_status codex Running ") }, "A legacy depth-only nested prompt that first gains a turn_id must not rewrite parent Running status, saw \(childPromptCommands)" ) @@ -2483,10 +2483,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "A legacy depth-only nested Stop that first gains a turn_id must remain nested, saw \(childStopCommands)" ) @@ -2536,14 +2536,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let childPromptCommands = Array(context.state.commands.dropFirst(childPromptStart)) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "A historical terminal turn must not make an active depth-only parent look finished, saw \(childPromptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { $0.hasPrefix("set_status codex Running ") }, "A historical terminal turn must not let the child rewrite parent Running status, saw \(childPromptCommands)" ) @@ -2555,10 +2555,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "A historical terminal turn must not let a child Stop notify while the parent is active, saw \(childStopCommands)" ) @@ -2607,14 +2607,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let childPromptCommands = Array(context.state.commands.dropFirst(childPromptStart)) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "A non-terminal parent turn_context must not make a depth-only parent look finished, saw \(childPromptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { $0.hasPrefix("set_status codex Running ") }, "A non-terminal parent turn_context must not let the child rewrite parent Running status, saw \(childPromptCommands)" ) @@ -2626,10 +2626,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "A non-terminal parent turn_context must not let a child Stop notify while the parent is active, saw \(childStopCommands)" ) @@ -2670,8 +2670,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let childStopStart = context.state.commands.count let childStop = runCodexHook( @@ -2680,10 +2680,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "The child Stop should close only the child depth, saw \(childStopCommands)" ) @@ -2695,14 +2695,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"parent-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(parentStop.timedOut, parentStop.stderr) - XCTAssertEqual(parentStop.status, 0, parentStop.stderr) + legacyAssertFalse(parentStop.timedOut, parentStop.stderr) + legacyAssertEqual(parentStop.status, 0, parentStop.stderr) let parentStopCommands = Array(context.state.commands.dropFirst(parentStopStart)) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "The depth-only parent Stop must notify after its child turn stops, saw \(parentStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( parentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "The depth-only parent Stop must mark Codex idle, saw \(parentStopCommands)" ) @@ -2751,12 +2751,12 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) - XCTAssertEqual(childStop.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "{}") - XCTAssertEqual(childStop.stderr, "") + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertEqual(childStop.stdout.trimmingCharacters(in: .whitespacesAndNewlines), "{}") + legacyAssertEqual(childStop.stderr, "") let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "A late terminal child Stop must stay nested while the depth-only parent remains active, saw \(childStopCommands)" ) @@ -2768,14 +2768,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"sibling-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"sibling"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(siblingPrompt.timedOut, siblingPrompt.stderr) - XCTAssertEqual(siblingPrompt.status, 0, siblingPrompt.stderr) + legacyAssertFalse(siblingPrompt.timedOut, siblingPrompt.stderr) + legacyAssertEqual(siblingPrompt.status, 0, siblingPrompt.stderr) let siblingPromptCommands = Array(context.state.commands.dropFirst(siblingPromptStart)) - XCTAssertFalse( + legacyAssertFalse( siblingPromptCommands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "A sibling prompt must stay nested while the depth-only parent remains active, saw \(siblingPromptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( siblingPromptCommands.contains { $0.hasPrefix("set_status codex Running ") }, "A sibling prompt must not rewrite parent Running status while the depth-only parent remains active, saw \(siblingPromptCommands)" ) @@ -2787,10 +2787,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"sibling-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"sibling done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(siblingStop.timedOut, siblingStop.stderr) - XCTAssertEqual(siblingStop.status, 0, siblingStop.stderr) + legacyAssertFalse(siblingStop.timedOut, siblingStop.stderr) + legacyAssertEqual(siblingStop.status, 0, siblingStop.stderr) let siblingStopCommands = Array(context.state.commands.dropFirst(siblingStopStart)) - XCTAssertFalse( + legacyAssertFalse( siblingStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "A sibling Stop must stay nested while the depth-only parent remains active, saw \(siblingStopCommands)" ) @@ -2840,14 +2840,14 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"child"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childPrompt.timedOut, childPrompt.stderr) - XCTAssertEqual(childPrompt.status, 0, childPrompt.stderr) + legacyAssertFalse(childPrompt.timedOut, childPrompt.stderr) + legacyAssertEqual(childPrompt.status, 0, childPrompt.stderr) let childPromptCommands = Array(context.state.commands.dropFirst(childPromptStart)) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { (self.jsonObject($0)?["method"] as? String)?.hasPrefix("surface.resume.") == true }, "Terminal history alone must not make unknown depth-only active prompts look finished, saw \(childPromptCommands)" ) - XCTAssertFalse( + legacyAssertFalse( childPromptCommands.contains { $0.hasPrefix("set_status codex Running ") }, "Terminal history alone must not let the child rewrite parent Running status, saw \(childPromptCommands)" ) @@ -2859,10 +2859,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"child-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"child done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(childStop.timedOut, childStop.stderr) - XCTAssertEqual(childStop.status, 0, childStop.stderr) + legacyAssertFalse(childStop.timedOut, childStop.stderr) + legacyAssertEqual(childStop.status, 0, childStop.stderr) let childStopCommands = Array(context.state.commands.dropFirst(childStopStart)) - XCTAssertFalse( + legacyAssertFalse( childStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "Terminal history alone must not let a child Stop notify while unknown depth remains, saw \(childStopCommands)" ) @@ -2889,8 +2889,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"old"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) - XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) + legacyAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) + legacyAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) let currentPrompt = runCodexHook( context: context, @@ -2898,8 +2898,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"current"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentPrompt.timedOut, currentPrompt.stderr) - XCTAssertEqual(currentPrompt.status, 0, currentPrompt.stderr) + legacyAssertFalse(currentPrompt.timedOut, currentPrompt.stderr) + legacyAssertEqual(currentPrompt.status, 0, currentPrompt.stderr) let staleStopStart = context.state.commands.count let staleStop = runCodexHook( @@ -2908,11 +2908,11 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"old done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(staleStop.timedOut, staleStop.stderr) - XCTAssertEqual(staleStop.status, 0, staleStop.stderr) + legacyAssertFalse(staleStop.timedOut, staleStop.stderr) + legacyAssertEqual(staleStop.status, 0, staleStop.stderr) let staleStopCommands = Array(context.state.commands.dropFirst(staleStopStart)) - XCTAssertFalse( + legacyAssertFalse( staleStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "A stale Stop from an older turn must not notify or mark the newer active turn idle, saw \(staleStopCommands)" ) @@ -2924,11 +2924,11 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"current done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentStop.timedOut, currentStop.stderr) - XCTAssertEqual(currentStop.status, 0, currentStop.stderr) + legacyAssertFalse(currentStop.timedOut, currentStop.stderr) + legacyAssertEqual(currentStop.status, 0, currentStop.stderr) let currentStopCommands = Array(context.state.commands.dropFirst(currentStopStart)) - XCTAssertTrue( + legacyAssertTrue( currentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "The current turn should still notify after a stale older Stop, saw \(currentStopCommands)" ) @@ -2955,8 +2955,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"old"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) - XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) + legacyAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) + legacyAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) let currentPrompt = runCodexHook( context: context, @@ -2964,8 +2964,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"current"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentPrompt.timedOut, currentPrompt.stderr) - XCTAssertEqual(currentPrompt.status, 0, currentPrompt.stderr) + legacyAssertFalse(currentPrompt.timedOut, currentPrompt.stderr) + legacyAssertEqual(currentPrompt.status, 0, currentPrompt.stderr) let currentStopStart = context.state.commands.count let currentStop = runCodexHook( @@ -2974,10 +2974,10 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"current done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentStop.timedOut, currentStop.stderr) - XCTAssertEqual(currentStop.status, 0, currentStop.stderr) + legacyAssertFalse(currentStop.timedOut, currentStop.stderr) + legacyAssertEqual(currentStop.status, 0, currentStop.stderr) let currentStopCommands = Array(context.state.commands.dropFirst(currentStopStart)) - XCTAssertTrue( + legacyAssertTrue( currentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "The current turn should notify before a late stale Stop arrives, saw \(currentStopCommands)" ) @@ -2989,11 +2989,11 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"old done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(lateStop.timedOut, lateStop.stderr) - XCTAssertEqual(lateStop.status, 0, lateStop.stderr) + legacyAssertFalse(lateStop.timedOut, lateStop.stderr) + legacyAssertEqual(lateStop.status, 0, lateStop.stderr) let lateStopCommands = Array(context.state.commands.dropFirst(lateStopStart)) - XCTAssertFalse( + legacyAssertFalse( lateStopCommands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "A late stale Stop from an older turn must not duplicate the newer turn completion, saw \(lateStopCommands)" ) @@ -3020,8 +3020,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"old"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) - XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) + legacyAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) + legacyAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) let currentStopStart = context.state.commands.count let currentStop = runCodexHook( @@ -3030,15 +3030,15 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"current done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentStop.timedOut, currentStop.stderr) - XCTAssertEqual(currentStop.status, 0, currentStop.stderr) + legacyAssertFalse(currentStop.timedOut, currentStop.stderr) + legacyAssertEqual(currentStop.status, 0, currentStop.stderr) let currentStopCommands = Array(context.state.commands.dropFirst(currentStopStart)) - XCTAssertTrue( + legacyAssertTrue( currentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "A Stop after a missed prompt-submit must clear terminal stale turns and notify, saw \(currentStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( currentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "A Stop after a missed prompt-submit must mark Codex idle, saw \(currentStopCommands)" ) @@ -3094,15 +3094,15 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"current-turn","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"current done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(currentStop.timedOut, currentStop.stderr) - XCTAssertEqual(currentStop.status, 0, currentStop.stderr) + legacyAssertFalse(currentStop.timedOut, currentStop.stderr) + legacyAssertEqual(currentStop.status, 0, currentStop.stderr) let currentStopCommands = Array(context.state.commands.dropFirst(currentStopStart)) - XCTAssertTrue( + legacyAssertTrue( currentStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "A missed prompt-submit Stop must clear a fully terminal stored stack and notify, saw \(currentStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( currentStopCommands.contains { $0.hasPrefix("set_status codex ") && $0.contains(" Idle ") }, "A missed prompt-submit Stop must clear a fully terminal stored stack and mark Codex idle, saw \(currentStopCommands)" ) @@ -3123,8 +3123,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"old"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) - XCTAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) + legacyAssertFalse(oldPrompt.timedOut, oldPrompt.stderr) + legacyAssertEqual(oldPrompt.status, 0, oldPrompt.stderr) let oldStop = runCodexHook( context: context, @@ -3132,8 +3132,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"old-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"old done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(oldStop.timedOut, oldStop.stderr) - XCTAssertEqual(oldStop.status, 0, oldStop.stderr) + legacyAssertFalse(oldStop.timedOut, oldStop.stderr) + legacyAssertEqual(oldStop.status, 0, oldStop.stderr) let unseenStopStart = context.state.commands.count let unseenStop = runCodexHook( @@ -3142,15 +3142,15 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","turn_id":"new-turn","cwd":"\#(context.root.path)","hook_event_name":"Stop","last_assistant_message":"new done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(unseenStop.timedOut, unseenStop.stderr) - XCTAssertEqual(unseenStop.status, 0, unseenStop.stderr) + legacyAssertFalse(unseenStop.timedOut, unseenStop.stderr) + legacyAssertEqual(unseenStop.status, 0, unseenStop.stderr) let unseenStopCommands = Array(context.state.commands.dropFirst(unseenStopStart)) - XCTAssertTrue( + legacyAssertTrue( unseenStopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "A Stop with a missed prompt-submit must still notify at idle depth, saw \(unseenStopCommands)" ) - XCTAssertTrue( + legacyAssertTrue( unseenStopCommands.contains { $0.hasPrefix("set_status codex ") }, "A Stop with a missed prompt-submit must still update Codex status, saw \(unseenStopCommands)" ) @@ -3175,18 +3175,18 @@ final class CLINotifyProcessIntegrationRegressionTests { ], uniquingKeysWith: { _, new in new }) ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") - XCTAssertTrue( + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") + legacyAssertTrue( context.state.commands.contains { $0.contains(#""method":"feed.push""#) && $0.contains(#""hook_event_name":"Stop""#) }, "Managed subagent Stop should remain Feed telemetry, saw \(context.state.commands)" ) - XCTAssertFalse( + legacyAssertFalse( context.state.commands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.set" }, "Managed subagent Stop should not publish a child resume binding, saw \(context.state.commands)" ) - XCTAssertFalse( + legacyAssertFalse( context.state.commands.contains { $0.hasPrefix("notify_target") || $0.hasPrefix("set_status codex ") }, "Managed subagent Stop should not notify or clobber visible status, saw \(context.state.commands)" ) @@ -3214,8 +3214,8 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"UserPromptSubmit","prompt":"top-level"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(prompt.timedOut, prompt.stderr) - XCTAssertEqual(prompt.status, 0, prompt.stderr) + legacyAssertFalse(prompt.timedOut, prompt.stderr) + legacyAssertEqual(prompt.status, 0, prompt.stderr) let stopStart = context.state.commands.count let stop = runCodexHook( @@ -3224,11 +3224,11 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","transcript_path":"\#(transcriptURL.path)","hook_event_name":"Stop","last_assistant_message":"parent done"}"#, extraEnvironment: launchEnvironment ) - XCTAssertFalse(stop.timedOut, stop.stderr) - XCTAssertEqual(stop.status, 0, stop.stderr) + legacyAssertFalse(stop.timedOut, stop.stderr) + legacyAssertEqual(stop.status, 0, stop.stderr) let stopCommands = Array(context.state.commands.dropFirst(stopStart)) - XCTAssertTrue( + legacyAssertTrue( stopCommands.contains { $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Codex|") }, "Stale completed-turn subagent relay should not suppress the parent completion notification, saw \(stopCommands)" ) @@ -3272,19 +3272,19 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertFalse( + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse( context.state.commands.contains { self.jsonObject($0)?["method"] as? String == "surface.resume.clear" }, "Managed subagent SessionEnd should not clear the parent resume binding, saw \(context.state.commands)" ) - XCTAssertFalse( + legacyAssertFalse( context.state.commands.contains { $0.hasPrefix("clear_agent_pid codex.") }, "Managed subagent SessionEnd should not clear the visible parent PID, saw \(context.state.commands)" ) - let savedState = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) - let savedSessions = try XCTUnwrap(savedState["sessions"] as? [String: Any]) - XCTAssertNotNil(savedSessions[sessionId], "Suppressed SessionEnd should leave the stored parent session intact") + let savedState = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let savedSessions = try legacyUnwrap(savedState["sessions"] as? [String: Any]) + legacyAssertNotNil(savedSessions[sessionId], "Suppressed SessionEnd should leave the stored parent session intact") } @Test @@ -3317,7 +3317,7 @@ final class CLINotifyProcessIntegrationRegressionTests { } let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in - XCTAssertEqual(line, item.expectedCommand) + legacyAssertEqual(line, item.expectedCommand) return item.response } @@ -3332,12 +3332,12 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, "\(item.name): \(result.stderr)") - XCTAssertEqual(result.status, 0, "\(item.name): \(result.stderr)") - XCTAssertEqual(result.stdout, item.stdout, item.name) - XCTAssertTrue(result.stderr.isEmpty, "\(item.name): \(result.stderr)") - XCTAssertEqual(state.commands, [item.expectedCommand], item.name) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, "\(item.name): \(result.stderr)") + legacyAssertEqual(result.status, 0, "\(item.name): \(result.stderr)") + legacyAssertEqual(result.stdout, item.stdout, item.name) + legacyAssertTrue(result.stderr.isEmpty, "\(item.name): \(result.stderr)") + legacyAssertEqual(state.commands, [item.expectedCommand], item.name) } } @@ -3356,11 +3356,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stdout.isEmpty, result.stdout) - XCTAssertTrue(result.stderr.contains("Unknown right-sidebar command 'unknown'"), result.stderr) - XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stdout.isEmpty, result.stdout) + legacyAssertTrue(result.stderr.contains("Unknown right-sidebar command 'unknown'"), result.stderr) + legacyAssertFalse(result.stderr.contains("Socket"), result.stderr) } @Test @@ -3378,11 +3378,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stdout.isEmpty, result.stdout) - XCTAssertTrue(result.stderr.contains("Unknown right-sidebar mode 'unknown'"), result.stderr) - XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stdout.isEmpty, result.stdout) + legacyAssertTrue(result.stderr.contains("Unknown right-sidebar mode 'unknown'"), result.stderr) + legacyAssertFalse(result.stderr.contains("Socket"), result.stderr) } @Test @@ -3401,26 +3401,26 @@ final class CLINotifyProcessIntegrationRegressionTests { func testSSHPersistentPTYJSONReportsResolvedSessionID() throws { let run = try runMockedSSH(arguments: [], jsonOutput: true) let payload = try jsonPayload(from: run.stdout) - let sessionID = try XCTUnwrap(payload["ssh_pty_session_id"] as? String) - let persistentDaemonSlot = try XCTUnwrap(payload["persistent_daemon_slot"] as? String) + let sessionID = try legacyUnwrap(payload["ssh_pty_session_id"] as? String) + let persistentDaemonSlot = try legacyUnwrap(payload["persistent_daemon_slot"] as? String) - XCTAssertEqual(sessionID, "ssh-\(run.workspaceId)-\(run.surfaceId)") - XCTAssertFalse(sessionID.contains("$"), sessionID) - XCTAssertFalse(sessionID.contains("{"), sessionID) - XCTAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) - XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) + legacyAssertEqual(sessionID, "ssh-\(run.workspaceId)-\(run.surfaceId)") + legacyAssertFalse(sessionID.contains("$"), sessionID) + legacyAssertFalse(sessionID.contains("{"), sessionID) + legacyAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) + legacyAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } @Test func testSSHPersistentPTYJSONResolvesSessionIDWhenWorkspaceCreateOmitsSurfaceID() throws { let run = try runMockedSSH(arguments: [], jsonOutput: true, omitWorkspaceCreateSurfaceID: true) let payload = try jsonPayload(from: run.stdout) - let sessionID = try XCTUnwrap(payload["ssh_pty_session_id"] as? String) - let persistentDaemonSlot = try XCTUnwrap(payload["persistent_daemon_slot"] as? String) + let sessionID = try legacyUnwrap(payload["ssh_pty_session_id"] as? String) + let persistentDaemonSlot = try legacyUnwrap(payload["persistent_daemon_slot"] as? String) - XCTAssertEqual(sessionID, "ssh-\(run.workspaceId)-\(run.surfaceId)") - XCTAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) - XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) + legacyAssertEqual(sessionID, "ssh-\(run.workspaceId)-\(run.surfaceId)") + legacyAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) + legacyAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } @Test @@ -3430,14 +3430,14 @@ final class CLINotifyProcessIntegrationRegressionTests { arguments: ["--forward-agent"], environmentOverrides: ["SSH_AUTH_SOCK": agentSocketPath] ) - let createParams = try XCTUnwrap(params(for: "workspace.create", in: run.requests)) - let configureParams = try XCTUnwrap(params(for: "workspace.remote.configure", in: run.requests)) - let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) - let initialEnv = try XCTUnwrap(createParams["initial_env"] as? [String: String]) + let createParams = try legacyUnwrap(params(for: "workspace.create", in: run.requests)) + let configureParams = try legacyUnwrap(params(for: "workspace.remote.configure", in: run.requests)) + let sshOptions = try legacyUnwrap(configureParams["ssh_options"] as? [String]) + let initialEnv = try legacyUnwrap(createParams["initial_env"] as? [String: String]) - XCTAssertTrue(sshOptions.contains("ForwardAgent=yes"), "ssh_options: \(sshOptions)") - XCTAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) - XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) + legacyAssertTrue(sshOptions.contains("ForwardAgent=yes"), "ssh_options: \(sshOptions)") + legacyAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) + legacyAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) } @Test @@ -3447,14 +3447,14 @@ final class CLINotifyProcessIntegrationRegressionTests { arguments: ["--ssh-option", "ForwardAgent=yes"], environmentOverrides: ["SSH_AUTH_SOCK": agentSocketPath] ) - let createParams = try XCTUnwrap(params(for: "workspace.create", in: run.requests)) - let configureParams = try XCTUnwrap(params(for: "workspace.remote.configure", in: run.requests)) - let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) - let initialEnv = try XCTUnwrap(createParams["initial_env"] as? [String: String]) + let createParams = try legacyUnwrap(params(for: "workspace.create", in: run.requests)) + let configureParams = try legacyUnwrap(params(for: "workspace.remote.configure", in: run.requests)) + let sshOptions = try legacyUnwrap(configureParams["ssh_options"] as? [String]) + let initialEnv = try legacyUnwrap(createParams["initial_env"] as? [String: String]) - XCTAssertTrue(sshOptions.contains("ForwardAgent=yes"), "ssh_options: \(sshOptions)") - XCTAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) - XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) + legacyAssertTrue(sshOptions.contains("ForwardAgent=yes"), "ssh_options: \(sshOptions)") + legacyAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) + legacyAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) } @Test @@ -3468,16 +3468,16 @@ final class CLINotifyProcessIntegrationRegressionTests { "SSH_AUTH_SOCK": "/tmp/cmux-test-agent-\(UUID().uuidString).sock", ] ) - let createParams = try XCTUnwrap(params(for: "workspace.create", in: run.requests)) - let configureParams = try XCTUnwrap(params(for: "workspace.remote.configure", in: run.requests)) - let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) + let createParams = try legacyUnwrap(params(for: "workspace.create", in: run.requests)) + let configureParams = try legacyUnwrap(params(for: "workspace.remote.configure", in: run.requests)) + let sshOptions = try legacyUnwrap(configureParams["ssh_options"] as? [String]) - XCTAssertEqual(sshOptions.filter { $0.hasPrefix("ForwardAgent=") }, [ + legacyAssertEqual(sshOptions.filter { $0.hasPrefix("ForwardAgent=") }, [ "ForwardAgent=yes", "ForwardAgent=no", ]) - XCTAssertNil(createParams["initial_env"]) - XCTAssertNil(configureParams["ssh_auth_sock"]) + legacyAssertNil(createParams["initial_env"]) + legacyAssertNil(configureParams["ssh_auth_sock"]) } @Test @@ -3489,13 +3489,13 @@ final class CLINotifyProcessIntegrationRegressionTests { "SSH_AUTH_SOCK": agentSocketPath, ] ) - let createParams = try XCTUnwrap(params(for: "workspace.create", in: run.requests)) - let configureParams = try XCTUnwrap(params(for: "workspace.remote.configure", in: run.requests)) - let initialEnv = try XCTUnwrap(createParams["initial_env"] as? [String: String]) + let createParams = try legacyUnwrap(params(for: "workspace.create", in: run.requests)) + let configureParams = try legacyUnwrap(params(for: "workspace.remote.configure", in: run.requests)) + let initialEnv = try legacyUnwrap(createParams["initial_env"] as? [String: String]) - XCTAssertNil(configureParams["ssh_options"]) - XCTAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) - XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) + legacyAssertNil(configureParams["ssh_options"]) + legacyAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) + legacyAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) } @Test @@ -3504,14 +3504,14 @@ final class CLINotifyProcessIntegrationRegressionTests { let run = try runMockedSSH( arguments: ["--ssh-option", "ForwardAgent=\(agentSocketPath)"] ) - let createParams = try XCTUnwrap(params(for: "workspace.create", in: run.requests)) - let configureParams = try XCTUnwrap(params(for: "workspace.remote.configure", in: run.requests)) - let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) - let initialEnv = try XCTUnwrap(createParams["initial_env"] as? [String: String]) + let createParams = try legacyUnwrap(params(for: "workspace.create", in: run.requests)) + let configureParams = try legacyUnwrap(params(for: "workspace.remote.configure", in: run.requests)) + let sshOptions = try legacyUnwrap(configureParams["ssh_options"] as? [String]) + let initialEnv = try legacyUnwrap(createParams["initial_env"] as? [String: String]) - XCTAssertTrue(sshOptions.contains("ForwardAgent=\(agentSocketPath)"), "ssh_options: \(sshOptions)") - XCTAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) - XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) + legacyAssertTrue(sshOptions.contains("ForwardAgent=\(agentSocketPath)"), "ssh_options: \(sshOptions)") + legacyAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) + legacyAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) } @Test @@ -3526,14 +3526,14 @@ final class CLINotifyProcessIntegrationRegressionTests { "HOME": homeURL.path, ] ) - let createParams = try XCTUnwrap(params(for: "workspace.create", in: run.requests)) - let configureParams = try XCTUnwrap(params(for: "workspace.remote.configure", in: run.requests)) - let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) - let initialEnv = try XCTUnwrap(createParams["initial_env"] as? [String: String]) + let createParams = try legacyUnwrap(params(for: "workspace.create", in: run.requests)) + let configureParams = try legacyUnwrap(params(for: "workspace.remote.configure", in: run.requests)) + let sshOptions = try legacyUnwrap(configureParams["ssh_options"] as? [String]) + let initialEnv = try legacyUnwrap(createParams["initial_env"] as? [String: String]) - XCTAssertTrue(sshOptions.contains("ForwardAgent=\(tildeSocketPath)"), "ssh_options: \(sshOptions)") - XCTAssertEqual(initialEnv["SSH_AUTH_SOCK"], expandedSocketURL.path) - XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, expandedSocketURL.path) + legacyAssertTrue(sshOptions.contains("ForwardAgent=\(tildeSocketPath)"), "ssh_options: \(sshOptions)") + legacyAssertEqual(initialEnv["SSH_AUTH_SOCK"], expandedSocketURL.path) + legacyAssertEqual(configureParams["ssh_auth_sock"] as? String, expandedSocketURL.path) } @Test @@ -3544,13 +3544,13 @@ final class CLINotifyProcessIntegrationRegressionTests { "SSH_AUTH_SOCK": "/tmp/cmux-test-agent-\(UUID().uuidString).sock", ] ) - let createParams = try XCTUnwrap(params(for: "workspace.create", in: run.requests)) - let configureParams = try XCTUnwrap(params(for: "workspace.remote.configure", in: run.requests)) - let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) + let createParams = try legacyUnwrap(params(for: "workspace.create", in: run.requests)) + let configureParams = try legacyUnwrap(params(for: "workspace.remote.configure", in: run.requests)) + let sshOptions = try legacyUnwrap(configureParams["ssh_options"] as? [String]) - XCTAssertTrue(sshOptions.contains("ForwardAgent=ask"), "ssh_options: \(sshOptions)") - XCTAssertNil(createParams["initial_env"]) - XCTAssertNil(configureParams["ssh_auth_sock"]) + legacyAssertTrue(sshOptions.contains("ForwardAgent=ask"), "ssh_options: \(sshOptions)") + legacyAssertNil(createParams["initial_env"]) + legacyAssertNil(configureParams["ssh_auth_sock"]) } @Test @@ -3562,14 +3562,14 @@ final class CLINotifyProcessIntegrationRegressionTests { "SSH_AUTH_SOCK": agentSocketPath, ] ) - let createParams = try XCTUnwrap(params(for: "workspace.create", in: run.requests)) - let configureParams = try XCTUnwrap(params(for: "workspace.remote.configure", in: run.requests)) - let sshOptions = try XCTUnwrap(configureParams["ssh_options"] as? [String]) - let initialEnv = try XCTUnwrap(createParams["initial_env"] as? [String: String]) + let createParams = try legacyUnwrap(params(for: "workspace.create", in: run.requests)) + let configureParams = try legacyUnwrap(params(for: "workspace.remote.configure", in: run.requests)) + let sshOptions = try legacyUnwrap(configureParams["ssh_options"] as? [String]) + let initialEnv = try legacyUnwrap(createParams["initial_env"] as? [String: String]) - XCTAssertTrue(sshOptions.contains("ForwardAgent=no"), "ssh_options: \(sshOptions)") - XCTAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) - XCTAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) + legacyAssertTrue(sshOptions.contains("ForwardAgent=no"), "ssh_options: \(sshOptions)") + legacyAssertEqual(initialEnv["SSH_AUTH_SOCK"], agentSocketPath) + legacyAssertEqual(configureParams["ssh_auth_sock"] as? String, agentSocketPath) } private func assertSSHPersistentPTYUsesReusableForegroundAuthControlConnection( @@ -3577,72 +3577,72 @@ final class CLINotifyProcessIntegrationRegressionTests { file: StaticString = #filePath, line: UInt = #line ) throws { - let createParams = try XCTUnwrap(params(for: "workspace.create", in: run.requests)) - let configureParams = try XCTUnwrap(params(for: "workspace.remote.configure", in: run.requests)) - let initialCommand = try XCTUnwrap(createParams["initial_command"] as? String) - let terminalStartupCommand = try XCTUnwrap(configureParams["terminal_startup_command"] as? String) - let initialScript = try XCTUnwrap(decodedReusableStartupScript(from: initialCommand)) - let terminalStartupScript = try XCTUnwrap(decodedReusableStartupScript(from: terminalStartupCommand)) - - XCTAssertTrue(initialScript.contains("ssh-pty-attach"), initialScript) - XCTAssertTrue(initialScript.contains("--wait"), initialScript) - XCTAssertTrue(initialScript.contains("ssh-session-end"), initialScript) - XCTAssertTrue(initialScript.contains("CMUX_WORKSPACE_ID"), initialScript) - XCTAssertTrue(initialScript.contains("CMUX_SURFACE_ID"), initialScript) - XCTAssertTrue( + let createParams = try legacyUnwrap(params(for: "workspace.create", in: run.requests)) + let configureParams = try legacyUnwrap(params(for: "workspace.remote.configure", in: run.requests)) + let initialCommand = try legacyUnwrap(createParams["initial_command"] as? String) + let terminalStartupCommand = try legacyUnwrap(configureParams["terminal_startup_command"] as? String) + let initialScript = try legacyUnwrap(decodedReusableStartupScript(from: initialCommand)) + let terminalStartupScript = try legacyUnwrap(decodedReusableStartupScript(from: terminalStartupCommand)) + + legacyAssertTrue(initialScript.contains("ssh-pty-attach"), initialScript) + legacyAssertTrue(initialScript.contains("--wait"), initialScript) + legacyAssertTrue(initialScript.contains("ssh-session-end"), initialScript) + legacyAssertTrue(initialScript.contains("CMUX_WORKSPACE_ID"), initialScript) + legacyAssertTrue(initialScript.contains("CMUX_SURFACE_ID"), initialScript) + legacyAssertTrue( initialScript.contains("required workspace context missing for SSH PTY attach"), initialScript ) - XCTAssertTrue( + legacyAssertTrue( initialScript.contains("required terminal context missing for SSH PTY attach"), initialScript ) - XCTAssertTrue(initialScript.contains("ssh-$cmux_ssh_pty_workspace_id-$cmux_ssh_pty_surface_id"), initialScript) + legacyAssertTrue(initialScript.contains("ssh-$cmux_ssh_pty_workspace_id-$cmux_ssh_pty_surface_id"), initialScript) let attachIDFlag = "--attachment-id \"$cmux_ssh_pty_surface_id\"" - XCTAssertTrue(initialScript.contains("254|255"), initialScript) - XCTAssertTrue(initialScript.split(separator: "\n").first { $0.contains("ssh-pty-attach") }.map { $0.contains(attachIDFlag) && !$0.contains("--surface") } == true, initialScript) - XCTAssertTrue( + legacyAssertTrue(initialScript.contains("254|255"), initialScript) + legacyAssertTrue(initialScript.split(separator: "\n").first { $0.contains("ssh-pty-attach") }.map { $0.contains(attachIDFlag) && !$0.contains("--surface") } == true, initialScript) + legacyAssertTrue( initialScript.contains("--workspace \"$cmux_ssh_pty_workspace_id\""), initialScript ) - XCTAssertEqual( + legacyAssertEqual( initialScript.components(separatedBy: "workspace.remote.foreground_auth_ready").count - 1, 1, initialScript ) - XCTAssertTrue(terminalStartupScript.contains("ssh-pty-attach"), terminalStartupScript) - XCTAssertTrue(terminalStartupScript.contains("ssh-session-end"), terminalStartupScript) - XCTAssertTrue(terminalStartupScript.contains("CMUX_WORKSPACE_ID"), terminalStartupScript) - XCTAssertTrue(terminalStartupScript.contains("CMUX_SURFACE_ID"), terminalStartupScript) - XCTAssertTrue( + legacyAssertTrue(terminalStartupScript.contains("ssh-pty-attach"), terminalStartupScript) + legacyAssertTrue(terminalStartupScript.contains("ssh-session-end"), terminalStartupScript) + legacyAssertTrue(terminalStartupScript.contains("CMUX_WORKSPACE_ID"), terminalStartupScript) + legacyAssertTrue(terminalStartupScript.contains("CMUX_SURFACE_ID"), terminalStartupScript) + legacyAssertTrue( terminalStartupScript.contains("required workspace context missing for SSH PTY attach"), terminalStartupScript ) - XCTAssertTrue( + legacyAssertTrue( terminalStartupScript.contains("required terminal context missing for SSH PTY attach"), terminalStartupScript ) - XCTAssertTrue( + legacyAssertTrue( terminalStartupScript.contains("ssh-$cmux_ssh_pty_workspace_id-$cmux_ssh_pty_surface_id"), terminalStartupScript ) - XCTAssertTrue(terminalStartupScript.contains("254|255"), terminalStartupScript) - XCTAssertTrue(terminalStartupScript.split(separator: "\n").first { $0.contains("ssh-pty-attach") }.map { $0.contains(attachIDFlag) && !$0.contains("--surface") } == true, terminalStartupScript) - XCTAssertTrue( + legacyAssertTrue(terminalStartupScript.contains("254|255"), terminalStartupScript) + legacyAssertTrue(terminalStartupScript.split(separator: "\n").first { $0.contains("ssh-pty-attach") }.map { $0.contains(attachIDFlag) && !$0.contains("--surface") } == true, terminalStartupScript) + legacyAssertTrue( terminalStartupScript.contains("--workspace \"$cmux_ssh_pty_workspace_id\""), terminalStartupScript ) - XCTAssertEqual( + legacyAssertEqual( terminalStartupScript.components(separatedBy: "workspace.remote.foreground_auth_ready").count - 1, 1, terminalStartupScript ) - XCTAssertEqual(configureParams["auto_connect"] as? Bool, false) - XCTAssertNotNil(configureParams["foreground_auth_token"] as? String) - XCTAssertEqual(configureParams["preserve_after_terminal_exit"] as? Bool, true) - let persistentDaemonSlot = try XCTUnwrap(configureParams["persistent_daemon_slot"] as? String) - XCTAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) - XCTAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) + legacyAssertEqual(configureParams["auto_connect"] as? Bool, false) + legacyAssertNotNil(configureParams["foreground_auth_token"] as? String) + legacyAssertEqual(configureParams["preserve_after_terminal_exit"] as? Bool, true) + let persistentDaemonSlot = try legacyUnwrap(configureParams["persistent_daemon_slot"] as? String) + legacyAssertTrue(persistentDaemonSlot.hasPrefix("ssh-"), persistentDaemonSlot) + legacyAssertNotNil(UUID(uuidString: String(persistentDaemonSlot.dropFirst(4)))) } @Test @@ -3656,28 +3656,28 @@ final class CLINotifyProcessIntegrationRegressionTests { for testCase in cases { let run = try runMockedSSH(arguments: testCase.arguments) - let createParams = try XCTUnwrap( + let createParams = try legacyUnwrap( params(for: "workspace.create", in: run.requests), testCase.name ) - let configureParams = try XCTUnwrap( + let configureParams = try legacyUnwrap( params(for: "workspace.remote.configure", in: run.requests), testCase.name ) - let initialCommand = try XCTUnwrap(createParams["initial_command"] as? String, testCase.name) - let terminalStartupCommand = try XCTUnwrap( + let initialCommand = try legacyUnwrap(createParams["initial_command"] as? String, testCase.name) + let terminalStartupCommand = try legacyUnwrap( configureParams["terminal_startup_command"] as? String, testCase.name ) let initialScript = decodedReusableStartupScript(from: initialCommand) ?? initialCommand let terminalStartupScript = decodedReusableStartupScript(from: terminalStartupCommand) ?? terminalStartupCommand - XCTAssertFalse(initialScript.contains("ssh-pty-attach"), testCase.name) - XCTAssertFalse(terminalStartupScript.contains("ssh-pty-attach"), testCase.name) - XCTAssertEqual(configureParams["auto_connect"] as? Bool, true, testCase.name) - XCTAssertNil(configureParams["foreground_auth_token"], testCase.name) - XCTAssertNil(configureParams["preserve_after_terminal_exit"], testCase.name) - XCTAssertNil(configureParams["persistent_daemon_slot"], testCase.name) + legacyAssertFalse(initialScript.contains("ssh-pty-attach"), testCase.name) + legacyAssertFalse(terminalStartupScript.contains("ssh-pty-attach"), testCase.name) + legacyAssertEqual(configureParams["auto_connect"] as? Bool, true, testCase.name) + legacyAssertNil(configureParams["foreground_auth_token"], testCase.name) + legacyAssertNil(configureParams["preserve_after_terminal_exit"], testCase.name) + legacyAssertNil(configureParams["persistent_daemon_slot"], testCase.name) } } @@ -3708,9 +3708,9 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.remote.pty_bridge": - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["session_id"] as? String, sessionId) - XCTAssertEqual(params["attachment_id"] as? String, surfaceId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertEqual(params["attachment_id"] as? String, surfaceId) return self.v2Response( id: id, ok: true, @@ -3723,9 +3723,9 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.remote.pty_attach_end": - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["surface_id"] as? String, surfaceId) - XCTAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["session_id"] as? String, sessionId) return self.v2Response( id: id, ok: true, @@ -3765,13 +3765,13 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [socketHandled, bridgeHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stdout.isEmpty, result.stdout) - XCTAssertTrue(result.stderr.contains("ssh-pty-attach: remote PTY start failed"), result.stderr) + legacyWait(for: [socketHandled, bridgeHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stdout.isEmpty, result.stdout) + legacyAssertTrue(result.stderr.contains("ssh-pty-attach: remote PTY start failed"), result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) + legacyAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) } @Test @@ -3801,7 +3801,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.remote.pty_bridge": - XCTAssertEqual(params["require_existing"] as? Bool, true) + legacyAssertEqual(params["require_existing"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -3814,8 +3814,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.remote.pty_resize": - XCTAssertEqual(params["attachment_token"] as? String, "attach-token") - XCTAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["attachment_token"] as? String, "attach-token") + legacyAssertEqual(params["surface_id"] as? String, surfaceId) return self.v2Response(id: id, ok: true, result: ["resized": true]) case "workspace.remote.pty_sessions": return self.v2Response( @@ -3831,11 +3831,11 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.remote.pty_detach": - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["surface_id"] as? String, surfaceId) - XCTAssertEqual(params["session_id"] as? String, sessionId) - XCTAssertEqual(params["attachment_id"] as? String, surfaceId) - XCTAssertEqual(params["attachment_token"] as? String, "attach-token") + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertEqual(params["attachment_id"] as? String, surfaceId) + legacyAssertEqual(params["attachment_token"] as? String, "attach-token") return self.v2Response( id: id, ok: true, @@ -3873,15 +3873,15 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [socketHandled, bridgeHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 254, result.stderr) - XCTAssertTrue( + legacyWait(for: [socketHandled, bridgeHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 254, result.stderr) + legacyAssertTrue( result.stderr.contains("ssh-pty-attach: bridge closed while remote PTY session is still running"), result.stderr ) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_detach"]) + legacyAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_detach"]) } @Test @@ -3923,8 +3923,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ) case "workspace.remote.pty_resize": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["attachment_token"] as? String, "attach-token") - XCTAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["attachment_token"] as? String, "attach-token") + legacyAssertEqual(params["surface_id"] as? String, surfaceId) return self.v2Response(id: id, ok: true, result: ["resized": true]) case "workspace.remote.pty_sessions": return self.v2Response( @@ -3936,9 +3936,9 @@ final class CLINotifyProcessIntegrationRegressionTests { ) case "workspace.remote.pty_attach_end": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["surface_id"] as? String, surfaceId) - XCTAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["session_id"] as? String, sessionId) return self.v2Response( id: id, ok: true, @@ -3975,13 +3975,13 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [socketHandled, bridgeHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.isEmpty, result.stdout) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [socketHandled, bridgeHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stdout.isEmpty, result.stdout) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) + legacyAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) } @Test @@ -4010,11 +4010,11 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.remote.pty_bridge": - XCTAssertNil(params["surface_id"]) - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertNil(params["surface_id"]) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["session_id"] as? String, sessionId) let attachmentID = params["attachment_id"] as? String - XCTAssertNotNil(attachmentID.flatMap { UUID(uuidString: $0) }) + legacyAssertNotNil(attachmentID.flatMap { UUID(uuidString: $0) }) return self.v2Response( id: id, ok: true, @@ -4027,11 +4027,11 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.remote.pty_resize": - XCTAssertEqual(params["attachment_token"] as? String, "attach-token") - XCTAssertNil(params["surface_id"]) + legacyAssertEqual(params["attachment_token"] as? String, "attach-token") + legacyAssertNil(params["surface_id"]) return self.v2Response(id: id, ok: true, result: ["resized": true]) case "workspace.remote.pty_sessions": - XCTAssertNil(params["surface_id"]) + legacyAssertNil(params["surface_id"]) return self.v2Response( id: id, ok: true, @@ -4065,13 +4065,13 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [socketHandled, bridgeHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.isEmpty, result.stdout) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [socketHandled, bridgeHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stdout.isEmpty, result.stdout) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions"]) + legacyAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions"]) } @Test @@ -4113,8 +4113,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ) case "workspace.remote.pty_resize": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["attachment_token"] as? String, "attach-token") - XCTAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["attachment_token"] as? String, "attach-token") + legacyAssertEqual(params["surface_id"] as? String, surfaceId) return self.v2Response(id: id, ok: true, result: ["resized": true]) case "workspace.remote.pty_sessions": return self.v2Response( @@ -4126,9 +4126,9 @@ final class CLINotifyProcessIntegrationRegressionTests { ) case "workspace.remote.pty_attach_end": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["surface_id"] as? String, surfaceId) - XCTAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["session_id"] as? String, sessionId) return self.v2Response( id: id, ok: true, @@ -4165,13 +4165,13 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [socketHandled, bridgeHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.isEmpty, result.stdout) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [socketHandled, bridgeHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stdout.isEmpty, result.stdout) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) + legacyAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_resize", "workspace.remote.pty_sessions", "workspace.remote.pty_attach_end"]) } @Test @@ -4246,9 +4246,9 @@ final class CLINotifyProcessIntegrationRegressionTests { ) case "workspace.remote.pty_resize": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["attachment_token"] as? String, "attach-token") - XCTAssertEqual(params["cols"] as? Int, 132) - XCTAssertEqual(params["rows"] as? Int, 43) + legacyAssertEqual(params["attachment_token"] as? String, "attach-token") + legacyAssertEqual(params["cols"] as? Int, 132) + legacyAssertEqual(params["rows"] as? Int, 43) return self.v2Response(id: id, ok: true, result: ["resized": true]) case "workspace.remote.pty_sessions": return self.v2Response(id: id, ok: true, result: ["sessions": []]) @@ -4272,7 +4272,7 @@ final class CLINotifyProcessIntegrationRegressionTests { } } - let bridgeHandled = expectation(description: "bridge handshake captured") + let bridgeHandled = legacyExpectation(description: "bridge handshake captured") DispatchQueue.global(qos: .userInitiated).async { defer { bridgeHandled.fulfill() } var clientAddr = sockaddr_in() @@ -4333,27 +4333,27 @@ final class CLINotifyProcessIntegrationRegressionTests { try process.run() slaveHandle.closeFile() - XCTAssertEqual(bridgeRequestReceived.wait(timeout: .now() + 5), .success) + legacyAssertEqual(bridgeRequestReceived.wait(timeout: .now() + 5), .success) try setPTYSize(cols: 132, rows: 43) allowBridgeResponse.signal() - XCTAssertEqual(handshakeReceived.wait(timeout: .now() + 5), .success) + legacyAssertEqual(handshakeReceived.wait(timeout: .now() + 5), .success) let exited = DispatchSemaphore(value: 0) DispatchQueue.global(qos: .userInitiated).async { process.waitUntilExit() exited.signal() } - XCTAssertEqual(exited.wait(timeout: .now() + 5), .success) - wait(for: [socketHandled, bridgeHandled], timeout: 5) + legacyAssertEqual(exited.wait(timeout: .now() + 5), .success) + legacyWait(for: [socketHandled, bridgeHandled], timeout: 5) let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - XCTAssertEqual(process.terminationStatus, 0, stderr) + legacyAssertEqual(process.terminationStatus, 0, stderr) handshakeLock.lock() let capturedHandshake = handshakePayload handshakeLock.unlock() - XCTAssertEqual(capturedHandshake?["token"] as? String, token) - XCTAssertEqual(capturedHandshake?["cols"] as? Int, 132) - XCTAssertEqual(capturedHandshake?["rows"] as? Int, 43) + legacyAssertEqual(capturedHandshake?["token"] as? String, token) + legacyAssertEqual(capturedHandshake?["cols"] as? Int, 132) + legacyAssertEqual(capturedHandshake?["rows"] as? Int, 43) } @Test @@ -4436,7 +4436,7 @@ final class CLINotifyProcessIntegrationRegressionTests { } let socketHandled = (0..<2).map { _ in startMockServer(listenerFD: listenerFD, state: state, handler: socketHandler) } - let bridgeHandled = expectation(description: "controlled bridge handled") + let bridgeHandled = legacyExpectation(description: "controlled bridge handled") DispatchQueue.global(qos: .userInitiated).async { defer { bridgeHandled.fulfill() } var clientAddr = sockaddr_in() @@ -4493,16 +4493,16 @@ final class CLINotifyProcessIntegrationRegressionTests { process.terminate() } } - XCTAssertEqual(bridgeReady.wait(timeout: .now() + 5), .success) + legacyAssertEqual(bridgeReady.wait(timeout: .now() + 5), .success) - XCTAssertEqual( + legacyAssertEqual( resizeRequestReceived.wait(timeout: .now() + 5), .success, "Expected ssh-pty-attach to issue its initial resize RPC after bridge ready" ) closeBridge.signal() - wait(for: [bridgeHandled], timeout: 5) + legacyWait(for: [bridgeHandled], timeout: 5) allowResizeResponse.signal() let exited = DispatchSemaphore(value: 0) @@ -4510,19 +4510,19 @@ final class CLINotifyProcessIntegrationRegressionTests { process.waitUntilExit() exited.signal() } - XCTAssertEqual(exited.wait(timeout: .now() + 5), .success) + legacyAssertEqual(exited.wait(timeout: .now() + 5), .success) - wait(for: socketHandled, timeout: 5) + legacyWait(for: socketHandled, timeout: 5) let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - XCTAssertEqual(process.terminationStatus, 0, stderr) - XCTAssertEqual(stdout, "") - XCTAssertEqual(stderr, "") + legacyAssertEqual(process.terminationStatus, 0, stderr) + legacyAssertEqual(stdout, "") + legacyAssertEqual(stderr, "") let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods.filter { $0 == "workspace.remote.pty_bridge" }.count, 1) - XCTAssertEqual(methods.filter { $0 == "workspace.remote.pty_resize" }.count, 1) - XCTAssertEqual(methods.filter { $0 == "workspace.remote.pty_sessions" }.count, 1) - XCTAssertEqual(methods.filter { $0 == "workspace.remote.pty_attach_end" }.count, 1) + legacyAssertEqual(methods.filter { $0 == "workspace.remote.pty_bridge" }.count, 1) + legacyAssertEqual(methods.filter { $0 == "workspace.remote.pty_resize" }.count, 1) + legacyAssertEqual(methods.filter { $0 == "workspace.remote.pty_sessions" }.count, 1) + legacyAssertEqual(methods.filter { $0 == "workspace.remote.pty_attach_end" }.count, 1) } @Test @@ -4546,19 +4546,19 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "surface.create") + legacyAssertEqual(method, "surface.create") let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["remote_pty_session_id"] as? String, sessionId) - XCTAssertEqual(params["focus"] as? Bool, true) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["remote_pty_session_id"] as? String, sessionId) + legacyAssertEqual(params["focus"] as? Bool, true) let initialCommand = params["initial_command"] as? String ?? "" - XCTAssertTrue(initialCommand.hasPrefix("/bin/sh -c "), initialCommand) - XCTAssertTrue(initialCommand.contains("ssh-pty-attach"), initialCommand) - XCTAssertTrue(initialCommand.contains("--require-existing"), initialCommand) - XCTAssertTrue(initialCommand.contains(sessionId), initialCommand) - XCTAssertTrue(initialCommand.contains("CMUX_WORKSPACE_ID"), initialCommand) - XCTAssertTrue(initialCommand.contains("CMUX_SURFACE_ID"), initialCommand) - XCTAssertTrue(initialCommand.contains("254|255"), initialCommand) + legacyAssertTrue(initialCommand.hasPrefix("/bin/sh -c "), initialCommand) + legacyAssertTrue(initialCommand.contains("ssh-pty-attach"), initialCommand) + legacyAssertTrue(initialCommand.contains("--require-existing"), initialCommand) + legacyAssertTrue(initialCommand.contains(sessionId), initialCommand) + legacyAssertTrue(initialCommand.contains("CMUX_WORKSPACE_ID"), initialCommand) + legacyAssertTrue(initialCommand.contains("CMUX_SURFACE_ID"), initialCommand) + legacyAssertTrue(initialCommand.contains("254|255"), initialCommand) return self.v2Response( id: id, ok: true, @@ -4589,11 +4589,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertEqual(state.snapshot().count, 1) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) + legacyAssertEqual(state.snapshot().count, 1) } @Test @@ -4623,10 +4623,10 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.remote.pty_bridge": - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["session_id"] as? String, sessionId) - XCTAssertEqual(params["attachment_id"] as? String, surfaceId) - XCTAssertEqual(params["require_existing"] as? Bool, true) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertEqual(params["attachment_id"] as? String, surfaceId) + legacyAssertEqual(params["require_existing"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -4639,9 +4639,9 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.remote.pty_attach_end": - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["surface_id"] as? String, surfaceId) - XCTAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["session_id"] as? String, sessionId) return self.v2Response( id: id, ok: true, @@ -4680,12 +4680,12 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [socketHandled, bridgeHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stderr.contains("ssh-pty-attach: missing session"), result.stderr) + legacyWait(for: [socketHandled, bridgeHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stderr.contains("ssh-pty-attach: missing session"), result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) + legacyAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) } @Test @@ -4712,10 +4712,10 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.remote.pty_bridge": - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["session_id"] as? String, sessionId) - XCTAssertEqual(params["attachment_id"] as? String, surfaceId) - XCTAssertEqual(params["require_existing"] as? Bool, true) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertEqual(params["attachment_id"] as? String, surfaceId) + legacyAssertEqual(params["require_existing"] as? Bool, true) return self.v2Response( id: id, ok: false, @@ -4725,9 +4725,9 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.remote.pty_attach_end": - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["surface_id"] as? String, surfaceId) - XCTAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["session_id"] as? String, sessionId) return self.v2Response( id: id, ok: true, @@ -4765,12 +4765,12 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 3 ) - wait(for: [socketHandled], timeout: 3) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stderr.contains("persistent SSH PTY session is no longer running"), result.stderr) + legacyWait(for: [socketHandled], timeout: 3) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stderr.contains("persistent SSH PTY session is no longer running"), result.stderr) let methods = state.snapshot().compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) + legacyAssertEqual(methods, ["workspace.remote.pty_bridge", "workspace.remote.pty_attach_end"]) } @Test @@ -4792,9 +4792,9 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "workspace.remote.pty_sessions") + legacyAssertEqual(method, "workspace.remote.pty_sessions") let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["all_workspaces"] as? Bool, true) + legacyAssertEqual(params["all_workspaces"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -4825,13 +4825,13 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertFalse(result.stdout.contains("No persisted SSH PTY sessions"), result.stdout) - XCTAssertTrue(result.stderr.contains("ssh-session-list failed for 1 remote workspace"), result.stderr) - XCTAssertTrue(result.stderr.contains("workspace:4"), result.stderr) - XCTAssertTrue(result.stderr.contains("remote connection is not active"), result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertFalse(result.stdout.contains("No persisted SSH PTY sessions"), result.stdout) + legacyAssertTrue(result.stderr.contains("ssh-session-list failed for 1 remote workspace"), result.stderr) + legacyAssertTrue(result.stderr.contains("workspace:4"), result.stderr) + legacyAssertTrue(result.stderr.contains("remote connection is not active"), result.stderr) } @Test @@ -4858,7 +4858,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.remote.pty_sessions": - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) return self.v2Response( id: id, ok: true, @@ -4880,12 +4880,12 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.remote.pty_close": - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) let sessionId = params["session_id"] as? String if sessionId == closedSessionId { return self.v2Response(id: id, ok: true, result: ["closed": true]) } - XCTAssertEqual(sessionId, failedSessionId) + legacyAssertEqual(sessionId, failedSessionId) return self.v2Response( id: id, ok: false, @@ -4915,15 +4915,15 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stdout.contains("Closed 1 persisted SSH PTY session"), result.stdout) - XCTAssertTrue(result.stderr.contains("ssh-session-cleanup failed for 2 persisted SSH PTY sessions"), result.stderr) - XCTAssertTrue(result.stderr.contains(failedSessionId), result.stderr) - XCTAssertTrue(result.stderr.contains("missing session_id in SSH PTY session list response"), result.stderr) - XCTAssertTrue(result.stderr.contains("remote PTY operation failed"), result.stderr) - XCTAssertFalse(result.stderr.contains("close failed"), result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stdout.contains("Closed 1 persisted SSH PTY session"), result.stdout) + legacyAssertTrue(result.stderr.contains("ssh-session-cleanup failed for 2 persisted SSH PTY sessions"), result.stderr) + legacyAssertTrue(result.stderr.contains(failedSessionId), result.stderr) + legacyAssertTrue(result.stderr.contains("missing session_id in SSH PTY session list response"), result.stderr) + legacyAssertTrue(result.stderr.contains("remote PTY operation failed"), result.stderr) + legacyAssertFalse(result.stderr.contains("close failed"), result.stderr) } @Test @@ -4949,7 +4949,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.remote.pty_sessions": - XCTAssertEqual(params["all_workspaces"] as? Bool, true) + legacyAssertEqual(params["all_workspaces"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -4969,8 +4969,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.remote.pty_close": - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["session_id"] as? String, closedSessionId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["session_id"] as? String, closedSessionId) return self.v2Response(id: id, ok: true, result: ["closed": true]) default: return self.v2Response( @@ -4996,14 +4996,14 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stdout.contains("Closed 1 persisted SSH PTY session"), result.stdout) - XCTAssertTrue(result.stderr.contains("ssh-session-cleanup failed for 1 persisted SSH PTY session"), result.stderr) - XCTAssertTrue(result.stderr.contains("workspace-query"), result.stderr) - XCTAssertTrue(result.stderr.contains("workspace:4"), result.stderr) - XCTAssertTrue(result.stderr.contains("remote connection is not active"), result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stdout.contains("Closed 1 persisted SSH PTY session"), result.stdout) + legacyAssertTrue(result.stderr.contains("ssh-session-cleanup failed for 1 persisted SSH PTY session"), result.stderr) + legacyAssertTrue(result.stderr.contains("workspace-query"), result.stderr) + legacyAssertTrue(result.stderr.contains("workspace:4"), result.stderr) + legacyAssertTrue(result.stderr.contains("remote connection is not active"), result.stderr) } @Test @@ -5028,7 +5028,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.remote.pty_sessions": - XCTAssertEqual(params["all_workspaces"] as? Bool, true) + legacyAssertEqual(params["all_workspaces"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -5071,14 +5071,14 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertFalse(state.snapshot().contains { $0.contains("workspace.remote.pty_close") }) - XCTAssertTrue(result.stderr.contains("ssh-session-cleanup failed for 1 persisted SSH PTY session"), result.stderr) - XCTAssertTrue(result.stderr.contains(sessionId), result.stderr) - XCTAssertTrue(result.stderr.contains("workspace:missing"), result.stderr) - XCTAssertTrue(result.stderr.contains("missing workspace_id in SSH PTY session list response"), result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertFalse(state.snapshot().contains { $0.contains("workspace.remote.pty_close") }) + legacyAssertTrue(result.stderr.contains("ssh-session-cleanup failed for 1 persisted SSH PTY session"), result.stderr) + legacyAssertTrue(result.stderr.contains(sessionId), result.stderr) + legacyAssertTrue(result.stderr.contains("workspace:missing"), result.stderr) + legacyAssertTrue(result.stderr.contains("missing workspace_id in SSH PTY session list response"), result.stderr) } @Test @@ -5103,7 +5103,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.remote.pty_sessions": - XCTAssertEqual(params["all_workspaces"] as? Bool, true) + legacyAssertEqual(params["all_workspaces"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -5146,14 +5146,14 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertFalse(state.snapshot().contains { $0.contains("workspace.remote.pty_close") }) - XCTAssertTrue(result.stderr.contains("ssh-session-cleanup failed for 1 persisted SSH PTY session"), result.stderr) - XCTAssertTrue(result.stderr.contains(sessionId), result.stderr) - XCTAssertTrue(result.stderr.contains("workspace:missing"), result.stderr) - XCTAssertTrue(result.stderr.contains("missing workspace_id in SSH PTY session list response"), result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertFalse(state.snapshot().contains { $0.contains("workspace.remote.pty_close") }) + legacyAssertTrue(result.stderr.contains("ssh-session-cleanup failed for 1 persisted SSH PTY session"), result.stderr) + legacyAssertTrue(result.stderr.contains(sessionId), result.stderr) + legacyAssertTrue(result.stderr.contains("workspace:missing"), result.stderr) + legacyAssertTrue(result.stderr.contains("missing workspace_id in SSH PTY session list response"), result.stderr) } @Test @@ -5214,13 +5214,13 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertFalse(state.snapshot().contains { $0.contains("workspace.remote.pty_close") }) - XCTAssertTrue(result.stderr.contains("ssh-session-cleanup failed for 1 persisted SSH PTY session"), result.stderr) - XCTAssertTrue(result.stderr.contains(sessionId), result.stderr) - XCTAssertTrue(result.stderr.contains("persistent SSH PTY session is no longer running"), result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertFalse(state.snapshot().contains { $0.contains("workspace.remote.pty_close") }) + legacyAssertTrue(result.stderr.contains("ssh-session-cleanup failed for 1 persisted SSH PTY session"), result.stderr) + legacyAssertTrue(result.stderr.contains(sessionId), result.stderr) + legacyAssertTrue(result.stderr.contains("persistent SSH PTY session is no longer running"), result.stderr) } @Test @@ -5247,7 +5247,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.remote.pty_sessions": - XCTAssertEqual(params["all_workspaces"] as? Bool, true) + legacyAssertEqual(params["all_workspaces"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -5265,8 +5265,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.remote.pty_close": - XCTAssertEqual(params["session_id"] as? String, sessionId) - XCTAssertTrue([workspaceA, workspaceB].contains(params["workspace_id"] as? String)) + legacyAssertEqual(params["session_id"] as? String, sessionId) + legacyAssertTrue([workspaceA, workspaceB].contains(params["workspace_id"] as? String)) return self.v2Response(id: id, ok: true, result: ["closed": true]) default: return self.v2Response( @@ -5292,10 +5292,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("Closed 2 persisted SSH PTY sessions"), result.stdout) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stdout.contains("Closed 2 persisted SSH PTY sessions"), result.stdout) let closedWorkspaces = state.snapshot().compactMap { line -> String? in guard let payload = self.jsonObject(line), @@ -5306,8 +5306,8 @@ final class CLINotifyProcessIntegrationRegressionTests { } return params["workspace_id"] as? String } - XCTAssertEqual(closedWorkspaces.count, 2) - XCTAssertEqual(Set(closedWorkspaces), Set([workspaceA, workspaceB])) + legacyAssertEqual(closedWorkspaces.count, 2) + legacyAssertEqual(Set(closedWorkspaces), Set([workspaceA, workspaceB])) } @Test @@ -5342,7 +5342,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ) case "workspace.list": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response( id: id, ok: true, @@ -5362,7 +5362,7 @@ final class CLINotifyProcessIntegrationRegressionTests { } } - XCTAssertEqual(line, "right_sidebar set find --tab=\(workspaceId) --window=\(windowId)") + legacyAssertEqual(line, "right_sidebar set find --tab=\(workspaceId) --window=\(windowId)") return "OK" } @@ -5377,16 +5377,16 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.isEmpty, result.stdout) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stdout.isEmpty, result.stdout) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["window.list", "workspace.list"] ) - XCTAssertEqual(state.commands.last, "right_sidebar set find --tab=\(workspaceId) --window=\(windowId)") + legacyAssertEqual(state.commands.last, "right_sidebar set find --tab=\(workspaceId) --window=\(windowId)") } @Test @@ -5407,7 +5407,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return "ERROR: Unexpected command \(line)" } - XCTAssertEqual(method, "workspace.list") + legacyAssertEqual(method, "workspace.list") return self.v2Response( id: id, ok: true, @@ -5430,16 +5430,16 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stdout.isEmpty, result.stdout) - XCTAssertTrue(result.stderr.contains("Workspace ref not found"), result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stdout.isEmpty, result.stdout) + legacyAssertTrue(result.stderr.contains("Workspace ref not found"), result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["workspace.list"] ) - XCTAssertFalse( + legacyAssertFalse( state.commands.contains { $0.hasPrefix("right_sidebar ") }, "Expected no right_sidebar command after target resolution failed, saw \(state.commands)" ) @@ -5474,10 +5474,10 @@ final class CLINotifyProcessIntegrationRegressionTests { } let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertNil(params["workspace_id"], "surface UUIDs should not be constrained to the caller workspace") - XCTAssertNil(params["window_id"], "surface UUIDs should not require an explicit window") - XCTAssertEqual(params["surface_id"] as? String, callerSurface) - XCTAssertEqual(params["body"] as? String, "Body") + legacyAssertNil(params["workspace_id"], "surface UUIDs should not be constrained to the caller workspace") + legacyAssertNil(params["window_id"], "surface UUIDs should not require an explicit window") + legacyAssertEqual(params["surface_id"] as? String, callerSurface) + legacyAssertEqual(params["body"] as? String, "Body") return self.v2Response( id: id, ok: true, @@ -5502,12 +5502,12 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertTrue( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") + legacyAssertTrue(result.stderr.isEmpty, result.stderr) + legacyAssertTrue( state.commands.contains { $0.contains("\"method\":\"notification.create\"") }, "Expected notify to use single-call UUID notification path, saw \(state.commands)" ) @@ -5548,7 +5548,7 @@ final class CLINotifyProcessIntegrationRegressionTests { environment: environment, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } @@ -5558,14 +5558,14 @@ final class CLINotifyProcessIntegrationRegressionTests { } return "0:\(notificationId)|\(workspaceId)|\(surfaceId)|unread|List Fields|cli-test|body|2026-01-01T00:00:00Z|pct:CLI%7CNotification Workspace" } - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) var rows = try notificationRows(from: result.stdout) - var row = try XCTUnwrap(rows.first(where: { $0["id"] as? String == notificationId })) - XCTAssertEqual(row["workspace_id"] as? String, workspaceId) - XCTAssertEqual(row["surface_id"] as? String, surfaceId) - XCTAssertEqual(row["created_at"] as? String, "2026-01-01T00:00:00Z") - XCTAssertEqual(row["tab_title"] as? String, "CLI|Notification Workspace") + var row = try legacyUnwrap(rows.first(where: { $0["id"] as? String == notificationId })) + legacyAssertEqual(row["workspace_id"] as? String, workspaceId) + legacyAssertEqual(row["surface_id"] as? String, surfaceId) + legacyAssertEqual(row["created_at"] as? String, "2026-01-01T00:00:00Z") + legacyAssertEqual(row["tab_title"] as? String, "CLI|Notification Workspace") result = run(["--json", "list-notifications", "--id-format", "uuids"]) { line in guard line == "list_notifications" else { @@ -5573,11 +5573,11 @@ final class CLINotifyProcessIntegrationRegressionTests { } return "0:\(notificationId)|\(workspaceId)|\(surfaceId)|unread|List Fields|cli-test|body|2026-01-01T00:00:00Z|pct:CLI%7CNotification Workspace" } - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) rows = try notificationRows(from: result.stdout) - row = try XCTUnwrap(rows.first(where: { $0["id"] as? String == notificationId })) - XCTAssertEqual(row["created_at"] as? String, "2026-01-01T00:00:00Z") + row = try legacyUnwrap(rows.first(where: { $0["id"] as? String == notificationId })) + legacyAssertEqual(row["created_at"] as? String, "2026-01-01T00:00:00Z") result = run(["mark-notification-read", "--id", notificationId, "--json", "--id-format", "uuids"]) { line in guard let payload = self.jsonObject(line), @@ -5585,16 +5585,16 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "notification.mark_read") + legacyAssertEqual(method, "notification.mark_read") let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["id"] as? String, notificationId) + legacyAssertEqual(params["id"] as? String, notificationId) return self.v2Response(id: id, ok: true, result: ["marked_read": 1, "id": notificationId]) } - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let markByIdPayload = try jsonPayload(from: result.stdout) - XCTAssertEqual(markByIdPayload["marked_read"] as? Int, 1) - XCTAssertEqual(markByIdPayload["id"] as? String, notificationId) + legacyAssertEqual(markByIdPayload["marked_read"] as? Int, 1) + legacyAssertEqual(markByIdPayload["id"] as? String, notificationId) result = run(["dismiss-notification", "--all-read", "--json", "--id-format", "uuids"]) { line in guard let payload = self.jsonObject(line), @@ -5602,16 +5602,16 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "notification.dismiss") + legacyAssertEqual(method, "notification.dismiss") let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["all_read"] as? Bool, true) + legacyAssertEqual(params["all_read"] as? Bool, true) return self.v2Response(id: id, ok: true, result: ["dismissed": 1, "all_read": true]) } - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let dismissPayload = try jsonPayload(from: result.stdout) - XCTAssertEqual(dismissPayload["dismissed"] as? Int, 1) - XCTAssertEqual(dismissPayload["all_read"] as? Bool, true) + legacyAssertEqual(dismissPayload["dismissed"] as? Int, 1) + legacyAssertEqual(dismissPayload["all_read"] as? Bool, true) result = run([ "mark-notification-read", @@ -5626,21 +5626,21 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "notification.mark_read") + legacyAssertEqual(method, "notification.mark_read") let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["tab_id"] as? String, workspaceId) - XCTAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["tab_id"] as? String, workspaceId) + legacyAssertEqual(params["surface_id"] as? String, surfaceId) return self.v2Response( id: id, ok: true, result: ["marked_read": 1, "workspace_id": workspaceId, "surface_id": surfaceId] ) } - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let markScopedPayload = try jsonPayload(from: result.stdout) - XCTAssertEqual(markScopedPayload["workspace_id"] as? String, workspaceId) - XCTAssertEqual(markScopedPayload["surface_id"] as? String, surfaceId) + legacyAssertEqual(markScopedPayload["workspace_id"] as? String, workspaceId) + legacyAssertEqual(markScopedPayload["surface_id"] as? String, surfaceId) result = run(["open-notification", "--id", openNotificationId, "--json", "--id-format", "uuids"]) { line in guard let payload = self.jsonObject(line), @@ -5648,9 +5648,9 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "notification.open") + legacyAssertEqual(method, "notification.open") let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["id"] as? String, openNotificationId) + legacyAssertEqual(params["id"] as? String, openNotificationId) return self.v2Response( id: id, ok: true, @@ -5663,12 +5663,12 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) } - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let openPayload = try jsonPayload(from: result.stdout) - XCTAssertEqual(openPayload["workspace_id"] as? String, openWorkspaceId) - XCTAssertEqual(openPayload["surface_id"] as? String, openSurfaceId) - XCTAssertEqual(openPayload["is_read"] as? Bool, true) + legacyAssertEqual(openPayload["workspace_id"] as? String, openWorkspaceId) + legacyAssertEqual(openPayload["surface_id"] as? String, openSurfaceId) + legacyAssertEqual(openPayload["is_read"] as? Bool, true) result = run(["jump-to-unread", "--json", "--id-format", "uuids"]) { line in guard let payload = self.jsonObject(line), @@ -5676,9 +5676,9 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "notification.jump_to_unread") + legacyAssertEqual(method, "notification.jump_to_unread") let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertTrue(params.isEmpty, "jump-to-unread should not send selector params") + legacyAssertTrue(params.isEmpty, "jump-to-unread should not send selector params") return self.v2Response( id: id, ok: true, @@ -5691,13 +5691,13 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) } - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let jumpPayload = try jsonPayload(from: result.stdout) - XCTAssertEqual(jumpPayload["id"] as? String, jumpNotificationId) - XCTAssertEqual(jumpPayload["workspace_id"] as? String, openWorkspaceId) - XCTAssertEqual(jumpPayload["surface_id"] as? String, openSurfaceId) - XCTAssertEqual(jumpPayload["is_read"] as? Bool, true) + legacyAssertEqual(jumpPayload["id"] as? String, jumpNotificationId) + legacyAssertEqual(jumpPayload["workspace_id"] as? String, openWorkspaceId) + legacyAssertEqual(jumpPayload["surface_id"] as? String, openSurfaceId) + legacyAssertEqual(jumpPayload["is_read"] as? Bool, true) let methods = state.snapshot().map { command -> String in if command == "list_notifications" { @@ -5705,7 +5705,7 @@ final class CLINotifyProcessIntegrationRegressionTests { } return self.jsonObject(command)?["method"] as? String ?? "invalid" } - XCTAssertEqual( + legacyAssertEqual( methods, [ "list_notifications", @@ -5752,16 +5752,16 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let rows = try notificationRows(from: result.stdout) - let row = try XCTUnwrap(rows.first) - XCTAssertEqual(row["id"] as? String, notificationId) - XCTAssertEqual(row["workspace_id"] as? String, workspaceId) - XCTAssertEqual(row["body"] as? String, "alpha|beta|gamma") - XCTAssertTrue(row["created_at"] is NSNull) - XCTAssertTrue(row["tab_title"] is NSNull) + let row = try legacyUnwrap(rows.first) + legacyAssertEqual(row["id"] as? String, notificationId) + legacyAssertEqual(row["workspace_id"] as? String, workspaceId) + legacyAssertEqual(row["body"] as? String, "alpha|beta|gamma") + legacyAssertTrue(row["created_at"] is NSNull) + legacyAssertTrue(row["tab_title"] is NSNull) } @Test @@ -5858,17 +5858,17 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - let session = try XCTUnwrap(sessions[sessionId] as? [String: Any]) - XCTAssertEqual(session["workspaceId"] as? String, currentWorkspaceId) - XCTAssertEqual(session["surfaceId"] as? String, currentSurfaceId) - XCTAssertTrue( + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + let session = try legacyUnwrap(sessions[sessionId] as? [String: Any]) + legacyAssertEqual(session["workspaceId"] as? String, currentWorkspaceId) + legacyAssertEqual(session["surfaceId"] as? String, currentSurfaceId) + legacyAssertTrue( state.commands.contains { $0.contains("set_status codex Running") && $0.contains("--tab=\(currentWorkspaceId)") }, "Expected Codex prompt status to target current workspace, saw \(state.commands)" ) @@ -5899,7 +5899,7 @@ final class CLINotifyProcessIntegrationRegressionTests { switch method { case "workspace.list": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response( id: id, ok: true, @@ -5915,9 +5915,9 @@ final class CLINotifyProcessIntegrationRegressionTests { ) case "pane.create": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["direction"] as? String, "right") + legacyAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["direction"] as? String, "right") return self.v2Response( id: id, ok: true, @@ -5944,11 +5944,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["workspace.list", "pane.create"] ) @@ -5980,7 +5980,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.list": - XCTAssertEqual(params["window_id"] as? String, targetWindowId) + legacyAssertEqual(params["window_id"] as? String, targetWindowId) return self.v2Response( id: id, ok: true, @@ -5995,8 +5995,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "pane.list": - XCTAssertEqual(params["window_id"] as? String, targetWindowId) - XCTAssertEqual(params["workspace_id"] as? String, targetWorkspaceId) + legacyAssertEqual(params["window_id"] as? String, targetWindowId) + legacyAssertEqual(params["workspace_id"] as? String, targetWorkspaceId) return self.v2Response( id: id, ok: true, @@ -6027,11 +6027,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertNotEqual(result.status, 0) - XCTAssertTrue(result.stderr.contains("Pane not found in window"), result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertNotEqual(result.status, 0) + legacyAssertTrue(result.stderr.contains("Pane not found in window"), result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["workspace.list", "pane.list"] ) @@ -6063,7 +6063,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let params = payload["params"] as? [String: Any] ?? [:] switch method { case "workspace.list": - XCTAssertEqual(params["window_id"] as? String, targetWindowId) + legacyAssertEqual(params["window_id"] as? String, targetWindowId) return self.v2Response( id: id, ok: true, @@ -6078,8 +6078,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "surface.list": - XCTAssertEqual(params["window_id"] as? String, targetWindowId) - XCTAssertEqual(params["workspace_id"] as? String, targetWorkspaceId) + legacyAssertEqual(params["window_id"] as? String, targetWindowId) + legacyAssertEqual(params["workspace_id"] as? String, targetWorkspaceId) return self.v2Response( id: id, ok: true, @@ -6110,11 +6110,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertNotEqual(result.status, 0) - XCTAssertTrue(result.stderr.contains("Surface not found in window"), result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertNotEqual(result.status, 0) + legacyAssertTrue(result.stderr.contains("Surface not found in window"), result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["workspace.list", "surface.list"] ) @@ -6172,11 +6172,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertNotEqual(result.status, 0) - XCTAssertTrue(result.stderr.contains("Window not found: window:2"), result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertNotEqual(result.status, 0) + legacyAssertTrue(result.stderr.contains("Window not found: window:2"), result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["window.list"] ) @@ -6220,11 +6220,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stderr.contains("Window not found: \(missingWindowId)"), result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stderr.contains("Window not found: \(missingWindowId)"), result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["window.list"] ) @@ -6293,11 +6293,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("OK vm-test-case-window"), result.stdout) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stdout.contains("OK vm-test-case-window"), result.stdout) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["window.list", "vm.create"] ) @@ -6342,7 +6342,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "system.identify": - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response( id: id, ok: true, @@ -6355,8 +6355,8 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "surface.read_text": - XCTAssertEqual(params["window_id"] as? String, windowId) - XCTAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["surface_id"] as? String, surfaceId) return self.v2Response(id: id, ok: true, result: ["text": "hello\n"]) default: return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected method: \(method)"]) @@ -6375,11 +6375,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "hello\nOK\n") - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "hello\nOK\n") + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["window.list", "system.identify", "surface.read_text"] ) @@ -6424,7 +6424,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.list": - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response( id: id, ok: true, @@ -6451,9 +6451,9 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "surface.read_text": - XCTAssertEqual(params["window_id"] as? String, windowId) - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertNil(params["surface_id"], line) + legacyAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertNil(params["surface_id"], line) return self.v2Response(id: id, ok: true, result: ["text": "workspace text\n"]) default: return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected method: \(method)"]) @@ -6472,11 +6472,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "workspace text\nOK\n") - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "workspace text\nOK\n") + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["window.list", "workspace.list", "surface.read_text"] ) @@ -6521,7 +6521,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "system.identify": - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response( id: id, ok: true, @@ -6534,9 +6534,9 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "surface.send_text": - XCTAssertEqual(params["window_id"] as? String, windowId) - XCTAssertEqual(params["surface_id"] as? String, surfaceId) - XCTAssertEqual(params["text"] as? String, "echo fresh\n") + legacyAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["surface_id"] as? String, surfaceId) + legacyAssertEqual(params["text"] as? String, "echo fresh\n") return self.v2Response(id: id, ok: true, result: ["surface_id": surfaceId]) default: return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected method: \(method)"]) @@ -6555,10 +6555,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["window.list", "system.identify", "surface.send_text"] ) @@ -6589,8 +6589,8 @@ final class CLINotifyProcessIntegrationRegressionTests { switch method { case "surface.list": - XCTAssertNil(params["window_id"]) - XCTAssertNil(params["workspace_id"]) + legacyAssertNil(params["window_id"]) + legacyAssertNil(params["workspace_id"]) return self.v2Response( id: id, ok: true, @@ -6605,9 +6605,9 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "surface.move": - XCTAssertEqual(params["surface_id"] as? String, sourceSurfaceId) - XCTAssertEqual(params["window_id"] as? String, targetWindowId) - XCTAssertEqual(params["workspace_id"] as? String, targetWorkspaceId) + legacyAssertEqual(params["surface_id"] as? String, sourceSurfaceId) + legacyAssertEqual(params["window_id"] as? String, targetWindowId) + legacyAssertEqual(params["workspace_id"] as? String, targetWorkspaceId) return self.v2Response( id: id, ok: true, @@ -6634,11 +6634,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["surface.list", "surface.move"] ) @@ -6669,8 +6669,8 @@ final class CLINotifyProcessIntegrationRegressionTests { guard method == "surface.move" else { return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected method: \(method)"]) } - XCTAssertEqual(params["surface_id"] as? String, sourceSurfaceRef) - XCTAssertEqual(params["window_id"] as? String, targetWindowId) + legacyAssertEqual(params["surface_id"] as? String, sourceSurfaceRef) + legacyAssertEqual(params["window_id"] as? String, targetWindowId) return self.v2Response( id: id, ok: true, @@ -6693,11 +6693,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["surface.move"] ) @@ -6725,13 +6725,13 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected method: \(method)"]) } let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceId]) } - XCTAssertTrue(line.hasPrefix("set_status build running"), line) - XCTAssertTrue(line.contains("--tab=\(workspaceId)"), line) - XCTAssertFalse(line.contains("--window"), line) + legacyAssertTrue(line.hasPrefix("set_status build running"), line) + legacyAssertTrue(line.contains("--tab=\(workspaceId)"), line) + legacyAssertFalse(line.contains("--window"), line) return "OK" } @@ -6747,11 +6747,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") + legacyAssertTrue(result.stderr.isEmpty, result.stderr) } @Test @@ -6776,11 +6776,11 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected method: \(method)"]) } let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceId]) } - XCTAssertEqual(line, "log --tab=\(workspaceId) -- --window target") + legacyAssertEqual(line, "log --tab=\(workspaceId) -- --window target") return "OK" } @@ -6796,11 +6796,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") + legacyAssertTrue(result.stderr.isEmpty, result.stderr) } @Test @@ -6826,7 +6826,7 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected method: \(method)"]) } let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response(id: id, ok: true, result: [:]) } @@ -6842,11 +6842,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stderr.contains("set-status: targeted window has no current workspace"), result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stderr.contains("set-status: targeted window has no current workspace"), result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["workspace.current"] ) @@ -6877,13 +6877,13 @@ final class CLINotifyProcessIntegrationRegressionTests { switch method { case "workspace.current": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceId]) case "notification.create": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["title"] as? String, "Window Notify") + legacyAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["title"] as? String, "Window Notify") return self.v2Response( id: id, ok: true, @@ -6906,11 +6906,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["workspace.current", "notification.create"] ) @@ -6937,8 +6937,8 @@ final class CLINotifyProcessIntegrationRegressionTests { guard let payload = self.jsonObject(line), let id = payload["id"] as? String, let method = payload["method"] as? String else { - XCTAssertTrue(line.hasPrefix("notify_target \(targetWorkspaceId) \(targetSurfaceId) "), line) - XCTAssertTrue(line.contains("Window Surface Notify"), line) + legacyAssertTrue(line.hasPrefix("notify_target \(targetWorkspaceId) \(targetSurfaceId) "), line) + legacyAssertTrue(line.contains("Window Surface Notify"), line) return "OK" } let params = payload["params"] as? [String: Any] ?? [:] @@ -6959,7 +6959,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.list": - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response( id: id, ok: true, @@ -6979,7 +6979,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "surface.list": - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) switch params["workspace_id"] as? String { case selectedWorkspaceId: return self.v2Response( @@ -7010,7 +7010,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) default: - XCTFail("Unexpected surface.list params: \(params)") + legacyFail("Unexpected surface.list params: \(params)") return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected workspace"]) } default: @@ -7030,12 +7030,12 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") let methods = state.commands.compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["window.list", "workspace.list", "surface.list", "surface.list"]) + legacyAssertEqual(methods, ["window.list", "workspace.list", "surface.list", "surface.list"]) } @Test @@ -7057,8 +7057,8 @@ final class CLINotifyProcessIntegrationRegressionTests { guard let payload = self.jsonObject(line), let id = payload["id"] as? String, let method = payload["method"] as? String else { - XCTAssertTrue(line.hasPrefix("notify_target \(selectedWorkspaceId) \(selectedSurfaceId) "), line) - XCTAssertTrue(line.contains("Window Indexed Notify"), line) + legacyAssertTrue(line.hasPrefix("notify_target \(selectedWorkspaceId) \(selectedSurfaceId) "), line) + legacyAssertTrue(line.contains("Window Indexed Notify"), line) return "OK" } let params = payload["params"] as? [String: Any] ?? [:] @@ -7079,11 +7079,11 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.current": - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response(id: id, ok: true, result: ["workspace_id": selectedWorkspaceId]) case "surface.list": - XCTAssertEqual(params["window_id"] as? String, windowId) - XCTAssertEqual(params["workspace_id"] as? String, selectedWorkspaceId) + legacyAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["workspace_id"] as? String, selectedWorkspaceId) return self.v2Response( id: id, ok: true, @@ -7114,12 +7114,12 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") let methods = state.commands.compactMap { self.jsonObject($0)?["method"] as? String } - XCTAssertEqual(methods, ["window.list", "workspace.current", "surface.list"]) + legacyAssertEqual(methods, ["window.list", "workspace.current", "surface.list"]) } @Test @@ -7146,13 +7146,13 @@ final class CLINotifyProcessIntegrationRegressionTests { switch method { case "workspace.current": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response(id: id, ok: true, result: ["workspace_id": workspaceId]) case "workspace.action": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) - XCTAssertEqual(params["workspace_id"] as? String, workspaceId) - XCTAssertEqual(params["action"] as? String, "pin") + legacyAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["workspace_id"] as? String, workspaceId) + legacyAssertEqual(params["action"] as? String, "pin") return self.v2Response( id: id, ok: true, @@ -7175,11 +7175,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["workspace.current", "workspace.action"] ) @@ -7208,7 +7208,7 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected method: \(method)"]) } let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response(id: id, ok: true, result: [:]) } @@ -7224,11 +7224,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stderr.contains("clear-notifications: targeted window has no current workspace"), result.stderr) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stderr.contains("clear-notifications: targeted window has no current workspace"), result.stderr) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["workspace.current"] ) @@ -7258,8 +7258,8 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "unexpected method: \(method)"]) } let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) - XCTAssertEqual(params["all_windows"] as? Bool, false) + legacyAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["all_windows"] as? Bool, false) return self.v2Response( id: id, ok: true, @@ -7283,10 +7283,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) } @Test @@ -7316,14 +7316,14 @@ final class CLINotifyProcessIntegrationRegressionTests { switch method { case "system.tree": - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response( id: id, ok: false, error: ["code": "method_not_found", "message": "system.tree"] ) case "system.identify": - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response( id: id, ok: true, @@ -7349,7 +7349,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "workspace.list": - XCTAssertEqual(params["window_id"] as? String, "window:2") + legacyAssertEqual(params["window_id"] as? String, "window:2") return self.v2Response( id: id, ok: true, @@ -7362,7 +7362,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "pane.list": - XCTAssertTrue([workspaceId, "workspace:1"].contains(params["workspace_id"] as? String)) + legacyAssertTrue([workspaceId, "workspace:1"].contains(params["workspace_id"] as? String)) return self.v2Response( id: id, ok: true, @@ -7373,7 +7373,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ] ) case "surface.list": - XCTAssertTrue([workspaceId, "workspace:1"].contains(params["workspace_id"] as? String)) + legacyAssertTrue([workspaceId, "workspace:1"].contains(params["workspace_id"] as? String)) return self.v2Response( id: id, ok: true, @@ -7408,16 +7408,16 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) let payload = try jsonPayload(from: result.stdout) - let windows = try XCTUnwrap(payload["windows"] as? [[String: Any]]) - XCTAssertEqual(windows.count, 1, result.stdout) - XCTAssertEqual(windows.first?["id"] as? String, windowId) - XCTAssertFalse(result.stdout.contains(otherWindowId), result.stdout) + let windows = try legacyUnwrap(payload["windows"] as? [[String: Any]]) + legacyAssertEqual(windows.count, 1, result.stdout) + legacyAssertEqual(windows.first?["id"] as? String, windowId) + legacyAssertFalse(result.stdout.contains(otherWindowId), result.stdout) } @Test @@ -7474,11 +7474,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") - XCTAssertFalse( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") + legacyAssertFalse( state.commands.contains { $0.contains("set_status codex Running") || $0.contains("notify_target_async") }, "Foreign cmux env must not mutate the selected workspace, saw \(state.commands)" ) @@ -7561,11 +7561,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") - XCTAssertFalse( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") + legacyAssertFalse( state.commands.contains { $0.contains("set_status codex Running") || $0.contains("notify_target_async") }, "Foreign cmux env must not reuse stale mapped sessions, saw \(state.commands)" ) @@ -7629,11 +7629,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") - XCTAssertFalse( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") + legacyAssertFalse( state.commands.contains { $0.contains("set_status codex Running") || $0.contains("notify_target_async") }, "Invalid surface must not fall back to the focused surface, saw \(state.commands)" ) @@ -7716,11 +7716,11 @@ final class CLINotifyProcessIntegrationRegressionTests { Darwin.close(listenerFD) listenerClosed = true - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") - XCTAssertFalse( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") + legacyAssertFalse( state.commands.contains { $0.contains("set_status codex Running") || $0.contains("notify_target_async") }, "Invalid mapped workspace must not mutate the selected workspace, saw \(state.commands)" ) @@ -7847,9 +7847,9 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let resumeBindingRequests = state.commands.compactMap { command -> [String: Any]? in guard let payload = jsonObject(command), @@ -7858,11 +7858,11 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - XCTAssertEqual(resumeBindingRequests.count, 1, state.commands.joined(separator: "\n")) - let request = try XCTUnwrap(resumeBindingRequests.first) - XCTAssertEqual(request["checkpoint_id"] as? String, sessionId) - XCTAssertEqual(request["auto_resume"] as? Bool, true) - XCTAssertEqual( + legacyAssertEqual(resumeBindingRequests.count, 1, state.commands.joined(separator: "\n")) + let request = try legacyUnwrap(resumeBindingRequests.first) + legacyAssertEqual(request["checkpoint_id"] as? String, sessionId) + legacyAssertEqual(request["auto_resume"] as? Bool, true) + legacyAssertEqual( request["command"] as? String, "{ cd -- '\(root.path)' 2>/dev/null || [ ! -d '\(root.path)' ]; } && '/usr/local/bin/cmux' 'codex-teams' 'resume' '\(sessionId)' '--model' 'gpt-5.4' '--sandbox' 'danger-full-access'" ) @@ -7929,7 +7929,7 @@ final class CLINotifyProcessIntegrationRegressionTests { case "surface.resume.clear": return self.v2Response(id: id, ok: true, result: ["cleared": true]) case "surface.resume.set": - XCTFail("Non-resumable launcher should not publish a resume binding") + legacyFail("Non-resumable launcher should not publish a resume binding") return self.v2Response(id: id, ok: true, result: ["ok": true]) case "feed.push": return self.v2Response(id: id, ok: true, result: [:]) @@ -7957,9 +7957,9 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let clearRequests = state.commands.compactMap { command -> [String: Any]? in guard let payload = jsonObject(command), @@ -7968,11 +7968,11 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - let request = try XCTUnwrap(clearRequests.first) - XCTAssertNil(request["workspace_id"]) - XCTAssertEqual(request["surface_id"] as? String, surfaceId) - XCTAssertEqual(request["source"] as? String, "agent-hook") - XCTAssertEqual(request["checkpoint_id"] as? String, sessionId) + let request = try legacyUnwrap(clearRequests.first) + legacyAssertNil(request["workspace_id"]) + legacyAssertEqual(request["surface_id"] as? String, surfaceId) + legacyAssertEqual(request["source"] as? String, "agent-hook") + legacyAssertEqual(request["checkpoint_id"] as? String, sessionId) } @Test @@ -8042,9 +8042,9 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let clearRequests = state.commands.compactMap { command -> [String: Any]? in guard let payload = jsonObject(command), payload["method"] as? String == "surface.resume.clear" else { @@ -8052,11 +8052,11 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - let request = try XCTUnwrap(clearRequests.first) - XCTAssertNil(request["workspace_id"]) - XCTAssertEqual(request["surface_id"] as? String, surfaceId) - XCTAssertEqual(request["checkpoint_id"] as? String, sessionId) - XCTAssertEqual(request["source"] as? String, "agent-hook") + let request = try legacyUnwrap(clearRequests.first) + legacyAssertNil(request["workspace_id"]) + legacyAssertEqual(request["surface_id"] as? String, surfaceId) + legacyAssertEqual(request["checkpoint_id"] as? String, sessionId) + legacyAssertEqual(request["source"] as? String, "agent-hook") } @Test @@ -8078,7 +8078,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "surface.resume.clear") + legacyAssertEqual(method, "surface.resume.clear") return self.v2Response(id: id, ok: true, result: ["cleared": false]) } @@ -8100,10 +8100,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") let clearRequests = state.commands.compactMap { command -> [String: Any]? in guard let payload = jsonObject(command), @@ -8112,11 +8112,11 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - let request = try XCTUnwrap(clearRequests.first) - XCTAssertEqual(request["workspace_id"] as? String, workspaceId) - XCTAssertEqual(request["surface_id"] as? String, surfaceId) - XCTAssertEqual(request["checkpoint_id"] as? String, "new-session") - XCTAssertEqual(request["source"] as? String, "agent-hook") + let request = try legacyUnwrap(clearRequests.first) + legacyAssertEqual(request["workspace_id"] as? String, workspaceId) + legacyAssertEqual(request["surface_id"] as? String, surfaceId) + legacyAssertEqual(request["checkpoint_id"] as? String, "new-session") + legacyAssertEqual(request["source"] as? String, "agent-hook") } @Test @@ -8138,7 +8138,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "surface.resume.set") + legacyAssertEqual(method, "surface.resume.set") return self.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) } @@ -8159,10 +8159,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") let setRequests = state.commands.compactMap { command -> [String: Any]? in guard let payload = jsonObject(command), @@ -8171,12 +8171,12 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - XCTAssertEqual(setRequests.count, 1) - let request = try XCTUnwrap(setRequests.first) - XCTAssertEqual(request["workspace_id"] as? String, workspaceId) - XCTAssertEqual(request["surface_id"] as? String, surfaceId) - XCTAssertEqual(request["kind"] as? String, "tmux") - XCTAssertEqual(request["command"] as? String, "tmux attach -t work") + legacyAssertEqual(setRequests.count, 1) + let request = try legacyUnwrap(setRequests.first) + legacyAssertEqual(request["workspace_id"] as? String, workspaceId) + legacyAssertEqual(request["surface_id"] as? String, surfaceId) + legacyAssertEqual(request["kind"] as? String, "tmux") + legacyAssertEqual(request["command"] as? String, "tmux attach -t work") } @Test @@ -8198,7 +8198,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "surface.resume.set") + legacyAssertEqual(method, "surface.resume.set") return self.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) } @@ -8223,10 +8223,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") let setRequests = state.commands.compactMap { command -> [String: Any]? in guard let payload = jsonObject(command), @@ -8235,12 +8235,12 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - let request = try XCTUnwrap(setRequests.first) - XCTAssertEqual(request["workspace_id"] as? String, workspaceId) - XCTAssertEqual(request["surface_id"] as? String, surfaceId) - XCTAssertNil(request["name"]) - XCTAssertNil(request["kind"]) - XCTAssertEqual( + let request = try legacyUnwrap(setRequests.first) + legacyAssertEqual(request["workspace_id"] as? String, workspaceId) + legacyAssertEqual(request["surface_id"] as? String, surfaceId) + legacyAssertNil(request["name"]) + legacyAssertNil(request["kind"]) + legacyAssertEqual( request["command"] as? String, "'myapp' '--name' 'foo' '--kind' 'bar' '--cwd' '/tmp/ignored' '--surface' 'not-a-target'" ) @@ -8265,7 +8265,7 @@ final class CLINotifyProcessIntegrationRegressionTests { let method = payload["method"] as? String else { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "surface.resume.set") + legacyAssertEqual(method, "surface.resume.set") return self.v2Response(id: id, ok: true, result: ["resume_binding": [:]]) } @@ -8286,9 +8286,9 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let setRequests = state.commands.compactMap { command -> [String: Any]? in guard let payload = jsonObject(command), @@ -8297,9 +8297,9 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - let request = try XCTUnwrap(setRequests.first) - XCTAssertNil(request["workspace_id"]) - XCTAssertEqual(request["surface_id"] as? String, movedSurfaceId) + let request = try legacyUnwrap(setRequests.first) + legacyAssertNil(request["workspace_id"]) + legacyAssertEqual(request["surface_id"] as? String, movedSurfaceId) } @Test @@ -8326,10 +8326,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertNotEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.contains("surface resume set: unexpected argument 'attach' after --shell")) - XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertNotEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.contains("surface resume set: unexpected argument 'attach' after --shell")) + legacyAssertFalse(result.stderr.contains("Socket"), result.stderr) } @Test @@ -8354,10 +8354,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stderr.contains("surface resume set: unexpected argument 'myapp' before --")) - XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stderr.contains("surface resume set: unexpected argument 'myapp' before --")) + legacyAssertFalse(result.stderr.contains("Socket"), result.stderr) } @Test @@ -8407,11 +8407,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stdout.isEmpty, result.stdout) - XCTAssertTrue(result.stderr.contains(item.expected), result.stderr) - XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stdout.isEmpty, result.stdout) + legacyAssertTrue(result.stderr.contains(item.expected), result.stderr) + legacyAssertFalse(result.stderr.contains("Socket"), result.stderr) } } @@ -8436,10 +8436,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertNotEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.contains("surface resume clear: --checkpoint requires a value")) - XCTAssertFalse(result.stderr.contains("Socket"), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertNotEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.contains("surface resume clear: --checkpoint requires a value")) + legacyAssertFalse(result.stderr.contains("Socket"), result.stderr) } @Test @@ -8473,7 +8473,7 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.v2Response(id: id, ok: true, result: ["window_id": windowId]) case "surface.list": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response( id: id, ok: true, @@ -8497,10 +8497,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") let clearRequests = state.commands.compactMap { command -> [String: Any]? in guard let payload = jsonObject(command), @@ -8509,16 +8509,16 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - XCTAssertFalse( + legacyAssertFalse( state.commands.contains { command in jsonObject(command)?["method"] as? String == "window.focus" }, "surface resume metadata commands should route by window_id without focusing the window" ) - let request = try XCTUnwrap(clearRequests.first) - XCTAssertEqual(request["window_id"] as? String, windowId) - XCTAssertNotEqual(request["window_id"] as? String, "0") - XCTAssertEqual(request["surface_id"] as? String, surfaceId) + let request = try legacyUnwrap(clearRequests.first) + legacyAssertEqual(request["window_id"] as? String, windowId) + legacyAssertNotEqual(request["window_id"] as? String, "0") + legacyAssertEqual(request["surface_id"] as? String, surfaceId) } @Test @@ -8549,7 +8549,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ) case "surface.list": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowId) + legacyAssertEqual(params["window_id"] as? String, windowId) return self.v2Response( id: id, ok: true, @@ -8573,11 +8573,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertFalse( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") + legacyAssertFalse( state.commands.contains { command in jsonObject(command)?["method"] as? String == "window.focus" }, @@ -8591,9 +8591,9 @@ final class CLINotifyProcessIntegrationRegressionTests { } return payload["params"] as? [String: Any] } - let request = try XCTUnwrap(clearRequests.first) - XCTAssertEqual(request["window_id"] as? String, windowId) - XCTAssertEqual(request["surface_id"] as? String, surfaceId) + let request = try legacyUnwrap(clearRequests.first) + legacyAssertEqual(request["window_id"] as? String, windowId) + legacyAssertEqual(request["surface_id"] as? String, surfaceId) } private struct ClaudeHookContext { @@ -8823,15 +8823,15 @@ final class CLINotifyProcessIntegrationRegressionTests { standardInput: standardInput, timeout: 5 ) - wait(for: [serverHandled], timeout: 5) + legacyWait(for: [serverHandled], timeout: 5) return result } private func readClaudeHookSession(_ sessionId: String, context: ClaudeHookContext) throws -> [String: Any] { let stateURL = context.root.appendingPathComponent("claude-hook-sessions.json") - let state = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) - let sessions = try XCTUnwrap(state["sessions"] as? [String: Any]) - return try XCTUnwrap(sessions[sessionId] as? [String: Any]) + let state = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let sessions = try legacyUnwrap(state["sessions"] as? [String: Any]) + return try legacyUnwrap(sessions[sessionId] as? [String: Any]) } private func feedPushEvents(in context: ClaudeHookContext) -> [[String: Any]] { @@ -8865,7 +8865,7 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "browser.import.cookies") + legacyAssertEqual(method, "browser.import.cookies") guard method == "browser.import.cookies" else { return self.v2Response( id: id, @@ -8874,11 +8874,11 @@ final class CLINotifyProcessIntegrationRegressionTests { ) } let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["scope"] as? String, "cookiesOnly") - XCTAssertEqual(params["browser"] as? String, "Chrome") - XCTAssertEqual(params["source_profiles"] as? [String], ["Default"]) - XCTAssertEqual(params["domain_filters"] as? [String], ["github.com"]) - XCTAssertEqual(params["destination_profile"] as? String, "Dev") + legacyAssertEqual(params["scope"] as? String, "cookiesOnly") + legacyAssertEqual(params["browser"] as? String, "Chrome") + legacyAssertEqual(params["source_profiles"] as? [String], ["Default"]) + legacyAssertEqual(params["domain_filters"] as? [String], ["github.com"]) + legacyAssertEqual(params["destination_profile"] as? String, "Dev") return self.v2Response( id: id, ok: true, @@ -8915,16 +8915,16 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) - let stdoutJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any]) - XCTAssertEqual(stdoutJSON["browser"] as? String, "Chrome") - XCTAssertEqual(stdoutJSON["imported_cookies"] as? Int, 3) - XCTAssertEqual(stdoutJSON["skipped_cookies"] as? Int, 1) - XCTAssertTrue( + let stdoutJSON = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any]) + legacyAssertEqual(stdoutJSON["browser"] as? String, "Chrome") + legacyAssertEqual(stdoutJSON["imported_cookies"] as? Int, 3) + legacyAssertEqual(stdoutJSON["skipped_cookies"] as? Int, 1) + legacyAssertTrue( state.commands.contains { $0.contains(#""method":"browser.import.cookies""#) }, "Expected coding-agent import to use non-interactive import, saw \(state.commands)" ) @@ -8949,7 +8949,7 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "browser.import.dialog") + legacyAssertEqual(method, "browser.import.dialog") guard method == "browser.import.dialog" else { return self.v2Response( id: id, @@ -8958,7 +8958,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ) } let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertNil(params["scope"]) + legacyAssertNil(params["scope"]) return self.v2Response(id: id, ok: true, result: ["opened": true]) } @@ -8986,12 +8986,12 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertTrue( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") + legacyAssertTrue(result.stderr.isEmpty, result.stderr) + legacyAssertTrue( state.commands.contains { $0.contains(#""method":"browser.import.dialog""#) }, "Expected human import to open the interactive dialog, saw \(state.commands)" ) @@ -9016,7 +9016,7 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "browser.import.dialog") + legacyAssertEqual(method, "browser.import.dialog") guard method == "browser.import.dialog" else { return self.v2Response( id: id, @@ -9025,7 +9025,7 @@ final class CLINotifyProcessIntegrationRegressionTests { ) } let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertNil(params["scope"]) + legacyAssertNil(params["scope"]) return self.v2Response(id: id, ok: true, result: ["opened": true]) } @@ -9041,12 +9041,12 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) - XCTAssertTrue( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK\n") + legacyAssertTrue(result.stderr.isEmpty, result.stderr) + legacyAssertTrue( state.commands.contains { $0.contains(#""method":"browser.import.dialog""#) }, "Expected --interactive to force the dialog in coding-agent env, saw \(state.commands)" ) @@ -9071,7 +9071,7 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, "browser.profiles.list") + legacyAssertEqual(method, "browser.profiles.list") return self.v2Response( id: id, ok: true, @@ -9099,11 +9099,11 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("default\tDefault\t52B43C05-4A1D-45D3-8FD5-9EF94952E445"), result.stdout) - XCTAssertTrue( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stdout.contains("default\tDefault\t52B43C05-4A1D-45D3-8FD5-9EF94952E445"), result.stdout) + legacyAssertTrue( state.commands.contains { $0.contains(#""method":"browser.profiles.list""#) }, "Expected browser profiles list to call browser.profiles.list, saw \(state.commands)" ) @@ -9178,9 +9178,9 @@ final class CLINotifyProcessIntegrationRegressionTests { return self.malformedRequestResponse(raw: line) } - XCTAssertEqual(method, testCase.expectedMethod) + legacyAssertEqual(method, testCase.expectedMethod) for expectedParam in testCase.expectedParams { - XCTAssertTrue(line.contains(expectedParam), line) + legacyAssertTrue(line.contains(expectedParam), line) } return self.v2Response(id: id, ok: true, result: testCase.responseResult) } @@ -9196,10 +9196,10 @@ final class CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue( state.commands.contains { $0.contains(#""method":"\#(testCase.expectedMethod)""#) }, "Expected \(testCase.expectedMethod), saw \(state.commands)" ) @@ -9310,10 +9310,10 @@ final class CLINotifyProcessIntegrationRegressionTests { let sawConfigureRequest = waitForMockSocketCommand(in: state) { line in line.contains(#""method":"workspace.remote.configure""#) } - XCTAssertTrue(sawConfigureRequest, "Expected workspace.remote.configure, saw \(state.snapshot())", file: file, line: line) - XCTAssertFalse(result.timedOut, result.stderr, file: file, line: line) - XCTAssertEqual(result.status, 0, result.stderr, file: file, line: line) - XCTAssertTrue(result.stderr.isEmpty, result.stderr, file: file, line: line) + legacyAssertTrue(sawConfigureRequest, "Expected workspace.remote.configure, saw \(state.snapshot())", file: file, line: line) + legacyAssertFalse(result.timedOut, result.stderr, file: file, line: line) + legacyAssertEqual(result.status, 0, result.stderr, file: file, line: line) + legacyAssertTrue(result.stderr.isEmpty, result.stderr, file: file, line: line) let requests = state.snapshot().compactMap { jsonObject($0) } return MockedSSHRun( @@ -9346,7 +9346,7 @@ final class CLINotifyProcessIntegrationRegressionTests { at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) - XCTAssertTrue( + legacyAssertTrue( FileManager.default.createFile(atPath: url.path, contents: Data()), "Expected to create \(url.path)" ) @@ -9386,7 +9386,7 @@ final class CLINotifyProcessIntegrationRegressionTests { private func notificationRows(from stdout: String) throws -> [[String: Any]] { let data = Data(stdout.utf8) - return try XCTUnwrap( + return try legacyUnwrap( JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]], "Expected notification JSON array, got: \(stdout)" ) @@ -9394,7 +9394,7 @@ final class CLINotifyProcessIntegrationRegressionTests { private func jsonPayload(from stdout: String) throws -> [String: Any] { let data = Data(stdout.utf8) - return try XCTUnwrap( + return try legacyUnwrap( JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], "Expected JSON object, got: \(stdout)" ) @@ -9461,20 +9461,20 @@ extension CLINotifyProcessIntegrationRegressionTests { ], timeout: 30 ) - wait(for: [handled], timeout: 30) + legacyWait(for: [handled], timeout: 30) - XCTAssertTrue( + legacyAssertTrue( result.stdout.contains("CMUX_SURFACE_ID=\(launchSurface)"), "launcher must stamp the LAUNCH surface; stdout:\n\(result.stdout)\nstderr:\n\(result.stderr)" ) - XCTAssertFalse( + legacyAssertFalse( result.stdout.contains("CMUX_SURFACE_ID=\(focusedSurface)"), "launcher must NOT stamp the focused surface; stdout:\n\(result.stdout)" ) - XCTAssertTrue(result.stdout.contains("CMUX_WORKSPACE_ID=\(launchWorkspace)"), result.stdout) + legacyAssertTrue(result.stdout.contains("CMUX_WORKSPACE_ID=\(launchWorkspace)"), result.stdout) // Matched-pair invariant: SURFACE == PANEL (the desync is exactly the bug). The surface-scoped // tab id passes through untouched. - XCTAssertTrue(result.stdout.contains("CMUX_PANEL_ID=\(launchSurface)"), result.stdout) - XCTAssertTrue(result.stdout.contains("CMUX_TAB_ID=\(launchTab)"), result.stdout) + legacyAssertTrue(result.stdout.contains("CMUX_PANEL_ID=\(launchSurface)"), result.stdout) + legacyAssertTrue(result.stdout.contains("CMUX_TAB_ID=\(launchTab)"), result.stdout) } } diff --git a/cmuxTests/CLINotifyProcessTestSupport.swift b/cmuxTests/CLINotifyProcessTestSupport.swift index e7d583e91503..146689e83131 100644 --- a/cmuxTests/CLINotifyProcessTestSupport.swift +++ b/cmuxTests/CLINotifyProcessTestSupport.swift @@ -2,275 +2,277 @@ import Darwin import Foundation import Testing -extension CLINotifyProcessIntegrationRegressionTests { - final class LegacyExpectation: @unchecked Sendable { - private let lock = NSLock() - private let semaphore = DispatchSemaphore(value: 0) - private var fulfilled = false - private let description: String +typealias LegacyExpectation = CLINotifyProcessIntegrationRegressionTests.LegacyExpectation - init(description: String) { - self.description = description - } +func legacyExpectation(description: String) -> LegacyExpectation { + LegacyExpectation(description: description) +} - func fulfill() { - lock.lock() - let shouldSignal = !fulfilled - fulfilled = true - lock.unlock() +func legacyWait( + for expectations: [LegacyExpectation], + timeout: TimeInterval, + file: StaticString = #filePath, + line: UInt = #line +) { + _ = legacyWaitForExpectations( + expectations, + timeout: timeout, + recordFailure: true, + file: file, + line: line + ) +} - if shouldSignal { - semaphore.signal() +@discardableResult +func legacyWaitForExpectations( + _ expectations: [LegacyExpectation], + timeout: TimeInterval, + recordFailure: Bool, + file _: StaticString = #filePath, + line _: UInt = #line +) -> Bool { + var allFulfilled = true + for expectation in expectations { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if expectation.pollFulfilled() { + break } + _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) } - func pollFulfilled() -> Bool { - lock.lock() - if fulfilled { - lock.unlock() - return true + if !expectation.pollFulfilled() { + allFulfilled = false + if recordFailure { + legacyFailure(expectation.timeoutMessage) } - lock.unlock() - - guard semaphore.wait(timeout: .now()) == .success else { - return false - } - - lock.lock() - fulfilled = true - lock.unlock() - return true - } - - var timeoutMessage: String { - "Timed out waiting for expectation: \(description)" } } + return allFulfilled +} - func expectation(description: String) -> LegacyExpectation { - LegacyExpectation(description: description) - } +func runLegacyActivity(named _: String, _ body: () throws -> T) rethrows -> T { + try body() +} - func wait( - for expectations: [LegacyExpectation], - timeout: TimeInterval, - file: StaticString = #filePath, - line: UInt = #line - ) { - _ = waitForLegacyExpectations( - expectations, - timeout: timeout, - recordFailure: true, - file: file, - line: line - ) - } +func legacyFailure(_ message: String) { + Issue.record(Comment(rawValue: message.isEmpty ? "Assertion failed" : message)) +} - @discardableResult - func waitForLegacyExpectations( - _ expectations: [LegacyExpectation], - timeout: TimeInterval, - recordFailure: Bool, - file _: StaticString = #filePath, - line _: UInt = #line - ) -> Bool { - var allFulfilled = true - for expectation in expectations { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if expectation.pollFulfilled() { - break - } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } +func legacyFail( + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) { + legacyFailure(message()) +} - if !expectation.pollFulfilled() { - allFulfilled = false - if recordFailure { - legacyFailure(expectation.timeoutMessage) - } - } +func legacyAssertTrue( + _ expression: @autoclosure () throws -> Bool, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) { + do { + if try !expression() { + legacyFailure(message()) } - return allFulfilled + } catch { + legacyFailure("\(message()) \(error)") } +} - func runLegacyActivity(named _: String, _ body: () throws -> T) rethrows -> T { - try body() +func legacyAssertFalse( + _ expression: @autoclosure () throws -> Bool, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) { + do { + if try expression() { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") } +} - func legacyFailure(_ message: String) { - Issue.record(Comment(rawValue: message.isEmpty ? "Assertion failed" : message)) +func legacyAssertEqual( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs != rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") } +} - func XCTFail( - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) { - legacyFailure(message()) +func legacyAssertNotEqual( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs == rhs { + legacyFailure(message()) + } + } catch { + legacyFailure("\(message()) \(error)") } +} - func XCTAssertTrue( - _ expression: @autoclosure () throws -> Bool, - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) { - do { - if try !expression() { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") +func legacyAssertGreaterThan( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs <= rhs { + legacyFailure(message()) } + } catch { + legacyFailure("\(message()) \(error)") } +} - func XCTAssertFalse( - _ expression: @autoclosure () throws -> Bool, - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) { - do { - if try expression() { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") +func legacyAssertGreaterThanOrEqual( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs < rhs { + legacyFailure(message()) } + } catch { + legacyFailure("\(message()) \(error)") } +} - func XCTAssertEqual( - _ expression1: @autoclosure () throws -> T, - _ expression2: @autoclosure () throws -> T, - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) { - do { - let lhs = try expression1() - let rhs = try expression2() - if lhs != rhs { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") +func legacyAssertLessThan( + _ expression1: @autoclosure () throws -> T, + _ expression2: @autoclosure () throws -> T, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) { + do { + let lhs = try expression1() + let rhs = try expression2() + if lhs >= rhs { + legacyFailure(message()) } + } catch { + legacyFailure("\(message()) \(error)") } +} - func XCTAssertNotEqual( - _ expression1: @autoclosure () throws -> T, - _ expression2: @autoclosure () throws -> T, - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) { - do { - let lhs = try expression1() - let rhs = try expression2() - if lhs == rhs { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") +func legacyAssertNil( + _ expression: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) { + do { + if try expression() != nil { + legacyFailure(message()) } + } catch { + legacyFailure("\(message()) \(error)") } +} - func XCTAssertGreaterThan( - _ expression1: @autoclosure () throws -> T, - _ expression2: @autoclosure () throws -> T, - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) { - do { - let lhs = try expression1() - let rhs = try expression2() - if lhs <= rhs { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") +func legacyAssertNotNil( + _ expression: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) { + do { + if try expression() == nil { + legacyFailure(message()) } + } catch { + legacyFailure("\(message()) \(error)") } +} - func XCTAssertGreaterThanOrEqual( - _ expression1: @autoclosure () throws -> T, - _ expression2: @autoclosure () throws -> T, - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) { - do { - let lhs = try expression1() - let rhs = try expression2() - if lhs < rhs { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") - } +func legacyUnwrap( + _ expression: @autoclosure () throws -> T?, + _ message: @autoclosure () -> String = "", + file _: StaticString = #filePath, + line _: UInt = #line +) throws -> T { + guard let value = try expression() else { + legacyFailure(message()) + throw NSError(domain: "CLINotifyProcessIntegrationRegressionTests", code: 1) } + return value +} - func XCTAssertLessThan( - _ expression1: @autoclosure () throws -> T, - _ expression2: @autoclosure () throws -> T, - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) { - do { - let lhs = try expression1() - let rhs = try expression2() - if lhs >= rhs { - legacyFailure(message()) - } - } catch { - legacyFailure("\(message()) \(error)") +extension CLINotifyProcessIntegrationRegressionTests { + final class LegacyExpectation: @unchecked Sendable { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var fulfilled = false + private let description: String + + init(description: String) { + self.description = description } - } - func XCTAssertNil( - _ expression: @autoclosure () throws -> T?, - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) { - do { - if try expression() != nil { - legacyFailure(message()) + func fulfill() { + lock.lock() + let shouldSignal = !fulfilled + fulfilled = true + lock.unlock() + + if shouldSignal { + semaphore.signal() } - } catch { - legacyFailure("\(message()) \(error)") } - } - func XCTAssertNotNil( - _ expression: @autoclosure () throws -> T?, - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) { - do { - if try expression() == nil { - legacyFailure(message()) + func pollFulfilled() -> Bool { + lock.lock() + if fulfilled { + lock.unlock() + return true } - } catch { - legacyFailure("\(message()) \(error)") + lock.unlock() + + guard semaphore.wait(timeout: .now()) == .success else { + return false + } + + lock.lock() + fulfilled = true + lock.unlock() + return true } - } - func XCTUnwrap( - _ expression: @autoclosure () throws -> T?, - _ message: @autoclosure () -> String = "", - file _: StaticString = #filePath, - line _: UInt = #line - ) throws -> T { - guard let value = try expression() else { - legacyFailure(message()) - throw NSError(domain: "CLINotifyProcessIntegrationRegressionTests", code: 1) + var timeoutMessage: String { + "Timed out waiting for expectation: \(description)" } - return value } struct ProcessRunResult { @@ -317,13 +319,13 @@ extension CLINotifyProcessIntegrationRegressionTests { func bindUnixSocket(at path: String) throws -> Int32 { unlink(path) let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) - XCTAssertGreaterThanOrEqual(fd, 0) + legacyAssertGreaterThanOrEqual(fd, 0) var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) let utf8 = Array(path.utf8) - XCTAssertLessThan(utf8.count, maxPathLength) + legacyAssertLessThan(utf8.count, maxPathLength) _ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in for index in 0...size)) } } - XCTAssertEqual(bindResult, 0) - XCTAssertEqual(Darwin.listen(fd, 1), 0) + legacyAssertEqual(bindResult, 0) + legacyAssertEqual(Darwin.listen(fd, 1), 0) return fd } @@ -407,7 +409,7 @@ extension CLINotifyProcessIntegrationRegressionTests { } func startBridgeErrorServer(listenerFD: Int32, message: String) -> LegacyExpectation { - let handled = expectation(description: "pty bridge error server handled") + let handled = legacyExpectation(description: "pty bridge error server handled") DispatchQueue.global(qos: .userInitiated).async { defer { handled.fulfill() } @@ -457,7 +459,7 @@ extension CLINotifyProcessIntegrationRegressionTests { } func startBridgeReadyThenCloseServer(listenerFD: Int32) -> LegacyExpectation { - let handled = expectation(description: "pty bridge ready close server handled") + let handled = legacyExpectation(description: "pty bridge ready close server handled") DispatchQueue.global(qos: .userInitiated).async { defer { handled.fulfill() } @@ -507,7 +509,7 @@ extension CLINotifyProcessIntegrationRegressionTests { } func startBridgeReadyThenResetAfterClientEOFServer(listenerFD: Int32) -> LegacyExpectation { - let handled = expectation(description: "pty bridge ready reset server handled") + let handled = legacyExpectation(description: "pty bridge ready reset server handled") DispatchQueue.global(qos: .userInitiated).async { defer { handled.fulfill() } @@ -600,7 +602,7 @@ extension CLINotifyProcessIntegrationRegressionTests { fulfillWhen: (@Sendable (String) -> Bool)? = nil, handler: @escaping @Sendable (String) -> String? ) -> LegacyExpectation { - let handled = expectation(description: "cli mock socket handled") + let handled = legacyExpectation(description: "cli mock socket handled") DispatchQueue.global(qos: .userInitiated).async { var didFulfill = false func fulfillOnce() { diff --git a/cmuxTests/CLIRovoDevHookPersistenceTests.swift b/cmuxTests/CLIRovoDevHookPersistenceTests.swift index 343f420a8c6f..17ded1ec1638 100644 --- a/cmuxTests/CLIRovoDevHookPersistenceTests.swift +++ b/cmuxTests/CLIRovoDevHookPersistenceTests.swift @@ -71,27 +71,27 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") let storeURL = root.appendingPathComponent("rovodev-hook-sessions.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - let session = try XCTUnwrap(sessions[sessionId] as? [String: Any]) - XCTAssertEqual(session["workspaceId"] as? String, workspaceId) - XCTAssertEqual(session["surfaceId"] as? String, surfaceId) - XCTAssertEqual(session["cwd"] as? String, workspace.path) - - let launchCommand = try XCTUnwrap(session["launchCommand"] as? [String: Any]) - XCTAssertEqual(launchCommand["launcher"] as? String, "rovodev") - XCTAssertEqual(launchCommand["executablePath"] as? String, "/usr/local/bin/acli") - XCTAssertEqual( + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + let session = try legacyUnwrap(sessions[sessionId] as? [String: Any]) + legacyAssertEqual(session["workspaceId"] as? String, workspaceId) + legacyAssertEqual(session["surfaceId"] as? String, surfaceId) + legacyAssertEqual(session["cwd"] as? String, workspace.path) + + let launchCommand = try legacyUnwrap(session["launchCommand"] as? [String: Any]) + legacyAssertEqual(launchCommand["launcher"] as? String, "rovodev") + legacyAssertEqual(launchCommand["executablePath"] as? String, "/usr/local/bin/acli") + legacyAssertEqual( launchCommand["arguments"] as? [String], ["/usr/local/bin/acli", "rovodev", "run", "--yolo"] ) - XCTAssertTrue( + legacyAssertTrue( state.commands.contains { $0.contains("set_status rovodev Running") && $0.contains("--tab=\(workspaceId)") }, "Expected Rovo Dev prompt status to target current workspace, saw \(state.commands)" ) @@ -172,16 +172,16 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") let storeURL = root.appendingPathComponent("rovodev-hook-sessions.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - XCTAssertNil(sessions["rovo-older-session"] as? [String: Any]) - XCTAssertNotNil(sessions["rovo-newer-session"] as? [String: Any]) + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + legacyAssertNil(sessions["rovo-older-session"] as? [String: Any]) + legacyAssertNotNil(sessions["rovo-newer-session"] as? [String: Any]) } @Test func testRovoDevPromptSubmitReadsConfiguredPersistenceDirWithCommentsHashAndApostrophePath() throws { @@ -270,15 +270,15 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") let storeURL = root.appendingPathComponent("rovodev-hook-sessions.json", isDirectory: false) - let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) - let sessions = try XCTUnwrap(json["sessions"] as? [String: Any]) - XCTAssertNotNil(sessions[sessionId] as? [String: Any]) + let json = try legacyUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: storeURL)) as? [String: Any]) + let sessions = try legacyUnwrap(json["sessions"] as? [String: Any]) + legacyAssertNotNil(sessions[sessionId] as? [String: Any]) } @Test func testRovoDevPromptSubmitWithoutCwdDoesNotInferUnrelatedSession() throws { @@ -340,16 +340,16 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "{}\n") + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "{}\n") let storeURL = root.appendingPathComponent("rovodev-hook-sessions.json", isDirectory: false) if let data = try? Data(contentsOf: storeURL), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let sessions = json["sessions"] as? [String: Any] { - XCTAssertNil(sessions[unrelatedSessionId] as? [String: Any]) + legacyAssertNil(sessions[unrelatedSessionId] as? [String: Any]) } } @Test @@ -377,10 +377,10 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertNotEqual(result.status, 0) - XCTAssertTrue(result.stderr.contains("could not be read"), result.stderr) - XCTAssertTrue(FileManager.default.fileExists(atPath: sentinelURL.path)) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertNotEqual(result.status, 0) + legacyAssertTrue(result.stderr.contains("could not be read"), result.stderr) + legacyAssertTrue(FileManager.default.fileExists(atPath: sentinelURL.path)) } @Test func testRovoAliasCreatesConfigDirAndInstallsRovoDevHooksFromSetup() throws { @@ -407,17 +407,17 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("rovodev:"), result.stdout) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stdout.contains("rovodev:"), result.stdout) let config = try String( contentsOf: configDir.appendingPathComponent("config.yml", isDirectory: false), encoding: .utf8 ) - XCTAssertTrue(config.contains("eventHooks:"), config) - XCTAssertTrue(config.contains(#"hooks rovodev prompt-submit"#), config) - XCTAssertTrue(config.contains(#"CMUX_BUNDLED_CLI_PATH"#), config) + legacyAssertTrue(config.contains("eventHooks:"), config) + legacyAssertTrue(config.contains(#"hooks rovodev prompt-submit"#), config) + legacyAssertTrue(config.contains(#"CMUX_BUNDLED_CLI_PATH"#), config) } @Test func testSetupHooksRejectsConflictingAgentFilters() throws { @@ -438,9 +438,9 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertNotEqual(result.status, 0) - XCTAssertTrue(result.stderr.contains("Conflicting hooks target"), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertNotEqual(result.status, 0) + legacyAssertTrue(result.stderr.contains("Conflicting hooks target"), result.stderr) } @Test func testSetupHooksRejectsMultiplePositionalTargets() throws { @@ -461,9 +461,9 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertNotEqual(result.status, 0) - XCTAssertTrue(result.stderr.contains("Too many hooks targets"), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertNotEqual(result.status, 0) + legacyAssertTrue(result.stderr.contains("Too many hooks targets"), result.stderr) } func writeRovoDevSessionMetadata( diff --git a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift index fde55a613c91..9182f5ab3ce9 100644 --- a/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift +++ b/cmuxTests/CLISSHPTYAttachProbeReplyRegressionTests.swift @@ -95,7 +95,7 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [socketHandled], timeout: 5) + legacyWait(for: [socketHandled], timeout: 5) #expect(bridgeHandled.wait(timeout: .now() + 5) == .success) #expect(!result.timedOut) #expect(result.status == 0) diff --git a/cmuxTests/SSHStartupSignalLifecycleTests.swift b/cmuxTests/SSHStartupSignalLifecycleTests.swift index d9ce94c1bf02..0ec7edcdfca3 100644 --- a/cmuxTests/SSHStartupSignalLifecycleTests.swift +++ b/cmuxTests/SSHStartupSignalLifecycleTests.swift @@ -53,14 +53,14 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - let expectedStatus = try XCTUnwrap(expectedStatuses[signal]) - XCTAssertEqual(result.status, expectedStatus, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + let expectedStatus = try legacyUnwrap(expectedStatuses[signal]) + legacyAssertEqual(result.status, expectedStatus, result.stderr) let recordedCalls = (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" let sessionEndCalls = recordedCalls .split(separator: "\n") .filter { $0.contains("ssh-session-end") } - XCTAssertTrue( + legacyAssertTrue( sessionEndCalls.isEmpty, "Pane-close \(signal) must not call ssh-session-end because that can tear down the shared SSH transport and kill sibling panes; recorded: \(recordedCalls)" ) @@ -124,10 +124,10 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - let expectedStatus = try XCTUnwrap(expectedStatuses[signal]) - XCTAssertEqual(result.status, expectedStatus, result.stderr) - XCTAssertTrue( + legacyAssertFalse(result.timedOut, result.stderr) + let expectedStatus = try legacyUnwrap(expectedStatuses[signal]) + legacyAssertEqual(result.status, expectedStatus, result.stderr) + legacyAssertTrue( waitForSSHSignalLifecycleLog(childSignalLog) { contents in contents.contains("child-completed") || contents.contains("child-hup") || @@ -137,8 +137,8 @@ extension CLINotifyProcessIntegrationRegressionTests { "Timed out waiting for fake SSH child to record completion or signal for \(signal)" ) let childSignalLogContents = (try? String(contentsOf: childSignalLog, encoding: .utf8)) ?? "" - XCTAssertTrue(childSignalLogContents.contains("child-started"), childSignalLogContents) - XCTAssertFalse( + legacyAssertTrue(childSignalLogContents.contains("child-started"), childSignalLogContents) + legacyAssertFalse( childSignalLogContents.contains("child-hup") || childSignalLogContents.contains("child-int") || childSignalLogContents.contains("child-term"), @@ -148,7 +148,7 @@ extension CLINotifyProcessIntegrationRegressionTests { let sessionEndCalls = recordedCalls .split(separator: "\n") .filter { $0.contains("ssh-session-end") } - XCTAssertTrue(sessionEndCalls.isEmpty, recordedCalls) + legacyAssertTrue(sessionEndCalls.isEmpty, recordedCalls) } } @Test @@ -198,18 +198,18 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) // The regular `cmux ssh` bootstrap path runs one SSH command to install // the remote bootstrap and another to open the session. A transient // install-channel failure therefore yields three raw SSH invocations: // failed install, retried install, successful session. - XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "3") + legacyAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "3") let recordedCalls = (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" let sessionEndCalls = recordedCalls .split(separator: "\n") .filter { $0.contains("ssh-session-end") } - XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) + legacyAssertEqual(sessionEndCalls.count, 1, recordedCalls) } @Test func testSSHStartupRemovesStaleCmuxControlSocketBeforeLaunchingPaneSSH() throws { @@ -230,7 +230,7 @@ extension CLINotifyProcessIntegrationRegressionTests { let staleSocketFD = try bindUnixSocket(at: staleControlPath.path) Darwin.close(staleSocketFD) - XCTAssertTrue(fileManager.fileExists(atPath: staleControlPath.path)) + legacyAssertTrue(fileManager.fileExists(atPath: staleControlPath.path)) try writeShellFile(at: fakeCLI, lines: [ "#!/bin/sh", @@ -284,13 +284,13 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertFalse(fileManager.fileExists(atPath: staleControlPath.path)) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(fileManager.fileExists(atPath: staleControlPath.path)) let sshLog = (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" - XCTAssertTrue(sshLog.contains("-G"), sshLog) - XCTAssertTrue(sshLog.contains("-O check"), sshLog) + legacyAssertTrue(sshLog.contains("-G"), sshLog) + legacyAssertTrue(sshLog.contains("-O check"), sshLog) } @Test func testSSHStartupStopsAtConfiguredReconnectLimit() throws { @@ -339,14 +339,14 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 255, result.stderr) - XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "3") + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 255, result.stderr) + legacyAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "3") let recordedCalls = (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" let sessionEndCalls = recordedCalls .split(separator: "\n") .filter { $0.contains("ssh-session-end") } - XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) + legacyAssertEqual(sessionEndCalls.count, 1, recordedCalls) } @Test func testSSHStartupDoesNotRetryNonTransientSSHExit() throws { @@ -394,14 +394,14 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "1") + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "1") let recordedCalls = (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" let sessionEndCalls = recordedCalls .split(separator: "\n") .filter { $0.contains("ssh-session-end") } - XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) + legacyAssertEqual(sessionEndCalls.count, 1, recordedCalls) } @Test func testSSHSignalDerivedChildExitReportsSessionEnd() throws { @@ -443,13 +443,13 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 130, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 130, result.stderr) let recordedCalls = (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" let sessionEndCalls = recordedCalls .split(separator: "\n") .filter { $0.contains("ssh-session-end") } - XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) + legacyAssertEqual(sessionEndCalls.count, 1, recordedCalls) } @Test func testSSHSignalDuringReconnectDelayDoesNotStartAnotherSSH() throws { @@ -502,14 +502,14 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 143, result.stderr) - XCTAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "1") + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 143, result.stderr) + legacyAssertEqual((try? String(contentsOf: attemptFile, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines), "1") let recordedCalls = (try? String(contentsOf: logFile, encoding: .utf8)) ?? "" let sessionEndCalls = recordedCalls .split(separator: "\n") .filter { $0.contains("ssh-session-end") } - XCTAssertEqual(sessionEndCalls.count, 1, recordedCalls) + legacyAssertEqual(sessionEndCalls.count, 1, recordedCalls) } @Test func testSSHStartupPrintsFinalErrorBannerWhenStderrIsCaptured() throws { @@ -551,10 +551,10 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 1, result.stderr) - XCTAssertTrue(result.stderr.contains("[cmux] ssh exited with status 1."), result.stderr) - XCTAssertTrue(result.stderr.contains("[cmux] press Enter to close this pane."), result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 1, result.stderr) + legacyAssertTrue(result.stderr.contains("[cmux] ssh exited with status 1."), result.stderr) + legacyAssertTrue(result.stderr.contains("[cmux] press Enter to close this pane."), result.stderr) } @Test func testSSHStartupForwardsStdinToBackgroundedSSH() throws { @@ -610,11 +610,11 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) let recorded = (try? String(contentsOf: stdinCapture, encoding: .utf8))? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - XCTAssertEqual( + legacyAssertEqual( recorded, "FORWARDED_KEYSTROKE", "Backgrounded ssh in the startup wrapper must inherit the wrapper's stdin so that keystrokes from the surface PTY reach the remote shell. Got: \(recorded.isEmpty ? "" : recorded)" @@ -704,20 +704,20 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) let requests = try state.commands.map { line -> [String: Any] in - let data = try XCTUnwrap(line.data(using: .utf8)) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) + let data = try legacyUnwrap(line.data(using: .utf8)) + return try legacyUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) } - let configureRequest = try XCTUnwrap( + let configureRequest = try legacyUnwrap( requests.first { ($0["method"] as? String) == "workspace.remote.configure" } ) - let configureParams = try XCTUnwrap(configureRequest["params"] as? [String: Any]) - return try XCTUnwrap(configureParams["terminal_startup_command"] as? String) + let configureParams = try legacyUnwrap(configureRequest["params"] as? [String: Any]) + return try legacyUnwrap(configureParams["terminal_startup_command"] as? String) } private func generatedVMSSHInitialStartupCommand() throws -> String { @@ -744,8 +744,8 @@ extension CLINotifyProcessIntegrationRegressionTests { switch method { case "vm.attach_info": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["id"] as? String, vmID) - XCTAssertEqual(params["require_daemon"] as? Bool, true) + legacyAssertEqual(params["id"] as? String, vmID) + legacyAssertEqual(params["require_daemon"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -806,20 +806,20 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stderr.isEmpty, result.stderr) let requests = try state.commands.map { line -> [String: Any] in - let data = try XCTUnwrap(line.data(using: .utf8)) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) + let data = try legacyUnwrap(line.data(using: .utf8)) + return try legacyUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) } - let createRequest = try XCTUnwrap( + let createRequest = try legacyUnwrap( requests.first { ($0["method"] as? String) == "workspace.create" } ) - let createParams = try XCTUnwrap(createRequest["params"] as? [String: Any]) - return try XCTUnwrap(createParams["initial_command"] as? String) + let createParams = try legacyUnwrap(createRequest["params"] as? [String: Any]) + return try legacyUnwrap(createParams["initial_command"] as? String) } private func writeShellFile(at url: URL, lines: [String]) throws { diff --git a/cmuxTests/VMSSHCommandTests.swift b/cmuxTests/VMSSHCommandTests.swift index ef7ed90816f6..a68cd80cc18d 100644 --- a/cmuxTests/VMSSHCommandTests.swift +++ b/cmuxTests/VMSSHCommandTests.swift @@ -29,8 +29,8 @@ extension CLINotifyProcessIntegrationRegressionTests { switch method { case "vm.attach_info": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["id"] as? String, vmID) - XCTAssertEqual(params["require_daemon"] as? Bool, true) + legacyAssertEqual(params["id"] as? String, vmID) + legacyAssertEqual(params["require_daemon"] as? Bool, true) return self.v2Response( id: id, ok: true, @@ -47,7 +47,7 @@ extension CLINotifyProcessIntegrationRegressionTests { ) case "workspace.create": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowID) + legacyAssertEqual(params["window_id"] as? String, windowID) return self.v2Response( id: id, ok: true, @@ -94,33 +94,33 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux@gateway.freestyle.sh state=connecting\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux@gateway.freestyle.sh state=connecting\n") + legacyAssertTrue(result.stderr.isEmpty, result.stderr) let requests = try state.commands.map { line -> [String: Any] in - let data = try XCTUnwrap(line.data(using: .utf8)) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) + let data = try legacyUnwrap(line.data(using: .utf8)) + return try legacyUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) } - XCTAssertEqual( + legacyAssertEqual( requests.compactMap { $0["method"] as? String }, ["vm.attach_info", "workspace.create", "workspace.rename", "workspace.remote.configure", "workspace.select"] ) - let configureRequest = try XCTUnwrap( + let configureRequest = try legacyUnwrap( requests.first { $0["method"] as? String == "workspace.remote.configure" }, "Expected workspace.remote.configure RPC request" ) - let configureParams = try XCTUnwrap(configureRequest["params"] as? [String: Any]) - XCTAssertEqual(configureParams["workspace_id"] as? String, workspaceID) - XCTAssertEqual(configureParams["destination"] as? String, "cmux@gateway.freestyle.sh") - XCTAssertEqual(configureParams["port"] as? Int, 2222) - XCTAssertEqual(configureParams["local_socket_path"] as? String, socketPath) - XCTAssertEqual(configureParams["skip_daemon_bootstrap"] as? Bool, true) - XCTAssertNotNil(configureParams["terminal_startup_command"] as? String) - XCTAssertNotNil(configureParams["relay_port"] as? Int) + let configureParams = try legacyUnwrap(configureRequest["params"] as? [String: Any]) + legacyAssertEqual(configureParams["workspace_id"] as? String, workspaceID) + legacyAssertEqual(configureParams["destination"] as? String, "cmux@gateway.freestyle.sh") + legacyAssertEqual(configureParams["port"] as? Int, 2222) + legacyAssertEqual(configureParams["local_socket_path"] as? String, socketPath) + legacyAssertEqual(configureParams["skip_daemon_bootstrap"] as? Bool, true) + legacyAssertNotNil(configureParams["terminal_startup_command"] as? String) + legacyAssertNotNil(configureParams["relay_port"] as? Int) } @Test func testSSHCommandGlobalWindowOverridesCallerEnvironment() throws { @@ -149,13 +149,13 @@ extension CLINotifyProcessIntegrationRegressionTests { switch method { case "window.focus": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowID) + legacyAssertEqual(params["window_id"] as? String, windowID) return self.v2Response(id: id, ok: true, result: ["window_id": windowID]) case "workspace.create": let params = payload["params"] as? [String: Any] ?? [:] - XCTAssertEqual(params["window_id"] as? String, windowID) - XCTAssertNil(params["workspace_id"]) - XCTAssertNil(params["surface_id"]) + legacyAssertEqual(params["window_id"] as? String, windowID) + legacyAssertNil(params["workspace_id"]) + legacyAssertNil(params["surface_id"]) return self.v2Response( id: id, ok: true, @@ -205,17 +205,17 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux-macmini state=connecting\n") - XCTAssertTrue(result.stderr.isEmpty, result.stderr) + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertEqual(result.stdout, "OK workspace=\(workspaceRef) target=cmux-macmini state=connecting\n") + legacyAssertTrue(result.stderr.isEmpty, result.stderr) let requests = try state.commands.map { line -> [String: Any] in - let data = try XCTUnwrap(line.data(using: .utf8)) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) + let data = try legacyUnwrap(line.data(using: .utf8)) + return try legacyUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]) } - XCTAssertEqual( + legacyAssertEqual( requests.compactMap { $0["method"] as? String }, ["window.focus", "workspace.create", "workspace.remote.configure"] ) @@ -269,12 +269,12 @@ extension CLINotifyProcessIntegrationRegressionTests { timeout: 5 ) - wait(for: [serverHandled], timeout: 5) - XCTAssertFalse(result.timedOut, result.stderr) - XCTAssertEqual(result.status, 0, result.stderr) - XCTAssertTrue(result.stdout.contains("ssh cmux@gateway.freestyle.sh -p 2222"), result.stdout) - XCTAssertTrue(result.stdout.contains("password: lease-token"), result.stdout) - XCTAssertEqual( + legacyWait(for: [serverHandled], timeout: 5) + legacyAssertFalse(result.timedOut, result.stderr) + legacyAssertEqual(result.status, 0, result.stderr) + legacyAssertTrue(result.stdout.contains("ssh cmux@gateway.freestyle.sh -p 2222"), result.stdout) + legacyAssertTrue(result.stdout.contains("password: lease-token"), result.stdout) + legacyAssertEqual( state.commands.compactMap { self.jsonObject($0)?["method"] as? String }, ["vm.ssh_info"] )