aboutsummaryrefslogtreecommitdiff
path: root/scripts/tests
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/tests')
-rw-r--r--scripts/tests/ai-launcher-helper.bats328
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"* ]]
+}