Initial commit: hqt — HQ Terminal TUI
TUI for orchestrating AI coding harness sessions (Claude Code, Codex, Kiro, etc.) via tmux. Click CLI bootstraps a Textual TUI over ProjectService/SessionService backed by SQLite, spawning harness sessions as tmux windows through TmuxManager. Includes recent fixes: - Visible Tab focus highlight on dialog OK/Cancel buttons - Auto-select first project on launch - Auto-select first session + per-project session-selection memory - tmux new-window targets an explicit free index, fixing "index N in use" failures (broken spawn/attach in attached sessions) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from hqt.harnesses.configurators.codex import CodexConfigurator
|
||||
from hqt.harnesses.configurators.claude import ClaudeConfigurator
|
||||
from hqt.harnesses.registry import discover_harnesses
|
||||
|
||||
|
||||
def test_claude_spawn_config():
|
||||
c = ClaudeConfigurator()
|
||||
sid = c.generate_session_id(1)
|
||||
cfg = c.build_spawn_config(Path("/tmp"), sid, model="opus")
|
||||
assert cfg.command == ["claude", "--session-id", sid, "--model", "opus"]
|
||||
|
||||
|
||||
def test_claude_resume_config():
|
||||
c = ClaudeConfigurator()
|
||||
sid = c.generate_session_id(1)
|
||||
cfg = c.build_resume_config(Path("/tmp"), sid)
|
||||
assert "--resume" in cfg.command
|
||||
assert sid in cfg.command
|
||||
|
||||
|
||||
def test_claude_resume_config_includes_model_when_set():
|
||||
"""build_resume_config appends --model when model is provided."""
|
||||
c = ClaudeConfigurator()
|
||||
sid = c.generate_session_id(1)
|
||||
cfg = c.build_resume_config(Path("/tmp"), sid, model="claude-opus-4-5")
|
||||
assert "--resume" in cfg.command
|
||||
assert sid in cfg.command
|
||||
assert "--model" in cfg.command
|
||||
assert "claude-opus-4-5" in cfg.command
|
||||
|
||||
|
||||
def test_claude_resume_config_omits_model_when_none():
|
||||
"""build_resume_config omits --model when model is None."""
|
||||
c = ClaudeConfigurator()
|
||||
sid = c.generate_session_id(1)
|
||||
cfg = c.build_resume_config(Path("/tmp"), sid, model=None)
|
||||
assert "--model" not in cfg.command
|
||||
|
||||
|
||||
def test_codex_resume_config_includes_model_when_set():
|
||||
"""build_resume_config appends -m <model> when model is provided."""
|
||||
c = CodexConfigurator()
|
||||
cfg = c.build_resume_config(Path("/tmp"), "abc-123", model="o3")
|
||||
assert cfg.command == ["codex", "resume", "abc-123", "-m", "o3"]
|
||||
|
||||
|
||||
def test_codex_resume_config_omits_model_when_none():
|
||||
"""build_resume_config omits -m when model is None."""
|
||||
c = CodexConfigurator()
|
||||
cfg = c.build_resume_config(Path("/tmp"), "abc-123", model=None)
|
||||
assert "-m" not in cfg.command
|
||||
assert cfg.command == ["codex", "resume", "abc-123"]
|
||||
|
||||
|
||||
def test_codex_spawn_with_model():
|
||||
c = CodexConfigurator()
|
||||
cfg = c.build_spawn_config(Path("/tmp"), "1", model="o3")
|
||||
assert cfg.command == ["codex", "-m", "o3"]
|
||||
|
||||
|
||||
def test_codex_resume_uses_uuid():
|
||||
c = CodexConfigurator()
|
||||
cfg = c.build_resume_config(Path("/tmp"), "abc-123")
|
||||
assert cfg.command == ["codex", "resume", "abc-123"]
|
||||
|
||||
|
||||
def test_codex_capture_session_id(tmp_path):
|
||||
sessions_dir = tmp_path / ".codex" / "sessions" / "2026" / "06" / "09"
|
||||
sessions_dir.mkdir(parents=True)
|
||||
rollout = sessions_dir / "rollout-20260609-abcdef12-3456-7890-abcd-ef1234567890.jsonl"
|
||||
meta = {"type": "session_meta", "payload": {"id": "abcdef12-3456-7890-abcd-ef1234567890", "cwd": "/projects/foo"}}
|
||||
rollout.write_text(json.dumps(meta) + "\n")
|
||||
|
||||
since = time.time() - 60 # far enough back that freshly-written file qualifies
|
||||
c = CodexConfigurator()
|
||||
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
||||
result = c.capture_session_id(Path("/projects/foo"), since)
|
||||
assert result == "abcdef12-3456-7890-abcd-ef1234567890"
|
||||
|
||||
|
||||
def test_codex_capture_session_id_wrong_cwd(tmp_path):
|
||||
sessions_dir = tmp_path / ".codex" / "sessions" / "2026" / "06" / "09"
|
||||
sessions_dir.mkdir(parents=True)
|
||||
rollout = sessions_dir / "rollout-20260609-abcdef12.jsonl"
|
||||
meta = {"type": "session_meta", "payload": {"id": "abcdef12", "cwd": "/other/path"}}
|
||||
rollout.write_text(json.dumps(meta) + "\n")
|
||||
|
||||
since = time.time() - 60
|
||||
c = CodexConfigurator()
|
||||
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
||||
result = c.capture_session_id(Path("/projects/foo"), since)
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch("hqt.harnesses.registry.shutil.which")
|
||||
def test_registry_discovers_harnesses(mock_which):
|
||||
mock_which.side_effect = lambda x: "/usr/bin/" + x if x in ("claude", "codex") else None
|
||||
result = discover_harnesses()
|
||||
assert "claude" in result
|
||||
assert "codex" in result
|
||||
assert "kiro" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 4: time-fenced capture_session_id and captures_session_id flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_codex_captures_session_id_flag():
|
||||
"""CodexConfigurator must expose captures_session_id = True."""
|
||||
assert CodexConfigurator.captures_session_id is True
|
||||
|
||||
|
||||
def test_base_captures_session_id_flag_false():
|
||||
"""HarnessConfigurator base class default must be False."""
|
||||
from hqt.harnesses.base import HarnessConfigurator
|
||||
assert HarnessConfigurator.captures_session_id is False
|
||||
|
||||
|
||||
def test_claude_captures_session_id_flag_false():
|
||||
"""ClaudeConfigurator must NOT set captures_session_id (inherits False)."""
|
||||
assert ClaudeConfigurator.captures_session_id is False
|
||||
|
||||
|
||||
def _write_rollout(path: Path, session_id: str, cwd: str) -> None:
|
||||
meta = {"type": "session_meta", "payload": {"id": session_id, "cwd": cwd}}
|
||||
path.write_text(json.dumps(meta) + "\n")
|
||||
|
||||
|
||||
def test_codex_capture_ignores_file_before_since(tmp_path):
|
||||
"""Rollout with mtime BEFORE since must not be returned even if cwd matches."""
|
||||
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
||||
sessions_dir.mkdir(parents=True)
|
||||
rollout = sessions_dir / "rollout-old.jsonl"
|
||||
_write_rollout(rollout, "old-id", "/projects/foo")
|
||||
|
||||
# Set mtime to something clearly in the past
|
||||
past = time.time() - 60
|
||||
os.utime(rollout, (past, past))
|
||||
|
||||
since = time.time() - 10 # since is after the file's mtime
|
||||
|
||||
c = CodexConfigurator()
|
||||
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
||||
result = c.capture_session_id(Path("/projects/foo"), since)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_codex_capture_accepts_file_after_since(tmp_path):
|
||||
"""Rollout with mtime >= since and matching cwd is returned."""
|
||||
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
||||
sessions_dir.mkdir(parents=True)
|
||||
rollout = sessions_dir / "rollout-new.jsonl"
|
||||
_write_rollout(rollout, "new-id", "/projects/foo")
|
||||
|
||||
since = time.time() - 60 # since is well before file was written
|
||||
# mtime of a freshly written file should be >= since
|
||||
|
||||
c = CodexConfigurator()
|
||||
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
||||
result = c.capture_session_id(Path("/projects/foo"), since)
|
||||
assert result == "new-id"
|
||||
|
||||
|
||||
def test_codex_capture_newest_post_since_cwd_match_wins(tmp_path):
|
||||
"""Among multiple post-since rollouts, newest matching cwd wins over older matching."""
|
||||
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
||||
sessions_dir.mkdir(parents=True)
|
||||
|
||||
rollout_older = sessions_dir / "rollout-older.jsonl"
|
||||
rollout_newer = sessions_dir / "rollout-newer.jsonl"
|
||||
_write_rollout(rollout_older, "older-id", "/projects/foo")
|
||||
_write_rollout(rollout_newer, "newer-id", "/projects/foo")
|
||||
|
||||
now = time.time()
|
||||
since = now - 120
|
||||
os.utime(rollout_older, (now - 30, now - 30))
|
||||
os.utime(rollout_newer, (now - 10, now - 10))
|
||||
|
||||
c = CodexConfigurator()
|
||||
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
||||
result = c.capture_session_id(Path("/projects/foo"), since)
|
||||
assert result == "newer-id"
|
||||
|
||||
|
||||
def test_codex_capture_skips_non_matching_cwd_picks_older_matching(tmp_path):
|
||||
"""Newest post-since file has wrong cwd; older post-since file with right cwd is returned."""
|
||||
sessions_dir = tmp_path / ".codex" / "sessions" / "dir"
|
||||
sessions_dir.mkdir(parents=True)
|
||||
|
||||
rollout_wrong = sessions_dir / "rollout-wrong.jsonl"
|
||||
rollout_right = sessions_dir / "rollout-right.jsonl"
|
||||
_write_rollout(rollout_wrong, "wrong-id", "/other/project")
|
||||
_write_rollout(rollout_right, "right-id", "/projects/foo")
|
||||
|
||||
now = time.time()
|
||||
since = now - 120
|
||||
# wrong cwd file is newer
|
||||
os.utime(rollout_wrong, (now - 5, now - 5))
|
||||
# right cwd file is older but still after since
|
||||
os.utime(rollout_right, (now - 20, now - 20))
|
||||
|
||||
c = CodexConfigurator()
|
||||
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
||||
result = c.capture_session_id(Path("/projects/foo"), since)
|
||||
assert result == "right-id"
|
||||
|
||||
|
||||
def test_codex_capture_missing_sessions_dir_returns_none(tmp_path):
|
||||
"""Missing ~/.codex/sessions dir returns None without error."""
|
||||
c = CodexConfigurator()
|
||||
with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path):
|
||||
result = c.capture_session_id(Path("/projects/foo"), time.time())
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 7: parse_status for harness configurators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
CLAUDE_WORKING_SCREEN = """\
|
||||
> Implement feature X
|
||||
|
||||
● Bash(git status)
|
||||
⎿ On branch main
|
||||
|
||||
● Write(src/foo.py)
|
||||
⎿ Wrote 42 lines
|
||||
|
||||
Esc to interrupt
|
||||
"""
|
||||
|
||||
CLAUDE_WAITING_SCREEN = """\
|
||||
> Do you want to proceed with these changes?
|
||||
❯ 1. Yes
|
||||
2. No
|
||||
|
||||
"""
|
||||
|
||||
CLAUDE_PERMISSION_PROMPT = """\
|
||||
> Do you want to allow this tool to run?
|
||||
❯ 1. Yes, allow once
|
||||
2. No
|
||||
|
||||
"""
|
||||
|
||||
CLAUDE_IDLE_SCREEN = """\
|
||||
>
|
||||
|
||||
✓ Done
|
||||
|
||||
"""
|
||||
|
||||
CODEX_WORKING_SCREEN = """\
|
||||
Working...
|
||||
|
||||
Esc to interrupt
|
||||
"""
|
||||
|
||||
CODEX_IDLE_SCREEN = """\
|
||||
>
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def test_base_parse_status_returns_none():
|
||||
"""Base HarnessConfigurator.parse_status always returns None."""
|
||||
from hqt.harnesses.configurators.kiro import KiroConfigurator
|
||||
k = KiroConfigurator()
|
||||
assert k.parse_status("anything") is None
|
||||
assert k.parse_status("") is None
|
||||
|
||||
|
||||
def test_claude_parse_status_working():
|
||||
"""ClaudeConfigurator: 'esc to interrupt' (case-insensitive) → 'working'."""
|
||||
c = ClaudeConfigurator()
|
||||
assert c.parse_status(CLAUDE_WORKING_SCREEN) == "working"
|
||||
|
||||
|
||||
def test_claude_parse_status_working_case_insensitive():
|
||||
"""ClaudeConfigurator: 'ESC TO INTERRUPT' upper-case → 'working'."""
|
||||
c = ClaudeConfigurator()
|
||||
assert c.parse_status("ESC TO INTERRUPT") == "working"
|
||||
|
||||
|
||||
def test_claude_parse_status_waiting_do_you_want():
|
||||
"""ClaudeConfigurator: line starting with 'Do you want' → 'waiting'."""
|
||||
c = ClaudeConfigurator()
|
||||
assert c.parse_status(CLAUDE_WAITING_SCREEN) == "waiting"
|
||||
|
||||
|
||||
def test_claude_parse_status_waiting_permission_prompt():
|
||||
"""ClaudeConfigurator: '❯ 1.' style line → 'waiting'."""
|
||||
c = ClaudeConfigurator()
|
||||
assert c.parse_status(CLAUDE_PERMISSION_PROMPT) == "waiting"
|
||||
|
||||
|
||||
def test_claude_parse_status_none_for_idle():
|
||||
"""ClaudeConfigurator: idle/done screen → None (activity-based fallback)."""
|
||||
c = ClaudeConfigurator()
|
||||
assert c.parse_status(CLAUDE_IDLE_SCREEN) is None
|
||||
|
||||
|
||||
def test_claude_parse_status_empty_returns_none():
|
||||
"""ClaudeConfigurator: empty text → None."""
|
||||
c = ClaudeConfigurator()
|
||||
assert c.parse_status("") is None
|
||||
|
||||
|
||||
def test_codex_parse_status_working():
|
||||
"""CodexConfigurator: 'esc to interrupt' → 'working'."""
|
||||
c = CodexConfigurator()
|
||||
assert c.parse_status(CODEX_WORKING_SCREEN) == "working"
|
||||
|
||||
|
||||
def test_codex_parse_status_none_for_idle():
|
||||
"""CodexConfigurator: idle screen → None."""
|
||||
c = CodexConfigurator()
|
||||
assert c.parse_status(CODEX_IDLE_SCREEN) is None
|
||||
|
||||
|
||||
def test_codex_parse_status_none_for_empty():
|
||||
"""CodexConfigurator: empty text → None."""
|
||||
c = CodexConfigurator()
|
||||
assert c.parse_status("") is None
|
||||
Reference in New Issue
Block a user