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>
205 lines
7.5 KiB
Python
205 lines
7.5 KiB
Python
"""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)
|