diff --git a/manager/core/.env.example b/manager/core/.env.example index b3e4790..ee6d556 100644 --- a/manager/core/.env.example +++ b/manager/core/.env.example @@ -15,6 +15,19 @@ BOARD_AGENT_ADAPTER=claude BOARD_CLAUDE_BIN=claude BOARD_OPENCODE_BIN=opencode +# The model headless agents run on — an opaque vendor-native name the +# adapter passes through untranslated (claude: a model name or alias for +# --model; opencode: the "provider/model-id" config key). Empty = inherit +# the vendor's own default, i.e. whatever the CLI on this machine would +# pick anyway. The per-intent settings beat the general one for their +# intent only; _REVIEW covers both PR reviews and relevance checks. Work +# agents write code; reviews just read and judge — they can ride a +# cheaper, faster model. +BOARD_AGENT_MODEL= +BOARD_AGENT_MODEL_WORK= +BOARD_AGENT_MODEL_ACT_PR= +BOARD_AGENT_MODEL_REVIEW= + # Command prefixes headless agents may run in their worktree — the # project's test/check commands, comma-separated, in plain neutral form # (each adapter renders them into its vendor's permission rules; the diff --git a/manager/core/adapters/README.md b/manager/core/adapters/README.md index 4a4dc0c..93ab132 100644 --- a/manager/core/adapters/README.md +++ b/manager/core/adapters/README.md @@ -15,9 +15,9 @@ An adapter is a directory with two executables: - env in: `AGENT_PROMPT` (the full prompt), `AGENT_MODE` (the launch intent, below), `AGENT_COMMANDS` (the project's allowed command - prefixes, below), `AGENT_CWD`, and the `BOARD_*` passthrough - (`BOARD_AGENT_ID`, `BOARD_TASK`, `BOARD_PORT`) which your event bridge - must forward with every event. + prefixes, below), `AGENT_MODEL` (optional, below), `AGENT_CWD`, and + the `BOARD_*` passthrough (`BOARD_AGENT_ID`, `BOARD_TASK`, + `BOARD_PORT`) which your event bridge must forward with every event. - stdout is captured by the board as the job log. The prompts instruct the agent to end with marker lines (`NOT READY:`, `RELEVANCE REVIEW:`, `PR REVIEW:`, `ADDRESSED:`) — the board parses them from this output, so @@ -57,6 +57,17 @@ prefix-pattern based, so the translation is mechanical: "allow"}}` in a generated config, wildcard rules, last match wins (`opencode/permission_config.py`) +### The model (`AGENT_MODEL`) — optional + +Absent = the vendor's own default: launch without any model argument and +let your CLI resolve it however it normally would. When set, it is an +opaque vendor-native model name — a claude alias, an opencode +`provider/model-id` — that core never validates or interprets; pass it +through untranslated (claude → `--model "$AGENT_MODEL"`, opencode → the +generated config's `model` key). Never send your vendor an empty value: +the board only sets the variable when a model is actually configured +(`BOARD_AGENT_MODEL` and its per-intent overrides in `local/.env`). + ### `wire` — wire live-session visibility into the host project Called by `install.py` with the project root as argv[1] (plus `--dry-run`). diff --git a/manager/core/adapters/claude/run b/manager/core/adapters/claude/run index b447b80..af06ee7 100755 --- a/manager/core/adapters/claude/run +++ b/manager/core/adapters/claude/run @@ -7,6 +7,9 @@ # (see core/adapters/README.md) # AGENT_COMMANDS comma-separated neutral command prefixes the # project lets agents run (tests/checks) +# AGENT_MODEL optional; a claude model name/alias passed +# through as --model. Absent = the CLI's own +# resolution (user settings), untouched. # AGENT_CWD working directory (already set as cwd by the board) # BOARD_* passthrough for the event bridge # stdout: captured by the board as the job log; the closing report's @@ -25,12 +28,20 @@ BIN="${BOARD_CLAUDE_BIN:-claude}" MODE="${AGENT_MODE:-work}" SETTINGS="$(python3 "$HERE/hook_settings.py" "$MODE")" +# The ${arr[@]+...} expansion keeps set -u happy on bash 3.2 when unset. +MODEL_ARGS=() +if [ -n "${AGENT_MODEL:-}" ]; then + MODEL_ARGS=(--model "$AGENT_MODEL") +fi + if [ "$MODE" = "review" ]; then exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \ + ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \ --permission-mode default \ --disallowedTools Edit Write NotebookEdit else # work and act-pr both mutate the worktree; the allowlist differs. exec "$BIN" -p "$AGENT_PROMPT" --settings "$SETTINGS" \ + ${MODEL_ARGS[@]+"${MODEL_ARGS[@]}"} \ --permission-mode acceptEdits fi diff --git a/manager/core/adapters/opencode/permission_config.py b/manager/core/adapters/opencode/permission_config.py index 94ac91f..bbac75f 100644 --- a/manager/core/adapters/opencode/permission_config.py +++ b/manager/core/adapters/opencode/permission_config.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """Print the opencode config JSON for one headless launch: the permission -rules for the launch's intent. +rules for the launch's intent, plus the model when the board configured one. Usage: permission_config.py [work|act-pr|review] @@ -21,6 +21,10 @@ and never a blanket allow: the worktree is isolated, the shell is not. The project's test/check commands arrive in AGENT_COMMANDS as comma- separated neutral command prefixes (set BOARD_AGENT_COMMANDS in local/.env); here each becomes "" and " *" allow rules. + +AGENT_MODEL, when set, becomes the config's top-level "model" key — +opencode's "provider/model-id" form (opencode.ai/docs/config), passed +through untranslated. Absent = no key, opencode's own resolution applies. """ import json import os @@ -56,17 +60,21 @@ def bash_rules(mode: str, commands: list[str]) -> dict: return rules -def build_config(mode: str, commands: list[str]) -> dict: - return { +def build_config(mode: str, commands: list[str], model: str = "") -> dict: + config = { "$schema": "https://opencode.ai/config.json", "permission": { "edit": "deny" if mode == "review" else "allow", "bash": bash_rules(mode, commands), }, } + if model: + config["model"] = model + return config if __name__ == "__main__": mode = sys.argv[1] if len(sys.argv) > 1 else "work" commands = split_commands(os.environ.get("AGENT_COMMANDS", "")) - print(json.dumps(build_config(mode, commands))) + model = os.environ.get("AGENT_MODEL", "").strip() + print(json.dumps(build_config(mode, commands, model))) diff --git a/manager/core/adapters/opencode/run b/manager/core/adapters/opencode/run index dbe901d..2a13bff 100755 --- a/manager/core/adapters/opencode/run +++ b/manager/core/adapters/opencode/run @@ -7,6 +7,9 @@ # (see core/adapters/README.md) # AGENT_COMMANDS comma-separated neutral command prefixes the # project lets agents run (tests/checks) +# AGENT_MODEL optional; opencode's "provider/model-id" form, +# set as the generated config's model key. +# Absent = opencode's own resolution, untouched. # AGENT_CWD working directory (already set as cwd by the board) # BOARD_* passthrough for the event bridge # stdout: captured by the board as the job log; `opencode run` prints diff --git a/manager/core/agents.py b/manager/core/agents.py index bcbab95..77fd970 100644 --- a/manager/core/agents.py +++ b/manager/core/agents.py @@ -81,6 +81,9 @@ def _agent_public(record: dict) -> dict: ("id", "task", "branch", "worktree", "status", "rc", "started", "session")} public["mode"] = record.get("mode", "work") public["name"] = record.get("name") + # The model the launch was actually given; None = inherited the + # vendor's own default. Honesty for the Sessions/Focus views. + public["model"] = record.get("model") return public @@ -111,9 +114,11 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log The adapter contract: `run` gets AGENT_PROMPT, AGENT_MODE (the intent: work = mutate and commit, act-pr = work + push, review = read-only + - post PR verdicts) and AGENT_COMMANDS (the project's runnable command - prefixes) plus the BOARD_* passthrough for its event bridge; its - stdout is the job log; exit 0 = completed. + post PR verdicts), AGENT_COMMANDS (the project's runnable command + prefixes) and AGENT_MODEL (the configured model, when there is one) + plus the BOARD_* passthrough for its event bridge; its stdout is the + job log; exit 0 = completed. Returns (proc, log_file, model) with + model = '' when the launch inherits the vendor default. """ adapter = config.adapter_dir() if adapter is None: @@ -130,6 +135,13 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log "BOARD_TASK": filename, "BOARD_PORT": str(state.serve_port), }) + model = config.agent_model(mode) + if model: + env["AGENT_MODEL"] = model + else: + # Inherit = the variable is simply absent. Popping also stops a + # stray AGENT_MODEL in the board's own environment leaking through. + env.pop("AGENT_MODEL", None) log_file = log_path.open("wb") try: proc = subprocess.Popen( @@ -138,7 +150,7 @@ def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log except OSError as exc: log_file.close() raise ValueError(f"could not launch adapter {adapter}: {exc}") - return proc, log_file + return proc, log_file, model def start_agent(filename: str, stage: str) -> dict: @@ -185,7 +197,7 @@ def start_agent(filename: str, stage: str) -> dict: prompt = config.prompt("work.md").format( branch=branch, filename=filename, body=task["body"]) - proc, log_file = _launch("work", prompt, worktree, agent_id, filename, log_path) + proc, log_file, model = _launch("work", prompt, worktree, agent_id, filename, log_path) name = _pick_name(stem) record = { @@ -193,7 +205,7 @@ def start_agent(filename: str, stage: str) -> dict: "worktree": str(worktree), "base": base, "status": "running", "rc": None, "started": time.time(), "session": None, "log": str(log_path), "proc": proc, "origin": stage, "mode": "work", - "name": name, + "name": name, "model": model or None, } with state.LOCK: state.AGENTS[agent_id] = record @@ -219,14 +231,14 @@ def start_review(filename: str, stage: str) -> dict: prompt = config.prompt("review.md").format( stage=stage, filename=filename, body=task["body"]) - proc, log_file = _launch("review", prompt, config.REPO, agent_id, filename, log_path) + proc, log_file, model = _launch("review", prompt, config.REPO, agent_id, filename, log_path) name = _pick_name(filename) record = { "id": agent_id, "task": filename, "branch": None, "worktree": None, "base": None, "status": "running", "rc": None, "started": time.time(), "session": None, "log": str(log_path), "proc": proc, - "origin": stage, "mode": "review", "name": name, + "origin": stage, "mode": "review", "name": name, "model": model or None, } with state.LOCK: state.AGENTS[agent_id] = record @@ -352,13 +364,13 @@ def start_pr_review(filename: str, stage: str) -> dict: prompt = config.prompt("review-pr.md").format( filename=filename, pr=task["pr"], branch=branch, body=task["body"]) - proc, log_file = _launch("review", prompt, config.REPO, agent_id, filename, log_path) + proc, log_file, model = _launch("review", prompt, config.REPO, agent_id, filename, log_path) record = { "id": agent_id, "task": filename, "branch": branch, "worktree": None, "base": None, "status": "running", "rc": None, "started": time.time(), "session": None, "log": str(log_path), "proc": proc, - "origin": stage, "mode": "review", "name": name, + "origin": stage, "mode": "review", "name": name, "model": model or None, } with state.LOCK: state.AGENTS[agent_id] = record @@ -403,14 +415,14 @@ def start_pr_fix(filename: str, stage: str) -> dict: prompt = config.prompt("act-pr.md").format( filename=filename, branch=branch, pr=task["pr"], body=task["body"]) # act-pr is the one intent allowed to push: the PR must update. - proc, log_file = _launch("act-pr", prompt, worktree, agent_id, filename, log_path) + proc, log_file, model = _launch("act-pr", prompt, worktree, agent_id, filename, log_path) record = { "id": agent_id, "task": filename, "branch": branch, "worktree": str(worktree), "base": None, "status": "running", "rc": None, "started": time.time(), "session": None, "log": str(log_path), "proc": proc, "origin": stage, "mode": "work", - "name": name, + "name": name, "model": model or None, } with state.LOCK: state.AGENTS[agent_id] = record diff --git a/manager/core/board.html b/manager/core/board.html index 5252847..ee26b1a 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -1300,12 +1300,15 @@ function renderFlight() { const stopBtn = agent && agent.status === 'running' ? `` : ''; const branch = agent && agent.branch ? ` · ${esc(agent.branch)}` : ''; + // Honesty about what the run actually rode: the configured model, or + // the vendor default it inherited. Interactive sessions say nothing. + const model = agent ? ` · ${agent.model ? esc(agent.model) : 'model inherited'}` : ''; $('#fsession').innerHTML = `
${esc((meta.label || sid).split(' · ')[0])}` + `${esc(sid.slice(0, 8))}
` + `
${meta.task ? 'on ' + esc(meta.task) + ' · ' : ''}` + `started ${fmtShort(meta.started)} · ${meta.count || 0} events · ` + - `${files.size} files edited · ${checks} check runs${branch}
` + + `${files.size} files edited · ${checks} check runs${branch}${model}` + stopBtn + spark(events, meta); const stop = $('#stopagent'); if (stop) stop.addEventListener('click', () => stopAgent(stop.dataset.aid)); @@ -1490,6 +1493,7 @@ function renderFocus() { refBits.push(task.number ? '#' + esc(task.number) : esc(task.file)); refBits.push(`${esc((meta.label || '').split(' · ')[0])}`); if (agent && agent.branch) refBits.push('worktree ' + esc(agent.branch)); + if (agent) refBits.push(agent.model ? esc(agent.model) : 'model inherited'); refBits.push(esc(task.stage) + '/' + esc(task.file)); } else { refBits.push(esc(sid.slice(0, 8)), 'no task attached'); diff --git a/manager/core/config.py b/manager/core/config.py index 8d735da..b396182 100644 --- a/manager/core/config.py +++ b/manager/core/config.py @@ -95,6 +95,24 @@ ADAPTER = setting("BOARD_AGENT_ADAPTER", "claude") # the adapter's own knowledge; this list is the project's half. AGENT_COMMANDS = setting("BOARD_AGENT_COMMANDS", "python3 -m unittest") +# Model per launch intent — an opaque vendor-native name core passes to the +# adapter untranslated (what names mean anything is vendor knowledge). Empty +# = inherit the vendor's own default, exactly today's behaviour. A per-intent +# setting beats the general one; review covers PR reviews and relevance +# checks (they share the review intent). +AGENT_MODEL = setting("BOARD_AGENT_MODEL", "") +AGENT_MODELS = { + "work": setting("BOARD_AGENT_MODEL_WORK", ""), + "act-pr": setting("BOARD_AGENT_MODEL_ACT_PR", ""), + "review": setting("BOARD_AGENT_MODEL_REVIEW", ""), +} + + +def agent_model(mode: str) -> str: + """The model one launch intent rides — '' means inherit.""" + return AGENT_MODELS.get(mode, "") or AGENT_MODEL + + # GitHub plumbing: the gh CLI (stub-able for tests) and the git remote PRs # go to. Empty remote = auto-detect the first remote; no remote = no PRs. GH_BIN = setting("BOARD_GH_BIN", "gh") diff --git a/tests/test_agent_model.py b/tests/test_agent_model.py new file mode 100644 index 0000000..a7c5bfe --- /dev/null +++ b/tests/test_agent_model.py @@ -0,0 +1,239 @@ +"""Choosing agent models per launch intent (task 12): the BOARD_AGENT_MODEL +settings resolve intent → model in config, travel to the adapter as +AGENT_MODEL (absent when empty — never an empty flag value), and each +adapter renders the opaque name natively. With nothing set, launches are +byte-identical to the inherit-everything behaviour. + +The `run` scripts are exercised end-to-end against stub binaries +(BOARD_CLAUDE_BIN / BOARD_OPENCODE_BIN), the same seam a live board uses. +Run with: python3 -m unittest discover -s tests +""" + +import importlib.util +import json +import os +import stat +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +CORE = REPO / "manager" / "core" +CLAUDE = CORE / "adapters" / "claude" +OPENCODE = CORE / "adapters" / "opencode" + +sys.path.insert(0, str(CORE)) + +import agents # noqa: E402 +import config # noqa: E402 + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +permission_config = _load("permission_config", OPENCODE / "permission_config.py") + +# Neutralize any local/.env or shell leakage: process env beats .env, and +# an empty value is exactly "nothing configured". +UNSET = {"BOARD_AGENT_MODEL": "", "BOARD_AGENT_MODEL_WORK": "", + "BOARD_AGENT_MODEL_ACT_PR": "", "BOARD_AGENT_MODEL_REVIEW": ""} + + +def _resolve(settings: dict) -> dict: + """config.agent_model per intent, in a fresh interpreter so the given + settings are what config reads at import.""" + env = dict(os.environ) + env.update(UNSET) + env.update(settings) + out = subprocess.check_output( + [sys.executable, "-c", + "import sys; sys.path.insert(0, sys.argv[1]); import config, json; " + "print(json.dumps({m: config.agent_model(m) " + "for m in ('work', 'act-pr', 'review')}))", + str(CORE)], + env=env, text=True) + return json.loads(out) + + +class ModelResolution(unittest.TestCase): + def test_nothing_set_means_inherit_for_every_intent(self): + self.assertEqual(_resolve({}), + {"work": "", "act-pr": "", "review": ""}) + + def test_the_general_setting_covers_all_intents(self): + self.assertEqual(_resolve({"BOARD_AGENT_MODEL": "vendor-x"}), + {"work": "vendor-x", "act-pr": "vendor-x", + "review": "vendor-x"}) + + def test_a_per_intent_setting_beats_the_general_one_for_that_intent_only(self): + resolved = _resolve({"BOARD_AGENT_MODEL": "big", + "BOARD_AGENT_MODEL_REVIEW": "cheap"}) + self.assertEqual(resolved, + {"work": "big", "act-pr": "big", "review": "cheap"}) + + def test_per_intent_alone_leaves_the_others_inheriting(self): + resolved = _resolve({"BOARD_AGENT_MODEL_ACT_PR": "pusher"}) + self.assertEqual(resolved, + {"work": "", "act-pr": "pusher", "review": ""}) + + +class LaunchEnv(unittest.TestCase): + """_launch's half of the contract: AGENT_MODEL set iff a model is + configured — absent means absent, even against a leaky environment.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + tmp = Path(self._tmp.name) + self.capture = tmp / "env.json" + adapter = tmp / "stub-adapter" + adapter.mkdir() + run = adapter / "run" + run.write_text("#!/usr/bin/env python3\n" + "import json, os\n" + f"open({str(self.capture)!r}, 'w').write(json.dumps(dict(os.environ)))\n") + run.chmod(run.stat().st_mode | stat.S_IXUSR) + self._saved = (config.AGENT_MODEL, config.AGENT_MODELS, + config.adapter_dir, config.child_env) + config.adapter_dir = lambda: adapter + # A stray AGENT_MODEL inherited by the board process must not leak. + config.child_env = lambda: {"PATH": os.environ.get("PATH", ""), + "AGENT_MODEL": "stray-from-the-shell"} + + def tearDown(self): + (config.AGENT_MODEL, config.AGENT_MODELS, + config.adapter_dir, config.child_env) = self._saved + self._tmp.cleanup() + + def _launch_env(self, mode: str) -> tuple[dict, str]: + log = Path(self._tmp.name) / "job.log" + proc, log_file, model = agents._launch( + mode, "do the task", Path(self._tmp.name), "id-1", "t.md", log) + proc.wait() + log_file.close() + return json.loads(self.capture.read_text()), model + + def test_no_model_configured_means_no_variable_at_all(self): + config.AGENT_MODEL = "" + config.AGENT_MODELS = {"work": "", "act-pr": "", "review": ""} + env, model = self._launch_env("work") + self.assertNotIn("AGENT_MODEL", env) + self.assertEqual(model, "") + + def test_the_resolved_model_arrives_as_agent_model(self): + config.AGENT_MODEL = "big" + config.AGENT_MODELS = {"work": "", "act-pr": "", "review": "cheap"} + env, model = self._launch_env("review") + self.assertEqual(env["AGENT_MODEL"], "cheap") + self.assertEqual(model, "cheap") + env, model = self._launch_env("work") + self.assertEqual(env["AGENT_MODEL"], "big") + self.assertEqual(model, "big") + + +class AgentRecord(unittest.TestCase): + def test_public_record_carries_the_model_none_means_inherited(self): + base = {"id": "a", "task": "t.md", "branch": None, "worktree": None, + "status": "running", "rc": None, "started": 0.0, + "session": None, "mode": "review", "name": "Wren"} + self.assertIsNone(agents._agent_public(base)["model"]) + self.assertEqual( + agents._agent_public({**base, "model": "cheap"})["model"], "cheap") + + +def _write_stub(directory: Path, name: str, script: str) -> Path: + stub = directory / name + stub.write_text(script, encoding="utf-8") + stub.chmod(stub.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return stub + + +class ClaudeRunModel(unittest.TestCase): + """AGENT_MODEL reaches the claude launch as --model; absent (or empty, + which must never happen but costs nothing to survive) = no flag.""" + + def _run(self, model: str | None) -> list[str]: + with tempfile.TemporaryDirectory() as tmp: + capture = Path(tmp) / "args.json" + stub = _write_stub(Path(tmp), "claude-stub", + "#!/usr/bin/env python3\n" + "import json, sys\n" + f"open({str(capture)!r}, 'w').write(json.dumps(sys.argv[1:]))\n") + wrapper = _write_stub(Path(tmp), "bin", + f"#!/usr/bin/env bash\nexec python3 {stub} \"$@\"\n") + env = dict(os.environ) + env.pop("AGENT_MODEL", None) + env.update({"BOARD_CLAUDE_BIN": str(wrapper), + "AGENT_PROMPT": "do the task", "AGENT_MODE": "work", + "AGENT_COMMANDS": "python3 -m unittest"}) + if model is not None: + env["AGENT_MODEL"] = model + result = subprocess.run(["bash", str(CLAUDE / "run")], env=env, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(capture.read_text()) + + def test_unset_launches_byte_identical_to_today(self): + args = self._run(None) + self.assertNotIn("--model", args) + self.assertEqual(args, self._run("")) # empty behaves like absent + + def test_set_appends_model_and_changes_nothing_else(self): + args = self._run("claude-model-x") + i = args.index("--model") + self.assertEqual(args[i + 1], "claude-model-x") + self.assertEqual(args[:i] + args[i + 2:], self._run(None)) + + +class OpencodeRunModel(unittest.TestCase): + """AGENT_MODEL reaches the opencode launch as the generated config's + model key; absent = no key, config byte-identical to today.""" + + def _run(self, model: str | None) -> dict: + with tempfile.TemporaryDirectory() as tmp: + capture = Path(tmp) / "capture.json" + stub = _write_stub(Path(tmp), "opencode-stub", + "#!/usr/bin/env python3\n" + "import json, os\n" + f"open({str(capture)!r}, 'w').write(" + "json.dumps(json.load(open(os.environ['OPENCODE_CONFIG']))))\n") + wrapper = _write_stub(Path(tmp), "bin", + f"#!/usr/bin/env bash\nexec python3 {stub} \"$@\"\n") + env = dict(os.environ) + env.pop("AGENT_MODEL", None) + env.update({"BOARD_OPENCODE_BIN": str(wrapper), + "AGENT_PROMPT": "do the task", "AGENT_MODE": "review", + "AGENT_COMMANDS": "python3 -m unittest"}) + if model is not None: + env["AGENT_MODEL"] = model + result = subprocess.run(["bash", str(OPENCODE / "run")], env=env, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(capture.read_text()) + + def test_unset_launches_byte_identical_to_today(self): + cfg = self._run(None) + self.assertNotIn("model", cfg) + self.assertEqual(cfg, permission_config.build_config( + "review", ["python3 -m unittest"])) + + def test_set_lands_as_the_config_model_key_untranslated(self): + cfg = self._run("anthropic/model-x") + self.assertEqual(cfg["model"], "anthropic/model-x") + del cfg["model"] + self.assertEqual(cfg, self._run(None)) + + def test_build_config_only_grows_the_key_when_given_a_model(self): + commands = ["python3 -m unittest"] + self.assertNotIn("model", permission_config.build_config("work", commands)) + self.assertEqual( + permission_config.build_config("work", commands, "p/m")["model"], "p/m") + + +if __name__ == "__main__": + unittest.main()