1차 v0.3 구현 후 plan-v0.3 와 대조해 발견된 18건 누락/명세 위반을 보강. 자기 리뷰 3 라운드 (누락·미완 / 오류·엣지케이스 / 과최적화) 모두 PASS. PR #5 plan-mode (3건): - BLOCKED_TOOLS_IN_PLAN_MODE 에 write_todos 추가 - /plan 시 system message inject (_PLAN_MODE_SYSTEM_PROMPT) - /approve 시 마지막 assistant 메시지를 "approved plan" system 으로 inject - InteractiveSession._pending_system_messages 인프라 신설 PR #2 compaction (1건): - CompactionResult.summary_text 추가, 다음 thread 첫 ainvoke 에 inject PR #3 auto-memory (6건): - global memory dir + bootstrap - frontmatter name/description/type 정식 도입 + MemoryEntry/MemoryType - _infer_memory_type (keyword heuristic, no LLM) - _scrub_secrets (OpenRouter/Anthropic/OpenAI/AWS/Bearer redaction) - /memory show <name> 서브명령 - /remember [--global] / /forget [--global] 스코프 토글 PR #4 skills (3건): - project_skills_dir + 두 스코프 (global / project) merge with last-wins - /skill <name> 본문 inject (queue_system_message) — 이전엔 REPL 출력만 - /skills show <name> 별도 서브명령 PR #6 sub-agent (4건): - budget.py `session:<uuid>` scope + CostMiddleware 자동 전달 - resolve_root_session_id walk-up (cycle guard) + sub-agent root 에 charge - run_subagent_to_completion 실제 ainvoke + 결과 push to parent - /agents 서브명령 구조 (list / spawn / show) + spawn 시 parent system msg PR #7 governance (1건): - bootstrap_user_dirs — instructions + global/memory + skills + projects 한 호출로 idempotent 부트스트랩 PR #8 Web GUI (1건): - index.html → 세션 목록, runs.html (신설) → workflow archive - conversation.html ?session=<id> deep-link PR #9 workflow integration (2건): - /workflow 백그라운드 WorkflowEngine.run + 진행 메시지 stream 누적 - /binding show <workflow-name[@version]> 인자 지원 테스트 (+17, 685 → 702 passed): - test_plan_mode: write_todos 차단 + blocklist sanity - test_memory: scrub + type 추론 + override - test_skills: project override + find_skill + resolve_skill_sources(pk) - test_subagents: resolve_root_session_id chain + missing fallback - test_budget: session: scope accumulation - test_instructions: governance bootstrap + idempotency - test_api_static: runs.html 신설 + index.html 재구성 게이트: - ruff check / format --check / mypy: PASS (141 source files) - pytest -q --ignore=tests/integration/test_e2e_workflow.py --ignore=tests/integration/test_openrouter_smoke.py: 702 passed Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
307 lines
10 KiB
Python
307 lines
10 KiB
Python
"""v0.3 PR #6 — Sub-agent session linkage tests.
|
|
|
|
Covers:
|
|
1. `spawn_subagent_session` creates a child row with correct parent_session_id,
|
|
depth = parent.depth + 1, inherited project_key.
|
|
2. Depth limit `MAX_SUBAGENT_DEPTH` rejects further spawns.
|
|
3. Spawn against ended/missing parent raises human_required errors.
|
|
4. `list_subagents` returns direct children in start-order, excludes grandchildren.
|
|
5. Persona upsert behaves correctly — same persona hash → same persona_id.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from my_deepagent.config import load_config
|
|
from my_deepagent.errors import MyDeepAgentError
|
|
from my_deepagent.persistence.db import Database
|
|
from my_deepagent.persistence.models import (
|
|
AgentPersonaRow,
|
|
InteractiveSessionRow,
|
|
)
|
|
from my_deepagent.persona import Persona
|
|
from my_deepagent.subagents import (
|
|
MAX_SUBAGENT_DEPTH,
|
|
list_subagents,
|
|
resolve_root_session_id,
|
|
spawn_subagent_session,
|
|
)
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(UTC).isoformat(timespec="seconds")
|
|
|
|
|
|
def _make_persona(name: str = "spec-writer") -> Persona:
|
|
return Persona(
|
|
name=name,
|
|
version=1,
|
|
backend="openrouter",
|
|
model="openrouter:deepseek/deepseek-chat",
|
|
provider_origin="CN/DeepSeek",
|
|
capabilities=("spec_write",),
|
|
max_risk_level="medium",
|
|
system_prompt="System prompt — at least ten chars",
|
|
deepagents_backend="state",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
async def db_with_root(tmp_path: Path) -> AsyncIterator[tuple[Database, str]]:
|
|
"""Database + one root InteractiveSessionRow with depth=0 + project_key='proj1234abcdef00'."""
|
|
db_url = f"sqlite+aiosqlite:///{tmp_path / 'subagent.sqlite3'}"
|
|
db = Database(db_url)
|
|
await db.init_schema()
|
|
|
|
persona_id = str(uuid.uuid4())
|
|
root_id = str(uuid.uuid4())
|
|
|
|
async with db.session() as s:
|
|
s.add(
|
|
AgentPersonaRow(
|
|
id=persona_id,
|
|
name="default-interactive",
|
|
version=1,
|
|
hash="parent-hash",
|
|
definition={"name": "default-interactive", "version": 1},
|
|
created_at=_now(),
|
|
)
|
|
)
|
|
s.add(
|
|
InteractiveSessionRow(
|
|
id=root_id,
|
|
persona_id=persona_id,
|
|
persona_hash="parent-hash",
|
|
started_at=_now(),
|
|
last_message_at=_now(),
|
|
state="active",
|
|
total_input_tokens=0,
|
|
total_output_tokens=0,
|
|
model="openrouter:deepseek/deepseek-chat",
|
|
project_key="proj1234abcdef00",
|
|
title="root",
|
|
plan_mode=False,
|
|
parent_session_id=None,
|
|
depth=0,
|
|
)
|
|
)
|
|
await s.commit()
|
|
try:
|
|
yield (db, root_id)
|
|
finally:
|
|
await db.dispose()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Happy path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spawn_creates_child_with_inherited_project_key(
|
|
db_with_root: tuple[Database, str],
|
|
) -> None:
|
|
db, root_id = db_with_root
|
|
persona = _make_persona()
|
|
child_id = await spawn_subagent_session(
|
|
db,
|
|
parent_session_id=uuid.UUID(root_id),
|
|
persona=persona,
|
|
initial_title="planner-1",
|
|
)
|
|
async with db.session() as s:
|
|
child = await s.get(InteractiveSessionRow, str(child_id))
|
|
assert child is not None
|
|
assert child.parent_session_id == root_id
|
|
assert child.depth == 1
|
|
assert child.project_key == "proj1234abcdef00" # inherited
|
|
assert child.title == "planner-1"
|
|
assert child.state == "active"
|
|
assert child.plan_mode is False
|
|
assert child.persona_hash == persona.compute_hash()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spawn_two_children_depth_one_each(
|
|
db_with_root: tuple[Database, str],
|
|
) -> None:
|
|
db, root_id = db_with_root
|
|
persona = _make_persona()
|
|
child_a = await spawn_subagent_session(
|
|
db, parent_session_id=uuid.UUID(root_id), persona=persona
|
|
)
|
|
child_b = await spawn_subagent_session(
|
|
db, parent_session_id=uuid.UUID(root_id), persona=persona
|
|
)
|
|
async with db.session() as s:
|
|
a = await s.get(InteractiveSessionRow, str(child_a))
|
|
b = await s.get(InteractiveSessionRow, str(child_b))
|
|
assert a is not None and b is not None
|
|
assert a.depth == b.depth == 1
|
|
assert a.parent_session_id == b.parent_session_id == root_id
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Depth limit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spawn_rejects_beyond_max_depth(db_with_root: tuple[Database, str]) -> None:
|
|
db, root_id = db_with_root
|
|
persona = _make_persona()
|
|
current = uuid.UUID(root_id)
|
|
# Chain spawns down to MAX_SUBAGENT_DEPTH (root depth=0; spawn produces 1, 2, 3).
|
|
for expected_depth in range(1, MAX_SUBAGENT_DEPTH + 1):
|
|
new_child = await spawn_subagent_session(db, parent_session_id=current, persona=persona)
|
|
async with db.session() as s:
|
|
row = await s.get(InteractiveSessionRow, str(new_child))
|
|
assert row is not None
|
|
assert row.depth == expected_depth
|
|
current = new_child
|
|
|
|
# Now `current` has depth=MAX_SUBAGENT_DEPTH (3) → spawn must reject.
|
|
with pytest.raises(MyDeepAgentError) as exc_info:
|
|
await spawn_subagent_session(db, parent_session_id=current, persona=persona)
|
|
assert exc_info.value.code == "subagent_depth_exceeded"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Invalid parent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spawn_missing_parent_raises(db_with_root: tuple[Database, str]) -> None:
|
|
db, _root_id = db_with_root
|
|
persona = _make_persona()
|
|
bogus = uuid.uuid4()
|
|
with pytest.raises(MyDeepAgentError) as exc_info:
|
|
await spawn_subagent_session(db, parent_session_id=bogus, persona=persona)
|
|
assert exc_info.value.code == "parent_session_missing"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spawn_ended_parent_raises(db_with_root: tuple[Database, str]) -> None:
|
|
db, root_id = db_with_root
|
|
async with db.session() as s:
|
|
row = await s.get(InteractiveSessionRow, root_id)
|
|
assert row is not None
|
|
row.state = "ended"
|
|
await s.commit()
|
|
persona = _make_persona()
|
|
with pytest.raises(MyDeepAgentError) as exc_info:
|
|
await spawn_subagent_session(db, parent_session_id=uuid.UUID(root_id), persona=persona)
|
|
assert exc_info.value.code == "parent_session_ended"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_subagents
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_subagents_returns_direct_children_only(
|
|
db_with_root: tuple[Database, str],
|
|
) -> None:
|
|
db, root_id = db_with_root
|
|
persona = _make_persona()
|
|
|
|
# root → child_a → grandchild
|
|
child_a = await spawn_subagent_session(
|
|
db, parent_session_id=uuid.UUID(root_id), persona=persona
|
|
)
|
|
child_b = await spawn_subagent_session(
|
|
db, parent_session_id=uuid.UUID(root_id), persona=persona
|
|
)
|
|
grandchild = await spawn_subagent_session(db, parent_session_id=child_a, persona=persona)
|
|
|
|
direct = await list_subagents(db, uuid.UUID(root_id))
|
|
ids = [r.id for r in direct]
|
|
assert str(child_a) in ids
|
|
assert str(child_b) in ids
|
|
assert str(grandchild) not in ids # depth-2 not in direct children
|
|
assert len(direct) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_subagents_no_children_returns_empty(
|
|
db_with_root: tuple[Database, str],
|
|
) -> None:
|
|
db, root_id = db_with_root
|
|
direct = await list_subagents(db, uuid.UUID(root_id))
|
|
assert direct == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Persona upsert
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_root_session_id_walks_to_root(
|
|
db_with_root: tuple[Database, str],
|
|
) -> None:
|
|
db, root_id = db_with_root
|
|
persona = _make_persona()
|
|
child = await spawn_subagent_session(db, parent_session_id=uuid.UUID(root_id), persona=persona)
|
|
grand = await spawn_subagent_session(db, parent_session_id=child, persona=persona)
|
|
great = await spawn_subagent_session(db, parent_session_id=grand, persona=persona)
|
|
|
|
assert (await resolve_root_session_id(db, uuid.UUID(root_id))) == uuid.UUID(root_id)
|
|
assert (await resolve_root_session_id(db, child)) == uuid.UUID(root_id)
|
|
assert (await resolve_root_session_id(db, grand)) == uuid.UUID(root_id)
|
|
assert (await resolve_root_session_id(db, great)) == uuid.UUID(root_id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_root_session_id_missing_returns_input(
|
|
db_with_root: tuple[Database, str],
|
|
) -> None:
|
|
db, _root_id = db_with_root
|
|
bogus = uuid.uuid4()
|
|
assert (await resolve_root_session_id(db, bogus)) == bogus
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_spawn_reuses_persona_row_for_same_hash(
|
|
db_with_root: tuple[Database, str],
|
|
) -> None:
|
|
db, root_id = db_with_root
|
|
persona = _make_persona("shared-persona")
|
|
|
|
child_a = await spawn_subagent_session(
|
|
db, parent_session_id=uuid.UUID(root_id), persona=persona
|
|
)
|
|
child_b = await spawn_subagent_session(
|
|
db, parent_session_id=uuid.UUID(root_id), persona=persona
|
|
)
|
|
|
|
async with db.session() as s:
|
|
a = await s.get(InteractiveSessionRow, str(child_a))
|
|
b = await s.get(InteractiveSessionRow, str(child_b))
|
|
assert a is not None and b is not None
|
|
assert a.persona_id == b.persona_id
|
|
assert a.persona_hash == b.persona_hash
|
|
# No duplicate AgentPersonaRow.
|
|
async with db.session() as s:
|
|
cfg = load_config(workspace_root=Path.cwd(), data_dir=Path.cwd() / "data") # noqa: F841
|
|
from sqlalchemy import select
|
|
|
|
rows = (
|
|
(
|
|
await s.execute(
|
|
select(AgentPersonaRow).where(AgentPersonaRow.hash == persona.compute_hash())
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
assert len(rows) == 1
|