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,20 @@
|
||||
import re
|
||||
|
||||
from hqt.config import Settings
|
||||
|
||||
|
||||
def test_tui_window_name_distinct_from_session_windows():
|
||||
"""The main TUI window name must not collide with the 'hqt-{id}' harness
|
||||
session-window pattern, so the tmux status bar can't read it as a truncated
|
||||
session name (the trailing '-' there is tmux's last-window flag, not a name)."""
|
||||
s = Settings()
|
||||
# Session windows are named hqt-1, hqt-2, ... The main window must differ.
|
||||
assert not re.fullmatch(r"hqt(-\d+)?", s.tui_window_name)
|
||||
assert s.tui_window_name != "hqt"
|
||||
assert s.tui_window_name == "⌂ HQT"
|
||||
|
||||
|
||||
def test_tui_session_name_is_short_for_status_left():
|
||||
"""tmux's default status-left prints "[#{session_name}] " in the lower-left
|
||||
corner; the session name should be the clean "hqt", not "hqt-main"."""
|
||||
assert Settings().tui_session_name == "hqt"
|
||||
@@ -0,0 +1,72 @@
|
||||
from pathlib import Path
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from hqt.config import Settings
|
||||
from hqt.db.engine import ensure_db, get_engine, get_session_factory
|
||||
from hqt.db.models import Harness, Project, Session
|
||||
|
||||
|
||||
def _tmp_settings(tmp_path: Path) -> Settings:
|
||||
return Settings(db_path=tmp_path / "test.db")
|
||||
|
||||
|
||||
def test_ensure_db_creates_tables(tmp_path):
|
||||
settings = _tmp_settings(tmp_path)
|
||||
ensure_db(settings)
|
||||
engine = get_engine(settings)
|
||||
tables = inspect(engine).get_table_names()
|
||||
assert "projects" in tables
|
||||
assert "sessions" in tables
|
||||
assert "harnesses" in tables
|
||||
assert "mcp_servers" in tables
|
||||
assert "project_mcp_servers" in tables
|
||||
assert "project_skills" in tables
|
||||
|
||||
|
||||
def test_project_crud(tmp_path):
|
||||
settings = _tmp_settings(tmp_path)
|
||||
ensure_db(settings)
|
||||
engine = get_engine(settings)
|
||||
factory = get_session_factory(engine)
|
||||
|
||||
with factory() as session:
|
||||
p = Project(name="test", path="/tmp/test")
|
||||
session.add(p)
|
||||
session.commit()
|
||||
assert p.id is not None
|
||||
|
||||
p.name = "updated"
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(Project, p.id)
|
||||
assert fetched.name == "updated"
|
||||
|
||||
session.delete(fetched)
|
||||
session.commit()
|
||||
assert session.get(Project, p.id) is None
|
||||
|
||||
|
||||
def test_session_with_fk(tmp_path):
|
||||
settings = _tmp_settings(tmp_path)
|
||||
ensure_db(settings)
|
||||
engine = get_engine(settings)
|
||||
factory = get_session_factory(engine)
|
||||
|
||||
with factory() as session:
|
||||
p = Project(name="proj", path="/tmp/proj")
|
||||
h = Harness(name="claude-code")
|
||||
session.add_all([p, h])
|
||||
session.commit()
|
||||
|
||||
s = Session(
|
||||
project_id=p.id,
|
||||
harness_id=h.id,
|
||||
tmux_session_name="hqt-test-1",
|
||||
)
|
||||
session.add(s)
|
||||
session.commit()
|
||||
|
||||
assert s.project.name == "proj"
|
||||
assert s.harness.name == "claude-code"
|
||||
assert s in p.sessions
|
||||
@@ -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
|
||||
@@ -0,0 +1,78 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from hqt.db.models import Base, Harness
|
||||
from hqt.projects.service import ProjectService
|
||||
from hqt.sessions.service import SessionService
|
||||
from hqt.tmux.manager import TmuxManager, SpawnResult
|
||||
from hqt.harnesses.configurators.claude import ClaudeConfigurator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
db = factory()
|
||||
db.add(Harness(name="claude-code", display_name="Claude Code"))
|
||||
db.commit()
|
||||
|
||||
tmux = MagicMock(spec=TmuxManager)
|
||||
tmux.spawn = AsyncMock(return_value=SpawnResult(ok=True, window_id="@1", error=""))
|
||||
tmux.kill = AsyncMock()
|
||||
tmux.is_alive = AsyncMock(return_value=True)
|
||||
tmux.window_exists = AsyncMock(return_value=True)
|
||||
tmux.poll_info = AsyncMock(return_value={})
|
||||
tmux.capture_pane = AsyncMock(return_value="")
|
||||
tmux.attach = AsyncMock(return_value=True)
|
||||
|
||||
configurator = ClaudeConfigurator()
|
||||
harnesses = {"claude-code": configurator}
|
||||
|
||||
proj_svc = ProjectService(db)
|
||||
sess_svc = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
return proj_svc, sess_svc, tmux
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_lifecycle(setup):
|
||||
proj_svc, sess_svc, tmux = setup
|
||||
|
||||
# Create project
|
||||
project = proj_svc.create(name="test-proj", path="/tmp/test-proj")
|
||||
assert project.id == 1
|
||||
|
||||
# Create session
|
||||
create_result = await sess_svc.create_session(project_id=project.id, harness_name="claude-code", nickname="e2e")
|
||||
session = create_result.session
|
||||
assert session.id is not None
|
||||
tmux.spawn.assert_called_once()
|
||||
|
||||
# List sessions - alive
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
tmux.poll_info.return_value = {session.tmux_session_name: WindowInfo(alive=True, last_activity=0)}
|
||||
infos = await sess_svc.list_sessions(project_id=project.id)
|
||||
assert len(infos) == 1
|
||||
assert infos[0].alive is True
|
||||
|
||||
# Stop session
|
||||
await sess_svc.stop_session(session_id=session.id)
|
||||
tmux.kill.assert_called_once_with(session.tmux_session_name)
|
||||
|
||||
# List sessions - dead
|
||||
tmux.poll_info.return_value = {session.tmux_session_name: WindowInfo(alive=False, last_activity=0)}
|
||||
infos = await sess_svc.list_sessions(project_id=project.id)
|
||||
assert len(infos) == 1
|
||||
assert infos[0].alive is False
|
||||
|
||||
# Delete session
|
||||
tmux.window_exists.return_value = False
|
||||
await sess_svc.delete_session(session_id=session.id)
|
||||
|
||||
# List sessions - empty
|
||||
infos = await sess_svc.list_sessions(project_id=project.id)
|
||||
assert len(infos) == 0
|
||||
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import hqt.logging as hqt_logging
|
||||
from hqt.config import Settings
|
||||
|
||||
|
||||
def test_setup_logging_does_not_write_to_terminal(tmp_path, monkeypatch):
|
||||
"""setup_logging must NOT attach a StreamHandler that writes to the real
|
||||
terminal stderr/stdout.
|
||||
|
||||
The TUI is a Textual app that paints to sys.__stderr__. A root-logger
|
||||
StreamHandler bound to that same fd writes raw log lines straight onto the
|
||||
pane Textual owns, corrupting tmux's grid — visible as rendering artifacts
|
||||
when switching back to the hqt window. Logs must go to file only.
|
||||
"""
|
||||
settings = Settings(db_path=tmp_path / "hqt.db")
|
||||
monkeypatch.setattr(hqt_logging, "get_settings", lambda: settings)
|
||||
|
||||
root = logging.getLogger()
|
||||
saved_handlers = root.handlers[:]
|
||||
saved_level = root.level
|
||||
try:
|
||||
root.handlers.clear()
|
||||
hqt_logging.setup_logging()
|
||||
|
||||
terminal_streams = {sys.stderr, sys.__stderr__, sys.stdout, sys.__stdout__}
|
||||
offending = [
|
||||
h
|
||||
for h in root.handlers
|
||||
# FileHandler subclasses StreamHandler but writes to a file, not the terminal.
|
||||
if type(h) is logging.StreamHandler
|
||||
and getattr(h, "stream", None) in terminal_streams
|
||||
]
|
||||
assert not offending, (
|
||||
f"setup_logging attached a terminal StreamHandler: {offending}"
|
||||
)
|
||||
# Logs are still captured to a file.
|
||||
assert any(isinstance(h, logging.FileHandler) for h in root.handlers)
|
||||
finally:
|
||||
root.handlers[:] = saved_handlers
|
||||
root.setLevel(saved_level)
|
||||
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from hqt.db.models import Base
|
||||
from hqt.mcp.service import McpService
|
||||
from hqt.projects.service import ProjectService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
class TestProjectService:
|
||||
def test_create_and_get(self, db):
|
||||
svc = ProjectService(db)
|
||||
p = svc.create("test", "/tmp/test")
|
||||
assert p.id is not None
|
||||
assert svc.get(p.id).name == "test"
|
||||
|
||||
def test_list_excludes_archived(self, db):
|
||||
svc = ProjectService(db)
|
||||
svc.create("a", "/a")
|
||||
p2 = svc.create("b", "/b")
|
||||
svc.archive(p2.id)
|
||||
assert len(svc.list_all()) == 1
|
||||
assert len(svc.list_all(include_archived=True)) == 2
|
||||
|
||||
def test_update_name_and_path(self, db):
|
||||
svc = ProjectService(db)
|
||||
p = svc.create("old", "/old/path")
|
||||
updated = svc.update(p.id, "new", "/new/path")
|
||||
assert updated.name == "new"
|
||||
assert updated.path == "/new/path"
|
||||
assert svc.get(p.id).path == "/new/path"
|
||||
|
||||
def test_update_duplicate_path_raises(self, db):
|
||||
svc = ProjectService(db)
|
||||
svc.create("a", "/a")
|
||||
p2 = svc.create("b", "/b")
|
||||
with pytest.raises(ValueError, match="already uses path"):
|
||||
svc.update(p2.id, "b", "/a")
|
||||
# DB session must remain usable after rollback, values unchanged
|
||||
assert svc.get(p2.id).path == "/b"
|
||||
assert svc.get(p2.id).name == "b"
|
||||
|
||||
def test_update_unknown_id_raises(self, db):
|
||||
svc = ProjectService(db)
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
svc.update(9999, "x", "/x")
|
||||
|
||||
|
||||
class TestMcpService:
|
||||
def test_crud(self, db):
|
||||
svc = McpService(db)
|
||||
server = svc.create("test-mcp", "stdio", command="node", args=["server.js"])
|
||||
assert server.id is not None
|
||||
assert svc.get("test-mcp") is not None
|
||||
assert len(svc.list_all()) == 1
|
||||
svc.delete("test-mcp")
|
||||
assert svc.get("test-mcp") is None
|
||||
|
||||
def test_bind_unbind(self, db):
|
||||
psvc = ProjectService(db)
|
||||
msvc = McpService(db)
|
||||
project = psvc.create("proj", "/proj")
|
||||
server = msvc.create("s1", "stdio", command="cmd")
|
||||
msvc.bind_to_project(project.id, server.id)
|
||||
assert len(msvc.get_project_mcps(project.id)) == 1
|
||||
msvc.unbind_from_project(project.id, server.id)
|
||||
assert len(msvc.get_project_mcps(project.id)) == 0
|
||||
@@ -0,0 +1,841 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from hqt.db.models import Base, Harness, Project
|
||||
from hqt.sessions.service import SessionInfo, SessionService
|
||||
from hqt.tmux.manager import SpawnResult, TmuxManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
session = factory()
|
||||
session.add(Harness(name="claude-code", display_name="Claude Code"))
|
||||
session.add(Project(name="myproj", path="/tmp/myproj"))
|
||||
session.commit()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmux():
|
||||
m = MagicMock(spec=TmuxManager)
|
||||
m.spawn = AsyncMock(return_value=SpawnResult(ok=True, window_id="@1", error=""))
|
||||
m.kill = AsyncMock()
|
||||
m.is_alive = AsyncMock(return_value=True)
|
||||
m.poll_info = AsyncMock(return_value={})
|
||||
m.capture_pane = AsyncMock(return_value="")
|
||||
m.attach = AsyncMock()
|
||||
m.window_exists = AsyncMock(return_value=True)
|
||||
m.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harnesses():
|
||||
h = MagicMock()
|
||||
h.captures_session_id = False
|
||||
h.generate_session_id.return_value = "sess-1"
|
||||
h.build_spawn_config.return_value = MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
|
||||
h.parse_status = MagicMock(return_value=None)
|
||||
return {"claude-code": h}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(db, tmux, harnesses):
|
||||
return SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session(service, db, tmux, harnesses):
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code", nickname="test")
|
||||
sess = result.session
|
||||
assert sess.id is not None
|
||||
assert sess.tmux_session_name == f"hqt-{sess.id}"
|
||||
assert sess.harness_session_id == "sess-1"
|
||||
tmux.spawn.assert_called_once()
|
||||
harnesses["claude-code"].build_spawn_config.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions(service, db, tmux):
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1000)}
|
||||
result = await service.list_sessions(project_id=1)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], SessionInfo)
|
||||
assert result[0].alive is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_session(service, tmux):
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
await service.stop_session(session_id=1)
|
||||
tmux.kill.assert_called_once_with("hqt-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_session_no_kill_when_window_missing(service, db, tmux):
|
||||
"""stop_session must NOT call kill when the window doesn't exist."""
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
tmux.window_exists = AsyncMock(return_value=False)
|
||||
tmux.kill.reset_mock()
|
||||
await service.stop_session(session_id=1)
|
||||
tmux.kill.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_logs_failed_spawn(service, db, tmux, caplog):
|
||||
"""create_session logs error including pane text when spawn fails, but still commits."""
|
||||
import logging
|
||||
tmux.spawn.return_value = SpawnResult(ok=False, window_id=None, error="bash: command not found")
|
||||
with caplog.at_level(logging.ERROR):
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
assert result.session.id is not None # row was committed
|
||||
# Error was logged and includes captured pane text
|
||||
error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]
|
||||
assert any("command not found" in m for m in error_messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_session_passes_env(service, db, tmux, harnesses):
|
||||
"""attach_session passes cfg.env to tmux.respawn_verified when the window needs respawning."""
|
||||
from unittest.mock import AsyncMock
|
||||
# Create a session first
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
# Set up harness to return a config with env
|
||||
env_cfg = MagicMock(command=["claude"], env={"SESSION_VAR": "abc"}, cwd=Path("/tmp/myproj"))
|
||||
harnesses["claude-code"].build_resume_config = MagicMock(return_value=env_cfg)
|
||||
|
||||
# Simulate window is not alive → respawn path
|
||||
tmux.is_alive = AsyncMock(return_value=False)
|
||||
tmux.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
|
||||
tmux.attach = AsyncMock(return_value=True)
|
||||
|
||||
await service.attach_session(session_id=1)
|
||||
|
||||
tmux.respawn_verified.assert_called()
|
||||
first_call = tmux.respawn_verified.call_args_list[0]
|
||||
# env must be forwarded (passed as keyword or positional)
|
||||
assert first_call.kwargs.get("env") == {"SESSION_VAR": "abc"} or \
|
||||
(len(first_call.args) >= 4 and first_call.args[3] == {"SESSION_VAR": "abc"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 3: fallback ladder + model preserved on resume
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_session_dead_resume_ok_attaches_once(service, db, tmux, harnesses):
|
||||
"""attach_session: resume ok → attach called once, no fallback spawn attempted."""
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
resume_cfg = MagicMock(command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
||||
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
|
||||
harnesses["claude-code"].build_spawn_config = MagicMock(
|
||||
return_value=MagicMock(command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
||||
)
|
||||
|
||||
tmux.is_alive = AsyncMock(return_value=False)
|
||||
tmux.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
|
||||
tmux.attach = AsyncMock(return_value=True)
|
||||
|
||||
result = await service.attach_session(session_id=1)
|
||||
|
||||
assert result is True
|
||||
tmux.attach.assert_called_once()
|
||||
# Only one respawn_verified call (the resume rung), no fallback
|
||||
assert tmux.respawn_verified.call_count == 1
|
||||
first_cmd = tmux.respawn_verified.call_args_list[0].args[1]
|
||||
assert "--resume" in first_cmd
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_session_dead_resume_fails_fallback_spawn(service, db, tmux, harnesses):
|
||||
"""attach_session: resume dies → fallback spawn with session-id, attach called once."""
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
resume_cfg = MagicMock(command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
||||
spawn_cfg = MagicMock(command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
||||
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
|
||||
harnesses["claude-code"].build_spawn_config = MagicMock(return_value=spawn_cfg)
|
||||
|
||||
tmux.is_alive = AsyncMock(return_value=False)
|
||||
tmux.respawn_verified = AsyncMock(side_effect=[
|
||||
SpawnResult(ok=False, window_id=None, error="no transcript"), # resume fails
|
||||
SpawnResult(ok=True, window_id="@2", error=""), # fresh spawn ok
|
||||
])
|
||||
tmux.attach = AsyncMock(return_value=True)
|
||||
|
||||
result = await service.attach_session(session_id=1)
|
||||
|
||||
assert result is True
|
||||
# Two respawn_verified calls: first resume, then spawn
|
||||
assert tmux.respawn_verified.call_count == 2
|
||||
first_cmd = tmux.respawn_verified.call_args_list[0].args[1]
|
||||
second_cmd = tmux.respawn_verified.call_args_list[1].args[1]
|
||||
assert "--resume" in first_cmd
|
||||
assert "--session-id" in second_cmd
|
||||
tmux.attach.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_session_both_rungs_fail_returns_false(service, db, tmux, harnesses):
|
||||
"""attach_session: both resume and spawn fail → False, no attach."""
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
resume_cfg = MagicMock(command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
||||
spawn_cfg = MagicMock(command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj"))
|
||||
harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg)
|
||||
harnesses["claude-code"].build_spawn_config = MagicMock(return_value=spawn_cfg)
|
||||
|
||||
tmux.is_alive = AsyncMock(return_value=False)
|
||||
tmux.respawn_verified = AsyncMock(return_value=SpawnResult(ok=False, window_id=None, error="fatal"))
|
||||
tmux.attach = AsyncMock(return_value=True)
|
||||
|
||||
result = await service.attach_session(session_id=1)
|
||||
|
||||
assert result is False
|
||||
tmux.attach.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 4: captures_session_id flag + bounded async retry in create_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harnesses_no_capture():
|
||||
"""Configurator with captures_session_id=False (like claude-code)."""
|
||||
h = MagicMock()
|
||||
h.captures_session_id = False
|
||||
h.generate_session_id.return_value = "sess-nc"
|
||||
h.build_spawn_config.return_value = MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
|
||||
return {"claude-code": h}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harnesses_capture():
|
||||
"""Configurator with captures_session_id=True (like codex)."""
|
||||
h = MagicMock()
|
||||
h.captures_session_id = True
|
||||
h.generate_session_id.return_value = "placeholder-id"
|
||||
h.build_spawn_config.return_value = MagicMock(command=["codex"], env={}, cwd=Path("/tmp/myproj"))
|
||||
return {"claude-code": h}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_no_capture_flag_never_calls_capture(db, tmux, harnesses_no_capture):
|
||||
"""When captures_session_id=False, capture_session_id is never called."""
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_no_capture)
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
harnesses_no_capture["claude-code"].capture_session_id.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_capture_retries_until_success(db, tmux, harnesses_capture):
|
||||
"""captures_session_id=True: returns None twice then an id → retries and stores captured id."""
|
||||
import hqt.sessions.service as svc_mod
|
||||
|
||||
harnesses_capture["claude-code"].capture_session_id.side_effect = [None, None, "captured-id-xyz"]
|
||||
|
||||
sleep_calls = []
|
||||
|
||||
async def fake_sleep(t):
|
||||
sleep_calls.append(t)
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
|
||||
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
assert result.session.harness_session_id == "captured-id-xyz"
|
||||
# capture was called 3 times: first immediate, then twice after sleeps
|
||||
assert harnesses_capture["claude-code"].capture_session_id.call_count == 3
|
||||
assert len(sleep_calls) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_capture_all_none_keeps_placeholder(db, tmux, harnesses_capture, caplog):
|
||||
"""All capture attempts return None → placeholder id kept, no crash, warning logged."""
|
||||
import logging
|
||||
import hqt.sessions.service as svc_mod
|
||||
|
||||
harnesses_capture["claude-code"].capture_session_id.return_value = None
|
||||
|
||||
async def fake_sleep(t):
|
||||
pass
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
assert result.session.harness_session_id == "placeholder-id"
|
||||
# Should have attempted up to 6 times (CAPTURE_MAX_ATTEMPTS)
|
||||
assert harnesses_capture["claude-code"].capture_session_id.call_count == svc_mod.CAPTURE_MAX_ATTEMPTS
|
||||
# Exhaustion must emit a warning
|
||||
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert any("exhausted" in m for m in warning_messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_capture_first_attempt_succeeds(db, tmux, harnesses_capture):
|
||||
"""captures_session_id=True: first attempt succeeds → no sleeps needed."""
|
||||
import hqt.sessions.service as svc_mod
|
||||
|
||||
harnesses_capture["claude-code"].capture_session_id.return_value = "immediate-id"
|
||||
|
||||
sleep_calls = []
|
||||
|
||||
async def fake_sleep(t):
|
||||
sleep_calls.append(t)
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
|
||||
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
assert result.session.harness_session_id == "immediate-id"
|
||||
assert len(sleep_calls) == 0 # no sleep needed — succeeded on first try
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 7: list_sessions status computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_dead_window(db, tmux, harnesses):
|
||||
"""list_sessions: dead window → status='dead', alive=False."""
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||
project_id=1, harness_name="claude-code"
|
||||
)
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
result = await service.list_sessions(project_id=1)
|
||||
assert len(result) == 1
|
||||
assert result[0].alive is False
|
||||
assert result[0].status == "dead"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_missing_window_is_dead(db, tmux, harnesses):
|
||||
"""list_sessions: window not in poll_info result → status='dead', alive=False."""
|
||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||
project_id=1, harness_name="claude-code"
|
||||
)
|
||||
tmux.poll_info.return_value = {} # no windows at all
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
result = await service.list_sessions(project_id=1)
|
||||
assert result[0].alive is False
|
||||
assert result[0].status == "dead"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_alive_parse_working(db, tmux, harnesses):
|
||||
"""Alive window + harness parse returns 'working' → status='working'."""
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||
project_id=1, harness_name="claude-code"
|
||||
)
|
||||
|
||||
now = 2000.0
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
|
||||
tmux.capture_pane = AsyncMock(return_value="Esc to interrupt\n")
|
||||
harnesses["claude-code"].parse_status = MagicMock(return_value="working")
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
result = await service.list_sessions(project_id=1, now=now)
|
||||
|
||||
assert result[0].status == "working"
|
||||
assert result[0].alive is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_alive_parse_none_recent_activity(db, tmux, harnesses):
|
||||
"""Alive window + parse_status None + recent activity → status='active'."""
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||
project_id=1, harness_name="claude-code"
|
||||
)
|
||||
|
||||
now = 2000.0
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1998)} # 2s ago
|
||||
tmux.capture_pane = AsyncMock(return_value="some text")
|
||||
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
result = await service.list_sessions(project_id=1, now=now)
|
||||
|
||||
assert result[0].status == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_alive_parse_none_stale_activity(db, tmux, harnesses):
|
||||
"""Alive window + parse_status None + stale activity → status='idle'."""
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||
project_id=1, harness_name="claude-code"
|
||||
)
|
||||
|
||||
now = 2000.0
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1990)} # 10s ago
|
||||
tmux.capture_pane = AsyncMock(return_value="some text")
|
||||
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
result = await service.list_sessions(project_id=1, now=now)
|
||||
|
||||
assert result[0].status == "idle"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_capture_empty_falls_back_to_activity(db, tmux, harnesses):
|
||||
"""capture_pane returns empty text → activity-based fallback, no crash."""
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||
project_id=1, harness_name="claude-code"
|
||||
)
|
||||
|
||||
now = 2000.0
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
|
||||
tmux.capture_pane = AsyncMock(return_value="") # empty — treat as capture failure
|
||||
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
result = await service.list_sessions(project_id=1, now=now)
|
||||
|
||||
# Should still produce a valid status via activity
|
||||
assert result[0].status in ("active", "idle")
|
||||
assert result[0].alive is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_alive_parse_waiting(db, tmux, harnesses):
|
||||
"""Alive window + parse_status 'waiting' → status='waiting'."""
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
|
||||
project_id=1, harness_name="claude-code"
|
||||
)
|
||||
|
||||
now = 2000.0
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
|
||||
tmux.capture_pane = AsyncMock(return_value="Do you want to proceed?\n❯ 1. Yes\n")
|
||||
harnesses["claude-code"].parse_status = MagicMock(return_value="waiting")
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
result = await service.list_sessions(project_id=1, now=now)
|
||||
|
||||
assert result[0].status == "waiting"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync_window_labels: status-reflecting tmux window titles (session-wide)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_window_labels_sets_status_symbol(db, tmux, harnesses):
|
||||
"""sync_window_labels pushes a '<symbol> <nickname>' label per session."""
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
await service.create_session(project_id=1, harness_name="claude-code", nickname="mywork")
|
||||
|
||||
now = 2000.0
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
|
||||
tmux.capture_pane = AsyncMock(return_value="Esc to interrupt\n")
|
||||
harnesses["claude-code"].parse_status = MagicMock(return_value="working")
|
||||
tmux.set_window_label = AsyncMock()
|
||||
|
||||
infos = await service.sync_window_labels(now=now)
|
||||
|
||||
assert len(infos) == 1
|
||||
assert infos[0].status == "working"
|
||||
# Label uses the working glyph (◐) and the nickname.
|
||||
tmux.set_window_label.assert_awaited_once_with("hqt-1", "◐ mywork")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_window_labels_dead_window_uses_dead_glyph(db, tmux, harnesses):
|
||||
"""A dead/missing window gets the ○ glyph and falls back to the window name."""
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
await service.create_session(project_id=1, harness_name="claude-code") # no nickname
|
||||
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
|
||||
tmux.set_window_label = AsyncMock()
|
||||
|
||||
infos = await service.sync_window_labels()
|
||||
|
||||
assert infos[0].status == "dead"
|
||||
# No nickname → falls back to the tmux window name.
|
||||
tmux.set_window_label.assert_awaited_once_with("hqt-1", "○ hqt-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_window_labels_covers_all_projects(db, tmux, harnesses):
|
||||
"""sync_window_labels is session-wide: it labels sessions across all projects."""
|
||||
from hqt.db.models import Project
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
|
||||
db.add(Project(name="proj2", path="/tmp/proj2"))
|
||||
db.commit()
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
await service.create_session(project_id=1, harness_name="claude-code", nickname="a")
|
||||
await service.create_session(project_id=2, harness_name="claude-code", nickname="b")
|
||||
|
||||
tmux.poll_info.return_value = {
|
||||
"hqt-1": WindowInfo(alive=False, last_activity=0),
|
||||
"hqt-2": WindowInfo(alive=False, last_activity=0),
|
||||
}
|
||||
tmux.set_window_label = AsyncMock()
|
||||
|
||||
infos = await service.sync_window_labels()
|
||||
|
||||
assert len(infos) == 2 # both projects' sessions processed
|
||||
labelled = {c.args[0] for c in tmux.set_window_label.await_args_list}
|
||||
assert labelled == {"hqt-1", "hqt-2"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Item 3: real-configurator integration-seam test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_real_configurator_waiting(db):
|
||||
"""list_sessions with real ClaudeConfigurator + spec'd TmuxManager mock → status='waiting'.
|
||||
|
||||
Uses the REAL ClaudeConfigurator (not a mock) to close the decorative-fixture
|
||||
gap and ensure parse_status is correctly wired end-to-end.
|
||||
|
||||
TDD story: before the capture_pane delegate is added to TmuxManager,
|
||||
MagicMock(spec=TmuxManager).capture_pane raises AttributeError, which propagates
|
||||
as an AttributeError from list_sessions. After the delegate is added the spec
|
||||
allows the attribute and this test passes.
|
||||
"""
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
from hqt.tmux.manager import TmuxManager
|
||||
from hqt.harnesses.configurators.claude import ClaudeConfigurator
|
||||
|
||||
# spec=TmuxManager: only methods that exist on TmuxManager are accessible.
|
||||
# capture_pane MUST be on TmuxManager (via the delegate) for the spec to allow it.
|
||||
tmux_spec = MagicMock(spec=TmuxManager)
|
||||
tmux_spec.spawn = AsyncMock(return_value=SpawnResult(ok=True, window_id="@1", error=""))
|
||||
tmux_spec.kill = AsyncMock()
|
||||
tmux_spec.is_alive = AsyncMock(return_value=True)
|
||||
tmux_spec.poll_info = AsyncMock(return_value={})
|
||||
# capture_pane returns realistic "waiting" pane text; the spec must allow this
|
||||
# attribute — fails with AttributeError before TmuxManager.capture_pane delegate exists.
|
||||
tmux_spec.capture_pane = AsyncMock(return_value="Do you want to proceed?\n❯ 1. Yes\n")
|
||||
tmux_spec.attach = AsyncMock()
|
||||
tmux_spec.window_exists = AsyncMock(return_value=True)
|
||||
tmux_spec.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
|
||||
|
||||
configurator = ClaudeConfigurator()
|
||||
harnesses = {"claude-code": configurator}
|
||||
service = SessionService(db=db, tmux=tmux_spec, harnesses=harnesses)
|
||||
|
||||
# Create a session so there is a row in the DB
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
now = 2000.0
|
||||
window_name = "hqt-1"
|
||||
tmux_spec.poll_info.return_value = {window_name: WindowInfo(alive=True, last_activity=1999)}
|
||||
|
||||
result = await service.list_sessions(project_id=1, now=now)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "waiting"
|
||||
assert result[0].alive is True
|
||||
tmux_spec.capture_pane.assert_called_once_with(window_name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Item 4: capture_pane NOT called for dead/missing windows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_dead_window_no_capture(db, tmux):
|
||||
"""list_sessions: dead window → capture_pane must NOT be called."""
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
from hqt.harnesses.configurators.claude import ClaudeConfigurator
|
||||
|
||||
harnesses = {"claude-code": ClaudeConfigurator()}
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
|
||||
tmux.capture_pane.reset_mock()
|
||||
|
||||
await service.list_sessions(project_id=1)
|
||||
|
||||
tmux.capture_pane.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_sessions_missing_window_no_capture(db, tmux):
|
||||
"""list_sessions: window missing from poll_info → capture_pane must NOT be called."""
|
||||
from hqt.harnesses.configurators.claude import ClaudeConfigurator
|
||||
|
||||
harnesses = {"claude-code": ClaudeConfigurator()}
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
tmux.poll_info.return_value = {} # window not present at all
|
||||
tmux.capture_pane.reset_mock()
|
||||
|
||||
await service.list_sessions(project_id=1)
|
||||
|
||||
tmux.capture_pane.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix 1: _respawn_with_fallback rung-2 updates harness_session_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harnesses_capture_fallback():
|
||||
"""Configurator with captures_session_id=True for fallback ladder tests."""
|
||||
h = MagicMock()
|
||||
h.name = "claude-code"
|
||||
h.captures_session_id = True
|
||||
h.generate_session_id.return_value = "placeholder-id"
|
||||
h.build_spawn_config.return_value = MagicMock(command=["codex"], env={}, cwd=Path("/tmp/myproj"))
|
||||
h.build_resume_config.return_value = MagicMock(command=["codex", "resume", "placeholder-id"], env={}, cwd=Path("/tmp/myproj"))
|
||||
return {"claude-code": h}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_fallback_rung2_updates_harness_session_id(db, tmux, harnesses_capture_fallback):
|
||||
"""Rung-2 success + captures_session_id=True + capture returns new id → sess.harness_session_id updated and committed."""
|
||||
import hqt.sessions.service as svc_mod
|
||||
|
||||
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = "new-codex-id"
|
||||
|
||||
async def fake_sleep(t):
|
||||
pass
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture_fallback)
|
||||
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
sess_id = result.session.id
|
||||
|
||||
# Simulate rung-1 resume fails, rung-2 fresh spawn succeeds
|
||||
harnesses_capture_fallback["claude-code"].capture_session_id.reset_mock()
|
||||
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = "new-codex-id-after-rung2"
|
||||
|
||||
tmux.is_alive = AsyncMock(return_value=False)
|
||||
tmux.respawn_verified = AsyncMock(side_effect=[
|
||||
SpawnResult(ok=False, window_id=None, error="no transcript"), # rung-1 resume fails
|
||||
SpawnResult(ok=True, window_id="@2", error=""), # rung-2 fresh spawn ok
|
||||
])
|
||||
tmux.attach = AsyncMock(return_value=True)
|
||||
|
||||
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||
ok = await service.attach_session(session_id=sess_id)
|
||||
|
||||
assert ok is True
|
||||
# harness_session_id must now be the newly captured id
|
||||
from hqt.db.models import Session as DBSession
|
||||
updated = db.get(DBSession, sess_id)
|
||||
assert updated.harness_session_id == "new-codex-id-after-rung2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(db, tmux, harnesses_capture_fallback, caplog):
|
||||
"""Rung-2 success + capture exhausted → old id kept + warning logged."""
|
||||
import logging
|
||||
import hqt.sessions.service as svc_mod
|
||||
|
||||
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = "placeholder-id"
|
||||
|
||||
async def fake_sleep(t):
|
||||
pass
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture_fallback)
|
||||
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
sess_id = result.session.id
|
||||
old_id = result.session.harness_session_id
|
||||
|
||||
# After create, reset capture to always return None
|
||||
harnesses_capture_fallback["claude-code"].capture_session_id.reset_mock()
|
||||
harnesses_capture_fallback["claude-code"].capture_session_id.return_value = None
|
||||
|
||||
tmux.is_alive = AsyncMock(return_value=False)
|
||||
tmux.respawn_verified = AsyncMock(side_effect=[
|
||||
SpawnResult(ok=False, window_id=None, error="no transcript"),
|
||||
SpawnResult(ok=True, window_id="@2", error=""),
|
||||
])
|
||||
tmux.attach = AsyncMock(return_value=True)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||
ok = await service.attach_session(session_id=sess_id)
|
||||
|
||||
assert ok is True
|
||||
from hqt.db.models import Session as DBSession
|
||||
updated = db.get(DBSession, sess_id)
|
||||
# Old id preserved
|
||||
assert updated.harness_session_id == old_id
|
||||
# Warning emitted
|
||||
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert any("exhausted" in m for m in warning_messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_fallback_rung2_no_capture_when_flag_false(db, tmux):
|
||||
"""Rung-2 success + captures_session_id=False → capture_session_id never called."""
|
||||
h = MagicMock()
|
||||
h.name = "claude-code"
|
||||
h.captures_session_id = False
|
||||
h.generate_session_id.return_value = "sess-nc"
|
||||
h.build_spawn_config.return_value = MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj"))
|
||||
h.build_resume_config.return_value = MagicMock(command=["claude", "--resume", "sess-nc"], env={}, cwd=Path("/tmp/myproj"))
|
||||
harnesses_nc = {"claude-code": h}
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_nc)
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
sess_id = result.session.id
|
||||
|
||||
h.capture_session_id.reset_mock()
|
||||
|
||||
tmux.is_alive = AsyncMock(return_value=False)
|
||||
tmux.respawn_verified = AsyncMock(side_effect=[
|
||||
SpawnResult(ok=False, window_id=None, error="no transcript"),
|
||||
SpawnResult(ok=True, window_id="@2", error=""),
|
||||
])
|
||||
tmux.attach = AsyncMock(return_value=True)
|
||||
|
||||
await service.attach_session(session_id=sess_id)
|
||||
|
||||
h.capture_session_id.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix 2 (Minor): capture NOT called when spawn_result.ok is False
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_no_capture_when_spawn_fails(db, tmux, harnesses_capture):
|
||||
"""create_session: spawn fails → capture_session_id must NOT be called."""
|
||||
import hqt.sessions.service as svc_mod
|
||||
|
||||
tmux.spawn.return_value = SpawnResult(ok=False, window_id=None, error="spawn failed")
|
||||
|
||||
async def fake_sleep(t):
|
||||
pass
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
|
||||
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
harnesses_capture["claude-code"].capture_session_id.assert_not_called()
|
||||
assert result.spawn_ok is False
|
||||
assert "spawn failed" in result.spawn_error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix 3: create_session returns CreateSessionResult
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_returns_result_dataclass(service, db, tmux, harnesses):
|
||||
"""create_session returns a CreateSessionResult with spawn_ok=True on success."""
|
||||
from hqt.sessions.service import CreateSessionResult
|
||||
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code", nickname="test")
|
||||
assert isinstance(result, CreateSessionResult)
|
||||
assert result.spawn_ok is True
|
||||
assert result.spawn_error == ""
|
||||
assert result.session.id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_result_propagates_spawn_failure(db, tmux, harnesses):
|
||||
"""create_session result reflects spawn failure."""
|
||||
from hqt.sessions.service import CreateSessionResult
|
||||
|
||||
tmux.spawn.return_value = SpawnResult(ok=False, window_id=None, error="command not found: claude")
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
assert isinstance(result, CreateSessionResult)
|
||||
assert result.spawn_ok is False
|
||||
assert "command not found" in result.spawn_error
|
||||
# Session row was still committed
|
||||
assert result.session.id is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rename_session / get_session
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_session(db, nickname=None):
|
||||
"""Create a session for project 1 / harness 'claude-code' (seeded by the db fixture)."""
|
||||
from hqt.db.models import Harness, Session
|
||||
|
||||
harness = db.query(Harness).filter_by(name="claude-code").first()
|
||||
sess = Session(
|
||||
project_id=1,
|
||||
harness_id=harness.id,
|
||||
nickname=nickname,
|
||||
tmux_session_name="hqt-rename",
|
||||
archived=False,
|
||||
)
|
||||
db.add(sess)
|
||||
db.commit()
|
||||
return sess
|
||||
|
||||
|
||||
def test_rename_session_sets_nickname(service, db):
|
||||
from hqt.db.models import Session
|
||||
|
||||
sess = _seed_session(db, nickname="old")
|
||||
service.rename_session(sess.id, "new-name")
|
||||
assert db.get(Session, sess.id).nickname == "new-name"
|
||||
|
||||
|
||||
def test_rename_session_empty_clears_to_none(service, db):
|
||||
from hqt.db.models import Session
|
||||
|
||||
sess = _seed_session(db, nickname="old")
|
||||
service.rename_session(sess.id, "")
|
||||
assert db.get(Session, sess.id).nickname is None
|
||||
|
||||
|
||||
def test_rename_session_whitespace_clears_to_none(service, db):
|
||||
from hqt.db.models import Session
|
||||
|
||||
sess = _seed_session(db, nickname="old")
|
||||
service.rename_session(sess.id, " ")
|
||||
assert db.get(Session, sess.id).nickname is None
|
||||
|
||||
|
||||
def test_get_session_returns_row(service, db):
|
||||
sess = _seed_session(db, nickname="x")
|
||||
assert service.get_session(sess.id).id == sess.id
|
||||
|
||||
|
||||
def test_get_session_missing_returns_none(service):
|
||||
assert service.get_session(99999) is None
|
||||
@@ -0,0 +1,27 @@
|
||||
from pathlib import Path
|
||||
|
||||
from hqt.skills.registry import SkillRegistry
|
||||
|
||||
|
||||
def test_discover_skills(tmp_path: Path):
|
||||
skill_dir = tmp_path / "my-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\nSkill content here")
|
||||
|
||||
registry = SkillRegistry(tmp_path)
|
||||
skills = registry.discover()
|
||||
|
||||
assert len(skills) == 1
|
||||
assert skills[0].name == "my-skill"
|
||||
assert skills[0].description == "A test skill"
|
||||
assert skills[0].content == "Skill content here"
|
||||
|
||||
|
||||
def test_discover_empty_dir(tmp_path: Path):
|
||||
registry = SkillRegistry(tmp_path)
|
||||
assert registry.discover() == []
|
||||
|
||||
|
||||
def test_discover_nonexistent_dir(tmp_path: Path):
|
||||
registry = SkillRegistry(tmp_path / "nope")
|
||||
assert registry.discover() == []
|
||||
@@ -0,0 +1,809 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from hqt.tmux.runner import TmuxRunner
|
||||
from hqt.tmux.manager import TmuxManager, SpawnRequest, SpawnResult
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
r = TmuxRunner(session_name="hqt-main")
|
||||
r._exec = AsyncMock(return_value=(0, "", ""))
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_window(runner):
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows -F '#{window_index}' (next-index query)
|
||||
(0, "@1\n", ""), # new-window -P -F
|
||||
(0, "", ""), # set-option (combined: remain-on-exit + allow-rename + automatic-rename)
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
result = await runner.new_window("hqt-1", "/tmp", "kiro-cli chat")
|
||||
assert result == "@1"
|
||||
# Step 2: combined set-option call with ";" separators
|
||||
set_call = runner._exec.call_args_list[2].args
|
||||
assert set_call == (
|
||||
"set-option", "-t", "hqt-main:=hqt-1", "remain-on-exit", "on",
|
||||
";", "set-option", "-t", "hqt-main:=hqt-1", "allow-rename", "off",
|
||||
";", "set-option", "-t", "hqt-main:=hqt-1", "automatic-rename", "off",
|
||||
)
|
||||
# respawn-pane runs the actual command
|
||||
calls = runner._exec.call_args_list
|
||||
respawn_args = calls[3].args
|
||||
assert "respawn-pane" in respawn_args
|
||||
assert "kiro-cli chat" in respawn_args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_window_targets_explicit_free_index(runner):
|
||||
"""Regression: new-window must target an explicit free index, not the bare
|
||||
session name.
|
||||
|
||||
With a session-only target (`-t hqt`), an *attached* tmux resolves the target
|
||||
to the session's current/active window and tries to create the new window at
|
||||
that window's index — which is always occupied — failing with
|
||||
'create window failed: index N in use'. That broke both spawning new sessions
|
||||
and respawning dead ones (so attach failed). Computing max(existing index)+1
|
||||
and targeting `session:idx` sidesteps the resolution entirely."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n1\n2\n", ""), # list-windows -F '#{window_index}' (existing: 0,1,2)
|
||||
(0, "@9\n", ""), # new-window -P -F -> window_id
|
||||
(0, "", ""), # set-option
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
window_id = await runner.new_window("hqt-9", "/tmp", "claude")
|
||||
assert window_id == "@9"
|
||||
|
||||
new_win_args = runner._exec.call_args_list[1].args
|
||||
assert "new-window" in new_win_args
|
||||
# Explicit free index = max(0,1,2)+1 = 3, targeted as "<session>:3".
|
||||
assert "hqt-main:3" in new_win_args, f"expected explicit free index target, got {new_win_args}"
|
||||
# Must NOT use the bare session name as the new-window target.
|
||||
ti = new_win_args.index("-t")
|
||||
assert new_win_args[ti + 1] == "hqt-main:3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_window_true(runner):
|
||||
runner._exec.return_value = (0, "hqt-1\nhqt-2\n", "")
|
||||
assert await runner.has_window("hqt-1") is True
|
||||
assert runner._exec.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_window_false(runner):
|
||||
runner._exec.return_value = (0, "hqt-2\n", "")
|
||||
assert await runner.has_window("hqt-1") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_pane_dead_true(runner):
|
||||
runner._exec.return_value = (0, "1\n", "")
|
||||
assert await runner.is_pane_dead("hqt-1") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_pane_dead_false(runner):
|
||||
runner._exec.return_value = (0, "0\n", "")
|
||||
assert await runner.is_pane_dead("hqt-1") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select_window(runner):
|
||||
await runner.select_window("hqt-1")
|
||||
runner._exec.assert_called_with("select-window", "-t", "hqt-main:=hqt-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_pane(runner):
|
||||
await runner.respawn_pane("hqt-1", "kiro-cli chat", "/tmp")
|
||||
runner._exec.assert_called_with(
|
||||
"respawn-pane", "-t", "hqt-main:=hqt-1", "-k", "-c", "/tmp", "kiro-cli chat",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_windows(runner):
|
||||
runner._exec.return_value = (0, "hqt\nhqt-1\nhqt-2\n", "")
|
||||
result = await runner.list_windows()
|
||||
assert result == ["hqt", "hqt-1", "hqt-2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_spawn(runner):
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@3\n", ""), # new-window -P -F
|
||||
(0, "", ""), # set-option
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||
mgr = TmuxManager(runner)
|
||||
req = SpawnRequest(window_name="hqt-1", command=["kiro-cli", "chat"], cwd="/home")
|
||||
result = await mgr.spawn(req)
|
||||
assert result.ok is True
|
||||
assert result.window_id == "@3"
|
||||
# new-window call must NOT have trailing command
|
||||
new_win_args = runner._exec.call_args_list[1].args
|
||||
assert "new-window" in new_win_args
|
||||
assert "kiro-cli chat" not in new_win_args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_attach_alive(runner):
|
||||
"""Attach to alive window just selects it."""
|
||||
mgr = TmuxManager(runner)
|
||||
runner._exec.reset_mock()
|
||||
runner._exec.return_value = (0, "hqt-1\n", "")
|
||||
result = await mgr.attach("hqt-1")
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_is_alive_true(runner):
|
||||
"""Window exists and pane running."""
|
||||
# has_window needs list-windows to return the name (one _exec call)
|
||||
# is_pane_dead needs list-panes to return "0"
|
||||
runner._exec.side_effect = [
|
||||
(0, "hqt-1\n", ""), # has_window
|
||||
(0, "0\n", ""), # is_pane_dead
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
assert await mgr.is_alive("hqt-1") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_is_alive_false_dead_pane(runner):
|
||||
"""Window exists but pane dead."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "hqt-1\n", ""), # has_window
|
||||
(0, "1\n", ""), # is_pane_dead
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
assert await mgr.is_alive("hqt-1") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_is_alive_false_no_window(runner):
|
||||
"""Window doesn't exist at all."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "hqt-2\n", ""), # has_window — hqt-1 not in output
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
assert await mgr.is_alive("hqt-1") is False
|
||||
|
||||
|
||||
def test_target_exact_match(runner):
|
||||
"""_target() must use =prefix to force exact-match-only resolution in tmux."""
|
||||
assert runner._target("hqt-1") == "hqt-main:=hqt-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_window_false_single_exec(runner):
|
||||
"""has_window returns False with a single exec when name is absent."""
|
||||
runner._exec.return_value = (0, "hqt-2\n", "")
|
||||
assert await runner.has_window("hqt-1") is False
|
||||
assert runner._exec.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 2: Safe spawn sequence — new_window race-free redesign
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_window_race_free_sequence(runner):
|
||||
"""new_window issues: new-window (no command), set-option, respawn-pane — in order."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@5\n", ""), # new-window -P -F -> window_id
|
||||
(0, "", ""), # set-option
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
window_id = await runner.new_window("hqt-1", "/tmp", "kiro-cli chat")
|
||||
assert window_id == "@5"
|
||||
|
||||
calls = runner._exec.call_args_list
|
||||
assert len(calls) == 4
|
||||
|
||||
# Step 1: new-window without a trailing command; must have -P and -F
|
||||
new_win_args = calls[1].args
|
||||
assert "new-window" in new_win_args
|
||||
assert "-P" in new_win_args
|
||||
assert "#{window_id}" in new_win_args
|
||||
# Must NOT have the command as the final positional arg (no trailing command)
|
||||
assert new_win_args[-1] != "kiro-cli chat"
|
||||
assert "kiro-cli chat" not in new_win_args
|
||||
|
||||
# Step 2: combined set-option call with ";" separators for all three options
|
||||
set_args = calls[2].args
|
||||
assert set_args == (
|
||||
"set-option", "-t", "hqt-main:=hqt-1", "remain-on-exit", "on",
|
||||
";", "set-option", "-t", "hqt-main:=hqt-1", "allow-rename", "off",
|
||||
";", "set-option", "-t", "hqt-main:=hqt-1", "automatic-rename", "off",
|
||||
)
|
||||
|
||||
# Step 3: respawn-pane runs the command
|
||||
resp_args = calls[3].args
|
||||
assert "respawn-pane" in resp_args
|
||||
assert "kiro-cli chat" in resp_args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_window_with_env(runner):
|
||||
"""new_window passes -e KEY=VALUE flags on respawn-pane (step 3), NOT on new-window (step 1)."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@7\n", ""),
|
||||
(0, "", ""),
|
||||
(0, "", ""),
|
||||
]
|
||||
window_id = await runner.new_window("hqt-2", "/tmp", "claude", env={"FOO": "bar", "X": "1"})
|
||||
assert window_id == "@7"
|
||||
|
||||
calls_list = runner._exec.call_args_list
|
||||
|
||||
# Step 1 (new-window) must NOT carry -e flags — they're useless there
|
||||
new_win_args = calls_list[1].args
|
||||
assert "-e" not in new_win_args
|
||||
assert "FOO=bar" not in new_win_args
|
||||
assert "X=1" not in new_win_args
|
||||
|
||||
# Step 3 (respawn-pane) must carry -e KEY=VALUE pairs
|
||||
respawn_args = calls_list[3].args
|
||||
assert "respawn-pane" in respawn_args
|
||||
assert "-e" in respawn_args
|
||||
flat = list(respawn_args)
|
||||
assert "FOO=bar" in flat
|
||||
assert "X=1" in flat
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_window_set_option_failure_aborts(runner):
|
||||
"""If set-option fails, new_window kills the orphaned window and returns None."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@3\n", ""), # new-window succeeds
|
||||
(1, "", "some tmux error"), # set-option fails
|
||||
(0, "", ""), # kill-window cleanup
|
||||
]
|
||||
result = await runner.new_window("hqt-3", "/tmp", "claude")
|
||||
assert result is None
|
||||
# kill-window must have been called to clean up the orphaned shell
|
||||
assert runner._exec.call_count == 4
|
||||
kill_args = runner._exec.call_args_list[3].args
|
||||
assert "kill-window" in kill_args
|
||||
assert "hqt-main:=hqt-3" in kill_args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_window_respawn_failure_kills_window(runner):
|
||||
"""If respawn-pane fails, new_window kills the orphaned window and returns None."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@4\n", ""), # new-window succeeds
|
||||
(0, "", ""), # set-option succeeds
|
||||
(1, "", "respawn error"), # respawn-pane fails
|
||||
(0, "", ""), # kill-window cleanup
|
||||
]
|
||||
result = await runner.new_window("hqt-4", "/tmp", "claude")
|
||||
assert result is None
|
||||
assert runner._exec.call_count == 5
|
||||
kill_args = runner._exec.call_args_list[4].args
|
||||
assert "kill-window" in kill_args
|
||||
assert "hqt-main:=hqt-4" in kill_args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_window_returns_window_id(runner):
|
||||
"""new_window returns the window_id string on full success."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@42\n", ""),
|
||||
(0, "", ""),
|
||||
(0, "", ""),
|
||||
]
|
||||
result = await runner.new_window("hqt-5", "/var", "sleep 30")
|
||||
assert result == "@42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_window_returns_none_on_new_window_failure(runner):
|
||||
"""new_window returns None when the initial new-window command fails."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(1, "", "session not found"), # new-window fails
|
||||
]
|
||||
result = await runner.new_window("hqt-x", "/tmp", "sleep 1")
|
||||
assert result is None
|
||||
assert runner._exec.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 2: verify_window_alive
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_window_alive_alive(runner):
|
||||
"""Pane stays alive through timeout → (True, '')."""
|
||||
# is_pane_dead returns False (pane alive) on every poll
|
||||
runner._exec.return_value = (0, "0\n", "")
|
||||
ok, text = await runner.verify_window_alive("hqt-1", timeout=0.1, interval=0.05)
|
||||
assert ok is True
|
||||
assert text == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_window_alive_dead_with_text(runner):
|
||||
"""Pane dies within timeout → (False, captured text)."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "1\n", ""), # is_pane_dead → dead
|
||||
(0, "command not found\n", ""), # capture_pane
|
||||
]
|
||||
ok, text = await runner.verify_window_alive("hqt-1", timeout=0.5, interval=0.05)
|
||||
assert ok is False
|
||||
assert "command not found" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_window_alive_dead_empty_text(runner):
|
||||
"""Pane dies with empty capture → (False, '') still False."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "1\n", ""), # is_pane_dead
|
||||
(0, "", ""), # capture_pane empty
|
||||
]
|
||||
ok, text = await runner.verify_window_alive("hqt-1", timeout=0.5, interval=0.05)
|
||||
assert ok is False
|
||||
assert text == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_window_alive_dies_during_last_interval(runner):
|
||||
"""Pane alive throughout polling loop but dies during the final sleep → (False, text)."""
|
||||
# All in-loop polls return alive (0), final post-loop check returns dead (1)
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # is_pane_dead (in-loop) → alive
|
||||
(0, "1\n", ""), # is_pane_dead (post-loop final check) → dead
|
||||
(0, "died at last\n", ""), # capture_pane
|
||||
]
|
||||
ok, text = await runner.verify_window_alive("hqt-1", timeout=0.05, interval=0.1)
|
||||
assert ok is False
|
||||
assert "died at last" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 2: SpawnResult + manager.spawn changes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_result_ok(runner):
|
||||
"""spawn returns SpawnResult(ok=True, window_id=...) on success."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@10\n", ""), # new-window
|
||||
(0, "", ""), # set-option
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||
mgr = TmuxManager(runner)
|
||||
req = SpawnRequest(window_name="hqt-1", command=["sleep", "30"], cwd="/tmp")
|
||||
result = await mgr.spawn(req)
|
||||
assert isinstance(result, SpawnResult)
|
||||
assert result.ok is True
|
||||
assert result.window_id == "@10"
|
||||
assert result.error == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_result_failure_verification(runner):
|
||||
"""spawn returns SpawnResult(ok=False, error=...) when pane dies immediately."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@11\n", ""), # new-window
|
||||
(0, "", ""), # set-option
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
runner.verify_window_alive = AsyncMock(return_value=(False, "no such binary\n"))
|
||||
mgr = TmuxManager(runner)
|
||||
req = SpawnRequest(window_name="hqt-1", command=["bad-cmd"], cwd="/tmp")
|
||||
result = await mgr.spawn(req)
|
||||
assert result.ok is False
|
||||
assert "no such binary" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_shlex_join(runner):
|
||||
"""spawn uses shlex.join so args with spaces are properly quoted."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@99\n", ""),
|
||||
(0, "", ""),
|
||||
(0, "", ""),
|
||||
]
|
||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||
mgr = TmuxManager(runner)
|
||||
req = SpawnRequest(window_name="hqt-1", command=["my cmd", "arg with space"], cwd="/tmp")
|
||||
await mgr.spawn(req)
|
||||
|
||||
# The respawn-pane call (now the 4th _exec call) should have the shlex-joined string
|
||||
respawn_call = runner._exec.call_args_list[3].args
|
||||
assert "respawn-pane" in respawn_call
|
||||
joined_cmd = respawn_call[-1]
|
||||
# shlex.join of ["my cmd", "arg with space"] → "'my cmd' 'arg with space'"
|
||||
import shlex
|
||||
expected = shlex.join(["my cmd", "arg with space"])
|
||||
assert joined_cmd == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_passes_env(runner):
|
||||
"""spawn passes env dict through to respawn-pane (step 3), NOT to new-window (step 1)."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@20\n", ""),
|
||||
(0, "", ""),
|
||||
(0, "", ""),
|
||||
]
|
||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||
mgr = TmuxManager(runner)
|
||||
req = SpawnRequest(window_name="hqt-env", command=["bash"], cwd="/tmp", env={"MY_VAR": "hello"})
|
||||
result = await mgr.spawn(req)
|
||||
assert result.ok is True
|
||||
|
||||
# new-window (step 1) must NOT carry -e
|
||||
new_win_args = runner._exec.call_args_list[1].args
|
||||
assert "-e" not in new_win_args
|
||||
assert "MY_VAR=hello" not in new_win_args
|
||||
|
||||
# respawn-pane (step 3) must carry -e MY_VAR=hello
|
||||
respawn_args = runner._exec.call_args_list[3].args
|
||||
assert "respawn-pane" in respawn_args
|
||||
assert "-e" in respawn_args
|
||||
assert "MY_VAR=hello" in respawn_args
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_pane_with_env(runner):
|
||||
"""respawn_pane passes -e KEY=VALUE flags when env is provided."""
|
||||
await runner.respawn_pane("hqt-1", "claude", "/tmp", env={"MYKEY": "myval", "A": "b"})
|
||||
call_args = runner._exec.call_args_list[0].args
|
||||
assert "respawn-pane" in call_args
|
||||
assert "-e" in call_args
|
||||
assert "MYKEY=myval" in call_args
|
||||
assert "A=b" in call_args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_pane_no_env(runner):
|
||||
"""respawn_pane without env emits no -e flags (backward compat)."""
|
||||
await runner.respawn_pane("hqt-1", "claude", "/tmp")
|
||||
call_args = runner._exec.call_args_list[0].args
|
||||
assert "respawn-pane" in call_args
|
||||
assert "-e" not in call_args
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawn_verify_dead_empty_text_returns_fallback_error(runner):
|
||||
"""When verify_window_alive returns (False, ''), spawn returns the fallback error message."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "0\n", ""), # list-windows (next-index query)
|
||||
(0, "@30\n", ""), # new-window
|
||||
(0, "", ""), # set-option
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
# Empty pane text — verify_window_alive signals immediate death with no output
|
||||
runner.verify_window_alive = AsyncMock(return_value=(False, ""))
|
||||
mgr = TmuxManager(runner)
|
||||
req = SpawnRequest(window_name="hqt-dead", command=["gone-cmd"], cwd="/tmp")
|
||||
result = await mgr.spawn(req)
|
||||
assert result.ok is False
|
||||
assert "pane died immediately" in result.error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 3: respawn_verified
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_verified_success_path(runner):
|
||||
"""respawn_verified: window exists, pane stays alive → SpawnResult(ok=True)."""
|
||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||
runner._exec.side_effect = [
|
||||
(0, "hqt-1\n", ""), # has_window → True
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
result = await mgr.respawn_verified("hqt-1", ["claude", "--resume", "abc"], "/tmp")
|
||||
assert result.ok is True
|
||||
assert result.error == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_verified_pane_dies_after_respawn(runner):
|
||||
"""respawn_verified: window exists, pane dies immediately → SpawnResult(ok=False, error=pane text)."""
|
||||
runner.verify_window_alive = AsyncMock(return_value=(False, "no such transcript\n"))
|
||||
runner._exec.side_effect = [
|
||||
(0, "hqt-1\n", ""), # has_window → True
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
result = await mgr.respawn_verified("hqt-1", ["claude", "--resume", "abc"], "/tmp")
|
||||
assert result.ok is False
|
||||
assert "no such transcript" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_verified_pane_dies_empty_text_fallback_message(runner):
|
||||
"""respawn_verified: pane dies with no output → fallback error message."""
|
||||
runner.verify_window_alive = AsyncMock(return_value=(False, ""))
|
||||
runner._exec.side_effect = [
|
||||
(0, "hqt-1\n", ""), # has_window → True
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
result = await mgr.respawn_verified("hqt-1", ["claude", "--resume", "abc"], "/tmp")
|
||||
assert result.ok is False
|
||||
assert "pane died immediately" in result.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_verified_window_gone_branch(runner):
|
||||
"""respawn_verified: window is gone → new_window + verify → SpawnResult(ok=True, window_id=...)."""
|
||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||
runner._exec.side_effect = [
|
||||
(0, "other\n", ""), # has_window → False
|
||||
(0, "0\n", ""), # new_window: list-windows (next-index query)
|
||||
(0, "@9\n", ""), # new-window step 1
|
||||
(0, "", ""), # set-option step 2
|
||||
(0, "", ""), # respawn-pane step 3
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
result = await mgr.respawn_verified("hqt-gone", ["claude", "--resume", "abc"], "/tmp")
|
||||
assert result.ok is True
|
||||
assert result.window_id == "@9"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_verified_window_gone_new_window_fails(runner):
|
||||
"""respawn_verified: window gone, new_window fails → SpawnResult(ok=False)."""
|
||||
runner._exec.side_effect = [
|
||||
(0, "other\n", ""), # has_window → False
|
||||
(0, "0\n", ""), # new_window: list-windows (next-index query)
|
||||
(1, "", "session not found"), # new-window fails
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
result = await mgr.respawn_verified("hqt-gone", ["claude", "--resume", "abc"], "/tmp")
|
||||
assert result.ok is False
|
||||
assert result.error != ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_verified_forwards_env(runner):
|
||||
"""respawn_verified passes env to respawn_pane when window exists."""
|
||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||
runner._exec.side_effect = [
|
||||
(0, "hqt-1\n", ""), # has_window → True
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
result = await mgr.respawn_verified(
|
||||
"hqt-1", ["claude"], "/tmp", env={"MY_VAR": "abc"}
|
||||
)
|
||||
assert result.ok is True
|
||||
resp_call = runner._exec.call_args_list[1].args
|
||||
assert "respawn-pane" in resp_call
|
||||
assert "-e" in resp_call
|
||||
assert "MY_VAR=abc" in resp_call
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_verified_uses_shlex_join(runner):
|
||||
"""respawn_verified uses shlex.join so args with spaces are properly quoted."""
|
||||
import shlex
|
||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||
runner._exec.side_effect = [
|
||||
(0, "hqt-1\n", ""), # has_window → True
|
||||
(0, "", ""), # respawn-pane
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
await mgr.respawn_verified("hqt-1", ["cmd with space", "arg"], "/tmp")
|
||||
|
||||
expected_cmd = shlex.join(["cmd with space", "arg"])
|
||||
resp_call = runner._exec.call_args_list[1].args
|
||||
assert "respawn-pane" in resp_call
|
||||
assert expected_cmd in resp_call
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respawn_verified_window_gone_forwards_env(runner):
|
||||
"""respawn_verified forwards env to new_window when window is gone."""
|
||||
runner.verify_window_alive = AsyncMock(return_value=(True, ""))
|
||||
runner._exec.side_effect = [
|
||||
(0, "other-window\n", ""), # has_window → False
|
||||
(0, "0\n", ""), # new_window: list-windows (next-index query)
|
||||
(0, "@5\n", ""), # new-window step 1
|
||||
(0, "", ""), # set-option step 2
|
||||
(0, "", ""), # respawn-pane step 3
|
||||
]
|
||||
mgr = TmuxManager(runner)
|
||||
result = await mgr.respawn_verified("hqt-gone", ["bash"], "/tmp", env={"GONE_VAR": "x"})
|
||||
assert result.ok is True
|
||||
|
||||
# The respawn-pane (step 3 of new_window) should carry the env
|
||||
resp_call = runner._exec.call_args_list[4].args
|
||||
assert "respawn-pane" in resp_call
|
||||
assert "-e" in resp_call
|
||||
assert "GONE_VAR=x" in resp_call
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 5: Literal send-keys (-l)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_text_uses_literal_flag(runner):
|
||||
"""send_text must pass -l and '--' before the text."""
|
||||
await runner.send_text("hqt-1", "Enter; echo hi C-c")
|
||||
runner._exec.assert_called_once_with(
|
||||
"send-keys", "-t", "hqt-main:=hqt-1", "-l", "--", "Enter; echo hi C-c",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_enter_no_literal_flag(runner):
|
||||
"""send_enter intentionally sends the Enter key name — must NOT have -l."""
|
||||
await runner.send_enter("hqt-1")
|
||||
runner._exec.assert_called_once_with(
|
||||
"send-keys", "-t", "hqt-main:=hqt-1", "Enter",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 7: list_windows_info + WindowInfo + capture_pane -J + send_text --
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_windows_info_alive_and_activity(runner):
|
||||
"""list_windows_info parses 3-column output; alive=True when pane_dead=0."""
|
||||
runner._exec.return_value = (0, "hqt-1\t0\t1000000\nhqt-2\t1\t999999\n", "")
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
result = await runner.list_windows_info()
|
||||
assert result["hqt-1"] == WindowInfo(alive=True, last_activity=1000000)
|
||||
assert result["hqt-2"] == WindowInfo(alive=False, last_activity=999999)
|
||||
runner._exec.assert_called_once_with(
|
||||
"list-panes", "-s", "-t", "hqt-main", "-F",
|
||||
"#{window_name}\t#{pane_dead}\t#{window_activity}",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_windows_info_multi_pane_alive_max_activity(runner):
|
||||
"""Multi-pane: alive if any pane alive; last_activity = max across panes."""
|
||||
runner._exec.return_value = (
|
||||
0,
|
||||
"hqt-1\t1\t1000\nhqt-1\t0\t2000\nhqt-2\t1\t500\nhqt-2\t1\t600\n",
|
||||
"",
|
||||
)
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
result = await runner.list_windows_info()
|
||||
assert result["hqt-1"] == WindowInfo(alive=True, last_activity=2000)
|
||||
assert result["hqt-2"] == WindowInfo(alive=False, last_activity=600)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_windows_info_malformed_line_skipped(runner):
|
||||
"""Lines that don't have 3 tab-separated columns are skipped silently."""
|
||||
runner._exec.return_value = (0, "hqt-1\t0\t1000\nbad_line\nhqt-2\t1\t500\n", "")
|
||||
result = await runner.list_windows_info()
|
||||
assert "hqt-1" in result
|
||||
assert "bad_line" not in result
|
||||
assert "hqt-2" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_windows_info_rc_nonzero_returns_empty(runner):
|
||||
"""rc != 0 → empty dict."""
|
||||
runner._exec.return_value = (1, "", "no server running")
|
||||
result = await runner.list_windows_info()
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_pane_includes_J_flag(runner):
|
||||
"""capture_pane must pass -J to join wrapped lines."""
|
||||
runner._exec.return_value = (0, "some output\n", "")
|
||||
await runner.capture_pane("hqt-1")
|
||||
args = runner._exec.call_args.args
|
||||
assert "-J" in args
|
||||
assert "capture-pane" in args
|
||||
assert "-p" in args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_text_includes_double_dash(runner):
|
||||
"""send_text must pass '--' before the text to prevent flag parsing."""
|
||||
await runner.send_text("hqt-1", "-flag-looking text")
|
||||
runner._exec.assert_called_once_with(
|
||||
"send-keys", "-t", "hqt-main:=hqt-1", "-l", "--", "-flag-looking text",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_text_double_dash_with_normal_text(runner):
|
||||
"""send_text includes '--' even for normal text (belt-and-suspenders)."""
|
||||
await runner.send_text("hqt-1", "hello world")
|
||||
runner._exec.assert_called_once_with(
|
||||
"send-keys", "-t", "hqt-main:=hqt-1", "-l", "--", "hello world",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_info_single_exec(runner):
|
||||
"""poll_info calls _exec exactly once and returns WindowInfo per name."""
|
||||
runner._exec.return_value = (0, "hqt-1\t0\t1234\nhqt-2\t1\t5678\n", "")
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
mgr = TmuxManager(runner)
|
||||
result = await mgr.poll_info(["hqt-1", "hqt-2", "hqt-missing"])
|
||||
assert runner._exec.call_count == 1
|
||||
assert result["hqt-1"] == WindowInfo(alive=True, last_activity=1234)
|
||||
assert result["hqt-2"] == WindowInfo(alive=False, last_activity=5678)
|
||||
# Missing window gets a dead placeholder
|
||||
assert result["hqt-missing"].alive is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_info_rc_nonzero_all_dead(runner):
|
||||
"""poll_info rc!=0 → all names map to dead WindowInfo."""
|
||||
runner._exec.return_value = (1, "", "error")
|
||||
mgr = TmuxManager(runner)
|
||||
result = await mgr.poll_info(["hqt-x"])
|
||||
assert result["hqt-x"].alive is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_window_label_sets_option_and_format(runner):
|
||||
"""set_window_label sets @hqt_label + a label-aware window-status-format,
|
||||
targeting the window by NAME (identity preserved — no rename)."""
|
||||
from hqt.tmux.runner import WINDOW_STATUS_FORMAT
|
||||
|
||||
await runner.set_window_label("hqt-1", "◐ myproj")
|
||||
args = runner._exec.call_args.args
|
||||
# Targets by name (exact-match prefix), never renames the window.
|
||||
assert "hqt-main:=hqt-1" in args
|
||||
assert "@hqt_label" in args
|
||||
assert "◐ myproj" in args
|
||||
# Both formats reference the label option so the bar shows it.
|
||||
assert args.count(WINDOW_STATUS_FORMAT) == 2
|
||||
assert "window-status-format" in args
|
||||
assert "window-status-current-format" in args
|
||||
# All in a single atomic tmux invocation.
|
||||
assert runner._exec.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_set_window_label_delegates(runner):
|
||||
"""TmuxManager.set_window_label forwards to the runner."""
|
||||
mgr = TmuxManager(runner)
|
||||
await mgr.set_window_label("hqt-2", "○ deadproj")
|
||||
args = runner._exec.call_args.args
|
||||
assert "hqt-main:=hqt-2" in args
|
||||
assert "○ deadproj" in args
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_info_empty_names(runner):
|
||||
"""poll_info with empty names list returns empty dict with exactly one exec."""
|
||||
runner._exec.return_value = (0, "", "")
|
||||
mgr = TmuxManager(runner)
|
||||
result = await mgr.poll_info([])
|
||||
assert result == {}
|
||||
assert runner._exec.call_count == 1
|
||||
+1059
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user