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:
2026-06-10 17:06:57 -04:00
commit 90944948f5
61 changed files with 8834 additions and 0 deletions
+841
View File
@@ -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