feat(my-deepagent): v0.3 PR #9 — workflow optionization + user dir wiring
Workflow engine 을 주력에서 "옵션" 으로 격하: 사용자가 명시적
`/workflow <name>` 호출 시만 활성. 대신 `<data_dir>/personas/` 와
`<data_dir>/workflows/` 에 YAML 파일을 떨궈 자신만의 persona·workflow 를
등록할 수 있게 함 (seed override 가능).
핵심 동작:
- `ensure_user_dirs_initialized(config)` — 두 사용자 디렉터리 `mkdir -p`,
idempotent. 매 REPL 시작 시 호출.
- `load_combined_personas(config, seed_dir)` — seed (strict) + user
(best-effort per-file skip) merge. Dedupe key `(name, version)`,
user-overrides-seed. Broken user YAML 1개 가 REPL 죽이지 못함.
- `load_combined_workflows(config, seed_dir)` — workflow 도 동일.
데이터·라이브러리:
- `user_dirs.py` (신규): `user_personas_dir`, `user_workflows_dir`,
`ensure_user_dirs_initialized`, `load_combined_personas`,
`load_combined_workflows`, `_safe_load_personas`, `_safe_load_workflows`.
REPL 통합 (`cli/interactive.py`):
- `InteractiveSession(..., workflows=...)` 시그니처 확장.
- `_interactive_loop_async` 가 user dir bootstrap + combined load 사용.
- 신규 슬래시 4개:
- `/personas` — 로드된 persona 목록 (현재 활성 표시)
- `/workflows` — 로드된 workflow 템플릿 목록 (phase/role 개수, 파일명)
- `/workflow <name>` — `mydeepagent run` 명령 안내 (현재 백그라운드 invoke
는 안내 메시지만; 실제 kick-off 는 별도 PR 또는 `mydeepagent run` CLI)
- `/binding show` — 각 workflow 의 role 별 required_capabilities 표시
- `_register_workflow_slash` 의 복잡도(C901) 회피를 위해 print 헬퍼
(`_print_personas` 등) 를 module-level 로 추출.
테스트 (`tests/integration/test_user_dirs.py`, 10 케이스):
- 부트스트랩 idempotency
- persona seed-only / seed+user / user-overrides-seed / malformed-user-skip
- workflow 동일 4종
- 빈 user 디렉터리 처리
게이트:
- ruff check / format --check / mypy: PASS
- pytest -q --ignore=tests/integration/test_e2e_workflow.py
--ignore=tests/integration/test_openrouter_smoke.py: 685 passed (10 신규 포함)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
204
my-deepagent/tests/integration/test_user_dirs.py
Normal file
204
my-deepagent/tests/integration/test_user_dirs.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""v0.3 PR #9 — User-scope persona/workflow directory tests.
|
||||
|
||||
Covers:
|
||||
1. `ensure_user_dirs_initialized` creates both directories (idempotent).
|
||||
2. `load_combined_personas` returns seed + user, deduplicated by (name, version).
|
||||
3. User entries override seed entries with the same key (last-wins).
|
||||
4. Malformed user persona files are logged + skipped (don't kill the REPL).
|
||||
5. `load_combined_workflows` mirrors the persona behaviour for workflow YAMLs.
|
||||
6. Empty user dirs → seed-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
from my_deepagent.config import load_config
|
||||
from my_deepagent.user_dirs import (
|
||||
ensure_user_dirs_initialized,
|
||||
load_combined_personas,
|
||||
load_combined_workflows,
|
||||
user_personas_dir,
|
||||
user_workflows_dir,
|
||||
)
|
||||
|
||||
|
||||
def _write_persona_yaml(
|
||||
target: Path,
|
||||
*,
|
||||
name: str,
|
||||
version: int = 1,
|
||||
model: str = "openrouter:deepseek/deepseek-chat",
|
||||
backend: str = "openrouter",
|
||||
capabilities: list[str] | None = None,
|
||||
) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
caps = capabilities or ["code_edit"]
|
||||
cap_lines = "\n".join(f" - {c}" for c in caps)
|
||||
target.write_text(
|
||||
dedent(
|
||||
f"""\
|
||||
name: {name}
|
||||
version: {version}
|
||||
backend: {backend}
|
||||
model: "{model}"
|
||||
provider_origin: "CN/DeepSeek"
|
||||
capabilities:
|
||||
{cap_lines}
|
||||
max_risk_level: medium
|
||||
system_prompt: |
|
||||
Test persona system prompt (must be ≥10 chars).
|
||||
allowed_tools:
|
||||
- read_file
|
||||
- write_file
|
||||
deepagents_backend: state
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_workflow_yaml(target: Path, *, name: str, version: int = 1) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(
|
||||
dedent(
|
||||
f"""\
|
||||
name: {name}
|
||||
version: {version}
|
||||
description: "test workflow {name}"
|
||||
roles:
|
||||
- id: writer
|
||||
required_capabilities: [code_edit]
|
||||
phases:
|
||||
- key: p1
|
||||
title: "first phase"
|
||||
risk: medium
|
||||
role: writer
|
||||
gates: []
|
||||
expected_artifact:
|
||||
path: artifacts/foo.md
|
||||
schema: text
|
||||
instructions: "do something useful in this phase"
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ensure_user_dirs_creates_both(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
ensure_user_dirs_initialized(cfg)
|
||||
assert user_personas_dir(cfg).is_dir()
|
||||
assert user_workflows_dir(cfg).is_dir()
|
||||
|
||||
|
||||
def test_ensure_user_dirs_is_idempotent(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
ensure_user_dirs_initialized(cfg)
|
||||
# Drop a file to make sure repeat doesn't wipe it.
|
||||
_write_persona_yaml(user_personas_dir(cfg) / "p.yaml", name="custom-test")
|
||||
ensure_user_dirs_initialized(cfg)
|
||||
assert (user_personas_dir(cfg) / "p.yaml").is_file()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_combined_personas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_combined_personas_returns_seed_only_when_no_user(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
seed = tmp_path / "seed"
|
||||
_write_persona_yaml(seed / "a.yaml", name="alpha")
|
||||
_write_persona_yaml(seed / "b.yaml", name="bravo")
|
||||
personas = load_combined_personas(cfg, seed)
|
||||
names = sorted(p.name for p in personas)
|
||||
assert names == ["alpha", "bravo"]
|
||||
|
||||
|
||||
def test_load_combined_personas_adds_user(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
seed = tmp_path / "seed"
|
||||
_write_persona_yaml(seed / "a.yaml", name="alpha")
|
||||
_write_persona_yaml(user_personas_dir(cfg) / "user.yaml", name="my-custom")
|
||||
personas = load_combined_personas(cfg, seed)
|
||||
names = sorted(p.name for p in personas)
|
||||
assert names == ["alpha", "my-custom"]
|
||||
|
||||
|
||||
def test_load_combined_personas_user_overrides_seed(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
seed = tmp_path / "seed"
|
||||
_write_persona_yaml(seed / "alpha.yaml", name="alpha", model="seed-model")
|
||||
_write_persona_yaml(user_personas_dir(cfg) / "alpha.yaml", name="alpha", model="user-model")
|
||||
personas = load_combined_personas(cfg, seed)
|
||||
assert len(personas) == 1
|
||||
assert personas[0].name == "alpha"
|
||||
assert personas[0].model == "user-model" # user wins
|
||||
|
||||
|
||||
def test_load_combined_personas_skips_malformed_user_file(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
seed = tmp_path / "seed"
|
||||
_write_persona_yaml(seed / "a.yaml", name="alpha")
|
||||
bad = user_personas_dir(cfg) / "broken.yaml"
|
||||
bad.parent.mkdir(parents=True, exist_ok=True)
|
||||
bad.write_text("not: a valid: persona:::", encoding="utf-8")
|
||||
# Should not raise — broken file is logged + skipped.
|
||||
personas = load_combined_personas(cfg, seed)
|
||||
# Seed alpha is still present.
|
||||
assert any(p.name == "alpha" for p in personas)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_combined_workflows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_combined_workflows_seed_only(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
seed = tmp_path / "wf-seed"
|
||||
_write_workflow_yaml(seed / "a.yaml", name="wfa")
|
||||
workflows = load_combined_workflows(cfg, seed)
|
||||
names = sorted(t.name for (_p, t) in workflows)
|
||||
assert names == ["wfa"]
|
||||
|
||||
|
||||
def test_load_combined_workflows_user_overrides_seed(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
seed = tmp_path / "wf-seed"
|
||||
_write_workflow_yaml(seed / "wfa.yaml", name="wfa", version=1)
|
||||
_write_workflow_yaml(user_workflows_dir(cfg) / "wfa.yaml", name="wfa", version=1)
|
||||
workflows = load_combined_workflows(cfg, seed)
|
||||
# Dedupe by (name, version) — only the user version remains.
|
||||
assert len(workflows) == 1
|
||||
path, tpl = workflows[0]
|
||||
assert tpl.name == "wfa"
|
||||
assert path.parent == user_workflows_dir(cfg)
|
||||
|
||||
|
||||
def test_load_combined_workflows_user_adds_distinct(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
seed = tmp_path / "wf-seed"
|
||||
_write_workflow_yaml(seed / "a.yaml", name="wfa")
|
||||
_write_workflow_yaml(user_workflows_dir(cfg) / "user.yaml", name="userwf")
|
||||
workflows = load_combined_workflows(cfg, seed)
|
||||
names = sorted(t.name for (_p, t) in workflows)
|
||||
assert names == ["userwf", "wfa"]
|
||||
|
||||
|
||||
def test_load_combined_workflows_skips_malformed(tmp_path: Path) -> None:
|
||||
cfg = load_config(workspace_root=tmp_path, data_dir=tmp_path / "data")
|
||||
seed = tmp_path / "wf-seed"
|
||||
_write_workflow_yaml(seed / "a.yaml", name="wfa")
|
||||
bad = user_workflows_dir(cfg) / "broken.yaml"
|
||||
bad.parent.mkdir(parents=True, exist_ok=True)
|
||||
bad.write_text("not: a workflow:::", encoding="utf-8")
|
||||
workflows = load_combined_workflows(cfg, seed)
|
||||
assert any(t.name == "wfa" for (_p, t) in workflows)
|
||||
Reference in New Issue
Block a user