diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7fd60f80de4f..032c2f8647b0 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -23428,7 +23428,7 @@ struct CMUXCLI { surfaceId: surfaceId, value: "Needs input", icon: "bell.fill", - color: "#4C8DFF" + color: "#4C8DFF", pid: claudePid ) let response = try sendV1Command("notify_target_async \(workspaceId) \(surfaceId) \(payload)", client: client) print(response) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e2eb4d4efe11..7ad77ce278c8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -158,6 +158,7 @@ D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */; }; C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */; }; A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */; }; + A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */; }; 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 */; }; @@ -1267,6 +1268,7 @@ D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEMarkedSelectionTests.swift; sourceTree = ""; }; C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeConfigDirectoryPathTests.swift; sourceTree = ""; }; A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeHookSurfaceResolutionSwiftTests.swift; sourceTree = ""; }; + A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeNotificationStatusLifecycleTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -3220,6 +3222,7 @@ A5A5A506A1B2C3D4E5F60718 /* TerminalNotificationDirectInteractionTests.swift */, A5C41104A1B2C3D4E5F60718 /* TerminalNotificationCallerTests.swift */, A5D41221A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift */, + A5D41223A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift */, A5D41204A1B2C3D4E5F60718 /* CLINotifyProcessIntegrationRegressionTests.swift */, 6379A0026379A0026379A002 /* CLISSHPTYResizeInputTests.swift */, C0F16B000000000000000002 /* CLIRemoteShellStartupPerformanceTests.swift */, @@ -4435,6 +4438,7 @@ D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */, C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */, A5D41220A1B2C3D4E5F60718 /* ClaudeHookSurfaceResolutionSwiftTests.swift in Sources */, + A5D41222A1B2C3D4E5F60718 /* ClaudeNotificationStatusLifecycleTests.swift in Sources */, A5D4120DA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift in Sources */, C0D3F1F00000000000000103 /* CLICodexHookTimeoutRegressionTests.swift in Sources */, C46790000000000000000001 /* CLIForwardingLaunchArgumentTests.swift in Sources */, diff --git a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift index bd1e7b0d72c6..c9d4bc0c86af 100644 --- a/cmuxTests/AgentSessionAutoResumeSwiftTests.swift +++ b/cmuxTests/AgentSessionAutoResumeSwiftTests.swift @@ -1,5 +1,6 @@ import Foundation import CmuxCore +import CmuxSidebar import Testing #if canImport(cmux_DEV) @@ -10,6 +11,32 @@ import Testing @Suite(.serialized) struct AgentSessionAutoResumeSwiftTests { + @MainActor + @Test func sessionRestoreDropsPersistedAgentStatusRuntimeState() throws { + let source = Workspace() + let sourcePanelId = try #require(source.focusedPanelId) + let pidKey = "claude_code.issue-6441" + + source.statusEntries["claude_code"] = SidebarStatusEntry( + key: "claude_code", + value: "Needs input" + ) + source.recordAgentPID(key: pidKey, pid: 42_424, panelId: sourcePanelId, refreshPorts: false) + + let snapshot = source.sessionSnapshot(includeScrollback: false) + #expect(snapshot.statusEntries.contains { $0.key == "claude_code" }) + + let restored = Workspace() + let restoredPanelIds = restored.restoreSessionSnapshot(snapshot) + let restoredPanelId = try #require(restoredPanelIds[sourcePanelId]) + + #expect(restored.statusEntries["claude_code"] == nil) + #expect(restored.agentPIDs.isEmpty) + #expect(restored.agentPIDPanelIdsByKey.isEmpty) + #expect(restored.agentPIDKeysByPanelId.isEmpty) + #expect(restored.agentHibernationLifecycleState(panelId: restoredPanelId, fallback: nil) == .unknown) + } + @MainActor @Test func claudeAgentHookResumeBindingRestoresFromLaunchCwdWhenRuntimeCwdDrifted() throws { try withRestoredDefaults(key: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey) { diff --git a/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift b/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift index 878dbec04b58..158eab773593 100644 --- a/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift +++ b/cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift @@ -426,10 +426,10 @@ struct ClaudeHookSurfaceResolutionSwiftTests { ) } - private struct ProcessRunResult { let status: Int32; let stdout: String; let stderr: String; let timedOut: Bool } - private typealias SurfaceFixture = (id: String, ref: String, focused: Bool) + struct ProcessRunResult { let status: Int32; let stdout: String; let stderr: String; let timedOut: Bool } + typealias SurfaceFixture = (id: String, ref: String, focused: Bool) - private struct ClaudeHookContext { + struct ClaudeHookContext { let cliPath: String let socketPath: String let listenerFD: Int32 @@ -445,7 +445,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests { } } - private final class MockSocketServerState: @unchecked Sendable { + final class MockSocketServerState: @unchecked Sendable { private let lock = NSLock() private var commands: [String] = [] @@ -463,7 +463,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests { } } - private func makeClaudeHookContext(name: String) throws -> ClaudeHookContext { + func makeClaudeHookContext(name: String) throws -> ClaudeHookContext { let root = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-\(name)-\(UUID().uuidString)", isDirectory: true) let socketPath = makeSocketPath(String(name.prefix(6))) @@ -597,7 +597,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests { return handled } - private func startClaudeSurfaceResolutionServer( + func startClaudeSurfaceResolutionServer( context: ClaudeHookContext, surfaces: [SurfaceFixture], ttyName: String, @@ -668,7 +668,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests { } } - private func claudeHookEnvironment(context: ClaudeHookContext, surfaceId: String, ttyName: String, storeURL: URL) -> [String: String] { + func claudeHookEnvironment(context: ClaudeHookContext, surfaceId: String, ttyName: String, storeURL: URL) -> [String: String] { [ "HOME": context.root.path, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", @@ -779,7 +779,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests { ) } - private func assertSuccessfulHook(_ result: ProcessRunResult) { + func assertSuccessfulHook(_ result: ProcessRunResult) { #expect(!result.timedOut, Comment(rawValue: result.stderr)) #expect(result.status == 0, Comment(rawValue: result.stderr)) #expect(result.stdout == "OK\n") @@ -822,7 +822,7 @@ struct ClaudeHookSurfaceResolutionSwiftTests { return data.base64EncodedString() } - private func runProcess(executablePath: String, arguments: [String], environment: [String: String], standardInput: String? = nil, timeout: TimeInterval) -> ProcessRunResult { + func runProcess(executablePath: String, arguments: [String], environment: [String: String], standardInput: String? = nil, timeout: TimeInterval) -> ProcessRunResult { let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() diff --git a/cmuxTests/ClaudeNotificationStatusLifecycleTests.swift b/cmuxTests/ClaudeNotificationStatusLifecycleTests.swift new file mode 100644 index 000000000000..1176368efd0a --- /dev/null +++ b/cmuxTests/ClaudeNotificationStatusLifecycleTests.swift @@ -0,0 +1,52 @@ +import Dispatch +import Foundation +import Testing + +@Suite(.serialized) +struct ClaudeNotificationStatusLifecycleTests { + @Test func claudeNotificationStatusCarriesPIDForStaleSweep() throws { + let harness = ClaudeHookSurfaceResolutionSwiftTests() + let context = try harness.makeClaudeHookContext(name: "claude-notify-pid") + defer { context.cleanup() } + + let claudePID = 42_424 + let serverHandled = harness.startClaudeSurfaceResolutionServer( + context: context, + surfaces: [(context.surfaceId, "surface:1", true)], + ttyName: "ttys-claude-notify-pid", + ttySurfaceId: context.surfaceId + ) + + var environment = harness.claudeHookEnvironment( + context: context, + surfaceId: context.surfaceId, + ttyName: "ttys-claude-notify-pid", + storeURL: context.root.appendingPathComponent("claude-hook-sessions.json") + ) + environment["CMUX_CLAUDE_PID"] = "\(claudePID)" + + let result = harness.runProcess( + executablePath: context.cliPath, + arguments: ["hooks", "claude", "notification"], + environment: environment, + standardInput: #"{"session_id":"claude-notify-pid-session","cwd":"\#(context.root.path)","hook_event_name":"Notification","message":"Claude needs your input"}"#, + timeout: 5 + ) + + #expect(serverHandled.wait(timeout: .now() + 5) == .success) + harness.assertSuccessfulHook(result) + + let statusCommand = try #require( + context.state.snapshot().first { + $0.hasPrefix("set_status claude_code Needs input ") + && $0.contains("--tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Expected Claude notification to set a Needs input status, saw \(context.state.snapshot())" + ) + #expect( + statusCommand.contains("--pid=\(claudePID)"), + "Claude notification status must be PID-backed so the stale PID sweep can clear it after abrupt agent exit; command=\(statusCommand)" + ) + } +}