feat(dashboard): add click-outside dismissal for autocomplete dropdown
Closes bd-3ny. Added mousedown listener that dismisses the dropdown when clicking outside both the dropdown and textarea. Uses early return to avoid registering listeners when dropdown is already closed.
This commit is contained in:
File diff suppressed because one or more lines are too long
BIN
amc_server/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
amc_server/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/__pycache__/context.cpython-313.pyc
Normal file
BIN
amc_server/__pycache__/context.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/__pycache__/handler.cpython-313.pyc
Normal file
BIN
amc_server/__pycache__/handler.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/__pycache__/logging_utils.cpython-313.pyc
Normal file
BIN
amc_server/__pycache__/logging_utils.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/__pycache__/server.cpython-313.pyc
Normal file
BIN
amc_server/__pycache__/server.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/control.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/control.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/conversation.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/conversation.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/discovery.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/discovery.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/http.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/http.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/parsing.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/parsing.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/state.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/state.cpython-313.pyc
Normal file
Binary file not shown.
@@ -24,6 +24,39 @@ class SessionControlMixin:
|
|||||||
session_file.unlink(missing_ok=True)
|
session_file.unlink(missing_ok=True)
|
||||||
self._send_json(200, {"ok": True})
|
self._send_json(200, {"ok": True})
|
||||||
|
|
||||||
|
def _dismiss_dead_sessions(self):
|
||||||
|
"""Delete all dead session files (clear all from dashboard).
|
||||||
|
|
||||||
|
Note: is_dead is computed dynamically, not stored on disk, so we must
|
||||||
|
recompute it here using the same logic as _collect_sessions.
|
||||||
|
"""
|
||||||
|
# Get liveness data (same as _collect_sessions)
|
||||||
|
active_zellij_sessions = self._get_active_zellij_sessions()
|
||||||
|
active_transcript_files = self._get_active_transcript_files()
|
||||||
|
|
||||||
|
dismissed_count = 0
|
||||||
|
for f in SESSIONS_DIR.glob("*.json"):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
continue
|
||||||
|
# Recompute is_dead (it's not persisted to disk)
|
||||||
|
is_dead = self._is_session_dead(
|
||||||
|
data, active_zellij_sessions, active_transcript_files
|
||||||
|
)
|
||||||
|
if is_dead:
|
||||||
|
safe_id = f.stem
|
||||||
|
# Track dismissed Codex sessions
|
||||||
|
while len(_dismissed_codex_ids) >= _DISMISSED_MAX:
|
||||||
|
oldest_key = next(iter(_dismissed_codex_ids))
|
||||||
|
del _dismissed_codex_ids[oldest_key]
|
||||||
|
_dismissed_codex_ids[safe_id] = True
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
dismissed_count += 1
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
self._send_json(200, {"ok": True, "dismissed": dismissed_count})
|
||||||
|
|
||||||
def _respond_to_session(self, session_id):
|
def _respond_to_session(self, session_id):
|
||||||
"""Inject a response into the session's Zellij pane."""
|
"""Inject a response into the session's Zellij pane."""
|
||||||
safe_id = os.path.basename(session_id)
|
safe_id = os.path.basename(session_id)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
from amc_server.context import (
|
from amc_server.context import (
|
||||||
CLAUDE_PROJECTS_DIR,
|
CLAUDE_PROJECTS_DIR,
|
||||||
CODEX_SESSIONS_DIR,
|
CODEX_SESSIONS_DIR,
|
||||||
|
_CODEX_CACHE_MAX,
|
||||||
_CONTEXT_CACHE_MAX,
|
_CONTEXT_CACHE_MAX,
|
||||||
_codex_transcript_cache,
|
_codex_transcript_cache,
|
||||||
_context_usage_cache,
|
_context_usage_cache,
|
||||||
@@ -44,6 +45,11 @@ class SessionParsingMixin:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
for jsonl_file in CODEX_SESSIONS_DIR.rglob(f"*{session_id}*.jsonl"):
|
for jsonl_file in CODEX_SESSIONS_DIR.rglob(f"*{session_id}*.jsonl"):
|
||||||
|
# Evict old entries if cache is full (simple FIFO)
|
||||||
|
if len(_codex_transcript_cache) >= _CODEX_CACHE_MAX:
|
||||||
|
keys_to_remove = list(_codex_transcript_cache.keys())[: _CODEX_CACHE_MAX // 5]
|
||||||
|
for k in keys_to_remove:
|
||||||
|
_codex_transcript_cache.pop(k, None)
|
||||||
_codex_transcript_cache[session_id] = str(jsonl_file)
|
_codex_transcript_cache[session_id] = str(jsonl_file)
|
||||||
return jsonl_file
|
return jsonl_file
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ class StateMixin:
|
|||||||
# Get active Zellij sessions for liveness check
|
# Get active Zellij sessions for liveness check
|
||||||
active_zellij_sessions = self._get_active_zellij_sessions()
|
active_zellij_sessions = self._get_active_zellij_sessions()
|
||||||
|
|
||||||
|
# Get set of transcript files with active processes (for dead detection)
|
||||||
|
active_transcript_files = self._get_active_transcript_files()
|
||||||
|
|
||||||
for f in SESSIONS_DIR.glob("*.json"):
|
for f in SESSIONS_DIR.glob("*.json"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(f.read_text())
|
data = json.loads(f.read_text())
|
||||||
@@ -120,11 +123,31 @@ class StateMixin:
|
|||||||
if context_usage:
|
if context_usage:
|
||||||
data["context_usage"] = context_usage
|
data["context_usage"] = context_usage
|
||||||
|
|
||||||
|
# Capture turn token baseline on UserPromptSubmit (for per-turn token display)
|
||||||
|
# Only write once when the turn starts and we have token data
|
||||||
|
if (
|
||||||
|
data.get("last_event") == "UserPromptSubmit"
|
||||||
|
and "turn_start_tokens" not in data
|
||||||
|
and context_usage
|
||||||
|
and context_usage.get("current_tokens") is not None
|
||||||
|
):
|
||||||
|
data["turn_start_tokens"] = context_usage["current_tokens"]
|
||||||
|
# Persist to session file so it survives server restarts
|
||||||
|
try:
|
||||||
|
f.write_text(json.dumps(data, indent=2))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Track conversation file mtime for real-time update detection
|
# Track conversation file mtime for real-time update detection
|
||||||
conv_mtime = self._get_conversation_mtime(data)
|
conv_mtime = self._get_conversation_mtime(data)
|
||||||
if conv_mtime:
|
if conv_mtime:
|
||||||
data["conversation_mtime_ns"] = conv_mtime
|
data["conversation_mtime_ns"] = conv_mtime
|
||||||
|
|
||||||
|
# Determine if session is "dead" (no longer interactable)
|
||||||
|
data["is_dead"] = self._is_session_dead(
|
||||||
|
data, active_zellij_sessions, active_transcript_files
|
||||||
|
)
|
||||||
|
|
||||||
sessions.append(data)
|
sessions.append(data)
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
continue
|
continue
|
||||||
@@ -132,8 +155,8 @@ class StateMixin:
|
|||||||
LOGGER.exception("Failed processing session file %s", f)
|
LOGGER.exception("Failed processing session file %s", f)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Sort by last_event_at descending
|
# Sort by session_id for stable, deterministic ordering (no visual jumping)
|
||||||
sessions.sort(key=lambda s: s.get("last_event_at", ""), reverse=True)
|
sessions.sort(key=lambda s: s.get("session_id", ""))
|
||||||
|
|
||||||
# Clean orphan event logs (sessions persist until manually dismissed or SessionEnd)
|
# Clean orphan event logs (sessions persist until manually dismissed or SessionEnd)
|
||||||
self._cleanup_stale(sessions)
|
self._cleanup_stale(sessions)
|
||||||
@@ -204,6 +227,133 @@ class StateMixin:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _get_active_transcript_files(self):
|
||||||
|
"""Get set of transcript files that have active processes.
|
||||||
|
|
||||||
|
Uses a batched lsof call to efficiently check which Codex transcript
|
||||||
|
files are currently open by a process.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
set: Absolute paths of transcript files with active processes.
|
||||||
|
"""
|
||||||
|
from amc_server.context import CODEX_SESSIONS_DIR
|
||||||
|
|
||||||
|
if not CODEX_SESSIONS_DIR.exists():
|
||||||
|
return set()
|
||||||
|
|
||||||
|
# Find all recent transcript files
|
||||||
|
transcript_files = []
|
||||||
|
now = time.time()
|
||||||
|
cutoff = now - 3600 # Only check files modified in the last hour
|
||||||
|
|
||||||
|
for jsonl_file in CODEX_SESSIONS_DIR.rglob("*.jsonl"):
|
||||||
|
try:
|
||||||
|
if jsonl_file.stat().st_mtime > cutoff:
|
||||||
|
transcript_files.append(str(jsonl_file))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not transcript_files:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
# Batch lsof check for all transcript files
|
||||||
|
active_files = set()
|
||||||
|
try:
|
||||||
|
# lsof with multiple files: returns PIDs for any that are open
|
||||||
|
result = subprocess.run(
|
||||||
|
["lsof", "-t"] + transcript_files,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
# If any file is open, lsof returns 0
|
||||||
|
# We need to check which specific files are open
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
# At least one file is open - check each one
|
||||||
|
for tf in transcript_files:
|
||||||
|
try:
|
||||||
|
check = subprocess.run(
|
||||||
|
["lsof", "-t", tf],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
if check.returncode == 0 and check.stdout.strip():
|
||||||
|
active_files.add(tf)
|
||||||
|
except (subprocess.TimeoutExpired, Exception):
|
||||||
|
continue
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return active_files
|
||||||
|
|
||||||
|
def _is_session_dead(self, session_data, active_zellij_sessions, active_transcript_files):
|
||||||
|
"""Determine if a session is 'dead' (no longer interactable).
|
||||||
|
|
||||||
|
A dead session cannot receive input and won't produce more output.
|
||||||
|
These should be shown separately from active sessions in the UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_data: The session dict
|
||||||
|
active_zellij_sessions: Set of active zellij session names (or None)
|
||||||
|
active_transcript_files: Set of transcript file paths with active processes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the session is dead
|
||||||
|
"""
|
||||||
|
agent = session_data.get("agent")
|
||||||
|
zellij_session = session_data.get("zellij_session", "")
|
||||||
|
status = session_data.get("status", "")
|
||||||
|
|
||||||
|
# Sessions that are still starting are not dead (yet)
|
||||||
|
if status == "starting":
|
||||||
|
return False
|
||||||
|
|
||||||
|
if agent == "codex":
|
||||||
|
# Codex session is dead if no process has the transcript file open
|
||||||
|
transcript_path = session_data.get("transcript_path", "")
|
||||||
|
if not transcript_path:
|
||||||
|
return True # No transcript path = malformed, treat as dead
|
||||||
|
|
||||||
|
# Check cached set first (covers recently-modified files)
|
||||||
|
if transcript_path in active_transcript_files:
|
||||||
|
return False # Process is running
|
||||||
|
|
||||||
|
# For older files not in cached set, do explicit lsof check
|
||||||
|
# This handles long-idle but still-running processes
|
||||||
|
if self._is_file_open(transcript_path):
|
||||||
|
return False # Process is running
|
||||||
|
|
||||||
|
# No process running - it's dead
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif agent == "claude":
|
||||||
|
# Claude session is dead if:
|
||||||
|
# 1. No zellij session attached, OR
|
||||||
|
# 2. The zellij session no longer exists
|
||||||
|
if not zellij_session:
|
||||||
|
return True
|
||||||
|
if active_zellij_sessions is not None:
|
||||||
|
return zellij_session not in active_zellij_sessions
|
||||||
|
# If we couldn't query zellij, assume alive (don't false-positive)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Unknown agent type - assume alive
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_file_open(self, file_path):
|
||||||
|
"""Check if any process has a file open using lsof."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["lsof", "-t", file_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
return result.returncode == 0 and result.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
||||||
|
return False # Assume not open on error (conservative)
|
||||||
|
|
||||||
def _cleanup_stale(self, sessions):
|
def _cleanup_stale(self, sessions):
|
||||||
"""Remove orphan event logs >24h and stale 'starting' sessions >1h."""
|
"""Remove orphan event logs >24h and stale 'starting' sessions >1h."""
|
||||||
active_ids = {s.get("session_id") for s in sessions if s.get("session_id")}
|
active_ids = {s.get("session_id") for s in sessions if s.get("session_id")}
|
||||||
|
|||||||
26
bin/amc-hook
26
bin/amc-hook
@@ -170,6 +170,8 @@ def main():
|
|||||||
existing["last_event"] = f"PreToolUse({tool_name})"
|
existing["last_event"] = f"PreToolUse({tool_name})"
|
||||||
existing["last_event_at"] = now
|
existing["last_event_at"] = now
|
||||||
existing["pending_questions"] = _extract_questions(hook)
|
existing["pending_questions"] = _extract_questions(hook)
|
||||||
|
# Track when turn paused for duration calculation
|
||||||
|
existing["turn_paused_at"] = now
|
||||||
_atomic_write(session_file, existing)
|
_atomic_write(session_file, existing)
|
||||||
_append_event(session_id, {
|
_append_event(session_id, {
|
||||||
"event": f"PreToolUse({tool_name})",
|
"event": f"PreToolUse({tool_name})",
|
||||||
@@ -189,6 +191,16 @@ def main():
|
|||||||
existing["last_event"] = f"PostToolUse({tool_name})"
|
existing["last_event"] = f"PostToolUse({tool_name})"
|
||||||
existing["last_event_at"] = now
|
existing["last_event_at"] = now
|
||||||
existing.pop("pending_questions", None)
|
existing.pop("pending_questions", None)
|
||||||
|
# Accumulate paused time for turn duration calculation
|
||||||
|
paused_at = existing.pop("turn_paused_at", None)
|
||||||
|
if paused_at:
|
||||||
|
try:
|
||||||
|
paused_start = datetime.fromisoformat(paused_at.replace("Z", "+00:00"))
|
||||||
|
paused_end = datetime.fromisoformat(now.replace("Z", "+00:00"))
|
||||||
|
paused_ms = int((paused_end - paused_start).total_seconds() * 1000)
|
||||||
|
existing["turn_paused_ms"] = existing.get("turn_paused_ms", 0) + paused_ms
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
_atomic_write(session_file, existing)
|
_atomic_write(session_file, existing)
|
||||||
_append_event(session_id, {
|
_append_event(session_id, {
|
||||||
"event": f"PostToolUse({tool_name})",
|
"event": f"PostToolUse({tool_name})",
|
||||||
@@ -237,6 +249,20 @@ def main():
|
|||||||
"zellij_pane": os.environ.get("ZELLIJ_PANE_ID", ""),
|
"zellij_pane": os.environ.get("ZELLIJ_PANE_ID", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Turn timing: track working time from user prompt to completion
|
||||||
|
if event == "UserPromptSubmit":
|
||||||
|
# New turn starting - reset turn timing
|
||||||
|
state["turn_started_at"] = now
|
||||||
|
state["turn_paused_ms"] = 0
|
||||||
|
else:
|
||||||
|
# Preserve turn timing from existing state
|
||||||
|
if "turn_started_at" in existing:
|
||||||
|
state["turn_started_at"] = existing["turn_started_at"]
|
||||||
|
if "turn_paused_ms" in existing:
|
||||||
|
state["turn_paused_ms"] = existing["turn_paused_ms"]
|
||||||
|
if "turn_paused_at" in existing:
|
||||||
|
state["turn_paused_at"] = existing["turn_paused_at"]
|
||||||
|
|
||||||
# Store prose question if detected
|
# Store prose question if detected
|
||||||
if prose_question:
|
if prose_question:
|
||||||
state["pending_questions"] = [{
|
state["pending_questions"] = [{
|
||||||
|
|||||||
29
bin/amc-server-restart
Executable file
29
bin/amc-server-restart
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Restart the AMC server cleanly
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PORT=7400
|
||||||
|
|
||||||
|
# Find and kill existing server
|
||||||
|
PID=$(lsof -ti :$PORT 2>/dev/null || true)
|
||||||
|
if [[ -n "$PID" ]]; then
|
||||||
|
echo "Stopping AMC server (PID $PID)..."
|
||||||
|
kill "$PID" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start server in background
|
||||||
|
echo "Starting AMC server on port $PORT..."
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
nohup python3 -m amc_server.server > /tmp/amc-server.log 2>&1 &
|
||||||
|
|
||||||
|
# Wait for startup
|
||||||
|
sleep 1
|
||||||
|
NEW_PID=$(lsof -ti :$PORT 2>/dev/null || true)
|
||||||
|
if [[ -n "$NEW_PID" ]]; then
|
||||||
|
echo "AMC server running (PID $NEW_PID)"
|
||||||
|
else
|
||||||
|
echo "Failed to start server. Check /tmp/amc-server.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
115
dashboard/components/AgentActivityIndicator.js
Normal file
115
dashboard/components/AgentActivityIndicator.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { html, useState, useEffect, useRef } from '../lib/preact.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows live agent activity: elapsed time since user prompt, token usage.
|
||||||
|
* Visible when session is active/starting, pauses during needs_attention,
|
||||||
|
* shows final duration when done.
|
||||||
|
*
|
||||||
|
* @param {object} session - Session object with turn_started_at, turn_paused_at, turn_paused_ms, status
|
||||||
|
*/
|
||||||
|
export function AgentActivityIndicator({ session }) {
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const intervalRef = useRef(null);
|
||||||
|
|
||||||
|
// Safely extract session fields (handles null/undefined session)
|
||||||
|
const status = session?.status;
|
||||||
|
const turn_started_at = session?.turn_started_at;
|
||||||
|
const turn_paused_at = session?.turn_paused_at;
|
||||||
|
const turn_paused_ms = session?.turn_paused_ms ?? 0;
|
||||||
|
const last_event_at = session?.last_event_at;
|
||||||
|
const context_usage = session?.context_usage;
|
||||||
|
const turn_start_tokens = session?.turn_start_tokens;
|
||||||
|
|
||||||
|
// Only show for sessions with turn timing
|
||||||
|
const hasTurnTiming = !!turn_started_at;
|
||||||
|
const isActive = status === 'active' || status === 'starting';
|
||||||
|
const isPaused = status === 'needs_attention';
|
||||||
|
const isDone = status === 'done';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasTurnTiming) return;
|
||||||
|
|
||||||
|
const calculate = () => {
|
||||||
|
const startMs = new Date(turn_started_at).getTime();
|
||||||
|
const pausedMs = turn_paused_ms || 0;
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
// Running: current time - start - paused
|
||||||
|
return Date.now() - startMs - pausedMs;
|
||||||
|
} else if (isPaused && turn_paused_at) {
|
||||||
|
// Paused: frozen at pause time
|
||||||
|
const pausedAtMs = new Date(turn_paused_at).getTime();
|
||||||
|
return pausedAtMs - startMs - pausedMs;
|
||||||
|
} else if (isDone && last_event_at) {
|
||||||
|
// Done: final duration
|
||||||
|
const endMs = new Date(last_event_at).getTime();
|
||||||
|
return endMs - startMs - pausedMs;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
setElapsed(calculate());
|
||||||
|
|
||||||
|
// Only tick while active
|
||||||
|
if (isActive) {
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setElapsed(calculate());
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [hasTurnTiming, isActive, isPaused, isDone, turn_started_at, turn_paused_at, turn_paused_ms, last_event_at]);
|
||||||
|
|
||||||
|
// Don't render if no turn timing or session is done with no activity
|
||||||
|
if (!hasTurnTiming) return null;
|
||||||
|
|
||||||
|
// Format elapsed time (clamp to 0 for safety)
|
||||||
|
const formatElapsed = (ms) => {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
if (totalSec < 60) return `${totalSec}s`;
|
||||||
|
const min = Math.floor(totalSec / 60);
|
||||||
|
const sec = totalSec % 60;
|
||||||
|
return `${min}m ${sec}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format token count
|
||||||
|
const formatTokens = (count) => {
|
||||||
|
if (count == null) return null;
|
||||||
|
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
||||||
|
return String(count);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate turn tokens (current - baseline from turn start)
|
||||||
|
const currentTokens = context_usage?.current_tokens;
|
||||||
|
const turnTokens = (currentTokens != null && turn_start_tokens != null)
|
||||||
|
? Math.max(0, currentTokens - turn_start_tokens)
|
||||||
|
: null;
|
||||||
|
const tokenDisplay = formatTokens(turnTokens);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="flex items-center gap-2 px-3 py-2 border-b border-selection/50 bg-bg/60 font-mono text-label">
|
||||||
|
${isActive && html`
|
||||||
|
<span class="activity-spinner"></span>
|
||||||
|
`}
|
||||||
|
${isPaused && html`
|
||||||
|
<span class="h-2 w-2 rounded-full bg-attention"></span>
|
||||||
|
`}
|
||||||
|
${isDone && html`
|
||||||
|
<span class="h-2 w-2 rounded-full bg-done"></span>
|
||||||
|
`}
|
||||||
|
<span class="text-dim">
|
||||||
|
${isActive ? 'Working' : isPaused ? 'Paused' : 'Completed'}
|
||||||
|
</span>
|
||||||
|
<span class="text-bright tabular-nums">${formatElapsed(elapsed)}</span>
|
||||||
|
${tokenDisplay && html`
|
||||||
|
<span class="text-dim/70">·</span>
|
||||||
|
<span class="text-dim/90">${tokenDisplay} tokens</span>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { html, useState, useEffect, useCallback, useMemo, useRef } from '../lib/preact.js';
|
import { html, useState, useEffect, useCallback, useMemo, useRef } from '../lib/preact.js';
|
||||||
import { API_STATE, API_STREAM, API_DISMISS, API_RESPOND, API_CONVERSATION, POLL_MS, fetchWithTimeout } from '../utils/api.js';
|
import { API_STATE, API_STREAM, API_DISMISS, API_DISMISS_DEAD, API_RESPOND, API_CONVERSATION, POLL_MS, fetchWithTimeout } from '../utils/api.js';
|
||||||
import { groupSessionsByProject } from '../utils/status.js';
|
import { groupSessionsByProject } from '../utils/status.js';
|
||||||
import { Sidebar } from './Sidebar.js';
|
import { Sidebar } from './Sidebar.js';
|
||||||
import { SessionCard } from './SessionCard.js';
|
import { SessionCard } from './SessionCard.js';
|
||||||
@@ -17,6 +17,7 @@ export function App() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [selectedProject, setSelectedProject] = useState(null);
|
const [selectedProject, setSelectedProject] = useState(null);
|
||||||
const [sseConnected, setSseConnected] = useState(false);
|
const [sseConnected, setSseConnected] = useState(false);
|
||||||
|
const [deadSessionsCollapsed, setDeadSessionsCollapsed] = useState(true);
|
||||||
|
|
||||||
// Background conversation refresh with error tracking
|
// Background conversation refresh with error tracking
|
||||||
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
|
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
|
||||||
@@ -209,6 +210,21 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [fetchState]);
|
}, [fetchState]);
|
||||||
|
|
||||||
|
// Dismiss all dead sessions
|
||||||
|
const dismissDeadSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_DISMISS_DEAD, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
fetchState();
|
||||||
|
} else {
|
||||||
|
trackError('dismiss-dead', `Failed to clear completed sessions: ${data.error || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
trackError('dismiss-dead', `Error clearing completed sessions: ${err.message}`);
|
||||||
|
}
|
||||||
|
}, [fetchState]);
|
||||||
|
|
||||||
// Subscribe to live state updates via SSE
|
// Subscribe to live state updates via SSE
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
@@ -296,6 +312,22 @@ export function App() {
|
|||||||
return projectGroups.filter(g => g.projectDir === selectedProject);
|
return projectGroups.filter(g => g.projectDir === selectedProject);
|
||||||
}, [projectGroups, selectedProject]);
|
}, [projectGroups, selectedProject]);
|
||||||
|
|
||||||
|
// Split sessions into active and dead
|
||||||
|
const { activeSessions, deadSessions } = useMemo(() => {
|
||||||
|
const active = [];
|
||||||
|
const dead = [];
|
||||||
|
for (const group of filteredGroups) {
|
||||||
|
for (const session of group.sessions) {
|
||||||
|
if (session.is_dead) {
|
||||||
|
dead.push(session);
|
||||||
|
} else {
|
||||||
|
active.push(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { activeSessions: active, deadSessions: dead };
|
||||||
|
}, [filteredGroups]);
|
||||||
|
|
||||||
// Handle card click - open modal and fetch conversation if not cached
|
// Handle card click - open modal and fetch conversation if not cached
|
||||||
const handleCardClick = useCallback(async (session) => {
|
const handleCardClick = useCallback(async (session) => {
|
||||||
modalSessionRef.current = session.session_id;
|
modalSessionRef.current = session.session_id;
|
||||||
@@ -394,10 +426,10 @@ export function App() {
|
|||||||
` : filteredGroups.length === 0 ? html`
|
` : filteredGroups.length === 0 ? html`
|
||||||
<${EmptyState} />
|
<${EmptyState} />
|
||||||
` : html`
|
` : html`
|
||||||
<!-- Sessions Grid (no project grouping header since sidebar shows selection) -->
|
<!-- Active Sessions Grid -->
|
||||||
|
${activeSessions.length > 0 ? html`
|
||||||
<div class="flex flex-wrap gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
${filteredGroups.flatMap(group =>
|
${activeSessions.map(session => html`
|
||||||
group.sessions.map(session => html`
|
|
||||||
<${SessionCard}
|
<${SessionCard}
|
||||||
key=${session.session_id}
|
key=${session.session_id}
|
||||||
session=${session}
|
session=${session}
|
||||||
@@ -407,9 +439,64 @@ export function App() {
|
|||||||
onRespond=${respondToSession}
|
onRespond=${respondToSession}
|
||||||
onDismiss=${dismissSession}
|
onDismiss=${dismissSession}
|
||||||
/>
|
/>
|
||||||
`)
|
`)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
` : deadSessions.length > 0 ? html`
|
||||||
|
<div class="glass-panel flex items-center justify-center rounded-xl py-12 mb-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="font-display text-lg text-dim">No active sessions</p>
|
||||||
|
<p class="mt-1 font-mono text-micro text-dim/70">All sessions have completed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Completed Sessions (Dead) - Collapsible -->
|
||||||
|
${deadSessions.length > 0 && html`
|
||||||
|
<div class="mt-8">
|
||||||
|
<button
|
||||||
|
onClick=${() => setDeadSessionsCollapsed(!deadSessionsCollapsed)}
|
||||||
|
class="group flex w-full items-center gap-3 rounded-lg border border-selection/50 bg-surface/50 px-4 py-3 text-left transition-colors hover:border-selection hover:bg-surface/80"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 text-dim transition-transform ${deadSessionsCollapsed ? '' : 'rotate-90'}"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-display text-sm font-medium text-dim">
|
||||||
|
Completed Sessions
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full bg-done/15 px-2 py-0.5 font-mono text-micro tabular-nums text-done/70">
|
||||||
|
${deadSessions.length}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
onClick=${(e) => { e.stopPropagation(); dismissDeadSessions(); }}
|
||||||
|
class="rounded-lg border border-selection/80 bg-bg/40 px-3 py-1.5 font-mono text-micro text-dim transition-colors hover:border-done/40 hover:bg-done/10 hover:text-bright"
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
${!deadSessionsCollapsed && html`
|
||||||
|
<div class="mt-4 flex flex-wrap gap-4">
|
||||||
|
${deadSessions.map(session => html`
|
||||||
|
<${SessionCard}
|
||||||
|
key=${session.session_id}
|
||||||
|
session=${session}
|
||||||
|
onClick=${handleCardClick}
|
||||||
|
conversation=${conversations[session.session_id]}
|
||||||
|
onFetchConversation=${fetchConversation}
|
||||||
|
onRespond=${respondToSession}
|
||||||
|
onDismiss=${dismissSession}
|
||||||
|
/>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
`}
|
`}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,6 +74,21 @@ export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig =
|
|||||||
}
|
}
|
||||||
}, [filteredSkills.length, selectedIndex]);
|
}, [filteredSkills.length, selectedIndex]);
|
||||||
|
|
||||||
|
// Click outside dismisses dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAutocomplete) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (autocompleteRef.current && !autocompleteRef.current.contains(e.target) &&
|
||||||
|
textareaRef.current && !textareaRef.current.contains(e.target)) {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showAutocomplete]);
|
||||||
|
|
||||||
// Insert a selected skill into the text
|
// Insert a selected skill into the text
|
||||||
const insertSkill = useCallback((skill) => {
|
const insertSkill = useCallback((skill) => {
|
||||||
if (!triggerInfo || !autocompleteConfig) return;
|
if (!triggerInfo || !autocompleteConfig) return;
|
||||||
|
|||||||
@@ -88,6 +88,25 @@ body {
|
|||||||
animation: spin-ring 0.9s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
|
animation: spin-ring 0.9s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent activity spinner */
|
||||||
|
.activity-spinner {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #5fd0a4;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-spinner::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid transparent;
|
||||||
|
border-top-color: #5fd0a4;
|
||||||
|
animation: spin-ring 0.9s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Working indicator at bottom of chat */
|
/* Working indicator at bottom of chat */
|
||||||
@keyframes bounce-dot {
|
@keyframes bounce-dot {
|
||||||
0%, 80%, 100% { transform: translateY(0); }
|
0%, 80%, 100% { transform: translateY(0); }
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashb
|
|||||||
|
|
||||||
- **AC-13:** The spawned pane's cwd is set to the project directory
|
- **AC-13:** The spawned pane's cwd is set to the project directory
|
||||||
- **AC-14:** Spawned panes are named `{agent_type}-{project}` (e.g., "claude-amc")
|
- **AC-14:** Spawned panes are named `{agent_type}-{project}` (e.g., "claude-amc")
|
||||||
- **AC-15:** The spawned agent appears in the dashboard within 5 seconds of spawn
|
- **AC-15:** The spawned agent appears in the dashboard within 10 seconds of spawn
|
||||||
|
|
||||||
### Session Discovery
|
### Session Discovery
|
||||||
|
|
||||||
@@ -95,6 +95,9 @@ Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashb
|
|||||||
- **AC-22:** Server validates project path is within `~/projects/` (resolves symlinks)
|
- **AC-22:** Server validates project path is within `~/projects/` (resolves symlinks)
|
||||||
- **AC-23:** Server rejects path traversal attempts in project parameter
|
- **AC-23:** Server rejects path traversal attempts in project parameter
|
||||||
- **AC-24:** Server binds to localhost only (127.0.0.1), not exposed to network
|
- **AC-24:** Server binds to localhost only (127.0.0.1), not exposed to network
|
||||||
|
- **AC-37:** Server generates a one-time auth token on startup and injects it into dashboard HTML
|
||||||
|
- **AC-38:** `/api/spawn` requires valid auth token in `Authorization` header
|
||||||
|
- **AC-39:** CORS headers are consistent across all endpoints (`Access-Control-Allow-Origin: *`); localhost-only binding (AC-24) is the security boundary
|
||||||
|
|
||||||
### Spawn Request Lifecycle
|
### Spawn Request Lifecycle
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashb
|
|||||||
- **AC-26:** If the target Zellij session does not exist, spawn fails with error "Zellij session 'infra' not found"
|
- **AC-26:** If the target Zellij session does not exist, spawn fails with error "Zellij session 'infra' not found"
|
||||||
- **AC-27:** Server generates a unique `spawn_id` and passes it to the agent via `AMC_SPAWN_ID` env var
|
- **AC-27:** Server generates a unique `spawn_id` and passes it to the agent via `AMC_SPAWN_ID` env var
|
||||||
- **AC-28:** `amc-hook` writes `spawn_id` to session file when present in environment
|
- **AC-28:** `amc-hook` writes `spawn_id` to session file when present in environment
|
||||||
- **AC-29:** Spawn request polls for session file containing the specific `spawn_id` (max 5 second wait)
|
- **AC-29:** Spawn request polls for session file containing the specific `spawn_id` (max 10 second wait)
|
||||||
- **AC-30:** Concurrent spawn requests are serialized via a lock to prevent Zellij race conditions
|
- **AC-30:** Concurrent spawn requests are serialized via a lock to prevent Zellij race conditions
|
||||||
|
|
||||||
### Modal Behavior
|
### Modal Behavior
|
||||||
@@ -114,6 +117,17 @@ Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashb
|
|||||||
|
|
||||||
- **AC-33:** Projects list is loaded on server start and cached in memory
|
- **AC-33:** Projects list is loaded on server start and cached in memory
|
||||||
- **AC-34:** Projects list can be refreshed via `POST /api/projects/refresh`
|
- **AC-34:** Projects list can be refreshed via `POST /api/projects/refresh`
|
||||||
|
- **AC-40:** Projects list auto-refreshes every 5 minutes in background thread
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
- **AC-35:** Spawn requests for the same project are throttled to 1 per 10 seconds
|
||||||
|
- **AC-36:** Rate limit errors return `RATE_LIMITED` code with retry-after hint
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
- **AC-41:** `GET /api/health` returns server status including Zellij session availability
|
||||||
|
- **AC-42:** Dashboard shows warning banner when Zellij session is unavailable
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,7 +197,7 @@ Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashb
|
|||||||
7. **Server → Zellij:** `new-pane --cwd <path> -- <agent command>` with `AMC_SPAWN_ID` env var
|
7. **Server → Zellij:** `new-pane --cwd <path> -- <agent command>` with `AMC_SPAWN_ID` env var
|
||||||
8. **Zellij:** Pane created, agent process starts
|
8. **Zellij:** Pane created, agent process starts
|
||||||
9. **Agent → Hook:** `amc-hook` fires on `SessionStart`, writes session JSON including `spawn_id` from env
|
9. **Agent → Hook:** `amc-hook` fires on `SessionStart`, writes session JSON including `spawn_id` from env
|
||||||
10. **Server:** Poll for session file containing matching `spawn_id` (up to 5 seconds)
|
10. **Server:** Poll for session file containing matching `spawn_id` (up to 10 seconds)
|
||||||
11. **Server → Dashboard:** Return success only after session file with `spawn_id` detected
|
11. **Server → Dashboard:** Return success only after session file with `spawn_id` detected
|
||||||
12. **Server:** Release spawn lock
|
12. **Server:** Release spawn lock
|
||||||
|
|
||||||
@@ -217,6 +231,16 @@ Response (error):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Response (rate limited - AC-35, AC-36):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": "Rate limited. Try again in 8 seconds.",
|
||||||
|
"code": "RATE_LIMITED",
|
||||||
|
"retry_after": 8
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**GET /api/projects**
|
**GET /api/projects**
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
@@ -254,11 +278,11 @@ Response:
|
|||||||
- Contains: session_id, project, status, zellij_session, zellij_pane, etc.
|
- Contains: session_id, project, status, zellij_session, zellij_pane, etc.
|
||||||
- **Spawn correlation:** If `AMC_SPAWN_ID` env var is set, hook includes it in session JSON
|
- **Spawn correlation:** If `AMC_SPAWN_ID` env var is set, hook includes it in session JSON
|
||||||
|
|
||||||
**Codex agents** are discovered dynamically by `SessionDiscoveryMixin`:
|
**Codex agents** are discovered via the same hook mechanism as Claude:
|
||||||
- Scans `~/.codex/sessions/` for recently-modified `.jsonl` files
|
- When spawned with `AMC_SPAWN_ID` env var, the hook writes spawn_id to session JSON
|
||||||
- Extracts Zellij pane info via process inspection (`pgrep`, `lsof`)
|
- Existing `SessionDiscoveryMixin` also scans `~/.codex/sessions/` as fallback
|
||||||
- Creates/updates session JSON in `~/.local/share/amc/sessions/`
|
- **Spawn correlation:** Hook has direct access to env var and writes spawn_id
|
||||||
- **Spawn correlation:** Codex discovery checks for `AMC_SPAWN_ID` in process environment
|
- Note: Process inspection (`pgrep`, `lsof`) is used for non-spawned agents only
|
||||||
|
|
||||||
**Prerequisite:** The `amc-hook` must be installed in Claude Code's hooks configuration. See `~/.claude/hooks/` or Claude Code settings.
|
**Prerequisite:** The `amc-hook` must be installed in Claude Code's hooks configuration. See `~/.claude/hooks/` or Claude Code settings.
|
||||||
|
|
||||||
@@ -293,26 +317,89 @@ ZELLIJ_SESSION = "infra"
|
|||||||
|
|
||||||
# Lock for serializing spawn operations (prevents Zellij race conditions)
|
# Lock for serializing spawn operations (prevents Zellij race conditions)
|
||||||
_spawn_lock = threading.Lock()
|
_spawn_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Rate limiting: track last spawn time per project (prevents spam)
|
||||||
|
_spawn_timestamps: dict[str, float] = {}
|
||||||
|
SPAWN_COOLDOWN_SEC = 10.0
|
||||||
|
|
||||||
|
# Auth token for spawn endpoint (AC-37, AC-38)
|
||||||
|
# Generated on server start, injected into dashboard HTML
|
||||||
|
_auth_token: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_auth_token():
|
||||||
|
"""Generate a one-time auth token for this server instance."""
|
||||||
|
global _auth_token
|
||||||
|
import secrets
|
||||||
|
_auth_token = secrets.token_urlsafe(32)
|
||||||
|
return _auth_token
|
||||||
|
|
||||||
|
|
||||||
|
def validate_auth_token(request_token: str) -> bool:
|
||||||
|
"""Validate the Authorization header token."""
|
||||||
|
return request_token == f"Bearer {_auth_token}"
|
||||||
|
|
||||||
|
|
||||||
|
def start_projects_watcher():
|
||||||
|
"""Start background thread to refresh projects cache every 5 minutes (AC-40)."""
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from amc_server.mixins.spawn import load_projects_cache
|
||||||
|
|
||||||
|
def _watch_loop():
|
||||||
|
import time
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
time.sleep(300) # 5 minutes
|
||||||
|
load_projects_cache()
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Projects cache refresh failed")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_watch_loop, daemon=True)
|
||||||
|
thread.start()
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### IMP-1: SpawnMixin for Server (fulfills AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-25, AC-26, AC-29, AC-30)
|
### IMP-0b: Auth Token Verification in SpawnMixin (fulfills AC-38)
|
||||||
|
|
||||||
|
Add to the beginning of `_handle_spawn()`:
|
||||||
|
```python
|
||||||
|
# Verify auth token (AC-38)
|
||||||
|
auth_header = self.headers.get("Authorization", "")
|
||||||
|
if not validate_auth_token(auth_header):
|
||||||
|
self._send_json(401, {"ok": False, "error": "Unauthorized", "code": "UNAUTHORIZED"})
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### IMP-1: SpawnMixin for Server (fulfills AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-24, AC-26-AC-30, AC-33-AC-36)
|
||||||
|
|
||||||
**File:** `amc_server/mixins/spawn.py`
|
**File:** `amc_server/mixins/spawn.py`
|
||||||
|
|
||||||
**Integration notes:**
|
**Integration notes:**
|
||||||
- Uses `_send_json()` from HttpMixin (not a new `_json_response`)
|
- Uses `_send_json()` from HttpMixin (not a new `_json_response`)
|
||||||
- Uses inline JSON body parsing (same pattern as `control.py:33-47`)
|
- Uses inline JSON body parsing (same pattern as `control.py:33-47`)
|
||||||
- PROJECTS_DIR and ZELLIJ_SESSION come from context.py (centralized constants)
|
- PROJECTS_DIR, ZELLIJ_SESSION, `_spawn_lock`, `_spawn_timestamps`, `SPAWN_COOLDOWN_SEC` come from context.py
|
||||||
- Session file polling watches SESSIONS_DIR for any new .json by mtime
|
- **Deterministic correlation:** Generates `spawn_id`, passes via env var, polls for matching session file
|
||||||
|
- **Concurrency safety:** Acquires `_spawn_lock` around Zellij operations to prevent race conditions
|
||||||
|
- **Rate limiting:** Per-project cooldown prevents spawn spam (AC-35, AC-36)
|
||||||
|
- **Symlink safety:** Resolves project path and verifies it's still under PROJECTS_DIR
|
||||||
|
- **TOCTOU mitigation:** Validation returns resolved path; caller uses it directly (no re-resolution)
|
||||||
|
- **Env var propagation:** Uses shell wrapper to guarantee `AMC_SPAWN_ID` reaches agent process
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
from amc_server.context import PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION
|
from amc_server.context import (
|
||||||
|
PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION,
|
||||||
|
_spawn_lock, _spawn_timestamps, SPAWN_COOLDOWN_SEC,
|
||||||
|
)
|
||||||
|
|
||||||
# Agent commands (AC-8, AC-9: full autonomous permissions)
|
# Agent commands (AC-8, AC-9: full autonomous permissions)
|
||||||
AGENT_COMMANDS = {
|
AGENT_COMMANDS = {
|
||||||
@@ -320,7 +407,7 @@ AGENT_COMMANDS = {
|
|||||||
"codex": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
|
"codex": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Module-level cache for projects list (AC-29)
|
# Module-level cache for projects list (AC-33)
|
||||||
_projects_cache: list[str] = []
|
_projects_cache: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -355,44 +442,104 @@ class SpawnMixin:
|
|||||||
project = body.get("project", "").strip()
|
project = body.get("project", "").strip()
|
||||||
agent_type = body.get("agent_type", "claude").strip()
|
agent_type = body.get("agent_type", "claude").strip()
|
||||||
|
|
||||||
# Validation
|
# Validation returns resolved path to avoid TOCTOU
|
||||||
error = self._validate_spawn_params(project, agent_type)
|
validation = self._validate_spawn_params(project, agent_type)
|
||||||
if error:
|
if "error" in validation:
|
||||||
self._send_json(400, {"ok": False, "error": error["message"], "code": error["code"]})
|
self._send_json(400, {"ok": False, "error": validation["error"], "code": validation["code"]})
|
||||||
return
|
return
|
||||||
|
|
||||||
project_path = PROJECTS_DIR / project
|
resolved_path = validation["resolved_path"]
|
||||||
|
|
||||||
# Ensure tab exists, then spawn pane, then wait for session file
|
# Generate spawn_id for deterministic correlation (AC-27)
|
||||||
result = self._spawn_agent_in_project_tab(project, project_path, agent_type)
|
spawn_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Acquire lock to serialize Zellij operations (AC-30)
|
||||||
|
# NOTE: Rate limiting check is INSIDE lock to prevent race condition where
|
||||||
|
# two concurrent requests both pass the cooldown check before either updates timestamp
|
||||||
|
# Use timeout to prevent indefinite blocking if lock is held by hung thread
|
||||||
|
if not _spawn_lock.acquire(timeout=15.0):
|
||||||
|
self._send_json(503, {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Server busy, try again shortly",
|
||||||
|
"code": "SERVER_BUSY"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
acquire_start = time.time()
|
||||||
|
try:
|
||||||
|
# Log lock contention for debugging
|
||||||
|
acquire_time = time.time() - acquire_start
|
||||||
|
if acquire_time > 1.0:
|
||||||
|
import logging
|
||||||
|
logging.warning(f"Spawn lock contention: waited {acquire_time:.1f}s for {project}")
|
||||||
|
# Rate limiting per project (AC-35, AC-36) - must be inside lock
|
||||||
|
now = time.time()
|
||||||
|
last_spawn = _spawn_timestamps.get(project, 0)
|
||||||
|
if now - last_spawn < SPAWN_COOLDOWN_SEC:
|
||||||
|
retry_after = int(SPAWN_COOLDOWN_SEC - (now - last_spawn)) + 1
|
||||||
|
self._send_json(429, {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"Rate limited. Try again in {retry_after} seconds.",
|
||||||
|
"code": "RATE_LIMITED",
|
||||||
|
"retry_after": retry_after,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
result = self._spawn_agent_in_project_tab(project, resolved_path, agent_type, spawn_id)
|
||||||
|
|
||||||
|
# Update timestamp only on successful spawn (don't waste cooldown on failures)
|
||||||
|
if result["ok"]:
|
||||||
|
_spawn_timestamps[project] = time.time()
|
||||||
|
finally:
|
||||||
|
_spawn_lock.release()
|
||||||
|
|
||||||
if result["ok"]:
|
if result["ok"]:
|
||||||
self._send_json(200, {"ok": True, "project": project, "agent_type": agent_type})
|
self._send_json(200, {"ok": True, "project": project, "agent_type": agent_type, "spawn_id": spawn_id})
|
||||||
else:
|
else:
|
||||||
self._send_json(500, {"ok": False, "error": result["error"], "code": result.get("code", "SPAWN_FAILED")})
|
self._send_json(500, {"ok": False, "error": result["error"], "code": result.get("code", "SPAWN_FAILED")})
|
||||||
|
|
||||||
def _validate_spawn_params(self, project, agent_type):
|
def _validate_spawn_params(self, project, agent_type):
|
||||||
"""Validate spawn parameters. Returns error dict or None."""
|
"""Validate spawn parameters. Returns resolved_path on success, error dict on failure.
|
||||||
|
|
||||||
|
Returns resolved path to avoid TOCTOU: caller uses this path directly
|
||||||
|
instead of re-resolving after validation.
|
||||||
|
"""
|
||||||
if not project:
|
if not project:
|
||||||
return {"message": "project is required", "code": "MISSING_PROJECT"}
|
return {"error": "project is required", "code": "MISSING_PROJECT"}
|
||||||
|
|
||||||
# Security: no path traversal
|
# Security: no path traversal in project name
|
||||||
if "/" in project or "\\" in project or ".." in project:
|
if "/" in project or "\\" in project or ".." in project:
|
||||||
return {"message": "Invalid project name", "code": "INVALID_PROJECT"}
|
return {"error": "Invalid project name", "code": "INVALID_PROJECT"}
|
||||||
|
|
||||||
# Project must exist
|
# Resolve symlinks and verify still under PROJECTS_DIR (AC-22)
|
||||||
project_path = PROJECTS_DIR / project
|
project_path = PROJECTS_DIR / project
|
||||||
if not project_path.is_dir():
|
try:
|
||||||
return {"message": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}
|
resolved = project_path.resolve()
|
||||||
|
except OSError:
|
||||||
|
return {"error": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}
|
||||||
|
|
||||||
|
# Symlink escape check: resolved path must be under PROJECTS_DIR
|
||||||
|
try:
|
||||||
|
resolved.relative_to(PROJECTS_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid project path", "code": "INVALID_PROJECT"}
|
||||||
|
|
||||||
|
if not resolved.is_dir():
|
||||||
|
return {"error": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}
|
||||||
|
|
||||||
# Agent type must be valid
|
# Agent type must be valid
|
||||||
if agent_type not in AGENT_COMMANDS:
|
if agent_type not in AGENT_COMMANDS:
|
||||||
return {"message": f"Invalid agent type: {agent_type}", "code": "INVALID_AGENT_TYPE"}
|
return {"error": f"Invalid agent type: {agent_type}", "code": "INVALID_AGENT_TYPE"}
|
||||||
|
|
||||||
return None
|
# Return resolved path to avoid TOCTOU
|
||||||
|
return {"resolved_path": resolved}
|
||||||
|
|
||||||
def _check_zellij_session_exists(self):
|
def _check_zellij_session_exists(self):
|
||||||
"""Check if the target Zellij session exists (AC-25)."""
|
"""Check if the target Zellij session exists (AC-26).
|
||||||
|
|
||||||
|
Uses line-by-line parsing rather than substring check to avoid
|
||||||
|
false positives from similarly-named sessions (e.g., "infra2" matching "infra").
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[ZELLIJ_BIN, "list-sessions"],
|
[ZELLIJ_BIN, "list-sessions"],
|
||||||
@@ -400,38 +547,55 @@ class SpawnMixin:
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=5
|
timeout=5
|
||||||
)
|
)
|
||||||
return ZELLIJ_SESSION in result.stdout
|
# Parse session names line by line to avoid substring false positives
|
||||||
|
# Each line is a session name (may have status suffix like " (current)")
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
session_name = line.split()[0] if line.strip() else ""
|
||||||
|
if session_name == ZELLIJ_SESSION:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _wait_for_session_file(self, timeout=5.0):
|
def _wait_for_session_file(self, spawn_id, timeout=10.0):
|
||||||
"""Poll for any new session file in SESSIONS_DIR (AC-26).
|
"""Poll for session file containing our spawn_id (AC-29).
|
||||||
|
|
||||||
Session files are named {session_id}.json. We don't know the session_id
|
Deterministic correlation: we look for the specific spawn_id we passed
|
||||||
in advance, so we watch for any .json file with mtime after spawn started.
|
to the agent, not just "any new file". This prevents false positives
|
||||||
|
from unrelated agent activity.
|
||||||
|
|
||||||
|
Note: We don't filter by mtime because spawn_id is already unique per
|
||||||
|
request - no risk of matching stale files. This also avoids edge cases
|
||||||
|
where file is written faster than our timestamp capture.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spawn_id: The UUID we passed to the agent via AMC_SPAWN_ID env var
|
||||||
|
timeout: Maximum seconds to poll (10s for cold starts, VM latency)
|
||||||
"""
|
"""
|
||||||
start = time.monotonic()
|
poll_start = time.time()
|
||||||
# Snapshot existing files to detect new ones
|
poll_interval = 0.25
|
||||||
existing_files = set()
|
|
||||||
if SESSIONS_DIR.exists():
|
|
||||||
existing_files = {f.name for f in SESSIONS_DIR.glob("*.json")}
|
|
||||||
|
|
||||||
while time.monotonic() - start < timeout:
|
while time.time() - poll_start < timeout:
|
||||||
if SESSIONS_DIR.exists():
|
if SESSIONS_DIR.exists():
|
||||||
for f in SESSIONS_DIR.glob("*.json"):
|
for f in SESSIONS_DIR.glob("*.json"):
|
||||||
# New file that didn't exist before spawn
|
try:
|
||||||
if f.name not in existing_files:
|
data = json.loads(f.read_text())
|
||||||
|
if data.get("spawn_id") == spawn_id:
|
||||||
return True
|
return True
|
||||||
# Or existing file with very recent mtime (reused session)
|
except (json.JSONDecodeError, OSError):
|
||||||
if f.stat().st_mtime > start:
|
continue
|
||||||
return True
|
time.sleep(poll_interval)
|
||||||
time.sleep(0.25)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _spawn_agent_in_project_tab(self, project, project_path, agent_type):
|
def _spawn_agent_in_project_tab(self, project, project_path, agent_type, spawn_id):
|
||||||
"""Ensure project tab exists and spawn agent pane."""
|
"""Ensure project tab exists and spawn agent pane.
|
||||||
|
|
||||||
|
Called with _spawn_lock held to serialize Zellij operations.
|
||||||
|
|
||||||
|
Note: project_path is pre-resolved by _validate_spawn_params to avoid TOCTOU.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Step 0: Check session exists (AC-25)
|
# Step 0: Check session exists (AC-26)
|
||||||
if not self._check_zellij_session_exists():
|
if not self._check_zellij_session_exists():
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
@@ -450,6 +614,8 @@ class SpawnMixin:
|
|||||||
return {"ok": False, "error": f"Failed to create/switch tab: {tab_result.stderr}", "code": "TAB_ERROR"}
|
return {"ok": False, "error": f"Failed to create/switch tab: {tab_result.stderr}", "code": "TAB_ERROR"}
|
||||||
|
|
||||||
# Step 2: Spawn new pane with agent command (AC-14: naming scheme)
|
# Step 2: Spawn new pane with agent command (AC-14: naming scheme)
|
||||||
|
# Pass AMC_SPAWN_ID via subprocess env dict, merged with inherited environment.
|
||||||
|
# This ensures the env var propagates through Zellij's subprocess tree to the agent.
|
||||||
agent_cmd = AGENT_COMMANDS[agent_type]
|
agent_cmd = AGENT_COMMANDS[agent_type]
|
||||||
pane_name = f"{agent_type}-{project}"
|
pane_name = f"{agent_type}-{project}"
|
||||||
|
|
||||||
@@ -461,20 +627,26 @@ class SpawnMixin:
|
|||||||
*agent_cmd
|
*agent_cmd
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Merge spawn_id into environment so it reaches the agent process
|
||||||
|
spawn_env = os.environ.copy()
|
||||||
|
spawn_env["AMC_SPAWN_ID"] = spawn_id
|
||||||
|
|
||||||
spawn_result = subprocess.run(
|
spawn_result = subprocess.run(
|
||||||
spawn_cmd,
|
spawn_cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10,
|
||||||
|
env=spawn_env,
|
||||||
)
|
)
|
||||||
if spawn_result.returncode != 0:
|
if spawn_result.returncode != 0:
|
||||||
return {"ok": False, "error": f"Failed to spawn pane: {spawn_result.stderr}", "code": "SPAWN_ERROR"}
|
return {"ok": False, "error": f"Failed to spawn pane: {spawn_result.stderr}", "code": "SPAWN_ERROR"}
|
||||||
|
|
||||||
# Step 3: Wait for session file (AC-26)
|
# Step 3: Wait for session file with matching spawn_id (AC-29)
|
||||||
if not self._wait_for_session_file(timeout=5.0):
|
# No mtime filter needed - spawn_id is unique per request
|
||||||
|
if not self._wait_for_session_file(spawn_id, timeout=10.0):
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": "Agent spawned but session file not detected within 5 seconds",
|
"error": "Agent spawned but session file not detected within 10 seconds",
|
||||||
"code": "SESSION_FILE_TIMEOUT"
|
"code": "SESSION_FILE_TIMEOUT"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,16 +658,45 @@ class SpawnMixin:
|
|||||||
return {"ok": False, "error": "zellij command timed out", "code": "TIMEOUT"}
|
return {"ok": False, "error": "zellij command timed out", "code": "TIMEOUT"}
|
||||||
|
|
||||||
def _handle_projects(self):
|
def _handle_projects(self):
|
||||||
"""Handle GET /api/projects - return cached projects list (AC-29)."""
|
"""Handle GET /api/projects - return cached projects list (AC-33)."""
|
||||||
self._send_json(200, {"projects": _projects_cache})
|
self._send_json(200, {"projects": _projects_cache})
|
||||||
|
|
||||||
def _handle_projects_refresh(self):
|
def _handle_projects_refresh(self):
|
||||||
"""Handle POST /api/projects/refresh - refresh cache (AC-30)."""
|
"""Handle POST /api/projects/refresh - refresh cache (AC-34)."""
|
||||||
load_projects_cache()
|
load_projects_cache()
|
||||||
self._send_json(200, {"ok": True, "projects": _projects_cache})
|
self._send_json(200, {"ok": True, "projects": _projects_cache})
|
||||||
|
|
||||||
|
def _handle_health(self):
|
||||||
|
"""Handle GET /api/health - return server status (AC-41)."""
|
||||||
|
zellij_available = self._check_zellij_session_exists()
|
||||||
|
self._send_json(200, {
|
||||||
|
"ok": True,
|
||||||
|
"zellij_session": ZELLIJ_SESSION,
|
||||||
|
"zellij_available": zellij_available,
|
||||||
|
"projects_count": len(_projects_cache),
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### IMP-2: HTTP Routing (fulfills AC-1, AC-3, AC-4, AC-30)
|
### IMP-1b: Update amc-hook for spawn_id (fulfills AC-28)
|
||||||
|
|
||||||
|
**File:** `bin/amc-hook`
|
||||||
|
|
||||||
|
**Integration notes:**
|
||||||
|
- Check for `AMC_SPAWN_ID` environment variable
|
||||||
|
- If present, include it in the session JSON written to disk
|
||||||
|
- This enables deterministic correlation between spawn request and session discovery
|
||||||
|
|
||||||
|
Add after reading hook JSON and before writing session file:
|
||||||
|
```python
|
||||||
|
# Include spawn_id if present in environment (for spawn correlation)
|
||||||
|
spawn_id = os.environ.get("AMC_SPAWN_ID")
|
||||||
|
if spawn_id:
|
||||||
|
session_data["spawn_id"] = spawn_id
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### IMP-2: HTTP Routing (fulfills AC-1, AC-3, AC-4, AC-34)
|
||||||
|
|
||||||
**File:** `amc_server/mixins/http.py`
|
**File:** `amc_server/mixins/http.py`
|
||||||
|
|
||||||
@@ -503,6 +704,8 @@ Add to `do_GET`:
|
|||||||
```python
|
```python
|
||||||
elif self.path == "/api/projects":
|
elif self.path == "/api/projects":
|
||||||
self._handle_projects()
|
self._handle_projects()
|
||||||
|
elif self.path == "/api/health":
|
||||||
|
self._handle_health()
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to `do_POST`:
|
Add to `do_POST`:
|
||||||
@@ -513,27 +716,53 @@ elif self.path == "/api/projects/refresh":
|
|||||||
self._handle_projects_refresh()
|
self._handle_projects_refresh()
|
||||||
```
|
```
|
||||||
|
|
||||||
Update `do_OPTIONS` for CORS preflight on new endpoints:
|
Update `do_OPTIONS` for CORS preflight on new endpoints (AC-39: consistent CORS):
|
||||||
```python
|
```python
|
||||||
def do_OPTIONS(self):
|
def do_OPTIONS(self):
|
||||||
# CORS preflight for API endpoints
|
# CORS preflight for API endpoints
|
||||||
|
# AC-39: Keep wildcard CORS consistent with existing endpoints;
|
||||||
|
# localhost-only binding (AC-24) is the real security boundary
|
||||||
self.send_response(204)
|
self.send_response(204)
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
```
|
```
|
||||||
|
|
||||||
### IMP-2b: Server Startup (fulfills AC-29)
|
### IMP-2b: Server Startup (fulfills AC-33, AC-37)
|
||||||
|
|
||||||
**File:** `amc_server/server.py`
|
**File:** `amc_server/server.py`
|
||||||
|
|
||||||
Add to server initialization:
|
Add to server initialization:
|
||||||
```python
|
```python
|
||||||
from amc_server.mixins.spawn import load_projects_cache
|
from amc_server.mixins.spawn import load_projects_cache
|
||||||
|
from amc_server.context import generate_auth_token, start_projects_watcher
|
||||||
|
|
||||||
# In server startup, before starting HTTP server:
|
# In server startup, before starting HTTP server:
|
||||||
load_projects_cache()
|
load_projects_cache()
|
||||||
|
auth_token = generate_auth_token() # AC-37: Generate one-time token
|
||||||
|
start_projects_watcher() # AC-40: Auto-refresh every 5 minutes
|
||||||
|
# Token is injected into dashboard HTML via template variable
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-2d: Inject Auth Token into Dashboard (fulfills AC-37)
|
||||||
|
|
||||||
|
**File:** `amc_server/mixins/http.py` (in dashboard HTML serving)
|
||||||
|
|
||||||
|
Inject the auth token into the dashboard HTML so JavaScript can use it:
|
||||||
|
```python
|
||||||
|
# In the HTML template that serves the dashboard:
|
||||||
|
html_content = html_content.replace(
|
||||||
|
"<!-- AMC_AUTH_TOKEN -->",
|
||||||
|
f'<script>window.AMC_AUTH_TOKEN = "{_auth_token}";</script>'
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**File:** `dashboard/index.html`
|
||||||
|
|
||||||
|
Add placeholder in `<head>`:
|
||||||
|
```html
|
||||||
|
<!-- AMC_AUTH_TOKEN -->
|
||||||
```
|
```
|
||||||
|
|
||||||
### IMP-2c: API Constants (follows existing pattern)
|
### IMP-2c: API Constants (follows existing pattern)
|
||||||
@@ -569,7 +798,7 @@ class AMCHandler(
|
|||||||
"""HTTP handler composed from focused mixins."""
|
"""HTTP handler composed from focused mixins."""
|
||||||
```
|
```
|
||||||
|
|
||||||
### IMP-4: SpawnModal Component (fulfills AC-2, AC-3, AC-6, AC-7, AC-20, AC-24, AC-27, AC-28)
|
### IMP-4: SpawnModal Component (fulfills AC-2, AC-3, AC-6, AC-7, AC-20, AC-25, AC-31, AC-32)
|
||||||
|
|
||||||
**File:** `dashboard/components/SpawnModal.js`
|
**File:** `dashboard/components/SpawnModal.js`
|
||||||
|
|
||||||
@@ -658,7 +887,10 @@ export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
|
|||||||
try {
|
try {
|
||||||
const response = await fetchWithTimeout(API_SPAWN, {
|
const response = await fetchWithTimeout(API_SPAWN, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${window.AMC_AUTH_TOKEN}`, // AC-38
|
||||||
|
},
|
||||||
body: JSON.stringify({ project, agent_type: agentType })
|
body: JSON.stringify({ project, agent_type: agentType })
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -785,6 +1017,8 @@ export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
|
|||||||
3. Add button to existing inline header (lines 331-380)
|
3. Add button to existing inline header (lines 331-380)
|
||||||
4. Add SpawnModal component at end of render
|
4. Add SpawnModal component at end of render
|
||||||
|
|
||||||
|
**Project identity note:** `selectedProject` in App.js is already the short project name (e.g., "amc"), not the full path. This comes from `groupSessionsByProject()` in `status.js` which uses `projectName` as the key. The modal can pass it directly to `/api/spawn`.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Add import at top
|
// Add import at top
|
||||||
import { SpawnModal } from './SpawnModal.js';
|
import { SpawnModal } from './SpawnModal.js';
|
||||||
@@ -802,6 +1036,7 @@ const [spawnModalOpen, setSpawnModalOpen] = useState(false);
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
// Add modal before closing fragment (after ToastContainer, around line 426)
|
// Add modal before closing fragment (after ToastContainer, around line 426)
|
||||||
|
// selectedProject is already the short name (e.g., "amc"), not a path
|
||||||
<${SpawnModal}
|
<${SpawnModal}
|
||||||
isOpen=${spawnModalOpen}
|
isOpen=${spawnModalOpen}
|
||||||
onClose=${() => setSpawnModalOpen(false)}
|
onClose=${() => setSpawnModalOpen(false)}
|
||||||
@@ -861,7 +1096,7 @@ curl -X POST http://localhost:7400/api/spawn \
|
|||||||
-d '{"project":"gitlore","agent_type":"codex"}'
|
-d '{"project":"gitlore","agent_type":"codex"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**ACs covered:** AC-4, AC-5, AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-25, AC-26, AC-29, AC-30
|
**ACs covered:** AC-4, AC-5, AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-24, AC-26, AC-27, AC-28, AC-29, AC-30, AC-33, AC-34, AC-35, AC-36, AC-40, AC-41
|
||||||
|
|
||||||
### Slice 2: Spawn Modal UI
|
### Slice 2: Spawn Modal UI
|
||||||
|
|
||||||
@@ -870,14 +1105,14 @@ curl -X POST http://localhost:7400/api/spawn \
|
|||||||
**Tasks:**
|
**Tasks:**
|
||||||
1. Create `SpawnModal` component with context-aware behavior
|
1. Create `SpawnModal` component with context-aware behavior
|
||||||
2. Add "+ New Agent" button to page header
|
2. Add "+ New Agent" button to page header
|
||||||
3. Pass `currentProject` from sidebar selection to modal
|
3. Pass `currentProject` from sidebar selection to modal (extract basename if needed)
|
||||||
4. Implement agent type toggle (Claude / Codex)
|
4. Implement agent type toggle (Claude / Codex)
|
||||||
5. Wire up project dropdown (only shown on "All Projects")
|
5. Wire up project dropdown (only shown on "All Projects")
|
||||||
6. Add loading and error states
|
6. Add loading and error states
|
||||||
7. Show toast on spawn result
|
7. Show toast on spawn result
|
||||||
8. Implement modal dismiss behavior (Escape, click-outside, Cancel)
|
8. Implement modal dismiss behavior (Escape, click-outside, Cancel)
|
||||||
|
|
||||||
**ACs covered:** AC-1, AC-2, AC-3, AC-6, AC-7, AC-20, AC-21, AC-24, AC-27, AC-28
|
**ACs covered:** AC-1, AC-2, AC-3, AC-6, AC-7, AC-20, AC-21, AC-25, AC-31, AC-32, AC-42
|
||||||
|
|
||||||
### Slice 3: Polish & Edge Cases
|
### Slice 3: Polish & Edge Cases
|
||||||
|
|
||||||
@@ -895,16 +1130,83 @@ curl -X POST http://localhost:7400/api/spawn \
|
|||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
1. **Rate limiting:** Should we limit spawn frequency to prevent accidental spam?
|
1. ~~**Rate limiting:** Should we limit spawn frequency to prevent accidental spam?~~ **RESOLVED:** Added per-project 10-second cooldown (AC-35, AC-36)
|
||||||
|
|
||||||
2. **Session cleanup:** When a spawned agent exits, should dashboard offer to close the pane?
|
2. **Session cleanup:** When a spawned agent exits, should dashboard offer to close the pane?
|
||||||
|
|
||||||
3. **Multiple Zellij sessions:** Currently hardcoded to "infra". Future: detect or let user pick?
|
3. **Multiple Zellij sessions:** Currently hardcoded to "infra". Future: detect or let user pick?
|
||||||
|
|
||||||
4. **Agent naming:** Current scheme is `{agent_type}-{project}`. Collision if multiple agents for same project?
|
4. **Agent naming:** Current scheme is `{agent_type}-{project}`. Collision if multiple agents for same project? (Zellij allows duplicate pane names; could add timestamp suffix)
|
||||||
|
|
||||||
5. **Spawn limits:** Should we add spawn limits or warnings for resource management?
|
5. **Spawn limits:** Should we add spawn limits or warnings for resource management? (Rate limiting helps but doesn't cap total)
|
||||||
|
|
||||||
6. **Dead code cleanup:** `Header.js` exists but isn't used (App.js has inline header). Remove it?
|
6. **Dead code cleanup:** `Header.js` exists but isn't used (App.js has inline header). Remove it?
|
||||||
|
|
||||||
7. **Hook verification:** Should spawn endpoint verify `amc-hook` is installed before spawning Claude agents?
|
7. **Hook verification:** Should spawn endpoint verify `amc-hook` is installed before spawning Claude agents? (Could add `/api/hook-status` endpoint)
|
||||||
|
|
||||||
|
8. **Async spawn confirmation:** Current design returns error if session file not detected in 5s even though pane exists. Future: return spawn_id immediately, let dashboard poll for confirmation? (Suggested by GPT 5.3 review but adds complexity)
|
||||||
|
|
||||||
|
9. **Tab focus disruption:** `go-to-tab-name --create` changes active tab globally in "infra" session. Explore `--skip-focus` or similar if available in Zellij CLI?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions (from review)
|
||||||
|
|
||||||
|
These issues were identified during external review and addressed in the plan:
|
||||||
|
|
||||||
|
| Issue | Resolution |
|
||||||
|
|-------|------------|
|
||||||
|
| `st_mtime` vs `time.monotonic()` bug | Fixed: Use `time.time()` for wall-clock comparison with file mtime |
|
||||||
|
| "Any new file" polling could return false success | Fixed: Deterministic `spawn_id` correlation via env var |
|
||||||
|
| Concurrent spawns race on Zellij tab focus | Fixed: `_spawn_lock` serializes all Zellij operations |
|
||||||
|
| Symlink escape from `~/projects/` | Fixed: `Path.resolve()` + `relative_to()` check |
|
||||||
|
| CORS `*` with dangerous agent flags | Accepted: localhost-only binding (AC-24) is sufficient for dev-machine use |
|
||||||
|
| Project identity mismatch (full path vs basename) | Documented: `selectedProject` from sidebar is already the short name; verify in implementation |
|
||||||
|
|
||||||
|
### Additional Issues (from GPT 5.3 second opinion)
|
||||||
|
|
||||||
|
| Issue | Resolution |
|
||||||
|
|-------|------------|
|
||||||
|
| `AMC_SPAWN_ID` propagation not guaranteed | Fixed: Use shell wrapper (`sh -c "export AMC_SPAWN_ID=...; exec ..."`) to guarantee env var reaches agent process |
|
||||||
|
| `go-to-tab-name --create` changes active tab globally | Accepted: Dev-machine tool; focus disruption is minor annoyance, not critical. Could explore `--skip-focus` flag in future |
|
||||||
|
| Process-local lock insufficient for multi-worker | Accepted: AMC is single-process by design; documented as design constraint |
|
||||||
|
| `SESSION_FILE_TIMEOUT` creates false-failure path | Documented: Pane exists but API returns error; future work could add idempotent retry with spawn_id deduplication |
|
||||||
|
| Authz/abuse controls missing on `/api/spawn` | Fixed: Added per-project rate limiting (AC-35, AC-36); localhost-only binding provides baseline security |
|
||||||
|
| TOCTOU: path validated then re-resolved | Fixed: `_validate_spawn_params` returns resolved path; caller uses it directly |
|
||||||
|
| Hardcoded "infra" session is SPOF | Documented: Single-session design is intentional for v1; multi-session support noted in Open Questions |
|
||||||
|
| 5s polling timeout brittle under cold starts | Accepted: 5s is generous for typical agent startup; SESSION_FILE_TIMEOUT error is actionable |
|
||||||
|
| Hook missing/broken causes confirmation failure | Documented: Prerequisite section notes hook must be installed; future work could add hook verification endpoint |
|
||||||
|
| Pane name collisions reduce debuggability | Accepted: Zellij allows duplicate names; dashboard shows full session context. Could add timestamp suffix in future |
|
||||||
|
|
||||||
|
### Issues from GPT 5.3 Third Review (Codex)
|
||||||
|
|
||||||
|
| Issue | Resolution |
|
||||||
|
|-------|------------|
|
||||||
|
| Rate-limit check outside `_spawn_lock` causes race | Fixed: Moved rate-limit check inside lock to prevent two requests bypassing cooldown simultaneously |
|
||||||
|
| `start = time.time()` after spawn causes false timeout | Fixed: Capture `spawn_start_time` BEFORE spawn command; pass to `_wait_for_session_file()` |
|
||||||
|
| CORS `*` + localhost insufficient for security | Fixed: Added AC-37, AC-38, AC-39 for auth token + strict CORS |
|
||||||
|
| `ZELLIJ_SESSION in stdout` substring check | Fixed: Parse session names line-by-line to avoid false positives (e.g., "infra2" matching "infra") |
|
||||||
|
| `go-to-tab-name` then `new-pane` not atomic | Accepted: Zellij CLI doesn't support atomic tab+pane creation; race window is small in practice |
|
||||||
|
|
||||||
|
### Issues from GPT 5.3 Fourth Review (Codex)
|
||||||
|
|
||||||
|
| Issue | Resolution |
|
||||||
|
|-------|------------|
|
||||||
|
| Timestamp captured BEFORE spawn creates mtime ambiguity | Fixed: Capture `spawn_complete_time` AFTER subprocess returns; spawn_id correlation handles fast writes |
|
||||||
|
| 5s polling timeout brittle for cold starts/VMs | Fixed: Increased timeout to 10s (AC-29 updated) |
|
||||||
|
| CORS inconsistency (wildcard removed only on spawn) | Fixed: Keep wildcard CORS consistent; localhost binding is security boundary (AC-39 updated) |
|
||||||
|
| Projects cache goes stale between server restarts | Fixed: Added AC-40 for 5-minute background refresh |
|
||||||
|
| Lock contention could silently delay requests | Fixed: Added contention logging when wait exceeds 1s |
|
||||||
|
| Auth token via inline script is fragile | Accepted: Works for localhost dev tool; secure cookie alternative documented as future option |
|
||||||
|
|
||||||
|
### Issues from GPT 5.3 Fifth Review (Codex)
|
||||||
|
|
||||||
|
| Issue | Resolution |
|
||||||
|
|-------|------------|
|
||||||
|
| Shell wrapper env var propagation is fragile | Fixed: Use `subprocess.run(..., env=spawn_env)` to pass env dict directly |
|
||||||
|
| Mtime check creates race if file written during spawn | Fixed: Removed mtime filter entirely; spawn_id is already deterministic |
|
||||||
|
| Rate-limit timestamp updated before spawn wastes cooldown | Fixed: Update timestamp only after successful spawn |
|
||||||
|
| Background thread can silently die on exception | Fixed: Added try-except with logging in watch loop |
|
||||||
|
| Lock acquisition can block indefinitely | Fixed: Added 15s timeout, return SERVER_BUSY on timeout |
|
||||||
|
| No way to check Zellij session status before spawning | Fixed: Added AC-41/AC-42 for health endpoint and dashboard warning |
|
||||||
|
| Codex discovery via process inspection unreliable with shell wrapper | Fixed: Clarified Codex uses hook-based discovery (same as Claude) |
|
||||||
|
|||||||
1631
plans/subagent-visibility.md
Normal file
1631
plans/subagent-visibility.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
tests/__pycache__/test_context.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_context.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_control.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_control.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
tests/__pycache__/test_state.cpython-313-pytest-9.0.2.pyc
Normal file
BIN
tests/__pycache__/test_state.cpython-313-pytest-9.0.2.pyc
Normal file
Binary file not shown.
@@ -172,5 +172,358 @@ class SessionControlMixinTests(unittest.TestCase):
|
|||||||
handler._try_write_chars_inject.assert_called_once()
|
handler._try_write_chars_inject.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestParsePaneId(unittest.TestCase):
|
||||||
|
"""Tests for _parse_pane_id edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyControlHandler()
|
||||||
|
|
||||||
|
def test_empty_string_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._parse_pane_id(""))
|
||||||
|
|
||||||
|
def test_none_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._parse_pane_id(None))
|
||||||
|
|
||||||
|
def test_direct_int_string_parses(self):
|
||||||
|
self.assertEqual(self.handler._parse_pane_id("42"), 42)
|
||||||
|
|
||||||
|
def test_terminal_format_parses(self):
|
||||||
|
self.assertEqual(self.handler._parse_pane_id("terminal_5"), 5)
|
||||||
|
|
||||||
|
def test_plugin_format_parses(self):
|
||||||
|
self.assertEqual(self.handler._parse_pane_id("plugin_3"), 3)
|
||||||
|
|
||||||
|
def test_unknown_prefix_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._parse_pane_id("pane_7"))
|
||||||
|
|
||||||
|
def test_non_numeric_suffix_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._parse_pane_id("terminal_abc"))
|
||||||
|
|
||||||
|
def test_too_many_underscores_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._parse_pane_id("terminal_5_extra"))
|
||||||
|
|
||||||
|
def test_negative_int_parses(self):
|
||||||
|
# Edge case: negative numbers
|
||||||
|
self.assertEqual(self.handler._parse_pane_id("-1"), -1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSubmitEnterDelaySec(unittest.TestCase):
|
||||||
|
"""Tests for _get_submit_enter_delay_sec edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyControlHandler()
|
||||||
|
|
||||||
|
def test_unset_env_returns_default(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
result = self.handler._get_submit_enter_delay_sec()
|
||||||
|
self.assertEqual(result, 0.20)
|
||||||
|
|
||||||
|
def test_empty_string_returns_default(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": ""}, clear=True):
|
||||||
|
result = self.handler._get_submit_enter_delay_sec()
|
||||||
|
self.assertEqual(result, 0.20)
|
||||||
|
|
||||||
|
def test_whitespace_only_returns_default(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": " "}, clear=True):
|
||||||
|
result = self.handler._get_submit_enter_delay_sec()
|
||||||
|
self.assertEqual(result, 0.20)
|
||||||
|
|
||||||
|
def test_negative_value_returns_zero(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "-100"}, clear=True):
|
||||||
|
result = self.handler._get_submit_enter_delay_sec()
|
||||||
|
self.assertEqual(result, 0.0)
|
||||||
|
|
||||||
|
def test_value_over_2000_clamped(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "5000"}, clear=True):
|
||||||
|
result = self.handler._get_submit_enter_delay_sec()
|
||||||
|
self.assertEqual(result, 2.0) # 2000ms = 2.0s
|
||||||
|
|
||||||
|
def test_valid_ms_converted_to_seconds(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "500"}, clear=True):
|
||||||
|
result = self.handler._get_submit_enter_delay_sec()
|
||||||
|
self.assertEqual(result, 0.5)
|
||||||
|
|
||||||
|
def test_float_value_works(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "150.5"}, clear=True):
|
||||||
|
result = self.handler._get_submit_enter_delay_sec()
|
||||||
|
self.assertAlmostEqual(result, 0.1505)
|
||||||
|
|
||||||
|
def test_non_numeric_returns_default(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "fast"}, clear=True):
|
||||||
|
result = self.handler._get_submit_enter_delay_sec()
|
||||||
|
self.assertEqual(result, 0.20)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllowUnsafeWriteCharsFallback(unittest.TestCase):
|
||||||
|
"""Tests for _allow_unsafe_write_chars_fallback edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyControlHandler()
|
||||||
|
|
||||||
|
def test_unset_returns_false(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
self.assertFalse(self.handler._allow_unsafe_write_chars_fallback())
|
||||||
|
|
||||||
|
def test_empty_returns_false(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": ""}, clear=True):
|
||||||
|
self.assertFalse(self.handler._allow_unsafe_write_chars_fallback())
|
||||||
|
|
||||||
|
def test_one_returns_true(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "1"}, clear=True):
|
||||||
|
self.assertTrue(self.handler._allow_unsafe_write_chars_fallback())
|
||||||
|
|
||||||
|
def test_true_returns_true(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "true"}, clear=True):
|
||||||
|
self.assertTrue(self.handler._allow_unsafe_write_chars_fallback())
|
||||||
|
|
||||||
|
def test_yes_returns_true(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "yes"}, clear=True):
|
||||||
|
self.assertTrue(self.handler._allow_unsafe_write_chars_fallback())
|
||||||
|
|
||||||
|
def test_on_returns_true(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "on"}, clear=True):
|
||||||
|
self.assertTrue(self.handler._allow_unsafe_write_chars_fallback())
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "TRUE"}, clear=True):
|
||||||
|
self.assertTrue(self.handler._allow_unsafe_write_chars_fallback())
|
||||||
|
|
||||||
|
def test_random_string_returns_false(self):
|
||||||
|
with patch.dict(os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "maybe"}, clear=True):
|
||||||
|
self.assertFalse(self.handler._allow_unsafe_write_chars_fallback())
|
||||||
|
|
||||||
|
|
||||||
|
class TestDismissSession(unittest.TestCase):
|
||||||
|
"""Tests for _dismiss_session edge cases."""
|
||||||
|
|
||||||
|
def test_deletes_existing_session_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
sessions_dir.mkdir(exist_ok=True)
|
||||||
|
session_file = sessions_dir / "abc123.json"
|
||||||
|
session_file.write_text('{"session_id": "abc123"}')
|
||||||
|
|
||||||
|
handler = DummyControlHandler()
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._dismiss_session("abc123")
|
||||||
|
|
||||||
|
self.assertFalse(session_file.exists())
|
||||||
|
self.assertEqual(handler.sent, [(200, {"ok": True})])
|
||||||
|
|
||||||
|
def test_handles_missing_file_gracefully(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
|
||||||
|
handler = DummyControlHandler()
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._dismiss_session("nonexistent")
|
||||||
|
|
||||||
|
# Should still return success
|
||||||
|
self.assertEqual(handler.sent, [(200, {"ok": True})])
|
||||||
|
|
||||||
|
def test_path_traversal_sanitized(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
sessions_dir.mkdir(exist_ok=True)
|
||||||
|
# Create a file that should NOT be deleted
|
||||||
|
secret_file = Path(tmpdir).parent / "secret.json"
|
||||||
|
|
||||||
|
handler = DummyControlHandler()
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._dismiss_session("../secret")
|
||||||
|
|
||||||
|
# Secret file should not have been targeted
|
||||||
|
# (if it existed, it would still exist)
|
||||||
|
|
||||||
|
def test_tracks_dismissed_codex_session(self):
|
||||||
|
from amc_server.context import _dismissed_codex_ids
|
||||||
|
_dismissed_codex_ids.clear()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
|
||||||
|
handler = DummyControlHandler()
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._dismiss_session("codex-session-123")
|
||||||
|
|
||||||
|
self.assertIn("codex-session-123", _dismissed_codex_ids)
|
||||||
|
_dismissed_codex_ids.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTryWriteCharsInject(unittest.TestCase):
|
||||||
|
"""Tests for _try_write_chars_inject edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyControlHandler()
|
||||||
|
|
||||||
|
def test_successful_write_without_enter(self):
|
||||||
|
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with patch.object(control, "ZELLIJ_BIN", "/usr/bin/zellij"), \
|
||||||
|
patch("amc_server.mixins.control.subprocess.run", return_value=completed) as run_mock:
|
||||||
|
result = self.handler._try_write_chars_inject({}, "infra", "hello", send_enter=False)
|
||||||
|
|
||||||
|
self.assertEqual(result, {"ok": True})
|
||||||
|
# Should only be called once (no Enter)
|
||||||
|
self.assertEqual(run_mock.call_count, 1)
|
||||||
|
|
||||||
|
def test_successful_write_with_enter(self):
|
||||||
|
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with patch.object(control, "ZELLIJ_BIN", "/usr/bin/zellij"), \
|
||||||
|
patch("amc_server.mixins.control.subprocess.run", return_value=completed) as run_mock:
|
||||||
|
result = self.handler._try_write_chars_inject({}, "infra", "hello", send_enter=True)
|
||||||
|
|
||||||
|
self.assertEqual(result, {"ok": True})
|
||||||
|
# Should be called twice (write-chars + write Enter)
|
||||||
|
self.assertEqual(run_mock.call_count, 2)
|
||||||
|
|
||||||
|
def test_write_chars_failure_returns_error(self):
|
||||||
|
failed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="write failed")
|
||||||
|
|
||||||
|
with patch.object(control, "ZELLIJ_BIN", "/usr/bin/zellij"), \
|
||||||
|
patch("amc_server.mixins.control.subprocess.run", return_value=failed):
|
||||||
|
result = self.handler._try_write_chars_inject({}, "infra", "hello", send_enter=False)
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertIn("write", result["error"].lower())
|
||||||
|
|
||||||
|
def test_timeout_returns_error(self):
|
||||||
|
with patch.object(control, "ZELLIJ_BIN", "/usr/bin/zellij"), \
|
||||||
|
patch("amc_server.mixins.control.subprocess.run",
|
||||||
|
side_effect=subprocess.TimeoutExpired("cmd", 2)):
|
||||||
|
result = self.handler._try_write_chars_inject({}, "infra", "hello", send_enter=False)
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertIn("timed out", result["error"].lower())
|
||||||
|
|
||||||
|
def test_zellij_not_found_returns_error(self):
|
||||||
|
with patch.object(control, "ZELLIJ_BIN", "/nonexistent/zellij"), \
|
||||||
|
patch("amc_server.mixins.control.subprocess.run",
|
||||||
|
side_effect=FileNotFoundError("No such file")):
|
||||||
|
result = self.handler._try_write_chars_inject({}, "infra", "hello", send_enter=False)
|
||||||
|
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertIn("not found", result["error"].lower())
|
||||||
|
|
||||||
|
|
||||||
|
class TestRespondToSessionEdgeCases(unittest.TestCase):
|
||||||
|
"""Additional edge case tests for _respond_to_session."""
|
||||||
|
|
||||||
|
def _write_session(self, sessions_dir, session_id, **kwargs):
|
||||||
|
sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
session_file = sessions_dir / f"{session_id}.json"
|
||||||
|
data = {"session_id": session_id, **kwargs}
|
||||||
|
session_file.write_text(json.dumps(data))
|
||||||
|
|
||||||
|
def test_invalid_json_body_returns_400(self):
|
||||||
|
handler = DummyControlHandler.__new__(DummyControlHandler)
|
||||||
|
handler.headers = {"Content-Length": "10"}
|
||||||
|
handler.rfile = io.BytesIO(b"not json!!")
|
||||||
|
handler.sent = []
|
||||||
|
handler.errors = []
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch.object(control, "SESSIONS_DIR", Path(tmpdir)):
|
||||||
|
handler._respond_to_session("test")
|
||||||
|
|
||||||
|
self.assertEqual(handler.errors, [(400, "Invalid JSON body")])
|
||||||
|
|
||||||
|
def test_non_dict_body_returns_400(self):
|
||||||
|
raw = b'"just a string"'
|
||||||
|
handler = DummyControlHandler.__new__(DummyControlHandler)
|
||||||
|
handler.headers = {"Content-Length": str(len(raw))}
|
||||||
|
handler.rfile = io.BytesIO(raw)
|
||||||
|
handler.sent = []
|
||||||
|
handler.errors = []
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch.object(control, "SESSIONS_DIR", Path(tmpdir)):
|
||||||
|
handler._respond_to_session("test")
|
||||||
|
|
||||||
|
self.assertEqual(handler.errors, [(400, "Invalid JSON body")])
|
||||||
|
|
||||||
|
def test_empty_text_returns_400(self):
|
||||||
|
handler = DummyControlHandler({"text": ""})
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
self._write_session(sessions_dir, "test", zellij_session="s", zellij_pane="1")
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._respond_to_session("test")
|
||||||
|
|
||||||
|
self.assertEqual(handler.errors, [(400, "Missing or empty 'text' field")])
|
||||||
|
|
||||||
|
def test_whitespace_only_text_returns_400(self):
|
||||||
|
handler = DummyControlHandler({"text": " \n\t "})
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
self._write_session(sessions_dir, "test", zellij_session="s", zellij_pane="1")
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._respond_to_session("test")
|
||||||
|
|
||||||
|
self.assertEqual(handler.errors, [(400, "Missing or empty 'text' field")])
|
||||||
|
|
||||||
|
def test_non_string_text_returns_400(self):
|
||||||
|
handler = DummyControlHandler({"text": 123})
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
self._write_session(sessions_dir, "test", zellij_session="s", zellij_pane="1")
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._respond_to_session("test")
|
||||||
|
|
||||||
|
self.assertEqual(handler.errors, [(400, "Missing or empty 'text' field")])
|
||||||
|
|
||||||
|
def test_missing_zellij_session_returns_400(self):
|
||||||
|
handler = DummyControlHandler({"text": "hello"})
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
self._write_session(sessions_dir, "test", zellij_session="", zellij_pane="1")
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._respond_to_session("test")
|
||||||
|
|
||||||
|
self.assertIn("missing Zellij pane info", handler.errors[0][1])
|
||||||
|
|
||||||
|
def test_missing_zellij_pane_returns_400(self):
|
||||||
|
handler = DummyControlHandler({"text": "hello"})
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
self._write_session(sessions_dir, "test", zellij_session="sess", zellij_pane="")
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._respond_to_session("test")
|
||||||
|
|
||||||
|
self.assertIn("missing Zellij pane info", handler.errors[0][1])
|
||||||
|
|
||||||
|
def test_invalid_pane_format_returns_400(self):
|
||||||
|
handler = DummyControlHandler({"text": "hello"})
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
self._write_session(sessions_dir, "test", zellij_session="sess", zellij_pane="invalid_format_here")
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._respond_to_session("test")
|
||||||
|
|
||||||
|
self.assertIn("Invalid pane format", handler.errors[0][1])
|
||||||
|
|
||||||
|
def test_invalid_option_count_treated_as_zero(self):
|
||||||
|
# optionCount that can't be parsed as int should default to 0
|
||||||
|
handler = DummyControlHandler({"text": "hello", "freeform": True, "optionCount": "not a number"})
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir)
|
||||||
|
self._write_session(sessions_dir, "test", zellij_session="sess", zellij_pane="5")
|
||||||
|
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||||
|
handler._inject_text_then_enter = MagicMock(return_value={"ok": True})
|
||||||
|
handler._respond_to_session("test")
|
||||||
|
|
||||||
|
# With optionCount=0, freeform mode shouldn't trigger the "other" selection
|
||||||
|
# It should go straight to inject_text_then_enter
|
||||||
|
handler._inject_text_then_enter.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
482
tests/test_conversation.py
Normal file
482
tests/test_conversation.py
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
"""Tests for mixins/conversation.py edge cases.
|
||||||
|
|
||||||
|
Unit tests for conversation parsing from Claude Code and Codex JSONL files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
import io
|
||||||
|
|
||||||
|
from amc_server.mixins.conversation import ConversationMixin
|
||||||
|
from amc_server.mixins.parsing import SessionParsingMixin
|
||||||
|
|
||||||
|
|
||||||
|
class DummyConversationHandler(ConversationMixin, SessionParsingMixin):
|
||||||
|
"""Minimal handler for testing conversation mixin."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sent_responses = []
|
||||||
|
|
||||||
|
def _send_json(self, code, payload):
|
||||||
|
self.sent_responses.append((code, payload))
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseCodexArguments(unittest.TestCase):
|
||||||
|
"""Tests for _parse_codex_arguments edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyConversationHandler()
|
||||||
|
|
||||||
|
def test_dict_input_returned_as_is(self):
|
||||||
|
result = self.handler._parse_codex_arguments({"key": "value"})
|
||||||
|
self.assertEqual(result, {"key": "value"})
|
||||||
|
|
||||||
|
def test_empty_dict_returned_as_is(self):
|
||||||
|
result = self.handler._parse_codex_arguments({})
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
def test_json_string_parsed(self):
|
||||||
|
result = self.handler._parse_codex_arguments('{"key": "value"}')
|
||||||
|
self.assertEqual(result, {"key": "value"})
|
||||||
|
|
||||||
|
def test_invalid_json_string_returns_raw(self):
|
||||||
|
result = self.handler._parse_codex_arguments("not valid json")
|
||||||
|
self.assertEqual(result, {"raw": "not valid json"})
|
||||||
|
|
||||||
|
def test_empty_string_returns_raw(self):
|
||||||
|
result = self.handler._parse_codex_arguments("")
|
||||||
|
self.assertEqual(result, {"raw": ""})
|
||||||
|
|
||||||
|
def test_none_returns_empty_dict(self):
|
||||||
|
result = self.handler._parse_codex_arguments(None)
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
def test_int_returns_empty_dict(self):
|
||||||
|
result = self.handler._parse_codex_arguments(42)
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
def test_list_returns_empty_dict(self):
|
||||||
|
result = self.handler._parse_codex_arguments([1, 2, 3])
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestServeEvents(unittest.TestCase):
|
||||||
|
"""Tests for _serve_events edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyConversationHandler()
|
||||||
|
|
||||||
|
def test_path_traversal_sanitized(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
events_dir = Path(tmpdir)
|
||||||
|
# Create a file that path traversal might try to access
|
||||||
|
secret_file = Path(tmpdir).parent / "secret.jsonl"
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir):
|
||||||
|
# Try path traversal
|
||||||
|
self.handler._serve_events("../secret")
|
||||||
|
|
||||||
|
# Should have served response with sanitized id
|
||||||
|
self.assertEqual(len(self.handler.sent_responses), 1)
|
||||||
|
code, payload = self.handler.sent_responses[0]
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
self.assertEqual(payload["session_id"], "secret")
|
||||||
|
self.assertEqual(payload["events"], [])
|
||||||
|
|
||||||
|
def test_nonexistent_file_returns_empty_events(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("amc_server.mixins.conversation.EVENTS_DIR", Path(tmpdir)):
|
||||||
|
self.handler._serve_events("nonexistent")
|
||||||
|
|
||||||
|
code, payload = self.handler.sent_responses[0]
|
||||||
|
self.assertEqual(payload["events"], [])
|
||||||
|
|
||||||
|
def test_empty_file_returns_empty_events(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
events_dir = Path(tmpdir)
|
||||||
|
event_file = events_dir / "session123.jsonl"
|
||||||
|
event_file.write_text("")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir):
|
||||||
|
self.handler._serve_events("session123")
|
||||||
|
|
||||||
|
code, payload = self.handler.sent_responses[0]
|
||||||
|
self.assertEqual(payload["events"], [])
|
||||||
|
|
||||||
|
def test_invalid_json_lines_skipped(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
events_dir = Path(tmpdir)
|
||||||
|
event_file = events_dir / "session123.jsonl"
|
||||||
|
event_file.write_text('{"valid": "event"}\nnot json\n{"another": "event"}\n')
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.conversation.EVENTS_DIR", events_dir):
|
||||||
|
self.handler._serve_events("session123")
|
||||||
|
|
||||||
|
code, payload = self.handler.sent_responses[0]
|
||||||
|
self.assertEqual(len(payload["events"]), 2)
|
||||||
|
self.assertEqual(payload["events"][0], {"valid": "event"})
|
||||||
|
self.assertEqual(payload["events"][1], {"another": "event"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseClaudeConversation(unittest.TestCase):
|
||||||
|
"""Tests for _parse_claude_conversation edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyConversationHandler()
|
||||||
|
|
||||||
|
def test_user_message_with_array_content_skipped(self):
|
||||||
|
# Array content is tool results, not human messages
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "user",
|
||||||
|
"message": {"content": [{"type": "tool_result"}]}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
|
||||||
|
messages = self.handler._parse_claude_conversation("session123", "/project")
|
||||||
|
self.assertEqual(messages, [])
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_user_message_with_string_content_included(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "user",
|
||||||
|
"timestamp": "2024-01-01T00:00:00Z",
|
||||||
|
"message": {"content": "Hello, Claude!"}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
|
||||||
|
messages = self.handler._parse_claude_conversation("session123", "/project")
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertEqual(messages[0]["role"], "user")
|
||||||
|
self.assertEqual(messages[0]["content"], "Hello, Claude!")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_assistant_message_with_text_parts(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "assistant",
|
||||||
|
"timestamp": "2024-01-01T00:00:00Z",
|
||||||
|
"message": {
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Part 1"},
|
||||||
|
{"type": "text", "text": "Part 2"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
|
||||||
|
messages = self.handler._parse_claude_conversation("session123", "/project")
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertEqual(messages[0]["content"], "Part 1\nPart 2")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_assistant_message_with_tool_use(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"content": [
|
||||||
|
{"type": "tool_use", "name": "Read", "input": {"file_path": "/test"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
|
||||||
|
messages = self.handler._parse_claude_conversation("session123", "/project")
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertEqual(messages[0]["tool_calls"][0]["name"], "Read")
|
||||||
|
self.assertEqual(messages[0]["tool_calls"][0]["input"]["file_path"], "/test")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_assistant_message_with_thinking(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"content": [
|
||||||
|
{"type": "thinking", "thinking": "Let me consider..."},
|
||||||
|
{"type": "text", "text": "Here's my answer"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
|
||||||
|
messages = self.handler._parse_claude_conversation("session123", "/project")
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertEqual(messages[0]["thinking"], "Let me consider...")
|
||||||
|
self.assertEqual(messages[0]["content"], "Here's my answer")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_assistant_message_content_as_string_parts(self):
|
||||||
|
# Some entries might have string content parts instead of dicts
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {
|
||||||
|
"content": ["plain string", {"type": "text", "text": "structured"}]
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
|
||||||
|
messages = self.handler._parse_claude_conversation("session123", "/project")
|
||||||
|
self.assertEqual(messages[0]["content"], "plain string\nstructured")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_missing_conversation_file_returns_empty(self):
|
||||||
|
with patch.object(self.handler, "_get_claude_conversation_file", return_value=None):
|
||||||
|
messages = self.handler._parse_claude_conversation("session123", "/project")
|
||||||
|
self.assertEqual(messages, [])
|
||||||
|
|
||||||
|
def test_non_dict_entry_skipped(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('"just a string"\n')
|
||||||
|
f.write('123\n')
|
||||||
|
f.write('{"type": "user", "message": {"content": "valid"}}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
|
||||||
|
messages = self.handler._parse_claude_conversation("session123", "/project")
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_non_list_content_in_assistant_skipped(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {"content": "not a list"}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_get_claude_conversation_file", return_value=path):
|
||||||
|
messages = self.handler._parse_claude_conversation("session123", "/project")
|
||||||
|
self.assertEqual(messages, [])
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseCodexConversation(unittest.TestCase):
|
||||||
|
"""Tests for _parse_codex_conversation edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyConversationHandler()
|
||||||
|
|
||||||
|
def test_developer_role_skipped(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "response_item",
|
||||||
|
"payload": {
|
||||||
|
"type": "message",
|
||||||
|
"role": "developer",
|
||||||
|
"content": [{"text": "System instructions"}]
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
|
||||||
|
messages = self.handler._parse_codex_conversation("session123")
|
||||||
|
self.assertEqual(messages, [])
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_injected_context_skipped(self):
|
||||||
|
skip_prefixes = [
|
||||||
|
"<INSTRUCTIONS>",
|
||||||
|
"<environment_context>",
|
||||||
|
"<permissions instructions>",
|
||||||
|
"# AGENTS.md instructions",
|
||||||
|
]
|
||||||
|
for prefix in skip_prefixes:
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "response_item",
|
||||||
|
"payload": {
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"text": f"{prefix} more content here"}]
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
|
||||||
|
messages = self.handler._parse_codex_conversation("session123")
|
||||||
|
self.assertEqual(messages, [], f"Should skip content starting with {prefix}")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_function_call_accumulated_to_next_assistant(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
# Tool call
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "response_item",
|
||||||
|
"payload": {
|
||||||
|
"type": "function_call",
|
||||||
|
"name": "shell",
|
||||||
|
"arguments": '{"command": "ls"}'
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
# Assistant message
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "response_item",
|
||||||
|
"payload": {
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"text": "Here are the files"}]
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
|
||||||
|
messages = self.handler._parse_codex_conversation("session123")
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertEqual(messages[0]["tool_calls"][0]["name"], "shell")
|
||||||
|
self.assertEqual(messages[0]["content"], "Here are the files")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_function_calls_flushed_before_user_message(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
# Tool call
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "response_item",
|
||||||
|
"payload": {"type": "function_call", "name": "tool1", "arguments": "{}"}
|
||||||
|
}) + "\n")
|
||||||
|
# User message (tool calls should be flushed first)
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "response_item",
|
||||||
|
"payload": {
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"text": "User response"}]
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
|
||||||
|
messages = self.handler._parse_codex_conversation("session123")
|
||||||
|
# First message should be assistant with tool_calls (flushed)
|
||||||
|
# Second should be user
|
||||||
|
self.assertEqual(len(messages), 2)
|
||||||
|
self.assertEqual(messages[0]["role"], "assistant")
|
||||||
|
self.assertEqual(messages[0]["tool_calls"][0]["name"], "tool1")
|
||||||
|
self.assertEqual(messages[1]["role"], "user")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_reasoning_creates_thinking_message(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "response_item",
|
||||||
|
"payload": {
|
||||||
|
"type": "reasoning",
|
||||||
|
"summary": [
|
||||||
|
{"type": "summary_text", "text": "Let me think..."},
|
||||||
|
{"type": "summary_text", "text": "I'll try this approach."},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
|
||||||
|
messages = self.handler._parse_codex_conversation("session123")
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertEqual(messages[0]["thinking"], "Let me think...\nI'll try this approach.")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_pending_tool_calls_flushed_at_end(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
# Tool call with no following message
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "response_item",
|
||||||
|
"payload": {"type": "function_call", "name": "final_tool", "arguments": "{}"}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
|
||||||
|
messages = self.handler._parse_codex_conversation("session123")
|
||||||
|
# Should flush pending tool calls at end
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertEqual(messages[0]["tool_calls"][0]["name"], "final_tool")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_non_response_item_types_skipped(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('{"type": "session_meta"}\n')
|
||||||
|
f.write('{"type": "event_msg"}\n')
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "response_item",
|
||||||
|
"payload": {"type": "message", "role": "user", "content": [{"text": "Hello"}]}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with patch.object(self.handler, "_find_codex_transcript_file", return_value=path):
|
||||||
|
messages = self.handler._parse_codex_conversation("session123")
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_missing_transcript_file_returns_empty(self):
|
||||||
|
with patch.object(self.handler, "_find_codex_transcript_file", return_value=None):
|
||||||
|
messages = self.handler._parse_codex_conversation("session123")
|
||||||
|
self.assertEqual(messages, [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestServeConversation(unittest.TestCase):
|
||||||
|
"""Tests for _serve_conversation routing."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyConversationHandler()
|
||||||
|
|
||||||
|
def test_routes_to_codex_parser(self):
|
||||||
|
with patch.object(self.handler, "_parse_codex_conversation", return_value=[]) as mock:
|
||||||
|
self.handler._serve_conversation("session123", "/project", agent="codex")
|
||||||
|
mock.assert_called_once_with("session123")
|
||||||
|
|
||||||
|
def test_routes_to_claude_parser_by_default(self):
|
||||||
|
with patch.object(self.handler, "_parse_claude_conversation", return_value=[]) as mock:
|
||||||
|
self.handler._serve_conversation("session123", "/project")
|
||||||
|
mock.assert_called_once_with("session123", "/project")
|
||||||
|
|
||||||
|
def test_sanitizes_session_id(self):
|
||||||
|
with patch.object(self.handler, "_parse_claude_conversation", return_value=[]):
|
||||||
|
self.handler._serve_conversation("../../../etc/passwd", "/project")
|
||||||
|
|
||||||
|
code, payload = self.handler.sent_responses[0]
|
||||||
|
self.assertEqual(payload["session_id"], "passwd")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
361
tests/test_discovery.py
Normal file
361
tests/test_discovery.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"""Tests for mixins/discovery.py edge cases.
|
||||||
|
|
||||||
|
Unit tests for Codex session discovery and pane matching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from amc_server.mixins.discovery import SessionDiscoveryMixin
|
||||||
|
from amc_server.mixins.parsing import SessionParsingMixin
|
||||||
|
|
||||||
|
|
||||||
|
class DummyDiscoveryHandler(SessionDiscoveryMixin, SessionParsingMixin):
|
||||||
|
"""Minimal handler for testing discovery mixin."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCodexPaneInfo(unittest.TestCase):
|
||||||
|
"""Tests for _get_codex_pane_info edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyDiscoveryHandler()
|
||||||
|
# Clear cache before each test
|
||||||
|
from amc_server.context import _codex_pane_cache
|
||||||
|
_codex_pane_cache["expires"] = 0
|
||||||
|
_codex_pane_cache["pid_info"] = {}
|
||||||
|
_codex_pane_cache["cwd_map"] = {}
|
||||||
|
|
||||||
|
def test_pgrep_failure_returns_empty(self):
|
||||||
|
failed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=failed):
|
||||||
|
pid_info, cwd_map = self.handler._get_codex_pane_info()
|
||||||
|
|
||||||
|
self.assertEqual(pid_info, {})
|
||||||
|
self.assertEqual(cwd_map, {})
|
||||||
|
|
||||||
|
def test_no_codex_processes_returns_empty(self):
|
||||||
|
no_results = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=no_results):
|
||||||
|
pid_info, cwd_map = self.handler._get_codex_pane_info()
|
||||||
|
|
||||||
|
self.assertEqual(pid_info, {})
|
||||||
|
self.assertEqual(cwd_map, {})
|
||||||
|
|
||||||
|
def test_extracts_zellij_env_vars(self):
|
||||||
|
pgrep_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="12345\n", stderr="")
|
||||||
|
ps_result = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0,
|
||||||
|
stdout="codex ZELLIJ_PANE_ID=7 ZELLIJ_SESSION_NAME=myproject",
|
||||||
|
stderr=""
|
||||||
|
)
|
||||||
|
lsof_result = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0,
|
||||||
|
stdout="p12345\nn/Users/test/project",
|
||||||
|
stderr=""
|
||||||
|
)
|
||||||
|
|
||||||
|
def mock_run(args, **kwargs):
|
||||||
|
if args[0] == "pgrep":
|
||||||
|
return pgrep_result
|
||||||
|
elif args[0] == "ps":
|
||||||
|
return ps_result
|
||||||
|
elif args[0] == "lsof":
|
||||||
|
return lsof_result
|
||||||
|
return subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.subprocess.run", side_effect=mock_run):
|
||||||
|
pid_info, cwd_map = self.handler._get_codex_pane_info()
|
||||||
|
|
||||||
|
self.assertIn("12345", pid_info)
|
||||||
|
self.assertEqual(pid_info["12345"]["pane_id"], "7")
|
||||||
|
self.assertEqual(pid_info["12345"]["zellij_session"], "myproject")
|
||||||
|
|
||||||
|
def test_cache_used_when_fresh(self):
|
||||||
|
from amc_server.context import _codex_pane_cache
|
||||||
|
_codex_pane_cache["pid_info"] = {"cached": {"pane_id": "1", "zellij_session": "s"}}
|
||||||
|
_codex_pane_cache["cwd_map"] = {"/cached/path": {"session": "s", "pane_id": "1"}}
|
||||||
|
_codex_pane_cache["expires"] = time.time() + 100
|
||||||
|
|
||||||
|
# Should not call subprocess
|
||||||
|
with patch("amc_server.mixins.discovery.subprocess.run") as mock_run:
|
||||||
|
pid_info, cwd_map = self.handler._get_codex_pane_info()
|
||||||
|
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
self.assertEqual(pid_info, {"cached": {"pane_id": "1", "zellij_session": "s"}})
|
||||||
|
|
||||||
|
def test_timeout_handled_gracefully(self):
|
||||||
|
with patch("amc_server.mixins.discovery.subprocess.run",
|
||||||
|
side_effect=subprocess.TimeoutExpired("cmd", 2)):
|
||||||
|
pid_info, cwd_map = self.handler._get_codex_pane_info()
|
||||||
|
|
||||||
|
self.assertEqual(pid_info, {})
|
||||||
|
self.assertEqual(cwd_map, {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchCodexSessionToPane(unittest.TestCase):
|
||||||
|
"""Tests for _match_codex_session_to_pane edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyDiscoveryHandler()
|
||||||
|
|
||||||
|
def test_lsof_match_found(self):
|
||||||
|
"""When lsof finds a PID with the session file open, use that match."""
|
||||||
|
pid_info = {
|
||||||
|
"12345": {"pane_id": "7", "zellij_session": "project"},
|
||||||
|
}
|
||||||
|
cwd_map = {}
|
||||||
|
|
||||||
|
lsof_result = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0, stdout="12345\n", stderr=""
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
|
||||||
|
session, pane = self.handler._match_codex_session_to_pane(
|
||||||
|
Path("/some/session.jsonl"), "/project", pid_info, cwd_map
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session, "project")
|
||||||
|
self.assertEqual(pane, "7")
|
||||||
|
|
||||||
|
def test_cwd_fallback_when_lsof_fails(self):
|
||||||
|
"""When lsof doesn't find a match, fall back to CWD matching."""
|
||||||
|
pid_info = {}
|
||||||
|
cwd_map = {
|
||||||
|
"/home/user/project": {"session": "myproject", "pane_id": "3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
lsof_result = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr=""
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
|
||||||
|
session, pane = self.handler._match_codex_session_to_pane(
|
||||||
|
Path("/some/session.jsonl"), "/home/user/project", pid_info, cwd_map
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session, "myproject")
|
||||||
|
self.assertEqual(pane, "3")
|
||||||
|
|
||||||
|
def test_no_match_returns_empty_strings(self):
|
||||||
|
pid_info = {}
|
||||||
|
cwd_map = {}
|
||||||
|
|
||||||
|
lsof_result = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr=""
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
|
||||||
|
session, pane = self.handler._match_codex_session_to_pane(
|
||||||
|
Path("/some/session.jsonl"), "/unmatched/path", pid_info, cwd_map
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session, "")
|
||||||
|
self.assertEqual(pane, "")
|
||||||
|
|
||||||
|
def test_cwd_normalized_for_matching(self):
|
||||||
|
"""CWD paths should be normalized for comparison."""
|
||||||
|
pid_info = {}
|
||||||
|
cwd_map = {
|
||||||
|
"/home/user/project": {"session": "proj", "pane_id": "1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
lsof_result = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr=""
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
|
||||||
|
# Session CWD has trailing slash and extra dots
|
||||||
|
session, pane = self.handler._match_codex_session_to_pane(
|
||||||
|
Path("/some/session.jsonl"), "/home/user/./project/", pid_info, cwd_map
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session, "proj")
|
||||||
|
|
||||||
|
def test_empty_session_cwd_no_match(self):
|
||||||
|
pid_info = {}
|
||||||
|
cwd_map = {"/some/path": {"session": "s", "pane_id": "1"}}
|
||||||
|
|
||||||
|
lsof_result = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=1, stdout="", stderr=""
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.subprocess.run", return_value=lsof_result):
|
||||||
|
session, pane = self.handler._match_codex_session_to_pane(
|
||||||
|
Path("/some/session.jsonl"), "", pid_info, cwd_map
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(session, "")
|
||||||
|
self.assertEqual(pane, "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscoverActiveCodexSessions(unittest.TestCase):
|
||||||
|
"""Tests for _discover_active_codex_sessions edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyDiscoveryHandler()
|
||||||
|
# Clear caches
|
||||||
|
from amc_server.context import _codex_transcript_cache, _dismissed_codex_ids
|
||||||
|
_codex_transcript_cache.clear()
|
||||||
|
_dismissed_codex_ids.clear()
|
||||||
|
|
||||||
|
def test_skips_when_codex_sessions_dir_missing(self):
|
||||||
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", Path("/nonexistent")):
|
||||||
|
# Should not raise
|
||||||
|
self.handler._discover_active_codex_sessions()
|
||||||
|
|
||||||
|
def test_skips_old_files(self):
|
||||||
|
"""Files older than CODEX_ACTIVE_WINDOW should be skipped."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
codex_dir = Path(tmpdir)
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
# Create an old transcript file
|
||||||
|
old_file = codex_dir / "old-12345678-1234-1234-1234-123456789abc.jsonl"
|
||||||
|
old_file.write_text('{"type": "session_meta", "payload": {"cwd": "/test"}}\n')
|
||||||
|
# Set mtime to 2 hours ago
|
||||||
|
old_time = time.time() - 7200
|
||||||
|
os.utime(old_file, (old_time, old_time))
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
||||||
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
||||||
|
self.handler._discover_active_codex_sessions()
|
||||||
|
|
||||||
|
# Should not have created a session file
|
||||||
|
self.assertEqual(list(sessions_dir.glob("*.json")), [])
|
||||||
|
|
||||||
|
def test_skips_dismissed_sessions(self):
|
||||||
|
"""Sessions in _dismissed_codex_ids should be skipped."""
|
||||||
|
from amc_server.context import _dismissed_codex_ids
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
codex_dir = Path(tmpdir)
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
# Create a recent transcript file
|
||||||
|
session_id = "12345678-1234-1234-1234-123456789abc"
|
||||||
|
transcript = codex_dir / f"session-{session_id}.jsonl"
|
||||||
|
transcript.write_text('{"type": "session_meta", "payload": {"cwd": "/test"}}\n')
|
||||||
|
|
||||||
|
# Mark as dismissed
|
||||||
|
_dismissed_codex_ids[session_id] = True
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
||||||
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
||||||
|
self.handler._discover_active_codex_sessions()
|
||||||
|
|
||||||
|
# Should not have created a session file
|
||||||
|
self.assertEqual(list(sessions_dir.glob("*.json")), [])
|
||||||
|
|
||||||
|
def test_skips_non_uuid_filenames(self):
|
||||||
|
"""Files without a UUID in the name should be skipped."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
codex_dir = Path(tmpdir)
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
# Create a file without a UUID
|
||||||
|
no_uuid = codex_dir / "random-name.jsonl"
|
||||||
|
no_uuid.write_text('{"type": "session_meta", "payload": {"cwd": "/test"}}\n')
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
||||||
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
||||||
|
self.handler._discover_active_codex_sessions()
|
||||||
|
|
||||||
|
self.assertEqual(list(sessions_dir.glob("*.json")), [])
|
||||||
|
|
||||||
|
def test_skips_non_session_meta_first_line(self):
|
||||||
|
"""Files without session_meta as first line should be skipped."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
codex_dir = Path(tmpdir)
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
session_id = "12345678-1234-1234-1234-123456789abc"
|
||||||
|
transcript = codex_dir / f"session-{session_id}.jsonl"
|
||||||
|
# First line is not session_meta
|
||||||
|
transcript.write_text('{"type": "response_item", "payload": {}}\n')
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
||||||
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
||||||
|
self.handler._discover_active_codex_sessions()
|
||||||
|
|
||||||
|
self.assertEqual(list(sessions_dir.glob("*.json")), [])
|
||||||
|
|
||||||
|
def test_creates_session_file_for_valid_transcript(self):
|
||||||
|
"""Valid recent transcripts should create session files."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
codex_dir = Path(tmpdir)
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
session_id = "12345678-1234-1234-1234-123456789abc"
|
||||||
|
transcript = codex_dir / f"session-{session_id}.jsonl"
|
||||||
|
transcript.write_text(json.dumps({
|
||||||
|
"type": "session_meta",
|
||||||
|
"payload": {"cwd": "/test/project", "timestamp": "2024-01-01T00:00:00Z"}
|
||||||
|
}) + "\n")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
||||||
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
||||||
|
self.handler._match_codex_session_to_pane = MagicMock(return_value=("proj", "5"))
|
||||||
|
self.handler._get_cached_context_usage = MagicMock(return_value=None)
|
||||||
|
self.handler._discover_active_codex_sessions()
|
||||||
|
|
||||||
|
session_file = sessions_dir / f"{session_id}.json"
|
||||||
|
self.assertTrue(session_file.exists())
|
||||||
|
|
||||||
|
data = json.loads(session_file.read_text())
|
||||||
|
self.assertEqual(data["session_id"], session_id)
|
||||||
|
self.assertEqual(data["agent"], "codex")
|
||||||
|
self.assertEqual(data["project"], "project")
|
||||||
|
self.assertEqual(data["zellij_session"], "proj")
|
||||||
|
self.assertEqual(data["zellij_pane"], "5")
|
||||||
|
|
||||||
|
def test_determines_status_by_file_age(self):
|
||||||
|
"""Recent files should be 'active', older ones 'done'."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
codex_dir = Path(tmpdir)
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
session_id = "12345678-1234-1234-1234-123456789abc"
|
||||||
|
transcript = codex_dir / f"session-{session_id}.jsonl"
|
||||||
|
transcript.write_text(json.dumps({
|
||||||
|
"type": "session_meta",
|
||||||
|
"payload": {"cwd": "/test"}
|
||||||
|
}) + "\n")
|
||||||
|
|
||||||
|
# Set mtime to 3 minutes ago (> 2 min threshold)
|
||||||
|
old_time = time.time() - 180
|
||||||
|
os.utime(transcript, (old_time, old_time))
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.discovery.CODEX_SESSIONS_DIR", codex_dir), \
|
||||||
|
patch("amc_server.mixins.discovery.SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._get_codex_pane_info = MagicMock(return_value=({}, {}))
|
||||||
|
self.handler._match_codex_session_to_pane = MagicMock(return_value=("", ""))
|
||||||
|
self.handler._get_cached_context_usage = MagicMock(return_value=None)
|
||||||
|
self.handler._discover_active_codex_sessions()
|
||||||
|
|
||||||
|
session_file = sessions_dir / f"{session_id}.json"
|
||||||
|
data = json.loads(session_file.read_text())
|
||||||
|
self.assertEqual(data["status"], "done")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
475
tests/test_hook.py
Normal file
475
tests/test_hook.py
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
"""Tests for bin/amc-hook functions.
|
||||||
|
|
||||||
|
These are unit tests for the pure functions in the hook script.
|
||||||
|
Edge cases are prioritized over happy paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# Import the hook module (no .py extension, so use compile+exec pattern)
|
||||||
|
hook_path = Path(__file__).parent.parent / "bin" / "amc-hook"
|
||||||
|
amc_hook = types.ModuleType("amc_hook")
|
||||||
|
amc_hook.__file__ = str(hook_path)
|
||||||
|
# Load module code - this is safe, we're loading our own source file
|
||||||
|
code = compile(hook_path.read_text(), hook_path, "exec")
|
||||||
|
exec(code, amc_hook.__dict__) # noqa: S102 - loading local module
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectProseQuestion(unittest.TestCase):
|
||||||
|
"""Tests for _detect_prose_question edge cases."""
|
||||||
|
|
||||||
|
def test_none_input_returns_none(self):
|
||||||
|
self.assertIsNone(amc_hook._detect_prose_question(None))
|
||||||
|
|
||||||
|
def test_empty_string_returns_none(self):
|
||||||
|
self.assertIsNone(amc_hook._detect_prose_question(""))
|
||||||
|
|
||||||
|
def test_whitespace_only_returns_none(self):
|
||||||
|
self.assertIsNone(amc_hook._detect_prose_question(" \n\t "))
|
||||||
|
|
||||||
|
def test_no_question_mark_returns_none(self):
|
||||||
|
self.assertIsNone(amc_hook._detect_prose_question("This is a statement."))
|
||||||
|
|
||||||
|
def test_question_mark_in_middle_not_at_end_returns_none(self):
|
||||||
|
# Question mark exists but message doesn't END with one
|
||||||
|
self.assertIsNone(amc_hook._detect_prose_question("What? I said hello."))
|
||||||
|
|
||||||
|
def test_trailing_whitespace_after_question_still_detects(self):
|
||||||
|
result = amc_hook._detect_prose_question("Is this a question? \n\t")
|
||||||
|
self.assertEqual(result, "Is this a question?")
|
||||||
|
|
||||||
|
def test_question_in_last_paragraph_only(self):
|
||||||
|
msg = "First paragraph here.\n\nSecond paragraph is the question?"
|
||||||
|
result = amc_hook._detect_prose_question(msg)
|
||||||
|
self.assertEqual(result, "Second paragraph is the question?")
|
||||||
|
|
||||||
|
def test_multiple_paragraphs_question_not_in_last_returns_none(self):
|
||||||
|
# Question in first paragraph, statement in last
|
||||||
|
msg = "Is this a question?\n\nNo, this is the last paragraph."
|
||||||
|
self.assertIsNone(amc_hook._detect_prose_question(msg))
|
||||||
|
|
||||||
|
def test_truncates_long_question_to_max_length(self):
|
||||||
|
long_question = "x" * 600 + "?"
|
||||||
|
result = amc_hook._detect_prose_question(long_question)
|
||||||
|
self.assertLessEqual(len(result), amc_hook.MAX_QUESTION_LEN + 1) # +1 for ?
|
||||||
|
|
||||||
|
def test_long_question_tries_sentence_boundary(self):
|
||||||
|
# Create a message longer than MAX_QUESTION_LEN (500) with a sentence boundary
|
||||||
|
# The truncation takes the LAST MAX_QUESTION_LEN chars, then finds FIRST ". " within that
|
||||||
|
prefix = "a" * 500 + ". Sentence start. "
|
||||||
|
suffix = "Is this the question?"
|
||||||
|
msg = prefix + suffix
|
||||||
|
self.assertGreater(len(msg), amc_hook.MAX_QUESTION_LEN)
|
||||||
|
result = amc_hook._detect_prose_question(msg)
|
||||||
|
# Code finds FIRST ". " in truncated portion, so starts at "Sentence start"
|
||||||
|
self.assertTrue(
|
||||||
|
result.startswith("Sentence start"),
|
||||||
|
f"Expected to start with 'Sentence start', got: {result[:50]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_long_question_no_sentence_boundary_truncates_from_end(self):
|
||||||
|
# No period in the long text
|
||||||
|
long_msg = "a" * 600 + "?"
|
||||||
|
result = amc_hook._detect_prose_question(long_msg)
|
||||||
|
self.assertTrue(result.endswith("?"))
|
||||||
|
self.assertLessEqual(len(result), amc_hook.MAX_QUESTION_LEN + 1)
|
||||||
|
|
||||||
|
def test_single_character_question(self):
|
||||||
|
result = amc_hook._detect_prose_question("?")
|
||||||
|
self.assertEqual(result, "?")
|
||||||
|
|
||||||
|
def test_newlines_within_last_paragraph_preserved(self):
|
||||||
|
msg = "Intro.\n\nLine one\nLine two?"
|
||||||
|
result = amc_hook._detect_prose_question(msg)
|
||||||
|
self.assertIn("\n", result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractQuestions(unittest.TestCase):
|
||||||
|
"""Tests for _extract_questions edge cases."""
|
||||||
|
|
||||||
|
def test_empty_hook_returns_empty_list(self):
|
||||||
|
self.assertEqual(amc_hook._extract_questions({}), [])
|
||||||
|
|
||||||
|
def test_missing_tool_input_returns_empty_list(self):
|
||||||
|
self.assertEqual(amc_hook._extract_questions({"other": "data"}), [])
|
||||||
|
|
||||||
|
def test_tool_input_is_none_returns_empty_list(self):
|
||||||
|
self.assertEqual(amc_hook._extract_questions({"tool_input": None}), [])
|
||||||
|
|
||||||
|
def test_tool_input_is_list_returns_empty_list(self):
|
||||||
|
# tool_input should be dict, not list
|
||||||
|
self.assertEqual(amc_hook._extract_questions({"tool_input": []}), [])
|
||||||
|
|
||||||
|
def test_tool_input_is_string_json_parsed(self):
|
||||||
|
tool_input = json.dumps({"questions": [{"question": "Test?", "options": []}]})
|
||||||
|
result = amc_hook._extract_questions({"tool_input": tool_input})
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0]["question"], "Test?")
|
||||||
|
|
||||||
|
def test_tool_input_invalid_json_string_returns_empty(self):
|
||||||
|
result = amc_hook._extract_questions({"tool_input": "not valid json"})
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_questions_key_is_none_returns_empty(self):
|
||||||
|
result = amc_hook._extract_questions({"tool_input": {"questions": None}})
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_questions_key_missing_returns_empty(self):
|
||||||
|
result = amc_hook._extract_questions({"tool_input": {"other": "data"}})
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_option_without_markdown_excluded_from_output(self):
|
||||||
|
hook = {
|
||||||
|
"tool_input": {
|
||||||
|
"questions": [{
|
||||||
|
"question": "Pick one",
|
||||||
|
"options": [{"label": "A", "description": "Desc A"}],
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = amc_hook._extract_questions(hook)
|
||||||
|
self.assertNotIn("markdown", result[0]["options"][0])
|
||||||
|
|
||||||
|
def test_option_with_markdown_included(self):
|
||||||
|
hook = {
|
||||||
|
"tool_input": {
|
||||||
|
"questions": [{
|
||||||
|
"question": "Pick one",
|
||||||
|
"options": [{"label": "A", "description": "Desc", "markdown": "```code```"}],
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = amc_hook._extract_questions(hook)
|
||||||
|
self.assertEqual(result[0]["options"][0]["markdown"], "```code```")
|
||||||
|
|
||||||
|
def test_missing_question_fields_default_to_empty(self):
|
||||||
|
hook = {"tool_input": {"questions": [{}]}}
|
||||||
|
result = amc_hook._extract_questions(hook)
|
||||||
|
self.assertEqual(result[0]["question"], "")
|
||||||
|
self.assertEqual(result[0]["header"], "")
|
||||||
|
self.assertEqual(result[0]["options"], [])
|
||||||
|
|
||||||
|
def test_option_missing_fields_default_to_empty(self):
|
||||||
|
hook = {"tool_input": {"questions": [{"options": [{}]}]}}
|
||||||
|
result = amc_hook._extract_questions(hook)
|
||||||
|
self.assertEqual(result[0]["options"][0]["label"], "")
|
||||||
|
self.assertEqual(result[0]["options"][0]["description"], "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAtomicWrite(unittest.TestCase):
|
||||||
|
"""Tests for _atomic_write edge cases."""
|
||||||
|
|
||||||
|
def test_writes_to_nonexistent_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "new_file.json"
|
||||||
|
amc_hook._atomic_write(path, {"key": "value"})
|
||||||
|
self.assertTrue(path.exists())
|
||||||
|
self.assertEqual(json.loads(path.read_text()), {"key": "value"})
|
||||||
|
|
||||||
|
def test_overwrites_existing_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "existing.json"
|
||||||
|
path.write_text('{"old": "data"}')
|
||||||
|
amc_hook._atomic_write(path, {"new": "data"})
|
||||||
|
self.assertEqual(json.loads(path.read_text()), {"new": "data"})
|
||||||
|
|
||||||
|
def test_cleans_up_temp_file_on_replace_failure(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "subdir" / "file.json"
|
||||||
|
# Parent doesn't exist, so mkstemp will fail
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
amc_hook._atomic_write(path, {"data": "test"})
|
||||||
|
|
||||||
|
def test_no_partial_writes_on_failure(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
path = Path(tmpdir) / "file.json"
|
||||||
|
path.write_text('{"original": "data"}')
|
||||||
|
|
||||||
|
# Mock os.replace to fail after the temp file is written
|
||||||
|
original_replace = os.replace
|
||||||
|
def failing_replace(src, dst):
|
||||||
|
raise PermissionError("Simulated failure")
|
||||||
|
|
||||||
|
with patch("os.replace", side_effect=failing_replace):
|
||||||
|
with self.assertRaises(PermissionError):
|
||||||
|
amc_hook._atomic_write(path, {"new": "data"})
|
||||||
|
|
||||||
|
# Original file should be unchanged
|
||||||
|
self.assertEqual(json.loads(path.read_text()), {"original": "data"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadSession(unittest.TestCase):
|
||||||
|
"""Tests for _read_session edge cases."""
|
||||||
|
|
||||||
|
def test_nonexistent_file_returns_empty_dict(self):
|
||||||
|
result = amc_hook._read_session(Path("/nonexistent/path/file.json"))
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
def test_empty_file_returns_empty_dict(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
f.write("")
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = amc_hook._read_session(path)
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_invalid_json_returns_empty_dict(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
f.write("not valid json {{{")
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = amc_hook._read_session(path)
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_valid_json_returned(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump({"session_id": "abc"}, f)
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = amc_hook._read_session(path)
|
||||||
|
self.assertEqual(result, {"session_id": "abc"})
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppendEvent(unittest.TestCase):
|
||||||
|
"""Tests for _append_event edge cases."""
|
||||||
|
|
||||||
|
def test_creates_file_if_missing(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch.object(amc_hook, "EVENTS_DIR", Path(tmpdir)):
|
||||||
|
amc_hook._append_event("session123", {"event": "test"})
|
||||||
|
event_file = Path(tmpdir) / "session123.jsonl"
|
||||||
|
self.assertTrue(event_file.exists())
|
||||||
|
|
||||||
|
def test_appends_to_existing_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
event_file = Path(tmpdir) / "session123.jsonl"
|
||||||
|
event_file.write_text('{"event": "first"}\n')
|
||||||
|
with patch.object(amc_hook, "EVENTS_DIR", Path(tmpdir)):
|
||||||
|
amc_hook._append_event("session123", {"event": "second"})
|
||||||
|
lines = event_file.read_text().strip().split("\n")
|
||||||
|
self.assertEqual(len(lines), 2)
|
||||||
|
self.assertEqual(json.loads(lines[1])["event"], "second")
|
||||||
|
|
||||||
|
def test_oserror_silently_ignored(self):
|
||||||
|
with patch.object(amc_hook, "EVENTS_DIR", Path("/nonexistent/path")):
|
||||||
|
# Should not raise
|
||||||
|
amc_hook._append_event("session123", {"event": "test"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainHookPathTraversal(unittest.TestCase):
|
||||||
|
"""Tests for path traversal protection in main()."""
|
||||||
|
|
||||||
|
def test_session_id_with_path_traversal_sanitized(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
events_dir.mkdir()
|
||||||
|
|
||||||
|
# Create a legitimate session file to test that traversal doesn't reach it
|
||||||
|
legit_file = Path(tmpdir) / "secret.json"
|
||||||
|
legit_file.write_text('{"secret": "data"}')
|
||||||
|
|
||||||
|
hook_input = json.dumps({
|
||||||
|
"hook_event_name": "SessionStart",
|
||||||
|
"session_id": "../secret",
|
||||||
|
"cwd": "/test/project",
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
|
||||||
|
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
|
||||||
|
patch("sys.stdin.read", return_value=hook_input):
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
# The sanitized session ID should be "secret" (basename of "../secret")
|
||||||
|
# and should NOT have modified the legit_file in parent dir
|
||||||
|
self.assertEqual(json.loads(legit_file.read_text()), {"secret": "data"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainHookEmptyInput(unittest.TestCase):
|
||||||
|
"""Tests for main() with various empty/invalid inputs."""
|
||||||
|
|
||||||
|
def test_empty_stdin_returns_silently(self):
|
||||||
|
with patch("sys.stdin.read", return_value=""):
|
||||||
|
# Should not raise
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
def test_whitespace_only_stdin_returns_silently(self):
|
||||||
|
with patch("sys.stdin.read", return_value=" \n\t "):
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
def test_invalid_json_stdin_fails_silently(self):
|
||||||
|
with patch("sys.stdin.read", return_value="not json"):
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
def test_missing_session_id_returns_silently(self):
|
||||||
|
with patch("sys.stdin.read", return_value='{"hook_event_name": "SessionStart"}'):
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
def test_missing_event_name_returns_silently(self):
|
||||||
|
with patch("sys.stdin.read", return_value='{"session_id": "abc123"}'):
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
def test_empty_session_id_after_sanitization_returns_silently(self):
|
||||||
|
# Edge case: session_id that becomes empty after basename()
|
||||||
|
with patch("sys.stdin.read", return_value='{"hook_event_name": "SessionStart", "session_id": "/"}'):
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainSessionEndDeletesFile(unittest.TestCase):
|
||||||
|
"""Tests for SessionEnd hook behavior."""
|
||||||
|
|
||||||
|
def test_session_end_deletes_existing_session_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
events_dir.mkdir()
|
||||||
|
|
||||||
|
session_file = sessions_dir / "abc123.json"
|
||||||
|
session_file.write_text('{"session_id": "abc123"}')
|
||||||
|
|
||||||
|
hook_input = json.dumps({
|
||||||
|
"hook_event_name": "SessionEnd",
|
||||||
|
"session_id": "abc123",
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
|
||||||
|
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
|
||||||
|
patch("sys.stdin.read", return_value=hook_input):
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
self.assertFalse(session_file.exists())
|
||||||
|
|
||||||
|
def test_session_end_missing_file_no_error(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
events_dir.mkdir()
|
||||||
|
|
||||||
|
hook_input = json.dumps({
|
||||||
|
"hook_event_name": "SessionEnd",
|
||||||
|
"session_id": "nonexistent",
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
|
||||||
|
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
|
||||||
|
patch("sys.stdin.read", return_value=hook_input):
|
||||||
|
# Should not raise
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainPreToolUseWithoutExistingSession(unittest.TestCase):
|
||||||
|
"""Edge case: PreToolUse arrives but session file doesn't exist."""
|
||||||
|
|
||||||
|
def test_pre_tool_use_no_existing_session_returns_silently(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
events_dir.mkdir()
|
||||||
|
|
||||||
|
hook_input = json.dumps({
|
||||||
|
"hook_event_name": "PreToolUse",
|
||||||
|
"tool_name": "AskUserQuestion",
|
||||||
|
"session_id": "nonexistent",
|
||||||
|
"tool_input": {"questions": []},
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
|
||||||
|
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
|
||||||
|
patch("sys.stdin.read", return_value=hook_input):
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
# No session file should be created
|
||||||
|
self.assertFalse((sessions_dir / "nonexistent.json").exists())
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainStopWithProseQuestion(unittest.TestCase):
|
||||||
|
"""Tests for Stop hook detecting prose questions."""
|
||||||
|
|
||||||
|
def test_stop_with_prose_question_sets_needs_attention(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
events_dir.mkdir()
|
||||||
|
|
||||||
|
# Create existing session
|
||||||
|
session_file = sessions_dir / "abc123.json"
|
||||||
|
session_file.write_text(json.dumps({
|
||||||
|
"session_id": "abc123",
|
||||||
|
"status": "active",
|
||||||
|
}))
|
||||||
|
|
||||||
|
hook_input = json.dumps({
|
||||||
|
"hook_event_name": "Stop",
|
||||||
|
"session_id": "abc123",
|
||||||
|
"last_assistant_message": "What do you think about this approach?",
|
||||||
|
"cwd": "/test/project",
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
|
||||||
|
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
|
||||||
|
patch("sys.stdin.read", return_value=hook_input):
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
data = json.loads(session_file.read_text())
|
||||||
|
self.assertEqual(data["status"], "needs_attention")
|
||||||
|
self.assertEqual(len(data["pending_questions"]), 1)
|
||||||
|
self.assertIn("approach?", data["pending_questions"][0]["question"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainTurnTimingAccumulation(unittest.TestCase):
|
||||||
|
"""Tests for turn timing accumulation across pause/resume cycles."""
|
||||||
|
|
||||||
|
def test_post_tool_use_accumulates_paused_time(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
events_dir.mkdir()
|
||||||
|
|
||||||
|
# Create session with existing paused state
|
||||||
|
session_file = sessions_dir / "abc123.json"
|
||||||
|
session_file.write_text(json.dumps({
|
||||||
|
"session_id": "abc123",
|
||||||
|
"status": "needs_attention",
|
||||||
|
"turn_paused_at": "2024-01-01T00:00:00+00:00",
|
||||||
|
"turn_paused_ms": 5000, # Already had 5 seconds paused
|
||||||
|
}))
|
||||||
|
|
||||||
|
hook_input = json.dumps({
|
||||||
|
"hook_event_name": "PostToolUse",
|
||||||
|
"tool_name": "AskUserQuestion",
|
||||||
|
"session_id": "abc123",
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch.object(amc_hook, "SESSIONS_DIR", sessions_dir), \
|
||||||
|
patch.object(amc_hook, "EVENTS_DIR", events_dir), \
|
||||||
|
patch("sys.stdin.read", return_value=hook_input):
|
||||||
|
amc_hook.main()
|
||||||
|
|
||||||
|
data = json.loads(session_file.read_text())
|
||||||
|
# Should have accumulated more paused time
|
||||||
|
self.assertGreater(data["turn_paused_ms"], 5000)
|
||||||
|
# turn_paused_at should be removed after resuming
|
||||||
|
self.assertNotIn("turn_paused_at", data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
335
tests/test_http.py
Normal file
335
tests/test_http.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
"""Tests for mixins/http.py edge cases.
|
||||||
|
|
||||||
|
Unit tests for HTTP routing and response handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from amc_server.mixins.http import HttpMixin
|
||||||
|
|
||||||
|
|
||||||
|
class DummyHttpHandler(HttpMixin):
|
||||||
|
"""Minimal handler for testing HTTP mixin."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.response_code = None
|
||||||
|
self.headers_sent = {}
|
||||||
|
self.body_sent = b""
|
||||||
|
self.path = "/"
|
||||||
|
self.wfile = io.BytesIO()
|
||||||
|
|
||||||
|
def send_response(self, code):
|
||||||
|
self.response_code = code
|
||||||
|
|
||||||
|
def send_header(self, key, value):
|
||||||
|
self.headers_sent[key] = value
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendBytesResponse(unittest.TestCase):
|
||||||
|
"""Tests for _send_bytes_response edge cases."""
|
||||||
|
|
||||||
|
def test_sends_correct_headers(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler._send_bytes_response(200, b"test", content_type="text/plain")
|
||||||
|
|
||||||
|
self.assertEqual(handler.response_code, 200)
|
||||||
|
self.assertEqual(handler.headers_sent["Content-Type"], "text/plain")
|
||||||
|
self.assertEqual(handler.headers_sent["Content-Length"], "4")
|
||||||
|
|
||||||
|
def test_includes_extra_headers(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler._send_bytes_response(
|
||||||
|
200, b"test",
|
||||||
|
extra_headers={"X-Custom": "value", "Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(handler.headers_sent["X-Custom"], "value")
|
||||||
|
self.assertEqual(handler.headers_sent["Cache-Control"], "no-cache")
|
||||||
|
|
||||||
|
def test_broken_pipe_returns_false(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler.wfile.write = MagicMock(side_effect=BrokenPipeError())
|
||||||
|
|
||||||
|
result = handler._send_bytes_response(200, b"test")
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_connection_reset_returns_false(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler.wfile.write = MagicMock(side_effect=ConnectionResetError())
|
||||||
|
|
||||||
|
result = handler._send_bytes_response(200, b"test")
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_os_error_returns_false(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler.wfile.write = MagicMock(side_effect=OSError("write error"))
|
||||||
|
|
||||||
|
result = handler._send_bytes_response(200, b"test")
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendJson(unittest.TestCase):
|
||||||
|
"""Tests for _send_json edge cases."""
|
||||||
|
|
||||||
|
def test_includes_cors_header(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler._send_json(200, {"key": "value"})
|
||||||
|
|
||||||
|
self.assertEqual(handler.headers_sent["Access-Control-Allow-Origin"], "*")
|
||||||
|
|
||||||
|
def test_sets_json_content_type(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler._send_json(200, {"key": "value"})
|
||||||
|
|
||||||
|
self.assertEqual(handler.headers_sent["Content-Type"], "application/json")
|
||||||
|
|
||||||
|
def test_encodes_payload_as_json(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler._send_json(200, {"key": "value"})
|
||||||
|
|
||||||
|
written = handler.wfile.getvalue()
|
||||||
|
self.assertEqual(json.loads(written), {"key": "value"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestServeDashboardFile(unittest.TestCase):
|
||||||
|
"""Tests for _serve_dashboard_file edge cases."""
|
||||||
|
|
||||||
|
def test_nonexistent_file_returns_404(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler.errors = []
|
||||||
|
|
||||||
|
def capture_error(code, message):
|
||||||
|
handler.errors.append((code, message))
|
||||||
|
|
||||||
|
handler._json_error = capture_error
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
||||||
|
handler._serve_dashboard_file("nonexistent.html")
|
||||||
|
|
||||||
|
self.assertEqual(len(handler.errors), 1)
|
||||||
|
self.assertEqual(handler.errors[0][0], 404)
|
||||||
|
|
||||||
|
def test_path_traversal_blocked(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler.errors = []
|
||||||
|
|
||||||
|
def capture_error(code, message):
|
||||||
|
handler.errors.append((code, message))
|
||||||
|
|
||||||
|
handler._json_error = capture_error
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create a file outside the dashboard dir that shouldn't be accessible
|
||||||
|
secret = Path(tmpdir).parent / "secret.txt"
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
||||||
|
handler._serve_dashboard_file("../secret.txt")
|
||||||
|
|
||||||
|
self.assertEqual(len(handler.errors), 1)
|
||||||
|
self.assertEqual(handler.errors[0][0], 403)
|
||||||
|
|
||||||
|
def test_correct_content_type_for_html(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
html_file = Path(tmpdir) / "test.html"
|
||||||
|
html_file.write_text("<html></html>")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
||||||
|
handler._serve_dashboard_file("test.html")
|
||||||
|
|
||||||
|
self.assertEqual(handler.headers_sent["Content-Type"], "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
def test_correct_content_type_for_css(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
css_file = Path(tmpdir) / "styles.css"
|
||||||
|
css_file.write_text("body {}")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
||||||
|
handler._serve_dashboard_file("styles.css")
|
||||||
|
|
||||||
|
self.assertEqual(handler.headers_sent["Content-Type"], "text/css; charset=utf-8")
|
||||||
|
|
||||||
|
def test_correct_content_type_for_js(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
js_file = Path(tmpdir) / "app.js"
|
||||||
|
js_file.write_text("console.log('hello')")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
||||||
|
handler._serve_dashboard_file("app.js")
|
||||||
|
|
||||||
|
self.assertEqual(handler.headers_sent["Content-Type"], "application/javascript; charset=utf-8")
|
||||||
|
|
||||||
|
def test_unknown_extension_gets_octet_stream(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
unknown_file = Path(tmpdir) / "data.xyz"
|
||||||
|
unknown_file.write_bytes(b"\x00\x01\x02")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
||||||
|
handler._serve_dashboard_file("data.xyz")
|
||||||
|
|
||||||
|
self.assertEqual(handler.headers_sent["Content-Type"], "application/octet-stream")
|
||||||
|
|
||||||
|
def test_no_cache_headers_set(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
html_file = Path(tmpdir) / "test.html"
|
||||||
|
html_file.write_text("<html></html>")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.http.DASHBOARD_DIR", Path(tmpdir)):
|
||||||
|
handler._serve_dashboard_file("test.html")
|
||||||
|
|
||||||
|
self.assertIn("no-cache", handler.headers_sent.get("Cache-Control", ""))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDoGet(unittest.TestCase):
|
||||||
|
"""Tests for do_GET routing edge cases."""
|
||||||
|
|
||||||
|
def _make_handler(self, path):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler.path = path
|
||||||
|
handler._serve_dashboard_file = MagicMock()
|
||||||
|
handler._serve_state = MagicMock()
|
||||||
|
handler._serve_stream = MagicMock()
|
||||||
|
handler._serve_events = MagicMock()
|
||||||
|
handler._serve_conversation = MagicMock()
|
||||||
|
handler._json_error = MagicMock()
|
||||||
|
return handler
|
||||||
|
|
||||||
|
def test_root_serves_index(self):
|
||||||
|
handler = self._make_handler("/")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._serve_dashboard_file.assert_called_with("index.html")
|
||||||
|
|
||||||
|
def test_index_html_serves_index(self):
|
||||||
|
handler = self._make_handler("/index.html")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._serve_dashboard_file.assert_called_with("index.html")
|
||||||
|
|
||||||
|
def test_static_file_served(self):
|
||||||
|
handler = self._make_handler("/components/App.js")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._serve_dashboard_file.assert_called_with("components/App.js")
|
||||||
|
|
||||||
|
def test_path_traversal_in_static_blocked(self):
|
||||||
|
handler = self._make_handler("/../../etc/passwd")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._json_error.assert_called_with(404, "Not Found")
|
||||||
|
|
||||||
|
def test_api_state_routed(self):
|
||||||
|
handler = self._make_handler("/api/state")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._serve_state.assert_called_once()
|
||||||
|
|
||||||
|
def test_api_stream_routed(self):
|
||||||
|
handler = self._make_handler("/api/stream")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._serve_stream.assert_called_once()
|
||||||
|
|
||||||
|
def test_api_events_routed_with_id(self):
|
||||||
|
handler = self._make_handler("/api/events/session-123")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._serve_events.assert_called_with("session-123")
|
||||||
|
|
||||||
|
def test_api_events_url_decoded(self):
|
||||||
|
handler = self._make_handler("/api/events/session%20with%20spaces")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._serve_events.assert_called_with("session with spaces")
|
||||||
|
|
||||||
|
def test_api_conversation_with_query_params(self):
|
||||||
|
handler = self._make_handler("/api/conversation/sess123?project_dir=/test&agent=codex")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._serve_conversation.assert_called_with("sess123", "/test", "codex")
|
||||||
|
|
||||||
|
def test_api_conversation_defaults_to_claude(self):
|
||||||
|
handler = self._make_handler("/api/conversation/sess123")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._serve_conversation.assert_called_with("sess123", "", "claude")
|
||||||
|
|
||||||
|
def test_unknown_api_path_returns_404(self):
|
||||||
|
handler = self._make_handler("/api/unknown")
|
||||||
|
handler.do_GET()
|
||||||
|
handler._json_error.assert_called_with(404, "Not Found")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDoPost(unittest.TestCase):
|
||||||
|
"""Tests for do_POST routing edge cases."""
|
||||||
|
|
||||||
|
def _make_handler(self, path):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler.path = path
|
||||||
|
handler._dismiss_dead_sessions = MagicMock()
|
||||||
|
handler._dismiss_session = MagicMock()
|
||||||
|
handler._respond_to_session = MagicMock()
|
||||||
|
handler._json_error = MagicMock()
|
||||||
|
return handler
|
||||||
|
|
||||||
|
def test_dismiss_dead_routed(self):
|
||||||
|
handler = self._make_handler("/api/dismiss-dead")
|
||||||
|
handler.do_POST()
|
||||||
|
handler._dismiss_dead_sessions.assert_called_once()
|
||||||
|
|
||||||
|
def test_dismiss_session_routed(self):
|
||||||
|
handler = self._make_handler("/api/dismiss/session-abc")
|
||||||
|
handler.do_POST()
|
||||||
|
handler._dismiss_session.assert_called_with("session-abc")
|
||||||
|
|
||||||
|
def test_dismiss_url_decoded(self):
|
||||||
|
handler = self._make_handler("/api/dismiss/session%2Fwith%2Fslash")
|
||||||
|
handler.do_POST()
|
||||||
|
handler._dismiss_session.assert_called_with("session/with/slash")
|
||||||
|
|
||||||
|
def test_respond_routed(self):
|
||||||
|
handler = self._make_handler("/api/respond/session-xyz")
|
||||||
|
handler.do_POST()
|
||||||
|
handler._respond_to_session.assert_called_with("session-xyz")
|
||||||
|
|
||||||
|
def test_unknown_post_path_returns_404(self):
|
||||||
|
handler = self._make_handler("/api/unknown")
|
||||||
|
handler.do_POST()
|
||||||
|
handler._json_error.assert_called_with(404, "Not Found")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDoOptions(unittest.TestCase):
|
||||||
|
"""Tests for do_OPTIONS CORS preflight."""
|
||||||
|
|
||||||
|
def test_returns_204_with_cors_headers(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler.do_OPTIONS()
|
||||||
|
|
||||||
|
self.assertEqual(handler.response_code, 204)
|
||||||
|
self.assertEqual(handler.headers_sent["Access-Control-Allow-Origin"], "*")
|
||||||
|
self.assertIn("POST", handler.headers_sent["Access-Control-Allow-Methods"])
|
||||||
|
self.assertIn("Content-Type", handler.headers_sent["Access-Control-Allow-Headers"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestJsonError(unittest.TestCase):
|
||||||
|
"""Tests for _json_error helper."""
|
||||||
|
|
||||||
|
def test_sends_json_with_error(self):
|
||||||
|
handler = DummyHttpHandler()
|
||||||
|
handler._json_error(404, "Not Found")
|
||||||
|
|
||||||
|
written = handler.wfile.getvalue()
|
||||||
|
payload = json.loads(written)
|
||||||
|
self.assertEqual(payload, {"ok": False, "error": "Not Found"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
635
tests/test_parsing.py
Normal file
635
tests/test_parsing.py
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
"""Tests for mixins/parsing.py edge cases.
|
||||||
|
|
||||||
|
Unit tests for parsing helper functions and conversation file resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from amc_server.mixins.parsing import SessionParsingMixin
|
||||||
|
|
||||||
|
|
||||||
|
class DummyParsingHandler(SessionParsingMixin):
|
||||||
|
"""Minimal handler for testing parsing mixin."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestToInt(unittest.TestCase):
|
||||||
|
"""Tests for _to_int edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyParsingHandler()
|
||||||
|
|
||||||
|
def test_none_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._to_int(None))
|
||||||
|
|
||||||
|
def test_bool_true_returns_none(self):
|
||||||
|
# Booleans are technically ints in Python, but we don't want to convert them
|
||||||
|
self.assertIsNone(self.handler._to_int(True))
|
||||||
|
|
||||||
|
def test_bool_false_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._to_int(False))
|
||||||
|
|
||||||
|
def test_int_returns_int(self):
|
||||||
|
self.assertEqual(self.handler._to_int(42), 42)
|
||||||
|
|
||||||
|
def test_negative_int_returns_int(self):
|
||||||
|
self.assertEqual(self.handler._to_int(-10), -10)
|
||||||
|
|
||||||
|
def test_zero_returns_zero(self):
|
||||||
|
self.assertEqual(self.handler._to_int(0), 0)
|
||||||
|
|
||||||
|
def test_float_truncates_to_int(self):
|
||||||
|
self.assertEqual(self.handler._to_int(3.7), 3)
|
||||||
|
|
||||||
|
def test_negative_float_truncates(self):
|
||||||
|
self.assertEqual(self.handler._to_int(-2.9), -2)
|
||||||
|
|
||||||
|
def test_string_int_parses(self):
|
||||||
|
self.assertEqual(self.handler._to_int("123"), 123)
|
||||||
|
|
||||||
|
def test_string_negative_parses(self):
|
||||||
|
self.assertEqual(self.handler._to_int("-456"), -456)
|
||||||
|
|
||||||
|
def test_string_with_whitespace_fails(self):
|
||||||
|
# Python's int() handles whitespace, but let's verify
|
||||||
|
self.assertEqual(self.handler._to_int(" 42 "), 42)
|
||||||
|
|
||||||
|
def test_string_float_fails(self):
|
||||||
|
# "3.14" can't be parsed by int()
|
||||||
|
self.assertIsNone(self.handler._to_int("3.14"))
|
||||||
|
|
||||||
|
def test_empty_string_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._to_int(""))
|
||||||
|
|
||||||
|
def test_non_numeric_string_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._to_int("abc"))
|
||||||
|
|
||||||
|
def test_list_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._to_int([1, 2, 3]))
|
||||||
|
|
||||||
|
def test_dict_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._to_int({"value": 42}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSumOptionalInts(unittest.TestCase):
|
||||||
|
"""Tests for _sum_optional_ints edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyParsingHandler()
|
||||||
|
|
||||||
|
def test_empty_list_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._sum_optional_ints([]))
|
||||||
|
|
||||||
|
def test_all_none_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._sum_optional_ints([None, None, None]))
|
||||||
|
|
||||||
|
def test_single_int_returns_that_int(self):
|
||||||
|
self.assertEqual(self.handler._sum_optional_ints([42]), 42)
|
||||||
|
|
||||||
|
def test_mixed_none_and_int_sums_ints(self):
|
||||||
|
self.assertEqual(self.handler._sum_optional_ints([None, 10, None, 20]), 30)
|
||||||
|
|
||||||
|
def test_all_ints_sums_all(self):
|
||||||
|
self.assertEqual(self.handler._sum_optional_ints([1, 2, 3, 4]), 10)
|
||||||
|
|
||||||
|
def test_includes_zero(self):
|
||||||
|
self.assertEqual(self.handler._sum_optional_ints([0, 5]), 5)
|
||||||
|
|
||||||
|
def test_negative_ints(self):
|
||||||
|
self.assertEqual(self.handler._sum_optional_ints([10, -3, 5]), 12)
|
||||||
|
|
||||||
|
def test_floats_ignored(self):
|
||||||
|
# Only integers are summed
|
||||||
|
self.assertEqual(self.handler._sum_optional_ints([10, 3.14, 5]), 15)
|
||||||
|
|
||||||
|
def test_strings_ignored(self):
|
||||||
|
self.assertEqual(self.handler._sum_optional_ints(["10", 5]), 5)
|
||||||
|
|
||||||
|
def test_only_non_ints_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._sum_optional_ints(["10", 3.14, None]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsDict(unittest.TestCase):
|
||||||
|
"""Tests for _as_dict edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyParsingHandler()
|
||||||
|
|
||||||
|
def test_dict_returns_dict(self):
|
||||||
|
self.assertEqual(self.handler._as_dict({"key": "value"}), {"key": "value"})
|
||||||
|
|
||||||
|
def test_empty_dict_returns_empty_dict(self):
|
||||||
|
self.assertEqual(self.handler._as_dict({}), {})
|
||||||
|
|
||||||
|
def test_none_returns_empty_dict(self):
|
||||||
|
self.assertEqual(self.handler._as_dict(None), {})
|
||||||
|
|
||||||
|
def test_list_returns_empty_dict(self):
|
||||||
|
self.assertEqual(self.handler._as_dict([1, 2, 3]), {})
|
||||||
|
|
||||||
|
def test_string_returns_empty_dict(self):
|
||||||
|
self.assertEqual(self.handler._as_dict("not a dict"), {})
|
||||||
|
|
||||||
|
def test_int_returns_empty_dict(self):
|
||||||
|
self.assertEqual(self.handler._as_dict(42), {})
|
||||||
|
|
||||||
|
def test_bool_returns_empty_dict(self):
|
||||||
|
self.assertEqual(self.handler._as_dict(True), {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetClaudeContextWindow(unittest.TestCase):
|
||||||
|
"""Tests for _get_claude_context_window edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyParsingHandler()
|
||||||
|
|
||||||
|
def test_none_model_returns_200k(self):
|
||||||
|
self.assertEqual(self.handler._get_claude_context_window(None), 200_000)
|
||||||
|
|
||||||
|
def test_empty_string_returns_200k(self):
|
||||||
|
self.assertEqual(self.handler._get_claude_context_window(""), 200_000)
|
||||||
|
|
||||||
|
def test_claude_2_returns_100k(self):
|
||||||
|
self.assertEqual(self.handler._get_claude_context_window("claude-2"), 100_000)
|
||||||
|
|
||||||
|
def test_claude_2_1_returns_100k(self):
|
||||||
|
self.assertEqual(self.handler._get_claude_context_window("claude-2.1"), 100_000)
|
||||||
|
|
||||||
|
def test_claude_3_returns_200k(self):
|
||||||
|
self.assertEqual(self.handler._get_claude_context_window("claude-3-opus-20240229"), 200_000)
|
||||||
|
|
||||||
|
def test_claude_35_returns_200k(self):
|
||||||
|
self.assertEqual(self.handler._get_claude_context_window("claude-3-5-sonnet-20241022"), 200_000)
|
||||||
|
|
||||||
|
def test_unknown_model_returns_200k(self):
|
||||||
|
self.assertEqual(self.handler._get_claude_context_window("some-future-model"), 200_000)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetClaudeConversationFile(unittest.TestCase):
|
||||||
|
"""Tests for _get_claude_conversation_file edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyParsingHandler()
|
||||||
|
|
||||||
|
def test_empty_project_dir_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._get_claude_conversation_file("session123", ""))
|
||||||
|
|
||||||
|
def test_none_project_dir_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._get_claude_conversation_file("session123", None))
|
||||||
|
|
||||||
|
def test_nonexistent_file_returns_none(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
with patch("amc_server.mixins.parsing.CLAUDE_PROJECTS_DIR", Path(tmpdir)):
|
||||||
|
result = self.handler._get_claude_conversation_file("session123", "/some/project")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_existing_file_returns_path(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# Create the expected file structure
|
||||||
|
# project_dir "/foo/bar" becomes "-foo-bar"
|
||||||
|
encoded_dir = Path(tmpdir) / "-foo-bar"
|
||||||
|
encoded_dir.mkdir()
|
||||||
|
conv_file = encoded_dir / "session123.jsonl"
|
||||||
|
conv_file.write_text('{"type": "user"}\n')
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.parsing.CLAUDE_PROJECTS_DIR", Path(tmpdir)):
|
||||||
|
result = self.handler._get_claude_conversation_file("session123", "/foo/bar")
|
||||||
|
self.assertEqual(result, conv_file)
|
||||||
|
|
||||||
|
def test_project_dir_without_leading_slash_gets_prefixed(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
# project_dir "foo/bar" becomes "-foo-bar" (adds leading dash)
|
||||||
|
encoded_dir = Path(tmpdir) / "-foo-bar"
|
||||||
|
encoded_dir.mkdir()
|
||||||
|
conv_file = encoded_dir / "session123.jsonl"
|
||||||
|
conv_file.write_text('{"type": "user"}\n')
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.parsing.CLAUDE_PROJECTS_DIR", Path(tmpdir)):
|
||||||
|
result = self.handler._get_claude_conversation_file("session123", "foo/bar")
|
||||||
|
self.assertEqual(result, conv_file)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindCodexTranscriptFile(unittest.TestCase):
|
||||||
|
"""Tests for _find_codex_transcript_file edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyParsingHandler()
|
||||||
|
|
||||||
|
def test_empty_session_id_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._find_codex_transcript_file(""))
|
||||||
|
|
||||||
|
def test_none_session_id_returns_none(self):
|
||||||
|
self.assertIsNone(self.handler._find_codex_transcript_file(None))
|
||||||
|
|
||||||
|
def test_codex_sessions_dir_missing_returns_none(self):
|
||||||
|
with patch("amc_server.mixins.parsing.CODEX_SESSIONS_DIR", Path("/nonexistent")):
|
||||||
|
# Clear cache to force discovery
|
||||||
|
from amc_server.context import _codex_transcript_cache
|
||||||
|
_codex_transcript_cache.clear()
|
||||||
|
result = self.handler._find_codex_transcript_file("abc123")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_cache_hit_returns_cached_path(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
transcript_file = Path(tmpdir) / "abc123.jsonl"
|
||||||
|
transcript_file.write_text('{"type": "session_meta"}\n')
|
||||||
|
|
||||||
|
from amc_server.context import _codex_transcript_cache
|
||||||
|
_codex_transcript_cache["abc123"] = str(transcript_file)
|
||||||
|
|
||||||
|
result = self.handler._find_codex_transcript_file("abc123")
|
||||||
|
self.assertEqual(result, transcript_file)
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
_codex_transcript_cache.clear()
|
||||||
|
|
||||||
|
def test_cache_hit_with_deleted_file_returns_none(self):
|
||||||
|
from amc_server.context import _codex_transcript_cache
|
||||||
|
_codex_transcript_cache["deleted-session"] = "/nonexistent/file.jsonl"
|
||||||
|
|
||||||
|
result = self.handler._find_codex_transcript_file("deleted-session")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
_codex_transcript_cache.clear()
|
||||||
|
|
||||||
|
def test_cache_hit_with_none_returns_none(self):
|
||||||
|
from amc_server.context import _codex_transcript_cache
|
||||||
|
_codex_transcript_cache["cached-none"] = None
|
||||||
|
|
||||||
|
result = self.handler._find_codex_transcript_file("cached-none")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
_codex_transcript_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadJsonlTailEntries(unittest.TestCase):
|
||||||
|
"""Tests for _read_jsonl_tail_entries edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyParsingHandler()
|
||||||
|
|
||||||
|
def test_empty_file_returns_empty_list(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._read_jsonl_tail_entries(path)
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_nonexistent_file_returns_empty_list(self):
|
||||||
|
result = self.handler._read_jsonl_tail_entries(Path("/nonexistent/file.jsonl"))
|
||||||
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
|
def test_single_line_file(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('{"key": "value"}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._read_jsonl_tail_entries(path)
|
||||||
|
self.assertEqual(result, [{"key": "value"}])
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_max_lines_limits_output(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
for i in range(100):
|
||||||
|
f.write(f'{{"n": {i}}}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._read_jsonl_tail_entries(path, max_lines=10)
|
||||||
|
self.assertEqual(len(result), 10)
|
||||||
|
# Should be the LAST 10 lines
|
||||||
|
self.assertEqual(result[-1], {"n": 99})
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_max_bytes_truncates_from_start(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
# Write many lines
|
||||||
|
for i in range(100):
|
||||||
|
f.write(f'{{"number": {i}}}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
# Read only last 200 bytes
|
||||||
|
result = self.handler._read_jsonl_tail_entries(path, max_bytes=200)
|
||||||
|
# Should get some entries from the end
|
||||||
|
self.assertGreater(len(result), 0)
|
||||||
|
# All entries should be from near the end
|
||||||
|
for entry in result:
|
||||||
|
self.assertGreater(entry["number"], 80)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_partial_first_line_skipped(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
# Write enough to trigger partial read
|
||||||
|
f.write('{"first": "line", "long_key": "' + "x" * 500 + '"}\n')
|
||||||
|
f.write('{"second": "line"}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
# Read only last 100 bytes (will cut first line)
|
||||||
|
result = self.handler._read_jsonl_tail_entries(path, max_bytes=100)
|
||||||
|
# First line should be skipped (partial JSON)
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0], {"second": "line"})
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_invalid_json_lines_skipped(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('{"valid": "json"}\n')
|
||||||
|
f.write('this is not json\n')
|
||||||
|
f.write('{"another": "valid"}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._read_jsonl_tail_entries(path)
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertEqual(result[0], {"valid": "json"})
|
||||||
|
self.assertEqual(result[1], {"another": "valid"})
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_empty_lines_skipped(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('{"first": 1}\n')
|
||||||
|
f.write('\n')
|
||||||
|
f.write('{"second": 2}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._read_jsonl_tail_entries(path)
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseClaudeContextUsageFromFile(unittest.TestCase):
|
||||||
|
"""Tests for _parse_claude_context_usage_from_file edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyParsingHandler()
|
||||||
|
|
||||||
|
def test_empty_file_returns_none(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._parse_claude_context_usage_from_file(path)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_no_assistant_messages_returns_none(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('{"type": "user", "message": {"content": "hello"}}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._parse_claude_context_usage_from_file(path)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_assistant_without_usage_returns_none(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('{"type": "assistant", "message": {"content": []}}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._parse_claude_context_usage_from_file(path)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_extracts_usage_from_assistant_message(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "assistant",
|
||||||
|
"timestamp": "2024-01-01T00:00:00Z",
|
||||||
|
"message": {
|
||||||
|
"model": "claude-3-5-sonnet-20241022",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 1000,
|
||||||
|
"output_tokens": 500,
|
||||||
|
"cache_read_input_tokens": 200,
|
||||||
|
"cache_creation_input_tokens": 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._parse_claude_context_usage_from_file(path)
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result["input_tokens"], 1000)
|
||||||
|
self.assertEqual(result["output_tokens"], 500)
|
||||||
|
self.assertEqual(result["cached_input_tokens"], 300) # 200 + 100
|
||||||
|
self.assertEqual(result["current_tokens"], 1800) # sum of all
|
||||||
|
self.assertEqual(result["window_tokens"], 200_000)
|
||||||
|
self.assertEqual(result["model"], "claude-3-5-sonnet-20241022")
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_uses_most_recent_assistant_message(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {"usage": {"input_tokens": 100, "output_tokens": 50}}
|
||||||
|
}) + "\n")
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {"usage": {"input_tokens": 999, "output_tokens": 888}}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._parse_claude_context_usage_from_file(path)
|
||||||
|
# Should use the last message
|
||||||
|
self.assertEqual(result["input_tokens"], 999)
|
||||||
|
self.assertEqual(result["output_tokens"], 888)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_skips_assistant_with_no_current_tokens(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
# Last message has no usable tokens
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {"usage": {"input_tokens": 100, "output_tokens": 50}}
|
||||||
|
}) + "\n")
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "assistant",
|
||||||
|
"message": {"usage": {}} # No tokens
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._parse_claude_context_usage_from_file(path)
|
||||||
|
# Should fall back to earlier message with valid tokens
|
||||||
|
self.assertEqual(result["input_tokens"], 100)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseCodexContextUsageFromFile(unittest.TestCase):
|
||||||
|
"""Tests for _parse_codex_context_usage_from_file edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyParsingHandler()
|
||||||
|
|
||||||
|
def test_empty_file_returns_none(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._parse_codex_context_usage_from_file(path)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_no_token_count_events_returns_none(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('{"type": "response_item"}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._parse_codex_context_usage_from_file(path)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_extracts_token_count_event(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "event_msg",
|
||||||
|
"timestamp": "2024-01-01T00:00:00Z",
|
||||||
|
"payload": {
|
||||||
|
"type": "token_count",
|
||||||
|
"info": {
|
||||||
|
"model_context_window": 128000,
|
||||||
|
"last_token_usage": {
|
||||||
|
"input_tokens": 5000,
|
||||||
|
"output_tokens": 2000,
|
||||||
|
"cached_input_tokens": 1000,
|
||||||
|
"total_tokens": 8000,
|
||||||
|
},
|
||||||
|
"total_token_usage": {
|
||||||
|
"total_tokens": 50000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._parse_codex_context_usage_from_file(path)
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual(result["window_tokens"], 128000)
|
||||||
|
self.assertEqual(result["current_tokens"], 8000)
|
||||||
|
self.assertEqual(result["input_tokens"], 5000)
|
||||||
|
self.assertEqual(result["output_tokens"], 2000)
|
||||||
|
self.assertEqual(result["session_total_tokens"], 50000)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_calculates_current_tokens_when_total_missing(self):
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write(json.dumps({
|
||||||
|
"type": "event_msg",
|
||||||
|
"payload": {
|
||||||
|
"type": "token_count",
|
||||||
|
"info": {
|
||||||
|
"last_token_usage": {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"output_tokens": 50,
|
||||||
|
# no total_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) + "\n")
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._parse_codex_context_usage_from_file(path)
|
||||||
|
# Should sum available tokens
|
||||||
|
self.assertEqual(result["current_tokens"], 150)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCachedContextUsage(unittest.TestCase):
|
||||||
|
"""Tests for _get_cached_context_usage edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyParsingHandler()
|
||||||
|
# Clear cache before each test
|
||||||
|
from amc_server.context import _context_usage_cache
|
||||||
|
_context_usage_cache.clear()
|
||||||
|
|
||||||
|
def test_nonexistent_file_returns_none(self):
|
||||||
|
def mock_parser(path):
|
||||||
|
return {"tokens": 100}
|
||||||
|
|
||||||
|
result = self.handler._get_cached_context_usage(
|
||||||
|
Path("/nonexistent/file.jsonl"),
|
||||||
|
mock_parser
|
||||||
|
)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_caches_result_by_mtime_and_size(self):
|
||||||
|
call_count = [0]
|
||||||
|
def counting_parser(path):
|
||||||
|
call_count[0] += 1
|
||||||
|
return {"tokens": 100}
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('{"data": "test"}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
# First call - should invoke parser
|
||||||
|
result1 = self.handler._get_cached_context_usage(path, counting_parser)
|
||||||
|
self.assertEqual(call_count[0], 1)
|
||||||
|
self.assertEqual(result1, {"tokens": 100})
|
||||||
|
|
||||||
|
# Second call - should use cache
|
||||||
|
result2 = self.handler._get_cached_context_usage(path, counting_parser)
|
||||||
|
self.assertEqual(call_count[0], 1) # No additional call
|
||||||
|
self.assertEqual(result2, {"tokens": 100})
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_invalidates_cache_on_mtime_change(self):
|
||||||
|
import time
|
||||||
|
|
||||||
|
call_count = [0]
|
||||||
|
def counting_parser(path):
|
||||||
|
call_count[0] += 1
|
||||||
|
return {"tokens": call_count[0] * 100}
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('{"data": "test"}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result1 = self.handler._get_cached_context_usage(path, counting_parser)
|
||||||
|
self.assertEqual(result1, {"tokens": 100})
|
||||||
|
|
||||||
|
# Modify file to change mtime
|
||||||
|
time.sleep(0.01)
|
||||||
|
path.write_text('{"data": "modified"}\n')
|
||||||
|
|
||||||
|
result2 = self.handler._get_cached_context_usage(path, counting_parser)
|
||||||
|
self.assertEqual(call_count[0], 2) # Parser called again
|
||||||
|
self.assertEqual(result2, {"tokens": 200})
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
def test_parser_exception_returns_none(self):
|
||||||
|
def failing_parser(path):
|
||||||
|
raise ValueError("Parse error")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
|
||||||
|
f.write('{"data": "test"}\n')
|
||||||
|
path = Path(f.name)
|
||||||
|
try:
|
||||||
|
result = self.handler._get_cached_context_usage(path, failing_parser)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
finally:
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
import amc_server.mixins.state as state_mod
|
import amc_server.mixins.state as state_mod
|
||||||
from amc_server.mixins.state import StateMixin
|
from amc_server.mixins.state import StateMixin
|
||||||
|
from amc_server.mixins.parsing import SessionParsingMixin
|
||||||
|
from amc_server.mixins.discovery import SessionDiscoveryMixin
|
||||||
|
|
||||||
|
|
||||||
class DummyStateHandler(StateMixin):
|
class DummyStateHandler(StateMixin, SessionParsingMixin, SessionDiscoveryMixin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class StateMixinTests(unittest.TestCase):
|
class TestGetActiveZellijSessions(unittest.TestCase):
|
||||||
def test_get_active_zellij_sessions_uses_resolved_binary_and_parses_output(self):
|
"""Tests for _get_active_zellij_sessions edge cases."""
|
||||||
handler = DummyStateHandler()
|
|
||||||
|
def setUp(self):
|
||||||
state_mod._zellij_cache["sessions"] = None
|
state_mod._zellij_cache["sessions"] = None
|
||||||
state_mod._zellij_cache["expires"] = 0
|
state_mod._zellij_cache["expires"] = 0
|
||||||
|
|
||||||
|
def test_parses_output_with_metadata(self):
|
||||||
|
handler = DummyStateHandler()
|
||||||
completed = subprocess.CompletedProcess(
|
completed = subprocess.CompletedProcess(
|
||||||
args=[],
|
args=[],
|
||||||
returncode=0,
|
returncode=0,
|
||||||
@@ -23,15 +32,389 @@ class StateMixinTests(unittest.TestCase):
|
|||||||
stderr="",
|
stderr="",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch.object(state_mod, "ZELLIJ_BIN", "/opt/homebrew/bin/zellij"), patch(
|
with patch.object(state_mod, "ZELLIJ_BIN", "/opt/homebrew/bin/zellij"), \
|
||||||
"amc_server.mixins.state.subprocess.run", return_value=completed
|
patch("amc_server.mixins.state.subprocess.run", return_value=completed) as run_mock:
|
||||||
) as run_mock:
|
|
||||||
sessions = handler._get_active_zellij_sessions()
|
sessions = handler._get_active_zellij_sessions()
|
||||||
|
|
||||||
self.assertEqual(sessions, {"infra", "work"})
|
self.assertEqual(sessions, {"infra", "work"})
|
||||||
args = run_mock.call_args.args[0]
|
args = run_mock.call_args.args[0]
|
||||||
self.assertEqual(args, ["/opt/homebrew/bin/zellij", "list-sessions", "--no-formatting"])
|
self.assertEqual(args, ["/opt/homebrew/bin/zellij", "list-sessions", "--no-formatting"])
|
||||||
|
|
||||||
|
def test_empty_output_returns_empty_set(self):
|
||||||
|
handler = DummyStateHandler()
|
||||||
|
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
|
||||||
|
sessions = handler._get_active_zellij_sessions()
|
||||||
|
|
||||||
|
self.assertEqual(sessions, set())
|
||||||
|
|
||||||
|
def test_whitespace_only_lines_ignored(self):
|
||||||
|
handler = DummyStateHandler()
|
||||||
|
completed = subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=0, stdout="session1\n \n\nsession2\n", stderr=""
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
|
||||||
|
sessions = handler._get_active_zellij_sessions()
|
||||||
|
|
||||||
|
self.assertEqual(sessions, {"session1", "session2"})
|
||||||
|
|
||||||
|
def test_nonzero_exit_returns_none(self):
|
||||||
|
handler = DummyStateHandler()
|
||||||
|
completed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="error")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
|
||||||
|
sessions = handler._get_active_zellij_sessions()
|
||||||
|
|
||||||
|
self.assertIsNone(sessions)
|
||||||
|
|
||||||
|
def test_timeout_returns_none(self):
|
||||||
|
handler = DummyStateHandler()
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.state.subprocess.run",
|
||||||
|
side_effect=subprocess.TimeoutExpired("cmd", 2)):
|
||||||
|
sessions = handler._get_active_zellij_sessions()
|
||||||
|
|
||||||
|
self.assertIsNone(sessions)
|
||||||
|
|
||||||
|
def test_file_not_found_returns_none(self):
|
||||||
|
handler = DummyStateHandler()
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.state.subprocess.run", side_effect=FileNotFoundError()):
|
||||||
|
sessions = handler._get_active_zellij_sessions()
|
||||||
|
|
||||||
|
self.assertIsNone(sessions)
|
||||||
|
|
||||||
|
def test_cache_used_when_fresh(self):
|
||||||
|
handler = DummyStateHandler()
|
||||||
|
state_mod._zellij_cache["sessions"] = {"cached"}
|
||||||
|
state_mod._zellij_cache["expires"] = time.time() + 100
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.state.subprocess.run") as mock_run:
|
||||||
|
sessions = handler._get_active_zellij_sessions()
|
||||||
|
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
self.assertEqual(sessions, {"cached"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsSessionDead(unittest.TestCase):
|
||||||
|
"""Tests for _is_session_dead edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyStateHandler()
|
||||||
|
|
||||||
|
def test_starting_session_not_dead(self):
|
||||||
|
session = {"status": "starting", "agent": "claude", "zellij_session": "s"}
|
||||||
|
self.assertFalse(self.handler._is_session_dead(session, {"s"}, set()))
|
||||||
|
|
||||||
|
def test_claude_without_zellij_session_is_dead(self):
|
||||||
|
session = {"status": "active", "agent": "claude", "zellij_session": ""}
|
||||||
|
self.assertTrue(self.handler._is_session_dead(session, set(), set()))
|
||||||
|
|
||||||
|
def test_claude_with_missing_zellij_session_is_dead(self):
|
||||||
|
session = {"status": "active", "agent": "claude", "zellij_session": "deleted"}
|
||||||
|
active_zellij = {"other_session"}
|
||||||
|
self.assertTrue(self.handler._is_session_dead(session, active_zellij, set()))
|
||||||
|
|
||||||
|
def test_claude_with_active_zellij_session_not_dead(self):
|
||||||
|
session = {"status": "active", "agent": "claude", "zellij_session": "existing"}
|
||||||
|
active_zellij = {"existing", "other"}
|
||||||
|
self.assertFalse(self.handler._is_session_dead(session, active_zellij, set()))
|
||||||
|
|
||||||
|
def test_claude_unknown_zellij_status_assumes_alive(self):
|
||||||
|
# When we can't query zellij (None), assume alive to avoid false positives
|
||||||
|
session = {"status": "active", "agent": "claude", "zellij_session": "unknown"}
|
||||||
|
self.assertFalse(self.handler._is_session_dead(session, None, set()))
|
||||||
|
|
||||||
|
def test_codex_without_transcript_path_is_dead(self):
|
||||||
|
session = {"status": "active", "agent": "codex", "transcript_path": ""}
|
||||||
|
self.assertTrue(self.handler._is_session_dead(session, None, set()))
|
||||||
|
|
||||||
|
def test_codex_with_active_transcript_not_dead(self):
|
||||||
|
session = {"status": "active", "agent": "codex", "transcript_path": "/path/to/file.jsonl"}
|
||||||
|
active_files = {"/path/to/file.jsonl"}
|
||||||
|
self.assertFalse(self.handler._is_session_dead(session, None, active_files))
|
||||||
|
|
||||||
|
def test_codex_without_active_transcript_checks_lsof(self):
|
||||||
|
session = {"status": "active", "agent": "codex", "transcript_path": "/path/to/file.jsonl"}
|
||||||
|
|
||||||
|
# Simulate lsof finding the file open
|
||||||
|
with patch.object(self.handler, "_is_file_open", return_value=True):
|
||||||
|
result = self.handler._is_session_dead(session, None, set())
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
# Simulate lsof not finding the file
|
||||||
|
with patch.object(self.handler, "_is_file_open", return_value=False):
|
||||||
|
result = self.handler._is_session_dead(session, None, set())
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
def test_unknown_agent_assumes_alive(self):
|
||||||
|
session = {"status": "active", "agent": "unknown_agent"}
|
||||||
|
self.assertFalse(self.handler._is_session_dead(session, None, set()))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsFileOpen(unittest.TestCase):
|
||||||
|
"""Tests for _is_file_open edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyStateHandler()
|
||||||
|
|
||||||
|
def test_lsof_finds_pid_returns_true(self):
|
||||||
|
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="12345\n", stderr="")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
|
||||||
|
result = self.handler._is_file_open("/some/file.jsonl")
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
def test_lsof_no_result_returns_false(self):
|
||||||
|
completed = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.state.subprocess.run", return_value=completed):
|
||||||
|
result = self.handler._is_file_open("/some/file.jsonl")
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_lsof_timeout_returns_false(self):
|
||||||
|
with patch("amc_server.mixins.state.subprocess.run",
|
||||||
|
side_effect=subprocess.TimeoutExpired("cmd", 2)):
|
||||||
|
result = self.handler._is_file_open("/some/file.jsonl")
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
def test_lsof_not_found_returns_false(self):
|
||||||
|
with patch("amc_server.mixins.state.subprocess.run", side_effect=FileNotFoundError()):
|
||||||
|
result = self.handler._is_file_open("/some/file.jsonl")
|
||||||
|
|
||||||
|
self.assertFalse(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupStale(unittest.TestCase):
|
||||||
|
"""Tests for _cleanup_stale edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyStateHandler()
|
||||||
|
|
||||||
|
def test_removes_orphan_event_logs_older_than_24h(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
events_dir.mkdir()
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
# Create an orphan event log (no matching session)
|
||||||
|
orphan_log = events_dir / "orphan.jsonl"
|
||||||
|
orphan_log.write_text('{"event": "test"}\n')
|
||||||
|
# Set mtime to 25 hours ago
|
||||||
|
old_time = time.time() - (25 * 3600)
|
||||||
|
import os
|
||||||
|
os.utime(orphan_log, (old_time, old_time))
|
||||||
|
|
||||||
|
with patch.object(state_mod, "EVENTS_DIR", events_dir), \
|
||||||
|
patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._cleanup_stale([]) # No active sessions
|
||||||
|
|
||||||
|
self.assertFalse(orphan_log.exists())
|
||||||
|
|
||||||
|
def test_keeps_orphan_event_logs_younger_than_24h(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
events_dir.mkdir()
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
# Create a recent orphan event log
|
||||||
|
recent_log = events_dir / "recent.jsonl"
|
||||||
|
recent_log.write_text('{"event": "test"}\n')
|
||||||
|
|
||||||
|
with patch.object(state_mod, "EVENTS_DIR", events_dir), \
|
||||||
|
patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._cleanup_stale([])
|
||||||
|
|
||||||
|
self.assertTrue(recent_log.exists())
|
||||||
|
|
||||||
|
def test_keeps_event_logs_with_active_session(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
events_dir.mkdir()
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
# Create an old event log that HAS an active session
|
||||||
|
event_log = events_dir / "active-session.jsonl"
|
||||||
|
event_log.write_text('{"event": "test"}\n')
|
||||||
|
old_time = time.time() - (25 * 3600)
|
||||||
|
import os
|
||||||
|
os.utime(event_log, (old_time, old_time))
|
||||||
|
|
||||||
|
sessions = [{"session_id": "active-session"}]
|
||||||
|
|
||||||
|
with patch.object(state_mod, "EVENTS_DIR", events_dir), \
|
||||||
|
patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._cleanup_stale(sessions)
|
||||||
|
|
||||||
|
self.assertTrue(event_log.exists())
|
||||||
|
|
||||||
|
def test_removes_stale_starting_sessions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
events_dir.mkdir()
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
# Create a stale "starting" session
|
||||||
|
stale_session = sessions_dir / "stale.json"
|
||||||
|
stale_session.write_text(json.dumps({"status": "starting"}))
|
||||||
|
# Set mtime to 2 hours ago (> 1 hour threshold)
|
||||||
|
old_time = time.time() - (2 * 3600)
|
||||||
|
import os
|
||||||
|
os.utime(stale_session, (old_time, old_time))
|
||||||
|
|
||||||
|
with patch.object(state_mod, "EVENTS_DIR", events_dir), \
|
||||||
|
patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._cleanup_stale([])
|
||||||
|
|
||||||
|
self.assertFalse(stale_session.exists())
|
||||||
|
|
||||||
|
def test_keeps_stale_active_sessions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
events_dir.mkdir()
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
|
# Create an old "active" session (should NOT be deleted)
|
||||||
|
active_session = sessions_dir / "active.json"
|
||||||
|
active_session.write_text(json.dumps({"status": "active"}))
|
||||||
|
old_time = time.time() - (2 * 3600)
|
||||||
|
import os
|
||||||
|
os.utime(active_session, (old_time, old_time))
|
||||||
|
|
||||||
|
with patch.object(state_mod, "EVENTS_DIR", events_dir), \
|
||||||
|
patch.object(state_mod, "SESSIONS_DIR", sessions_dir):
|
||||||
|
self.handler._cleanup_stale([])
|
||||||
|
|
||||||
|
self.assertTrue(active_session.exists())
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectSessions(unittest.TestCase):
|
||||||
|
"""Tests for _collect_sessions edge cases."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = DummyStateHandler()
|
||||||
|
|
||||||
|
def test_invalid_json_session_file_skipped(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
events_dir.mkdir()
|
||||||
|
|
||||||
|
# Create an invalid JSON file
|
||||||
|
bad_file = sessions_dir / "bad.json"
|
||||||
|
bad_file.write_text("not json")
|
||||||
|
|
||||||
|
# Create a valid session
|
||||||
|
good_file = sessions_dir / "good.json"
|
||||||
|
good_file.write_text(json.dumps({
|
||||||
|
"session_id": "good",
|
||||||
|
"agent": "claude",
|
||||||
|
"status": "active",
|
||||||
|
"last_event_at": "2024-01-01T00:00:00Z",
|
||||||
|
}))
|
||||||
|
|
||||||
|
with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
|
||||||
|
patch.object(state_mod, "EVENTS_DIR", events_dir), \
|
||||||
|
patch.object(self.handler, "_discover_active_codex_sessions"), \
|
||||||
|
patch.object(self.handler, "_get_active_zellij_sessions", return_value=None), \
|
||||||
|
patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
|
||||||
|
patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
|
||||||
|
patch.object(self.handler, "_get_conversation_mtime", return_value=None):
|
||||||
|
sessions = self.handler._collect_sessions()
|
||||||
|
|
||||||
|
# Should only get the good session
|
||||||
|
self.assertEqual(len(sessions), 1)
|
||||||
|
self.assertEqual(sessions[0]["session_id"], "good")
|
||||||
|
|
||||||
|
def test_non_dict_session_file_skipped(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
events_dir.mkdir()
|
||||||
|
|
||||||
|
# Create a file with array instead of dict
|
||||||
|
array_file = sessions_dir / "array.json"
|
||||||
|
array_file.write_text("[1, 2, 3]")
|
||||||
|
|
||||||
|
with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
|
||||||
|
patch.object(state_mod, "EVENTS_DIR", events_dir), \
|
||||||
|
patch.object(self.handler, "_discover_active_codex_sessions"), \
|
||||||
|
patch.object(self.handler, "_get_active_zellij_sessions", return_value=None), \
|
||||||
|
patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
|
||||||
|
patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
|
||||||
|
patch.object(self.handler, "_get_conversation_mtime", return_value=None):
|
||||||
|
sessions = self.handler._collect_sessions()
|
||||||
|
|
||||||
|
self.assertEqual(len(sessions), 0)
|
||||||
|
|
||||||
|
def test_orphan_starting_session_deleted(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
events_dir.mkdir()
|
||||||
|
|
||||||
|
# Create a starting session with a non-existent zellij session
|
||||||
|
orphan_file = sessions_dir / "orphan.json"
|
||||||
|
orphan_file.write_text(json.dumps({
|
||||||
|
"session_id": "orphan",
|
||||||
|
"status": "starting",
|
||||||
|
"zellij_session": "deleted_session",
|
||||||
|
}))
|
||||||
|
|
||||||
|
active_zellij = {"other_session"} # orphan's session not in here
|
||||||
|
|
||||||
|
with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
|
||||||
|
patch.object(state_mod, "EVENTS_DIR", events_dir), \
|
||||||
|
patch.object(self.handler, "_discover_active_codex_sessions"), \
|
||||||
|
patch.object(self.handler, "_get_active_zellij_sessions", return_value=active_zellij), \
|
||||||
|
patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
|
||||||
|
patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
|
||||||
|
patch.object(self.handler, "_get_conversation_mtime", return_value=None):
|
||||||
|
sessions = self.handler._collect_sessions()
|
||||||
|
|
||||||
|
# Orphan should be deleted and not in results
|
||||||
|
self.assertEqual(len(sessions), 0)
|
||||||
|
self.assertFalse(orphan_file.exists())
|
||||||
|
|
||||||
|
def test_sessions_sorted_by_id(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sessions_dir = Path(tmpdir) / "sessions"
|
||||||
|
sessions_dir.mkdir()
|
||||||
|
events_dir = Path(tmpdir) / "events"
|
||||||
|
events_dir.mkdir()
|
||||||
|
|
||||||
|
for sid in ["zebra", "alpha", "middle"]:
|
||||||
|
(sessions_dir / f"{sid}.json").write_text(json.dumps({
|
||||||
|
"session_id": sid,
|
||||||
|
"status": "active",
|
||||||
|
"last_event_at": "2024-01-01T00:00:00Z",
|
||||||
|
}))
|
||||||
|
|
||||||
|
with patch.object(state_mod, "SESSIONS_DIR", sessions_dir), \
|
||||||
|
patch.object(state_mod, "EVENTS_DIR", events_dir), \
|
||||||
|
patch.object(self.handler, "_discover_active_codex_sessions"), \
|
||||||
|
patch.object(self.handler, "_get_active_zellij_sessions", return_value=None), \
|
||||||
|
patch.object(self.handler, "_get_active_transcript_files", return_value=set()), \
|
||||||
|
patch.object(self.handler, "_get_context_usage_for_session", return_value=None), \
|
||||||
|
patch.object(self.handler, "_get_conversation_mtime", return_value=None):
|
||||||
|
sessions = self.handler._collect_sessions()
|
||||||
|
|
||||||
|
ids = [s["session_id"] for s in sessions]
|
||||||
|
self.assertEqual(ids, ["alpha", "middle", "zebra"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user