Files
hqt/tests/test_sessions.py
T
felixm 90944948f5 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>
2026-06-10 17:06:57 -04:00

842 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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