diff --git a/README.md b/README.md index 3404e64..425c0b8 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ It does not replace Codex, scrape its sidebar, or mirror its interface. | Input | Default action | Scope | |---|---|---| | `LB` | Hold to preview recent tasks; release to open one | Codex | -| `L3` | Hold to preview project roots; release to start a task there | Codex | +| `L3` | Hold to preview project roots; release to open a new task there | Codex | | `RB` | Hold to talk; release to finish dictation | Codex | | `X` | Stop the managed or frontmost task | Codex | | Right stick | Proportional vertical scrolling | Codex | @@ -67,16 +67,19 @@ and open that exact task. ### Project-based Session Starter Hold `L3` to freeze recent project roots discovered from real Codex sessions. -Release on a project to start one persistent task with that exact working -directory. Cancel, focus drift, disconnect, or an invalid directory creates -nothing. +Release on a project to invoke Codex Desktop's app-scoped `switchToMode2` +command, then open its native new-session page with that exact working directory +selected. Agent Controller follows Codex's configured shortcut for that command +(default `Control+2`) and therefore requires Accessibility for this step. The +task remains uncreated until you submit its first prompt. Cancel, focus drift, +disconnect, or an invalid directory opens nothing. ### Push to Talk, Stop, and scrolling `RB` delegates audio capture and transcription to Codex, leaving the result -editable and unsent. `X` stops the exact task last opened through `LB` or -created through `L3`; a bounded current-task fallback is available when no -managed target exists. The right stick sends proportional scroll events only +editable and unsent. `X` stops the exact task last opened through `LB`; a +bounded current-task fallback covers a task started from the `L3` composer or +any other frontmost task. The right stick sends proportional scroll events only after revalidating the same frontmost Codex window. ## Safety model diff --git a/README.zh-CN.md b/README.zh-CN.md index 9f5e1b2..4ef56f1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,7 +26,7 @@ Agent Controller 是一个原生 macOS 控制层,把实体手柄的输入变 | 输入 | 默认动作 | 生效范围 | |---|---|---| | `LB` | 按住查看最近任务,松开后打开所选任务 | Codex | -| `L3` | 按住选择项目目录,松开后在该项目中创建任务 | Codex | +| `L3` | 按住选择项目目录,松开后进入该项目的新任务页面 | Codex | | `RB` | 按住说话,松开结束语音输入 | Codex | | `X` | Stop 当前受管任务或前台任务 | Codex | | 右摇杆 | 按推动幅度纵向滚动 | Codex | @@ -64,17 +64,19 @@ flowchart LR 用方向键或左摇杆预览,期间 Codex 不会切换;松开 `LB` 后,它会再次校验并 只打开你选中的那条任务。 -### 从项目目录创建任务 +### 从项目目录开始新任务 -按住 `L3` 会显示从真实 Codex 历史中整理出的近期项目目录。选中项目并松开 -后,Agent Controller 会以所选目录作为准确的 working directory,创建一个持久化 -任务。按 `B` 取消、焦点变化、手柄断开、目录失效,都会直接放弃,不创建任务。 +按住 `L3` 可预览从真实 Codex 历史中整理出的近期项目目录;松开后,Codex 会在 +先执行应用内的 `switchToMode2` 命令,再到准确选中的 working directory 打开原生 +新任务页面。Agent Controller 会跟随 Codex 为该命令保存的键位(默认 +`Control+2`),因此这一步需要 Accessibility 权限。在你提交第一条 prompt 前, +不会创建空任务;按 `B` 取消、焦点变化、手柄断开或目录失效时,页面都不会打开。 ### Push to Talk、Stop 与滚动 按住 `RB` 时,录音和转写仍由 Codex 完成;松开后文字留在 Codex 中供你确认, -不会自动发送。`X` 会优先停止最近一次通过 `LB` 打开或通过 `L3` 创建的目标 -任务;没有受管目标时,才使用范围受限的当前任务回退方案。右摇杆每次发送 +不会自动发送。按 `X` 会精确停止最近一次通过 `LB` 打开的目标任务;从 `L3` +页面开始的任务及其他前台任务,则使用范围受限的当前任务回退方案。右摇杆每次发送 滚动事件前,都会重新确认前台仍是同一个 Codex 窗口。 ## 安全边界 diff --git a/Sources/AgentControllerApp/AppModel+ProjectStarter.swift b/Sources/AgentControllerApp/AppModel+ProjectStarter.swift index b9e8fcd..036813a 100644 --- a/Sources/AgentControllerApp/AppModel+ProjectStarter.swift +++ b/Sources/AgentControllerApp/AppModel+ProjectStarter.swift @@ -79,7 +79,11 @@ extension AppModel { detail: "Session Starter cancelled." ) case .commitSelection(let path, let frozenContext): - startSession(path: path, frozenContext: frozenContext, monitorEventID: monitorEventID) + openNewSessionPage( + path: path, + frozenContext: frozenContext, + monitorEventID: monitorEventID + ) } } @@ -97,7 +101,7 @@ extension AppModel { ) } - private func startSession( + private func openNewSessionPage( path: String, frozenContext: ApplicationContext, monitorEventID: UUID? @@ -114,12 +118,12 @@ extension AppModel { return } hideSelector() - publishEvent("Starting a new Codex session…") - finishInputMonitorEvent(monitorEventID, status: .routed, detail: "Starting selected project.") + publishEvent("Opening a new Codex session page…") + finishInputMonitorEvent(monitorEventID, status: .routed, detail: "Opening selected project.") commitTask?.cancel() let token = commitGate.begin() commitTask = Task { [weak self] in - await self?.performSessionStart( + await self?.performNewSessionOpen( project: selectedProject, frozenContext: frozenContext, token: token, @@ -128,7 +132,7 @@ extension AppModel { } } - private func performSessionStart( + private func performNewSessionOpen( project: CodexProjectSummary, frozenContext: ApplicationContext, token: UInt64, @@ -146,63 +150,89 @@ extension AppModel { ) return } - let startedSession = try await sessionClient.startSession(in: project) + let modeShortcut = try codexModeShortcutResolver.resolve() + try keyboardPulseAdapter.pulse( + modeShortcut, + frozenContext: frozenContext, + targetBundleIdentifier: CodexModeShortcutResolver.contract.targetBundleIdentifier + ) + accessibilityAuthorized = true + try await Task.sleep(for: .milliseconds(180)) guard canCommit(token: token, frozenContext: frozenContext) else { - publishEvent("Session created, but Codex focus changed before it could open.") + publishEvent("Cancelled: Codex focus changed while switching modes.") finishInputMonitorEvent( monitorEventID, status: .cancelled, - detail: "Session created; focus changed before opening." - ) - refreshSessions() - return - } - do { - try sessionOpener.open(threadID: startedSession.id) - } catch { - guard commitGate.isCurrent(token) else { return } - publishEvent("Session started, but Codex did not open its task link.") - finishInputMonitorEvent( - monitorEventID, - status: .failed, - detail: "Session created; task link was rejected." + detail: "Focus changed while switching to Codex mode." ) - refreshSessions() return } + try sessionOpener.openNewSession(projectPath: project.path) guard !Task.isCancelled, commitGate.isCurrent(token) else { return } - managedStopSession = startedSession - publishEvent("Started a new Codex session in the selected project.") + managedStopSession = nil + publishEvent("Opened Codex's new session page in the selected project.") finishInputMonitorEvent( monitorEventID, status: .completed, - detail: "New project session opened." + detail: "New project session page requested." ) refreshSessions() - } catch is CancellationError { + } catch { + handleNewSessionOpenError(error, token: token, monitorEventID: monitorEventID) + } + } + + private func handleNewSessionOpenError( + _ error: Error, + token: UInt64, + monitorEventID: UUID? + ) { + if error is CancellationError { finishInputMonitorEvent( monitorEventID, status: .cancelled, detail: "Session start cancelled." ) - } catch CodexSessionClientError.projectUnavailable { - guard commitGate.isCurrent(token) else { return } + return + } + guard commitGate.isCurrent(token) else { return } + switch error { + case CodexSessionClientError.projectUnavailable: publishEvent("Blocked: that Codex project folder is no longer available.") finishInputMonitorEvent( monitorEventID, status: .blocked, detail: "Selected project is unavailable." ) - refreshSessions() - } catch { - guard commitGate.isCurrent(token) else { return } - publishEvent("Blocked: Codex could not start a session for that project.") + case let pulseError as KeyboardChordPulseError: + synchronizeGlobalCommandState() + handlePulseError( + pulseError, + actionName: "Session Starter", + monitorEventID: monitorEventID + ) + case CodexModeShortcutError.commandDisabled: + publishEvent("Blocked: Codex's mode-switch shortcut is disabled.") + finishInputMonitorEvent( + monitorEventID, + status: .blocked, + detail: "Codex mode-switch shortcut is disabled." + ) + case CodexModeShortcutError.unsupportedShortcut: + publishEvent("Blocked: Codex's mode-switch shortcut is not supported.") + finishInputMonitorEvent( + monitorEventID, + status: .blocked, + detail: "Codex mode-switch shortcut is unsupported." + ) + default: + publishEvent("Blocked: Codex did not open a new task page for that project.") finishInputMonitorEvent( monitorEventID, status: .failed, - detail: "Session start adapter rejected the request." + detail: "New-session link was rejected." ) - refreshSessions() } + refreshSessions() } } diff --git a/Sources/AgentControllerApp/AppModel+Stop.swift b/Sources/AgentControllerApp/AppModel+Stop.swift index 376bb71..0662fea 100644 --- a/Sources/AgentControllerApp/AppModel+Stop.swift +++ b/Sources/AgentControllerApp/AppModel+Stop.swift @@ -152,7 +152,7 @@ extension AppModel { } isStoppingCodex = true finishInputMonitorEvent(monitorEventID, status: .routed, detail: "Exact-session Stop routed.") - publishStopEvent("X received. Stopping the exact session opened by LB or L3…") + publishStopEvent("X received. Stopping the exact session opened by LB…") let stopAdapter = stopAdapter stopTask = Task { [weak self] in guard let self else { return } @@ -184,7 +184,7 @@ extension AppModel { guard !Task.isCancelled else { return } switch result { case .interrupted: - publishStopEvent("X stopped the exact session opened by LB or L3.") + publishStopEvent("X stopped the exact session opened by LB.") finishInputMonitorEvent(monitorEventID, status: .completed, detail: "Exact session stopped.") case .idle: publishStopEvent("X target was already idle.") diff --git a/Sources/AgentControllerApp/AppModel.swift b/Sources/AgentControllerApp/AppModel.swift index 8bc7235..d9f6eab 100644 --- a/Sources/AgentControllerApp/AppModel.swift +++ b/Sources/AgentControllerApp/AppModel.swift @@ -70,6 +70,7 @@ final class AppModel: ObservableObject { let sessionOpener = CodexSessionOpener() let voiceAdapter = CodexDictationShortcutAdapter() let keyboardPulseAdapter = KeyboardChordPulseAdapter() + let codexModeShortcutResolver = CodexModeShortcutResolver() let scrollAdapter = CodexScrollAdapter() let stopAdapter = CodexDesktopStopAdapter() let mappingStore: ControllerMappingStore diff --git a/Sources/AgentControllerApp/CodexSettingsView.swift b/Sources/AgentControllerApp/CodexSettingsView.swift index 26d3dc6..0b5f244 100644 --- a/Sources/AgentControllerApp/CodexSettingsView.swift +++ b/Sources/AgentControllerApp/CodexSettingsView.swift @@ -37,7 +37,7 @@ struct CodexSettingsView: View { MappingSummaryRow( input: "L3", title: "Session Starter", - detail: "Choose a project before starting a new task.", + detail: "Choose a project, switch to Codex, then start a new task.", value: projectCountValue, actionLabel: "Details", selected: selectedMapping == .projectStarter, @@ -56,7 +56,7 @@ struct CodexSettingsView: View { MappingSummaryRow( input: "X", title: "Stop", - detail: "Stop the current task, or the exact task opened by LB or L3.", + detail: "Stop the current task, or the exact task opened by LB.", value: stopMappingValue, actionLabel: "Details", selected: selectedMapping == .stop, @@ -148,7 +148,7 @@ struct CodexSettingsView: View { LabeledContent("Commit", value: "Release L3") LabeledContent("Projects", value: "Recent Codex working folders") HStack { - Text("The project list freezes for each hold. Release starts one new task in the exact selected folder.") + Text("The project list freezes for each hold. Release switches to Codex mode, then opens its new-task page in the exact selected folder.") .font(.caption) .foregroundStyle(.secondary) Spacer() @@ -170,7 +170,7 @@ struct CodexSettingsView: View { .lineLimit(1) .help(model.managedStopSession?.title ?? "No exact selector target") } - Text("With no selector target, X stops the current Codex response. After LB opens or L3 starts a task, X stops that exact task.") + Text("With no LB selector target, X stops the current Codex response, including tasks started from the L3 composer.") .font(.caption) .foregroundStyle(.secondary) } @@ -231,7 +231,8 @@ struct CodexSettingsView: View { } private var projectCountValue: String { - model.recentProjects.isEmpty ? "No projects" : "\(model.recentProjects.count) ready" + if !model.accessibilityAuthorized { return "Needs Accessibility" } + return model.recentProjects.isEmpty ? "No projects" : "\(model.recentProjects.count) ready" } private var sessionInventoryValue: String { diff --git a/Sources/AgentControllerMac/CodexAppServerClient.swift b/Sources/AgentControllerMac/CodexAppServerClient.swift index cf201e3..44a80db 100644 --- a/Sources/AgentControllerMac/CodexAppServerClient.swift +++ b/Sources/AgentControllerMac/CodexAppServerClient.swift @@ -7,7 +7,6 @@ public enum CodexSessionClientError: LocalizedError, Equatable, Sendable { case invalidResponse case sessionUnavailable case projectUnavailable - case sessionStartRejected case invalidThreadIdentifier case deepLinkRejected @@ -23,8 +22,6 @@ public enum CodexSessionClientError: LocalizedError, Equatable, Sendable { "That Codex session is no longer available." case .projectUnavailable: "That Codex project folder is no longer available." - case .sessionStartRejected: - "Codex did not start a session for that project." case .invalidThreadIdentifier: "Codex returned an invalid session identifier." case .deepLinkRejected: @@ -70,20 +67,6 @@ public struct CodexAppServerClient: Sendable { return CodexInventorySnapshot(sessions: sessions, projects: projects) } - public func startSession(in project: CodexProjectSummary) async throws -> CodexSessionSummary { - guard let normalizedPath = Self.normalizedProjectPath(project.path), - normalizedPath == project.path, - Self.isAvailableProjectDirectory(normalizedPath) else { - throw CodexSessionClientError.projectUnavailable - } - let response = try await request(.start(cwd: normalizedPath)) - do { - return try Self.decodeStartedSession(from: response, expectedCwd: normalizedPath) - } catch CodexSessionClientError.sessionUnavailable { - throw CodexSessionClientError.sessionStartRejected - } - } - public func sessionExists(_ threadID: String) async throws -> Bool { guard Self.isValidThreadID(threadID) else { throw CodexSessionClientError.invalidThreadIdentifier diff --git a/Sources/AgentControllerMac/CodexAppServerResponseDecoder.swift b/Sources/AgentControllerMac/CodexAppServerResponseDecoder.swift index 7e64a3a..34100b7 100644 --- a/Sources/AgentControllerMac/CodexAppServerResponseDecoder.swift +++ b/Sources/AgentControllerMac/CodexAppServerResponseDecoder.swift @@ -56,35 +56,6 @@ extension CodexAppServerClient { return projects } - static func decodeStartedSession( - from response: Data, - expectedCwd: String - ) throws -> CodexSessionSummary { - let result = try resultObject(from: response) - guard let responseCwd = result["cwd"] as? String, - normalizedProjectPath(responseCwd) == expectedCwd, - let thread = result["thread"] as? [String: Any], - let threadID = thread["id"] as? String, - let ephemeral = thread["ephemeral"] as? Bool, - !ephemeral, - isValidThreadID(threadID), - let threadCwd = thread["cwd"] as? String, - normalizedProjectPath(threadCwd) == expectedCwd else { - throw CodexSessionClientError.invalidResponse - } - return CodexSessionSummary( - id: threadID, - title: displayTitle( - name: thread["name"] as? String, - preview: thread["preview"] as? String ?? "" - ), - project: projectName(for: expectedCwd), - workingDirectory: expectedCwd, - updatedAt: threadDate(thread) ?? Date(), - status: sessionStatus(thread["status"] as? [String: Any] ?? [:]) - ) - } - static func decodeThreadID(from response: Data) throws -> String { let result = try resultObject(from: response) guard let thread = result["thread"] as? [String: Any], @@ -118,14 +89,6 @@ extension CodexAppServerClient { return normalized == "/" ? nil : normalized } - static func startRequestParams(cwd: String) -> [String: Any] { - CodexAppServerRequest.start(cwd: cwd).params - } - - static var startRequestMethod: String { - CodexAppServerRequest.start(cwd: "/").method - } - static func isAvailableProjectDirectory(_ path: String) -> Bool { var isDirectory: ObjCBool = false return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) diff --git a/Sources/AgentControllerMac/CodexAppServerTransport.swift b/Sources/AgentControllerMac/CodexAppServerTransport.swift index 4c081b9..2fa1826 100644 --- a/Sources/AgentControllerMac/CodexAppServerTransport.swift +++ b/Sources/AgentControllerMac/CodexAppServerTransport.swift @@ -4,13 +4,11 @@ import Foundation enum CodexAppServerRequest: Sendable { case list(limit: Int) case read(threadID: String) - case start(cwd: String) var method: String { switch self { case .list: "thread/list" case .read: "thread/read" - case .start: "thread/start" } } @@ -29,11 +27,6 @@ enum CodexAppServerRequest: Sendable { "threadId": threadID, "includeTurns": false, ] - case .start(let cwd): - [ - "cwd": cwd, - "ephemeral": false, - ] } } } diff --git a/Sources/AgentControllerMac/CodexKeyboardShortcut.swift b/Sources/AgentControllerMac/CodexKeyboardShortcut.swift index e46cf1d..fc0acf5 100644 --- a/Sources/AgentControllerMac/CodexKeyboardShortcut.swift +++ b/Sources/AgentControllerMac/CodexKeyboardShortcut.swift @@ -91,6 +91,11 @@ public struct KeyboardChord: Codable, Equatable, Hashable, Sendable { key: Key(displayName: "Escape", keyCode: 53), triggerMode: .pulse ) + public static let defaultCodexMode = KeyboardChord( + modifiers: [.control], + key: Key(displayName: "2", keyCode: 19), + triggerMode: .pulse + ) public let modifiers: [Modifier] public let key: Key diff --git a/Sources/AgentControllerMac/CodexModeShortcutResolver.swift b/Sources/AgentControllerMac/CodexModeShortcutResolver.swift new file mode 100644 index 0000000..3511e09 --- /dev/null +++ b/Sources/AgentControllerMac/CodexModeShortcutResolver.swift @@ -0,0 +1,72 @@ +import AgentControllerCore +import Foundation + +public struct CodexModeShortcutContract: Equatable, Sendable { + public let semanticAction: String + public let commandIdentifier: String + public let targetBundleIdentifier: String + public let defaultShortcut: KeyboardChord +} + +public enum CodexModeShortcutError: Error, Equatable, Sendable { + case commandDisabled + case unsupportedShortcut +} + +/// Resolves Codex Desktop's app-scoped `switchToMode2` command. Codex mode is +/// the second product mode; its macOS default is Control+2. Reading Codex's +/// own keymap keeps the semantic action working when that shortcut is changed. +public struct CodexModeShortcutResolver { + public static let contract = CodexModeShortcutContract( + semanticAction: "activateCodexTaskMode", + commandIdentifier: "switchToMode2", + targetBundleIdentifier: ProfileRegistry.codexBundleIdentifier, + defaultShortcut: .defaultCodexMode + ) + + private struct Binding: Decodable { + let command: String + let key: String? + } + + private let loadKeymap: () -> Data? + + public init( + keymapURL: URL = FileManager.default.homeDirectoryForCurrentUser + .appending(path: ".codex/keybindings.json") + ) { + loadKeymap = { try? Data(contentsOf: keymapURL, options: [.mappedIfSafe]) } + } + + init(loadKeymap: @escaping () -> Data?) { + self.loadKeymap = loadKeymap + } + + public func resolve() throws -> KeyboardChord { + try Self.resolve(data: loadKeymap()) + } + + static func resolve(data: Data?) throws -> KeyboardChord { + guard let data, + let bindings = try? JSONDecoder().decode([Binding].self, from: data) else { + return contract.defaultShortcut + } + let commandBindings = bindings.filter { $0.command == contract.commandIdentifier } + guard !commandBindings.isEmpty else { return contract.defaultShortcut } + guard commandBindings.allSatisfy({ $0.key != nil }) else { + throw CodexModeShortcutError.commandDisabled + } + for binding in commandBindings { + guard let key = binding.key else { continue } + let macAccelerator = key.replacingOccurrences(of: "CmdOrCtrl", with: "Command") + if let chord = KeyboardChord( + legacyDescription: macAccelerator, + triggerMode: .pulse, + requiresModifier: false + ) { + return chord + } + } + throw CodexModeShortcutError.unsupportedShortcut + } +} diff --git a/Sources/AgentControllerMac/CodexSessionOpener.swift b/Sources/AgentControllerMac/CodexSessionOpener.swift index 78e3d8b..4a2b131 100644 --- a/Sources/AgentControllerMac/CodexSessionOpener.swift +++ b/Sources/AgentControllerMac/CodexSessionOpener.swift @@ -7,6 +7,15 @@ public struct CodexSessionOpener { public func open(threadID: String) throws { let url = try Self.deepLink(threadID: threadID) + try open(url) + } + + public func openNewSession(projectPath: String) throws { + let url = try Self.newSessionDeepLink(projectPath: projectPath) + try open(url) + } + + private func open(_ url: URL) throws { guard NSWorkspace.shared.open(url) else { throw CodexSessionClientError.deepLinkRejected } @@ -25,4 +34,20 @@ public struct CodexSessionOpener { } return url } + + nonisolated static func newSessionDeepLink(projectPath: String) throws -> URL { + guard let normalizedPath = CodexAppServerClient.normalizedProjectPath(projectPath), + normalizedPath == projectPath, + CodexAppServerClient.isAvailableProjectDirectory(normalizedPath) else { + throw CodexSessionClientError.projectUnavailable + } + var components = URLComponents() + components.scheme = "codex" + components.host = "new" + components.queryItems = [URLQueryItem(name: "path", value: normalizedPath)] + guard let url = components.url else { + throw CodexSessionClientError.projectUnavailable + } + return url + } } diff --git a/Tests/AgentControllerMacTests/CodexAppServerClientTests.swift b/Tests/AgentControllerMacTests/CodexAppServerClientTests.swift index 6605600..d7fc511 100644 --- a/Tests/AgentControllerMacTests/CodexAppServerClientTests.swift +++ b/Tests/AgentControllerMacTests/CodexAppServerClientTests.swift @@ -33,47 +33,6 @@ import AgentControllerCore #expect(CodexAppServerClient.normalizedProjectPath(" ") == nil) } -@Test func startedSessionRequiresTheExactRequestedProject() throws { - let response = try #require(startFixtureResponse.data(using: .utf8)) - let session = try CodexAppServerClient.decodeStartedSession( - from: response, - expectedCwd: "/tmp" - ) - - #expect(session.id == "019f8f00-0000-7000-8000-000000000001") - #expect(session.title == "Untitled Codex chat") - #expect(session.project == "tmp") - #expect(session.workingDirectory == "/tmp") - #expect(throws: CodexSessionClientError.invalidResponse) { - try CodexAppServerClient.decodeStartedSession(from: response, expectedCwd: "/tmp/other") - } -} - -@Test func threadStartSendsOnlyTheFrozenProjectPathAndPersistenceFlag() async throws { - let params = CodexAppServerClient.startRequestParams(cwd: "/tmp") - #expect(CodexAppServerClient.startRequestMethod == "thread/start") - #expect(params.count == 2) - #expect(params["cwd"] as? String == "/tmp") - #expect(params["ephemeral"] as? Bool == false) - - let script = #""" - #!/bin/sh - IFS= read -r _ || exit 1 - IFS= read -r _ || exit 1 - IFS= read -r _ || exit 1 - printf '%s\n' '{"id":2,"result":{"cwd":"/tmp","thread":{"id":"019f8f00-0000-7000-8000-000000000001","preview":"","cwd":"/tmp","ephemeral":false,"updatedAt":1784541700,"status":{"type":"idle"}}}}' - """# - let executable = try makeFakeServer(script: script) - defer { try? FileManager.default.removeItem(at: executable.deletingLastPathComponent()) } - let client = CodexAppServerClient(executableURL: executable, requestTimeout: 2) - let project = CodexProjectSummary(name: "tmp", path: "/tmp", lastUsedAt: .now) - - let session = try await client.startSession(in: project) - - #expect(session.id == "019f8f00-0000-7000-8000-000000000001") - #expect(session.workingDirectory == "/tmp") -} - @Test func titleFallbackNeverProducesAnEmptyRow() { #expect(CodexAppServerClient.displayTitle(name: " Named chat ", preview: "Preview") == "Named chat") #expect(CodexAppServerClient.displayTitle(name: nil, preview: " Preview ") == "Preview") @@ -115,6 +74,29 @@ import AgentControllerCore #expect(url.absoluteString == "codex://threads/thread_ABC-123.~") } +@Test func newSessionDeepLinkCarriesTheExactExistingProjectPath() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("agent controller & opener", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let path = directory.standardizedFileURL.path + let url = try CodexSessionOpener.newSessionDeepLink(projectPath: path) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + #expect(components.scheme == "codex") + #expect(components.host == "new") + #expect(components.queryItems == [URLQueryItem(name: "path", value: path)]) +} + +@Test func newSessionDeepLinkRejectsAnUnavailableProjectPath() { + #expect(throws: CodexSessionClientError.projectUnavailable) { + try CodexSessionOpener.newSessionDeepLink( + projectPath: "/private/tmp/agent-controller-project-that-does-not-exist" + ) + } +} + @Test func appServerRequestHasABoundedDeadline() async throws { let executable = try makeFakeServer(script: "#!/bin/sh\nexec /bin/sleep 10\n") defer { try? FileManager.default.removeItem(at: executable.deletingLastPathComponent()) } @@ -314,20 +296,3 @@ private let projectFixtureResponse = #""" } } """# - -private let startFixtureResponse = #""" -{ - "id": 2, - "result": { - "cwd": "/tmp", - "thread": { - "id": "019f8f00-0000-7000-8000-000000000001", - "preview": "", - "cwd": "/tmp", - "ephemeral": false, - "updatedAt": 1784541700, - "status": {"type": "idle"} - } - } -} -"""# diff --git a/Tests/AgentControllerMacTests/CodexModeShortcutResolverTests.swift b/Tests/AgentControllerMacTests/CodexModeShortcutResolverTests.swift new file mode 100644 index 0000000..ac383df --- /dev/null +++ b/Tests/AgentControllerMacTests/CodexModeShortcutResolverTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import AgentControllerMac + +@Test func codexModeShortcutUsesDocumentedMacDefaultWithoutAnOverride() throws { + let shortcut = try CodexModeShortcutResolver(loadKeymap: { nil }).resolve() + + #expect(CodexModeShortcutResolver.contract.semanticAction == "activateCodexTaskMode") + #expect(CodexModeShortcutResolver.contract.commandIdentifier == "switchToMode2") + #expect(CodexModeShortcutResolver.contract.targetBundleIdentifier == "com.openai.codex") + #expect(shortcut == .defaultCodexMode) + #expect(shortcut.displayName == "Control+2") +} + +@Test func codexModeShortcutFollowsTheCodexKeymapOverride() throws { + let data = Data(""" + [ + {"command":"nextTab","key":"Ctrl+Tab"}, + {"command":"switchToMode2","key":"Command+9"} + ] + """.utf8) + + let shortcut = try CodexModeShortcutResolver(loadKeymap: { data }).resolve() + + #expect(shortcut.displayName == "Command+9") + #expect(shortcut.triggerMode == .pulse) +} + +@Test func codexModeShortcutTreatsAnExplicitNullAsDisabled() { + let data = Data(""" + [{"command":"switchToMode2","key":null}] + """.utf8) + + #expect(throws: CodexModeShortcutError.commandDisabled) { + try CodexModeShortcutResolver(loadKeymap: { data }).resolve() + } +} + +@Test func codexModeShortcutRejectsAnUnsupportedCustomAccelerator() { + let data = Data(""" + [{"command":"switchToMode2","key":"Control+F13"}] + """.utf8) + + #expect(throws: CodexModeShortcutError.unsupportedShortcut) { + try CodexModeShortcutResolver(loadKeymap: { data }).resolve() + } +} + +@Test func malformedCodexKeymapMatchesCodexFallbackBehavior() throws { + let malformed = Data("not-json".utf8) + + let shortcut = try CodexModeShortcutResolver(loadKeymap: { malformed }).resolve() + + #expect(shortcut == .defaultCodexMode) +} diff --git a/Tests/AgentControllerMacTests/CodexSessionOpenerLiveTests.swift b/Tests/AgentControllerMacTests/CodexSessionOpenerLiveTests.swift index 6edcefa..dcd2dec 100644 --- a/Tests/AgentControllerMacTests/CodexSessionOpenerLiveTests.swift +++ b/Tests/AgentControllerMacTests/CodexSessionOpenerLiveTests.swift @@ -14,4 +14,15 @@ final class CodexSessionOpenerLiveTests: XCTestCase { ) try CodexSessionOpener().open(threadID: threadID) } + + func testNewSessionDeepLinkIsAccepted() throws { + try XCTSkipUnless( + ProcessInfo.processInfo.environment["AGENT_CONTROLLER_LIVE_CODEX_NEW_SESSION_PROJECT"] != nil, + "Set AGENT_CONTROLLER_LIVE_CODEX_NEW_SESSION_PROJECT for reversible new-session proof." + ) + let projectPath = try XCTUnwrap( + ProcessInfo.processInfo.environment["AGENT_CONTROLLER_LIVE_CODEX_NEW_SESSION_PROJECT"] + ) + try CodexSessionOpener().openNewSession(projectPath: projectPath) + } }