diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/tests/ai-launcher-helper.bats | 328 |
1 files changed, 328 insertions, 0 deletions
diff --git a/scripts/tests/ai-launcher-helper.bats b/scripts/tests/ai-launcher-helper.bats new file mode 100644 index 0000000..0ede4fb --- /dev/null +++ b/scripts/tests/ai-launcher-helper.bats @@ -0,0 +1,328 @@ +#!/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" <<STUB +#!/bin/bash +exit $1 +STUB + chmod +x "$PROJ/.ai/scripts/agent-roster" +} + +# --- the roster gate, end to end through --print-launch ------------------------ + +@test "--helper with a live primary launches under the helper contract" { + _stub_roster 1 + run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"helper-mode.org"* ]] + # The helper opener replaces the primary one; it must not send the session + # to protocols.org, whose startup would run pulls, rsync, and inbox work. + [[ "$output" != *"protocols.org"* ]] +} + +@test "--helper exports the agent id and the helper flag into the pane" { + _stub_roster 1 + run bash "$AI" --helper --print-launch "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"AI_AGENT_ID=helper-"* ]] + [[ "$output" == *"AI_HELPER=1"* ]] +} + +@test "--helper assigns a helper-<rand4> 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/<id>.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"* ]] +} |
