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:
chungyeong
2026-05-17 21:11:19 +09:00
parent e326c07dcb
commit 361d6d7636
4 changed files with 465 additions and 2 deletions

View 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)