eigent/backend/tests/app/memory/test_service_lifecycle.py

429 lines
14 KiB
Python

# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""MemoryService end-to-end lifecycle tests (M2 + M4 + M5).
Verifies:
- on_run_start writes the full Space/Project/Run tree + the user prompt
- on_run_end persists the assistant final response + status=done
- runtime-log artifact (camel_logs) registered and marked not-eligible
- a *fresh* MemoryService instance reading the same root sees the prior
Project's summary and recent conversation -- simulating an app restart
"""
from __future__ import annotations
from pathlib import Path
import pytest
from app.memory import (
LocalMemoryStore,
MemoryService,
ProjectContextBuilder,
finalize_task_lock_run_memory,
)
from app.run_context import RunContext
def _make_run_context(tmp_path: Path) -> RunContext:
return RunContext(
space_id="space_lifecycle",
project_id="project_lifecycle",
run_id="run_lifecycle_1",
task_id="task_lifecycle_1",
email="alice@example.com",
user_id="42",
working_directory=tmp_path / "work",
task_output_root=tmp_path / "out",
camel_log_dir=tmp_path / "logs",
binding_source="default",
workdir_mode="artifact-only",
browser_port=9222,
)
@pytest.fixture
def store(tmp_path) -> LocalMemoryStore:
return LocalMemoryStore(root=tmp_path / "memory")
@pytest.fixture
def service(store) -> MemoryService:
return MemoryService(store=store)
@pytest.fixture
def run_context(tmp_path) -> RunContext:
return _make_run_context(tmp_path)
class TestRunStart:
def test_on_run_start_creates_full_tree(self, service, run_context):
service.on_run_start(
run_context=run_context,
space_name="My Workspace",
project_name="Memory Trial",
space_source_type="blank",
mode="single_agent",
user_prompt="Investigate Q2 dip",
)
store = service.store
space = store.read_space("user_42", run_context.space_id)
assert space is not None
assert space.name == "My Workspace"
project = store.read_project(
"user_42", run_context.space_id, run_context.project_id
)
assert project is not None
assert project.name == "Memory Trial"
assert project.last_run_id == run_context.run_id
run = store.read_run(
"user_42",
run_context.space_id,
run_context.project_id,
run_context.run_id,
)
assert run is not None
assert run.user_prompt == "Investigate Q2 dip"
status = store.read_run_status(
"user_42",
run_context.space_id,
run_context.project_id,
run_context.run_id,
)
assert status is not None
assert status.state == "running"
# First conversation event is the user prompt.
tail = store.read_conversation_tail(
"user_42", run_context.space_id, run_context.project_id, limit=10
)
assert len(tail) == 1
assert tail[0].role == "user"
assert tail[0].content == "Investigate Q2 dip"
assert tail[0].run_id == run_context.run_id
def test_on_run_start_with_no_identity_is_noop(self, service, tmp_path):
ctx_no_id = RunContext(
space_id="s",
project_id="p",
run_id="r",
task_id="t",
email="",
user_id=None,
working_directory=tmp_path / "work",
task_output_root=tmp_path / "out",
camel_log_dir=tmp_path / "logs",
binding_source="default",
workdir_mode=None,
browser_port=9222,
)
result = service.on_run_start(
run_context=ctx_no_id,
space_name=None,
project_name=None,
mode="single_agent",
user_prompt="hi",
)
assert result is None
# Nothing written -- memory root has no users/ subtree
assert not (service.store.root / "users").exists()
def test_on_run_start_deduplicates_a_stable_conversation_event(
self, service, run_context
):
for _ in range(2):
event_id = service.on_run_start(
run_context=run_context,
space_name="W",
project_name="P",
mode="single_agent",
user_prompt="retry-safe prompt",
conversation_event_id="memory:request-1",
)
tail = service.store.read_conversation_tail(
"user_42", run_context.space_id, run_context.project_id, limit=10
)
assert event_id == "memory:request-1"
assert [event.content for event in tail] == ["retry-safe prompt"]
def test_followup_run_inherits_mode_from_project(
self, service, run_context
):
# First turn declares workforce mode.
service.on_run_start(
run_context=run_context,
space_name="W",
project_name="P",
mode="workforce",
user_prompt="kick off",
)
first_run = service.store.read_run(
"user_42",
run_context.space_id,
run_context.project_id,
run_context.run_id,
)
assert first_run is not None and first_run.mode == "workforce"
# Follow-up turn (improve path) passes mode=None to avoid clobbering
# project.json -- run.json should still record the inherited mode.
from dataclasses import replace
followup_ctx = replace(run_context, run_id="run_lifecycle_2")
service.on_run_start(
run_context=followup_ctx,
space_name=None,
project_name=None,
mode=None, # caller defers to project.json
user_prompt="follow-up",
)
followup_run = service.store.read_run(
"user_42",
followup_ctx.space_id,
followup_ctx.project_id,
followup_ctx.run_id,
)
assert followup_run is not None
# Must NOT be None -- otherwise ContextBuilder picks the wrong profile.
assert followup_run.mode == "workforce"
def test_legacy_space_id_forces_source_type_legacy(
self, service, tmp_path
):
ctx = _make_run_context(tmp_path)
# Override with a legacy-prefixed space id.
from dataclasses import replace
ctx = replace(ctx, space_id="legacy_42")
service.on_run_start(
run_context=ctx,
space_name=None,
project_name=None,
space_source_type="blank", # caller said blank but...
mode="single_agent",
user_prompt="x",
)
space = service.store.read_space("user_42", "legacy_42")
assert space is not None
# ...legacy id wins.
assert space.source_type == "legacy"
class TestPerTurnWrites:
def test_human_reply_is_persisted_as_user_turn(self, service, run_context):
service.on_run_start(
run_context=run_context,
space_name="W",
project_name="P",
mode="single_agent",
user_prompt="create a script",
)
event_id = service.on_human_reply(
run_context=run_context,
content="a simple script is enough",
)
assert event_id is not None
tail = service.store.read_conversation_tail(
"user_42", run_context.space_id, run_context.project_id, limit=10
)
assert [event.role for event in tail] == ["user", "user"]
assert tail[-1].content == "a simple script is enough"
class TestRunEnd:
def test_on_run_end_done_appends_assistant_and_status(
self, service, run_context
):
service.on_run_start(
run_context=run_context,
space_name="W",
project_name="P",
mode="single_agent",
user_prompt="user q",
)
service.on_run_end(
run_context=run_context,
state="done",
final_result="assistant answer",
)
tail = service.store.read_conversation_tail(
"user_42", run_context.space_id, run_context.project_id, limit=10
)
# user prompt + assistant final
assert [e.role for e in tail] == ["user", "assistant"]
assert tail[-1].content == "assistant answer"
status = service.store.read_run_status(
"user_42",
run_context.space_id,
run_context.project_id,
run_context.run_id,
)
assert status is not None
assert status.state == "done"
assert status.ended_at is not None
def test_on_run_end_failed_records_error(self, service, run_context):
service.on_run_start(
run_context=run_context,
space_name="W",
project_name="P",
mode="single_agent",
user_prompt="q",
)
service.on_run_end(
run_context=run_context,
state="failed",
error="boom",
)
status = service.store.read_run_status(
"user_42",
run_context.space_id,
run_context.project_id,
run_context.run_id,
)
assert status is not None
assert status.state == "failed"
assert status.last_error == "boom"
class TestCamelLogsArtifact:
def test_register_runtime_log_marks_not_eligible(
self, service, run_context
):
service.on_run_start(
run_context=run_context,
space_name="W",
project_name="P",
mode="single_agent",
user_prompt="q",
)
service.register_runtime_log_artifact(
run_context=run_context,
relative_path=f"task_{run_context.run_id}/camel_logs",
)
arts = service.store.read_artifacts(
"user_42", run_context.space_id, run_context.project_id
)
assert len(arts) == 1
assert arts[0].kind == "runtime_log"
assert arts[0].visible_to_user is False
assert arts[0].eligible_for_context is False
assert arts[0].path.endswith("camel_logs")
class TestTaskLockFinalizer:
def test_legacy_task_lock_finalizer_is_retired_noop(
self, service, run_context
):
class DummyTaskLock:
pass
task_lock = DummyTaskLock()
task_lock.memory_service = service
task_lock.run_context = run_context
task_lock._memory_finalized_runs = set()
service.on_run_start(
run_context=run_context,
space_name="W",
project_name="P",
mode="single_agent",
user_prompt="q",
)
assert not finalize_task_lock_run_memory(
task_lock,
state="done",
final_result="answer",
)
assert not finalize_task_lock_run_memory(
task_lock,
state="cancelled",
final_result="late cancel",
)
status = service.store.read_run_status(
"user_42",
run_context.space_id,
run_context.project_id,
run_context.run_id,
)
assert status is not None
assert status.state == "running"
tail = service.store.read_conversation_tail(
"user_42", run_context.space_id, run_context.project_id, limit=10
)
assert [event.content for event in tail] == ["q"]
class TestCrossRestartRecovery:
def test_fresh_service_recovers_durable_context(
self, tmp_path, run_context
):
"""Simulate app restart: original service writes, a new service
instance bound to the same root reads + ContextBuilder assembles a
non-empty bundle."""
root = tmp_path / "memory"
# --- Original "run" ---
first_store = LocalMemoryStore(root=root)
first_service = MemoryService(store=first_store)
first_service.on_run_start(
run_context=run_context,
space_name="W",
project_name="P",
mode="single_agent",
user_prompt="investigate Q2 drop",
)
first_service.on_run_end(
run_context=run_context,
state="done",
final_result="Checked dashboards; pricing change is the cause.",
)
first_service.store.write_project_summary(
"user_42",
run_context.space_id,
run_context.project_id,
"Pricing change shipped 2026-04-15 caused Q2 retention drop.",
)
# --- Simulated restart: brand-new service + new run_id ---
from dataclasses import replace
second_store = LocalMemoryStore(root=root)
builder = ProjectContextBuilder(second_store)
new_run_context = replace(run_context, run_id="run_lifecycle_2")
bundle = builder.build(
user_key="user_42",
space_id=new_run_context.space_id,
project_id=new_run_context.project_id,
run_id=new_run_context.run_id,
mode="single_agent",
token_budget=4000,
current_user_prompt="What was the cause again?",
)
assert not bundle.is_empty()
assert "Pricing change" in bundle.project_summary
# The first run's user + assistant turns survived; they belong to a
# different run_id so the in-flight exclusion does not drop them.
roles = [e.role for e in bundle.recent_conversation]
assert roles == ["user", "assistant"]
rendered = bundle.to_prompt("single_agent")
assert "Pricing change" in rendered
assert "What was the cause again?" in rendered