agent tracker update

This commit is contained in:
David Chen 2026-07-07 15:49:34 +08:00
parent 816bf84824
commit f667ba7225
39 changed files with 8408 additions and 104 deletions

View file

@ -0,0 +1,20 @@
FROM golang:1.25-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
tmux zsh jq python3 fzf git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/zsh testuser
WORKDIR /home/testuser/agent-tracker
# Source is bind-mounted at runtime; build happens in entrypoint
COPY test/docker/entrypoint.sh /entrypoint.sh
COPY test/docker/fake-agent.sh /home/testuser/fake-agent.sh
COPY test/docker/run-tests.sh /run-tests.sh
RUN chmod +x /entrypoint.sh /home/testuser/fake-agent.sh /run-tests.sh
ENV TMUX_BIN=/usr/bin/tmux
ENV GOPROXY=off
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Capture tmux pane as a grid of cells with full ANSI attribute info.
Handles 256-color (38;5;N / 48;5;N) and true-color (38;2;R;G;B) sequences.
"""
import sys, json, subprocess, re
def capture(target):
result = subprocess.run(
["tmux", "capture-pane", "-e", "-p", "-t", target],
capture_output=True, text=True
)
return result.stdout
ANSI_RE = re.compile(r'\x1b\[([0-9;]*)m')
def empty_cell():
return {"ch": " ", "fg": None, "bg": None, "bold": False, "dim": False,
"underline": False, "reverse": False, "italic": False}
class AnsiState:
def __init__(self):
self.fg = None
self.bg = None
self.bold = False
self.dim = False
self.underline = False
self.reverse = False
self.italic = False
def reset(self):
self.fg = None
self.bg = None
self.bold = False
self.dim = False
self.underline = False
self.reverse = False
self.italic = False
def cell(self, ch):
return {
"ch": ch,
"fg": self.fg,
"bg": self.bg,
"bold": self.bold,
"dim": self.dim,
"underline": self.underline,
"reverse": self.reverse,
"italic": self.italic,
}
def apply_sgr(params, st):
"""Apply an SGR parameter list to the state."""
if not params or params == [0]:
st.reset()
return
i = 0
while i < len(params):
p = params[i]
if p == 0:
st.reset()
elif p == 1:
st.bold = True
elif p == 2:
st.dim = True
elif p == 3:
st.italic = True
elif p == 4:
st.underline = True
elif p == 7:
st.reverse = True
elif p == 22:
st.bold = False
st.dim = False
elif p == 23:
st.italic = False
elif p == 24:
st.underline = False
elif p == 27:
st.reverse = False
elif p == 39:
st.fg = None
elif p == 49:
st.bg = None
elif 30 <= p <= 37:
st.fg = str(p - 30)
elif 40 <= p <= 47:
st.bg = str(p - 40)
elif 90 <= p <= 97:
st.fg = str(p - 90 + 8)
elif 100 <= p <= 107:
st.bg = str(p - 100 + 8)
elif p == 38 or p == 48:
# Extended color: 38;5;N (256-color) or 38;2;R;G;B (true-color)
if i + 1 < len(params):
if params[i+1] == 5 and i + 2 < len(params):
# 256-color
color = str(params[i+2])
if p == 38:
st.fg = color
else:
st.bg = color
i += 2
elif params[i+1] == 2 and i + 4 < len(params):
# True-color: approximate to 256-color palette
r, g, b = params[i+2], params[i+3], params[i+4]
color = rgb_to_256(r, g, b)
if p == 38:
st.fg = color
else:
st.bg = color
i += 4
i += 1
def rgb_to_256(r, g, b):
"""Approximate RGB to nearest 256-color palette index."""
# Check grayscale first (232-255)
if r == g == b:
if r < 8:
return "16"
if r > 248:
return "231"
gray = int(round((r - 8) / 247 * 24))
return str(232 + gray)
# 6x6x6 color cube (16-231)
def to_cube(v):
if v < 48:
return 0
if v < 115:
return 1
return min(5, int(round((v - 115) / 40)) + 2)
return str(16 + 36 * to_cube(r) + 6 * to_cube(g) + to_cube(b))
def parse_to_grid(raw, rows, cols):
lines = raw.split('\n')
grid = []
st = AnsiState()
for ri in range(rows):
if ri >= len(lines):
grid.append([empty_cell() for _ in range(cols)])
continue
line = lines[ri]
cells = []
i = 0
while i < len(line) and len(cells) < cols:
ch = line[i]
if ch == '\x1b':
m = ANSI_RE.match(line, i)
if m:
param_str = m.group(1)
if param_str:
params = [int(x) for x in param_str.split(';') if x]
else:
params = [0]
apply_sgr(params, st)
i = m.end()
continue
else:
i += 1
continue
if ch == '\r':
i += 1
continue
cells.append(st.cell(ch))
i += 1
while len(cells) < cols:
c = empty_cell()
c["bg"] = st.bg
cells.append(c)
grid.append(cells[:cols])
return grid
if __name__ == '__main__':
target = sys.argv[1]
rows = int(sys.argv[2]) if len(sys.argv) > 2 else 50
cols = int(sys.argv[3]) if len(sys.argv) > 3 else 100
raw = capture(target)
grid = parse_to_grid(raw, rows, cols)
print(json.dumps(grid))

View file

@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Debug helper: dump grid cells with attributes for specific rows."""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from capture_grid import parse_to_grid
import subprocess, json
target = sys.argv[1]
rows_to_show = [int(x) for x in sys.argv[2:]] if len(sys.argv) > 2 else [3, 4, 5]
raw = subprocess.run(["tmux", "capture-pane", "-e", "-p", "-t", target],
capture_output=True, text=True).stdout
grid = parse_to_grid(raw, 50, 100)
for r in rows_to_show:
if r >= len(grid):
continue
row = grid[r]
text = "".join(c["ch"] for c in row).rstrip()
bg_count = sum(1 for c in row if str(c.get("bg")) not in ("None", "none") and c.get("bg") is not None)
bg238 = sum(1 for c in row if str(c.get("bg")) == "238")
print(f"\nRow {r}: |{text}|")
print(f" cells with any bg: {bg_count}/100, with bg=238: {bg238}/100")
# Show cells with attributes
shown = 0
for i, c in enumerate(row):
bg = c.get("bg")
fg = c.get("fg")
bold = c.get("bold", False)
if (bg and str(bg) not in ("None", "none")) or (fg and str(fg) not in ("None", "none") and c["ch"] != " "):
ch = c["ch"]
parts = []
if bg: parts.append(f"bg={bg}")
if fg: parts.append(f"fg={fg}")
if bold: parts.append("bold")
print(f" col {i}: {repr(ch)} [{', '.join(parts)}]")
shown += 1
if shown > 20:
print(" ... (truncated)")
break

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME=/home/testuser
AGENT_SRC="/home/testuser/agent-tracker"
AGENT_BIN="/home/testuser/bin"
mkdir -p "$AGENT_BIN"
echo "=== Building agent-tracker ==="
cd "$AGENT_SRC"
go build -o "$AGENT_BIN/agent" ./cmd/agent 2>&1
echo "Build complete."
echo "=== Running TUI tests ==="
bash "$AGENT_SRC/test/docker/run-tests.sh"
TUI_EXIT=$?
echo "=== Running visual tests ==="
AGENT_BIN="/home/testuser/bin/agent" python3 "$AGENT_SRC/test/docker/visual_tests.py"
VISUAL_EXIT=$?
echo ""
echo "TUI tests: $TUI_EXIT Visual tests: $VISUAL_EXIT"
exit $((TUI_EXIT + VISUAL_EXIT))

View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Fake agent: simulates an opencode turn by firing tracker hook events.
# Usage: fake-agent.sh <window_id> <user_message>
# This mimics what the real hook script does: start_task with the message as summary.
set -euo pipefail
WINDOW_ID="${1:-}"
MESSAGE="${2:-working...}"
AGENT_BIN="/home/testuser/bin/agent"
TRACKER_BIN="/home/testuser/bin/tracker-client"
# Resolve tmux context for the window
PANE=$(tmux list-panes -a -F '#{window_id}|#{pane_id}' 2>/dev/null | grep "^${WINDOW_ID}|" | head -1 | cut -d'|' -f2 || true)
if [ -z "$PANE" ]; then
echo "fake-agent: no pane for window $WINDOW_ID" >&2
exit 1
fi
SESSION_ID=$(tmux display-message -p -t "$PANE" "#{session_id}" 2>/dev/null || echo "")
WINDOW_NAME=$(tmux display-message -p -t "$PANE" "#{window_name}" 2>/dev/null || echo "")
SESSION_NAME=$(tmux display-message -p -t "$PANE" "#{session_name}" 2>/dev/null || echo "")
echo "fake-agent: window=$WINDOW_ID pane=$PANE session=$SESSION_ID message=$MESSAGE"
# Start a tracker task (this creates the draft thread)
$TRACKER_BIN command \
-session-id "$SESSION_ID" \
-window-id "$WINDOW_ID" \
-pane "$PANE" \
-summary "$MESSAGE" \
start_task 2>/dev/null || true
# Revive any done thread for this window with the new name
$AGENT_BIN goal revive --window "$WINDOW_ID" --name "$MESSAGE" 2>/dev/null || true
echo "fake-agent: turn started"

View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME=/home/testuser
AGENT_SRC="/home/testuser/agent-tracker"
AGENT_BIN="/home/testuser/bin"
mkdir -p "$AGENT_BIN"
echo "=== Building agent-tracker ==="
cd "$AGENT_SRC"
go build -o "$AGENT_BIN/agent" ./cmd/agent 2>&1
echo "Build complete."
# Seed fake data and create tmux session with drafts
bash "$AGENT_SRC/test/docker/seed-data.sh"
# Launch goals panel in the main window
MAIN_WID=$(tmux display-message -p -t test:0 "#{window_id}")
tmux send-keys -t test:0 "$AGENT_BIN/agent palette --mode goals --window=$MAIN_WID --path=/tmp --session-name=test --window-name=main" Enter
echo ""
echo "============================================"
echo " Interactive test container ready"
echo "============================================"
echo ""
echo "tmux session 'test' is running with:"
echo " - 4 goals (Ship V2 > Board UX, API Refactor; Marketing Site)"
echo " - 5 threads (3 assigned, 2 standalone, 1 done)"
echo " - 5 drafts (unassigned agent windows)"
echo ""
echo "Goals panel is open in window 0."
echo ""
echo "To interact:"
echo " docker exec -it <container> tmux attach -t test"
echo ""
echo "To send keys from outside:"
echo " docker exec <container> tmux send-keys -t test:0 <key>"
echo ""
echo "To capture screen:"
echo " docker exec <container> tmux capture-pane -p -t test:0"
echo ""
echo "Keeping container alive..."
exec sleep infinity

View file

@ -0,0 +1,113 @@
#!/usr/bin/env bash
# TUI test: drives the goals panel via tmux send-keys + capture-pane.
set -uo pipefail
AGENT="/home/testuser/bin/agent"
PASS=0; FAIL=0
check() {
if echo "$2" | grep -q -- "$3"; then
echo " PASS: $1"; PASS=$((PASS+1))
else
echo " FAIL: $1 (expected '$3')"
echo "$2" | head -24 | sed 's/^/ | /'
FAIL=$((FAIL+1))
fi
}
tk() { tmux send-keys -t test:0.0 "$@"; sleep 0.4; }
cap() { tmux capture-pane -p -t test:0.0 2>/dev/null; }
type_() { printf '%s' "$1" | while IFS= read -r -n1 ch; do tmux send-keys -t test:0.0 -l "$ch"; sleep 0.03; done; sleep 0.2; }
# ── Setup ───────────────────────────────────────────────────
tmux kill-session -t test 2>/dev/null || true
tmux new-session -d -s test -x 200 -y 60
sleep 0.3
rm -rf ~/.cache/agent; mkdir -p ~/.cache/agent
$AGENT goal add-goal --title "Ship V2" >/dev/null
$AGENT goal add-goal --title "Board UX" --parent ship-v2 >/dev/null
$AGENT goal add-thread --name "drag-drop" --goal board-ux >/dev/null
$AGENT goal add-thread --name "notif" --goal board-ux >/dev/null
# ── 1. Palette + Goals view ─────────────────────────────────
echo "=== 1. Palette + Goals view ==="
tmux send-keys -t test:0.0 "$AGENT palette --window=@0 --path=/tmp --session-name=test --window-name=test0" Enter
sleep 1.5
OUT=$(cap); check "palette shows" "$OUT" "Command Palette"
tk M-r
OUT=$(cap)
check "goals header" "$OUT" "Goals"
check "goal Ship V2" "$OUT" "Ship V2"
check "sub-goal Board UX" "$OUT" "Board UX"
check "thread drag-drop" "$OUT" "drag-drop"
check "thread notif" "$OUT" "notif"
# ── 2. Navigation ───────────────────────────────────────────
echo "=== 2. Navigation ==="
tk e; OUT=$(cap); check "e moves down" "$OUT" "Goals"
tk e; OUT=$(cap); check "e again" "$OUT" "Goals"
tk e; OUT=$(cap); check "e to thread" "$OUT" "Goals"
tk u; OUT=$(cap); check "u moves up" "$OUT" "Goals"
tk n; OUT=$(cap); check "n parent jump" "$OUT" "Goals"
# ── 3. Expand thread ────────────────────────────────────────
echo "=== 3. Expand thread ==="
tk e; tk e # navigate to a thread
tk Right
OUT=$(cap); check "expand keeps goals" "$OUT" "Goals"
tk Right # collapse
OUT=$(cap); check "collapse keeps goals" "$OUT" "Goals"
# ── 4. More options (o) ─────────────────────────────────────
echo "=== 4. More options ==="
tk o
OUT=$(cap)
check "more-options opens" "$OUT" "[Aa]ction"
# filter for "add goal"
type_ "add goal"
OUT=$(cap); check "filter works" "$OUT" "[Aa]dd [Gg]oal"
tk Enter
OUT=$(cap); check "add-goal input opens" "$OUT" "[Nn]ew"
# ── 5. Create goal via input ────────────────────────────────
echo "=== 5. Create goal ==="
type_ "NewGoal"
tk Enter
OUT=$(cap); check "new goal in view" "$OUT" "NewGoal"
# ── 6. Rename ───────────────────────────────────────────────
echo "=== 6. Rename ==="
tk u; tk u # move up to a goal
tk r
OUT=$(cap); check "rename input opens" "$OUT" "[Nn]ame\|[Tt]itle"
tk C-u # clear existing text
type_ "RenamedGoal"
tk Enter
OUT=$(cap); check "rename shows in view" "$OUT" "RenamedGoal"
# ── 7. Done toggle (D) ──────────────────────────────────────
echo "=== 7. Done toggle ==="
tk e; tk e # move to a thread
tk S-D # shift-D
OUT=$(cap); check "D confirm opens" "$OUT" "[Dd]elete\|[Dd]one\|[Tt]oggle"
tk y # confirm
OUT=$(cap); check "D action applied" "$OUT" "Goals"
# ── 8. Close + persist check ────────────────────────────────
echo "=== 8. Persist ==="
sleep 0.5
tk M-s
sleep 0.5
LIST=$($AGENT goal list 2>/dev/null)
check "new goal persisted" "$LIST" "NewGoal"
check "rename persisted" "$LIST" "RenamedGoal"
# ── Summary ─────────────────────────────────────────────────
echo ""
echo "========================================"
echo "TUI Results: $PASS passed, $FAIL failed"
echo "========================================"
tmux kill-session -t test 2>/dev/null || true
exit $FAIL

View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME=/home/testuser
AGENT=/home/testuser/bin/agent
echo "=== Seeding fake data ==="
rm -rf ~/.cache/agent
mkdir -p ~/.cache/agent
# Goals
$AGENT goal add-goal --title "Ship V2"
$AGENT goal add-goal --title "Board UX" --parent ship-v2
$AGENT goal add-goal --title "API Refactor" --parent ship-v2
$AGENT goal add-goal --title "Marketing Site"
# Threads (assigned to goals)
$AGENT goal add-thread --name "drag-drop upload" --goal board-ux
$AGENT goal add-thread --name "notification system" --goal board-ux
$AGENT goal add-thread --name "auth middleware" --goal api-refactor
# Threads (unassigned, top-level)
$AGENT goal add-thread --name "Standalone thread A"
$AGENT goal add-thread --name "Standalone thread B"
# Mark one as done
DONE_ID=$($AGENT goal list 2>/dev/null | grep 'drag-drop' | grep -o 'th_[a-f0-9]*' | head -1)
if [ -n "$DONE_ID" ]; then
$AGENT goal done --thread "$DONE_ID" 2>/dev/null || true
fi
echo "=== Creating fake drafts ==="
# Kill any existing tmux session
tmux kill-session -t test 2>/dev/null || true
# Create session
tmux new-session -d -s test -x 120 -y 55 -n main
sleep 0.3
# Create windows to simulate agent sessions (drafts)
declare -A WINDOWS
WINDOWS=(
["fix-bug"]="Fix the login page redirect bug"
["add-tests"]="Add unit tests for payment module"
["refactor-nav"]="Refactor the navigation component"
["docs-readme"]="Update README with new install steps"
["perf-query"]="Optimize the slow database query"
)
for wname in "${!WINDOWS[@]}"; do
msg="${WINDOWS[$wname]}"
tmux new-window -t test -n "$wname"
sleep 0.1
wid=$(tmux display-message -p -t test "=#{window_id}")
/home/testuser/fake-agent.sh "$wid" "$msg" 2>/dev/null || true
done
# Go back to first window
tmux select-window -t test:0
echo ""
echo "=== Seed data complete ==="
$AGENT goal list 2>/dev/null
echo ""
echo "Drafts (unassigned windows):"
echo " fix-bug, add-tests, refactor-nav, docs-readme, perf-query"

View file

@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Scenario: cursor on Goal B, press m, u, e.
Setup:
Goal A
Goal B
Thread C
-----------------------
Draft 1
Draft 2
Draft 3
Prints the panel after each keypress so we can observe the ghost position.
"""
import sys, os, subprocess, time
sys.path.insert(0, os.path.dirname(__file__))
from visual_lib import *
TARGET = "test:0.0"
AGENT = os.environ.get("AGENT_BIN", "/home/testuser/bin/agent")
TRACKER_SERVER = "/home/testuser/bin/tracker-server"
ROWS, COLS = 50, 100
def dump(label):
g = capture(TARGET, ROWS, COLS)
print(f"\n===== {label} =====")
for r in range(ROWS):
rt = row_text(r)
if rt.strip():
print(f" {r:2d}: |{rt}|")
# locate ghost
for r in range(ROWS):
if "(moving)" in row_text(r):
print(f" >> ghost at row {r}: |{row_text(r)}|")
break
def navigate_to(text):
"""Move cursor until the selected (bg 238) row contains text. Down then up."""
for _ in range(60):
capture(TARGET, ROWS, COLS)
for r in range(ROWS):
if row_has_bg(r, "238") > 5:
if text in row_text(r):
return True
break
send_keys(TARGET, "e", 0.08)
for _ in range(60):
capture(TARGET, ROWS, COLS)
for r in range(ROWS):
if row_has_bg(r, "238") > 5:
if text in row_text(r):
return True
break
send_keys(TARGET, "u", 0.08)
return False
def setup():
subprocess.run(["tmux", "kill-session", "-t", "test"], capture_output=True)
time.sleep(0.2)
subprocess.run(["go", "build", "-o", TRACKER_SERVER, "./cmd/tracker-server"],
cwd="/home/testuser/agent-tracker", capture_output=True)
subprocess.run(["pkill", "-f", "tracker-server"], capture_output=True)
time.sleep(0.2)
subprocess.Popen([TRACKER_SERVER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.5)
subprocess.run(["tmux", "new-session", "-d", "-s", "test",
"-x", str(COLS), "-y", str(ROWS)], capture_output=True)
time.sleep(0.3)
cache = os.path.expanduser("~/.cache/agent")
subprocess.run(["rm", "-rf", cache], capture_output=True)
subprocess.run(["mkdir", "-p", cache], capture_output=True)
ga = subprocess.run([AGENT, "goal", "add-goal", "--title", "Goal A"],
capture_output=True, text=True).stdout.strip()
gb = subprocess.run([AGENT, "goal", "add-goal", "--title", "Goal B"],
capture_output=True, text=True).stdout.strip()
print(f"goal ids: A={ga} B={gb}")
subprocess.run([AGENT, "goal", "add-thread", "--name", "Thread C", "--goal", gb],
capture_output=True)
for name in ["Draft 1", "Draft 2", "Draft 3"]:
result = subprocess.run(["tmux", "new-window", "-d", "-t", "test", "-n", name,
"-P", "-F", "#{window_id}"], capture_output=True, text=True)
wid = result.stdout.strip()
if wid:
subprocess.run([AGENT, "tracker", "command", "--window-id", wid,
"--session", "test", "--window", name, "start_task", name],
capture_output=True)
time.sleep(0.3)
subprocess.run(["tmux", "select-window", "-t", "test:0"], capture_output=True)
subprocess.run(
["tmux", "send-keys", "-t", TARGET,
f"{AGENT} palette --mode goals --window=@0 --path=/tmp --session-name=test --window-name=test0",
"Enter"],
capture_output=True)
time.sleep(1.5)
capture(TARGET, ROWS, COLS)
def main():
setup()
dump("initial")
if not navigate_to("Goal B"):
print("FAIL: could not navigate cursor to Goal B")
return 1
dump("cursor on Goal B")
send_keys(TARGET, "m", 0.5)
dump("after m (enter move mode)")
send_keys(TARGET, "u", 0.4)
dump("after u (expect: nest under Goal A)")
send_keys(TARGET, "e", 0.4)
dump("after e (expect: pop back to root; NOT in draft area)")
# Assertions
g = capture(TARGET, ROWS, COLS)
ghost_row = -1
first_draft_row = -1
for r in range(ROWS):
rt = row_text(r)
if "(moving)" in rt and ghost_row < 0:
ghost_row = r
if "Draft" in rt and first_draft_row < 0:
first_draft_row = r
print(f"\nghost_row={ghost_row} first_draft_row={first_draft_row}")
check("ghost still visible after e", ghost_row >= 0)
if ghost_row >= 0 and first_draft_row >= 0:
check("ghost above draft section (not in draft area)",
ghost_row < first_draft_row,
f"ghost row {ghost_row} >= first draft row {first_draft_row}")
if ghost_row >= 0:
gt = row_text(ghost_row)
ghost_lead = len(gt) - len(gt.lstrip())
# root rows carry 1 leading space (panel padding); find Goal A's indent as the root reference
root_lead = 1
for r in range(ROWS):
if "Goal A" in row_text(r):
root_lead = len(row_text(r)) - len(row_text(r).lstrip())
break
check("ghost at root depth (matches Goal A indent)", ghost_lead == root_lead,
f"ghost leading={ghost_lead}, root leading={root_lead}: |{gt}|")
print(summary())
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Scenario: cursor on a thread, press m, move ghost into the draft section,
press Enter to demote the thread to a draft. The cursor should land on the
new draft after Enter.
Setup:
Goal A
Thread T (bound to window "AlphaWin")
-----------------------
Draft 1
Draft 2
"""
import sys, os, subprocess, time
sys.path.insert(0, os.path.dirname(__file__))
from visual_lib import *
TARGET = "test:0.0"
AGENT = os.environ.get("AGENT_BIN", "/home/testuser/bin/agent")
TRACKER_SERVER = "/home/testuser/bin/tracker-server"
ROWS, COLS = 50, 100
DRAFT_WINDOW = "AlphaWin"
def navigate_to(text):
for _ in range(60):
capture(TARGET, ROWS, COLS)
for r in range(ROWS):
if row_has_bg(r, "238") > 5:
if text in row_text(r):
return True
break
send_keys(TARGET, "e", 0.08)
for _ in range(60):
capture(TARGET, ROWS, COLS)
for r in range(ROWS):
if row_has_bg(r, "238") > 5:
if text in row_text(r):
return True
break
send_keys(TARGET, "u", 0.08)
return False
def find_row(substring):
capture(TARGET, ROWS, COLS)
for r in range(ROWS):
if substring in row_text(r):
return r
return -1
def ghost_below_separator():
"""Return True if the (moving) ghost row is below the separator row."""
capture(TARGET, ROWS, COLS)
sep_row = -1
ghost_row = -1
for r in range(ROWS):
rt = row_text(r)
if sep_row < 0 and "───" in rt:
sep_row = r
if "(moving)" in rt:
ghost_row = r
return ghost_row > sep_row >= 0
def setup():
subprocess.run(["tmux", "kill-session", "-t", "test"], capture_output=True)
time.sleep(0.2)
subprocess.run(["go", "build", "-o", TRACKER_SERVER, "./cmd/tracker-server"],
cwd="/home/testuser/agent-tracker", capture_output=True)
subprocess.run(["pkill", "-f", "tracker-server"], capture_output=True)
time.sleep(0.2)
subprocess.Popen([TRACKER_SERVER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.5)
subprocess.run(["tmux", "new-session", "-d", "-s", "test", "-x", str(COLS), "-y", str(ROWS)],
capture_output=True)
time.sleep(0.3)
cache = os.path.expanduser("~/.cache/agent")
subprocess.run(["rm", "-rf", cache], capture_output=True)
subprocess.run(["mkdir", "-p", cache], capture_output=True)
ga = subprocess.run([AGENT, "goal", "add-goal", "--title", "Goal A"],
capture_output=True, text=True).stdout.strip()
# Create the thread's window + tracker task, then promote it to a thread under Goal A
res = subprocess.run(["tmux", "new-window", "-d", "-t", "test", "-n", DRAFT_WINDOW,
"-P", "-F", "#{window_id}"], capture_output=True, text=True)
wid = res.stdout.strip()
subprocess.run([AGENT, "tracker", "command", "--window-id", wid, "--session", "test",
"--window", DRAFT_WINDOW, "start_task", DRAFT_WINDOW], capture_output=True)
subprocess.run([AGENT, "goal", "promote", "--window", wid, "--name", "Thread T",
"--goal", ga], capture_output=True)
# Two existing drafts (so there is a draft section to move into)
for name in ["Draft 1", "Draft 2"]:
r = subprocess.run(["tmux", "new-window", "-d", "-t", "test", "-n", name,
"-P", "-F", "#{window_id}"], capture_output=True, text=True)
w = r.stdout.strip()
if w:
subprocess.run([AGENT, "tracker", "command", "--window-id", w, "--session", "test",
"--window", name, "start_task", name], capture_output=True)
time.sleep(0.3)
subprocess.run(["tmux", "select-window", "-t", "test:0"], capture_output=True)
subprocess.run(
["tmux", "send-keys", "-t", TARGET,
f"{AGENT} palette --mode goals --window=@0 --path=/tmp --session-name=test --window-name=test0",
"Enter"],
capture_output=True)
time.sleep(1.5)
capture(TARGET, ROWS, COLS)
def dump(label):
g = capture(TARGET, ROWS, COLS)
print(f"\n--- {label} ---")
for r in range(ROWS):
rt = row_text(r)
if rt.strip():
print(f" {r:2d}: |{rt}|")
def main():
setup()
dump("initial")
if not navigate_to("Thread T"):
print("FAIL: could not navigate to Thread T")
return 1
dump("cursor on Thread T")
send_keys(TARGET, "m", 0.5)
dump("after m")
# Move ghost down until it is in the draft section
moved = False
for _ in range(12):
if ghost_below_separator():
moved = True
break
send_keys(TARGET, "e", 0.3)
dump("ghost in draft section" if moved else "ghost NEVER reached draft section")
if not moved:
print("FAIL: ghost did not reach the draft section")
return 1
# Commit demotion
send_keys(TARGET, "Enter", 0.6)
dump("after Enter (demote)")
# Assertions: cursor (selected row) should be on the new draft (DRAFT_WINDOW)
g = capture(TARGET, ROWS, COLS)
sel_row = -1
for r in range(ROWS):
if row_has_bg(r, "238") > 5:
sel_row = r
break
check("a row is selected after Enter", sel_row >= 0)
if sel_row >= 0:
sel_text = row_text(sel_row)
check("cursor on the demoted draft", DRAFT_WINDOW in sel_text,
f"selected row {sel_row}: |{sel_text}|")
check("Thread T demoted (no longer a thread row)", find_row("Thread T") < 0)
print(summary())
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Visual test framework for agent-tracker TUI.
Captures tmux pane cells with full ANSI attribute info and runs assertions
on background colors, foreground colors, bold, and spatial layout.
"""
import sys, json, subprocess, time, os
GRID = [] # type: list
def send_keys(target, keys, delay=0.3):
subprocess.run(["tmux", "send-keys", "-t", target] + keys.split(),
capture_output=True)
time.sleep(delay)
def send_keys_literal(target, text, delay=0.2):
"""Send text literally, char by char, preserving spaces."""
for ch in text:
subprocess.run(["tmux", "send-keys", "-t", target, "-l", ch],
capture_output=True)
time.sleep(0.03)
time.sleep(delay)
def capture(target, rows=50, cols=100):
result = subprocess.run(
["python3", os.path.join(os.path.dirname(__file__), "capture_grid.py"),
target, str(rows), str(cols)],
capture_output=True, text=True
)
global GRID
new_grid = json.loads(result.stdout)
GRID.clear()
GRID.extend(new_grid)
return GRID
def cell(row, col):
if row < 0 or row >= len(GRID):
return {"ch": " ", "fg": None, "bg": None, "bold": False, "dim": False}
r = GRID[row]
if col < 0 or col >= len(r):
return {"ch": " ", "fg": None, "bg": None, "bold": False, "dim": False}
return r[col]
def row_text(row, cols=None):
if row < 0 or row >= len(GRID):
return ""
r = GRID[row]
if cols:
r = r[:cols]
return "".join(c["ch"] for c in r).rstrip()
def row_has_bg(row, bg_val, min_cols=1, max_col=None):
"""Count cells in a row that have a specific background."""
if row < 0 or row >= len(GRID):
return 0
r = GRID[row]
if max_col is None:
max_col = len(r)
count = 0
for c in r[:max_col]:
if str(c.get("bg")) == str(bg_val):
count += 1
return count
def row_bg_extent(row):
"""Return (start_col, end_col) of contiguous bg on a row, or (0,0) if none."""
if row < 0 or row >= len(GRID):
return (0, 0)
r = GRID[row]
bgs = set()
for c in r:
bg = c.get("bg")
if bg is not None and str(bg) not in ("None", "none"):
bgs.add(str(bg))
if not bgs:
return (0, 0)
# find first and last cell with any bg
first = None
last = None
for i, c in enumerate(r):
bg = c.get("bg")
if bg is not None and str(bg) not in ("None", "none"):
if first is None:
first = i
last = i
return (first if first else 0, last if last else 0)
def find_row_with_text(text, start=0, end=None):
"""Find row index containing text."""
if end is None:
end = len(GRID)
for r in range(start, end):
if text in row_text(r):
return r
return -1
PASS = 0
FAIL = 0
FAILURES = []
def check(name, condition, detail=""):
global PASS, FAIL
if condition:
PASS += 1
print(f" PASS: {name}")
else:
FAIL += 1
FAILURES.append(name)
print(f" FAIL: {name}" + (f"{detail}" if detail else ""))
def check_cell_bg(name, row, col, expected_bg):
c = cell(row, col)
actual = c.get("bg")
ok = str(actual) == str(expected_bg)
check(name, ok,
f"row {row} col {col}: expected bg={expected_bg}, got bg={actual} ch={repr(c['ch'])}")
def check_cell_fg(name, row, col, expected_fg):
c = cell(row, col)
actual = c.get("fg")
ok = str(actual) == str(expected_fg)
check(name, ok,
f"row {row} col {col}: expected fg={expected_fg}, got fg={actual} ch={repr(c['ch'])}")
def check_cell_bold(name, row, col, expected=True):
c = cell(row, col)
actual = c.get("bold", False)
ok = actual == expected
check(name, ok,
f"row {row} col {col}: expected bold={expected}, got bold={actual} ch={repr(c['ch'])}")
def check_row_bg_full(name, row, expected_bg, width, min_pct=0.9):
"""Check that a row has bg color extending at least min_pct of width."""
count = row_has_bg(row, expected_bg, max_col=width)
pct = count / width if width > 0 else 0
ok = pct >= min_pct
check(name, ok,
f"row {row}: expected bg={expected_bg} covering >= {min_pct*100:.0f}% of {width} cols, "
f"got {count}/{width} ({pct*100:.0f}%)")
def check_row_bg_continuous(name, row, expected_bg, expected_width):
"""Check that bg extends continuously from first cell to at least expected_width."""
if row >= len(GRID):
check(name, False, f"row {row} out of bounds")
return
r = GRID[row]
first_bg = None
for i, c in enumerate(r):
if str(c.get("bg")) == str(expected_bg):
first_bg = i
break
if first_bg is None:
check(name, False, f"row {row}: no cell with bg={expected_bg} found")
return
# check continuity from first_bg
continuous = 0
for i in range(first_bg, len(r)):
if str(r[i].get("bg")) == str(expected_bg):
continuous += 1
else:
break
ok = continuous >= expected_width
check(name, ok,
f"row {row}: expected continuous bg={expected_bg} for >= {expected_width} cols from col {first_bg}, "
f"got {continuous}")
def dump_row(row, cols=None):
"""Pretty-print a row with cell attributes."""
if row < 0 or row >= len(GRID):
print(f" [row {row}: out of bounds]")
return
r = GRID[row]
if cols:
r = r[:cols]
text = "".join(c["ch"] for c in r)
print(f" row {row}: |{text}|")
for i, c in enumerate(r):
attrs = []
if c.get("bg") and str(c["bg"]) not in ("None", "none"):
attrs.append(f"bg={c['bg']}")
if c.get("fg") and str(c["fg"]) not in ("None", "none"):
attrs.append(f"fg={c['fg']}")
if c.get("bold"):
attrs.append("bold")
if attrs and c["ch"] != " ":
print(f" col {i}: {repr(c['ch'])} [{', '.join(attrs)}]")
elif attrs and c["ch"] == " ":
# only print first few bg-only cells to avoid noise
pass
def dump_row_bg(row, cols=None):
"""Show only bg coverage for a row."""
if row < 0 or row >= len(GRID):
print(f" [row {row}: out of bounds]")
return
r = GRID[row]
if cols:
r = r[:cols]
parts = []
for i, c in enumerate(r):
bg = c.get("bg")
if bg and str(bg) not in ("None", "none"):
parts.append(str(bg))
else:
parts.append("·")
bg_str = " ".join(parts[:60])
text = "".join(c["ch"] for c in r).rstrip()
print(f" row {row} text: |{text}|")
print(f" row {row} bg: {bg_str}")
def summary():
print(f"\n{'='*40}")
print(f"Visual Results: {PASS} passed, {FAIL} failed")
print(f"{'='*40}")
if FAILURES:
print(f"Failed: {', '.join(FAILURES)}")
return FAIL

File diff suppressed because it is too large Load diff