Files
Octop/tests/unit/agents/test_agent_manager.py
薄生 8b0793c69a feat(memory): add manual profile context and conflict review (#2103)
* feat(memory): add strong memory editor and surface task failures

* fix(memory): explain session snapshot lifetime in strong memory UI

* feat(memory): add owner profile editor and manual conflict review

* test: verify fault history, turn recovery and memory isolation

* fix: persist terminal failures and release stalled chat turns

* fix(deps): require released memory runtime packages

Require octop-memory 1.0.2, octop-harness 1.0.4 and octop-gateway 1.0.3 for manual document review and terminal stream cleanup. Remove the obsolete harness Git pin and refresh the lockfile so CI installs the matching released APIs.
2026-10-16 20:56:13 +08:00

2867 lines
103 KiB
Python

"""Unit tests for :mod:`octop.infra.agents.manager` internals."""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import threading
from collections.abc import AsyncIterator
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from octop.config import OctopConfig
from octop.i18n.domains.agents import NO_MODELS_CONFIGURED, format_agent_start_error
from octop.infra.agents.experts.catalog import default_library_root
from octop.infra.agents.manager import AgentManager, _memory_extract_settings
from octop.infra.backend.resolver import default_agent_backend_spec
from octop.infra.db.migrate import run_migrations
from octop.infra.db.pool import SqlitePool
from octop.infra.db.repos.agents import AgentRow
from octop.infra.db.services import build_shared_services
from octop.infra.errors import ErrorCode, OctopError
from octop.infra.utils.paths import PathLayout
# Real HarnessAgentManager starts memory maintenance (GC/vacuum) on a daemon
# thread. Closing the agent while that tick holds the SQLite handle races on
# Windows (access violation under pytest-xdist). Unit tests here only need
# workspace/config behavior — keep memory off.
_MEMORY_OFF: dict[str, Any] = {"memory": {"memory_enabled": False}}
async def _collect_async(iterator: AsyncIterator[Any]) -> list[Any]:
return [item async for item in iterator]
def _expected_default_backend(manager: AgentManager, agent_id: str) -> dict[str, Any]:
from octop.infra.agents.workspace.execute_env import inject_agent_execute_env
ws = manager._paths.ensure_agent_workspace(agent_id)
return inject_agent_execute_env(
default_agent_backend_spec(ws),
paths=manager.paths,
row=_row(agent_id=agent_id),
workspace_dir=ws,
)
@pytest.fixture
def manager(tmp_path: Path) -> AgentManager:
paths = PathLayout(tmp_path / ".octop")
paths.ensure_root()
db = SqlitePool(paths.db)
run_migrations(db)
services = build_shared_services(db=db, paths=paths, config=OctopConfig())
return AgentManager(repos=services.repos, paths=services.paths)
def _seed_test_provider(manager: AgentManager) -> None:
manager._repos.provider_repo.create(
name="test-openai",
kind="openai",
base_url="https://api.example.com/v1",
api_key="sk-test",
models_json=json.dumps(
[{"id": "gpt-4o-mini", "name": "gpt-4o-mini", "enabled": True}],
),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("cancel_review", [False, True])
async def test_manual_review_serializes_profile_edits_without_changing_session(
manager: AgentManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cancel_review: bool
) -> None:
from deepagents.backends import FilesystemBackend
from octop_harness.backends.workspace import BackendWorkspace
ws = BackendWorkspace(FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True), tmp_path)
ws.write_text("USER.md", "Original profile", force=True)
entered, release = threading.Event(), threading.Event()
def review(documents: dict[str, str], *, is_current: Any) -> dict[str, Any]:
entered.set()
assert release.wait(timeout=3)
assert documents["USER.md"] == "Original profile" and is_current()
return {"status": "complete"}
service = SimpleNamespace(reconcile_manual_documents=review)
agent = SimpleNamespace(_memory_runtime=SimpleNamespace(service=service))
monkeypatch.setattr(manager, "workspace_for_agent", lambda _: ws)
monkeypatch.setattr(manager, "get_agent", lambda _: agent)
task = asyncio.create_task(manager.review_manual_memory_documents("a1"))
assert await asyncio.to_thread(entered.wait, 2)
edit = asyncio.create_task(
manager.save_manual_memory_document("a1", "USER.md", "Updated profile")
)
if cancel_review:
task.cancel()
await asyncio.sleep(0.01)
assert not edit.done() and ws.read_text("USER.md") == "Original profile"
release.set()
if cancel_review:
with pytest.raises(asyncio.CancelledError):
await task
else:
assert await task == {"status": "complete"}
await edit
assert ws.read_text("USER.md") == "Updated profile"
with pytest.raises(ValueError, match="Unsupported"):
await manager.save_manual_memory_document("a1", "AGENTS.md", "invalid")
@pytest.mark.asyncio
@pytest.mark.parametrize("failure", [False, True])
async def test_stopped_agent_manual_review_closes_temporary_runtime(
manager: AgentManager, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: bool
) -> None:
from deepagents.backends import FilesystemBackend
from octop_harness.backends.workspace import BackendWorkspace
ws = BackendWorkspace(FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True), tmp_path)
ws.write_text("USER.md", "Current profile", force=True)
monkeypatch.setattr(manager, "workspace_for_agent", lambda _: ws)
def stopped(_: str) -> Any:
raise OctopError(ErrorCode.AGENT_NOT_RUNNING, "stopped")
monkeypatch.setattr(manager, "get_agent", stopped)
monkeypatch.setattr(manager, "get_row", lambda _: _row())
cfg = SimpleNamespace(memory_enabled=True, providers=[], workspace_dir=str(tmp_path))
monkeypatch.setattr(manager, "_build_harness_config", lambda _: cfg)
factory, runtime = MagicMock(), MagicMock()
runtime.service.reconcile_manual_documents.return_value = {"status": "complete"}
if failure:
runtime.service.reconcile_manual_documents.side_effect = RuntimeError("audit failed")
factory_type = MagicMock(return_value=factory)
runtime_type = MagicMock(return_value=runtime)
monkeypatch.setattr("octop_harness.llm.factory.ChatModelFactory", factory_type)
monkeypatch.setattr("octop_harness.memory.runtime.MemoryRuntime", runtime_type)
if failure:
with pytest.raises(RuntimeError, match="audit failed"):
await manager.review_manual_memory_documents("a1")
else:
assert await manager.review_manual_memory_documents("a1") == {"status": "complete"}
assert runtime.service.reconcile_manual_documents.call_args.kwargs["is_current"]()
assert runtime_type.call_args.kwargs["workspace"] is ws
runtime.close.assert_called_once()
factory.close.assert_called_once()
def _row(
*,
agent_id: str = "01AGENT",
config_json: str | None = None,
default_model: str | None = None,
) -> AgentRow:
return AgentRow(
id=1,
agent_id=agent_id,
user_id=1,
name="bot",
description=None,
persona_mbti=None,
default_model=default_model,
system_prompt=None,
enabled=1,
config_json=config_json,
last_state=None,
last_error=None,
created_at=0,
updated_at=0,
)
def test_inject_gateway_tools_skips_empty_host_configs() -> None:
from octop.infra.connectors.builder import inject_missing_gateway_tools
agent = MagicMock()
inject_missing_gateway_tools(
agent,
svc=MagicMock(),
connector_repo=MagicMock(),
user_id=1,
agent_id="host",
mcp_server_configs={},
)
agent.inject_mcp_tools.assert_not_called()
def test_default_mcp_servers_empty_for_team_host(manager: AgentManager) -> None:
from dataclasses import replace as dc_replace
manager._repos.agent_repo.get = MagicMock( # type: ignore[method-assign]
return_value=dc_replace(_row(agent_id="host"), kind="team", mcp_servers='["docs__1"]')
)
assert manager.default_mcp_servers("host") == []
def test_persist_mcp_servers_clears_team_host(manager: AgentManager) -> None:
from dataclasses import replace as dc_replace
manager._repos.agent_repo.get = MagicMock( # type: ignore[method-assign]
return_value=dc_replace(_row(agent_id="host"), kind="team")
)
manager._repos.agent_repo.update_config = MagicMock() # type: ignore[method-assign]
manager.persist_mcp_servers("host", ["docs__1"])
manager._repos.agent_repo.update_config.assert_called_once_with("host", mcp_servers="[]")
@pytest.mark.asyncio
async def test_reload_connectors_skips_team_host(manager: AgentManager) -> None:
from dataclasses import replace as dc_replace
manager.get_row = MagicMock( # type: ignore[method-assign]
return_value=dc_replace(_row(agent_id="host"), kind="team")
)
manager._reload_agent = AsyncMock() # type: ignore[method-assign]
await manager.reload_connectors("host", connector_user_id=1)
manager._reload_agent.assert_not_awaited()
def test_strip_team_host_runtime_tools_drops_mcp(manager: AgentManager) -> None:
from dataclasses import replace as dc_replace
fake_agent = MagicMock()
fake_agent.config.mcp_server_configs = {"tencent-news__1": {}}
fake_agent._mcp_tools = [object()]
manager.get_row = MagicMock(return_value=dc_replace(_row(agent_id="host"), kind="team")) # type: ignore[method-assign]
manager.get_agent = MagicMock(return_value=fake_agent) # type: ignore[method-assign]
manager.sync_effective_tools_disabled = MagicMock() # type: ignore[method-assign]
manager.strip_team_host_runtime_tools("host")
assert fake_agent.config.mcp_server_configs == {}
fake_agent.replace_mcp_tools.assert_called_once_with([])
manager.sync_effective_tools_disabled.assert_called_once_with("host")
def test_apply_team_host_config_forces_async_ask_agent(manager: AgentManager) -> None:
from dataclasses import replace as dc_replace
from octop_harness.config import HarnessAgentConfig
manager._teams.member_ids = MagicMock(return_value=["mem-a", "mem-b"]) # type: ignore[method-assign]
cfg = HarnessAgentConfig(workspace_dir=manager.paths.ensure_agent_workspace("host"))
out = manager._apply_team_host_config(cfg, dc_replace(_row(agent_id="host"), kind="team"))
assert out.team_peers == ("mem-a", "mem-b")
if hasattr(out, "peer_invoke_mode"):
assert out.peer_invoke_mode == "async"
disabled = set(getattr(out, "tools_disabled", ()) or ())
assert disabled >= {"write_file", "execute", "task", "ls", "web_fetch"}
assert "ask_agent" not in disabled
assert "agent_list" not in disabled
assert out.bootstrap_enabled is False
assert "Host dispatch" in (out.system_prompt or "") or "主持人调度" in (out.system_prompt or "")
def test_apply_expert_config_forces_sync_ask_agent(manager: AgentManager) -> None:
from octop_harness.config import HarnessAgentConfig
cfg = HarnessAgentConfig(workspace_dir=manager.paths.ensure_agent_workspace("expert"))
out = manager._apply_team_host_config(cfg, _row(agent_id="expert"))
if hasattr(out, "peer_invoke_mode"):
assert out.peer_invoke_mode == "sync"
@pytest.mark.asyncio
async def test_install_team_host_dispatch_uses_inbox(manager: AgentManager) -> None:
from dataclasses import replace as dc_replace
team = MagicMock()
team.enabled = True
team.call_peer = AsyncMock(return_value="sync")
team.submit_peer = MagicMock(return_value="queued")
team._enrich_request = None
manager._harness_manager = SimpleNamespace(team=team)
manager._team_processor = SimpleNamespace(take_team_peer_prompt=MagicMock(return_value=None))
manager.get_row = MagicMock( # type: ignore[method-assign]
return_value=dc_replace(_row(agent_id="host"), kind="team")
)
manager._install_team_host_dispatch()
result = await team.call_peer(
from_agent_id="host",
to_agent_id="child",
message="go",
user_id=1,
source_thread_id="t",
session_key="sk",
)
assert result == "queued"
team.submit_peer.assert_called_once()
@pytest.mark.asyncio
async def test_install_expert_dispatch_keeps_sync_call_peer(manager: AgentManager) -> None:
team = MagicMock()
team.enabled = True
team.call_peer = AsyncMock(return_value="sync")
team.submit_peer = MagicMock(return_value="queued")
team._enrich_request = None
manager._harness_manager = SimpleNamespace(team=team)
manager._team_processor = SimpleNamespace(take_team_peer_prompt=MagicMock(return_value=None))
manager.get_row = MagicMock(return_value=_row(agent_id="expert")) # type: ignore[method-assign]
manager._install_team_host_dispatch()
result = await team.call_peer(
from_agent_id="expert",
to_agent_id="child",
message="go",
user_id=1,
source_thread_id="t",
session_key="sk",
)
assert result == "sync"
team.submit_peer.assert_not_called()
@pytest.mark.asyncio
async def test_install_expert_inbox_call_agent_stays_sync(manager: AgentManager) -> None:
original_call = AsyncMock(return_value={"ok": True})
stream_host = AsyncMock(side_effect=AssertionError("expert inbox must not room-stream"))
inbox = SimpleNamespace(_call_agent=original_call)
team = MagicMock()
team.enabled = True
team.call_peer = AsyncMock(return_value="sync")
team.submit_peer = MagicMock()
team._call_agent = original_call
team.inbox = inbox
team._invoke_peer = None
team._enrich_request = None
manager._harness_manager = SimpleNamespace(team=team)
manager._team_processor = SimpleNamespace(
take_team_peer_prompt=MagicMock(return_value=None),
stream_team_peer_to_room=AsyncMock(),
stream_host_followup_to_room=stream_host,
)
manager.get_row = MagicMock(return_value=_row(agent_id="expert")) # type: ignore[method-assign]
manager._install_team_host_dispatch()
req = SimpleNamespace(source="inbox", thread_id="thr_dm", agent_id="expert")
result = await inbox._call_agent("expert", req)
assert result == {"ok": True}
original_call.assert_awaited_once()
stream_host.assert_not_called()
@pytest.mark.asyncio
async def test_install_team_host_dispatch_stream_skips_unknown_job_id(
manager: AgentManager,
) -> None:
from dataclasses import replace as dc_replace
seen: dict[str, object] = {}
async def build_peer_request(
*,
from_agent_id: str,
to_agent_id: str,
message: str,
user_id: str | int,
source: str,
source_thread_id: str | None,
source_session_key: str | None,
) -> SimpleNamespace:
seen["build"] = source_thread_id
return SimpleNamespace(thread_id="t~child")
async def original_invoke(**kwargs: object) -> object:
raise AssertionError(f"should stream, got {kwargs!r}")
async def stream_peer(
request: object,
*,
room_thread_id: str,
speaker_id: str,
) -> dict[str, object]:
seen["stream"] = (room_thread_id, speaker_id, request)
return {"messages": [], "team_live_streamed": True}
team = MagicMock()
team.enabled = True
team.call_peer = AsyncMock(return_value="sync")
team.submit_peer = MagicMock()
team._invoke_peer = original_invoke
team._build_peer_request = build_peer_request
team._enrich_request = None
team._after_peer = None
manager._harness_manager = SimpleNamespace(team=team)
manager._team_processor = SimpleNamespace(
take_team_peer_prompt=MagicMock(return_value=None),
stream_team_peer_to_room=stream_peer,
)
manager.get_row = MagicMock( # type: ignore[method-assign]
return_value=dc_replace(_row(agent_id="host"), kind="team")
)
manager._install_team_host_dispatch()
request, payload = await team._invoke_peer(
from_agent_id="host",
to_agent_id="child",
message="go",
user_id=1,
source="inbox",
source_thread_id="t",
source_session_key="sk",
job_id="job-1",
)
assert seen["build"] == "t"
assert seen["stream"][0] == "t"
assert seen["stream"][1] == "child"
assert request.thread_id == "t~child"
assert payload["team_live_streamed"] is True
@pytest.mark.asyncio
async def test_install_async_dispatch_streams_non_team_inbox(manager: AgentManager) -> None:
seen: dict[str, object] = {}
async def build_peer_request(
*,
from_agent_id: str,
to_agent_id: str,
message: str,
user_id: str | int,
source: str,
source_thread_id: str | None,
source_session_key: str | None,
) -> SimpleNamespace:
seen["build"] = source_thread_id
return SimpleNamespace(thread_id="t~child")
async def original_invoke(**kwargs: object) -> object:
raise AssertionError(f"should stream, got {kwargs!r}")
async def stream_peer(
request: object,
*,
room_thread_id: str,
speaker_id: str,
) -> dict[str, object]:
seen["stream"] = (room_thread_id, speaker_id)
return {"messages": [], "team_live_streamed": True}
team = MagicMock()
team.enabled = True
team.call_peer = AsyncMock(return_value="sync")
team.submit_peer = MagicMock()
team._invoke_peer = original_invoke
team._build_peer_request = build_peer_request
team._enrich_request = None
team._after_peer = None
manager._harness_manager = SimpleNamespace(team=team)
manager._team_processor = SimpleNamespace(
take_team_peer_prompt=MagicMock(return_value=None),
stream_team_peer_to_room=stream_peer,
)
manager.get_row = MagicMock(return_value=_row(agent_id="expert")) # type: ignore[method-assign]
manager._install_team_host_dispatch()
request, payload = await team._invoke_peer(
from_agent_id="expert",
to_agent_id="child",
message="go",
user_id=1,
source="inbox",
source_thread_id="t",
source_session_key="sk",
job_id="job-1",
)
assert seen["build"] == "t"
assert seen["stream"] == ("t", "child")
assert request.thread_id == "t~child"
assert payload["team_live_streamed"] is True
@pytest.mark.asyncio
async def test_install_team_host_enrich_uses_host_assignment(manager: AgentManager) -> None:
from langchain_core.messages import HumanMessage, SystemMessage
team = MagicMock()
team.enabled = True
team.call_peer = AsyncMock(return_value="sync")
team.submit_peer = MagicMock()
team._invoke_peer = None
team._enrich_request = None
manager._harness_manager = SimpleNamespace(team=team)
manager._team_processor = SimpleNamespace(
take_team_peer_prompt=MagicMock(
return_value=(
"请给出可执行的睡眠建议,聚焦作息而不是诊断",
"[团队派工,不是用户在直接问你]\n用户: 我最近总失眠",
)
),
)
manager._install_team_host_dispatch()
req = SimpleNamespace(thread_id="t~child", messages="我最近总失眠")
out = await team._enrich_request("child", req)
assert isinstance(out.messages[0], SystemMessage)
assert isinstance(out.messages[1], HumanMessage)
assert out.messages[1].content == "请给出可执行的睡眠建议,聚焦作息而不是诊断"
assert "团队派工" in out.messages[0].content
@pytest.mark.asyncio
async def test_install_team_host_dispatch_streams_inbox_wrapup(
manager: AgentManager,
) -> None:
from dataclasses import replace as dc_replace
from langchain_core.messages import AIMessage
seen: dict[str, object] = {}
async def stream_host(
request: object,
*,
room_thread_id: str,
speaker_id: str,
) -> dict[str, object]:
seen["wrap"] = (room_thread_id, speaker_id, getattr(request, "source", None))
return {
"messages": [AIMessage(content="可以收工。")],
"team_live_streamed": True,
}
original_call = AsyncMock(side_effect=AssertionError("wrap-up must stream"))
inbox = SimpleNamespace(_call_agent=original_call)
team = MagicMock()
team.enabled = True
team.call_peer = AsyncMock(return_value="sync")
team.submit_peer = MagicMock()
team._call_agent = original_call
team.inbox = inbox
team._invoke_peer = None
team._enrich_request = None
manager._harness_manager = SimpleNamespace(team=team)
manager._team_processor = SimpleNamespace(
take_team_peer_prompt=MagicMock(return_value=None),
stream_team_peer_to_room=AsyncMock(),
stream_host_followup_to_room=stream_host,
)
manager.get_row = MagicMock( # type: ignore[method-assign]
return_value=dc_replace(_row(agent_id="host"), kind="team")
)
manager._install_team_host_dispatch()
req = SimpleNamespace(source="inbox", thread_id="thr_parent", agent_id="host")
result = await inbox._call_agent("host", req)
assert seen["wrap"] == ("thr_parent", "host", "inbox")
assert result["team_live_streamed"] is True
original_call.assert_not_called()
@pytest.mark.asyncio
async def test_install_team_host_dispatch_patches_inbox_synthesize(
manager: AgentManager,
) -> None:
from dataclasses import replace as dc_replace
from langchain_core.messages import AIMessage
seen: dict[str, object] = {}
async def stream_host(
request: object,
*,
room_thread_id: str,
speaker_id: str,
) -> dict[str, object]:
seen["wrap"] = (room_thread_id, speaker_id, getattr(request, "source", None))
return {
"messages": [AIMessage(content="可以收工。")],
"team_live_streamed": True,
}
async def original_synth(*_args: object, **_kwargs: object) -> str:
raise AssertionError("wrap-up must stream, not call()")
inbox = SimpleNamespace(
_synthesize_reply=original_synth,
_processor=SimpleNamespace(compose_followup=lambda *_a, **_k: "请收口"),
_is_cancelled=lambda _msg: False,
_call_agent=AsyncMock(side_effect=AssertionError("wrap-up must stream")),
)
team = MagicMock()
team.enabled = True
team.call_peer = AsyncMock(return_value="sync")
team.submit_peer = MagicMock()
team._call_agent = inbox._call_agent
team.inbox = inbox
team._invoke_peer = None
team._enrich_request = None
manager._harness_manager = SimpleNamespace(team=team)
manager._team_processor = SimpleNamespace(
take_team_peer_prompt=MagicMock(return_value=None),
stream_team_peer_to_room=AsyncMock(),
stream_host_followup_to_room=stream_host,
)
manager.get_row = MagicMock( # type: ignore[method-assign]
return_value=dc_replace(_row(agent_id="host"), kind="team")
)
manager._install_team_host_dispatch()
text = await inbox._synthesize_reply(
SimpleNamespace(
source_agent_id="host",
source_thread_id="thr_parent",
user_id=1,
target_agent_id="child",
message="task",
),
"member done",
None,
)
assert seen["wrap"] == ("thr_parent", "host", "inbox")
assert text == "可以收工。"
def test_format_agent_start_error_no_providers_message() -> None:
exc = RuntimeError("HarnessAgent requires providers on the config, an injected model_factory (")
assert format_agent_start_error(exc) == NO_MODELS_CONFIGURED
def test_format_agent_start_error_no_enabled_models_message() -> None:
exc = RuntimeError("No enabled models found in providers")
assert format_agent_start_error(exc) == NO_MODELS_CONFIGURED
def test_format_agent_start_error_unknown_passthrough() -> None:
exc = RuntimeError("disk full")
assert format_agent_start_error(exc) == "disk full"
def test_memory_extract_settings_supports_legacy_harness(caplog: pytest.LogCaptureFixture) -> None:
settings = _memory_extract_settings(
{
"memory": {
"memory_enabled": False,
"extract_on_session_end": True,
"extract_trigger_mode": "interval",
"extract_idle_seconds": 60,
"extract_interval_seconds": 600,
}
},
supported_fields=frozenset(
{
"memory_enabled",
"memory_extract_on_session_end",
"memory_extract_idle_seconds",
}
),
)
assert settings == {
"memory_enabled": False,
"memory_extract_on_session_end": True,
"memory_extract_idle_seconds": 600.0,
}
assert "lacks interval memory extraction" in caplog.text
def test_memory_extract_settings_forwards_aux_model_to_both_tiers() -> None:
settings = _memory_extract_settings(
{"memory": {"aux_model": "hai/MiniMax-M2.7"}},
is_ref_usable=lambda ref: ref == "hai/MiniMax-M2.7",
)
assert settings == {
"memory_aux_light_model": "hai/MiniMax-M2.7",
"memory_aux_heavy_model": "hai/MiniMax-M2.7",
}
def test_memory_extract_settings_drops_stale_aux_model(
caplog: pytest.LogCaptureFixture,
) -> None:
settings = _memory_extract_settings(
{"memory": {"aux_model": "gone/model"}},
is_ref_usable=lambda _ref: False,
)
assert settings == {}
assert "no longer usable" in caplog.text
def test_memory_extract_settings_skips_aux_model_on_legacy_harness() -> None:
settings = _memory_extract_settings(
{"memory": {"aux_model": "hai/MiniMax-M2.7"}},
supported_fields=frozenset({"memory_enabled"}),
is_ref_usable=lambda _ref: True,
)
assert settings == {}
def test_memory_extract_settings_forwards_aux_call_options() -> None:
settings = _memory_extract_settings(
{
"memory": {
"extract_light_timeout_s": 42,
"extract_heavy_timeout_s": 90,
"extract_max_tokens": 1024,
"extract_extra_body": {"chat_template_kwargs": {"enable_thinking": False}},
}
},
supported_fields=frozenset(
{
"memory_aux_light_timeout_s",
"memory_aux_heavy_timeout_s",
"memory_extract_max_tokens",
"memory_aux_extra_body",
}
),
)
assert settings == {
"memory_aux_light_timeout_s": 42.0,
"memory_aux_heavy_timeout_s": 90.0,
"memory_extract_max_tokens": 1024,
"memory_aux_extra_body": {"chat_template_kwargs": {"enable_thinking": False}},
}
def test_memory_extract_settings_handles_legacy_limits_and_stale_model() -> None:
settings = _memory_extract_settings(
{
"memory": {
"aux_model": "deleted/Qwen3",
"extract_max_tokens": 1,
"extract_light_timeout_s": 604800,
"extract_heavy_timeout_s": 1,
"extract_extra_body": {"chat_template_kwargs": {"enable_thinking": False}},
}
},
is_ref_usable=lambda _ref: False,
)
assert settings["memory_extract_max_tokens"] == 128
assert settings["memory_aux_light_timeout_s"] == 3600
assert settings["memory_aux_heavy_timeout_s"] == 30
# A deleted model falls back before the LLM client is built; its vendor
# options must not reach that replacement model either.
assert "memory_aux_extra_body" not in settings
def test_memory_extract_settings_skips_aux_call_options_on_legacy_harness() -> None:
# Older octop-harness releases predate the aux call options; the stored
# values must be ignored (not rejected) so hot reload keeps working.
settings = _memory_extract_settings(
{
"memory": {
"extract_light_timeout_s": 42,
"extract_max_tokens": 1024,
"extract_extra_body": {"enable_thinking": False},
}
},
supported_fields=frozenset({"memory_enabled"}),
)
assert settings == {}
def test_memory_extract_budget_reaches_actual_extractor(tmp_path: Path) -> None:
from dataclasses import fields
from octop_harness.config import HarnessAgentConfig
from octop_harness.memory.runtime import MemoryRuntime
from octop_memory.pipeline.extractor import CandidateExtractor
settings = _memory_extract_settings(
{"memory": {"extract_max_tokens": 1024, "extract_light_timeout_s": 45}},
supported_fields=frozenset(field.name for field in fields(HarnessAgentConfig)),
)
model = MagicMock()
model.bind.return_value = model
model.invoke.return_value = SimpleNamespace(content='{"candidates": []}')
factory = MagicMock()
factory.get_chat_model.return_value = model
config = HarnessAgentConfig(
name="budget-test",
workspace_dir=tmp_path,
default_model="local/test",
memory_backend={"type": "sqlite", "db_path": str(tmp_path / "memory.sqlite")},
session_log_enabled=False,
checkpointer=False,
**settings,
)
runtime = MemoryRuntime(config=config, workspace_path=tmp_path, model_factory=factory)
try:
service = runtime.service
assert service is not None
extractor = CandidateExtractor(
llm=runtime._llm_client, max_tokens=service._runtime._extraction_cfg()["max_tokens"]
)
result = extractor._call_with_retry(prompt="test", session_id="test", raw_event_ids=set())
assert result.failure_reason is None
assert model.bind.call_args.kwargs["max_tokens"] == 1024
assert model.bind.call_args.kwargs["timeout"] == 45
finally:
runtime.close()
def test_memory_extract_settings_ignores_unset_aux_call_options() -> None:
# None / zero / empty values fall through to the harness defaults.
settings = _memory_extract_settings(
{
"memory": {
"extract_light_timeout_s": None,
"extract_heavy_timeout_s": 0,
"extract_max_tokens": 0,
"extract_extra_body": {},
}
},
supported_fields=frozenset(
{
"memory_aux_light_timeout_s",
"memory_aux_heavy_timeout_s",
"memory_aux_max_tokens",
"memory_aux_extra_body",
}
),
)
assert settings == {}
def test_build_harness_config_accepts_memory_extract_settings(manager: AgentManager) -> None:
row = _row(
config_json=json.dumps(
{
"memory": {
"memory_enabled": False,
"extract_on_session_end": True,
"extract_trigger_mode": "interval",
"extract_idle_seconds": 60,
"extract_interval_seconds": 600,
}
}
)
)
cfg = manager._build_harness_config(row)
assert cfg.memory_enabled is False
if hasattr(cfg, "memory_extract_trigger_mode"):
assert cfg.memory_extract_trigger_mode == "interval"
assert cfg.memory_extract_interval_seconds == 600.0
else:
assert cfg.memory_extract_idle_seconds == 600.0
def test_format_agent_start_error_unwraps_exception_group() -> None:
exc = BaseExceptionGroup(
"unhandled errors in a TaskGroup (1 sub-exception)",
[ValueError("storage backend 'cos' not found")],
)
assert format_agent_start_error(exc) == "storage backend 'cos' not found"
def test_build_harness_config_includes_cronjob_tools_when_cron_manager_set(
manager: AgentManager,
tmp_path: Path,
) -> None:
from unittest.mock import MagicMock
from octop.infra.cron.manager import CronManager
gw = MagicMock()
gw.thread_registry = MagicMock()
from octop.infra.cron.delivery import CronDeliveryService
cron_mgr = CronManager(
gateway=gw,
delivery_service=CronDeliveryService(
gateway=gw,
agent_manager=manager,
repos=manager._repos,
),
repos=manager._repos,
timezone="UTC",
)
cron_mgr._scheduler = MagicMock()
manager.set_cron_manager(cron_mgr)
cfg = manager._build_harness_config(_row(agent_id="AGT001"))
assert cfg.tools is not None
names = {t.name for t in cfg.tools}
assert names == {
"cronjob_list",
"cronjob_get",
"cronjob_create",
"cronjob_update",
"cronjob_delete",
"cronjob_run_now",
"search_knowledge",
}
def test_build_harness_config_includes_search_knowledge_without_cron(
manager: AgentManager,
) -> None:
from octop.infra.knowledge.hint import KnowledgeSearchHintMiddleware
cfg = manager._build_harness_config(_row(agent_id="AGT001"))
assert cfg.tools is not None
assert {t.name for t in cfg.tools} == {"search_knowledge"}
assert any(isinstance(item, KnowledgeSearchHintMiddleware) for item in (cfg.middleware or []))
def test_build_harness_config_defaults_local_shell_backend(manager: AgentManager) -> None:
cfg = manager._build_harness_config(_row(agent_id="AGT001"))
assert cfg.backend == _expected_default_backend(manager, "AGT001")
assert cfg.workspace_dir.name == "AGT001"
assert cfg.workspace_dir.parent.name == "agents"
assert cfg.bootstrap_enabled is True
# Kept for harness FilesystemGuardMiddleware (not passed to deepagents).
assert cfg.permissions is not None
@pytest.mark.asyncio
async def test_boot_reuses_process_log_instead_of_dated_harness_files(
manager: AgentManager,
) -> None:
from octop_harness.observability.logging import teardown_logging
await manager.boot()
try:
hm = manager._harness_manager
assert hm is not None
assert hm._log_file is None
log_dir = manager.paths.logs_dir
dated = list(log_dir.glob("????-??-??.log")) if log_dir.is_dir() else []
assert dated == []
finally:
await manager.shutdown()
teardown_logging()
@pytest.mark.asyncio
async def test_shutdown_drains_sqlite_worker_before_returning(manager: AgentManager) -> None:
"""Real checkpoint cleanup must finish before the caller can close its loop."""
_seed_test_provider(manager)
await manager.boot()
agent_id = manager._repos.agent_repo.create(
agent_id="CLOSE1", user_id=None, name="close-test", config_json=json.dumps(_MEMORY_OFF)
)
row = manager._repos.agent_repo.get(agent_id)
assert row is not None
agent = await manager._start_agent(row)
assert agent is not None
await agent.checkpointer.setup()
conn = agent.checkpointer.conn
entered, release = threading.Event(), threading.Event()
def pending_operation() -> None:
entered.set()
assert release.wait(10)
pending = asyncio.create_task(conn._execute(pending_operation))
closing = None
try:
assert await asyncio.to_thread(entered.wait, 5)
closing = asyncio.create_task(manager.shutdown())
await asyncio.sleep(0.05)
assert not closing.done(), "shutdown returned with SQLite work still pending"
release.set()
await asyncio.wait_for(asyncio.gather(pending, closing), timeout=5)
assert conn._connection is None
await asyncio.to_thread(conn._thread.join, 5)
assert not conn._thread.is_alive()
assert manager._harness_manager is None
finally:
release.set()
await pending
if closing is not None:
await closing
else:
await manager.shutdown()
await asyncio.to_thread(conn._thread.join, 5)
def test_build_harness_config_enables_bootstrap_for_expert_template(manager: AgentManager) -> None:
from dataclasses import replace
cfg = manager._build_harness_config(
replace(_row(agent_id="AGT001"), template_name="cvm-ai-doctor"),
)
assert cfg.bootstrap_enabled is True
def test_build_harness_config_disables_bootstrap_for_team_host(manager: AgentManager) -> None:
from dataclasses import replace
agent_id = "AGT_TEAM"
ws = manager._paths.ensure_agent_workspace(agent_id)
row = replace(
_row(agent_id=agent_id),
kind="team",
system_prompt="Team coordinator prompt",
config_json=json.dumps({"backend": _fs_backend(ws)}),
)
cfg = manager._build_harness_config(row)
assert cfg.bootstrap_enabled is False
assert cfg.system_prompt is not None
assert cfg.system_prompt.startswith("Team coordinator prompt")
assert "主持人调度" in cfg.system_prompt or "Host dispatch" in cfg.system_prompt
assert cfg.memory is None
assert cfg.skills_dir is None
def test_is_bootstrapped_returns_true_for_team_host_when_not_running(
manager: AgentManager,
) -> None:
from dataclasses import replace
manager.get_row = MagicMock( # type: ignore[method-assign]
return_value=replace(_row(agent_id="host"), kind="team")
)
assert manager.is_bootstrapped("host") is True
def _fs_backend(ws: Path) -> dict[str, str]:
return {"type": "filesystem", "root_dir": str(ws), "virtual_mode": False}
def test_build_harness_config_suppresses_system_prompt_while_bootstrap_pending(
manager: AgentManager,
) -> None:
from dataclasses import replace
agent_id = "AGT_BOOT"
ws = manager._paths.ensure_agent_workspace(agent_id)
row = replace(
_row(agent_id=agent_id),
system_prompt="MBTI persona prompt",
config_json=json.dumps({"backend": _fs_backend(ws)}),
)
cfg = manager._build_harness_config(row)
assert cfg.system_prompt is None
assert cfg.memory == ()
def test_build_harness_config_keeps_memory_after_bootstrap(
manager: AgentManager,
) -> None:
from dataclasses import replace
agent_id = "AGT_MEM"
ws = manager._paths.ensure_agent_workspace(agent_id)
(ws / ".bootstrapped").write_text("", encoding="utf-8")
row = replace(
_row(agent_id=agent_id),
system_prompt="MBTI persona prompt",
config_json=json.dumps({"backend": _fs_backend(ws)}),
)
cfg = manager._build_harness_config(row)
assert cfg.memory is None
def test_build_harness_config_keeps_system_prompt_after_bootstrap(
manager: AgentManager,
) -> None:
from dataclasses import replace
agent_id = "AGT_DONE"
ws = manager._paths.ensure_agent_workspace(agent_id)
(ws / ".bootstrapped").write_text("", encoding="utf-8")
row = replace(
_row(agent_id=agent_id),
system_prompt="MBTI persona prompt",
config_json=json.dumps({"backend": _fs_backend(ws)}),
)
cfg = manager._build_harness_config(row)
assert cfg.system_prompt == "MBTI persona prompt"
def test_bootstrap_complete_defers_graph_refresh(manager: AgentManager) -> None:
agent_id = "AGT_BOOT"
manager._repos.agent_repo.create(agent_id=agent_id, user_id=None, name="boot")
manager._repos.agent_repo.update_config(agent_id, system_prompt="Persona from DB")
agent = MagicMock()
cfg = MagicMock()
cfg.memory = ()
cfg.system_prompt = None
agent._config = cfg
manager._mark_bootstrap_graph_refresh_pending(agent_id, agent)
assert cfg.system_prompt == "Persona from DB"
assert cfg.memory is None
agent._init_graph.assert_not_called()
assert agent_id in manager._bootstrap_graph_refresh_pending
def test_apply_pending_bootstrap_graph_refresh_recompiles_graph(manager: AgentManager) -> None:
agent_id = "AGT_BOOT2"
agent = MagicMock()
harness_manager = MagicMock()
harness_manager.get_agent.return_value = MagicMock(agent=agent)
manager._harness_manager = harness_manager
manager._bootstrap_graph_refresh_pending.add(agent_id)
manager._apply_pending_bootstrap_graph_refresh(agent_id)
agent._init_graph.assert_called_once()
assert agent_id not in manager._bootstrap_graph_refresh_pending
def test_is_bootstrapped_returns_false_when_agent_not_running(manager: AgentManager) -> None:
assert manager.is_bootstrapped("NOPE") is False
def test_is_bootstrapped_does_not_cache_an_incomplete_marker(manager: AgentManager) -> None:
agent = MagicMock()
agent.is_bootstrapped.side_effect = [False, True]
harness_manager = MagicMock()
harness_manager.get_agent.return_value = MagicMock(agent=agent)
manager._harness_manager = harness_manager
manager._repos.agent_repo.create(agent_id="AGT_COS", user_id=None, name="cos", config_json="{}")
assert manager.is_bootstrapped("AGT_COS") is False
assert manager.is_bootstrapped("AGT_COS") is True
assert agent.is_bootstrapped.call_count == 2
assert manager.is_bootstrapped("AGT_COS") is True
assert agent.is_bootstrapped.call_count == 2
def test_is_bootstrapped_assumes_true_when_backend_check_fails(manager: AgentManager) -> None:
agent = MagicMock()
agent.is_bootstrapped.side_effect = OSError("TLS CA bundle missing")
harness_manager = MagicMock()
harness_manager.get_agent.return_value = MagicMock(agent=agent)
manager._harness_manager = harness_manager
assert manager.is_bootstrapped("AGT_COS") is True
@pytest.mark.asyncio
async def test_delete_thread_checkpoint_missing_agent_raises(manager: AgentManager) -> None:
with pytest.raises(OctopError) as exc:
await manager.delete_thread_checkpoint("NOPE", "thr_1")
assert exc.value.code == ErrorCode.AGENT_NOT_FOUND
@pytest.mark.asyncio
async def test_delete_thread_checkpoint_also_clears_configured_store(
manager: AgentManager, monkeypatch: Any
) -> None:
"""A live checkpointer delete must not skip the SQLite or Postgres store."""
user_id = manager._repos.user_repo.create(username="alice", password_hash="x", role="admin")
manager._repos.agent_repo.create(agent_id="AGT1", user_id=user_id, name="a")
agent = MagicMock()
agent.adelete_thread = AsyncMock(return_value=True)
harness_manager = MagicMock()
harness_manager.get_agent.return_value = MagicMock(agent=agent)
manager._harness_manager = harness_manager
seen: dict[str, str] = {}
def _capture(**kwargs: Any) -> None:
seen["thread_id"] = kwargs["thread_id"]
monkeypatch.setattr(
"octop.infra.agents.memory.thread_cleanup.delete_thread_from_agent_stores",
_capture,
)
await manager.delete_thread_checkpoint("AGT1", "thr_1")
agent.adelete_thread.assert_awaited_once_with("thr_1")
assert seen["thread_id"] == "thr_1"
@pytest.mark.asyncio
async def test_delete_removes_every_agent_directory(manager: AgentManager) -> None:
agent_id = "AGT_DIRS"
manager._repos.agent_repo.create(agent_id=agent_id, user_id=None, name="dirs")
agents = manager._paths.agent_workspace(agent_id)
scoped = manager._paths.root / "workspaces" / agent_id
agents.mkdir(parents=True)
scoped.mkdir(parents=True)
(agents / "a.txt").write_text("a", encoding="utf-8")
(scoped / "b.txt").write_text("b", encoding="utf-8")
harness_manager = MagicMock()
harness_manager.aremove_agent = AsyncMock()
manager._harness_manager = harness_manager
await manager.delete(agent_id)
assert not agents.exists()
assert not scoped.exists()
assert manager.get_row(agent_id) is None
@pytest.mark.asyncio
async def test_delete_thread_checkpoint_delegates_to_harness_adelete_thread(
manager: AgentManager,
) -> None:
agent = MagicMock()
agent.adelete_thread = AsyncMock(return_value=True)
harness_manager = MagicMock()
harness_manager.get_agent.return_value = MagicMock(agent=agent)
manager._harness_manager = harness_manager
result = await manager.delete_thread_checkpoint("AGT1", "thr_1")
assert result is None
agent.adelete_thread.assert_awaited_once_with("thr_1")
@pytest.mark.asyncio
async def test_delete_thread_checkpoint_opens_store_when_harness_cannot_delete(
manager: AgentManager,
) -> None:
agent = MagicMock(spec=[]) # no adelete_thread attribute at all
harness_manager = MagicMock()
harness_manager.get_agent.return_value = MagicMock(agent=agent)
manager._harness_manager = harness_manager
result = await manager.delete_thread_checkpoint("AGT1", "thr_1")
assert result is None
@pytest.mark.asyncio
async def test_delete_thread_checkpoint_propagates_unexpected_errors(
manager: AgentManager,
) -> None:
"""A live agent whose checkpointer delete genuinely fails must not report success."""
agent = MagicMock()
agent.adelete_thread = AsyncMock(side_effect=RuntimeError("db unavailable"))
harness_manager = MagicMock()
harness_manager.get_agent.return_value = MagicMock(agent=agent)
manager._harness_manager = harness_manager
with pytest.raises(OctopError) as exc:
await manager.delete_thread_checkpoint("AGT1", "thr_1")
assert exc.value.code == ErrorCode.CHECKPOINT_DELETE_FAILED
@pytest.mark.asyncio
async def test_stream_applies_bootstrap_refresh_after_turn(manager: AgentManager) -> None:
agent_id = "AGT_STREAM"
agent = MagicMock()
harness_manager = MagicMock()
async def fake_stream(*_args: Any, **_kwargs: Any) -> AsyncIterator[dict[str, str]]:
yield {"type": "token", "content": "hi"}
harness_manager.stream = fake_stream
harness_manager.get_agent.return_value = MagicMock(agent=agent)
manager._harness_manager = harness_manager
manager._bootstrap_graph_refresh_pending.add(agent_id)
chunks = [chunk async for chunk in manager.stream(agent_id, {"thread_id": "thr1"})]
assert chunks == [{"type": "token", "content": "hi"}]
agent._init_graph.assert_called_once()
assert agent_id not in manager._bootstrap_graph_refresh_pending
@pytest.mark.asyncio
async def test_history_backfill_refuses_while_agent_stream_is_active(
manager: AgentManager,
) -> None:
agent_id = "AGT_BUSY"
started = asyncio.Event()
release = asyncio.Event()
harness_manager = MagicMock()
async def fake_stream(*_args: Any, **_kwargs: Any) -> AsyncIterator[dict[str, str]]:
started.set()
await release.wait()
yield {"type": "done"}
harness_manager.stream = fake_stream
manager._harness_manager = harness_manager
task = asyncio.create_task(_collect_async(manager.stream(agent_id, {"thread_id": "thr-busy"})))
await started.wait()
assert manager.is_agent_active(agent_id) is True
assert manager.try_begin_history_backfill(agent_id) is False
release.set()
await task
assert manager.is_agent_active(agent_id) is False
@pytest.mark.asyncio
async def test_waiting_agent_stream_prevents_next_history_backfill(
manager: AgentManager,
) -> None:
agent_id = "AGT_PRIORITY"
stream_started = asyncio.Event()
stream_release = asyncio.Event()
harness_manager = MagicMock()
async def fake_stream(*_args: Any, **_kwargs: Any) -> AsyncIterator[dict[str, str]]:
stream_started.set()
await stream_release.wait()
yield {"type": "done"}
harness_manager.stream = fake_stream
manager._harness_manager = harness_manager
assert manager.try_begin_history_backfill(agent_id) is True
task = asyncio.create_task(
_collect_async(manager.stream(agent_id, {"thread_id": "thr-priority"}))
)
await asyncio.sleep(0)
assert stream_started.is_set() is False
assert manager.try_begin_history_backfill(agent_id) is False
manager.end_history_backfill(agent_id)
await stream_started.wait()
assert manager.is_agent_active(agent_id) is True
assert manager.try_begin_history_backfill(agent_id) is False
stream_release.set()
await task
assert manager.try_begin_history_backfill(agent_id) is True
manager.end_history_backfill(agent_id)
@pytest.mark.asyncio
async def test_reload_agent_clears_bootstrap_refresh_pending(manager: AgentManager) -> None:
agent_id = "AGT_RELOAD"
manager._repos.agent_repo.create(agent_id=agent_id, user_id=None, name="reload")
manager._repos.agent_repo.set_state(agent_id, "stopped")
manager._bootstrap_graph_refresh_pending.add(agent_id)
harness_manager = MagicMock()
harness_manager.aremove_agent = AsyncMock()
manager._harness_manager = harness_manager
await manager._reload_agent(agent_id)
assert agent_id not in manager._bootstrap_graph_refresh_pending
harness_manager.aremove_agent.assert_awaited_once_with(agent_id)
@pytest.mark.asyncio
async def test_compact_now_runs_when_idle(manager: AgentManager, monkeypatch: Any) -> None:
seen: list[str] = []
def _sync(agent_id: str) -> bool:
seen.append(agent_id)
return True
monkeypatch.setattr(manager, "_compact_agent_database_sync", _sync)
assert await manager.compact_agent_database("AGT1") is True
assert seen == ["AGT1"]
assert "AGT1" not in manager._reclaim_pending
@pytest.mark.asyncio
async def test_compact_now_defers_while_a_turn_is_running(
manager: AgentManager, monkeypatch: Any
) -> None:
manager._active_invocations["AGT1"] = 1
monkeypatch.setattr(manager, "_compact_agent_database_sync", lambda _agent_id: True)
try:
assert await manager.compact_agent_database("AGT1", wait_s=0) is False
assert "AGT1" in manager._reclaim_pending
finally:
manager._reclaim_pending.clear()
task = manager._reclaim_task
if task is not None and not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
@pytest.mark.asyncio
async def test_delete_purges_memory_before_removing_row(
manager: AgentManager, monkeypatch: Any
) -> None:
"""Checkpoints and memory are removed while the agent row still exists."""
user_id = manager._repos.user_repo.create(username="alice", password_hash="x", role="admin")
agent_id = "AGT_PURGE"
manager._repos.agent_repo.create(agent_id=agent_id, user_id=user_id, name="purge")
manager._repos.thread_repo.insert(
thread_id="thr_purge",
agent_id=agent_id,
user_id=user_id,
channel_type="dashboard",
session_key="purge",
)
seen: dict[str, Any] = {}
def _capture(**kwargs: Any) -> None:
seen["threads"] = set(kwargs["thread_ids"])
seen["row_still_present"] = manager.get_row(agent_id) is not None
monkeypatch.setattr(
"octop.infra.agents.memory.thread_cleanup.delete_agent_memory_and_checkpoints",
_capture,
)
harness_manager = MagicMock()
harness_manager.aremove_agent = AsyncMock()
manager._harness_manager = harness_manager
await manager.delete(agent_id)
assert seen["threads"] == {"thr_purge"}
assert seen["row_still_present"] is True
assert manager.get_row(agent_id) is None
@pytest.mark.asyncio
async def test_delete_keeps_row_when_memory_purge_fails(
manager: AgentManager, monkeypatch: Any
) -> None:
agent_id = "AGT_PURGE_FAIL"
manager._repos.agent_repo.create(agent_id=agent_id, user_id=None, name="purge-fail")
harness_manager = MagicMock()
harness_manager.aremove_agent = AsyncMock()
manager._harness_manager = harness_manager
def _boom(**kwargs: Any) -> None:
raise RuntimeError("store locked")
monkeypatch.setattr(
"octop.infra.agents.memory.thread_cleanup.delete_agent_memory_and_checkpoints",
_boom,
)
with pytest.raises(RuntimeError, match="store locked"):
await manager.delete(agent_id)
assert manager.get_row(agent_id) is not None
@pytest.mark.asyncio
async def test_delete_removes_workspace_directory(manager: AgentManager) -> None:
"""Deleting an agent must clean up its workspace directory on disk."""
agent_id = "AGT_DELETE_WS"
manager._repos.agent_repo.create(agent_id=agent_id, user_id=None, name="delete-ws")
workspace_dir = manager._paths.ensure_agent_workspace(agent_id)
assert workspace_dir.is_dir()
(workspace_dir / "MEMORY.md").write_text("old memory", encoding="utf-8")
harness_manager = MagicMock()
harness_manager.aremove_agent = AsyncMock()
manager._harness_manager = harness_manager
await manager.delete(agent_id)
harness_manager.aremove_agent.assert_awaited_once_with(agent_id)
assert manager.get_row(agent_id) is None
assert not workspace_dir.exists()
@pytest.mark.asyncio
async def test_delete_stops_memory_maintenance_before_close(manager: AgentManager) -> None:
agent_id = "AGT_DELETE_MEM"
manager._repos.agent_repo.create(agent_id=agent_id, user_id=None, name="delete-mem")
mw = MagicMock()
mw._maintenance_running = None
runtime = MagicMock()
runtime._middleware = mw
agent = MagicMock()
agent._memory_runtime = runtime
entry = MagicMock()
entry.agent = agent
harness_manager = MagicMock()
harness_manager.get_agent.return_value = entry
harness_manager.aremove_agent = AsyncMock()
manager._harness_manager = harness_manager
await manager.delete(agent_id)
mw.shutdown.assert_called_once()
harness_manager.aremove_agent.assert_awaited_once_with(agent_id)
@pytest.mark.asyncio
async def test_delete_removes_persisted_workspace_dir(
manager: AgentManager, tmp_path: Path
) -> None:
agent_id = "AGT_DELETE_CUSTOM"
custom = tmp_path / "custom-ws"
custom.mkdir()
(custom / "SOUL.md").write_text("keep", encoding="utf-8")
manager._repos.agent_repo.create(
agent_id=agent_id,
user_id=None,
name="delete-custom",
config_json=json.dumps({"workspace_dir": str(custom)}),
)
harness_manager = MagicMock()
harness_manager.aremove_agent = AsyncMock()
manager._harness_manager = harness_manager
await manager.delete(agent_id)
assert manager.get_row(agent_id) is None
assert not custom.exists()
@pytest.mark.asyncio
async def test_delete_still_removes_db_row_when_workspace_rmtree_fails(
manager: AgentManager, monkeypatch: Any
) -> None:
"""A failed workspace rmtree must not abort agent deletion (mirrors user removal)."""
import shutil
agent_id = "AGT_DELETE_ERR"
manager._repos.agent_repo.create(agent_id=agent_id, user_id=None, name="delete-err")
def _fail_rmtree(path: object) -> None:
raise OSError("permission denied")
monkeypatch.setattr(shutil, "rmtree", _fail_rmtree)
harness_manager = MagicMock()
harness_manager.aremove_agent = AsyncMock()
manager._harness_manager = harness_manager
await manager.delete(agent_id)
assert manager.get_row(agent_id) is None
def test_bootstrap_pending_detects_unfinished_onboarding(tmp_path: Path) -> None:
from octop_harness.backends import resolve_backend
from octop_harness.backends.workspace import BackendWorkspace
from octop_harness.middleware.bootstrap import bootstrap_marker_exists
backend = resolve_backend(
{"type": "filesystem", "root_dir": str(tmp_path), "virtual_mode": False},
workspace_dir=tmp_path,
)
ws = BackendWorkspace(backend, tmp_path)
assert not bootstrap_marker_exists(ws)
(tmp_path / ".bootstrapped").write_text("", encoding="utf-8")
assert bootstrap_marker_exists(ws)
def test_build_harness_config_respects_config_json_backend(manager: AgentManager) -> None:
custom = {"type": "filesystem", "root_dir": "/srv/data"}
cfg = manager._build_harness_config(
_row(config_json=json.dumps({"backend": custom})),
)
assert cfg.backend == custom
def test_backend_spec_for_row_neutralizes_host_root_on_windows(
manager: AgentManager, monkeypatch: Any
) -> None:
# The dashboard persists local backends with root_dir "/" (host-root sentinel).
# On Windows that resolves to the current-drive root, breaking cross-drive reads
# of the workspace; the resolver must rewrite root_dir to the workspace path.
monkeypatch.setattr(os, "name", "nt")
row = _row(
config_json=json.dumps(
{"backend": {"type": "local_shell", "root_dir": "/", "virtual_mode": True}}
)
)
ws = manager.resolve_workspace_dir(row.agent_id)
assert manager._backend_spec_for_row(row) == {
"type": "local_shell",
"root_dir": str(ws.resolve()),
"virtual_mode": True,
}
def test_build_harness_config_keeps_fs_permissions_for_local_shell_guard(
manager: AgentManager,
) -> None:
"""local_shell keeps permissions on config; harness mounts FilesystemGuard.
Octop must not re-mount the guard (or ModelSettings) via cfg.middleware.
"""
from octop_harness.middleware.filesystem_guard import FilesystemGuardMiddleware
from octop_harness.middleware.model_settings import ModelSettingsMiddleware
cfg = manager._build_harness_config(
_row(config_json=json.dumps({"backend": {"type": "local_shell", "virtual_mode": True}})),
)
backend = cfg.backend
assert isinstance(backend, dict)
assert backend.get("type") == "local_shell"
assert backend.get("virtual_mode") is True
assert cfg.permissions is not None
middleware = cfg.middleware or []
assert not any(isinstance(item, FilesystemGuardMiddleware) for item in middleware)
assert not any(isinstance(item, ModelSettingsMiddleware) for item in middleware)
cfg = manager._build_harness_config(
_row(config_json=json.dumps({"backend": {"type": "filesystem", "virtual_mode": True}})),
)
assert cfg.permissions is not None
assert not any(isinstance(item, FilesystemGuardMiddleware) for item in (cfg.middleware or []))
def test_build_harness_config_without_default_model(manager: AgentManager) -> None:
cfg = manager._build_harness_config(_row())
assert cfg.name == "agent_01AGENT"
assert cfg.system_prompt is None
assert cfg.backend == _expected_default_backend(manager, "01AGENT")
@pytest.mark.asyncio
@pytest.mark.parametrize("failure_type", [TypeError, httpx.ReadTimeout])
async def test_model_retry_exhaustion_fails_background_job(
manager: AgentManager, failure_type: type[Exception]
) -> None:
from langchain.agents.middleware import ModelRetryMiddleware
from langchain_core.messages import AIMessage
from octop_harness.teams.inbox import HarnessAgentInboxManager, InboxMessage
from octop_harness.teams.processor import default_compose_followup
cfg = manager._build_harness_config(_row())
retries = [m for m in cfg.middleware or [] if isinstance(m, ModelRetryMiddleware)]
assert len(retries) == 1
assert not cfg.model_retry_enabled # The host installs exactly one retry middleware.
retry = retries[0]
assert retry.on_failure == "error"
assert retry.retry_on is ModelRetryMiddleware().retry_on
assert retry.max_retries == cfg.model_retry_max_retries
assert retry.initial_delay == cfg.model_retry_initial_delay
assert retry.max_delay == cfg.model_retry_max_delay
retry.initial_delay = 0
failure = failure_type("provider unavailable")
attempts = 0
async def fail(request: Any) -> Any:
nonlocal attempts
attempts += 1
raise failure
async def target(msg: Any) -> dict[str, Any]:
response = await retry.awrap_model_call(None, fail)
return {"messages": response.result}
with pytest.raises(failure_type) as caught:
await retry.awrap_model_call(None, fail)
assert caught.value is failure
assert attempts == cfg.model_retry_max_retries + 1
attempts = 0
source = AsyncMock(return_value={"messages": [AIMessage(content="failure relayed")]})
processor = SimpleNamespace(
compose_followup=MagicMock(side_effect=default_compose_followup),
on_reply=AsyncMock(),
)
inbox = HarnessAgentInboxManager(call_agent=source, processor=processor, invoke_target=target)
msg = InboxMessage(
id="retry-failure",
target_agent_id="B",
source_agent_id="A",
source_thread_id="thread",
message="test",
user_id=1,
)
await inbox._process(msg)
assert attempts == cfg.model_retry_max_retries + 1
assert msg.status == "failed"
event = processor.on_reply.call_args.args[0]
assert event.status == "failed"
assert "provider unavailable" in event.error_text
assert processor.compose_followup.call_args.kwargs["result_text"] is None
assert processor.compose_followup.call_args.kwargs["error_text"] == event.error_text
def test_model_retry_sync_failure_and_recovery(manager: AgentManager) -> None:
from langchain.agents.middleware import ModelResponse, ModelRetryMiddleware
from langchain_core.messages import AIMessage
cfg = manager._build_harness_config(_row())
retry = next(m for m in cfg.middleware or [] if isinstance(m, ModelRetryMiddleware))
retry.initial_delay = 0
failure = TypeError("model failed")
handler = MagicMock(side_effect=failure)
with pytest.raises(TypeError) as caught:
retry.wrap_model_call(None, handler)
assert caught.value is failure
assert handler.call_count == cfg.model_retry_max_retries + 1
response = ModelResponse(result=[AIMessage(content="recovered")])
handler = MagicMock(side_effect=[failure, response])
assert retry.wrap_model_call(None, handler) is response
assert handler.call_count == 2
@pytest.mark.parametrize("status", [400, 401, 403, 422])
def test_host_model_sdk_errors_keep_original_retry_budget(
manager: AgentManager, status: int
) -> None:
from langchain.agents.middleware import ModelRetryMiddleware
from openai import APIStatusError
cfg = manager._build_harness_config(_row())
retry = next(m for m in cfg.middleware or [] if isinstance(m, ModelRetryMiddleware))
retry.initial_delay = 0
error = APIStatusError(
"opaque rejection",
response=httpx.Response(
status, request=httpx.Request("POST", "https://provider.example/v1/messages")
),
body={},
)
handler = MagicMock(side_effect=error)
with pytest.raises(APIStatusError) as caught:
retry.wrap_model_call(None, handler)
assert caught.value is error
assert handler.call_count == cfg.model_retry_max_retries + 1
def test_runtime_uses_health_aware_model_failover(manager: AgentManager) -> None:
from octop.infra.agents.middleware.model_failover import SafeModelFailoverMiddleware
_seed_test_provider(manager)
factory = MagicMock()
factory.get_chat_model.return_value = object()
manager._harness_manager = MagicMock(shared_factory=factory)
cfg = manager._build_harness_config(_row())
failovers = [
item for item in (cfg.middleware or []) if isinstance(item, SafeModelFailoverMiddleware)
]
assert len(failovers) == 1
assert not cfg.model_retry_enabled
assert failovers[0]._on_failure == "error"
def test_auto_route_fallback_skips_model_in_cooldown(manager: AgentManager) -> None:
from octop.infra.agents.providers.health import FailureScope
manager._repos.provider_repo.create(
name="p",
kind="openai",
base_url="https://api.example.com/v1",
api_key="sk-test",
models_json=json.dumps(
[
{"id": "bad", "enabled": True},
{"id": "ready", "enabled": True},
]
),
)
manager._model_health.record_failure(
"p/bad",
kind="provider_unavailable",
scope=FailureScope.MODEL,
)
assert manager.resolve_fallback_model_ref() == "p/ready"
def test_build_harness_config_auto_expert_falls_back_to_first_model(
manager: AgentManager,
) -> None:
"""AUTO expert still gets a concrete default_model so the memory aux LLM works."""
_seed_test_provider(manager)
cfg = manager._build_harness_config(_row(default_model=None))
assert cfg.default_model == "test-openai/gpt-4o-mini"
assert cfg.providers == []
def test_build_harness_config_keeps_model_during_runtime_cooldown(
manager: AgentManager,
) -> None:
from octop.infra.agents.providers.health import FailureScope
_seed_test_provider(manager)
manager._model_health.record_failure(
"test-openai/gpt-4o-mini",
kind="timeout",
scope=FailureScope.MODEL,
)
cfg = manager._build_harness_config(_row(default_model=None))
assert cfg.default_model == "test-openai/gpt-4o-mini"
def test_build_harness_config_uses_active_model_before_first(
manager: AgentManager,
) -> None:
"""AUTO expert default_model follows the global active model when usable."""
_seed_test_provider(manager)
manager._repos.provider_repo.create(
name="second-openai",
kind="openai",
base_url="https://api.example.com/v1",
api_key="sk-test",
models_json=json.dumps(
[{"id": "gpt-5", "name": "gpt-5", "enabled": True}],
),
)
manager._repos.settings_repo.set_active_model("second-openai", "gpt-5")
cfg = manager._build_harness_config(_row(default_model=None))
assert cfg.default_model == "second-openai/gpt-5"
def test_build_harness_config_active_model_ignored_when_unusable(
manager: AgentManager,
) -> None:
"""Active model pointing at a disabled/removed provider falls back to first."""
_seed_test_provider(manager)
manager._repos.settings_repo.set_active_model("deleted-provider", "gone")
cfg = manager._build_harness_config(_row(default_model=None))
assert cfg.default_model == "test-openai/gpt-4o-mini"
def test_build_harness_config_explicit_default_wins_over_active(
manager: AgentManager,
) -> None:
"""Pinned expert default takes precedence over the global active model."""
_seed_test_provider(manager)
manager._repos.settings_repo.set_active_model("test-openai", "gpt-4o-mini")
cfg = manager._build_harness_config(
_row(config_json=json.dumps({"default_model": "test-openai/gpt-4o-mini"})),
)
assert cfg.default_model == "test-openai/gpt-4o-mini"
def test_build_harness_config_no_providers_leaves_default_model_unset(
manager: AgentManager,
) -> None:
cfg = manager._build_harness_config(_row(default_model=None))
assert cfg.default_model is None
def test_build_harness_config_applies_usable_aux_model(manager: AgentManager) -> None:
_seed_test_provider(manager)
cfg = manager._build_harness_config(
_row(config_json=json.dumps({"memory": {"aux_model": "test-openai/gpt-4o-mini"}})),
)
assert cfg.memory_aux_light_model == "test-openai/gpt-4o-mini"
assert cfg.memory_aux_heavy_model == "test-openai/gpt-4o-mini"
def test_build_harness_config_ignores_stale_aux_model(manager: AgentManager) -> None:
_seed_test_provider(manager)
cfg = manager._build_harness_config(
_row(config_json=json.dumps({"memory": {"aux_model": "deleted-provider/gone"}})),
)
assert cfg.memory_aux_light_model is None
assert cfg.memory_aux_heavy_model is None
@pytest.mark.skip(
reason="HarnessAgentConfig monkeypatch incompatible with SecurityPolicy.apply_to_config"
)
def test_build_harness_config_passes_default_model_without_embedded_providers(
manager: AgentManager,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""default_model is forwarded; providers stay on HarnessAgentManager, not per-agent config."""
from octop.infra.agents import manager as mgr_mod
captured: list[dict] = []
class _FakeCfg:
def __init__(self, **kwargs: object) -> None:
captured.append(kwargs)
monkeypatch.setattr(mgr_mod, "HarnessAgentConfig", _FakeCfg)
manager._build_harness_config(_row(default_model="openai-live/MiniMax-M2.7"))
assert captured[0]["default_model"] == "openai-live/MiniMax-M2.7"
assert "providers" not in captured[0]
def test_build_harness_config_tolerates_bad_config_json(manager: AgentManager) -> None:
cfg = manager._build_harness_config(_row(config_json="{not-json"))
assert cfg.backend == _expected_default_backend(manager, "01AGENT")
@pytest.mark.asyncio
async def test_start_agent_real_harness_seeds_agents_md(manager: AgentManager) -> None:
"""Uses real HarnessAgentManager — no LLM call, only workspace init."""
from octop_harness import HarnessAgentManager
_seed_test_provider(manager)
manager._harness_manager = HarnessAgentManager(
providers=manager.providers.build_harness_configs(),
log_dir=str(manager.paths.logs_dir),
)
row = manager._repos.agent_repo.create(
agent_id="REAL01",
user_id=None,
name="real",
config_json=json.dumps(_MEMORY_OFF),
)
row = manager._repos.agent_repo.get("REAL01")
assert row is not None
from octop_harness import HarnessAgent
agent = await manager._start_agent(row)
assert isinstance(agent, HarnessAgent)
assert agent.workspace.exists("AGENTS.md")
db_row = manager.get_row("REAL01")
assert db_row is not None
assert db_row.last_state == "running"
manager._harness_manager.close()
@pytest.mark.asyncio
async def test_stop_and_start_round_trip(manager: AgentManager) -> None:
from octop_harness import HarnessAgentManager
_seed_test_provider(manager)
manager._harness_manager = HarnessAgentManager(
providers=manager.providers.build_harness_configs(),
log_dir=str(manager.paths.logs_dir),
)
manager._repos.agent_repo.create(
agent_id="STOP01",
user_id=None,
name="stop-me",
config_json=json.dumps(_MEMORY_OFF),
)
row = manager.get_row("STOP01")
assert row is not None
await manager._start_agent(row)
assert manager.get_row("STOP01") is not None
assert manager.get_row("STOP01").last_state == "running"
await manager.stop("STOP01")
assert manager.get_row("STOP01") is not None
assert manager.get_row("STOP01").last_state == "stopped"
with pytest.raises(OctopError, match="not running") as stopped_exc:
manager.get_agent("STOP01")
assert stopped_exc.value.code is ErrorCode.AGENT_NOT_RUNNING
await manager.start("STOP01")
assert manager.get_row("STOP01") is not None
assert manager.get_row("STOP01").last_state == "running"
manager.get_agent("STOP01")
manager._harness_manager.close()
@pytest.mark.asyncio
async def test_save_security_rebuilds_running_octop_harness(manager: AgentManager) -> None:
from octop_harness import HarnessAgentManager
_seed_test_provider(manager)
manager._harness_manager = HarnessAgentManager(
providers=manager.providers.build_harness_configs(),
log_dir=str(manager.paths.logs_dir),
)
manager._repos.agent_repo.create(
agent_id="SEC01",
user_id=None,
name="sec",
config_json=json.dumps(_MEMORY_OFF),
)
row = manager.get_row("SEC01")
assert row is not None
await manager._start_agent(row)
policy = manager.save_security(
{"hitl": {"enabled": False}, "tool_guard": {"enabled": True, "mode": "warn"}}
)
assert policy.hitl.enabled is False
assert policy.tool_guard.mode == "warn"
manager.get_agent("SEC01")
manager._harness_manager.close()
@pytest.mark.asyncio
async def test_reload_skips_stopped_agent(manager: AgentManager) -> None:
from octop_harness import HarnessAgentManager
_seed_test_provider(manager)
manager._harness_manager = HarnessAgentManager(
providers=manager.providers.build_harness_configs(),
log_dir=str(manager.paths.logs_dir),
)
manager._repos.agent_repo.create(
agent_id="SKIP01",
user_id=None,
name="skip",
config_json=json.dumps(_MEMORY_OFF),
)
row = manager.get_row("SKIP01")
assert row is not None
await manager._start_agent(row)
await manager.stop("SKIP01")
await manager.reload("SKIP01")
assert manager.get_row("SKIP01") is not None
assert manager.get_row("SKIP01").last_state == "stopped"
with pytest.raises(OctopError, match="not running") as skipped_exc:
manager.get_agent("SKIP01")
assert skipped_exc.value.code is ErrorCode.AGENT_NOT_RUNNING
manager._harness_manager.close()
@pytest.mark.asyncio
async def test_create_seeds_bootstrap_files(manager: AgentManager) -> None:
"""create() must seed harness workspace (BOOTSTRAP.md, AGENTS.md, …) before start."""
from octop_harness import HarnessAgentManager
from octop.infra.agents.manager import AgentCreateSpec
_seed_test_provider(manager)
manager._harness_manager = HarnessAgentManager(
providers=manager.providers.build_harness_configs(),
log_dir=str(manager.paths.logs_dir),
)
row = await manager.create(AgentCreateSpec(name="seeded", config=dict(_MEMORY_OFF)))
agent = manager.get_agent(row.agent_id)
assert agent.workspace.exists("BOOTSTRAP.md")
assert agent.workspace.exists("AGENTS.md")
cfg = manager.get_config(row.agent_id)
assert cfg.get("workspace_dir") == str(
manager.paths.ensure_agent_workspace(row.agent_id).resolve()
)
assert cfg.get("system_files_path") == ".octop"
ws = manager.resolve_workspace_dir(row.agent_id)
assert cfg.get("workspace_dir") == str(ws)
assert (ws / "AGENTS.md").is_file()
assert (ws / ".octop" / "_builtin_skills").is_dir() or agent.workspace.exists(
"_builtin_skills/skill-manager/SKILL.md"
)
assert not (ws / "_builtin_skills").exists()
manager._harness_manager.close()
@pytest.mark.asyncio
async def test_start_team_host_does_not_copy_builtin_skills(manager: AgentManager) -> None:
"""Team hosts skip harness init_workspace so skills are never copied."""
from octop_harness import HarnessAgent, HarnessAgentManager
_seed_test_provider(manager)
manager._harness_manager = HarnessAgentManager(
providers=manager.providers.build_harness_configs(),
log_dir=str(manager.paths.logs_dir),
)
manager._repos.agent_repo.create(
agent_id="TEAM01",
user_id=None,
name="team-host",
kind="team",
config_json=json.dumps(_MEMORY_OFF),
)
row = manager._repos.agent_repo.get("TEAM01")
assert row is not None
agent = await manager._start_agent(row, init_workspace=True)
assert isinstance(agent, HarnessAgent)
ws = manager.resolve_workspace_dir("TEAM01")
assert not (ws / "_builtin_skills").exists()
assert not (ws / ".octop" / "_builtin_skills").exists()
assert not agent.workspace.exists("_builtin_skills/skill-manager/SKILL.md")
assert await agent.list_skill_summaries() == []
manager._harness_manager.close()
@pytest.mark.asyncio
async def test_create_keeps_user_workspace_dir(
manager: AgentManager,
tmp_path: Path,
) -> None:
"""Explicit config.workspace_dir must not be replaced by the scoped default."""
from octop_harness import HarnessAgentManager
from octop.infra.agents.manager import AgentCreateSpec
_seed_test_provider(manager)
manager._harness_manager = HarnessAgentManager(
providers=manager.providers.build_harness_configs(),
log_dir=str(manager.paths.logs_dir),
)
custom = tmp_path / "custom-user-ws"
try:
row = await manager.create(
AgentCreateSpec(
name="user-ws",
config={
**_MEMORY_OFF,
"backend": {
"type": "local_shell",
"root_dir": str(tmp_path),
"virtual_mode": True,
},
"workspace_dir": str(custom),
},
)
)
cfg = manager.get_config(row.agent_id)
assert cfg["workspace_dir"] == str(custom)
assert manager.resolve_workspace_dir(row.agent_id) == custom.resolve()
assert custom.is_dir()
finally:
manager._harness_manager.close()
@pytest.mark.asyncio
async def test_create_persists_rootfs_workspace_under_scoped_root(
manager: AgentManager,
tmp_path: Path,
) -> None:
"""Non-host root_dir → config.workspace_dir is rootfs-absolute under that root."""
from octop_harness import HarnessAgentManager
from octop.infra.agents.manager import AgentCreateSpec
_seed_test_provider(manager)
manager._harness_manager = HarnessAgentManager(
providers=manager.providers.build_harness_configs(),
log_dir=str(manager.paths.logs_dir),
)
try:
# manager fixture uses ``{tmp_path}/.octop`` as OCTOP_HOME, so scoping
# root_dir to tmp_path places the default workspace inside the rootfs.
row = await manager.create(
AgentCreateSpec(
name="scoped-ws",
config={
**_MEMORY_OFF,
"backend": {
"type": "local_shell",
"root_dir": str(tmp_path),
"virtual_mode": True,
},
},
)
)
cfg = manager.get_config(row.agent_id)
assert cfg["workspace_dir"] == f"/.octop/workspaces/{row.agent_id}"
host = manager.resolve_workspace_dir(row.agent_id)
assert host == (tmp_path / ".octop" / "workspaces" / row.agent_id).resolve()
assert host.is_dir()
assert (host / "AGENTS.md").is_file()
harness_cfg = manager._build_harness_config(row)
# POSIX hands harness the persisted agent-facing path — not the host join.
# Windows cannot express it as absolute, so harness gets the host mapping.
expected_harness_ws = (
Path(f"/.octop/workspaces/{row.agent_id}") if os.name == "posix" else host
)
assert Path(harness_cfg.workspace_dir) == expected_harness_ws
assert not (tmp_path / ".octop" / "agents" / row.agent_id).exists()
finally:
manager._harness_manager.close()
def test_resolve_workspace_dir_uses_persisted_path(manager: AgentManager, tmp_path: Path) -> None:
custom = tmp_path / "custom-ws"
custom.mkdir()
manager._repos.agent_repo.create(
agent_id="WSDIR1",
user_id=None,
name="ws",
config_json=json.dumps({"workspace_dir": str(custom)}),
)
assert manager.resolve_workspace_dir("WSDIR1") == custom.resolve()
def test_resolve_workspace_dir_remaps_unwritable_host_path(
manager: AgentManager, tmp_path: Path
) -> None:
blocker = tmp_path / "not-a-dir"
blocker.write_text("x", encoding="utf-8")
stale = blocker / "YZQ7X4"
manager._repos.agent_repo.create(
agent_id="YZQ7X4",
user_id=None,
name="stale-root",
config_json=json.dumps(
{
"workspace_dir": str(stale),
"backend": {"type": "local_shell", "virtual_mode": True, "root_dir": "/"},
}
),
)
resolved = manager.resolve_workspace_dir("YZQ7X4")
assert resolved == manager.paths.ensure_agent_workspace("YZQ7X4").resolve()
assert manager.get_config("YZQ7X4")["workspace_dir"] == str(resolved)
def test_build_harness_config_remaps_unwritable_workspace(
manager: AgentManager, tmp_path: Path
) -> None:
blocker = tmp_path / "not-a-dir"
blocker.write_text("x", encoding="utf-8")
stale = blocker / "YZQ7X4"
manager._repos.agent_repo.create(
agent_id="YZQ7X4",
user_id=None,
name="stale-root",
config_json=json.dumps({"workspace_dir": str(stale), **_MEMORY_OFF}),
)
row = manager.get_row("YZQ7X4")
assert row is not None
cfg = manager._build_harness_config(row)
expected = manager.paths.ensure_agent_workspace("YZQ7X4").resolve()
assert Path(cfg.workspace_dir) == expected
assert manager.get_config("YZQ7X4")["workspace_dir"] == str(expected)
def test_resolve_workspace_dir_remaps_unwritable_scoped_root(
manager: AgentManager, tmp_path: Path
) -> None:
blocker = tmp_path / "not-a-dir"
blocker.write_text("x", encoding="utf-8")
manager._repos.agent_repo.create(
agent_id="F46T8Y",
user_id=None,
name="stale-jail",
config_json=json.dumps(
{
"workspace_dir": "/.octop/workspaces/F46T8Y",
"backend": {
"type": "local_shell",
"virtual_mode": True,
"root_dir": str(blocker),
},
}
),
)
resolved = manager.resolve_workspace_dir("F46T8Y")
assert resolved == manager.paths.ensure_agent_workspace("F46T8Y").resolve()
cfg = manager.get_config("F46T8Y")
assert cfg["workspace_dir"] == str(resolved)
assert cfg["backend"]["root_dir"] == "/"
def test_resolve_workspace_dir_backfills_legacy_row(manager: AgentManager) -> None:
manager._repos.agent_repo.create(
agent_id="WSDIR2",
user_id=None,
name="legacy",
config_json="{}",
)
resolved = manager.resolve_workspace_dir("WSDIR2")
assert resolved == manager.paths.ensure_agent_workspace("WSDIR2").resolve()
assert manager.get_config("WSDIR2")["workspace_dir"] == str(resolved)
@pytest.mark.asyncio
async def test_templated_agent_keeps_expert_soul_on_reload(manager: AgentManager) -> None:
"""Reload must not overwrite expert template SOUL.md with persona defaults."""
from octop_harness import HarnessAgentManager
from octop.infra.agents.experts.catalog import ExpertCatalog
from octop.infra.agents.manager import AgentCreateSpec
catalog = ExpertCatalog(default_library_root())
catalog.refresh()
ga = catalog.get("general-assistant")
assert ga is not None
manager._expert_catalog = catalog
_seed_test_provider(manager)
manager._harness_manager = HarnessAgentManager(
providers=manager.providers.build_harness_configs(),
log_dir=str(manager.paths.logs_dir),
)
row = await manager.create(
AgentCreateSpec(
name="tpl-bot",
template_name="general-assistant",
config=dict(_MEMORY_OFF),
),
)
agent = manager.get_agent(row.agent_id)
expected_soul = (default_library_root() / "general-assistant" / "SOUL.md").read_text(
encoding="utf-8"
)
soul_text = agent.workspace.read_text("SOUL.md") or ""
assert expected_soul.strip() in soul_text.strip()
await manager._reload_agent(row.agent_id)
soul_after = manager.get_agent(row.agent_id).workspace.read_text("SOUL.md") or ""
assert "Persona: Default" not in soul_after
assert expected_soul.strip() in soul_after
manager._harness_manager.close()
@pytest.mark.asyncio
async def test_seed_expert_template_writes_workspace_files(
manager: AgentManager, tmp_path: Path
) -> None:
from octop.infra.agents.experts.catalog import Expert, ExpertCatalog, ExpertSummary
expert_dir = tmp_path / "demo"
expert_dir.mkdir()
(expert_dir / "SOUL.md").write_text("# Soul", encoding="utf-8")
(expert_dir / "manifest.json").write_text(
json.dumps(
{
"id": "demo",
"label": {"zh": "演示", "en": "Demo"},
"description": {"zh": "", "en": ""},
"welcome_message": {"zh": "欢迎", "en": "Welcome"},
"quick_prompts": [],
}
),
encoding="utf-8",
)
catalog = MagicMock(spec=ExpertCatalog)
catalog.get = MagicMock(
return_value=Expert(
summary=ExpertSummary(
id="demo",
label_zh="演示",
label_en="Demo",
description_zh="",
description_en="",
),
files=["SOUL.md"],
prompt_files=["SOUL.md"],
),
)
catalog.expert_dir = MagicMock(return_value=expert_dir)
manager._expert_catalog = catalog
manager._repos.agent_repo.create(agent_id="AGT1", user_id=None, name="demo-bot")
agent_row = manager._repos.agent_repo.get("AGT1")
assert agent_row is not None
await manager._seed_expert_template(agent_row, "demo")
ws = manager._paths.ensure_agent_workspace("AGT1")
assert (ws / "SOUL.md").read_text(encoding="utf-8") == "# Soul"
manifest = json.loads((ws / ".octop" / "manifest.json").read_text(encoding="utf-8"))
assert manifest["id"] == "demo"
assert manifest["welcome_message"]["zh"] == "欢迎"
@pytest.mark.asyncio
async def test_persist_skills_disabled_does_not_schedule_reload(
manager: AgentManager, monkeypatch: pytest.MonkeyPatch
) -> None:
"""skills_disabled is hot-synced; must not tear down the running agent."""
from octop.infra.agents.manager import AgentCreateSpec
fake_agent = MagicMock()
fake_hm = MagicMock()
fake_hm.get_agent.return_value = MagicMock(agent=fake_agent)
fake_hm.acreate_agent = AsyncMock(return_value=MagicMock(agent=fake_agent))
fake_hm.shared_factory = object()
manager._harness_manager = fake_hm
row = await manager.create(AgentCreateSpec(name="skills-hot"))
scheduled: list[str] = []
monkeypatch.setattr(manager, "_schedule_reload", lambda aid: scheduled.append(aid))
await manager.persist_skills_disabled(row.agent_id, {"pdf", "docx"})
cfg = manager.get_config(row.agent_id)
assert cfg.get("skills_disabled") == ["docx", "pdf"]
fake_agent.set_skills_disabled.assert_called_once_with({"pdf", "docx"})
assert scheduled == []
@pytest.mark.asyncio
async def test_persist_tools_disabled_strips_critical_and_hot_syncs(
manager: AgentManager, monkeypatch: pytest.MonkeyPatch
) -> None:
"""tools_disabled is hot-synced; critical names are dropped."""
from octop.infra.agents.manager import AgentCreateSpec
fake_agent = MagicMock()
fake_hm = MagicMock()
fake_hm.get_agent.return_value = MagicMock(agent=fake_agent)
fake_hm.acreate_agent = AsyncMock(return_value=MagicMock(agent=fake_agent))
fake_hm.shared_factory = object()
manager._harness_manager = fake_hm
row = await manager.create(AgentCreateSpec(name="tools-hot"))
scheduled: list[str] = []
monkeypatch.setattr(manager, "_schedule_reload", lambda aid: scheduled.append(aid))
await manager.persist_tools_disabled(row.agent_id, {"web_fetch", "read_file", "execute"})
cfg = manager.get_config(row.agent_id)
assert cfg.get("tools_disabled") == ["execute", "web_fetch"]
fake_agent.set_tools_disabled.assert_called_once_with({"execute", "web_fetch"})
assert scheduled == []
@pytest.mark.asyncio
async def test_update_config_json_still_schedules_reload(
manager: AgentManager, monkeypatch: pytest.MonkeyPatch
) -> None:
from octop.infra.agents.manager import AgentCreateSpec
fake_agent = MagicMock()
fake_hm = MagicMock()
fake_hm.get_agent.return_value = MagicMock(agent=fake_agent)
fake_hm.acreate_agent = AsyncMock(return_value=MagicMock(agent=fake_agent))
fake_hm.shared_factory = object()
manager._harness_manager = fake_hm
row = await manager.create(AgentCreateSpec(name="cfg-reload"))
scheduled: list[str] = []
monkeypatch.setattr(manager, "_schedule_reload", lambda aid: scheduled.append(aid))
await manager.update_config_json(row.agent_id, json.dumps({"foo": 1}))
assert scheduled == [row.agent_id]
@pytest.mark.asyncio
async def test_update_config_json_cannot_change_system_files_path(
manager: AgentManager, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``system_files_path`` is an internal layout knob; user updates must not mutate it."""
from octop.infra.agents.manager import AgentCreateSpec
# Avoid starting harness runtime in this unit-test context.
row = await manager.create(AgentCreateSpec(name="sysfiles-fixed"), defer_bootstrap=True)
monkeypatch.setattr(manager, "_schedule_reload", lambda _aid: None)
assert manager.get_config(row.agent_id).get("system_files_path") == ".octop"
await manager.update_config_json(
row.agent_id,
json.dumps({"system_files_path": "", "foo": 1}),
)
assert manager.get_config(row.agent_id).get("system_files_path") == ".octop"
@pytest.mark.asyncio
async def test_update_config_json_cannot_move_the_workspace(
manager: AgentManager, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``workspace_dir`` is an internal layout knob too; a partial update must not move it.
Rewriting it silently relocates the agent's workspace: a scoped or container agent then
resolves to the classic layout and loses sight of its skills, sessions and generated files.
"""
from octop.infra.agents.manager import AgentCreateSpec
row = await manager.create(AgentCreateSpec(name="wsdir-fixed"), defer_bootstrap=True)
monkeypatch.setattr(manager, "_schedule_reload", lambda _aid: None)
scoped = str(manager.paths.root / "sandbox-root" / "ws" / row.agent_id)
# An agent created against a scoped/container root stores that workspace in its config.
manager._repos.agent_repo.update_config( # noqa: SLF001
agent_id=row.agent_id,
config_json=json.dumps({**manager.get_config(row.agent_id), "workspace_dir": scoped}),
)
assert manager.get_config(row.agent_id).get("workspace_dir") == scoped
# A third-party PATCH that only carries the fields it manages.
await manager.update_config_json(row.agent_id, json.dumps({"foo": 1}))
assert manager.get_config(row.agent_id).get("workspace_dir") == scoped
assert manager.resolve_workspace_dir(row.agent_id) == Path(scoped)
@pytest.mark.asyncio
async def test_internal_persist_can_still_set_the_workspace(manager: AgentManager) -> None:
"""The internal writer must keep writing ``workspace_dir`` (no over-pinning)."""
from octop.infra.agents.manager import AgentCreateSpec
row = await manager.create(AgentCreateSpec(name="wsdir-internal"), defer_bootstrap=True)
other = str(manager.paths.root / "other-ws" / row.agent_id)
cfg = manager.get_config(row.agent_id)
cfg["workspace_dir"] = other
manager.persist_harness_config(row.agent_id, cfg)
assert manager.get_config(row.agent_id).get("workspace_dir") == other
@pytest.mark.asyncio
async def test_reload_agent_does_not_block_event_loop(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Harness create/remove must run off the event loop (MCP init blocks the loop)."""
import asyncio
import time
from unittest.mock import MagicMock
from octop.infra.agents.manager import AgentCreateSpec, AgentManager
paths = PathLayout(tmp_path / ".octop")
paths.ensure_root()
db = SqlitePool(paths.db)
run_migrations(db)
services = build_shared_services(db=db, paths=paths, config=OctopConfig())
registry = AgentManager(repos=services.repos, paths=services.paths)
fake_hm = MagicMock()
fake_entry = MagicMock()
fake_entry.agent = MagicMock()
fake_hm.shared_factory = object()
fake_hm.acreate_agent = AsyncMock(return_value=fake_entry)
rebuild_started = asyncio.Event()
rebuild_finished = asyncio.Event()
post_start_finished = asyncio.Event()
async def slow_rebuild(*_args: object, **_kwargs: object) -> MagicMock:
rebuild_started.set()
await asyncio.to_thread(time.sleep, 0.15)
rebuild_finished.set()
return fake_entry
async def fake_post_start(*_args: object, **_kwargs: object) -> None:
post_start_finished.set()
fake_hm.arebuild_agent = AsyncMock(side_effect=slow_rebuild)
fake_hm.aremove_agent = AsyncMock()
registry._harness_manager = fake_hm
monkeypatch.setattr(registry, "_post_start_agent", fake_post_start)
row = await registry.create(AgentCreateSpec(name="block-test"))
post_start_finished.clear()
tick = asyncio.Event()
async def ticker() -> None:
await rebuild_started.wait()
tick.set()
ticker_task = asyncio.create_task(ticker())
await registry.update(row.agent_id, name="block-test-v2")
await asyncio.wait_for(tick.wait(), timeout=1.0)
assert not rebuild_finished.is_set()
await asyncio.wait_for(post_start_finished.wait(), timeout=1.0)
await ticker_task
def test_build_mcp_configs_registers_gateway_without_transport(manager: AgentManager) -> None:
"""Gateway connectors register a name-only placeholder; tools inject in-process."""
from octop.infra.connectors.builder import mcp_server_name
from octop.infra.connectors.crypto import encrypt_credentials
from octop.infra.utils.ulid import new_ulid
with manager._repos.db.transaction() as conn:
conn.execute(
"INSERT INTO users(username, password_hash, role, created_at) VALUES (?, ?, ?, 0)",
("gw", "h", "user"),
)
uid = conn.execute("SELECT id FROM users WHERE username = 'gw'").fetchone()["id"]
agent_id = manager._repos.agent_repo.create(agent_id="GWAGENT", user_id=uid, name="gw-agent")
iid = new_ulid()
mcp_name = mcp_server_name("tencent-ima", iid)
manager._repos.connector_repo.create(
instance_id=iid,
user_id=uid,
kind="tencent-ima",
display_name="IMA",
mcp_server_name=mcp_name,
)
creds = encrypt_credentials(
manager._repos.secret_repo,
{"api_key": "k", "client_id": "c", "internal_token": "tok"},
)
manager._repos.connector_repo.upsert_credentials(instance_id=iid, blob=creds, expires_at=None)
configs = manager._build_harness_config(manager.get_row(agent_id) or _row()).mcp_server_configs
assert mcp_name in configs
assert configs[mcp_name] == {}
def test_build_mcp_configs_shared_agent_uses_connector_user_override(manager: AgentManager) -> None:
"""Shared agents (user_id=NULL) need connector_user_override to resolve connectors."""
from octop.infra.connectors.builder import mcp_server_name
from octop.infra.connectors.crypto import encrypt_credentials
from octop.infra.utils.ulid import new_ulid
with manager._repos.db.transaction() as conn:
conn.execute(
"INSERT INTO users(username, password_hash, role, created_at) VALUES (?, ?, ?, 0)",
("shared-gw", "h", "user"),
)
uid = conn.execute("SELECT id FROM users WHERE username = 'shared-gw'").fetchone()["id"]
agent_id = manager._repos.agent_repo.create(agent_id="SHAREDAG", user_id=None, name="shared")
assert (
manager._build_harness_config(manager.get_row(agent_id) or _row()).mcp_server_configs == {}
)
iid = new_ulid()
mcp_name = mcp_server_name("tencent-ima", iid)
manager._repos.connector_repo.create(
instance_id=iid,
user_id=uid,
kind="tencent-ima",
display_name="IMA",
mcp_server_name=mcp_name,
)
creds = encrypt_credentials(
manager._repos.secret_repo,
{"api_key": "k", "client_id": "c", "internal_token": "tok"},
)
manager._repos.connector_repo.upsert_credentials(instance_id=iid, blob=creds, expires_at=None)
manager._connector_user_override[agent_id] = uid
try:
configs = manager._build_harness_config(
manager.get_row(agent_id) or _row()
).mcp_server_configs
finally:
manager._connector_user_override.pop(agent_id, None)
assert mcp_name in configs
def test_mcp_tool_filter_uses_server_prefix(manager: AgentManager) -> None:
"""Harness exposes MCP tools as {mcp_server_name}_{tool}; chat filters by prefix."""
from octop_harness.mcp import filter_tools_for_mcp_servers, mcp_tool_names
mcp_name = "tencent-ima__01INST"
tools = [{"name": f"{mcp_name}_list_notes"}, {"name": f"{mcp_name}_search_notes"}]
tool_set = mcp_tool_names(tools)
filtered = filter_tools_for_mcp_servers(
tools,
mcp_tool_names=tool_set,
server_names=frozenset({mcp_name}),
active_servers=[mcp_name],
)
assert len(filtered) == 2
def test_prepare_stream_request_maps_max_iters_to_recursion_limit(
manager: AgentManager,
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = "01AGENT"
monkeypatch.setattr(
manager,
"get_config",
lambda _agent_id: {"max_iters": 17},
)
req = manager._prepare_stream_request(agent_id, {"messages": "hi"})
assert req["recursion_limit"] == 17
def test_prepare_stream_request_maps_model_settings_and_max_input_tokens(
manager: AgentManager,
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = "01AGENT"
monkeypatch.setattr(
manager,
"get_config",
lambda _agent_id: {
"temperature": 0.2,
"top_p": 0.95,
"max_tokens": 2048,
"max_input_length": 32000,
},
)
req = manager._prepare_stream_request(agent_id, {"messages": "hi"})
assert req["configurable"]["model_settings"] == {
"temperature": 0.2,
"top_p": 0.95,
"max_tokens": 2048,
}
assert req["configurable"]["max_input_tokens"] == 32000
def test_peer_manifest_metadata_missing_file(manager: AgentManager) -> None:
manager._repos.agent_repo.create(agent_id="AGT1", user_id=None, name="demo")
assert manager._peer_manifest_metadata("AGT1", None) == {}
def test_peer_manifest_metadata_bad_json(manager: AgentManager) -> None:
manager._repos.agent_repo.create(agent_id="AGT1", user_id=None, name="demo")
ws = manager._paths.ensure_agent_workspace("AGT1")
(ws / ".octop").mkdir(parents=True, exist_ok=True)
(ws / ".octop" / "manifest.json").write_text("{", encoding="utf-8")
assert manager._peer_manifest_metadata("AGT1", "row") == {}
def test_peer_manifest_metadata_reads_cards(manager: AgentManager) -> None:
manager._repos.agent_repo.create(
agent_id="AGT1", user_id=None, name="demo", description="from-db"
)
ws = manager._paths.ensure_agent_workspace("AGT1")
(ws / ".octop").mkdir(parents=True, exist_ok=True)
(ws / ".octop" / "manifest.json").write_text(
json.dumps(
{
"description": {"zh": "manifest-only", "en": "manifest-only"},
"quick_prompts": [
{
"title": {"zh": "画图", "en": "Plot"},
"description": {"zh": "说明", "en": "Hint"},
"prompt": {"zh": "机密", "en": "secret"},
}
],
}
),
encoding="utf-8",
)
extra = manager._peer_manifest_metadata("AGT1", "from-db")
assert extra["quick_prompts"][0]["title"]["zh"] == "画图"
assert "description" not in extra
def test_refresh_peer_entry_picks_up_manifest_edits(manager: AgentManager) -> None:
manager._repos.agent_repo.create(
agent_id="AGT1", user_id=None, name="demo", description="from-db"
)
ws = manager._paths.ensure_agent_workspace("AGT1")
(ws / ".octop").mkdir(parents=True, exist_ok=True)
(ws / ".octop" / "manifest.json").write_text(
json.dumps(
{
"quick_prompts": [
{
"title": {"zh": "新卡", "en": "New"},
"description": {"zh": "说明", "en": "Hint"},
}
],
}
),
encoding="utf-8",
)
entry = MagicMock()
entry.agent_id = "AGT1"
entry.metadata = {
"description": "stale",
"quick_prompts": [{"title": "old"}],
}
manager._refresh_peer_entry(entry)
assert entry.metadata["description"] == "from-db"
assert entry.metadata["display_name"] == "demo"
assert entry.metadata["quick_prompts"][0]["title"]["zh"] == "新卡"
def test_refresh_peer_entry_clears_empty_cards(manager: AgentManager) -> None:
manager._repos.agent_repo.create(agent_id="AGT1", user_id=None, name="demo")
ws = manager._paths.ensure_agent_workspace("AGT1")
(ws / ".octop").mkdir(parents=True, exist_ok=True)
(ws / ".octop" / "manifest.json").write_text(
json.dumps({"quick_prompts": []}),
encoding="utf-8",
)
entry = MagicMock()
entry.agent_id = "AGT1"
entry.metadata = {"quick_prompts": [{"title": "old"}]}
manager._refresh_peer_entry(entry)
assert "quick_prompts" not in entry.metadata
async def test_stream_and_resume_hitl_serialize_per_thread(
manager: AgentManager,
) -> None:
"""A turn and a HITL resume on one thread must not overlap inside harness.
Dashboard turns are serialized per thread by the channel debounce lock, but
``POST /chat/hitl/resume`` drives the same checkpoint without it — so a
resume must still exclude an in-flight turn on the same thread.
"""
peak = 0
inside = 0
entered = asyncio.Event()
release = asyncio.Event()
async def _stream(agent_id: str, request: dict[str, Any], **_kw: Any) -> AsyncIterator[Any]:
nonlocal peak, inside
inside += 1
peak = max(peak, inside)
entered.set()
await release.wait()
inside -= 1
yield {"type": "token", "content": "ok"}
async def _resume(
agent_id: str, thread_id: str, decisions: list[Any], **_kw: Any
) -> AsyncIterator[Any]:
nonlocal peak, inside
inside += 1
peak = max(peak, inside)
await release.wait()
inside -= 1
yield {"type": "token", "content": "resumed"}
manager._harness_manager = SimpleNamespace(stream=_stream, resume_hitl=_resume)
turn = asyncio.create_task(_collect_async(manager.stream("01AGENT", {"thread_id": "thr-1"})))
await entered.wait()
resume = asyncio.create_task(
_collect_async(manager.resume_hitl("01AGENT", "thr-1", [{"type": "approve"}]))
)
await asyncio.sleep(0.05)
release.set()
await turn
await resume
assert peak == 1
def test_memory_extract_settings_preserves_zero_retries() -> None:
memory = {"extract_thinking": "off", "extract_max_retries": 0, "extract_retry_backoff_s": 0}
supported = frozenset(
{"memory_aux_thinking", "memory_aux_max_retries", "memory_aux_retry_backoff_s"}
)
assert _memory_extract_settings({"memory": memory}, supported_fields=supported) == {
"memory_aux_thinking": "off",
"memory_aux_max_retries": 0,
"memory_aux_retry_backoff_s": 0,
}
assert _memory_extract_settings({"memory": memory}, supported_fields=frozenset()) == {}
@pytest.mark.asyncio
@pytest.mark.parametrize("legacy_runtime", [False, True])
async def test_restore_respects_runtime_capability_and_expert_scope(
manager, monkeypatch, legacy_runtime
):
import sqlite3
from contextlib import closing
from langgraph.checkpoint.base import empty_checkpoint
from octop_harness import HarnessAgentManager
from octop_harness.middleware.memory import MemoryMiddleware
from octop.infra.agents.manager import AgentCreateSpec
from octop.infra.agents.memory.sqlite_restore import restore_sqlite
monkeypatch.setattr(MemoryMiddleware, "_arm_maintenance", lambda *a, **k: None)
if legacy_runtime:
monkeypatch.setattr(MemoryMiddleware, "pause_background", None, raising=False)
_seed_test_provider(manager)
manager._harness_manager = HarnessAgentManager(
providers=manager.providers.build_harness_configs(), log_dir=str(manager.paths.logs_dir)
)
rows = []
try:
for name in ("restore-one", "restore-other"):
rows.append(await manager.create(AgentCreateSpec(name=name)))
first, other = [manager.get_agent(row.agent_id) for row in rows]
old_memory = first._memory_runtime.memory
assert old_memory is not None
old_memory.put(
{"configurable": {"thread_id": "one", "checkpoint_ns": ""}}, empty_checkpoint(), {}, {}
)
path = Path(old_memory.backend._db_path)
backup = path.with_name(path.name + ".before-slim." + "a" * 32 + ".bak")
with closing(sqlite3.connect(path)) as source, closing(sqlite3.connect(backup)) as target:
source.backup(target)
old_memory.put(
{"configurable": {"thread_id": "two", "checkpoint_ns": ""}}, empty_checkpoint(), {}, {}
)
assert manager.try_begin_history_backfill(rows[0].agent_id)
if not callable(getattr(MemoryMiddleware, "pause_background", None)):
operation = MagicMock()
with pytest.raises(ValueError, match="does not support"):
await manager.restore_memory_store(
rows[0].agent_id, old_memory, operation, locale="en"
)
operation.assert_not_called()
assert manager.get_agent(rows[0].agent_id) is first
assert first._memory_runtime.memory is old_memory
assert manager.get_agent(rows[1].agent_id) is other
with closing(sqlite3.connect(path)) as conn:
assert conn.execute("SELECT count(*) FROM checkpoints").fetchone()[0] == 2
manager.end_history_backfill(rows[0].agent_id)
return
result = await manager.restore_memory_store(
rows[0].agent_id,
old_memory,
lambda: restore_sqlite(
path, old_memory.backend._ns, backup.name, locale="en", progress=lambda _: None
),
locale="en",
)
manager.end_history_backfill(rows[0].agent_id)
new_agent = manager.get_agent(rows[0].agent_id)
assert new_agent is not first
assert new_agent._memory_runtime.memory is not old_memory
assert manager.get_agent(rows[1].agent_id) is other
with closing(sqlite3.connect(path)) as conn:
assert conn.execute("SELECT count(*) FROM checkpoints").fetchone()[0] == 1
assert Path(result["backup_path"]).exists()
finally:
for row in rows:
await manager.stop(row.agent_id)