duet-cli 0.2.16__tar.gz → 0.2.17__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {duet_cli-0.2.16/duet_cli.egg-info → duet_cli-0.2.17}/PKG-INFO +9 -6
  2. {duet_cli-0.2.16 → duet_cli-0.2.17}/README.md +8 -5
  3. {duet_cli-0.2.16 → duet_cli-0.2.17}/duet.py +358 -5
  4. {duet_cli-0.2.16 → duet_cli-0.2.17/duet_cli.egg-info}/PKG-INFO +9 -6
  5. {duet_cli-0.2.16 → duet_cli-0.2.17}/duet_cli.egg-info/SOURCES.txt +3 -1
  6. duet_cli-0.2.17/tests/test_loop_run_info.py +51 -0
  7. duet_cli-0.2.17/tests/test_zcode_backend.py +439 -0
  8. {duet_cli-0.2.16 → duet_cli-0.2.17}/LICENSE +0 -0
  9. {duet_cli-0.2.16 → duet_cli-0.2.17}/duet_cli.egg-info/dependency_links.txt +0 -0
  10. {duet_cli-0.2.16 → duet_cli-0.2.17}/duet_cli.egg-info/entry_points.txt +0 -0
  11. {duet_cli-0.2.16 → duet_cli-0.2.17}/duet_cli.egg-info/requires.txt +0 -0
  12. {duet_cli-0.2.16 → duet_cli-0.2.17}/duet_cli.egg-info/top_level.txt +0 -0
  13. {duet_cli-0.2.16 → duet_cli-0.2.17}/pyproject.toml +0 -0
  14. {duet_cli-0.2.16 → duet_cli-0.2.17}/setup.cfg +0 -0
  15. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_backend_metrics.py +0 -0
  16. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_bump_release_version.py +0 -0
  17. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_control_plane.py +0 -0
  18. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_duet.py +0 -0
  19. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_feedback_concurrency.py +0 -0
  20. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_finding_reports.py +0 -0
  21. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_finding_workflow.py +0 -0
  22. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_metrics_cli.py +0 -0
  23. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_metrics_collection.py +0 -0
  24. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_metrics_lifecycle.py +0 -0
  25. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_metrics_reporting.py +0 -0
  26. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_no_subagents.py +0 -0
  27. {duet_cli-0.2.16 → duet_cli-0.2.17}/tests/test_stop_control_plane.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.16
3
+ Version: 0.2.17
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -29,7 +29,8 @@ and you get a saved transcript to inspect.
29
29
 
30
30
  Run **two different models through a single agent harness (CLI)**, such as
31
31
  Codex or OpenCode. You can also pair different CLIs, such as Claude Code and
32
- Codex. Gemini and Copilot are also supported.
32
+ Codex. Gemini and Copilot are also supported. A native ZCode account can
33
+ provide a [read-only review peer](https://github.com/volkan/duet/blob/main/docs/ZCODE.md).
33
34
  [See examples using one CLI](https://github.com/volkan/duet/blob/main/docs/USAGE.md#same-backend-peering).
34
35
 
35
36
  [![Watch the 80-second review workflow demo](https://raw.githubusercontent.com/volkan/duet/main/docs/demos/finding-review.png)](https://github.com/volkan/duet/blob/main/docs/demos/finding-review.mp4)
@@ -63,19 +64,21 @@ This starts a review and fix loop with the coder in a separate Git worktree.
63
64
  Inspect the changes before merging; agent agreement does not prove correctness.
64
65
 
65
66
  Already working inside an agent? Install the **same Duet skill** for Claude
66
- Code, Codex, and OpenCode (requires Node.js/npm; choose **Symlink**):
67
+ Code, Codex, OpenCode, and ZCode (requires Node.js/npm; choose **Symlink**):
67
68
 
68
69
  ```bash
69
70
  npx skills add volkan/duet --skill duet --global \
70
- --agent claude-code --agent codex --agent opencode
71
+ --agent claude-code --agent codex --agent opencode --agent zcode
71
72
  ```
72
73
 
73
- Start a new session, then use `/duet` in Claude Code, `$duet` in Codex, or ask
74
- OpenCode to use the `duet` skill.
74
+ Start a new session. Use `/duet` in Claude Code, `$duet` in Codex, or `/skill duet` in ZCode.
75
+ Ask OpenCode to use the `duet` skill.
75
76
  [Installation details](https://github.com/volkan/duet/blob/main/docs/INSTALLATION.md)
76
77
  cover `~/.agents/skills`, prerequisites, and alternative installers. See the
77
78
  [OpenCode guide](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md)
78
79
  for its native skill and optional command wrapper.
80
+ The [ZCode guide](https://github.com/volkan/duet/blob/main/docs/ZCODE.md#host-shortcuts)
81
+ provides its `/duet` shortcut and native peer setup.
79
82
 
80
83
  ## Native plugin installation
81
84
 
@@ -8,7 +8,8 @@ and you get a saved transcript to inspect.
8
8
 
9
9
  Run **two different models through a single agent harness (CLI)**, such as
10
10
  Codex or OpenCode. You can also pair different CLIs, such as Claude Code and
11
- Codex. Gemini and Copilot are also supported.
11
+ Codex. Gemini and Copilot are also supported. A native ZCode account can
12
+ provide a [read-only review peer](https://github.com/volkan/duet/blob/main/docs/ZCODE.md).
12
13
  [See examples using one CLI](https://github.com/volkan/duet/blob/main/docs/USAGE.md#same-backend-peering).
13
14
 
14
15
  [![Watch the 80-second review workflow demo](https://raw.githubusercontent.com/volkan/duet/main/docs/demos/finding-review.png)](https://github.com/volkan/duet/blob/main/docs/demos/finding-review.mp4)
@@ -42,19 +43,21 @@ This starts a review and fix loop with the coder in a separate Git worktree.
42
43
  Inspect the changes before merging; agent agreement does not prove correctness.
43
44
 
44
45
  Already working inside an agent? Install the **same Duet skill** for Claude
45
- Code, Codex, and OpenCode (requires Node.js/npm; choose **Symlink**):
46
+ Code, Codex, OpenCode, and ZCode (requires Node.js/npm; choose **Symlink**):
46
47
 
47
48
  ```bash
48
49
  npx skills add volkan/duet --skill duet --global \
49
- --agent claude-code --agent codex --agent opencode
50
+ --agent claude-code --agent codex --agent opencode --agent zcode
50
51
  ```
51
52
 
52
- Start a new session, then use `/duet` in Claude Code, `$duet` in Codex, or ask
53
- OpenCode to use the `duet` skill.
53
+ Start a new session. Use `/duet` in Claude Code, `$duet` in Codex, or `/skill duet` in ZCode.
54
+ Ask OpenCode to use the `duet` skill.
54
55
  [Installation details](https://github.com/volkan/duet/blob/main/docs/INSTALLATION.md)
55
56
  cover `~/.agents/skills`, prerequisites, and alternative installers. See the
56
57
  [OpenCode guide](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md)
57
58
  for its native skill and optional command wrapper.
59
+ The [ZCode guide](https://github.com/volkan/duet/blob/main/docs/ZCODE.md#host-shortcuts)
60
+ provides its `/duet` shortcut and native peer setup.
58
61
 
59
62
  ## Native plugin installation
60
63
 
@@ -32,6 +32,8 @@ Each agent keeps its own session across turns:
32
32
  `gemini --resume <session_id>` from the JSON response.
33
33
  - Copilot: `copilot -p ... --output-format json`; subsequent turns use
34
34
  `copilot --resume=<sessionId>` from the JSONL result event.
35
+ - ZCode: official headless JSON summaries and explicit --resume sess_ IDs;
36
+ private credential-free profiles select the account model and reasoning.
35
37
  - OpenCode: `opencode run --format json`; subsequent turns use
36
38
  `opencode run -s <sessionID>` from the JSONL event stream.
37
39
 
@@ -65,11 +67,12 @@ import threading
65
67
  import time
66
68
  import traceback
67
69
  import uuid
70
+ import urllib.parse
68
71
  from typing import Callable, Iterable, Optional
69
72
 
70
73
  # ---------- defaults ----------
71
74
 
72
- __version__ = "0.2.16"
75
+ __version__ = "0.2.17"
73
76
 
74
77
  DEFAULT_SENTINEL = "<<<LGTM>>>"
75
78
  DEFAULT_SANDBOX = "workspace-write"
@@ -422,6 +425,7 @@ class Agent:
422
425
  # stays out of Agent's repr and serialized config/state shape; the run
423
426
  # lifecycle records it alongside the turn when metrics are enabled.
424
427
  last_call_metrics: dict = dataclasses.field(default_factory=dict, repr=False)
428
+ zcode_selection: Optional[dict] = None # native resume cannot change model/effort
425
429
 
426
430
  def __post_init__(self) -> None:
427
431
  # Pin Claude to its stable Sonnet alias instead of inheriting a
@@ -462,6 +466,8 @@ def agent_state(a: Agent) -> dict:
462
466
  data["extra_args"] = a.extra_args
463
467
  if a.reasoning_effort is not None:
464
468
  data["reasoning_effort"] = a.reasoning_effort
469
+ if a.zcode_selection is not None:
470
+ data["zcode_selection"] = a.zcode_selection
465
471
  return data
466
472
 
467
473
 
@@ -594,6 +600,11 @@ def validate_config(cfg: DuetConfig,
594
600
  f"expected {choices}",
595
601
  parser,
596
602
  )
603
+ if agent.backend == "zcode":
604
+ try:
605
+ _zcode_requested_selection(agent, effective_reasoning(agent, cfg.reasoning))
606
+ except ValueError as exc:
607
+ _config_error(str(exc), parser)
597
608
  if agent.name in seen_names:
598
609
  _config_error(
599
610
  f"duplicate agent name {agent.name!r}; agent names must be unique",
@@ -1598,6 +1609,8 @@ def _metrics_reasoning_profile(agent: Agent, cfg: DuetConfig) -> tuple[
1598
1609
  return requested, None, None, "native_default", fast
1599
1610
  transport = "prompt"
1600
1611
  backend_value = None
1612
+ elif agent.backend == "zcode":
1613
+ transport = "config_file"
1601
1614
  elif agent.backend == "codex":
1602
1615
  transport = "config_flag"
1603
1616
  else:
@@ -2470,6 +2483,335 @@ def _call_opencode_backend(agent: Agent, system_prompt: str, message: str,
2470
2483
  )
2471
2484
 
2472
2485
 
2486
+
2487
+ _ZCODE_DESKTOP_RESOURCES = pathlib.Path("/Applications/ZCode.app/Contents/Resources")
2488
+ _ZCODE_SESSION_ID_RE = re.compile(r"^sess_[A-Za-z0-9_-]+$")
2489
+ _ZCODE_REVIEW_DENIED_TOOLS = CLAUDE_DENIED_DELEGATION_TOOLS + (
2490
+ "Bash", "Write", "Edit", "NotebookEdit", "ApplyPatch", "CreateWorkflow",
2491
+ "AmendWorkflow", "FillWorkflowHole", "ResumeWorkflowRun", "SaveWorkflow",
2492
+ "OffPeakCreate", "CronCreate", "CronUpdate", "CronDelete",
2493
+ "WebSearch", "WebFetch", "AskUserQuestion",
2494
+ "EnterPlanMode", "ExitPlanMode", "ReplyToChannel",
2495
+ )
2496
+
2497
+
2498
+ def _zcode_command() -> tuple[list[str], dict[str, str]]:
2499
+ """Use the official CLI, or the installed macOS app's headless runtime."""
2500
+ cli = shutil.which("zcode")
2501
+ if cli:
2502
+ key = "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE"
2503
+ source = os.environ.get(key, "").strip()
2504
+ if not source:
2505
+ directory = pathlib.Path(cli).resolve().parent
2506
+ candidates = (directory / "provider/zcode-builtin.json",
2507
+ (directory / "../../../../../config/provider/zcode-builtin.json").resolve())
2508
+ source = next((str(path) for path in candidates if path.is_file()), "")
2509
+ if not source:
2510
+ raise ValueError("Cannot locate the PATH ZCode CLI's catalog; set "
2511
+ "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE to that installation's JSON catalog")
2512
+ return [cli], {key: source}
2513
+ entry = _ZCODE_DESKTOP_RESOURCES / "glm" / "zcode.cjs"
2514
+ builtin = _ZCODE_DESKTOP_RESOURCES / "config" / "provider" / "zcode-builtin.json"
2515
+ node = shutil.which("node")
2516
+ if sys.platform != "darwin" or not node or not entry.is_file() or not builtin.is_file():
2517
+ raise ValueError("ZCode CLI is not on PATH and no installed macOS ZCode runtime was found")
2518
+ return [node, str(entry)], {
2519
+ "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE": str(builtin),
2520
+ }
2521
+
2522
+
2523
+ def _zcode_requested_selection(agent: Agent, reasoning: Optional[str]) -> dict:
2524
+ if agent.extra_args:
2525
+ raise ValueError("ZCode review backend does not accept extra_args; native policy must stay fixed")
2526
+ if agent.role not in {"reviewer", "triage-reviewer", "planner"}:
2527
+ raise ValueError("ZCode currently supports read-only reviewer/planner roles, not coder roles")
2528
+ provider, separator, model = (agent.model or "").partition("/")
2529
+ if not separator or not provider.startswith("account:") or not model:
2530
+ raise ValueError("ZCode requires its native account provider/model ID, e.g. "
2531
+ "account:zai-individual-coding-plan/GLM-5.3")
2532
+ pin = {"model": agent.model, "reasoning": reasoning}
2533
+ if agent.session_id:
2534
+ if not _ZCODE_SESSION_ID_RE.fullmatch(agent.session_id):
2535
+ raise ValueError("ZCode requires an explicit sess_ session ID; cwd-based resume is unsafe")
2536
+ if agent.zcode_selection != pin:
2537
+ raise ValueError("ZCode resume requires its original model/effort pin; start a fresh peer to change them")
2538
+ return {"providerId": provider, "modelId": model,
2539
+ **({"options": {"reasoningLevel": reasoning}} if reasoning else {})}
2540
+
2541
+
2542
+
2543
+ def _reject_zcode_json_constant(value: str):
2544
+ raise ValueError("ZCode configuration must use strict JSON")
2545
+
2546
+
2547
+ def _zcode_resolved_provider(config: dict, provider: dict) -> dict:
2548
+ concrete = provider["config"]
2549
+ template_id = provider.get("templateId")
2550
+ if not template_id:
2551
+ return concrete
2552
+ templates = [row for row in config["providerConfigRules"].get("templateRules", [])
2553
+ if row["templateId"] == template_id]
2554
+ if len(templates) != 1:
2555
+ raise ValueError("ZCode catalog has no unique provider template")
2556
+ base = templates[0]["config"]
2557
+ resolved = {**base, **concrete}
2558
+ if isinstance(base.get("api"), dict) and isinstance(concrete.get("api"), dict):
2559
+ resolved["api"] = {**base["api"], **concrete["api"]}
2560
+ return resolved
2561
+
2562
+
2563
+ def _zcode_catalog(selection: dict, launch_env: dict[str, str]) -> dict:
2564
+ source = launch_env.get("ZCODE_BUILTIN_PROVIDER_CONFIG_FILE") or os.environ.get(
2565
+ "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE")
2566
+ if not source:
2567
+ raise ValueError("ZCode needs ZCODE_BUILTIN_PROVIDER_CONFIG_FILE for its selected runtime; "
2568
+ "a PATH CLI must not use another installation's catalog")
2569
+ data = json.loads(pathlib.Path(source).read_text(encoding="utf-8"),
2570
+ parse_constant=_reject_zcode_json_constant)
2571
+ config = data["config"]
2572
+ providers = config["providerConfigRules"]["providerRules"]
2573
+ matches = [row for row in providers if row["providerId"] == selection["providerId"]]
2574
+ if len(matches) != 1:
2575
+ raise ValueError("Requested ZCode account model is absent from its native catalog")
2576
+ provider = {**matches[0], "config": _zcode_resolved_provider(config, matches[0])}
2577
+ if selection["modelId"] not in provider["config"].get("builtinModelIds", []):
2578
+ raise ValueError("Requested ZCode account model is absent from its native catalog")
2579
+ levels = _zcode_catalog_reasoning(config["modelConfigRules"], provider, selection["modelId"])
2580
+ requested = selection.get("options", {}).get("reasoningLevel")
2581
+ if requested is not None and requested not in levels:
2582
+ raise ValueError("Requested ZCode reasoning level is absent from its native model catalog")
2583
+ return data
2584
+
2585
+
2586
+ def _zcode_rule_url(value: str) -> str:
2587
+ # Match native URL normalization for canonical HTTP(S) endpoints. Reject
2588
+ # exotic forms rather than validate an effort against a different site.
2589
+ if not re.fullmatch(r"[A-Za-z0-9:/?&#_~!$()*+,;=@.\[\]-]+", value):
2590
+ raise ValueError("Unsupported ZCode catalog API URL")
2591
+ url = urllib.parse.urlsplit(value)
2592
+ host = url.hostname or ""
2593
+ if (url.scheme not in {"http", "https"} or not host or url.username is not None
2594
+ or ":" in host or host.rstrip(".").rsplit(".", 1)[-1].isdigit()
2595
+ or host.rstrip(".").rsplit(".", 1)[-1].lower().startswith("0x")
2596
+ or re.search(r"(?:^|/)\.{1,2}(?:/|$)", url.path)
2597
+ or value.endswith(("?", "#")) or "?#" in value):
2598
+ raise ValueError("Unsupported ZCode catalog API URL")
2599
+ port = url.port
2600
+ authority = host.lower()
2601
+ if port is not None and port != {"http": 80, "https": 443}[url.scheme]:
2602
+ authority += ":" + str(port)
2603
+ endpoint = f"{url.scheme}://{authority}{url.path}".rstrip("/")
2604
+ return endpoint + ("?" + url.query if url.query else "") + ("#" + url.fragment if url.fragment else "")
2605
+
2606
+
2607
+ def _zcode_catalog_reasoning(rules: dict, provider: dict, model: str) -> list[str]:
2608
+ levels = []
2609
+ api = provider["config"].get("api") or {}
2610
+ subjects = {"modelMatch": model, "apiTypeMatch": api.get("type", ""),
2611
+ "baseUrlMatch": _zcode_rule_url(api["baseUrl"]) if api.get("baseUrl") else ""}
2612
+ for layer in ("modelRules", "modelApiRules", "providerSiteRules",
2613
+ "templateModelRules", "builtinProviderModelRules"):
2614
+ for row in rules.get(layer, []):
2615
+ if any(key in row and (not value or not re.fullmatch(
2616
+ row[key], value, re.IGNORECASE if key == "modelMatch" else 0))
2617
+ for key, value in subjects.items()):
2618
+ continue
2619
+ if row.get("modelId", model) != model:
2620
+ continue
2621
+ if row.get("providerId", provider["providerId"]) != provider["providerId"]:
2622
+ continue
2623
+ if "templateId" in row and row["templateId"] != provider.get("templateId"):
2624
+ continue
2625
+ specs = row.get("config", {}).get("optionSpecs", {})
2626
+ if specs is None or ("reasoningLevel" in specs and specs["reasoningLevel"] is None):
2627
+ levels = []
2628
+ continue
2629
+ reasoning = specs.get("reasoningLevel", {})
2630
+ values = reasoning.get("values")
2631
+ if "values" in reasoning and values is None:
2632
+ levels = []
2633
+ continue
2634
+ if values is not None:
2635
+ if not isinstance(values, list) or not all(isinstance(item, str) for item in values):
2636
+ raise ValueError("Unsupported ZCode native reasoning catalog shape")
2637
+ levels = values
2638
+ return levels
2639
+
2640
+
2641
+ def _zcode_permission_paths(cwd: pathlib.Path) -> list[pathlib.Path]:
2642
+ directory = pathlib.Path(os.path.abspath(cwd))
2643
+ ancestors = [directory, *directory.parents]
2644
+ directories = [directory]
2645
+ for index, parent in enumerate(ancestors):
2646
+ if (parent / ".git").exists():
2647
+ directories = list(reversed(ancestors[:index + 1]))
2648
+ break
2649
+ return [pathlib.Path.home() / ".zcode/cli/config.json", *[
2650
+ path for folder in directories for path in (folder / "zcode.json", folder / ".zcode/config.json")]]
2651
+
2652
+
2653
+ def _zcode_permission_denials(cwd: pathlib.Path) -> tuple[list[str], dict]:
2654
+ denied = list(_ZCODE_REVIEW_DENIED_TOOLS)
2655
+ fingerprints = {}
2656
+ mcp_disabled = False
2657
+ for path in _zcode_permission_paths(cwd):
2658
+ try:
2659
+ raw = path.read_bytes()
2660
+ except FileNotFoundError:
2661
+ fingerprints[path] = None
2662
+ continue
2663
+ except OSError:
2664
+ raise ValueError("Cannot read a native ZCode permission configuration") from None
2665
+ fingerprints[path] = hashlib.sha256(raw).hexdigest()
2666
+ try:
2667
+ data = json.loads(raw.decode("utf-8"), parse_constant=_reject_zcode_json_constant)
2668
+ permission = data.get("permission", {})
2669
+ rules = permission.get("disallowedTools", [])
2670
+ if (not isinstance(data, dict) or not isinstance(permission, dict)
2671
+ or not isinstance(rules, list) or not all(isinstance(rule, str) for rule in rules)
2672
+ or permission.get("allowedTools")):
2673
+ raise ValueError()
2674
+ if "features" in data:
2675
+ if not isinstance(data["features"], dict):
2676
+ raise ValueError()
2677
+ # Native ignores a whole invalid document. Certify only the
2678
+ # minimal schema we validate completely, not a richer file
2679
+ # whose unrelated fields could cause MCP=false to be skipped.
2680
+ mcp_disabled = (set(data) == {"features"}
2681
+ and set(data["features"]) == {"mcp"}
2682
+ and data["features"]["mcp"] is False)
2683
+ # CLI accepts comma/space-separated whole-tool names. Normalize
2684
+ # native command-pattern denials conservatively to the whole tool.
2685
+ for rule in rules:
2686
+ name = rule.strip().split("(", 1)[0].strip()
2687
+ if not name or re.search(r"[\s,]", name):
2688
+ raise ValueError()
2689
+ denied.append(name)
2690
+ except (ValueError, AttributeError, TypeError):
2691
+ raise ValueError("Unsupported native ZCode permission configuration; refusing to replace it") from None
2692
+ if not mcp_disabled:
2693
+ raise ValueError("ZCode review requires native features.mcp=false; plan mode permits some MCP writes")
2694
+ return list(dict.fromkeys(denied)), fingerprints
2695
+
2696
+
2697
+ def _zcode_check_permission_inputs(cwd: pathlib.Path, expected: dict) -> None:
2698
+ _, current = _zcode_permission_denials(cwd)
2699
+ if current != expected:
2700
+ raise ValueError("Native ZCode permission configuration changed before launch")
2701
+
2702
+
2703
+ @contextlib.contextmanager
2704
+ def _zcode_peer_profile(selection: dict, launch_env: dict[str, str]):
2705
+ # Native headless CLI has no model/effort flags. Its supported provider
2706
+ # profile carries only this peer's choice; auth stays in ZCode's store.
2707
+ catalog = _zcode_catalog(selection, launch_env)
2708
+ config = catalog["config"]
2709
+ providers = config["providerConfigRules"]["providerRules"]
2710
+ # Native defaults may fall back. Expose only the requested account/model;
2711
+ # an unavailable selection must fail before any other model is invoked.
2712
+ hidden = [{"providerId": row["providerId"], "config": {"visibility": "hidden"}}
2713
+ for row in providers if row["providerId"] != selection["providerId"]]
2714
+ disabled = [{"providerId": row["providerId"], "modelId": model,
2715
+ "config": {"enabled": False}}
2716
+ for row in providers
2717
+ for model in [*_zcode_resolved_provider(config, row).get("builtinModelIds", []),
2718
+ *_zcode_resolved_provider(config, row).get("personalModelIds", [])]
2719
+ if (row["providerId"], model) != (selection["providerId"], selection["modelId"])]
2720
+ reasoning = selection.get("options", {}).get("reasoningLevel")
2721
+ if reasoning is not None:
2722
+ # Narrow only after native-catalog validation. A session edited outside
2723
+ # Duet must not resume with another still-supported effort silently.
2724
+ disabled.append({"providerId": selection["providerId"], "modelId": selection["modelId"],
2725
+ "config": {"optionSpecs": {"reasoningLevel": {"values": [reasoning]}}}})
2726
+ with tempfile.TemporaryDirectory(prefix="duet-zcode-") as directory:
2727
+ profile = pathlib.Path(directory) / "provider.json"
2728
+ config = {"schemaVersion": 1, "config": {
2729
+ "providerConfigRules": {"providerRules": hidden},
2730
+ "modelConfigRules": {"providerModelRules": disabled, "manualProviderModelRules": []},
2731
+ "defaultModelSelection": selection,
2732
+ }}
2733
+ write_text_atomic(profile, json.dumps(config))
2734
+ profile.chmod(0o600)
2735
+ builtin = pathlib.Path(directory) / "builtin.json"
2736
+ write_text_atomic(builtin, json.dumps(catalog))
2737
+ builtin.chmod(0o600)
2738
+ yield {**launch_env, "ZCODE_PERSONAL_PROVIDER_CONFIG_FILE": str(profile),
2739
+ "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE": str(builtin),
2740
+ "ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE": ""}
2741
+
2742
+
2743
+ def _parse_zcode_reply(stdout: str) -> tuple[str, str]:
2744
+ try:
2745
+ result = json.loads(stdout)
2746
+ except (ValueError, TypeError) as exc:
2747
+ raise ValueError("ZCode did not return a JSON summary") from exc
2748
+ if not isinstance(result, dict):
2749
+ raise ValueError("ZCode JSON summary must be an object")
2750
+ session = result.get("sessionId")
2751
+ response = result.get("response")
2752
+ if not isinstance(session, str) or not _ZCODE_SESSION_ID_RE.fullmatch(session):
2753
+ raise ValueError("ZCode JSON summary has no valid sessionId")
2754
+ if not isinstance(response, str) or not response.strip():
2755
+ raise ValueError("ZCode JSON summary has no assistant response")
2756
+ projection = result.get("projection") or {}
2757
+ if result.get("error") or not isinstance(projection, dict) or projection.get("status") in {"error", "failed"}:
2758
+ raise ValueError("ZCode reported a failed turn")
2759
+ return response, session
2760
+
2761
+
2762
+ def call_zcode(agent: Agent, system_prompt: str, message: str,
2763
+ cwd: pathlib.Path, timeout: int, dry: bool,
2764
+ reasoning: Optional[str] = None,
2765
+ stderr_log_path: Optional[pathlib.Path] = None,
2766
+ pid_file_path: Optional[pathlib.Path] = None) -> tuple[str, Optional[str]]:
2767
+ selection = _zcode_requested_selection(agent, reasoning)
2768
+ pin = {"model": agent.model, "reasoning": reasoning}
2769
+ if dry:
2770
+ session = agent.session_id or "sess_dry_" + agent.name
2771
+ agent.zcode_selection = pin
2772
+ return (f"[dry-run zcode/{agent.name} reasoning={reasoning}] received {len(message)} chars\n"
2773
+ "LGTM rationale: dry-run validates the launch only, not a model response.\n"
2774
+ + DEFAULT_SENTINEL), session
2775
+ try:
2776
+ command, launch_env = _zcode_command()
2777
+ native_cwd = pathlib.Path(agent.cwd_override or cwd)
2778
+ denied, permission_inputs = _zcode_permission_denials(native_cwd)
2779
+ command += ["--cwd", str(native_cwd), "--mode", "plan",
2780
+ "--workflow-mode", "disabled", "--json", "--no-color",
2781
+ "--disallowed-tools", ",".join(denied)]
2782
+ if agent.session_id:
2783
+ command += ["--resume", agent.session_id]
2784
+ command += ["--prompt", f"=== ROLE ===\n{system_prompt}\n\n=== MESSAGE FROM PARTNER ===\n{message}"]
2785
+ with _zcode_peer_profile(selection, launch_env) as env:
2786
+ _zcode_check_permission_inputs(native_cwd, permission_inputs)
2787
+ rc, out, err = _run(command, cwd=agent.cwd_override or cwd, stdin=None,
2788
+ timeout=timeout, stderr_log_path=stderr_log_path,
2789
+ pid_file_path=pid_file_path, env_overrides=env)
2790
+ if rc != 0:
2791
+ reason = FINISHED_TIMEOUT if rc == 124 else FINISHED_AGENT_ERROR
2792
+ raise AgentRunError(reason, _agent_exit_error("zcode", rc, out, err))
2793
+ response, session = _parse_zcode_reply(out)
2794
+ if agent.session_id and session != agent.session_id:
2795
+ raise ValueError("ZCode changed its pinned sessionId; refusing an unrelated conversation")
2796
+ except AgentRunError:
2797
+ raise
2798
+ except Exception as exc:
2799
+ raise AgentRunError(FINISHED_AGENT_ERROR, f"ZCode invocation failed: {exc}") from exc
2800
+ agent.zcode_selection = pin
2801
+ _metrics_record_raw_output(agent, out)
2802
+ return response, session
2803
+
2804
+
2805
+ def _call_zcode_backend(agent: Agent, system_prompt: str, message: str,
2806
+ cfg: DuetConfig, first_turn: bool,
2807
+ stderr_log_path: Optional[pathlib.Path],
2808
+ pid_file_path: Optional[pathlib.Path],
2809
+ reasoning: Optional[str]) -> tuple[str, Optional[str]]:
2810
+ return call_zcode(agent, system_prompt, message, cfg.cwd,
2811
+ cfg.per_turn_timeout, cfg.dry_run, reasoning,
2812
+ stderr_log_path, pid_file_path)
2813
+
2814
+
2473
2815
  BACKENDS = {
2474
2816
  "claude": BackendAdapter(
2475
2817
  name="claude",
@@ -2500,6 +2842,10 @@ BACKENDS = {
2500
2842
  reasoning_map=COPILOT_REASONING_MAP,
2501
2843
  reasoning_prompt_prefix=COPILOT_REASONING_PROMPT_PREFIX,
2502
2844
  ),
2845
+ "zcode": BackendAdapter(
2846
+ name="zcode", binary="zcode", call=_call_zcode_backend,
2847
+ reasoning_map={level: level for level in REASONING_LEVELS}, reasoning_prompt_prefix={},
2848
+ ),
2503
2849
  "opencode": BackendAdapter(
2504
2850
  name="opencode",
2505
2851
  binary="opencode",
@@ -6386,6 +6732,7 @@ def _agents_from_state(state: dict,
6386
6732
  role_prompt=(str(raw["role_prompt"]) if raw.get("role_prompt") else None),
6387
6733
  model=(str(raw["model"]) if raw.get("model") else None),
6388
6734
  session_id=(str(raw["session_id"]) if raw.get("session_id") else None),
6735
+ zcode_selection=raw.get("zcode_selection"),
6389
6736
  extra_args=[str(x) for x in raw_extra_args],
6390
6737
  reasoning_effort=(str(raw["reasoning_effort"])
6391
6738
  if raw.get("reasoning_effort") else None),
@@ -7119,9 +7466,9 @@ def _build_arg_parser() -> argparse.ArgumentParser:
7119
7466
  ap.add_argument("--task-from-cmd", metavar="CMD",
7120
7467
  help="run shell command with cwd=--cwd and use stdout as the task")
7121
7468
  ap.add_argument("--partner", default=None,
7122
- help="partner agent spec, e.g. codex:coder, claude:reviewer, gemini:coder, copilot:coder, opencode:coder (default codex:coder)")
7469
+ help="partner agent spec, e.g. codex:coder, claude:reviewer, gemini:coder, copilot:coder, opencode:coder, zcode:reviewer (default codex:coder)")
7123
7470
  ap.add_argument("--lead", default=None,
7124
- help="lead agent spec, e.g. claude:planner, gemini:reviewer, copilot:planner, opencode:planner (default; ignored if --resume-claude given)")
7471
+ help="lead agent spec, e.g. claude:planner, gemini:reviewer, copilot:planner, opencode:planner, zcode:reviewer (default; ignored if --resume-claude given)")
7125
7472
  ap.add_argument("--lead-model", metavar="MODEL", default=None,
7126
7473
  help="lead backend model via --model; Claude defaults to 'sonnet'")
7127
7474
  ap.add_argument("--partner-model", metavar="MODEL", default=None,
@@ -7153,9 +7500,9 @@ def _build_arg_parser() -> argparse.ArgumentParser:
7153
7500
  help="atomically write the new run's schema-v1 launch JSON; "
7154
7501
  "refuses to overwrite FILE")
7155
7502
  ap.add_argument("--sandbox", default=None,
7156
- help="codex --sandbox: read-only|workspace-write|danger-full-access. No-op for claude/gemini/copilot/opencode.")
7503
+ help="codex --sandbox: read-only|workspace-write|danger-full-access. No-op for claude/gemini/copilot/opencode/zcode.")
7157
7504
  ap.add_argument("--permission-mode", default=None,
7158
- help="claude --permission-mode; gemini maps default/acceptEdits/plan/bypassPermissions to --approval-mode. No-op for copilot/opencode.")
7505
+ help="claude --permission-mode; gemini maps default/acceptEdits/plan/bypassPermissions to --approval-mode. No-op for copilot/opencode/zcode.")
7159
7506
  ap.add_argument("--config", help="optional YAML/JSON config (overrides flags except --resume-*)")
7160
7507
  worktree_group = ap.add_mutually_exclusive_group()
7161
7508
  worktree_group.add_argument(
@@ -7637,6 +7984,12 @@ def _warn_missing_backends(cfg: DuetConfig) -> None:
7637
7984
  return
7638
7985
  for backend in {a.backend for a in cfg.agents}:
7639
7986
  binary = backend_adapter(backend).binary
7987
+ if backend == "zcode":
7988
+ try:
7989
+ _zcode_command()
7990
+ continue
7991
+ except ValueError:
7992
+ pass
7640
7993
  if shutil.which(binary) is None:
7641
7994
  print(
7642
7995
  f"[duet] WARNING: '{binary}' not on PATH — this run will fail. "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.16
3
+ Version: 0.2.17
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -29,7 +29,8 @@ and you get a saved transcript to inspect.
29
29
 
30
30
  Run **two different models through a single agent harness (CLI)**, such as
31
31
  Codex or OpenCode. You can also pair different CLIs, such as Claude Code and
32
- Codex. Gemini and Copilot are also supported.
32
+ Codex. Gemini and Copilot are also supported. A native ZCode account can
33
+ provide a [read-only review peer](https://github.com/volkan/duet/blob/main/docs/ZCODE.md).
33
34
  [See examples using one CLI](https://github.com/volkan/duet/blob/main/docs/USAGE.md#same-backend-peering).
34
35
 
35
36
  [![Watch the 80-second review workflow demo](https://raw.githubusercontent.com/volkan/duet/main/docs/demos/finding-review.png)](https://github.com/volkan/duet/blob/main/docs/demos/finding-review.mp4)
@@ -63,19 +64,21 @@ This starts a review and fix loop with the coder in a separate Git worktree.
63
64
  Inspect the changes before merging; agent agreement does not prove correctness.
64
65
 
65
66
  Already working inside an agent? Install the **same Duet skill** for Claude
66
- Code, Codex, and OpenCode (requires Node.js/npm; choose **Symlink**):
67
+ Code, Codex, OpenCode, and ZCode (requires Node.js/npm; choose **Symlink**):
67
68
 
68
69
  ```bash
69
70
  npx skills add volkan/duet --skill duet --global \
70
- --agent claude-code --agent codex --agent opencode
71
+ --agent claude-code --agent codex --agent opencode --agent zcode
71
72
  ```
72
73
 
73
- Start a new session, then use `/duet` in Claude Code, `$duet` in Codex, or ask
74
- OpenCode to use the `duet` skill.
74
+ Start a new session. Use `/duet` in Claude Code, `$duet` in Codex, or `/skill duet` in ZCode.
75
+ Ask OpenCode to use the `duet` skill.
75
76
  [Installation details](https://github.com/volkan/duet/blob/main/docs/INSTALLATION.md)
76
77
  cover `~/.agents/skills`, prerequisites, and alternative installers. See the
77
78
  [OpenCode guide](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md)
78
79
  for its native skill and optional command wrapper.
80
+ The [ZCode guide](https://github.com/volkan/duet/blob/main/docs/ZCODE.md#host-shortcuts)
81
+ provides its `/duet` shortcut and native peer setup.
79
82
 
80
83
  ## Native plugin installation
81
84
 
@@ -15,9 +15,11 @@ tests/test_duet.py
15
15
  tests/test_feedback_concurrency.py
16
16
  tests/test_finding_reports.py
17
17
  tests/test_finding_workflow.py
18
+ tests/test_loop_run_info.py
18
19
  tests/test_metrics_cli.py
19
20
  tests/test_metrics_collection.py
20
21
  tests/test_metrics_lifecycle.py
21
22
  tests/test_metrics_reporting.py
22
23
  tests/test_no_subagents.py
23
- tests/test_stop_control_plane.py
24
+ tests/test_stop_control_plane.py
25
+ tests/test_zcode_backend.py
@@ -0,0 +1,51 @@
1
+ """The live loop driver must not discover runs from human-readable output."""
2
+ import importlib.util
3
+ import json
4
+ import pathlib
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+
10
+ _SPEC = importlib.util.spec_from_file_location(
11
+ "duet_loop_driver_test", pathlib.Path(__file__).parents[1] / "scripts/duet_loop_e2e.py",
12
+ )
13
+ DRIVER = importlib.util.module_from_spec(_SPEC)
14
+ sys.modules[_SPEC.name] = DRIVER
15
+ _SPEC.loader.exec_module(DRIVER)
16
+
17
+
18
+ class TestLoopRunInfo(unittest.TestCase):
19
+ def test_accepts_schema_one_handoff(self):
20
+ with tempfile.TemporaryDirectory() as directory:
21
+ path = pathlib.Path(directory) / "run-info.json"
22
+ payload = self.payload(path.parent)
23
+ path.write_text(json.dumps(payload), encoding="utf-8")
24
+ self.assertEqual(DRIVER.extract_run_dir(path), pathlib.Path(payload["run_dir"]))
25
+
26
+ def test_banner_cannot_replace_launch_document(self):
27
+ with tempfile.TemporaryDirectory() as directory:
28
+ path = pathlib.Path(directory) / "run-info.json"
29
+ self.assertIsNone(DRIVER.extract_run_dir(path))
30
+ path.write_text("[duet] run: /tmp/spoofed\n", encoding="utf-8")
31
+ self.assertIsNone(DRIVER.extract_run_dir(path))
32
+
33
+ def test_rejects_incompatible_or_incomplete_handoffs(self):
34
+ with tempfile.TemporaryDirectory() as directory:
35
+ path = pathlib.Path(directory) / "run-info.json"
36
+ overrides = [
37
+ {"schema_version": 2}, {"schema_version": True}, {"kind": "duet.status"},
38
+ {"run_id": ""}, {"pid": True}, {"pid": "42"},
39
+ {"run_dir": "relative"}, {"state_path": "relative"}, {"run_dir": None},
40
+ ]
41
+ for override in overrides:
42
+ with self.subTest(override=override):
43
+ path.write_text(json.dumps({**self.payload(path.parent), **override}), encoding="utf-8")
44
+ self.assertIsNone(DRIVER.extract_run_dir(path))
45
+ path.write_text("[]", encoding="utf-8")
46
+ self.assertIsNone(DRIVER.extract_run_dir(path))
47
+
48
+ @staticmethod
49
+ def payload(root):
50
+ return {"schema_version": 1, "kind": "duet.run", "run_id": "test-run", "pid": 42,
51
+ "run_dir": str(root / "run"), "state_path": str(root / "run/state.json")}
@@ -0,0 +1,439 @@
1
+ """Native ZCode profile, resume, and failure contracts; no provider requests."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import importlib.util
7
+ import types
8
+ import os
9
+ import pathlib
10
+ import sys
11
+ import tempfile
12
+ import unittest
13
+ from unittest import mock
14
+
15
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
16
+ sys.path.insert(0, str(ROOT))
17
+ import duet
18
+
19
+
20
+ class ZCodeBackendTests(unittest.TestCase):
21
+ def setUp(self):
22
+ self.directory = tempfile.TemporaryDirectory()
23
+ self.addCleanup(self.directory.cleanup)
24
+ self.cwd = pathlib.Path(self.directory.name)
25
+ (self.cwd / "zcode.json").write_text(json.dumps({"features": {"mcp": False}}))
26
+ self.home = self.cwd / "home"
27
+ home_patch = mock.patch.object(pathlib.Path, "home", return_value=self.home)
28
+ home_patch.start()
29
+ self.addCleanup(home_patch.stop)
30
+ self.agent = duet.Agent(name="glm", backend="zcode", role="reviewer",
31
+ model="account:zai-individual-coding-plan/GLM-5.3")
32
+ self.catalog = self.cwd / "catalog.json"
33
+ self.catalog.write_text(json.dumps({"schemaVersion": 1, "config": {
34
+ "providerConfigRules": {"providerRules": [
35
+ {"providerId": "account:zai-individual-coding-plan", "config": {
36
+ "builtinModelIds": ["GLM-5.3", "GLM-5.3-Flash"],
37
+ "api": {"type": "anthropic-messages", "baseUrl": "https://api.example"}}},
38
+ {"providerId": "account:other", "config": {"builtinModelIds": ["other"]}},
39
+ ]},
40
+ "modelConfigRules": {"modelRules": [
41
+ {"modelMatch": ".*glm-5.*", "config": {"optionSpecs": {
42
+ "reasoningLevel": {"values": ["low", "high", "max"]}}}}],
43
+ "modelApiRules": [], "providerSiteRules": [],
44
+ "templateModelRules": [], "builtinProviderModelRules": []},
45
+ }}))
46
+ self.launch = mock.patch.object(duet, "_zcode_command", return_value=(
47
+ ["zcode"], {"ZCODE_BUILTIN_PROVIDER_CONFIG_FILE": str(self.catalog)}))
48
+ self.launch.start()
49
+ self.addCleanup(self.launch.stop)
50
+
51
+ def invoke(self, **overrides):
52
+ args = dict(agent=self.agent, system_prompt="system {literal}", message="peer message",
53
+ cwd=self.cwd, timeout=60, dry=False, reasoning="max")
54
+ args.update(overrides)
55
+ return duet.call_zcode(**args)
56
+
57
+ @staticmethod
58
+ def result(session="sess_abc", response="review complete", **kwargs):
59
+ return 0, json.dumps(dict(sessionId=session, response=response, **kwargs)), ""
60
+
61
+ def test_fresh_call_uses_private_profile_and_native_read_only_policy(self):
62
+ seen = {}
63
+
64
+ def run(command, **kwargs):
65
+ profile = pathlib.Path(kwargs["env_overrides"]["ZCODE_PERSONAL_PROVIDER_CONFIG_FILE"])
66
+ seen["profile"] = profile
67
+ data = json.loads(profile.read_text())
68
+ self.assertEqual(0o600, profile.stat().st_mode & 0o777)
69
+ self.assertEqual([{ "providerId": "account:other", "config": {"visibility": "hidden"}}],
70
+ data["config"]["providerConfigRules"]["providerRules"])
71
+ self.assertEqual([
72
+ {"providerId": "account:zai-individual-coding-plan", "modelId": "GLM-5.3-Flash",
73
+ "config": {"enabled": False}},
74
+ {"providerId": "account:zai-individual-coding-plan", "modelId": "GLM-5.3",
75
+ "config": {"optionSpecs": {"reasoningLevel": {"values": ["max"]}}}},
76
+ {"providerId": "account:other", "modelId": "other", "config": {"enabled": False}},
77
+ ], sorted(data["config"]["modelConfigRules"]["providerModelRules"],
78
+ key=lambda row: (row["providerId"] == "account:other", row["modelId"] == "GLM-5.3")))
79
+ self.assertEqual({"providerId": "account:zai-individual-coding-plan",
80
+ "modelId": "GLM-5.3", "options": {"reasoningLevel": "max"}},
81
+ data["config"]["defaultModelSelection"])
82
+ self.assertNotIn("apiKey", profile.read_text())
83
+ self.assertEqual("plan", command[command.index("--mode") + 1])
84
+ self.assertEqual("disabled", command[command.index("--workflow-mode") + 1])
85
+ denied = command[command.index("--disallowed-tools") + 1].split(",")
86
+ for tool in ("Task", "Agent", "Skill", "Bash", "Write", "Edit", "CreateWorkflow",
87
+ "AmendWorkflow", "FillWorkflowHole", "ResumeWorkflowRun", "SaveWorkflow",
88
+ "OffPeakCreate", "CronCreate", "CronUpdate", "CronDelete"):
89
+ self.assertIn(tool, denied)
90
+ self.assertNotIn("OffPeakTask", denied)
91
+ self.assertNotIn("RunWorkflow", denied)
92
+ self.assertNotIn("--resume", command)
93
+ self.assertIn("system {literal}", command[-1])
94
+ self.assertIsNone(kwargs["stdin"])
95
+ return self.result()
96
+
97
+ with mock.patch.object(duet, "_run", side_effect=run), mock.patch.dict(os.environ, {"TEST_SECRET": "not-in-profile"}):
98
+ before = dict(os.environ)
99
+ reply, session = self.invoke()
100
+ self.assertEqual(before, dict(os.environ))
101
+ self.assertEqual(("review complete", "sess_abc"), (reply, session))
102
+ self.assertFalse(seen["profile"].exists())
103
+ self.assertEqual({"model": self.agent.model, "reasoning": "max"}, self.agent.zcode_selection)
104
+
105
+ def test_resume_is_pinned_and_reasserts_policy(self):
106
+ with mock.patch.object(duet, "_run", return_value=self.result()) as run:
107
+ _, self.agent.session_id = self.invoke()
108
+ self.invoke()
109
+ command = run.call_args.args[0]
110
+ self.assertEqual("sess_abc", command[command.index("--resume") + 1])
111
+ self.assertIn("--disallowed-tools", command)
112
+ self.assertNotIn("--continue", command)
113
+
114
+ def test_resume_rejects_model_or_effort_change_before_start(self):
115
+ self.agent.session_id = "sess_abc"
116
+ self.agent.zcode_selection = {"model": self.agent.model, "reasoning": "high"}
117
+ with mock.patch.object(duet, "_run") as run:
118
+ with self.assertRaisesRegex(ValueError, "original model/effort"):
119
+ self.invoke()
120
+ run.assert_not_called()
121
+
122
+ def test_untracked_native_session_cannot_be_resumed(self):
123
+ self.agent.session_id = "sess_external"
124
+ with self.assertRaisesRegex(ValueError, "original model/effort"):
125
+ self.invoke()
126
+
127
+ def test_cwd_resume_and_extra_args_are_rejected(self):
128
+ self.agent.session_id = "latest"
129
+ with self.assertRaisesRegex(ValueError, "explicit sess_"):
130
+ self.invoke()
131
+ self.agent.session_id = None
132
+ self.agent.extra_args = ["--mode", "yolo"]
133
+ with self.assertRaisesRegex(ValueError, "extra_args"):
134
+ self.invoke()
135
+
136
+ def test_only_native_account_model_ids_are_accepted(self):
137
+ for model in (None, "GLM-5.3", "opencode/glm-5.3", "account:zai/"):
138
+ with self.subTest(model=model):
139
+ self.agent.model = model
140
+ with self.assertRaisesRegex(ValueError, "native account"):
141
+ self.invoke()
142
+
143
+ def test_coder_role_is_rejected(self):
144
+ self.agent.role = "coder"
145
+ with self.assertRaisesRegex(ValueError, "read-only"):
146
+ self.invoke()
147
+
148
+ def test_profile_is_removed_on_failure_and_does_not_publish_pin(self):
149
+ paths = []
150
+
151
+ def run(command, **kwargs):
152
+ paths.append(pathlib.Path(kwargs["env_overrides"]["ZCODE_PERSONAL_PROVIDER_CONFIG_FILE"]))
153
+ return 1, "", "Select a model before continuing"
154
+
155
+ with mock.patch.object(duet, "_run", side_effect=run):
156
+ with self.assertRaises(duet.AgentRunError) as caught:
157
+ self.invoke()
158
+ self.assertEqual(duet.FINISHED_AGENT_ERROR, caught.exception.finished_reason)
159
+ self.assertFalse(paths[0].exists())
160
+ self.assertIsNone(self.agent.zcode_selection)
161
+
162
+ def test_timeout_has_the_existing_timeout_contract(self):
163
+ with mock.patch.object(duet, "_run", return_value=(124, "", "timeout")):
164
+ with self.assertRaises(duet.AgentRunError) as caught:
165
+ self.invoke()
166
+ self.assertEqual(duet.FINISHED_TIMEOUT, caught.exception.finished_reason)
167
+
168
+ def test_session_change_is_not_accepted(self):
169
+ self.agent.session_id = "sess_expected"
170
+ self.agent.zcode_selection = {"model": self.agent.model, "reasoning": "max"}
171
+ with mock.patch.object(duet, "_run", return_value=self.result("sess_different")):
172
+ with self.assertRaisesRegex(duet.AgentRunError, "changed its pinned"):
173
+ self.invoke()
174
+
175
+ def test_json_failure_and_missing_reply_are_not_success(self):
176
+ cases = ["not json", "[]", '{}', json.dumps({"sessionId": "sess_a", "response": ""}),
177
+ json.dumps({"sessionId": "sess_a", "response": "partial", "projection": {"status": "error"}})]
178
+ for output in cases:
179
+ with self.subTest(output=output), mock.patch.object(duet, "_run", return_value=(0, output, "")):
180
+ with self.assertRaises(duet.AgentRunError):
181
+ self.invoke()
182
+
183
+ def test_supported_reasoning_levels_are_native_profile_values(self):
184
+ for level in ("low", "high", "max"):
185
+ with self.subTest(level=level):
186
+ def run(command, **kwargs):
187
+ profile = json.loads(pathlib.Path(kwargs["env_overrides"]["ZCODE_PERSONAL_PROVIDER_CONFIG_FILE"]).read_text())
188
+ self.assertEqual(level, profile["config"]["defaultModelSelection"]["options"]["reasoningLevel"])
189
+ return self.result()
190
+ with mock.patch.object(duet, "_run", side_effect=run):
191
+ self.invoke(reasoning=level)
192
+
193
+ def test_invalid_catalog_selection_never_starts_a_model(self):
194
+ for level in ("minimal", "medium", "xhigh"):
195
+ with self.subTest(level=level), mock.patch.object(duet, "_run") as run:
196
+ with self.assertRaisesRegex(duet.AgentRunError, "reasoning level is absent"):
197
+ self.invoke(reasoning=level)
198
+ run.assert_not_called()
199
+ self.agent.model = "account:zai-individual-coding-plan/missing-model"
200
+ with mock.patch.object(duet, "_run") as run:
201
+ with self.assertRaisesRegex(duet.AgentRunError, "account model is absent"):
202
+ self.invoke()
203
+ run.assert_not_called()
204
+
205
+ def test_catalog_layers_preserve_provider_specific_effort_values(self):
206
+ data = json.loads(self.catalog.read_text())
207
+ data["config"]["modelConfigRules"]["modelApiRules"] = [
208
+ {"modelMatch": "glm-5\\.3", "apiTypeMatch": "anthropic-messages",
209
+ "config": {"optionSpecs": {"reasoningLevel": {"values": ["high"]}}}},
210
+ ]
211
+ self.catalog.write_text(json.dumps(data))
212
+ with mock.patch.object(duet, "_run") as run:
213
+ with self.assertRaisesRegex(duet.AgentRunError, "reasoning level is absent"):
214
+ self.invoke(reasoning="max")
215
+ run.assert_not_called()
216
+
217
+ def test_catalog_patterns_are_anchored_and_api_case_is_preserved(self):
218
+ data = json.loads(self.catalog.read_text())
219
+ data["config"]["modelConfigRules"]["modelApiRules"] = [
220
+ {"modelMatch": "glm-5", "apiTypeMatch": "anthropic-messages",
221
+ "config": {"optionSpecs": {"reasoningLevel": {"values": ["low"]}}}},
222
+ {"modelMatch": "glm-5\\.3", "apiTypeMatch": "ANTHROPIC-MESSAGES",
223
+ "config": {"optionSpecs": {"reasoningLevel": {"values": ["low"]}}}},
224
+ ]
225
+ self.catalog.write_text(json.dumps(data))
226
+ with mock.patch.object(duet, "_run", return_value=self.result()):
227
+ self.invoke(reasoning="max")
228
+
229
+ def test_path_cli_does_not_borrow_desktop_catalog(self):
230
+ selection = {"providerId": "account:zai-individual-coding-plan", "modelId": "GLM-5.3"}
231
+ with mock.patch.dict(os.environ, {}, clear=True):
232
+ with self.assertRaisesRegex(ValueError, "another installation's catalog"):
233
+ duet._zcode_catalog(selection, {})
234
+
235
+ def test_path_cli_resolves_catalog_from_its_real_installation(self):
236
+ self.launch.stop()
237
+ for bundled in (True, False):
238
+ with self.subTest(bundled=bundled):
239
+ installation = self.cwd / str(bundled)
240
+ entry = installation / "apps/zcode-cli/packages/cli/dist/zcode.cjs"
241
+ entry.parent.mkdir(parents=True)
242
+ entry.write_text("// native entry")
243
+ catalog = (entry.parent / "provider/zcode-builtin.json" if bundled else
244
+ installation / "config/provider/zcode-builtin.json")
245
+ catalog.parent.mkdir(parents=True)
246
+ catalog.write_text(self.catalog.read_text())
247
+ link = installation / "zcode"
248
+ link.symlink_to(entry)
249
+ with mock.patch.object(duet.shutil, "which", return_value=str(link)), \
250
+ mock.patch.dict(os.environ, {"ZCODE_BUILTIN_PROVIDER_CONFIG_FILE": ""}):
251
+ command, env = duet._zcode_command()
252
+ self.assertEqual([str(link)], command)
253
+ self.assertEqual(str(catalog.resolve()), env["ZCODE_BUILTIN_PROVIDER_CONFIG_FILE"])
254
+ selection = {"providerId": "account:zai-individual-coding-plan", "modelId": "GLM-5.3"}
255
+ self.assertEqual(json.loads(catalog.read_text()), duet._zcode_catalog(selection, env))
256
+
257
+ def test_path_cli_honors_explicit_catalog(self):
258
+ self.launch.stop()
259
+ with mock.patch.object(duet.shutil, "which", return_value="/runtime/zcode"), \
260
+ mock.patch.dict(os.environ, {"ZCODE_BUILTIN_PROVIDER_CONFIG_FILE": str(self.catalog)}):
261
+ command, env = duet._zcode_command()
262
+ self.assertEqual(["/runtime/zcode"], command)
263
+ self.assertEqual(str(self.catalog), env["ZCODE_BUILTIN_PROVIDER_CONFIG_FILE"])
264
+
265
+ def test_missing_path_catalog_does_not_borrow_desktop_catalog(self):
266
+ self.launch.stop()
267
+ desktop = self.cwd / "desktop"
268
+ catalog = desktop / "config/provider/zcode-builtin.json"
269
+ catalog.parent.mkdir(parents=True)
270
+ catalog.write_text(self.catalog.read_text())
271
+ entry = desktop / "glm/zcode.cjs"
272
+ entry.parent.mkdir()
273
+ entry.write_text("// desktop entry")
274
+ with mock.patch.object(duet, "_ZCODE_DESKTOP_RESOURCES", desktop), \
275
+ mock.patch.object(duet.shutil, "which", return_value=str(self.cwd / "cli/zcode")), \
276
+ mock.patch.dict(os.environ, {"ZCODE_BUILTIN_PROVIDER_CONFIG_FILE": ""}):
277
+ with self.assertRaisesRegex(ValueError, "PATH ZCode CLI's catalog"):
278
+ duet._zcode_command()
279
+
280
+ def test_catalog_snapshot_freezes_validation_and_disables_remote_refresh(self):
281
+ before = json.loads(self.catalog.read_text())
282
+ snapshots = []
283
+
284
+ def run(command, **kwargs):
285
+ env = kwargs["env_overrides"]
286
+ snapshot = pathlib.Path(env["ZCODE_BUILTIN_PROVIDER_CONFIG_FILE"])
287
+ snapshots.append(snapshot)
288
+ self.catalog.write_text('{"changed":true}')
289
+ self.assertEqual(before, json.loads(snapshot.read_text()))
290
+ self.assertEqual(0o600, snapshot.stat().st_mode & 0o777)
291
+ self.assertEqual("", env["ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE"])
292
+ return self.result()
293
+
294
+ with mock.patch.dict(os.environ, {"ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE": "elsewhere"}), \
295
+ mock.patch.object(duet, "_run", side_effect=run):
296
+ self.invoke()
297
+ self.assertEqual("elsewhere", os.environ["ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE"])
298
+ self.assertFalse(snapshots[0].exists())
299
+
300
+ def test_native_site_url_normalization_and_template_api_inheritance(self):
301
+ data = json.loads(self.catalog.read_text())
302
+ provider = data["config"]["providerConfigRules"]["providerRules"][0]
303
+ provider["templateId"] = "native-template"
304
+ del provider["config"]["api"]
305
+ data["config"]["providerConfigRules"]["templateRules"] = [{
306
+ "templateId": "native-template", "config": {"api": {
307
+ "type": "anthropic-messages", "baseUrl": "https://API.Example:443/"}}}]
308
+ data["config"]["modelConfigRules"]["providerSiteRules"] = [{
309
+ "modelMatch": "glm-5\\.3", "apiTypeMatch": "anthropic-messages",
310
+ "baseUrlMatch": "https://api\\.example",
311
+ "config": {"optionSpecs": {"reasoningLevel": {"values": ["high"]}}}}]
312
+ self.catalog.write_text(json.dumps(data))
313
+ with mock.patch.object(duet, "_run") as run:
314
+ with self.assertRaisesRegex(duet.AgentRunError, "reasoning level is absent"):
315
+ self.invoke(reasoning="max")
316
+ run.assert_not_called()
317
+ with mock.patch.object(duet, "_run", return_value=self.result()):
318
+ self.invoke(reasoning="high")
319
+ self.assertEqual("https://api.example/Path?q=Value#Section",
320
+ duet._zcode_rule_url("https://API.Example:443/Path/?q=Value#Section"))
321
+ for unsupported in ("https://api.example/%2e/", "https://api.example/../", "https://api.example/?",
322
+ "https://user@api.example", "https://127.1", "https://0x7f000001/a",
323
+ "https://127.1./a", "https://api.example/a b"):
324
+ with self.subTest(url=unsupported), self.assertRaises(ValueError):
325
+ duet._zcode_rule_url(unsupported)
326
+
327
+ def test_native_null_option_spec_clears_inherited_reasoning(self):
328
+ data = json.loads(self.catalog.read_text())
329
+ data["config"]["modelConfigRules"]["builtinProviderModelRules"] = [{
330
+ "providerId": "account:zai-individual-coding-plan", "modelId": "GLM-5.3",
331
+ "config": {"optionSpecs": {"reasoningLevel": None}}}]
332
+ self.catalog.write_text(json.dumps(data))
333
+ with mock.patch.object(duet, "_run") as run:
334
+ with self.assertRaisesRegex(duet.AgentRunError, "reasoning level is absent"):
335
+ self.invoke()
336
+ run.assert_not_called()
337
+
338
+ def test_native_null_reasoning_values_clear_inherited_levels(self):
339
+ provider = {"providerId": "account:a", "config": {}}
340
+ rules = {"modelRules": [
341
+ {"modelMatch": ".*", "config": {"optionSpecs": {"reasoningLevel": {"values": ["high", "max"]}}}},
342
+ {"modelMatch": ".*", "config": {"optionSpecs": {"reasoningLevel": {"values": None}}}},
343
+ ]}
344
+ self.assertEqual([], duet._zcode_catalog_reasoning(rules, provider, "GLM-5.3"))
345
+
346
+ def test_mcp_must_be_disabled_by_native_config_before_review(self):
347
+ path = self.cwd / "zcode.json"
348
+ for data in ({}, {"features": {"mcp": True}}, {"features": {"skill": False}},
349
+ {"features": {"mcp": False, "skill": "false"}},
350
+ {"features": {"mcp": False}, "network": {"timeout": "bad"}},
351
+ {"features": {"mcp": 0}}):
352
+ with self.subTest(data=data), mock.patch.object(duet, "_run") as run:
353
+ path.write_text(json.dumps(data))
354
+ with self.assertRaisesRegex(duet.AgentRunError, "native features.mcp=false"):
355
+ self.invoke()
356
+ run.assert_not_called()
357
+
358
+ def test_utf16_policy_cannot_certify_native_utf8_configuration(self):
359
+ (self.cwd / "zcode.json").write_bytes('{"features":{"mcp":false}}'.encode("utf-16"))
360
+ with mock.patch.object(duet, "_run") as run:
361
+ with self.assertRaisesRegex(duet.AgentRunError, "Unsupported native ZCode permission"):
362
+ self.invoke()
363
+ run.assert_not_called()
364
+
365
+ def test_native_user_and_project_denials_survive_cli_overrides(self):
366
+ (self.cwd / ".git").mkdir()
367
+ child = self.cwd / "subdir"
368
+ child.mkdir()
369
+ paths = duet._zcode_permission_paths(child)
370
+ self.assertEqual([self.home / ".zcode/cli/config.json", self.cwd / "zcode.json",
371
+ self.cwd / ".zcode/config.json", child / "zcode.json", child / ".zcode/config.json"], paths)
372
+ for path, data in [(paths[0], {"permission": {"disallowedTools": ["Read", "mcp__private__mutate"]}}),
373
+ (paths[1], {"permission": {"disallowedTools": ["Grep", "Bash(git *)"]}}),
374
+ (paths[2], {"features": {"mcp": False}}),
375
+ (paths[-1], {"permission": {"mode": "yolo"}})]:
376
+ path.parent.mkdir(parents=True, exist_ok=True)
377
+ path.write_text(json.dumps(data))
378
+ with mock.patch.object(duet, "_run", return_value=self.result()) as run:
379
+ self.invoke(cwd=child)
380
+ command = run.call_args.args[0]
381
+ denied = command[command.index("--disallowed-tools") + 1].split(",")
382
+ self.assertTrue({"Read", "Grep", "Bash", "mcp__private__mutate"}.issubset(denied))
383
+ self.assertEqual("plan", command[command.index("--mode") + 1])
384
+ _, fingerprints = duet._zcode_permission_denials(child)
385
+ paths[-1].write_text('{"permission":{"disallowedTools":["Glob"]}}')
386
+ with self.assertRaisesRegex(ValueError, "changed before launch"):
387
+ duet._zcode_check_permission_inputs(child, fingerprints)
388
+
389
+ def test_unsupported_native_permission_config_fails_before_model_start(self):
390
+ path = self.home / ".zcode/cli/config.json"
391
+ path.parent.mkdir(parents=True)
392
+ for value in ('{"permission": {"allowedTools":["Read"]}}', '{"permission":{"disallowedTools":"Read"}}',
393
+ '{"permission":null}', '[]', '// JSONC unsupported', '{"network":{"timeout":NaN}}', '{"permission":{"disallowedTools":["Read,Write"]}}'):
394
+ with self.subTest(value=value), mock.patch.object(duet, "_run") as run:
395
+ path.write_text(value)
396
+ with self.assertRaisesRegex(duet.AgentRunError, "Unsupported native ZCode permission"):
397
+ self.invoke()
398
+ run.assert_not_called()
399
+
400
+ def test_loop_prerequisite_accepts_desktop_runtime_without_path_cli(self):
401
+ spec = importlib.util.spec_from_file_location("duet_loop_e2e", ROOT / "scripts/duet_loop_e2e.py")
402
+ loop = importlib.util.module_from_spec(spec)
403
+ sys.modules[spec.name] = loop
404
+ self.addCleanup(sys.modules.pop, spec.name, None)
405
+ spec.loader.exec_module(loop)
406
+ args = ["loop", "--lead-backend", "zcode", "--partner-backend", "codex", "--base-dir", str(self.cwd / "loop")]
407
+ runtime = mock.Mock()
408
+ with mock.patch.object(sys, "argv", args), mock.patch.object(loop.shutil, "which", side_effect=lambda name: None if name == "zcode" else name), \
409
+ mock.patch.object(loop, "load_duet_module", return_value=runtime), mock.patch.object(loop, "scenarios", side_effect=RuntimeError("prerequisites passed")):
410
+ with self.assertRaisesRegex(RuntimeError, "prerequisites passed"):
411
+ loop.main()
412
+ runtime._zcode_command.assert_called_once()
413
+ fixture = self.cwd / "native-fixture"
414
+ fixture.mkdir()
415
+ (fixture / "LOCKED.md").write_text("Preserve this fixture.")
416
+ loop.init_git_repo(fixture)
417
+ loop.seed_zcode_review_policy(fixture)
418
+ self.assertEqual({"features": {"mcp": False}}, json.loads((fixture / "zcode.json").read_text()))
419
+ self.assertEqual("", loop.run_cmd(["git", "status", "--porcelain"], fixture).stdout)
420
+
421
+ def test_pin_survives_state_restore(self):
422
+ with mock.patch.object(duet, "_run", return_value=self.result()):
423
+ _, self.agent.session_id = self.invoke()
424
+ state = duet.agent_state(self.agent)
425
+ partner = duet.agent_state(duet.Agent(name="gpt", backend="codex"))
426
+ restored = duet._agents_from_state({"agents": [state, partner]},
427
+ argparse.ArgumentParser(), "--continue")[0]
428
+ self.assertEqual(self.agent.zcode_selection, restored.zcode_selection)
429
+
430
+ def test_dry_run_does_not_start_zcode(self):
431
+ with mock.patch.object(duet, "_run") as run:
432
+ reply, session = self.invoke(dry=True)
433
+ run.assert_not_called()
434
+ self.assertTrue(session.startswith("sess_"))
435
+ self.assertIn("dry-run", reply)
436
+
437
+
438
+ if __name__ == "__main__":
439
+ unittest.main()
File without changes
File without changes
File without changes
File without changes