Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 40 additions & 14 deletions Packages/CrowCodex/Sources/CrowCodex/OpenAICodexAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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,
Expand All @@ -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() {
Expand All @@ -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
Expand Down
Loading