refactor: standardize --agent option usage, improve YAML registry fallback, and fix layout falsy-zero trap (J-1)
- In stop_session.sh and update_yaml_resumed.sh: standardize explicit --agent option and use resolve_agent_type_from_registry to read agent type from YAML/DB state rather than brittle suffix-only regex inference. - Update multi-agent-mux-stop/SKILL.md, multi-agent-mux-resume/SKILL.md, multi-agent-mux-create/SKILL.md, and deploy/INSTALL.md to standardize passing --agent explicitly. - Fix J-1 in layout.py: refactor _env_int(*names, default=None) to take an explicit default parameter, eliminating the falsy-zero trap so MAM_MIN_PANE_COLS=0 is respected. - Add regression and contract tests: test_j1_env_zero_min_cols_matches_flag_zero, test_j1_env_zero_min_rows_matches_flag_zero, test_comp_stop_agent_fallback_*, test_comp_docs_stop_examples_pass_agent. - Verified 100% UNANIMOUS PASS from Planner claude and Reviewers claude and cline.
This commit is contained in:
@@ -46,6 +46,15 @@ def test_agent_of_row_priority():
|
||||
row3 = {'pane': {'cmd': 'cline'}}
|
||||
assert agent_of_row(row3) == 'cline'
|
||||
|
||||
def test_agent_of_row_pane_cmd_binary_path_and_failure():
|
||||
# pane.cmd 가 절대 경로 형태여도 해석된다
|
||||
assert agent_of_row({'pane': {'cmd': '/usr/local/bin/agy'}}) == 'agy'
|
||||
# 세 경로 모두 실패하면 None — 호출자가 오류를 소유한다
|
||||
assert agent_of_row({}, session_name='bad-session-name') is None
|
||||
# 입양 조회용 match_cmd=False 에서는 pane.cmd 를 보지 않는다
|
||||
assert agent_of_row({'name': 'agy-creator-01', 'pane': {'cmd': 'agy'}},
|
||||
match_cmd=False) is None
|
||||
|
||||
def test_adapter_required_properties():
|
||||
from lib_py.agents.base import BaseAgentAdapter
|
||||
base = BaseAgentAdapter()
|
||||
|
||||
@@ -403,7 +403,79 @@ def test_headless_max_columns_growth_guard():
|
||||
d3 = compute_2xk_layout(headless(3), max_columns=2)
|
||||
assert d3.direction == "down" and not d3.is_overflow
|
||||
|
||||
# n=5 is the first odd n that can discriminate: n//2 == 2 == max_columns, so an
|
||||
# over-correction that also checked the cap on the odd branch would return
|
||||
# overflow here. n=3 has n//2 == 1 and cannot reach the check at all.
|
||||
d5 = compute_2xk_layout(headless(5), max_columns=2)
|
||||
assert d5.direction == "down" and not d5.is_overflow
|
||||
assert d5.reason == "headless_odd_down"
|
||||
|
||||
# When max_columns is not set, existing alternation is preserved (behavior neutrality)
|
||||
assert compute_2xk_layout(headless(4)).direction == "right"
|
||||
|
||||
|
||||
_LAYOUT_ENV_VARS = ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", "MAM_MIN_ROWS",
|
||||
"MAM_MIN_PANE_ROWS", "MAM_MAX_COLS", "MAM_MAX_PANE_COLS")
|
||||
|
||||
|
||||
def _run_layout(payload, args=(), env_extra=None):
|
||||
env = {**os.environ, "PYTHONPATH": os.path.abspath(".agents/skills")}
|
||||
for k in _LAYOUT_ENV_VARS:
|
||||
env.pop(k, None) # 호출자 셸의 오염 차단
|
||||
env.update(env_extra or {})
|
||||
res = subprocess.run([sys.executable, "-m", "lib_py.layout", "--json", *args],
|
||||
input=json.dumps(payload), capture_output=True, text=True, env=env)
|
||||
assert res.returncode == 0, res.stderr
|
||||
return json.loads(res.stdout)
|
||||
|
||||
|
||||
# height//2 = 15 < min_rows(20) 로 제약 분기 진입, width//2 = 25 가 min_cols 와 비교됨.
|
||||
_ZERO_TRAP = {"result": {"panes": [
|
||||
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 30}}]}}
|
||||
|
||||
|
||||
def test_j1_env_zero_min_cols_matches_flag_zero():
|
||||
"""J-1: MAM_MIN_PANE_COLS=0 must mean 0, not fall through to the 60 default."""
|
||||
flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0"))
|
||||
assert flag["direction"] == "right" and flag["reason"] == "single_pane_height_constrained"
|
||||
for var in ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS"):
|
||||
assert _run_layout(_ZERO_TRAP, (), {var: "0"}) == flag, var
|
||||
|
||||
|
||||
def test_j1_env_zero_min_rows_matches_flag_zero():
|
||||
flag = _run_layout(_ZERO_TRAP, ("--min-rows", "0"))
|
||||
assert flag["direction"] == "down" and flag["reason"] == "single_pane_split_down"
|
||||
for var in ("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS"):
|
||||
assert _run_layout(_ZERO_TRAP, (), {var: "0"}) == flag, var
|
||||
|
||||
|
||||
def test_j1_nonzero_and_malformed_env_behaviour_unchanged():
|
||||
"""Behaviour neutrality: non-zero env still applies, and a lone typo still
|
||||
lands on the documented default instead of crashing on a None comparison."""
|
||||
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25"}) == \
|
||||
_run_layout(_ZERO_TRAP, ("--min-cols", "25"))
|
||||
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "abc"}) == _run_layout(_ZERO_TRAP)
|
||||
|
||||
|
||||
def test_j1b_invalid_alias_does_not_shadow_the_documented_var():
|
||||
"""C-2: MAM_MIN_COLS is a legacy alias checked first; MAM_MIN_PANE_COLS is the
|
||||
name .mam.env.example documents. An unparsable value in the alias must be
|
||||
skipped, not abort the search and discard the documented setting.
|
||||
|
||||
Empty values already fell through (`if raw:`); this makes invalid values
|
||||
behave the same way. When every candidate is unusable, `default` still wins.
|
||||
"""
|
||||
good = _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25"})
|
||||
assert good["direction"] == "right"
|
||||
# 별칭이 깨져 있어도 문서화된 변수가 적용된다
|
||||
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
|
||||
"MAM_MIN_PANE_COLS": "25"}) == good
|
||||
# 0 도 마찬가지 (J-1 과의 상호작용)
|
||||
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
|
||||
"MAM_MIN_PANE_COLS": "0"}) == \
|
||||
_run_layout(_ZERO_TRAP, ("--min-cols", "0"))
|
||||
# 모든 후보가 무효면 문서화된 기본값으로 흡수 (Rev.1 불변식 보존)
|
||||
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
|
||||
"MAM_MIN_PANE_COLS": "bar"}) == _run_layout(_ZERO_TRAP)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import json
|
||||
import sqlite3
|
||||
@@ -293,7 +294,7 @@ d['herdr_sessions'] = [{
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 3: Stop Session (4 Test Cases)
|
||||
# FEATURE 3: Stop Session (7 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
def test_comp_stop_sqlite_state_update(mam_sandbox):
|
||||
@@ -411,6 +412,70 @@ d['herdr_sessions'] = [{
|
||||
assert any(("send" in call or "prompt" in call) and "/exit" in call for call in calls)
|
||||
|
||||
|
||||
def test_comp_stop_agent_fallback_reads_pane_cmd(mam_sandbox):
|
||||
"""B-21: --agent 생략 시 세션명에 에이전트 접미사가 없어도 레지스트리 행의
|
||||
pane.cmd 로 해석된다 (라이브 `agy-creator-01` 형태)."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'agy-creator-01',
|
||||
'status': 'running',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER', 'cmd': 'agy'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
res = subprocess.run(["bash", str(script), "--session", "agy-creator-01"],
|
||||
capture_output=True, text=True)
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert re.search(r"^\s*agent:\s+agy\s*$", res.stdout, re.M), res.stdout
|
||||
|
||||
|
||||
def test_comp_stop_agent_fallback_prefers_explicit_agent_field(mam_sandbox):
|
||||
"""우선순위 계약: 명시 `agent` 필드가 세션명 접미사와 pane.cmd 를 모두 이긴다."""
|
||||
mutation = """
|
||||
d['herdr_sessions'] = [{
|
||||
'name': 'x-creator-claude',
|
||||
'status': 'running',
|
||||
'agent': 'hermes',
|
||||
'pane': {'cwd': 'WS_PLACEHOLDER', 'cmd': 'claude'}
|
||||
}]
|
||||
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
|
||||
run_mutation(mam_sandbox, mutation)
|
||||
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
||||
res = subprocess.run(["bash", str(script), "--session", "x-creator-claude"],
|
||||
capture_output=True, text=True)
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert re.search(r"^\s*agent:\s+hermes\s*$", res.stdout, re.M), res.stdout
|
||||
|
||||
|
||||
# 코드 펜스 안의 stop_session.sh 호출을 '명령 단위'로 잘라낸다.
|
||||
# - 펜스 스코프: 산문 속 `stop_session.sh` 언급을 명령으로 오인하지 않는다
|
||||
# (Pitfalls / When-NOT-to-use 절은 성격상 스크립트를 산문으로 언급한다).
|
||||
# - 명령 단위: 한 펜스에 여러 호출이 들어 있어도 각각을 따로 검증한다
|
||||
# (블록 단위로 보면 그중 하나만 --agent 를 가져도 통과해 버린다).
|
||||
_FENCE_RE = re.compile(r"```(?:bash|sh)\n(.*?)```", re.S)
|
||||
_STOP_CALL_RE = re.compile(r"(?:bash\s+)?\S*stop_session\.sh[^\n\\]*(?:\\\n[^\n\\]*)*")
|
||||
|
||||
|
||||
def test_comp_docs_stop_examples_pass_agent():
|
||||
"""B-21 문서 계약: 문서의 모든 stop_session.sh 예제는 --agent 를 넘긴다.
|
||||
문서 변경은 뮤테이션 감도가 없으므로 이 가드가 표준의 유일한 집행 장치다."""
|
||||
repo = Path(__file__).resolve().parent.parent
|
||||
expected = { # 문서별 최소 예제 수 — 예제를 지워 가드를 무력화하는 것을 막는다
|
||||
repo / ".agents/skills/multi-agent-mux-stop/SKILL.md": 3,
|
||||
repo / "deploy/INSTALL.md": 2,
|
||||
}
|
||||
for doc, floor in expected.items():
|
||||
seen = 0
|
||||
for block in _FENCE_RE.findall(doc.read_text()):
|
||||
for m in _STOP_CALL_RE.finditer(block):
|
||||
snippet = m.group(0)
|
||||
seen += 1
|
||||
assert "--agent" in snippet, \
|
||||
f"{doc.name}: stop_session.sh example without --agent:\n{snippet}"
|
||||
assert seen >= floor, f"{doc.name}: expected >= {floor} examples, saw {seen}"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# FEATURE 4: Status Query (5 Test Cases)
|
||||
# ==============================================================================
|
||||
|
||||
Reference in New Issue
Block a user