duet-cli 0.2.5__tar.gz → 0.2.6__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.
- {duet_cli-0.2.5/duet_cli.egg-info → duet_cli-0.2.6}/PKG-INFO +30 -10
- {duet_cli-0.2.5 → duet_cli-0.2.6}/README.md +29 -9
- {duet_cli-0.2.5 → duet_cli-0.2.6}/duet.py +184 -4
- {duet_cli-0.2.5 → duet_cli-0.2.6/duet_cli.egg-info}/PKG-INFO +30 -10
- {duet_cli-0.2.5 → duet_cli-0.2.6}/pyproject.toml +1 -1
- {duet_cli-0.2.5 → duet_cli-0.2.6}/tests/test_duet.py +229 -0
- {duet_cli-0.2.5 → duet_cli-0.2.6}/LICENSE +0 -0
- {duet_cli-0.2.5 → duet_cli-0.2.6}/duet_cli.egg-info/SOURCES.txt +0 -0
- {duet_cli-0.2.5 → duet_cli-0.2.6}/duet_cli.egg-info/dependency_links.txt +0 -0
- {duet_cli-0.2.5 → duet_cli-0.2.6}/duet_cli.egg-info/entry_points.txt +0 -0
- {duet_cli-0.2.5 → duet_cli-0.2.6}/duet_cli.egg-info/requires.txt +0 -0
- {duet_cli-0.2.5 → duet_cli-0.2.6}/duet_cli.egg-info/top_level.txt +0 -0
- {duet_cli-0.2.5 → duet_cli-0.2.6}/setup.cfg +0 -0
- {duet_cli-0.2.5 → duet_cli-0.2.6}/tests/test_bump_release_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duet-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Two CLI agents in conversation. One Python file. Stdlib only.
|
|
5
5
|
Author: Volkan Altan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -24,12 +24,12 @@ Dynamic: license-file
|
|
|
24
24
|
**Two CLI agents in conversation. One Python file. Stdlib only.**
|
|
25
25
|
|
|
26
26
|
`duet` runs two command-line coding agents in alternating turns until they
|
|
27
|
-
agree. By default that is Claude and Codex; Gemini and
|
|
28
|
-
supported, and you can pair two agents from the same backend. One agent
|
|
29
|
-
or reviews while the other implements; each keeps its own session memory
|
|
30
|
-
turns, and every run leaves a transcript you can inspect.
|
|
27
|
+
agree. By default that is Claude and Codex; Gemini, Copilot, and OpenCode are
|
|
28
|
+
also supported, and you can pair two agents from the same backend. One agent
|
|
29
|
+
plans or reviews while the other implements; each keeps its own session memory
|
|
30
|
+
across turns, and every run leaves a transcript you can inspect.
|
|
31
31
|
|
|
32
|
-
## Use it
|
|
32
|
+
## Use it four ways
|
|
33
33
|
|
|
34
34
|
### 1. Inside Claude Code — `/duet`
|
|
35
35
|
|
|
@@ -93,7 +93,26 @@ CLI, so install that first (see below) and make sure `command -v duet` passes in
|
|
|
93
93
|
Codex's shell. Full guide:
|
|
94
94
|
[docs/CODEX_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/CODEX_PLUGIN.md).
|
|
95
95
|
|
|
96
|
-
### 3.
|
|
96
|
+
### 3. Inside OpenCode — `/duet`
|
|
97
|
+
|
|
98
|
+
OpenCode custom commands are drop-in files — no marketplace step:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
mkdir -p ~/.config/opencode/command
|
|
102
|
+
cp plugins/duet-opencode/command/duet.md ~/.config/opencode/command/duet.md
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then invoke `/duet` in the OpenCode TUI (or `opencode run --command duet "..."`
|
|
106
|
+
non-interactively). Like the other plugins it shells out to the `duet` CLI, so
|
|
107
|
+
install that first and make sure `command -v duet` passes in OpenCode's shell.
|
|
108
|
+
The command runs on OpenCode's `build` agent; plain `/duet` runs the same
|
|
109
|
+
`claude -p /review` kickoff, and `/duet 'npm test 2>&1' --turns 4` seeds from
|
|
110
|
+
any command. Full guide:
|
|
111
|
+
[docs/OPENCODE_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md).
|
|
112
|
+
(duet can also drive OpenCode as a backend — `--partner opencode:coder` — so
|
|
113
|
+
OpenCode can be one of the two looped agents too.)
|
|
114
|
+
|
|
115
|
+
### 4. From the terminal — `duet`
|
|
97
116
|
|
|
98
117
|
```bash
|
|
99
118
|
pipx install duet-cli # recommended; the command it installs is `duet`
|
|
@@ -172,8 +191,9 @@ coder fixes them). Run one with `duet --config examples/pr-review.yaml`.
|
|
|
172
191
|
## How it works
|
|
173
192
|
|
|
174
193
|
Each agent keeps its own conversation memory across turns (Claude via
|
|
175
|
-
`--resume`, Codex via `codex exec resume`, Gemini and
|
|
176
|
-
session ids). On each turn duet sends one agent's latest reply to the
|
|
194
|
+
`--resume`, Codex via `codex exec resume`, Gemini, Copilot, and OpenCode via
|
|
195
|
+
their JSON session ids). On each turn duet sends one agent's latest reply to the
|
|
196
|
+
other.
|
|
177
197
|
|
|
178
198
|
To converge, an agent must include an `LGTM rationale:` explaining why the work
|
|
179
199
|
is done, followed by the sentinel `<<<LGTM>>>` on its own line — a bare
|
|
@@ -186,7 +206,7 @@ stderr logs, and the `wt/` worktree when `--worktree` is on. Inspect a run with
|
|
|
186
206
|
`duet --status <run-id>`, list runs with `duet --list`, and start a fresh run
|
|
187
207
|
from saved state with `duet --continue <run> --task "next thing"`.
|
|
188
208
|
|
|
189
|
-
- **Backends:** `claude`, `codex`, `gemini`, `copilot`
|
|
209
|
+
- **Backends:** `claude`, `codex`, `gemini`, `copilot`, `opencode`
|
|
190
210
|
- **Roles:** `planner`, `coder`, `reviewer`, `triage-reviewer`, or a custom one
|
|
191
211
|
- **Reasoning:** `--reasoning minimal|low|medium|high|xhigh|max`
|
|
192
212
|
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
**Two CLI agents in conversation. One Python file. Stdlib only.**
|
|
4
4
|
|
|
5
5
|
`duet` runs two command-line coding agents in alternating turns until they
|
|
6
|
-
agree. By default that is Claude and Codex; Gemini and
|
|
7
|
-
supported, and you can pair two agents from the same backend. One agent
|
|
8
|
-
or reviews while the other implements; each keeps its own session memory
|
|
9
|
-
turns, and every run leaves a transcript you can inspect.
|
|
6
|
+
agree. By default that is Claude and Codex; Gemini, Copilot, and OpenCode are
|
|
7
|
+
also supported, and you can pair two agents from the same backend. One agent
|
|
8
|
+
plans or reviews while the other implements; each keeps its own session memory
|
|
9
|
+
across turns, and every run leaves a transcript you can inspect.
|
|
10
10
|
|
|
11
|
-
## Use it
|
|
11
|
+
## Use it four ways
|
|
12
12
|
|
|
13
13
|
### 1. Inside Claude Code — `/duet`
|
|
14
14
|
|
|
@@ -72,7 +72,26 @@ CLI, so install that first (see below) and make sure `command -v duet` passes in
|
|
|
72
72
|
Codex's shell. Full guide:
|
|
73
73
|
[docs/CODEX_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/CODEX_PLUGIN.md).
|
|
74
74
|
|
|
75
|
-
### 3.
|
|
75
|
+
### 3. Inside OpenCode — `/duet`
|
|
76
|
+
|
|
77
|
+
OpenCode custom commands are drop-in files — no marketplace step:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
mkdir -p ~/.config/opencode/command
|
|
81
|
+
cp plugins/duet-opencode/command/duet.md ~/.config/opencode/command/duet.md
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Then invoke `/duet` in the OpenCode TUI (or `opencode run --command duet "..."`
|
|
85
|
+
non-interactively). Like the other plugins it shells out to the `duet` CLI, so
|
|
86
|
+
install that first and make sure `command -v duet` passes in OpenCode's shell.
|
|
87
|
+
The command runs on OpenCode's `build` agent; plain `/duet` runs the same
|
|
88
|
+
`claude -p /review` kickoff, and `/duet 'npm test 2>&1' --turns 4` seeds from
|
|
89
|
+
any command. Full guide:
|
|
90
|
+
[docs/OPENCODE_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md).
|
|
91
|
+
(duet can also drive OpenCode as a backend — `--partner opencode:coder` — so
|
|
92
|
+
OpenCode can be one of the two looped agents too.)
|
|
93
|
+
|
|
94
|
+
### 4. From the terminal — `duet`
|
|
76
95
|
|
|
77
96
|
```bash
|
|
78
97
|
pipx install duet-cli # recommended; the command it installs is `duet`
|
|
@@ -151,8 +170,9 @@ coder fixes them). Run one with `duet --config examples/pr-review.yaml`.
|
|
|
151
170
|
## How it works
|
|
152
171
|
|
|
153
172
|
Each agent keeps its own conversation memory across turns (Claude via
|
|
154
|
-
`--resume`, Codex via `codex exec resume`, Gemini and
|
|
155
|
-
session ids). On each turn duet sends one agent's latest reply to the
|
|
173
|
+
`--resume`, Codex via `codex exec resume`, Gemini, Copilot, and OpenCode via
|
|
174
|
+
their JSON session ids). On each turn duet sends one agent's latest reply to the
|
|
175
|
+
other.
|
|
156
176
|
|
|
157
177
|
To converge, an agent must include an `LGTM rationale:` explaining why the work
|
|
158
178
|
is done, followed by the sentinel `<<<LGTM>>>` on its own line — a bare
|
|
@@ -165,7 +185,7 @@ stderr logs, and the `wt/` worktree when `--worktree` is on. Inspect a run with
|
|
|
165
185
|
`duet --status <run-id>`, list runs with `duet --list`, and start a fresh run
|
|
166
186
|
from saved state with `duet --continue <run> --task "next thing"`.
|
|
167
187
|
|
|
168
|
-
- **Backends:** `claude`, `codex`, `gemini`, `copilot`
|
|
188
|
+
- **Backends:** `claude`, `codex`, `gemini`, `copilot`, `opencode`
|
|
169
189
|
- **Roles:** `planner`, `coder`, `reviewer`, `triage-reviewer`, or a custom one
|
|
170
190
|
- **Reasoning:** `--reasoning minimal|low|medium|high|xhigh|max`
|
|
171
191
|
|
|
@@ -28,8 +28,12 @@ Each agent keeps its own session across turns:
|
|
|
28
28
|
UUID makes resume robust to parallel Codex sessions sharing the cwd, but
|
|
29
29
|
`--last` is still keyed on cwd — use `--worktree` to isolate duet's Codex
|
|
30
30
|
cwd from the host repo when no UUID is available.
|
|
31
|
+
- Gemini: `gemini -p ... --output-format json`; subsequent turns use
|
|
32
|
+
`gemini --resume <session_id>` from the JSON response.
|
|
31
33
|
- Copilot: `copilot -p ... --output-format json`; subsequent turns use
|
|
32
34
|
`copilot --resume=<sessionId>` from the JSONL result event.
|
|
35
|
+
- OpenCode: `opencode run --format json`; subsequent turns use
|
|
36
|
+
`opencode run -s <sessionID>` from the JSONL event stream.
|
|
33
37
|
|
|
34
38
|
Transcript is always logged to runs/<ts>/transcript.md for humans, but each
|
|
35
39
|
prompt sent to an agent is just the latest counterpart message — keeping
|
|
@@ -223,6 +227,18 @@ COPILOT_REASONING_MAP = {
|
|
|
223
227
|
}
|
|
224
228
|
COPILOT_REASONING_PROMPT_PREFIX = CLAUDE_REASONING_PROMPT_PREFIX
|
|
225
229
|
|
|
230
|
+
# OpenCode exposes reasoning effort through `run --variant <value>`, documented
|
|
231
|
+
# as "provider-specific reasoning effort (e.g. high, max, minimal)". The valid
|
|
232
|
+
# set is model/provider-dependent, but OpenCode silently ignores a variant a
|
|
233
|
+
# model doesn't define rather than erroring, so we pass duet's level through
|
|
234
|
+
# unchanged (identity map): a model that knows the variant honors it, one that
|
|
235
|
+
# doesn't is unaffected. This keeps the mapping forward-compatible without duet
|
|
236
|
+
# tracking each provider's variant vocabulary.
|
|
237
|
+
OPENCODE_REASONING_MAP = {level: level for level in REASONING_LEVELS}
|
|
238
|
+
# `--variant` is the authoritative effort control; the high/xhigh/max prompt
|
|
239
|
+
# nudges are belt-and-braces, same as Claude/Gemini/Copilot.
|
|
240
|
+
OPENCODE_REASONING_PROMPT_PREFIX = CLAUDE_REASONING_PROMPT_PREFIX
|
|
241
|
+
|
|
226
242
|
# Map duet's existing Claude-flavored permission mode knob to Gemini CLI's
|
|
227
243
|
# approval vocabulary. Unknown values pass through so advanced users can try
|
|
228
244
|
# newer Gemini modes without waiting for a duet release.
|
|
@@ -1498,6 +1514,145 @@ def call_copilot(agent: Agent, system_prompt: str, message: str,
|
|
|
1498
1514
|
return text, new_sid
|
|
1499
1515
|
|
|
1500
1516
|
|
|
1517
|
+
def _parse_opencode_jsonl(stdout: str) -> tuple[str, Optional[str], Optional[str]]:
|
|
1518
|
+
"""Return (assistant_text, sessionID, error_message) from `opencode run --format json`.
|
|
1519
|
+
|
|
1520
|
+
OpenCode emits a JSONL event stream on stdout. Every event carries a
|
|
1521
|
+
top-level `sessionID`; assistant reply text arrives as one or more
|
|
1522
|
+
``{"type":"text","part":{"type":"text","text":...}}`` events; failures
|
|
1523
|
+
arrive as ``{"type":"error","error":{...}}`` events. Crucially, `opencode
|
|
1524
|
+
run` exits 0 even on model/tool errors — the failure lives in the event
|
|
1525
|
+
stream, not the exit code — so the caller must treat a returned
|
|
1526
|
+
error_message as fatal.
|
|
1527
|
+
|
|
1528
|
+
Text parts that carry an id are keyed by it (last write wins) so a streamed
|
|
1529
|
+
part re-emitted as it grows doesn't duplicate; the rare id-less part gets a
|
|
1530
|
+
unique synthetic key (``__noid_<n>``) so distinct parts (e.g. text split
|
|
1531
|
+
around a tool call) are still concatenated in arrival order without ever
|
|
1532
|
+
aliasing a real id. Non-JSON lines are skipped rather than fatal: OpenCode
|
|
1533
|
+
can print a one-time DB-migration banner on a fresh machine, and a genuine
|
|
1534
|
+
failure still surfaces via a missing sessionID or an error event.
|
|
1535
|
+
"""
|
|
1536
|
+
parts: dict[str, str] = {}
|
|
1537
|
+
session_id: Optional[str] = None
|
|
1538
|
+
error_message: Optional[str] = None
|
|
1539
|
+
for raw_line in stdout.splitlines():
|
|
1540
|
+
line = raw_line.strip()
|
|
1541
|
+
if not line:
|
|
1542
|
+
continue
|
|
1543
|
+
try:
|
|
1544
|
+
event = json.loads(line)
|
|
1545
|
+
except json.JSONDecodeError:
|
|
1546
|
+
continue
|
|
1547
|
+
if not isinstance(event, dict):
|
|
1548
|
+
continue
|
|
1549
|
+
sid = event.get("sessionID")
|
|
1550
|
+
if sid:
|
|
1551
|
+
session_id = str(sid)
|
|
1552
|
+
etype = event.get("type")
|
|
1553
|
+
if etype == "text":
|
|
1554
|
+
part = event.get("part")
|
|
1555
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
1556
|
+
text = part.get("text")
|
|
1557
|
+
if isinstance(text, str):
|
|
1558
|
+
# Real OpenCode parts always carry a stable id (prt_…). The
|
|
1559
|
+
# synthetic key keeps a rare id-less part distinct without
|
|
1560
|
+
# ever aliasing a real id like "0".
|
|
1561
|
+
raw_id = part.get("id")
|
|
1562
|
+
pid = str(raw_id) if raw_id else f"__noid_{len(parts)}"
|
|
1563
|
+
parts[pid] = text
|
|
1564
|
+
elif etype == "error":
|
|
1565
|
+
err = event.get("error")
|
|
1566
|
+
if isinstance(err, str):
|
|
1567
|
+
# OpenCode documents a dict error payload, but tolerate a
|
|
1568
|
+
# plain-string one so the real cause is surfaced verbatim
|
|
1569
|
+
# instead of degrading to "unknown error".
|
|
1570
|
+
msg = err
|
|
1571
|
+
else:
|
|
1572
|
+
data = err.get("data") if isinstance(err, dict) else None
|
|
1573
|
+
msg = data.get("message") if isinstance(data, dict) else None
|
|
1574
|
+
if not msg and isinstance(err, dict):
|
|
1575
|
+
msg = err.get("name")
|
|
1576
|
+
error_message = str(msg or "unknown error")
|
|
1577
|
+
return "\n".join(parts.values()).rstrip(), session_id, error_message
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def call_opencode(agent: Agent, system_prompt: str, message: str,
|
|
1581
|
+
cwd: pathlib.Path, timeout: int, dry: bool,
|
|
1582
|
+
first_turn: bool, reasoning: Optional[str] = None,
|
|
1583
|
+
stderr_log_path: Optional[pathlib.Path] = None,
|
|
1584
|
+
pid_file_path: Optional[pathlib.Path] = None) -> tuple[str, Optional[str]]:
|
|
1585
|
+
"""Returns (assistant_text, new_session_id) from the OpenCode CLI JSONL stream."""
|
|
1586
|
+
# OpenCode resume is id-keyed (`-s <sessionID>`) and the id is stable across
|
|
1587
|
+
# turns, so there's no first-turn command bifurcation; the argument keeps the
|
|
1588
|
+
# adapter dispatch signature aligned with Codex/Gemini/Copilot.
|
|
1589
|
+
_ = first_turn
|
|
1590
|
+
eff_cwd = agent.cwd_override or cwd
|
|
1591
|
+
if reasoning:
|
|
1592
|
+
prefix = backend_adapter("opencode").reasoning_prompt_prefix.get(reasoning, "")
|
|
1593
|
+
system_prompt = prefix + system_prompt
|
|
1594
|
+
if dry:
|
|
1595
|
+
new_sid = agent.session_id or f"dry-opencode-{agent.name}-{int(time.time())}"
|
|
1596
|
+
wt_note = f" wt={eff_cwd}" if agent.cwd_override else ""
|
|
1597
|
+
rn = f" reasoning={reasoning}" if reasoning else ""
|
|
1598
|
+
return (
|
|
1599
|
+
f"[dry-run opencode/{agent.name}{wt_note}{rn}] received {len(message)} chars\n"
|
|
1600
|
+
"LGTM rationale: dry-run accepted the harness path and has no real "
|
|
1601
|
+
"agent output to review.\n"
|
|
1602
|
+
f"{DEFAULT_SENTINEL}"
|
|
1603
|
+
), new_sid
|
|
1604
|
+
|
|
1605
|
+
full_prompt = f"=== ROLE ===\n{system_prompt}\n\n=== MESSAGE FROM PARTNER ===\n{message}"
|
|
1606
|
+
# OpenCode permissions are native; like Copilot we run non-interactively and
|
|
1607
|
+
# auto-approve so tool-using turns don't hang waiting for an approval that
|
|
1608
|
+
# can never arrive. `--sandbox` / `permission_mode` do not apply. All options
|
|
1609
|
+
# precede the positional message (the `run [message..]` positional is last).
|
|
1610
|
+
cmd = [
|
|
1611
|
+
"opencode", "run",
|
|
1612
|
+
"--format", "json",
|
|
1613
|
+
"--dir", str(eff_cwd),
|
|
1614
|
+
"--dangerously-skip-permissions",
|
|
1615
|
+
]
|
|
1616
|
+
if agent.session_id:
|
|
1617
|
+
cmd += ["-s", agent.session_id]
|
|
1618
|
+
if reasoning:
|
|
1619
|
+
variant = backend_adapter("opencode").reasoning_map.get(reasoning, reasoning)
|
|
1620
|
+
if variant:
|
|
1621
|
+
cmd += ["--variant", variant]
|
|
1622
|
+
if agent.model:
|
|
1623
|
+
cmd += ["-m", agent.model]
|
|
1624
|
+
cmd += agent.extra_args
|
|
1625
|
+
cmd.append(full_prompt)
|
|
1626
|
+
rc, out, err = _agent_run(
|
|
1627
|
+
cmd,
|
|
1628
|
+
backend="opencode",
|
|
1629
|
+
cwd=eff_cwd,
|
|
1630
|
+
stdin=None,
|
|
1631
|
+
timeout=timeout,
|
|
1632
|
+
stderr_log_path=stderr_log_path,
|
|
1633
|
+
pid_file_path=pid_file_path,
|
|
1634
|
+
)
|
|
1635
|
+
if rc != 0:
|
|
1636
|
+
reason = FINISHED_TIMEOUT if rc == 124 else FINISHED_AGENT_ERROR
|
|
1637
|
+
raise AgentRunError(reason, f"opencode exited {rc}\nstderr:\n{err}")
|
|
1638
|
+
text, new_sid, error_message = _parse_opencode_jsonl(out)
|
|
1639
|
+
# `opencode run` exits 0 even when the model/tool errored; the failure is an
|
|
1640
|
+
# error event in the JSONL stream. Surface it as agent_error rather than
|
|
1641
|
+
# forwarding an empty or partial reply.
|
|
1642
|
+
if error_message:
|
|
1643
|
+
raise AgentRunError(
|
|
1644
|
+
FINISHED_AGENT_ERROR,
|
|
1645
|
+
f"opencode reported an error event: {error_message}",
|
|
1646
|
+
)
|
|
1647
|
+
if not new_sid:
|
|
1648
|
+
raise AgentRunError(
|
|
1649
|
+
FINISHED_AGENT_ERROR,
|
|
1650
|
+
"opencode JSONL output did not include a sessionID; duet requires an "
|
|
1651
|
+
"OpenCode CLI build that emits JSON session events for multi-turn memory.",
|
|
1652
|
+
)
|
|
1653
|
+
return text, new_sid
|
|
1654
|
+
|
|
1655
|
+
|
|
1501
1656
|
def _call_claude_backend(agent: Agent, system_prompt: str, message: str,
|
|
1502
1657
|
cfg: DuetConfig, first_turn: bool,
|
|
1503
1658
|
stderr_log_path: Optional[pathlib.Path],
|
|
@@ -1566,6 +1721,24 @@ def _call_copilot_backend(agent: Agent, system_prompt: str, message: str,
|
|
|
1566
1721
|
)
|
|
1567
1722
|
|
|
1568
1723
|
|
|
1724
|
+
def _call_opencode_backend(agent: Agent, system_prompt: str, message: str,
|
|
1725
|
+
cfg: DuetConfig, first_turn: bool,
|
|
1726
|
+
stderr_log_path: Optional[pathlib.Path],
|
|
1727
|
+
pid_file_path: Optional[pathlib.Path],
|
|
1728
|
+
reasoning: Optional[str]) -> tuple[str, Optional[str]]:
|
|
1729
|
+
# OpenCode's `run` operates on the whole project under `--dir`; it has no
|
|
1730
|
+
# per-call extra-root flag, so cfg.add_dirs is intentionally not plumbed
|
|
1731
|
+
# through (mirrors Codex, which also ignores add_dirs).
|
|
1732
|
+
return call_opencode(
|
|
1733
|
+
agent, system_prompt, message, cfg.cwd,
|
|
1734
|
+
cfg.per_turn_timeout, cfg.dry_run,
|
|
1735
|
+
first_turn=first_turn,
|
|
1736
|
+
reasoning=reasoning,
|
|
1737
|
+
stderr_log_path=stderr_log_path,
|
|
1738
|
+
pid_file_path=pid_file_path,
|
|
1739
|
+
)
|
|
1740
|
+
|
|
1741
|
+
|
|
1569
1742
|
BACKENDS = {
|
|
1570
1743
|
"claude": BackendAdapter(
|
|
1571
1744
|
name="claude",
|
|
@@ -1596,6 +1769,13 @@ BACKENDS = {
|
|
|
1596
1769
|
reasoning_map=COPILOT_REASONING_MAP,
|
|
1597
1770
|
reasoning_prompt_prefix=COPILOT_REASONING_PROMPT_PREFIX,
|
|
1598
1771
|
),
|
|
1772
|
+
"opencode": BackendAdapter(
|
|
1773
|
+
name="opencode",
|
|
1774
|
+
binary="opencode",
|
|
1775
|
+
call=_call_opencode_backend,
|
|
1776
|
+
reasoning_map=OPENCODE_REASONING_MAP,
|
|
1777
|
+
reasoning_prompt_prefix=OPENCODE_REASONING_PROMPT_PREFIX,
|
|
1778
|
+
),
|
|
1599
1779
|
}
|
|
1600
1780
|
SUPPORTED_BACKENDS = set(BACKENDS)
|
|
1601
1781
|
|
|
@@ -3368,9 +3548,9 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
3368
3548
|
ap.add_argument("--task-from-cmd", metavar="CMD",
|
|
3369
3549
|
help="run shell command with cwd=--cwd and use stdout as the task")
|
|
3370
3550
|
ap.add_argument("--partner", default="codex:coder",
|
|
3371
|
-
help="partner agent spec, e.g. codex:coder, claude:reviewer, gemini:coder, copilot:coder (default codex:coder)")
|
|
3551
|
+
help="partner agent spec, e.g. codex:coder, claude:reviewer, gemini:coder, copilot:coder, opencode:coder (default codex:coder)")
|
|
3372
3552
|
ap.add_argument("--lead", default="claude:planner",
|
|
3373
|
-
help="lead agent spec, e.g. claude:planner, gemini:reviewer, copilot:planner (default; ignored if --resume-claude given)")
|
|
3553
|
+
help="lead agent spec, e.g. claude:planner, gemini:reviewer, copilot:planner, opencode:planner (default; ignored if --resume-claude given)")
|
|
3374
3554
|
ap.add_argument("--lead-model", metavar="MODEL", default=None,
|
|
3375
3555
|
help="model name to pass to the lead agent's backend via --model")
|
|
3376
3556
|
ap.add_argument("--partner-model", metavar="MODEL", default=None,
|
|
@@ -3387,9 +3567,9 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
3387
3567
|
"proposals; YAML key: `verify_cmd:`.")
|
|
3388
3568
|
ap.add_argument("--runs-dir", default=None, help="where to save transcripts")
|
|
3389
3569
|
ap.add_argument("--sandbox", default="workspace-write",
|
|
3390
|
-
help="codex --sandbox: read-only|workspace-write|danger-full-access. No-op for claude/gemini/copilot.")
|
|
3570
|
+
help="codex --sandbox: read-only|workspace-write|danger-full-access. No-op for claude/gemini/copilot/opencode.")
|
|
3391
3571
|
ap.add_argument("--permission-mode", default="acceptEdits",
|
|
3392
|
-
help="claude --permission-mode; gemini maps default/acceptEdits/plan/bypassPermissions to --approval-mode. No-op for copilot.")
|
|
3572
|
+
help="claude --permission-mode; gemini maps default/acceptEdits/plan/bypassPermissions to --approval-mode. No-op for copilot/opencode.")
|
|
3393
3573
|
ap.add_argument("--config", help="optional YAML/JSON config (overrides flags except --resume-*)")
|
|
3394
3574
|
ap.add_argument("--worktree", action="store_true",
|
|
3395
3575
|
help="run the partner agent in a throwaway git worktree on a fresh branch; "
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duet-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Two CLI agents in conversation. One Python file. Stdlib only.
|
|
5
5
|
Author: Volkan Altan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -24,12 +24,12 @@ Dynamic: license-file
|
|
|
24
24
|
**Two CLI agents in conversation. One Python file. Stdlib only.**
|
|
25
25
|
|
|
26
26
|
`duet` runs two command-line coding agents in alternating turns until they
|
|
27
|
-
agree. By default that is Claude and Codex; Gemini and
|
|
28
|
-
supported, and you can pair two agents from the same backend. One agent
|
|
29
|
-
or reviews while the other implements; each keeps its own session memory
|
|
30
|
-
turns, and every run leaves a transcript you can inspect.
|
|
27
|
+
agree. By default that is Claude and Codex; Gemini, Copilot, and OpenCode are
|
|
28
|
+
also supported, and you can pair two agents from the same backend. One agent
|
|
29
|
+
plans or reviews while the other implements; each keeps its own session memory
|
|
30
|
+
across turns, and every run leaves a transcript you can inspect.
|
|
31
31
|
|
|
32
|
-
## Use it
|
|
32
|
+
## Use it four ways
|
|
33
33
|
|
|
34
34
|
### 1. Inside Claude Code — `/duet`
|
|
35
35
|
|
|
@@ -93,7 +93,26 @@ CLI, so install that first (see below) and make sure `command -v duet` passes in
|
|
|
93
93
|
Codex's shell. Full guide:
|
|
94
94
|
[docs/CODEX_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/CODEX_PLUGIN.md).
|
|
95
95
|
|
|
96
|
-
### 3.
|
|
96
|
+
### 3. Inside OpenCode — `/duet`
|
|
97
|
+
|
|
98
|
+
OpenCode custom commands are drop-in files — no marketplace step:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
mkdir -p ~/.config/opencode/command
|
|
102
|
+
cp plugins/duet-opencode/command/duet.md ~/.config/opencode/command/duet.md
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then invoke `/duet` in the OpenCode TUI (or `opencode run --command duet "..."`
|
|
106
|
+
non-interactively). Like the other plugins it shells out to the `duet` CLI, so
|
|
107
|
+
install that first and make sure `command -v duet` passes in OpenCode's shell.
|
|
108
|
+
The command runs on OpenCode's `build` agent; plain `/duet` runs the same
|
|
109
|
+
`claude -p /review` kickoff, and `/duet 'npm test 2>&1' --turns 4` seeds from
|
|
110
|
+
any command. Full guide:
|
|
111
|
+
[docs/OPENCODE_PLUGIN.md](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md).
|
|
112
|
+
(duet can also drive OpenCode as a backend — `--partner opencode:coder` — so
|
|
113
|
+
OpenCode can be one of the two looped agents too.)
|
|
114
|
+
|
|
115
|
+
### 4. From the terminal — `duet`
|
|
97
116
|
|
|
98
117
|
```bash
|
|
99
118
|
pipx install duet-cli # recommended; the command it installs is `duet`
|
|
@@ -172,8 +191,9 @@ coder fixes them). Run one with `duet --config examples/pr-review.yaml`.
|
|
|
172
191
|
## How it works
|
|
173
192
|
|
|
174
193
|
Each agent keeps its own conversation memory across turns (Claude via
|
|
175
|
-
`--resume`, Codex via `codex exec resume`, Gemini and
|
|
176
|
-
session ids). On each turn duet sends one agent's latest reply to the
|
|
194
|
+
`--resume`, Codex via `codex exec resume`, Gemini, Copilot, and OpenCode via
|
|
195
|
+
their JSON session ids). On each turn duet sends one agent's latest reply to the
|
|
196
|
+
other.
|
|
177
197
|
|
|
178
198
|
To converge, an agent must include an `LGTM rationale:` explaining why the work
|
|
179
199
|
is done, followed by the sentinel `<<<LGTM>>>` on its own line — a bare
|
|
@@ -186,7 +206,7 @@ stderr logs, and the `wt/` worktree when `--worktree` is on. Inspect a run with
|
|
|
186
206
|
`duet --status <run-id>`, list runs with `duet --list`, and start a fresh run
|
|
187
207
|
from saved state with `duet --continue <run> --task "next thing"`.
|
|
188
208
|
|
|
189
|
-
- **Backends:** `claude`, `codex`, `gemini`, `copilot`
|
|
209
|
+
- **Backends:** `claude`, `codex`, `gemini`, `copilot`, `opencode`
|
|
190
210
|
- **Roles:** `planner`, `coder`, `reviewer`, `triage-reviewer`, or a custom one
|
|
191
211
|
- **Reasoning:** `--reasoning minimal|low|medium|high|xhigh|max`
|
|
192
212
|
|
|
@@ -454,6 +454,130 @@ class TestAgentFinishReasons(unittest.TestCase):
|
|
|
454
454
|
self.assertEqual(ctx.exception.finished_reason, duet.FINISHED_AGENT_ERROR)
|
|
455
455
|
self.assertIn("copilot exited 2", str(ctx.exception))
|
|
456
456
|
|
|
457
|
+
def test_opencode_error_event_maps_to_agent_error(self) -> None:
|
|
458
|
+
# `opencode run` exits 0 on a model error, so call_opencode must catch
|
|
459
|
+
# the error event in the JSONL stream and not forward a broken reply.
|
|
460
|
+
def fake_run(cmd, **kwargs):
|
|
461
|
+
return (
|
|
462
|
+
0,
|
|
463
|
+
'{"type":"error","sessionID":"ses_x","error":{"data":{"message":"boom"}}}',
|
|
464
|
+
"",
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
agent = duet.Agent(name="opencode-partner", backend="opencode", role="coder")
|
|
468
|
+
with mock.patch.object(duet, "_run", fake_run):
|
|
469
|
+
with self.assertRaises(duet.AgentRunError) as ctx:
|
|
470
|
+
duet.call_opencode(
|
|
471
|
+
agent, "sys", "msg", _ROOT, 60,
|
|
472
|
+
dry=False, first_turn=True,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
self.assertEqual(ctx.exception.finished_reason, duet.FINISHED_AGENT_ERROR)
|
|
476
|
+
self.assertIn("boom", str(ctx.exception))
|
|
477
|
+
|
|
478
|
+
def test_opencode_missing_session_id_maps_to_agent_error(self) -> None:
|
|
479
|
+
def fake_run(cmd, **kwargs):
|
|
480
|
+
return (
|
|
481
|
+
0,
|
|
482
|
+
'{"type":"text","part":{"type":"text","text":"ok","id":"p1"}}',
|
|
483
|
+
"",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
agent = duet.Agent(name="opencode-partner", backend="opencode", role="coder")
|
|
487
|
+
with mock.patch.object(duet, "_run", fake_run):
|
|
488
|
+
with self.assertRaises(duet.AgentRunError) as ctx:
|
|
489
|
+
duet.call_opencode(
|
|
490
|
+
agent, "sys", "msg", _ROOT, 60,
|
|
491
|
+
dry=False, first_turn=True,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
self.assertEqual(ctx.exception.finished_reason, duet.FINISHED_AGENT_ERROR)
|
|
495
|
+
self.assertIn("sessionID", str(ctx.exception))
|
|
496
|
+
|
|
497
|
+
def test_opencode_rc_124_maps_to_timeout(self) -> None:
|
|
498
|
+
def fake_run(cmd, **kwargs):
|
|
499
|
+
return 124, "", "[duet] TIMEOUT after 1s"
|
|
500
|
+
|
|
501
|
+
agent = duet.Agent(name="opencode-partner", backend="opencode", role="coder")
|
|
502
|
+
with mock.patch.object(duet, "_run", fake_run):
|
|
503
|
+
with self.assertRaises(duet.AgentRunError) as ctx:
|
|
504
|
+
duet.call_opencode(
|
|
505
|
+
agent, "sys", "msg", _ROOT, 1,
|
|
506
|
+
dry=False, first_turn=True,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
self.assertEqual(ctx.exception.finished_reason, duet.FINISHED_TIMEOUT)
|
|
510
|
+
self.assertIn("opencode exited 124", str(ctx.exception))
|
|
511
|
+
|
|
512
|
+
def test_opencode_error_event_precedes_missing_session_id(self) -> None:
|
|
513
|
+
# An error event with no sessionID must surface the error text, not the
|
|
514
|
+
# generic missing-sessionID message — locks the check ordering in
|
|
515
|
+
# call_opencode (mirrors the Gemini precedence test).
|
|
516
|
+
def fake_run(cmd, **kwargs):
|
|
517
|
+
return (
|
|
518
|
+
0,
|
|
519
|
+
'{"type":"error","error":{"data":{"message":"boom"}}}',
|
|
520
|
+
"",
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
agent = duet.Agent(name="opencode-partner", backend="opencode", role="coder")
|
|
524
|
+
with mock.patch.object(duet, "_run", fake_run):
|
|
525
|
+
with self.assertRaises(duet.AgentRunError) as ctx:
|
|
526
|
+
duet.call_opencode(
|
|
527
|
+
agent, "sys", "msg", _ROOT, 60,
|
|
528
|
+
dry=False, first_turn=True,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
self.assertEqual(ctx.exception.finished_reason, duet.FINISHED_AGENT_ERROR)
|
|
532
|
+
self.assertIn("boom", str(ctx.exception))
|
|
533
|
+
self.assertNotIn("sessionID", str(ctx.exception))
|
|
534
|
+
|
|
535
|
+
def test_opencode_command_construction_and_resume(self) -> None:
|
|
536
|
+
# Capture the constructed argv to pin the flags the reasoning-check and
|
|
537
|
+
# dry-run exit codes can't see: the `-s` resume flag, whose silent loss
|
|
538
|
+
# would orphan multi-turn memory while still returning rc=0, and the
|
|
539
|
+
# `-m provider/model` form OpenCode requires for model selection.
|
|
540
|
+
captured: list[list[str]] = []
|
|
541
|
+
|
|
542
|
+
def fake_run(cmd, **kwargs):
|
|
543
|
+
captured.append(list(cmd))
|
|
544
|
+
return (
|
|
545
|
+
0,
|
|
546
|
+
'{"type":"text","sessionID":"ses_new","part":{"type":"text","text":"ok","id":"p1"}}',
|
|
547
|
+
"",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
agent = duet.Agent(
|
|
551
|
+
name="opencode-partner",
|
|
552
|
+
backend="opencode",
|
|
553
|
+
role="coder",
|
|
554
|
+
model="anthropic/claude-sonnet-4-6",
|
|
555
|
+
)
|
|
556
|
+
with mock.patch.object(duet, "_run", fake_run):
|
|
557
|
+
# First turn: no session_id yet.
|
|
558
|
+
duet.call_opencode(agent, "sys", "msg", _ROOT, 60,
|
|
559
|
+
dry=False, first_turn=True)
|
|
560
|
+
first = captured[-1]
|
|
561
|
+
self.assertEqual(first[:2], ["opencode", "run"])
|
|
562
|
+
self.assertIn("--format", first)
|
|
563
|
+
self.assertEqual(first[first.index("--format") + 1], "json")
|
|
564
|
+
self.assertIn("--dir", first)
|
|
565
|
+
self.assertIn("--dangerously-skip-permissions", first)
|
|
566
|
+
self.assertIn("-m", first)
|
|
567
|
+
self.assertEqual(first[first.index("-m") + 1], "anthropic/claude-sonnet-4-6")
|
|
568
|
+
self.assertNotIn("-s", first) # nothing to resume on turn 1
|
|
569
|
+
# The prompt is the trailing positional arg (options come first).
|
|
570
|
+
self.assertIn("=== MESSAGE FROM PARTNER ===", first[-1])
|
|
571
|
+
self.assertTrue(first[-1].rstrip().endswith("msg"))
|
|
572
|
+
|
|
573
|
+
# Second turn: resume by the parsed session id.
|
|
574
|
+
agent.session_id = "ses_prev"
|
|
575
|
+
duet.call_opencode(agent, "sys", "msg2", _ROOT, 60,
|
|
576
|
+
dry=False, first_turn=False)
|
|
577
|
+
second = captured[-1]
|
|
578
|
+
self.assertIn("-s", second)
|
|
579
|
+
self.assertEqual(second[second.index("-s") + 1], "ses_prev")
|
|
580
|
+
|
|
457
581
|
|
|
458
582
|
# ---------- _parse_codex_session_id ----------
|
|
459
583
|
|
|
@@ -572,6 +696,102 @@ class TestParseCopilotJsonl(unittest.TestCase):
|
|
|
572
696
|
duet._parse_copilot_jsonl("not json")
|
|
573
697
|
|
|
574
698
|
|
|
699
|
+
# ---------- _parse_opencode_jsonl ----------
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
class TestParseOpencodeJsonl(unittest.TestCase):
|
|
703
|
+
def test_extracts_text_and_session_id(self) -> None:
|
|
704
|
+
out = "\n".join([
|
|
705
|
+
'{"type":"step_start","sessionID":"ses_abc","part":{"type":"step-start"}}',
|
|
706
|
+
'{"type":"text","sessionID":"ses_abc","part":{"type":"text","text":"PONG","id":"p1"}}',
|
|
707
|
+
'{"type":"step_finish","sessionID":"ses_abc","part":{"type":"step-finish"}}',
|
|
708
|
+
])
|
|
709
|
+
|
|
710
|
+
text, session_id, error = duet._parse_opencode_jsonl(out)
|
|
711
|
+
|
|
712
|
+
self.assertEqual(text, "PONG")
|
|
713
|
+
self.assertEqual(session_id, "ses_abc")
|
|
714
|
+
self.assertIsNone(error)
|
|
715
|
+
|
|
716
|
+
def test_concatenates_multiple_distinct_text_parts(self) -> None:
|
|
717
|
+
# Text split around a tool call arrives as two parts with distinct ids;
|
|
718
|
+
# both are kept, in arrival order.
|
|
719
|
+
out = "\n".join([
|
|
720
|
+
'{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"Let me check.","id":"p1"}}',
|
|
721
|
+
'{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"Done.","id":"p2"}}',
|
|
722
|
+
])
|
|
723
|
+
|
|
724
|
+
text, session_id, error = duet._parse_opencode_jsonl(out)
|
|
725
|
+
|
|
726
|
+
self.assertEqual(text, "Let me check.\nDone.")
|
|
727
|
+
self.assertEqual(session_id, "ses_x")
|
|
728
|
+
self.assertIsNone(error)
|
|
729
|
+
|
|
730
|
+
def test_streamed_part_last_write_wins(self) -> None:
|
|
731
|
+
# A part re-emitted as it grows (same id) must not duplicate; last wins.
|
|
732
|
+
out = "\n".join([
|
|
733
|
+
'{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"PO","id":"p1"}}',
|
|
734
|
+
'{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"PONG","id":"p1"}}',
|
|
735
|
+
])
|
|
736
|
+
|
|
737
|
+
text, _, _ = duet._parse_opencode_jsonl(out)
|
|
738
|
+
|
|
739
|
+
self.assertEqual(text, "PONG")
|
|
740
|
+
|
|
741
|
+
def test_error_event_is_surfaced(self) -> None:
|
|
742
|
+
# `opencode run` exits 0 even on model errors; the failure is an error
|
|
743
|
+
# event in the stream, so the parser must report it.
|
|
744
|
+
out = ('{"type":"error","sessionID":"ses_x","error":{"name":"UnknownError",'
|
|
745
|
+
'"data":{"message":"Model not found: foo."}}}')
|
|
746
|
+
|
|
747
|
+
text, session_id, error = duet._parse_opencode_jsonl(out)
|
|
748
|
+
|
|
749
|
+
self.assertEqual(text, "")
|
|
750
|
+
self.assertEqual(session_id, "ses_x")
|
|
751
|
+
self.assertEqual(error, "Model not found: foo.")
|
|
752
|
+
|
|
753
|
+
def test_non_json_banner_lines_are_skipped(self) -> None:
|
|
754
|
+
# A fresh machine can print a one-time DB-migration banner; tolerate it
|
|
755
|
+
# rather than treating the whole turn as malformed.
|
|
756
|
+
out = "\n".join([
|
|
757
|
+
"Performing one time database migration...",
|
|
758
|
+
'{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"ok","id":"p1"}}',
|
|
759
|
+
])
|
|
760
|
+
|
|
761
|
+
text, session_id, error = duet._parse_opencode_jsonl(out)
|
|
762
|
+
|
|
763
|
+
self.assertEqual(text, "ok")
|
|
764
|
+
self.assertEqual(session_id, "ses_x")
|
|
765
|
+
self.assertIsNone(error)
|
|
766
|
+
|
|
767
|
+
def test_string_error_payload_is_surfaced(self) -> None:
|
|
768
|
+
# OpenCode documents a dict error payload, but a plain-string one must
|
|
769
|
+
# surface verbatim rather than degrading to "unknown error".
|
|
770
|
+
out = '{"type":"error","sessionID":"ses_x","error":"Model not found: foo"}'
|
|
771
|
+
|
|
772
|
+
text, session_id, error = duet._parse_opencode_jsonl(out)
|
|
773
|
+
|
|
774
|
+
self.assertEqual(text, "")
|
|
775
|
+
self.assertEqual(session_id, "ses_x")
|
|
776
|
+
self.assertEqual(error, "Model not found: foo")
|
|
777
|
+
|
|
778
|
+
def test_idless_part_does_not_alias_real_id(self) -> None:
|
|
779
|
+
# An id-less text part must not collide with a real part whose id is the
|
|
780
|
+
# bare integer the old `len(parts)` fallback would have produced ("0").
|
|
781
|
+
# Both texts must survive, in arrival order.
|
|
782
|
+
out = "\n".join([
|
|
783
|
+
'{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"A"}}',
|
|
784
|
+
'{"type":"text","sessionID":"ses_x","part":{"type":"text","text":"B","id":"0"}}',
|
|
785
|
+
])
|
|
786
|
+
|
|
787
|
+
text, _, _ = duet._parse_opencode_jsonl(out)
|
|
788
|
+
|
|
789
|
+
self.assertEqual(text, "A\nB")
|
|
790
|
+
|
|
791
|
+
def test_empty_returns_no_session(self) -> None:
|
|
792
|
+
self.assertEqual(duet._parse_opencode_jsonl(""), ("", None, None))
|
|
793
|
+
|
|
794
|
+
|
|
575
795
|
# ---------- _parse_gemini_session_id ----------
|
|
576
796
|
|
|
577
797
|
|
|
@@ -758,9 +978,11 @@ class TestReasoningHelpers(unittest.TestCase):
|
|
|
758
978
|
self.assertIn(level, duet.CODEX_REASONING_MAP)
|
|
759
979
|
self.assertIn(level, duet.GEMINI_REASONING_MAP)
|
|
760
980
|
self.assertIn(level, duet.COPILOT_REASONING_MAP)
|
|
981
|
+
self.assertIn(level, duet.OPENCODE_REASONING_MAP)
|
|
761
982
|
self.assertIn(level, duet.CLAUDE_REASONING_PROMPT_PREFIX)
|
|
762
983
|
self.assertIn(level, duet.GEMINI_REASONING_PROMPT_PREFIX)
|
|
763
984
|
self.assertIn(level, duet.COPILOT_REASONING_PROMPT_PREFIX)
|
|
985
|
+
self.assertIn(level, duet.OPENCODE_REASONING_PROMPT_PREFIX)
|
|
764
986
|
|
|
765
987
|
def test_codex_max_maps_to_xhigh(self) -> None:
|
|
766
988
|
# Codex documents `xhigh` but not `max`; duet keeps `max` as a
|
|
@@ -784,6 +1006,13 @@ class TestReasoningHelpers(unittest.TestCase):
|
|
|
784
1006
|
with self.subTest(level=level):
|
|
785
1007
|
self.assertEqual(duet.GEMINI_REASONING_MAP[level], "")
|
|
786
1008
|
|
|
1009
|
+
def test_opencode_reasoning_map_is_identity(self) -> None:
|
|
1010
|
+
# OpenCode tolerates/ignores unknown `--variant` values, so duet passes
|
|
1011
|
+
# each level through unchanged for forward-compatibility.
|
|
1012
|
+
for level in duet.REASONING_LEVELS:
|
|
1013
|
+
with self.subTest(level=level):
|
|
1014
|
+
self.assertEqual(duet.OPENCODE_REASONING_MAP[level], level)
|
|
1015
|
+
|
|
787
1016
|
|
|
788
1017
|
# ---------- parse_partner ----------
|
|
789
1018
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|