diff --git a/README.md b/README.md index 783d1837..6e7bcb7f 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Current caveat: the official registry sets `automator` to `default_channel: next - This is an orchestrator, not a correctness guarantee. Bad planning artifacts still produce bad implementation runs. - The npm installer writes the skill into every supported dependency skill root that is complete: `.agents/skills`, `.claude/skills`, and/or `.codex/skills`. -- Child sessions can use Claude or Codex depending on agent configuration. +- Child sessions can use Claude, Codex, Gemini, or a custom non-Codex agent command depending on agent configuration. - Retrospectives inherit the configured primary agent by default, and can be overridden explicitly via `agentConfig`. - The automator expects sprint planning to be complete before it starts. - Review completion is gated by verification, not by child-session exit alone. @@ -195,7 +195,7 @@ Host requirements: - `python3` 3.11+ - `tmux` -- Claude Code or Codex runtime access +- Claude Code, Codex, Gemini CLI, or configured custom-agent runtime access - macOS, Linux, or Windows via WSL Target project requirements: @@ -207,7 +207,7 @@ Target project requirements: - `bmad-retrospective` - optional `bmad-qa-generate-e2e-tests` -Claude-only, Codex-only, and mixed projects are all supported. The installer updates each supported root that already contains the required dependency `SKILL.md` files. +Claude-only, Codex-only, Gemini-assisted, custom-agent, and mixed projects are all supported. The installer updates each supported root that already contains the required dependency `SKILL.md` files. Dependency skill internals such as `workflow.md` are optional. If the QA skill is missing, install still succeeds. Run Story Automator with `Skip Automate = true` unless the QA skill is installed. diff --git a/docs/agents-and-monitoring.md b/docs/agents-and-monitoring.md index 5121615d..5ce02dfd 100644 --- a/docs/agents-and-monitoring.md +++ b/docs/agents-and-monitoring.md @@ -7,7 +7,7 @@ This doc explains how Story Automator chooses child agents, builds child-session There are two distinct agent layers: - the orchestrator itself, which runs from a supported top-level agent session -- child sessions, which can run Claude or Codex depending on the agent plan +- child sessions, which can run Claude, Codex, Gemini, or configured custom non-Codex agent commands depending on the agent plan Agent selection is driven by: @@ -74,14 +74,29 @@ sequenceDiagram Environment details: - `STORY_AUTOMATOR_CHILD=true` -- `AI_AGENT=` +- `AI_AGENT=` - Codex child sessions use isolated `CODEX_HOME` under `/tmp` +- Gemini child sessions use `gemini --approval-mode yolo -p` by default so autonomous workflows can edit files and run tests. ⚠️ `yolo` auto-approves every tool call (file writes, shell, tests) without prompting, so only run it in a trusted workspace you control; do not point a yolo Gemini session at untrusted code or a workspace holding secrets +- custom non-Codex agents can be configured with `STORY_AUTOMATOR_AGENT__COMMAND`; set `STORY_AUTOMATOR_AGENT__PROCESS` only when the monitor should look for a different process name -## Claude vs Codex +## Agent CLI selection -Python Story Automator does support Codex child sessions. +Python Story Automator supports Claude, Codex, Gemini, and custom non-Codex child sessions at the runtime/dispatch layer. Unknown agent names fail fast unless a custom command is configured, so typos do not silently run under Claude. -That is a major difference from the older Go README guidance. +Runtime support means the wrapper launches the requested CLI and monitors the requested process. The agent still needs a local tool contract capable of the target workflow. For example, BMAD create/dev/auto/review steps require file reads/writes and often shell/test execution; a Gemini CLI setup without those tools may launch correctly but fail inside the workflow. + +Built-in command mapping: + +- `claude` → `claude --dangerously-skip-permissions` +- `codex` → `codex exec` / isolated Codex execution path +- `gemini` → `gemini --approval-mode yolo -p` + +Custom command example: + +```bash +export STORY_AUTOMATOR_AGENT_GLM_COMMAND='glm-cli --prompt' +export STORY_AUTOMATOR_AGENT_GLM_PROCESS='glm-cli' # optional; defaults to agent name +``` Important Codex-specific behavior: diff --git a/skills/bmad-story-automator/data/agent-fallback.md b/skills/bmad-story-automator/data/agent-fallback.md index ae7997b9..333023ca 100644 --- a/skills/bmad-story-automator/data/agent-fallback.md +++ b/skills/bmad-story-automator/data/agent-fallback.md @@ -1,6 +1,6 @@ # Agent Fallback Strategy (v3.0.0) -**Multi-Agent Support:** The orchestrator can use Claude or Codex as AI coding agents, with automatic fallback on failure. +**Multi-Agent Support:** The orchestrator can use Claude, Codex, Gemini, or configured custom non-Codex commands as AI coding agents, with automatic fallback on failure. ## Configuration @@ -28,9 +28,13 @@ Agent selection is resolved via the deterministic agents file created in preflig | Agent | CLI | Prompt Style | Timeout | Todo Tracking | |-------|-----|--------------|---------|---------------| | Claude | `claude --dangerously-skip-permissions` | Natural language skill prompt | 60min | ☒/☐ checkboxes | -| Codex | `codex exec --full-auto` | Natural language prompt | 90min (1.5x) | Not supported | +| Codex | isolated `codex exec -s workspace-write -c 'approval_policy="never"' -c 'model_reasoning_effort="high"' --disable plugins --disable sqlite --disable shell_snapshot` (under a temp `CODEX_HOME`) | Natural language prompt | 90min (1.5x) | Not supported | +| Gemini | `gemini --approval-mode yolo -p` (with model: `gemini --approval-mode yolo --model -p`) | Natural language skill prompt | 60min | best-effort via generic monitor | +| Custom | `STORY_AUTOMATOR_AGENT__COMMAND` | Natural language skill prompt | 60min | best-effort via configured process pattern | -**CRITICAL:** Both Claude and Codex prompts must name the skill/workflow to execute and include the story ID. +**CRITICAL:** All child-agent prompts must name the skill/workflow to execute and include the story ID. + +> ⚠️ **Gemini `--approval-mode yolo` safety:** `yolo` auto-approves every tool call (file edits, shell, test runs) with no prompt, so the child session can modify the workspace and run commands fully autonomously. Only use it for Gemini child sessions in a trusted workspace you control (the orchestrator runs in BMAD-managed repos with `GEMINI_CLI_TRUST_WORKSPACE=true`). Do not point a yolo Gemini session at untrusted code or a workspace holding secrets/credentials. The `story-automator tmux-wrapper build-cmd` function automatically generates the correct prompt format based on `AI_AGENT` environment variable. diff --git a/skills/bmad-story-automator/data/tmux-commands.md b/skills/bmad-story-automator/data/tmux-commands.md index f7faecab..71cdae47 100644 --- a/skills/bmad-story-automator/data/tmux-commands.md +++ b/skills/bmad-story-automator/data/tmux-commands.md @@ -188,15 +188,15 @@ tmux capture-pane -t "SESSION" -p -S -100 # Raw capture (use sparingly) **Agent Configuration (v1.3.0):** -| Variable | Claude | Codex | -|----------|--------|-------| -| CLI | `claude --dangerously-skip-permissions` | `codex exec --full-auto` | -| Prompt Style | Natural language skill prompt | Natural language skill prompt | -| Timeout Multiplier | 1x (60min) | 1.5x (90min) | -| Todo Tracking | ☒/☐ checkboxes | Not supported | +| Variable | Claude | Codex | Gemini | Custom non-Codex | +|----------|--------|-------|--------|------------------| +| CLI | `claude --dangerously-skip-permissions` | `codex exec -s workspace-write -c 'approval_policy="never"' -c 'model_reasoning_effort="high"' --disable plugins --disable sqlite --disable shell_snapshot` | `gemini --approval-mode yolo -p` | `STORY_AUTOMATOR_AGENT__COMMAND` | +| Prompt Style | Natural language skill prompt | Natural language skill prompt | Natural language skill prompt | Natural language skill prompt | +| Timeout Multiplier | 1x (60min) | 1.5x (90min) | 1x (60min) | 1x (60min) | +| Todo Tracking | ☒/☐ checkboxes | Not supported | best-effort generic monitor | best-effort generic monitor | **Environment Variables:** -- `AI_AGENT` = `claude` or `codex` (used by story-automator tmux-wrapper and story-automator monitor-session) +- `AI_AGENT` = `claude`, `codex`, `gemini`, or a custom agent name (used by story-automator tmux-wrapper and story-automator monitor-session) - `AI_COMMAND` = Full CLI (legacy, deprecated) `{projectPath}` = project root diff --git a/skills/bmad-story-automator/src/story_automator/adapters/tmux.py b/skills/bmad-story-automator/src/story_automator/adapters/tmux.py index 5421a015..14cd5115 100644 --- a/skills/bmad-story-automator/src/story_automator/adapters/tmux.py +++ b/skills/bmad-story-automator/src/story_automator/adapters/tmux.py @@ -7,7 +7,7 @@ from ..core.tmux_runtime import ( agent_cli, agent_type, - detect_codex_session, + detect_agent_session, estimate_wait, extract_active_task, generate_session_name, diff --git a/skills/bmad-story-automator/src/story_automator/commands/tmux.py b/skills/bmad-story-automator/src/story_automator/commands/tmux.py index 1d62e10c..2a1e49ce 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/tmux.py +++ b/skills/bmad-story-automator/src/story_automator/commands/tmux.py @@ -97,7 +97,11 @@ def cmd_tmux_wrapper(args: list[str]) -> int: idx += 2 continue idx += 1 - print(agent_cli(agent_type(), model)) + try: + print(agent_cli(agent_type(), model)) + except ValueError as exc: + print(str(exc), file=__import__("sys").stderr) + return 1 return 0 if action == "skill-prefix": print(skill_prefix(agent_type())) @@ -201,9 +205,16 @@ def _build_cmd(args: list[str]) -> int: return 1 ai_command = os.environ.get("AI_COMMAND", "").strip() if ai_command and not os.environ.get("AI_AGENT"): - cli = ai_command + if agent == "gemini": + cli = agent_cli(agent, model) + else: + cli = ai_command elif agent != "codex": - cli = agent_cli(agent, model) + try: + cli = agent_cli(agent, model) + except ValueError as exc: + print(str(exc), file=__import__("sys").stderr) + return 1 else: cli = "codex exec" quoted_prompt = shlex.quote(prompt) @@ -470,11 +481,16 @@ def _flag_value(args: list[str], idx: int, flag: str) -> str: def _raw_agent_selection() -> str: value = os.environ.get("AI_AGENT", "").strip().lower() - if not value: - inferred = _infer_agent_from_command(os.environ.get("AI_COMMAND", "")) - if inferred: - return inferred - return value if value in {"claude", "codex", "auto", "runtime"} else "auto" + if value: + # A non-empty AI_AGENT is authoritative: preserve the normalized name so + # agent_cli can use a configured custom command or fail fast on a typo. + # Collapsing unknown values to "auto" would silently break env-configured + # custom agents and violate the fail-fast contract. + return value + inferred = _infer_agent_from_command(os.environ.get("AI_COMMAND", "")) + if inferred: + return inferred + return "auto" def _resolve_agent_selection(agent: str, project_root: str) -> str: @@ -490,8 +506,11 @@ def _infer_agent_from_command(command: str) -> str: executable = Path(shlex.split(value)[0]).name.lower() except ValueError: return "" - if "codex" in executable: + token = Path(executable).stem.lower() + if token in {"codex", "codex-cli"}: return "codex" - if "claude" in executable: + if token == "claude": return "claude" + if token in {"gemini", "gemini-cli"}: + return "gemini" return "" diff --git a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py index 75dbe1a8..bfe9eac9 100644 --- a/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py +++ b/skills/bmad-story-automator/src/story_automator/core/tmux_runtime.py @@ -79,23 +79,101 @@ def generate_session_name(step: str, epic: str, story_id: str, cycle: str = "") def agent_type() -> str: - value = os.environ.get("AI_AGENT", "").strip().lower() - if value in {"claude", "codex"}: + value = normalize_agent_name(os.environ.get("AI_AGENT", "")) + # `auto`/`runtime` are meta-selectors, not agent names: resolve them through + # the runtime provider so they never flow into agent_cli() (which rejects + # them). Built-ins and configured custom agent names pass through verbatim. + if value and value not in {"auto", "runtime"}: return value return runtime_provider() def agent_cli(agent: str, model: str = "") -> str: + agent = normalize_agent_name(agent) model = (model or "").strip() - if agent == "codex": + custom = custom_agent_command(agent) + if custom: + base = custom + elif agent in {"", "claude"}: + base = "claude --dangerously-skip-permissions" + elif agent == "codex": base = "codex exec" + elif agent == "gemini": + # `-p` consumes the next argument as the prompt, so `--model` must come + # before it; otherwise `-p` swallows `--model` and the model id is lost. + if model: + return f"gemini --approval-mode yolo --model {shlex.quote(model)} -p" + return "gemini --approval-mode yolo -p" else: - base = "claude --dangerously-skip-permissions" + raise ValueError( + f"unsupported agent {agent!r}; supported agents are claude, codex, gemini, " + f"or set STORY_AUTOMATOR_AGENT_{env_agent_name(agent)}_COMMAND" + ) if model: - base = f"{base} --model {shlex.quote(model)}" + quoted = shlex.quote(model) + if custom and base.endswith(" -p"): + # Custom CLIs that end in the prompt flag (e.g. a Gemini-like + # `... -p`) consume the next token as the prompt, so `--model` must + # precede `-p`; appending it afterwards would let `-p` swallow it. + base = f"{base[: -len(' -p')]} --model {quoted} -p" + else: + base = f"{base} --model {quoted}" return base +def custom_agent_command(agent: str) -> str: + if not agent: + return "" + for key in ( + f"STORY_AUTOMATOR_AGENT_{env_agent_name(agent)}_COMMAND", + f"AI_COMMAND_{env_agent_name(agent)}", + ): + value = os.environ.get(key, "").strip() + if value: + return value + return "" + + +def _command_basename(command: str) -> str: + value = (command or "").strip() + if not value: + return "" + try: + executable = shlex.split(value)[0] + except (ValueError, IndexError): + return "" + return Path(executable).name + + +def normalize_agent_name(agent: str) -> str: + return (agent or "").strip().lower() + + +def env_agent_name(agent: str) -> str: + clean = re.sub(r"[^A-Za-z0-9]+", "_", agent or "").strip("_") + return clean.upper() + + +def agent_process_pattern(agent: str) -> str: + agent = normalize_agent_name(agent) + if not agent: + return "claude" + custom = os.environ.get(f"STORY_AUTOMATOR_AGENT_{env_agent_name(agent)}_PROCESS", "").strip() + if custom: + return custom + if agent == "codex": + return "codex" + if agent == "gemini": + return "gemini" + # For env-configured custom agents the alias (e.g. "gemini-pro") rarely names + # the real process. Derive the basename from the configured command so the + # monitor watches the actual executable instead of the alias. + command_basename = _command_basename(custom_agent_command(agent)) + if command_basename: + return command_basename + return agent + + def skill_prefix(agent: str) -> str: return "none" if agent == "codex" else "bmad-" @@ -348,11 +426,14 @@ def extract_active_task(capture: str) -> str: return active[:80] -def detect_codex_session(session: str, capture: str) -> str: - if tmux_show_environment(session, "AI_AGENT") == "codex": - return "codex" +def detect_agent_session(session: str, capture: str) -> str: + env_agent = normalize_agent_name(tmux_show_environment(session, "AI_AGENT")) + if env_agent: + return env_agent if re.search(r"(?i)OpenAI Codex|codex exec|gpt-[0-9]+-codex|tokens used|codex-cli", capture): return "codex" + if re.search(r"(?i)Gemini CLI|gemini -p|gemini-cli|gemini --approval-mode", capture): + return "gemini" return "claude" @@ -414,6 +495,8 @@ def _spawn_runner(session: str, command: str, selected_agent: str, project_root: "CLAUDECODE=", "-e", "BASH_ENV=", + "-e", + "GEMINI_CLI_TRUST_WORKSPACE=true", *PLACEHOLDER_COMMAND, ) if create_code != 0: @@ -508,6 +591,8 @@ def _spawn_legacy(session: str, command: str, selected_agent: str, project_root: f"AI_AGENT={selected_agent}", "-e", "CLAUDECODE=", + "-e", + "GEMINI_CLI_TRUST_WORKSPACE=true", ) if code != 0: return (output, code) @@ -861,8 +946,9 @@ def _legacy_claude_session_status( "session_state": "completed", } + agent = detect_agent_session(session, capture) pane_pid = _safe_int(tmux_display(session, "#{pane_pid}")) - claude_running = pane_pid > 0 and run_cmd("pgrep", "-P", str(pane_pid), "-f", "claude")[1] == 0 + agent_running = _pane_has_agent_descendant(pane_pid, agent_process_pattern(agent)) activity_detected = bool( re.search( r"(?i)ctrl\+c to interrupt|Musing|Thinking|Working|Running|Loading|Beaming|Galloping|Razzmatazzing|Creating|⏺|✻|·", @@ -870,8 +956,8 @@ def _legacy_claude_session_status( ) ) - if activity_detected or claude_running: - active_task = extract_active_task(capture) or "Claude working" + if activity_detected or agent_running: + active_task = extract_active_task(capture) or f"{agent.title()} working" wait_estimate = estimate_wait(active_task, todos_done, todos_total) _save_legacy_state( state_path, @@ -934,7 +1020,7 @@ def _legacy_heartbeat_check(session: str, selected_agent: str) -> tuple[str, flo pane_pid = _safe_int(tmux_display(session, "#{pane_pid}")) if pane_pid <= 0: return ("completed" if prompt == "true" else "dead", 0.0, "", prompt) - pattern = "codex" if selected_agent == "codex" else "claude" + pattern = agent_process_pattern(selected_agent) agent_pid = _find_agent_pid(str(pane_pid), pattern, 0) if not agent_pid: return ("completed" if prompt == "true" else "dead", 0.0, "", prompt) @@ -1252,6 +1338,62 @@ def _find_agent_pid(parent: str, pattern: str, depth: int) -> str: return "" +def _pane_has_agent_descendant(pane_pid: int, pattern: str) -> bool: + """Return True if any process matching ``pattern`` is a descendant of ``pane_pid``. + + ``pgrep -P`` only matches direct children, so it misses agents launched + through a wrapper or shell (the agent then runs as a grandchild). Here we + collect candidate PIDs by command pattern and confirm ancestry by walking + parent PIDs back up to ``pane_pid``. + """ + if pane_pid <= 0: + return False + output, code = run_cmd("pgrep", "-f", pattern) + if code != 0: + return False + for line in output.splitlines(): + candidate = _safe_int(line.strip()) + if candidate <= 0 or candidate == pane_pid: + continue + if _pid_has_ancestor(candidate, pane_pid): + return True + return False + + +def _pid_has_ancestor(pid: int, ancestor: int, max_depth: int = 32) -> bool: + current = pid + seen: set[int] = set() + for _ in range(max_depth): + ppid = _process_ppid(current) + if ppid <= 0 or ppid == current or ppid in seen: + return False + if ppid == ancestor: + return True + seen.add(ppid) + current = ppid + return False + + +def _process_ppid(pid: int) -> int: + """Return the parent PID of ``pid`` (prefer ``/proc`` on Linux, fall back to ``ps``).""" + try: + raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8", errors="replace") + except OSError: + raw = "" + if raw: + # Format: "pid (comm) state ppid ...". comm may contain spaces or + # parentheses, so split after the final ')' to read state/ppid safely. + rparen = raw.rfind(")") + if rparen != -1: + fields = raw[rparen + 1:].split() + if len(fields) >= 2: + return _safe_int(fields[1]) + output, code = run_cmd("ps", "-o", "ppid=", "-p", str(pid)) + if code != 0: + return 0 + return _safe_int(output.strip()) + + def _process_cpu(pid: int) -> float: output, code = run_cmd("ps", "-o", "%cpu=", "-p", str(pid)) if code != 0: diff --git a/tests/test_agent_config_model.py b/tests/test_agent_config_model.py index 32f27469..89a07a06 100644 --- a/tests/test_agent_config_model.py +++ b/tests/test_agent_config_model.py @@ -17,14 +17,23 @@ resolve_agent_for_task, resolve_agents, ) -from story_automator.core.tmux_runtime import agent_cli +from story_automator.core.tmux_runtime import ( + agent_cli, + agent_process_pattern, + detect_agent_session, + env_agent_name, +) from story_automator.commands.orchestrator import cmd_orchestrator_helper from story_automator.commands.orchestrator_epic_agents import ( parse_agent_config, resolve_agent, ) from story_automator.commands.state import cmd_build_state_doc -from story_automator.commands.tmux import _build_cmd +from story_automator.commands.tmux import ( + _build_cmd, + _infer_agent_from_command, + _raw_agent_selection, +) REPO_ROOT = Path(__file__).resolve().parents[1] @@ -35,6 +44,117 @@ def test_agent_cli_without_model_unchanged(self) -> None: self.assertEqual(agent_cli("claude"), "claude --dangerously-skip-permissions") self.assertEqual(agent_cli("codex"), "codex exec") + def test_agent_cli_supports_gemini(self) -> None: + self.assertEqual(agent_cli("gemini"), "gemini --approval-mode yolo -p") + self.assertEqual(agent_cli(" Gemini "), "gemini --approval-mode yolo -p") + + def test_agent_cli_gemini_with_model_keeps_prompt_flag_last(self) -> None: + # Regression: `-p` consumes the next token as the prompt, so `--model` + # must precede it. Appending `--model` after `-p` makes `-p` swallow it. + self.assertEqual( + agent_cli("gemini", "gemini-2.5-pro"), + "gemini --approval-mode yolo --model gemini-2.5-pro -p", + ) + + def test_agent_cli_gemini_model_is_quoted(self) -> None: + cli = agent_cli("gemini", "gemini pro[exp]") + self.assertIn("--model 'gemini pro[exp]'", cli) + self.assertTrue(cli.endswith(" -p"), cli) + + def test_agent_cli_rejects_unknown_without_custom_command(self) -> None: + with self.assertRaisesRegex(ValueError, "unsupported agent"): + agent_cli("not-a-real-agent") + + def test_agent_cli_supports_custom_command(self) -> None: + with patch.dict(os.environ, {"STORY_AUTOMATOR_AGENT_MY_AGENT_COMMAND": "my-agent --prompt"}, clear=False): + self.assertEqual(agent_cli("my-agent"), "my-agent --prompt") + + def test_agent_cli_supports_legacy_custom_command_env(self) -> None: + with patch.dict(os.environ, {"AI_COMMAND_MY_AGENT": "legacy-agent --prompt"}, clear=False): + self.assertEqual(agent_cli("my-agent"), "legacy-agent --prompt") + + def test_agent_cli_custom_command_with_trailing_prompt_flag_keeps_model_before_p(self) -> None: + # Regression: a custom Gemini-like command ending in `-p` consumes the + # next token as the prompt. The model must be injected before `-p`, not + # appended after it (where `-p` would swallow it). + with patch.dict( + os.environ, + {"STORY_AUTOMATOR_AGENT_GEMINI_PRO_COMMAND": "gemini --approval-mode yolo -p"}, + clear=False, + ): + cli = agent_cli("gemini-pro", "gemini-2.5-pro") + self.assertEqual(cli, "gemini --approval-mode yolo --model gemini-2.5-pro -p") + self.assertTrue(cli.endswith(" -p"), cli) + self.assertNotIn("-p --model", cli) + + def test_agent_cli_custom_command_trailing_prompt_flag_quotes_model(self) -> None: + with patch.dict( + os.environ, + {"STORY_AUTOMATOR_AGENT_GEMINI_PRO_COMMAND": "gemini -p"}, + clear=False, + ): + cli = agent_cli("gemini-pro", "gemini pro[exp]") + self.assertEqual(cli, "gemini --model 'gemini pro[exp]' -p") + + def test_agent_cli_custom_command_without_trailing_prompt_flag_appends_model(self) -> None: + with patch.dict( + os.environ, + {"STORY_AUTOMATOR_AGENT_MY_AGENT_COMMAND": "my-agent run"}, + clear=False, + ): + cli = agent_cli("my-agent", "some-model") + self.assertEqual(cli, "my-agent run --model some-model") + + def test_agent_process_pattern_supports_gemini_and_custom(self) -> None: + self.assertEqual(agent_process_pattern("gemini"), "gemini") + self.assertEqual(agent_process_pattern("my-agent"), "my-agent") + with patch.dict(os.environ, {"STORY_AUTOMATOR_AGENT_MY_AGENT_PROCESS": "my-agent-bin"}, clear=False): + self.assertEqual(agent_process_pattern("my-agent"), "my-agent-bin") + + def test_agent_process_pattern_derives_basename_from_custom_command(self) -> None: + # Regression: without an explicit PROCESS override, monitor the real + # executable from the configured command, not the alias name. + with patch.dict( + os.environ, + {"STORY_AUTOMATOR_AGENT_GEMINI_PRO_COMMAND": "/usr/local/bin/gemini -p --model pro"}, + clear=False, + ): + self.assertEqual(agent_process_pattern("gemini-pro"), "gemini") + + def test_agent_process_pattern_explicit_override_beats_command_basename(self) -> None: + with patch.dict( + os.environ, + { + "STORY_AUTOMATOR_AGENT_GEMINI_PRO_COMMAND": "gemini -p", + "STORY_AUTOMATOR_AGENT_GEMINI_PRO_PROCESS": "gemini-wrapper", + }, + clear=False, + ): + self.assertEqual(agent_process_pattern("gemini-pro"), "gemini-wrapper") + + def test_env_agent_name_normalizes_for_env_vars(self) -> None: + self.assertEqual(env_agent_name("gemini-pro preview"), "GEMINI_PRO_PREVIEW") + + def test_detect_agent_session_supports_gemini_capture(self) -> None: + with patch("story_automator.core.tmux_runtime.tmux_show_environment", return_value=""): + self.assertEqual(detect_agent_session("session", "Gemini CLI ready"), "gemini") + for marker in ("gemini -p prompt", "gemini-cli v1", "gemini --approval-mode yolo"): + self.assertEqual(detect_agent_session("session", marker), "gemini") + + def test_detect_agent_session_ignores_bare_gemini_mention(self) -> None: + # Regression: a stray "Gemini" word in output (e.g. discussing the model) + # must not be misread as a Gemini CLI session. Env AI_AGENT stays + # authoritative; capture heuristics need CLI-specific markers. + with patch("story_automator.core.tmux_runtime.tmux_show_environment", return_value=""): + self.assertEqual( + detect_agent_session("session", "Comparing Gemini and Claude output quality"), + "claude", + ) + + def test_detect_agent_session_env_remains_authoritative(self) -> None: + with patch("story_automator.core.tmux_runtime.tmux_show_environment", return_value="gemini-pro"): + self.assertEqual(detect_agent_session("session", "no markers here"), "gemini-pro") + def test_agent_cli_with_model_for_claude(self) -> None: self.assertEqual( agent_cli("claude", "claude-sonnet-4-6"), @@ -553,6 +673,57 @@ def test_build_cmd_missing_model_value_fails(self) -> None: self.assertIn("--model requires a value", err.getvalue()) +class RawAgentSelectionTests(unittest.TestCase): + def test_ai_agent_gemini_is_preserved(self) -> None: + with patch.dict(os.environ, {"AI_AGENT": "gemini"}, clear=False): + self.assertEqual(_raw_agent_selection(), "gemini") + + def test_ai_agent_gemini_is_normalized(self) -> None: + with patch.dict(os.environ, {"AI_AGENT": " GEMINI "}, clear=False): + self.assertEqual(_raw_agent_selection(), "gemini") + + def test_known_agents_are_preserved(self) -> None: + for agent in ("claude", "codex", "gemini", "auto", "runtime"): + with patch.dict(os.environ, {"AI_AGENT": agent}, clear=False): + self.assertEqual(_raw_agent_selection(), agent) + + def test_nonempty_unknown_agent_is_preserved_not_collapsed_to_auto(self) -> None: + # Regression: a non-empty AI_AGENT (e.g. an env-configured custom agent) + # must be preserved verbatim (normalized), not collapsed to "auto". + # Collapsing it broke custom agents and violated fail-fast. + with patch.dict(os.environ, {"AI_AGENT": "gemini-pro"}, clear=False): + self.assertEqual(_raw_agent_selection(), "gemini-pro") + with patch.dict(os.environ, {"AI_AGENT": " Gemini-Pro "}, clear=False): + self.assertEqual(_raw_agent_selection(), "gemini-pro") + + def test_empty_agent_without_command_returns_auto(self) -> None: + # Only an empty AI_AGENT with no AI_COMMAND inference should fall back to "auto". + with patch.dict(os.environ, {"AI_AGENT": "", "AI_COMMAND": ""}, clear=False): + self.assertEqual(_raw_agent_selection(), "auto") + + def test_infer_gemini_from_command_when_ai_agent_empty(self) -> None: + with patch.dict(os.environ, {"AI_AGENT": "", "AI_COMMAND": "gemini -p"}, clear=False): + self.assertEqual(_raw_agent_selection(), "gemini") + + +class InferAgentFromCommandTests(unittest.TestCase): + def test_detects_gemini_executable(self) -> None: + self.assertEqual(_infer_agent_from_command("gemini -p"), "gemini") + self.assertEqual(_infer_agent_from_command("/usr/local/bin/gemini --model pro"), "gemini") + + def test_detects_claude_and_codex(self) -> None: + self.assertEqual(_infer_agent_from_command("claude --dangerously-skip-permissions"), "claude") + self.assertEqual(_infer_agent_from_command("codex exec"), "codex") + + def test_empty_and_unknown_return_empty(self) -> None: + self.assertEqual(_infer_agent_from_command(""), "") + self.assertEqual(_infer_agent_from_command("some-other-tool run"), "") + self.assertEqual(_infer_agent_from_command("my-gemini-wrapper run"), "") + self.assertEqual(_infer_agent_from_command("codextra run"), "") + self.assertEqual(_infer_agent_from_command("prefix-claude run"), "") + self.assertEqual(_infer_agent_from_command("gemini-wrapper run"), "") + + class BuildCmdModelFlagTests(unittest.TestCase): def setUp(self) -> None: self.tmp = tempfile.TemporaryDirectory() @@ -580,6 +751,67 @@ def test_build_cmd_injects_model_for_codex(self) -> None: self.assertIn("--model gpt-5.5", rendered) self.assertIn("codex exec -s workspace-write", rendered) + def test_build_cmd_uses_gemini_without_claude_fallback(self) -> None: + stdout = io.StringIO() + with patch.dict(os.environ, {"PROJECT_ROOT": str(self.project_root), "AI_AGENT": "gemini"}, clear=False), redirect_stdout(stdout): + code = _build_cmd(["review", "9.1", "--agent", "gemini"]) + self.assertEqual(code, 0) + rendered = stdout.getvalue() + self.assertIn("gemini --approval-mode yolo -p", rendered) + self.assertNotIn("claude --dangerously-skip-permissions", rendered) + + def test_build_cmd_honors_ai_agent_gemini_without_explicit_flag(self) -> None: + """Repro: AI_AGENT=gemini with no `--agent` must still resolve to + gemini (not silently fall back to `auto`/claude). Exercises the + `_raw_agent_selection` allowlist path that `_build_cmd` takes when + no `--agent` flag is supplied.""" + stdout = io.StringIO() + with patch.dict(os.environ, {"PROJECT_ROOT": str(self.project_root), "AI_AGENT": "gemini"}, clear=False), redirect_stdout(stdout): + code = _build_cmd(["review", "9.1"]) + self.assertEqual(code, 0) + rendered = stdout.getvalue() + self.assertIn("gemini --approval-mode yolo -p", rendered) + self.assertNotIn("claude --dangerously-skip-permissions", rendered) + + def test_build_cmd_honors_ai_command_only_gemini_without_raw_bypass(self) -> None: + stdout = io.StringIO() + env = {"PROJECT_ROOT": str(self.project_root), "AI_COMMAND": "gemini", "AI_AGENT": ""} + with patch.dict(os.environ, env, clear=False), redirect_stdout(stdout): + code = _build_cmd(["review", "9.1", "--model", "gemini-2.5-pro"]) + self.assertEqual(code, 0) + rendered = stdout.getvalue() + self.assertIn("gemini --approval-mode yolo --model gemini-2.5-pro -p", rendered) + self.assertNotIn("claude --dangerously-skip-permissions", rendered) + + def test_build_cmd_preserves_ai_command_only_custom_command(self) -> None: + stdout = io.StringIO() + env = {"PROJECT_ROOT": str(self.project_root), "AI_COMMAND": "custom-agent --prompt", "AI_AGENT": ""} + with patch.dict(os.environ, env, clear=False), redirect_stdout(stdout): + code = _build_cmd(["review", "9.1"]) + self.assertEqual(code, 0) + self.assertIn("custom-agent --prompt", stdout.getvalue()) + + def test_build_cmd_rejects_unknown_agent_without_claude_fallback(self) -> None: + stdout = io.StringIO() + stderr = io.StringIO() + with patch.dict(os.environ, {"PROJECT_ROOT": str(self.project_root), "AI_AGENT": "claude"}, clear=False), redirect_stdout(stdout), __import__("contextlib").redirect_stderr(stderr): + code = _build_cmd(["review", "9.1", "--agent", "gemini-pro"]) + self.assertEqual(code, 1) + self.assertNotIn("claude --dangerously-skip-permissions", stdout.getvalue()) + self.assertIn("unsupported agent", stderr.getvalue()) + + def test_build_cmd_allows_custom_agent_command(self) -> None: + stdout = io.StringIO() + env = { + "PROJECT_ROOT": str(self.project_root), + "AI_AGENT": "gemini-pro", + "STORY_AUTOMATOR_AGENT_GEMINI_PRO_COMMAND": "gemini -p --model pro", + } + with patch.dict(os.environ, env, clear=False), redirect_stdout(stdout): + code = _build_cmd(["review", "9.1", "--agent", "gemini-pro"]) + self.assertEqual(code, 0) + self.assertIn("gemini -p --model pro", stdout.getvalue()) + def test_build_cmd_without_model_unchanged(self) -> None: stdout = io.StringIO() with patch.dict(os.environ, {"PROJECT_ROOT": str(self.project_root), "AI_AGENT": "claude"}, clear=False), redirect_stdout(stdout): diff --git a/tests/test_tmux_runtime.py b/tests/test_tmux_runtime.py index 9a4a97f5..18ceae68 100644 --- a/tests/test_tmux_runtime.py +++ b/tests/test_tmux_runtime.py @@ -10,9 +10,12 @@ from story_automator.core.tmux_runtime import ( PaneSnapshot, + agent_type, _check_prompt_visible, _claude_completion_marker_present, _legacy_heartbeat_check, + _pane_has_agent_descendant, + _pid_has_ancestor, _reconcile_runner_state, _runner_file_content, cleanup_runtime_artifacts, @@ -481,6 +484,104 @@ def test_legacy_heartbeat_treats_fractional_cpu_as_alive(self) -> None: self.assertEqual(pid, "12") self.assertEqual(prompt, "false") + def test_legacy_heartbeat_uses_selected_agent_process_pattern(self) -> None: + with ( + mock.patch("story_automator.core.tmux_runtime.tmux_has_session", return_value=True), + mock.patch("story_automator.core.tmux_runtime._capture_text", return_value="working"), + mock.patch("story_automator.core.tmux_runtime.tmux_display", return_value="10"), + mock.patch("story_automator.core.tmux_runtime._find_agent_pid", return_value="12") as find_pid, + mock.patch("story_automator.core.tmux_runtime._process_cpu", return_value=0.5), + ): + status, _cpu, pid, _prompt = _legacy_heartbeat_check("sa-test-legacy-gemini", "gemini") + + self.assertEqual(status, "alive") + self.assertEqual(pid, "12") + find_pid.assert_called_once_with("10", "gemini", 0) + + +class AgentTypeResolutionTests(unittest.TestCase): + def test_auto_resolves_through_runtime_provider(self) -> None: + with ( + mock.patch.dict(os.environ, {"AI_AGENT": "auto"}, clear=False), + mock.patch("story_automator.core.tmux_runtime.runtime_provider", return_value="codex"), + ): + self.assertEqual(agent_type(), "codex") + + def test_runtime_resolves_through_runtime_provider(self) -> None: + with ( + mock.patch.dict(os.environ, {"AI_AGENT": " RUNTIME "}, clear=False), + mock.patch("story_automator.core.tmux_runtime.runtime_provider", return_value="claude"), + ): + self.assertEqual(agent_type(), "claude") + + def test_empty_resolves_through_runtime_provider(self) -> None: + with ( + mock.patch.dict(os.environ, {"AI_AGENT": ""}, clear=False), + mock.patch("story_automator.core.tmux_runtime.runtime_provider", return_value="claude"), + ): + self.assertEqual(agent_type(), "claude") + + def test_builtin_agent_passes_through_without_provider(self) -> None: + with ( + mock.patch.dict(os.environ, {"AI_AGENT": " Gemini "}, clear=False), + mock.patch( + "story_automator.core.tmux_runtime.runtime_provider", + side_effect=AssertionError("runtime_provider must not be consulted for explicit agents"), + ), + ): + self.assertEqual(agent_type(), "gemini") + + def test_custom_agent_name_passes_through(self) -> None: + with ( + mock.patch.dict(os.environ, {"AI_AGENT": "gemini-pro"}, clear=False), + mock.patch( + "story_automator.core.tmux_runtime.runtime_provider", + side_effect=AssertionError("runtime_provider must not be consulted for explicit agents"), + ), + ): + self.assertEqual(agent_type(), "gemini-pro") + + +class PaneDescendantDetectionTests(unittest.TestCase): + def test_detects_grandchild_agent_via_ancestry_walk(self) -> None: + # pane(100) -> shell(200) -> claude(300); pgrep -f matches only the grandchild. + parents = {300: 200, 200: 100} + with ( + mock.patch("story_automator.core.tmux_runtime.run_cmd", return_value=("300\n", 0)), + mock.patch( + "story_automator.core.tmux_runtime._process_ppid", + side_effect=lambda pid: parents.get(pid, 0), + ), + ): + self.assertTrue(_pane_has_agent_descendant(100, "claude")) + + def test_ignores_matching_process_outside_pane_tree(self) -> None: + # An unrelated claude process whose ancestry never reaches the pane pid. + parents = {300: 999, 999: 1} + with ( + mock.patch("story_automator.core.tmux_runtime.run_cmd", return_value=("300\n", 0)), + mock.patch( + "story_automator.core.tmux_runtime._process_ppid", + side_effect=lambda pid: parents.get(pid, 0), + ), + ): + self.assertFalse(_pane_has_agent_descendant(100, "claude")) + + def test_no_match_when_pgrep_finds_nothing(self) -> None: + with mock.patch("story_automator.core.tmux_runtime.run_cmd", return_value=("", 1)): + self.assertFalse(_pane_has_agent_descendant(100, "claude")) + + def test_invalid_pane_pid_short_circuits(self) -> None: + self.assertFalse(_pane_has_agent_descendant(0, "claude")) + + def test_pid_has_ancestor_handles_cycles(self) -> None: + # Defensive: a self/looping parent chain must terminate, not hang. + with mock.patch( + "story_automator.core.tmux_runtime._process_ppid", + side_effect=lambda pid: pid, + ): + self.assertFalse(_pid_has_ancestor(300, 100)) + if __name__ == "__main__": unittest.main()