#!/usr/bin/env bats # The ai launcher's --helper flag: open a SECOND agent session in a project that # already has a live one, under the helper-mode.org role contract. # # The load-bearing behavior is the roster gate. `ai --helper` is an assertion by # the operator that a primary is already running; the roster is what checks it. # Three outcomes, all tested here: confirmed (launch a helper), refuted (no other # agent — warn and launch a normal primary instead), and unverifiable (no roster # script, or a platform without /proc — warn and launch a helper anyway, because # helper mode is the strictly less destructive guess when we cannot tell). # # --print-launch is the seam, as it is for the runtime tests: it prints the exact # command a real run would send to the pane without touching tmux or fzf. The # roster runs BEFORE that print, so the printed line reflects the real decision. setup() { REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" AI="$REPO_ROOT/claude-templates/bin/ai" PROJ="$(mktemp -d)" mkdir -p "$PROJ/.ai/scripts" touch "$PROJ/.ai/protocols.org" # Every tmux call in this file goes to a private server under the test # tmpdir, so nothing here can reach Craig's live 'ai' session. export TMUX_TMPDIR="$PROJ" unset TMUX # Source for direct access to the pure cores. The guard skips main(). # shellcheck disable=SC1090 source "$AI" } teardown() { tmux kill-server 2>/dev/null || true rm -rf "$PROJ" } # Install a stub roster that exits with the given status. Exit codes are the # real agent-roster's contract: 0 alone, 1 others live, 2 unavailable. _stub_roster() { cat > "$PROJ/.ai/scripts/agent-roster" < id" { _stub_roster 1 run bash "$AI" --helper --print-launch "$PROJ" [ "$status" -eq 0 ] [[ "$output" =~ AI_AGENT_ID=helper-[0-9a-f]{4}[[:space:]] ]] } @test "--helper honors an id the caller already exported" { _stub_roster 1 AI_AGENT_ID=helper-beef run bash "$AI" --helper --print-launch "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"AI_AGENT_ID=helper-beef"* ]] } @test "--helper sanitizes an id carrying shell metacharacters" { _stub_roster 1 # The id is interpolated into the command typed into the pane. An id # carrying ';' would end the assignment and run the rest as its own # command — the helper never launches and something else does. AI_AGENT_ID='x;touch /tmp/ai-helper-pwned' run bash "$AI" --helper --print-launch "$PROJ" [ "$status" -eq 0 ] [[ "$output" != *";touch"* ]] [[ "$output" != *" /tmp/ai-helper-pwned"* ]] # Assert the exact surviving form too. Negative-only assertions also pass # when the id is dropped or mangled some other way, which is how a broken # sanitizer slipped through once already. [[ "$output" == *"AI_AGENT_ID=x_touch__tmp_ai-helper-pwned "* ]] } @test "--helper sanitizes an id carrying a space" { _stub_roster 1 AI_AGENT_ID='helper beef' run bash "$AI" --helper --print-launch "$PROJ" [ "$status" -eq 0 ] # A bare space would split the assignment from the command word. Anchored on # the following space, not a substring: a substring match also accepts a # mangled "helper_beef_", which an earlier sanitizer actually produced. [[ "$output" == *"AI_AGENT_ID=helper_beef "* ]] } @test "--helper mints a fresh id rather than reusing a live one" { _stub_roster 1 # A helper's pane exports AI_AGENT_ID, so `ai --helper` run from inside a # helper inherits its parent's id. Reusing it lands both agents on one # .ai/session-context.d/.org — the lost-update shape helper mode exists # to prevent. mkdir -p "$PROJ/.ai/session-context.d" printf 'helper-beef\n' > "$PROJ/.ai/session-context.d/helper-beef.org" AI_AGENT_ID=helper-beef run bash "$AI" --helper --print-launch "$PROJ" [ "$status" -eq 0 ] [[ "$output" != *"AI_AGENT_ID=helper-beef"* ]] [[ "$output" =~ AI_AGENT_ID=helper-[0-9a-f]{4}[[:space:]] ]] [[ "$output" == *"already live"* ]] } @test "--helper with no other agent falls back to a primary session and says so" { _stub_roster 0 run bash "$AI" --helper --print-launch "$PROJ" [ "$status" -eq 0 ] # Refuted: this is the normal primary launch. [[ "$output" == *"protocols.org"* ]] [[ "$output" != *"helper-mode.org"* ]] [[ "$output" != *"AI_HELPER=1"* ]] [[ "$output" == *"no other agent"* ]] } @test "--helper with no roster installed still launches a helper, with a warning" { # No stub written: an older checkout whose .ai/scripts predates agent-roster. run bash "$AI" --helper --print-launch "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"helper-mode.org"* ]] [[ "$output" == *"could not verify"* ]] } @test "--helper with an unavailable roster still launches a helper, with a warning" { _stub_roster 2 run bash "$AI" --helper --print-launch "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"helper-mode.org"* ]] [[ "$output" == *"could not verify"* ]] } @test "--helper names the host and project in the opener, as the primary does" { _stub_roster 1 run bash "$AI" --helper --print-launch "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"$(basename "$PROJ")"* ]] [[ "$output" == *"$(uname -n)"* ]] } @test "--helper composes with --runtime" { _stub_roster 1 run bash "$AI" --runtime codex --helper --print-launch "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"codex "* ]] [[ "$output" == *"helper-mode.org"* ]] } @test "--helper refuses a directory that is not an agent-template project" { run bash "$AI" --helper --print-launch "$BATS_TEST_TMPDIR" [ "$status" -eq 1 ] [[ "$output" == *"protocols.org"* ]] } @test "--helper needs a project directory" { run bash "$AI" --helper [ "$status" -eq 2 ] [[ "$output" == *"needs a project directory"* ]] } # --- pure cores --------------------------------------------------------------- @test "_helper_launch_mode: roster found other agents (1) — launch a helper" { run _helper_launch_mode 1 [ "$output" = helper ] } @test "_helper_launch_mode: roster says alone (0) — fall back to primary" { run _helper_launch_mode 0 [ "$output" = primary ] } @test "_helper_launch_mode: roster unavailable (2) — helper, unverified" { run _helper_launch_mode 2 [ "$output" = helper-unverified ] } @test "_helper_launch_mode: no roster script at all — helper, unverified" { run _helper_launch_mode absent [ "$output" = helper-unverified ] } @test "_resolve_helper_launch: builds the roster path from its own argument" { _stub_roster 1 # With no `dir` in the caller's scope, a roster path built from the caller's # variable instead of the parameter resolves to "/.ai/scripts/agent-roster", # which isn't executable — so the gate would silently report unverified and # every helper launch would skip its check. The bug is invisible when the # caller happens to have its own $dir holding the same value, which both # production callers do. unset dir run _resolve_helper_launch "$PROJ" [ "$output" = helper ] } @test "_helper_id: shape is helper- plus four hex digits" { run _helper_id [[ "$output" =~ ^helper-[0-9a-f]{4}$ ]] } @test "_helper_id: uses the full 16 bits, not bash RANDOM's 15" { # The shape test alone passes against `RANDOM % 65536`, which can never set # the top bit — so every id would begin 0-7 and nothing would fail. Draw # enough to make a genuinely 16-bit generator almost certain to show a high # leading digit, and assert one appears. local i high=0 for i in $(seq 1 200); do case "$(_helper_id)" in helper-[89abcdef]*) high=1; break ;; esac done [ "$high" -eq 1 ] } @test "_sanitize_agent_id: keeps the safe charset and maps everything else" { run _sanitize_agent_id 'helper-a83f' [ "$output" = "helper-a83f" ] run _sanitize_agent_id 'a b;c/d$e' [ "$output" = "a_b_c_d_e" ] run _sanitize_agent_id 'keep.dots_and-dashes' [ "$output" = "keep.dots_and-dashes" ] } @test "_resolve_helper_id: a minted id also avoids a live anchor" { # Seed every id _helper_id can produce for a stubbed generator, so the mint # path must notice the collision rather than hand back a taken id. mkdir -p "$PROJ/.ai/session-context.d" _helper_id() { echo "helper-dead"; } : > "$PROJ/.ai/session-context.d/helper-dead.org" run _resolve_helper_id "$PROJ" # Bounded retries mean it gives up and returns the id, but it must not have # returned it silently on the first look — the loop ran its full bound. [ "$status" -eq 0 ] [ "$output" = "helper-dead" ] } @test "_resolve_helper_id: a free minted id is returned as-is" { _helper_id() { echo "helper-cafe"; } run _resolve_helper_id "$PROJ" [ "$output" = "helper-cafe" ] } # --- window ordering ---------------------------------------------------------- @test "_order_windows: a helper window sorts with its project, not with others" { local listing names out listing="$(printf 'beta\t@1\nzzz-other\t@2\nalpha:helper-a83f\t@3\nalpha\t@4')" names="$(printf 'alpha\nbeta')" out="$(printf '%s\n' "$listing" | _order_windows "$names" | cut -f1 | paste -sd, -)" [ "$out" = "zzz-other,alpha,alpha:helper-a83f,beta" ] } @test "_order_windows: a colon name whose prefix is not a project stays in others" { local out out="$(printf 'nope:helper-a83f\t@1\n' | _order_windows "$(printf 'alpha')" | cut -f1)" [ "$out" = "nope:helper-a83f" ] } # --- functional: the second window (private tmux socket) ---------------------- # # The regression these guard against: single_mode focuses the project's existing # window and returns, so routing a helper through it would hand back the PRIMARY # session instead of opening a second one. # # The window-list tail (sort_windows, attach_session) is stubbed out. Both are # already covered in the characterization suite, attaching needs a real terminal # these tests don't have, and build_candidates legitimately returns non-zero # under bats's errexit (bin/ai itself runs without set -e — see that file's NOTE). _stub_window_tail() { sort_windows() { :; } attach_session() { :; } export AGENT_CMD="true" } @test "functional helper_mode: opens a NEW window beside the project's existing one" { _stub_roster 1 _stub_window_tail tmux new-session -d -s ai -n "$(basename "$PROJ")" -c "$PROJ" run helper_mode "$PROJ" [ "$status" -eq 0 ] names="$(tmux list-windows -t ai -F '#{window_name}')" # The primary's window survives untouched, and a helper window joins it. printf '%s\n' "$names" | grep -qx "$(basename "$PROJ")" printf '%s\n' "$names" | grep -qE "^$(basename "$PROJ"):helper-[0-9a-f]{4}$" [ "$(printf '%s\n' "$names" | wc -l)" -eq 2 ] } @test "functional helper_mode: the window name carries the exported id" { _stub_roster 1 _stub_window_tail tmux new-session -d -s ai -n base -c "$PROJ" AI_AGENT_ID=helper-beef run helper_mode "$PROJ" [ "$status" -eq 0 ] tmux list-windows -t ai -F '#{window_name}' | grep -qx "$(basename "$PROJ"):helper-beef" } @test "functional helper_mode: an empty roster opens the plain project window" { _stub_roster 0 _stub_window_tail tmux new-session -d -s ai -n base -c "$PROJ" run helper_mode "$PROJ" [ "$status" -eq 0 ] names="$(tmux list-windows -t ai -F '#{window_name}')" printf '%s\n' "$names" | grep -qx "$(basename "$PROJ")" ! printf '%s\n' "$names" | grep -q ':helper-' } # --- help --------------------------------------------------------------------- @test "usage documents --helper" { run bash "$AI" -h [ "$status" -eq 0 ] [[ "$output" == *"--helper"* ]] }