diff --git a/Packages/CrowCodex/Sources/CrowCodex/OpenAICodexAgent.swift b/Packages/CrowCodex/Sources/CrowCodex/OpenAICodexAgent.swift index d65154a..a304364 100644 --- a/Packages/CrowCodex/Sources/CrowCodex/OpenAICodexAgent.swift +++ b/Packages/CrowCodex/Sources/CrowCodex/OpenAICodexAgent.swift @@ -45,21 +45,47 @@ public struct OpenAICodexAgent: CodingAgent { autoPermissionMode: Bool, telemetryPort: UInt16? ) -> String? { - // Review-on-Codex isn't supported in Phase C — the review skill is - // Claude-only. Returning nil tells `SessionService.launchAgent` to - // log and skip rather than producing a malformed command. - guard session.kind == .work else { return nil } + let codexPath = findBinary() ?? "codex" - // Bare `codex` launch in-app. First-launch prompt delivery happens - // in the workspace skill (`crow-workspace/setup.sh launch_codex`), - // which passes the prompt file as Codex's positional argv before - // handing the terminal off to Crow. In-app re-launches resume the - // existing TUI rather than re-running the original prompt — same - // shape as `CursorAgent` `.work` (no `--continue` equivalent in MVP). - // No env prefix (Codex has no OTEL equivalent), no `--rc` (Codex - // doesn't do remote control). The terminal's cwd is already the - // worktree path. - return "codex\n" + switch session.kind { + case .work: + // Bare `codex` launch — `.work` has no in-app prompt-file + // convention (`SessionService.initialPromptFileName` only fires + // for `.job`/`.review`). Skill-created `.work` sessions are + // seeded by `launch_codex` in `crow-workspace/setup.sh`, which + // feeds the prompt at first-launch time via `--command` (#492); + // the in-app resume path here just reopens the TUI. No env + // prefix (Codex has no OTEL equivalent), no `--continue` (MVP + // doesn't auto-resume), no `--rc` (Codex doesn't do remote + // control). + return "\(codexPath)\n" + case .job: + // First launch: feed `.crow-job-prompt.md` as the positional + // initial message so Codex starts working unattended. + // `SessionService.launchAgent` wrote the file before invoking us + // and flips `reviewPromptDispatched` (the generic "initial + // prompt dispatched" gate) after the command goes out. + // Subsequent restarts fall back to bare `codex` — Codex has no + // `--continue` equivalent in MVP, so the user just resumes the + // TUI rather than re-running the whole prompt (CROW-493). + // Mirrors `CursorAgent.autoLaunchCommand`'s `.job` branch. + if !session.reviewPromptDispatched { + let promptPath = (worktreePath as NSString) + .appendingPathComponent(".crow-job-prompt.md") + return "\(codexPath) \"$(cat \(promptPath))\"\n" + } + return "\(codexPath)\n" + case .review: + // Review-on-Codex isn't supported in Phase C — the + // `/crow-review-pr` skill is Claude-only. Returning nil tells + // `SessionService.launchAgent` to log the skip and paste a + // user-facing `⚠️` echo. + return nil + case .manager: + // Manager sessions never auto-launch an agent — Crow drives them + // externally. Matches `CursorAgent`'s `.manager` contract. + return nil + } } public func generatePrompt( diff --git a/Packages/CrowCodex/Tests/CrowCodexTests/OpenAICodexAgentTests.swift b/Packages/CrowCodex/Tests/CrowCodexTests/OpenAICodexAgentTests.swift index cd4aa19..b2c06b0 100644 --- a/Packages/CrowCodex/Tests/CrowCodexTests/OpenAICodexAgentTests.swift +++ b/Packages/CrowCodex/Tests/CrowCodexTests/OpenAICodexAgentTests.swift @@ -24,7 +24,11 @@ struct OpenAICodexAgentTests { autoPermissionMode: false, telemetryPort: nil ) - #expect(cmd == "codex\n") + // Work sessions launch a bare `codex` — prefer the absolute binary + // path when `findBinary()` resolves, otherwise fall back to the bare + // token. Either way the tail is `codex\n` (no prompt, no flags). + #expect(cmd?.hasSuffix("codex\n") == true) + #expect(cmd?.contains(".crow-job-prompt.md") == false) } @Test func autoLaunchCommandIgnoresTelemetryAndRemoteControl() { @@ -38,10 +42,18 @@ struct OpenAICodexAgentTests { autoPermissionMode: false, telemetryPort: 4318 ) - #expect(cmd == "codex\n") + #expect(cmd?.hasSuffix("codex\n") == true) + // No OTEL env-var prefix and no review/job prompt file should be + // referenced for a plain work session. + #expect(cmd?.contains("OTEL_") == false) + #expect(cmd?.contains(".crow-job-prompt.md") == false) } @Test func autoLaunchCommandReviewSessionUnsupported() { + // Review-on-Codex isn't supported in Phase C — the review skill is + // Claude-only. Returning nil tells SessionService to log a skip and + // surface a `⚠️` echo in the terminal rather than producing a + // malformed command. let session = Session(name: "review", kind: .review, agentKind: .codex) let cmd = agent.autoLaunchCommand( session: session, @@ -50,7 +62,57 @@ struct OpenAICodexAgentTests { autoPermissionMode: false, telemetryPort: nil ) - #expect(cmd == nil) // Codex review sessions aren't supported in MVP. + #expect(cmd == nil) + } + + @Test func autoLaunchCommandManagerSessionUnsupported() { + // Manager sessions never auto-launch an agent; Crow drives them + // externally. Matches Cursor's `.manager` contract. + let session = Session(name: "manager", kind: .manager, agentKind: .codex) + let cmd = agent.autoLaunchCommand( + session: session, + worktreePath: "/tmp/wt", + remoteControlEnabled: false, + autoPermissionMode: false, + telemetryPort: nil + ) + #expect(cmd == nil) + } + + @Test func autoLaunchCommandJobSessionFirstLaunch() { + // First job launch (reviewPromptDispatched == false) should pass the + // pre-written `.crow-job-prompt.md` as argv so Codex starts working + // unattended — mirrors the Claude/Cursor Jobs path (CROW-493). + let session = Session(name: "job", kind: .job, agentKind: .codex) + let cmd = agent.autoLaunchCommand( + session: session, + worktreePath: "/tmp/wt", + remoteControlEnabled: false, + autoPermissionMode: false, + telemetryPort: nil + ) + #expect(cmd != nil) + #expect(cmd?.contains(".crow-job-prompt.md") == true) + #expect(cmd?.contains("/tmp/wt/.crow-job-prompt.md") == true) + #expect(cmd?.hasSuffix("\n") == true) + } + + @Test func autoLaunchCommandJobSessionSubsequentLaunch() { + // After the initial prompt has been dispatched, the deferred-launch + // path falls back to a bare `codex` (Codex has no `--continue`), so + // restarting Crow resumes the TUI instead of re-running the prompt. + var session = Session(name: "job", kind: .job, agentKind: .codex) + session.reviewPromptDispatched = true + let cmd = agent.autoLaunchCommand( + session: session, + worktreePath: "/tmp/wt", + remoteControlEnabled: false, + autoPermissionMode: false, + telemetryPort: nil + ) + #expect(cmd != nil) + #expect(cmd?.contains(".crow-job-prompt.md") == false) + #expect(cmd?.hasSuffix("codex\n") == true) } @Test func findBinaryReturnsNilWhenAbsent() { @@ -76,6 +138,25 @@ struct OpenAICodexAgentTests { #expect(agent.findBinary() == "/bin/sh") } + @Test func autoLaunchCommandHonorsBinaryOverride() { + // The .work branch should resolve through findBinary(), not + // hardcode `"codex"` — this catches the regression of the prior + // bug where `autoLaunchCommand` ignored `defaults.binaries.codex` + // overrides (CROW-484). + BinaryOverrides.shared.set(["codex": "/bin/sh"]) + defer { BinaryOverrides.shared.set([:]) } + + let session = Session(name: "test", agentKind: .codex) + let cmd = agent.autoLaunchCommand( + session: session, + worktreePath: "/tmp/wt", + remoteControlEnabled: false, + autoPermissionMode: false, + telemetryPort: nil + ) + #expect(cmd == "/bin/sh\n") + } + @Test func findBinaryIgnoresOverrideWhenPathMissing() { // A stale override (binary moved/uninstalled after config edit) must // not break registration outright — fall through to PATH/fallback