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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,7 @@ jobs:
tests/test_cli_agent_resume.py \
tests/test_cli_agent_usage.py \
tests/test_cli_worktree_from_issue.py \
tests/test_cli_race_agent_launch.py \
tests/test_shell_state_report_ordering.py; do
PROGRAMA_CLI_BIN="$CLI_BIN" python3 "$t" || result=1
done
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p

### Fixed
- `programa handoff` pastes into a pane whose terminal has not started yet instead of failing.
- `programa race --agent opencode` now hands OpenCode the prompt with `--prompt`. OpenCode read the bare argument as a project path, so the prompt never arrived.
- With dozens of tabs in a pane, each tab keeps a width that shows its title instead of shrinking to one letter. The tab bar scrolls with a regular mouse wheel and keeps the selected tab in view.
- Renaming a tab, or a program changing its title, updates the tab bar right away.
- Launching a second build of Programa no longer cuts the running app's command line tool and agent hooks off. The new copy used to delete the running app's socket and take it over.
Expand Down
5 changes: 1 addition & 4 deletions CLI/CLI+WorktreeIssue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,6 @@ extension ProgramaCLI {
/// text is untrusted, so it reaches the agent through the file and never through the shell.
func worktreeIssueAgentLaunchCommand(agent: String, issueNumber: Int, promptFile: String) -> String {
let prompt = "Work on GitHub issue #\(issueNumber). Its title, link and description are in \(promptFile); read that file first."
if agent == "opencode" {
return "opencode --prompt \(shellSingleQuote(prompt))"
}
return "\(agent) \(shellSingleQuote(prompt))"
return raceAgentLaunchCommand(agent: agent, prompt: prompt)
}
}
6 changes: 3 additions & 3 deletions CLI/programa.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4302,9 +4302,9 @@ struct ProgramaCLI {
/// elsewhere in the app for status badges.
static let raceSupportedAgents: [String] = ["claude", "opencode", "codex"]

/// Builds the shell command line that starts an agent with `prompt` as a shell-quoted positional argument.
private func raceAgentLaunchCommand(agent: String, prompt: String) -> String {
"\(agent) \(shellSingleQuote(prompt))"
/// Builds the shell command line that starts an agent on `prompt`. OpenCode reads a bare argument as a project path.
func raceAgentLaunchCommand(agent: String, prompt: String) -> String {
"\(agent) \(agent == "opencode" ? "--prompt " : "")\(shellSingleQuote(prompt))"
}

/// Shell-quotes arbitrary text for safe inclusion in a POSIX shell command line typed into
Expand Down
91 changes: 91 additions & 0 deletions tests/test_cli_race_agent_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Behavioral contract for the command `programa race` types to start each agent.

Runs the built CLI against a fake unix-socket server (the harness from
`test_cli_registry_behavior.py`) inside a scratch git repo and checks the text
sent with `surface.send_text`:

- claude and codex get the prompt as one shell-quoted positional argument
- opencode gets it through `--prompt`, since OpenCode reads a bare argument
as the project path
"""

from __future__ import annotations

import os
import shutil
import subprocess
import tempfile
from typing import Any

from test_cli_registry_behavior import CLI_PATH, SocketRecorder, merged_output, run_cli


PROMPT = "fix the bug in Bob's parser"
QUOTED_PROMPT = "'fix the bug in Bob'\\''s parser'"


class RaceRecorder(SocketRecorder):
def _result_for(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
if method == "worktree.create":
return {
"workspace_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"workspace_ref": "workspace:5",
"worktree": {"path": "/tmp/fake-race-worktree", "branch": params.get("branch")},
}
return super()._result_for(method, params)


def main() -> int:
if not CLI_PATH or not os.path.isfile(CLI_PATH) or not os.access(CLI_PATH, os.X_OK):
print("FAIL: PROGRAMA_CLI_BIN must point to an executable programa CLI")
return 1
git = shutil.which("git")
if not git:
print("FAIL: git must be on PATH")
return 1

failures: list[str] = []
expected = {
"claude": f"claude {QUOTED_PROMPT}\n",
"codex": f"codex {QUOTED_PROMPT}\n",
"opencode": f"opencode --prompt {QUOTED_PROMPT}\n",
}

with tempfile.TemporaryDirectory(prefix="pwtr-", dir="/tmp") as root:
repo = os.path.join(root, "repo")
os.makedirs(repo)
subprocess.run([git, "init", "-q", repo], check=True)
os.chdir(repo)

for agent, command in expected.items():
socket_dir = tempfile.mkdtemp(prefix="s", dir=root)
with RaceRecorder(socket_dir) as recorder:
try:
process = run_cli(recorder.path, ["race", "--n", "1", "--agent", agent, PROMPT])
except subprocess.TimeoutExpired:
failures.append(f"{agent}: CLI timed out")
continue
failures.extend(f"{agent}: {error}" for error in recorder.errors)
if process.returncode != 0:
failures.append(f"{agent}: exit {process.returncode}: {merged_output(process)}")
continue
sends = [frame for frame in recorder.frames if frame.get("method") == "surface.send_text"]
if len(sends) != 1:
failures.append(f"{agent}: expected one surface.send_text, got {len(sends)}")
continue
text = sends[0].get("params", {}).get("text")
if text != command:
failures.append(f"{agent}: typed {text!r}, expected {command!r}")

if failures:
print("FAIL: race agent launch command")
for failure in failures:
print(f" - {failure}")
return 1
print("PASS: race agent launch command")
return 0


if __name__ == "__main__":
raise SystemExit(main())
2 changes: 1 addition & 1 deletion tests/test_cli_worktree_from_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ def run(name: str, args: list[str], *, path: str | None = None, recorder_class:
payload = None
failures.append(f"agent start fails (json): stdout is not one JSON object: {process.stdout!r}")
if isinstance(payload, dict):
check(payload.get("workspace_id") == NEW_WORKSPACE_ID, f"agent start fails (json): workspace_id was {payload.get('workspace_id')!r}")
check(payload.get("workspace_ref") == "workspace:9", f"agent start fails (json): workspace_ref was {payload.get('workspace_ref')!r}")
check("fixture surface is gone" in str(payload.get("agent_error", "")),
f"agent start fails (json): agent_error was {payload.get('agent_error')!r}")
check("starting claude failed" in process.stderr, f"agent start fails (json): stderr was {process.stderr!r}")
Expand Down
Loading