From 7e09901af2cb9fd572a9a6e9fb0a7be308a340e0 Mon Sep 17 00:00:00 2001 From: GA Date: Thu, 23 Jul 2026 00:39:33 +0800 Subject: [PATCH] fix: open project starters in Codex mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activate Codex Desktop’s app-scoped mode command through its effective shortcut before opening the selected project deep link. Honor Codex keybinding overrides, revalidate the frozen process after the mode transition, and keep the task uncreated until first submission. --- README.md | 17 +-- README.zh-CN.md | 16 +-- .../AppModel+ProjectStarter.swift | 100 ++++++++++++------ .../AgentControllerApp/AppModel+Stop.swift | 4 +- Sources/AgentControllerApp/AppModel.swift | 1 + .../CodexSettingsView.swift | 11 +- .../CodexAppServerClient.swift | 17 --- .../CodexAppServerResponseDecoder.swift | 37 ------- .../CodexAppServerTransport.swift | 7 -- .../CodexKeyboardShortcut.swift | 5 + .../CodexModeShortcutResolver.swift | 72 +++++++++++++ .../CodexSessionOpener.swift | 25 +++++ .../CodexAppServerClientTests.swift | 81 ++++---------- .../CodexModeShortcutResolverTests.swift | 55 ++++++++++ .../CodexSessionOpenerLiveTests.swift | 11 ++ 15 files changed, 284 insertions(+), 175 deletions(-) create mode 100644 Sources/AgentControllerMac/CodexModeShortcutResolver.swift create mode 100644 Tests/AgentControllerMacTests/CodexModeShortcutResolverTests.swift 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) + } }