Files
EvoScientist-Multi/tests/test_langgraph_manager.py
T
dinos 92d95dee68 feat(memory): add observation memory lifecycle (#259)
* feat(memory): add observation memory lifecycle

Add file-backed observation memory with deterministic markdown records,
structured record_observation tooling, startup indexing, and
profile/observation prompt guidance.

Launch post-turn and post-subagent EvoMemory workers through LangGraph
dev so completed runs can update profile memory, save durable
observations, and write subagent execution summaries without blocking
the active agent.

Wire memory middleware into the main agent, subagents, async graphs, TUI
status reporting, worker activity accounting, and observation-aware
research prompts, with regression coverage for storage, lifecycle
scheduling, graph registration, status display, and stream reset
behavior.

* fix(cli): sync background agent server on resume

Resume flows now need to keep the LangGraph dev background server
aligned with the active workspace even when async subagents are
disabled. EvoMemory workers use that server too, so gating resume-time
sync on enable_async_subagents could leave workers pinned to the launch
workspace after resuming a thread from another workspace.

Run workspace sync unconditionally for Rich CLI and Textual resume
paths, while preserving WorkspaceMismatchError handling so failed sync
aborts the resume before mutating the active thread or workspace.

Propagate aborted resume callbacks through the command UI so
channel-issued /resume commands do not send false success or history
output. Channel slash dispatch now treats CommandManager-caught command
errors as command errors and skips completion hooks for those failed
commands.

Add regression coverage for disabled async subagents, callback aborts,
and channel command error reporting.

* fix(cli): prepare serve resume workspace before adopting

Load the resumed workspace agent and sync the background server as a
single pre-adoption step. Restore the previous active workspace if
preparation fails so serve mode keeps using the old session
consistently.

* fix(memory): untrack abandoned worker status watches

Stop treating watcher shutdown as confirmed worker completion. Terminal
worker statuses still count memory deltas, while poll failures or
watcher setup failures now remove the active run without crediting
partial outputs.

* fix(cli): report channel command failures accurately

Treat command_error as a None sentinel so empty error strings still
fail, and let TUI resumes continue only on non-mismatch
background-server sync failures while reporting degraded mode.

* fix(stream): clear memory counters for resume streams

Reset completed-memory counters for every new agent stream, including
Command-based HITL and resume streams, so saved-memory indicators do not
leak across turns.

* docs(tools): make observation recording guidance conditional

Clarify that agents should call record_observation only when the
observation tool is available, preserving the existing durability and
usefulness criteria.

* feat(config): add controls for profile and observation memory

Add config flags for profile memory, observation memory, observation
writer placement, and background memory workers.

Wire the controls through main agents, subagents, EvoMemory middleware,
and memory lifecycle workers so observation writes can be assigned to
the live agent, subagent worker, both, or neither. Keep turn memory
workers profile-only and make prompts reflect the available observation
read/write paths. Skip langgraph dev startup when neither async
subagents nor memory workers need the background server.

Add coverage for config parsing, prompt gating, middleware wiring, and
worker tool availability.

* test(cli): include memory defaults in serve config stubs

* fix(memory): offload async worker launch blocking calls

Run the langgraph-dev health check and memory-output snapshot in worker
threads from the async EvoMemory launcher so it does not block the event
loop.

* chore(memory): harden turn worker subagent guardrail

* chore(memory): refresh profile context per request

* fix(memory): offload async profile file reads

* fix(memory): offload async worker completion accounting
2026-06-05 15:11:20 +01:00

269 lines
11 KiB
Python

"""Happy-path tests for langgraph_dev.manager.
Mocks httpx, psutil, subprocess.Popen, and module-level state so the tests
run on CI without requiring the langgraph CLI to be installed or any port
to be available.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import httpx
import pytest
from EvoScientist.config.settings import EvoScientistConfig
from EvoScientist.langgraph_dev import manager
@pytest.fixture(autouse=True)
def reset_module_state():
"""Reset manager module globals before each test for isolation."""
manager._PROCESS = None
manager._PROCESS_WORKSPACE = None
manager._ASYNC_SUBAGENTS_AVAILABLE = False
yield
manager._PROCESS = None
manager._PROCESS_WORKSPACE = None
manager._ASYNC_SUBAGENTS_AVAILABLE = False
# =============================================================================
# is_langgraph_dev_running
# =============================================================================
class TestIsLanggraphDevRunning:
@patch("EvoScientist.langgraph_dev.manager.httpx.get")
def test_returns_false_on_connect_error(self, mock_get):
mock_get.side_effect = httpx.ConnectError("refused")
assert manager.is_langgraph_dev_running(port=6174) is False
@patch("EvoScientist.langgraph_dev.manager.httpx.get")
def test_returns_false_on_timeout(self, mock_get):
mock_get.side_effect = httpx.TimeoutException("slow")
assert manager.is_langgraph_dev_running(port=6174) is False
@patch("EvoScientist.langgraph_dev.manager.httpx.get")
def test_returns_true_on_200(self, mock_get):
mock_get.return_value = MagicMock(status_code=200)
assert manager.is_langgraph_dev_running(port=6174) is True
# Verify it probed /ok at the configured port.
called_url = mock_get.call_args[0][0]
assert called_url == "http://localhost:6174/ok"
@patch("EvoScientist.langgraph_dev.manager.httpx.get")
def test_returns_false_on_non_200(self, mock_get):
mock_get.return_value = MagicMock(status_code=503)
assert manager.is_langgraph_dev_running(port=6174) is False
# =============================================================================
# _list_pids_on_port
# =============================================================================
class TestListPidsOnPort:
@patch("EvoScientist.langgraph_dev.manager.psutil.net_connections")
def test_empty_when_no_connections(self, mock_net):
mock_net.return_value = []
assert manager._list_pids_on_port(6174) == []
@patch("EvoScientist.langgraph_dev.manager.psutil.net_connections")
def test_returns_pid_for_matching_port(self, mock_net):
mock_net.return_value = [
SimpleNamespace(laddr=SimpleNamespace(port=6174), pid=12345),
SimpleNamespace(laddr=SimpleNamespace(port=8080), pid=99999),
]
result = manager._list_pids_on_port(6174)
assert result == [12345]
@patch("EvoScientist.langgraph_dev.manager.psutil.net_connections")
def test_filters_none_pid(self, mock_net):
mock_net.return_value = [
SimpleNamespace(laddr=SimpleNamespace(port=6174), pid=None),
SimpleNamespace(laddr=SimpleNamespace(port=6174), pid=12345),
]
result = manager._list_pids_on_port(6174)
assert result == [12345]
def test_returns_empty_on_access_denied(self):
with patch.object(
manager.psutil,
"net_connections",
side_effect=manager.psutil.AccessDenied(),
):
assert manager._list_pids_on_port(6174) == []
# =============================================================================
# _kill_owned_stale_process
# =============================================================================
class TestKillOwnedStaleProcess:
def test_returns_false_if_no_pid_file(self, tmp_path):
with patch.object(manager, "_PID_FILE", tmp_path / "missing.pid"):
assert manager._kill_owned_stale_process(6174) is False
def test_returns_false_if_pid_file_unreadable(self, tmp_path):
pid_file = tmp_path / "bad.pid"
pid_file.write_text("not-a-number")
with patch.object(manager, "_PID_FILE", pid_file):
assert manager._kill_owned_stale_process(6174) is False
def test_returns_false_if_pid_not_in_occupiers(self, tmp_path):
pid_file = tmp_path / "lg.pid"
pid_file.write_text("12345")
with (
patch.object(manager, "_PID_FILE", pid_file),
patch.object(manager, "_list_pids_on_port", return_value=[99999]),
):
assert manager._kill_owned_stale_process(6174) is False
# PID file should be left intact — the port is held by someone
# else, not a stale ours.
assert pid_file.exists()
def test_refuses_to_kill_recycled_pid(self, tmp_path):
"""PID matches but cmdline doesn't contain 'langgraph' → don't kill."""
pid_file = tmp_path / "lg.pid"
pid_file.write_text("12345")
fake_proc = MagicMock()
fake_proc.cmdline.return_value = ["bash", "-c", "echo hi"]
with (
patch.object(manager, "_PID_FILE", pid_file),
patch.object(manager, "_list_pids_on_port", return_value=[12345]),
patch.object(manager.psutil, "Process", return_value=fake_proc),
):
assert manager._kill_owned_stale_process(6174) is False
fake_proc.kill.assert_not_called()
# PID file should be removed — the entry is stale (our process is
# gone, PID was recycled by an unrelated process).
assert not pid_file.exists()
def test_kills_when_cmdline_matches_langgraph(self, tmp_path):
"""Owned PID + cmdline contains 'langgraph' → kill + cleanup PID file."""
pid_file = tmp_path / "lg.pid"
pid_file.write_text("12345")
fake_proc = MagicMock()
fake_proc.cmdline.return_value = [
"/usr/bin/python",
"/usr/bin/langgraph",
"dev",
]
with (
patch.object(manager, "_PID_FILE", pid_file),
patch.object(manager, "_list_pids_on_port", return_value=[12345]),
patch.object(manager.psutil, "Process", return_value=fake_proc),
):
assert manager._kill_owned_stale_process(6174) is True
fake_proc.kill.assert_called_once()
assert not pid_file.exists()
def test_handles_dead_pid(self, tmp_path):
"""PID file claims a PID but the process is gone → cleanup PID file, no error."""
pid_file = tmp_path / "lg.pid"
pid_file.write_text("12345")
with (
patch.object(manager, "_PID_FILE", pid_file),
patch.object(manager, "_list_pids_on_port", return_value=[12345]),
patch.object(
manager.psutil,
"Process",
side_effect=manager.psutil.NoSuchProcess(12345),
),
):
assert manager._kill_owned_stale_process(6174) is False
assert not pid_file.exists()
# =============================================================================
# ensure_langgraph_dev — high-level orchestration
# =============================================================================
class TestEnsureLanggraphDev:
def test_starts_when_async_disabled_but_memory_workers_enabled(self, tmp_path):
"""EvoMemory workers can require langgraph dev even without async subagents."""
cfg = EvoScientistConfig()
cfg.enable_async_subagents = False
cfg.memory_workers_enabled = True
cfg.langgraph_dev_port = 6174
cfg.langgraph_dev_file_persistence = True
proc = MagicMock()
with (
patch.object(manager, "is_langgraph_dev_running", return_value=False),
patch.object(manager, "start_langgraph_dev", return_value=proc) as start,
patch.object(manager, "_FILE_LOCK_PATH", tmp_path / "lg.lock"),
patch.object(manager, "_PID_DIR", tmp_path / "pids"),
):
result = manager.ensure_langgraph_dev(cfg, workspace_dir=tmp_path)
assert result is proc
start.assert_called_once()
assert manager.is_async_subagents_available() is True
def test_skips_when_async_and_memory_workers_disabled(self, tmp_path):
"""No background server is needed without async subagents or workers."""
cfg = EvoScientistConfig()
cfg.enable_async_subagents = False
cfg.memory_workers_enabled = False
cfg.langgraph_dev_port = 6174
cfg.langgraph_dev_file_persistence = True
with (
patch.object(manager, "is_langgraph_dev_running") as mock_running,
patch.object(manager, "start_langgraph_dev") as start,
patch.object(manager, "_FILE_LOCK_PATH", tmp_path / "lg.lock"),
patch.object(manager, "_PID_DIR", tmp_path / "pids"),
):
result = manager.ensure_langgraph_dev(cfg, workspace_dir=tmp_path)
assert result is None
mock_running.assert_not_called()
start.assert_not_called()
assert manager.is_async_subagents_available() is False
def test_reuses_existing_healthy_subprocess(self, tmp_path):
"""When the subprocess is already running, no new Popen call."""
cfg = EvoScientistConfig()
cfg.enable_async_subagents = True
cfg.langgraph_dev_port = 6174
cfg.langgraph_dev_file_persistence = True
with (
patch.object(
manager, "is_langgraph_dev_running", return_value=True
) as mock_running,
patch.object(manager, "start_langgraph_dev") as mock_start,
patch.object(manager, "_FILE_LOCK_PATH", tmp_path / "lg.lock"),
# Isolate from real ``~/.config/evoscientist/`` — without this
# patch, the FileLock setup would mkdir the user's actual config
# dir as a test side-effect.
patch.object(manager, "_PID_DIR", tmp_path / "pids"),
):
result = manager.ensure_langgraph_dev(cfg, workspace_dir=tmp_path)
# We didn't spawn anything — there's already a healthy server.
mock_start.assert_not_called()
# Reuse path returns None (we don't own the existing process).
assert result is None
# is_async_subagents_available was flipped True.
assert manager.is_async_subagents_available() is True
# Health check was called at least once.
assert mock_running.called
# =============================================================================
# is_async_subagents_available — module state
# =============================================================================
class TestIsAsyncSubagentsAvailable:
def test_starts_false(self):
assert manager.is_async_subagents_available() is False
def test_reflects_module_state(self):
manager._ASYNC_SUBAGENTS_AVAILABLE = True
assert manager.is_async_subagents_available() is True
manager._ASYNC_SUBAGENTS_AVAILABLE = False
assert manager.is_async_subagents_available() is False