tandem-cli 0.5.0__tar.gz → 0.5.2__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.
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/PKG-INFO +17 -1
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/README.md +16 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/formats.md +43 -6
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/how-it-works.md +2 -2
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/plugin/.claude-plugin/plugin.json +1 -1
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/pyproject.toml +1 -1
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/compat.py +3 -3
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/flip.py +23 -8
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/harness/claude_code.py +28 -6
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/harness/codex.py +20 -5
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/ops.py +2 -13
- tandem_cli-0.5.2/tests/test_current_formats.py +189 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_flip.py +44 -44
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_parse.py +7 -1
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_participants.py +1 -1
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_paths_compat.py +6 -3
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_sync.py +8 -3
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_toolmap.py +13 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/uv.lock +1 -1
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/.claude-plugin/marketplace.json +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/.github/workflows/ci.yml +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/.gitignore +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/LICENSE +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/configuration.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/development.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-07-29-native-tool-call-translation.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-07-31-codex-subagents.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-01-plugin-marketplace-install.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-01-sandbox-consent-and-manual-routing.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-02-plugin-auto-install.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-03-manual-default-model-passthrough.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-06-harness-startup-args.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-09-meta-harness-frame.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-09-readme-restructure.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-10-idle-flip-status-probe.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-10-retire-interstitial-prompt.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-13-pr1-nharness-core.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-13-pr2-opencode-adapter.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-07-29-native-tool-call-translation-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-07-31-codex-subagents-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-02-plugin-auto-install-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-03-manual-default-model-passthrough-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-06-harness-startup-args-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-09-meta-harness-frame-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-09-model-pin-recovery-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-09-readme-restructure-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-10-remove-interstitial-prompt-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-13-opencode-harness-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/subagents.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/plans/2026-07-29-tandem-shell.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/plans/2026-08-11-process-warmup-live-validation.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/plans/2026-08-11-process-warmup.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/plans/2026-08-12-process-warmup-pipelined.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/specs/2026-07-29-tandem-shell-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/specs/2026-08-10-idle-flip-status-probe.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/specs/2026-08-11-process-warmup-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/specs/2026-08-12-process-warmup-pipelined-design.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/why.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/plugin/README.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/plugin/agents/codex-worker.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/plugin/agents/gpt.md +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/plugin/hooks/hooks.json +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/__init__.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/cli.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/config.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/constants.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/converter.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/doctor.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/events.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/frame.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/harness/__init__.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/harness/base.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/harness/opencode.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/hookroute.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/memory_sync.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/modelcat.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/paths.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/pinstash.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/plugin_setup.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/ptyrun.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/ratelimit.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/runner.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/state.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/summarize.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/sync.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/tailer.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/toolmap.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/util.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/warm.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/conftest.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/golden/claude-probe.jsonl +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/golden/codex-probe.jsonl +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_cli.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_config.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_converter.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_frame.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_hookroute.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_memory_doctor.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_modelcat.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_opencode.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_opencode_oracle.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_ops.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_pinstash.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_plugin.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_plugin_setup.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_ptyrun.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_ratelimit.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_runner.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_state.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_sub.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_tail.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_usage.py +0 -0
- {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_warm.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: tandem-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Run Claude Code, Codex CLI, and opencode in one shared coding session with live trace sync.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Bhavya6187/tandem
|
|
6
6
|
Project-URL: Repository, https://github.com/Bhavya6187/tandem
|
|
@@ -94,6 +94,22 @@ network calls — set `rate_limits = false` under `[frame]` in
|
|
|
94
94
|
[Configuration](https://github.com/Bhavya6187/tandem/blob/main/docs/configuration.md)
|
|
95
95
|
for the status bar's other settings.
|
|
96
96
|
|
|
97
|
+
**0.5.1 keeps up with the current CLIs.** claude 2.1.26x and codex 0.153
|
|
98
|
+
changed their session records, and codex now holds a writer lock on every
|
|
99
|
+
open thread. On those versions 0.5.0 failed `tandem doctor` and every flip
|
|
100
|
+
into claude on the new metadata records, warned that codex was out of range
|
|
101
|
+
at each start, and could lose a claude → codex flip to `already has an
|
|
102
|
+
active writer`. All three are fixed, and codex's list-shaped tool outputs
|
|
103
|
+
now reach the other side as text with their exit codes intact.
|
|
104
|
+
|
|
105
|
+
**0.5.2 stops claude complaining about synced turns.** claude 2.1.261
|
|
106
|
+
restores a session's model on resume from its last assistant entry, and a
|
|
107
|
+
claude session that had never run a turn of its own (claude → codex →
|
|
108
|
+
opencode → claude) opened with `Session model <synced> could not be
|
|
109
|
+
restored … using fable instead`. Synced entries now carry claude's own
|
|
110
|
+
`<synthetic>` tag until claude has run, which its resume skips; the synced
|
|
111
|
+
turns still render and still reach the model.
|
|
112
|
+
|
|
97
113
|
## Why tandem?
|
|
98
114
|
|
|
99
115
|
- **Keep going when a model hits its limit.** The tab bar shows each
|
|
@@ -68,6 +68,22 @@ network calls — set `rate_limits = false` under `[frame]` in
|
|
|
68
68
|
[Configuration](https://github.com/Bhavya6187/tandem/blob/main/docs/configuration.md)
|
|
69
69
|
for the status bar's other settings.
|
|
70
70
|
|
|
71
|
+
**0.5.1 keeps up with the current CLIs.** claude 2.1.26x and codex 0.153
|
|
72
|
+
changed their session records, and codex now holds a writer lock on every
|
|
73
|
+
open thread. On those versions 0.5.0 failed `tandem doctor` and every flip
|
|
74
|
+
into claude on the new metadata records, warned that codex was out of range
|
|
75
|
+
at each start, and could lose a claude → codex flip to `already has an
|
|
76
|
+
active writer`. All three are fixed, and codex's list-shaped tool outputs
|
|
77
|
+
now reach the other side as text with their exit codes intact.
|
|
78
|
+
|
|
79
|
+
**0.5.2 stops claude complaining about synced turns.** claude 2.1.261
|
|
80
|
+
restores a session's model on resume from its last assistant entry, and a
|
|
81
|
+
claude session that had never run a turn of its own (claude → codex →
|
|
82
|
+
opencode → claude) opened with `Session model <synced> could not be
|
|
83
|
+
restored … using fable instead`. Synced entries now carry claude's own
|
|
84
|
+
`<synthetic>` tag until claude has run, which its resume skips; the synced
|
|
85
|
+
turns still render and still reach the model.
|
|
86
|
+
|
|
71
87
|
## Why tandem?
|
|
72
88
|
|
|
73
89
|
- **Keep going when a model hits its limit.** The tab bar shows each
|
|
@@ -6,14 +6,21 @@ database) the CLIs wrote. Versions observed:
|
|
|
6
6
|
|
|
7
7
|
| CLI | version | session storage |
|
|
8
8
|
| --- | --- | --- |
|
|
9
|
-
| Claude Code (`claude`) | 2.1.
|
|
10
|
-
| Codex CLI (`codex`) | 0.
|
|
9
|
+
| Claude Code (`claude`) | 2.1.261 | `~/.claude/projects/<munged-cwd>/<sessionId>.jsonl` |
|
|
10
|
+
| Codex CLI (`codex`) | 0.153.4 | `~/.codex/sessions/YYYY/MM/DD/rollout-<YYYY-MM-DDThh-mm-ss>-<uuidv7>.jsonl` |
|
|
11
11
|
| opencode (`opencode`) | 1.18.15 | one SQLite database, the path `opencode db path` prints (see below) |
|
|
12
12
|
|
|
13
13
|
Env overrides honored: `CLAUDE_CONFIG_DIR` (claude home), `CODEX_HOME` (codex
|
|
14
14
|
home), `OPENCODE_DB` (opencode database), `TANDEM_HOME` (tandem state).
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Rechecked 2026-09-05 on claude 2.1.261 / codex 0.153.4 (a live paired
|
|
17
|
+
session plus a four-turn claude→codex→claude→codex→claude probe): the
|
|
18
|
+
conversation-bearing shapes below are unchanged; the additions are marked
|
|
19
|
+
"2.1.26x" / "0.153". Codex's accepted range runs through 0.159; 0.160+
|
|
20
|
+
warns until rechecked. Unknown top-level record types still fail
|
|
21
|
+
validation rather than being silently declared compatible.
|
|
22
|
+
|
|
23
|
+
## Claude Code transcript (claude 2.1.220, rechecked 2.1.261)
|
|
17
24
|
|
|
18
25
|
- One JSONL file per session, named `<sessionId>.jsonl` (sessionId is a
|
|
19
26
|
UUIDv4). Project directory name = cwd with every character outside
|
|
@@ -42,13 +49,32 @@ home), `OPENCODE_DB` (opencode database), `TANDEM_HOME` (tandem state).
|
|
|
42
49
|
metadata (permission mode, AI-generated title, linked PR, worktree
|
|
43
50
|
moves, file-backup tracking). Not conversation content; claude resumes
|
|
44
51
|
transcripts containing them without complaint.
|
|
52
|
+
- 2.1.26x adds four more uuid-less metadata records: `atis-latch`
|
|
53
|
+
(`atis`, `sessionId`), `bridge-session` (`bridgeSessionId`,
|
|
54
|
+
`lastSequenceNum`, owner account/org — routine per-session identity,
|
|
55
|
+
`lastSequenceNum` constant 0), `cost-state` (accumulated cost, timing,
|
|
56
|
+
line counts, `modelUsage`) and `custom-title` (`customTitle`, set when
|
|
57
|
+
the user renames a session). Same handling as the batch above.
|
|
58
|
+
- 2.1.26x can put a `fallback` block (`{from: {model}, to: {model}}`) in
|
|
59
|
+
an assistant message when the model is switched mid-session. No prose;
|
|
60
|
+
tandem drops it like any other non-text assistant block.
|
|
45
61
|
- Resume: `claude --resume <sessionId>` (from the same cwd). A new session
|
|
46
62
|
can be pinned to a chosen id with `claude --session-id <uuid>`.
|
|
63
|
+
- 2.1.261 restores the session model on resume from the last assistant
|
|
64
|
+
entry whose `message.model` is a string other than `"<synthetic>"`
|
|
65
|
+
(claude's own tag on model-less stubs such as API-error messages) and
|
|
66
|
+
warns `Session model X could not be restored (not a model this version
|
|
67
|
+
of Claude Code recognizes) — using <default> instead` when that value is
|
|
68
|
+
not a model it knows. Tandem-rendered assistant entries therefore copy
|
|
69
|
+
the model claude last used in that transcript, and carry `"<synthetic>"`
|
|
70
|
+
until claude has run at all (a zero-turn claude flipped back into). The
|
|
71
|
+
`"<synced>"` placeholder tandem ≤0.5.1 wrote there drew the warning;
|
|
72
|
+
entries carrying it are still skipped when tandem derives the model.
|
|
47
73
|
- Turn boundary: a `user` entry with string content starts a turn; an
|
|
48
74
|
`assistant` line with `stop_reason: "end_turn"` ends it. The `Stop` hook
|
|
49
75
|
(injectable per-invocation via `--settings '<json>'`) fires at turn end.
|
|
50
76
|
|
|
51
|
-
## Codex rollout (codex-cli 0.145.0)
|
|
77
|
+
## Codex rollout (codex-cli 0.145.0, rechecked 0.153.4)
|
|
52
78
|
|
|
53
79
|
- One JSONL file per session under a date-sharded dir; the session id
|
|
54
80
|
(UUIDv7) is embedded in the filename. `~/.codex/session_index.jsonl` maps
|
|
@@ -70,9 +96,15 @@ home), `OPENCODE_DB` (opencode database), `TANDEM_HOME` (tandem state).
|
|
|
70
96
|
- `function_call` — `{name, arguments: <json string>, call_id}` (e.g.
|
|
71
97
|
`exec_command`).
|
|
72
98
|
- `function_call_output` — `{call_id, output}` (chunked shell output with
|
|
73
|
-
exit code header).
|
|
99
|
+
exit code header). `output` is a string, or (0.153, roughly half of
|
|
100
|
+
all outputs) a list of `{type: "input_text", text}` blocks that are
|
|
101
|
+
consecutive chunks of one output — flatten by concatenation. Same
|
|
102
|
+
for `custom_tool_call_output`.
|
|
74
103
|
- `custom_tool_call` / `custom_tool_call_output` — `apply_patch` with
|
|
75
|
-
`input` = patch text (`*** Begin Patch ...`).
|
|
104
|
+
`input` = patch text (`*** Begin Patch ...`). 0.153 code mode uses the
|
|
105
|
+
same pair with `name: "exec"` and JavaScript in `input` (plus `id`,
|
|
106
|
+
`status`, `internal_chat_message_metadata_passthrough`, all inert);
|
|
107
|
+
nested tool calls live inside that outer call/output.
|
|
76
108
|
- `event_msg` — the **UI-facing** stream. `payload.type`: `task_started`
|
|
77
109
|
(`turn_id`), `user_message`, `agent_message` (`phase`), `token_count`,
|
|
78
110
|
`patch_apply_end` (`{stdout, success, changes: {path: {type, content}}}`),
|
|
@@ -89,6 +121,11 @@ home), `OPENCODE_DB` (opencode database), `TANDEM_HOME` (tandem state).
|
|
|
89
121
|
grepping for these literals gets back, not a rejection.
|
|
90
122
|
- `turn_context` — per turn: cwd, approval_policy, sandbox_policy, model.
|
|
91
123
|
- `world_state` — environment snapshot.
|
|
124
|
+
- `token_usage_record` (0.153) — per-response accounting: thread/turn/
|
|
125
|
+
response ids plus `usage`, `turn_token_usage`, `thread_token_usage`.
|
|
126
|
+
Not model history. `event_msg/token_count` still appears and remains
|
|
127
|
+
the usage meter's source — never sum the two. `event_msg/
|
|
128
|
+
thread_settings_applied` is likewise bookkeeping.
|
|
92
129
|
- Resume: `codex resume <session-id>` (interactive) and
|
|
93
130
|
`codex exec resume <session-id> "<prompt>"` (one-shot). Turn-complete
|
|
94
131
|
notification hook: `-c 'notify=["/bin/sh","-c","..."]'` per invocation.
|
|
@@ -133,8 +133,8 @@ tandem pins what it was built against (observed formats documented in
|
|
|
133
133
|
|
|
134
134
|
| CLI | Tested | Accepted range |
|
|
135
135
|
| --- | --- | --- |
|
|
136
|
-
| Claude Code | 2.1.
|
|
137
|
-
| Codex CLI | 0.
|
|
136
|
+
| Claude Code | 2.1.261 | ≥ 2.0, < 3 |
|
|
137
|
+
| Codex CLI | 0.153.4 | ≥ 0.140, < 0.160 |
|
|
138
138
|
| opencode | 1.18.15 | ≥ 1.18 (no ceiling) |
|
|
139
139
|
|
|
140
140
|
Above a range's ceiling, tandem warns and asks you to run `tandem doctor`
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Both session formats are internal to their CLIs and drift between releases.
|
|
4
4
|
Tandem pins the ranges it was built against; outside a range we warn and
|
|
5
|
-
|
|
5
|
+
recommend `tandem doctor` before trusting sync (see cli.py).
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
@@ -23,8 +23,8 @@ class CompatRange:
|
|
|
23
23
|
|
|
24
24
|
# Format observations in docs/formats.md correspond to these versions.
|
|
25
25
|
COMPAT: dict[str, CompatRange] = {
|
|
26
|
-
"claude": CompatRange(tested="2.1.
|
|
27
|
-
"codex": CompatRange(tested="0.
|
|
26
|
+
"claude": CompatRange(tested="2.1.261", min_version=(2, 0), max_exclusive=(3,)),
|
|
27
|
+
"codex": CompatRange(tested="0.153.4", min_version=(0, 140), max_exclusive=(0, 160)),
|
|
28
28
|
# Floor-only by operator decision (spec: Compat gate). Pre-1.18 opencode
|
|
29
29
|
# predates SQLite session storage and genuinely cannot work.
|
|
30
30
|
"opencode": CompatRange(tested="1.18.15", min_version=(1, 18)),
|
|
@@ -31,8 +31,8 @@ def run_session(tandem_id: str, sink_factory, run_harness=None) -> int:
|
|
|
31
31
|
# The standby travels across flips here: popped for adoption by the
|
|
32
32
|
# next run, refilled from the runner that just ended. Injected test
|
|
33
33
|
# runners never touch it. `reapers` collects the threads tearing down
|
|
34
|
-
# standbys the gate rejected — the flip does not
|
|
35
|
-
# function's finally does.
|
|
34
|
+
# standbys the gate rejected for the wrong side — the flip does not
|
|
35
|
+
# wait for those, this function's finally does.
|
|
36
36
|
carry: dict = {"standby": None, "reapers": []}
|
|
37
37
|
if run_harness is None:
|
|
38
38
|
|
|
@@ -104,9 +104,10 @@ def _reap(standby, carry: dict) -> None:
|
|
|
104
104
|
session's exit can join it.
|
|
105
105
|
|
|
106
106
|
The kill ladder is slow by construction — quit keystrokes, then the
|
|
107
|
-
unconditional soft/term waits — so killing a stale standby inline
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
unconditional soft/term waits — so killing a stale standby inline puts
|
|
108
|
+
seconds between the user's Ctrl-] and the next harness starting, on
|
|
109
|
+
precisely the flips warmup exists to speed up. Only affordable when the
|
|
110
|
+
standby and the relaunch share no session (see `_switch`); the child is
|
|
110
111
|
already out of the carry, so nothing can adopt it while it dies."""
|
|
111
112
|
thread = threading.Thread(
|
|
112
113
|
target=_kill, args=(standby,), name="tandem-warm-reap", daemon=True
|
|
@@ -226,8 +227,11 @@ def _switch(
|
|
|
226
227
|
and the state it snapshotted are finally comparable against what the
|
|
227
228
|
next run will actually launch. A stale one is killed here and nowhere
|
|
228
229
|
else — the runner silently ignores an adoptee it cannot use, so a wrong
|
|
229
|
-
side that survived this gate would leak
|
|
230
|
-
|
|
230
|
+
side that survived this gate would leak. One warmed for the side about
|
|
231
|
+
to launch dies inline, before the relaunch: it holds that side's session
|
|
232
|
+
open, and codex refuses a second writer on a thread. One warmed for any
|
|
233
|
+
other side dies on a reaper thread, since the ladder's own timeouts
|
|
234
|
+
would otherwise be charged to the flip for nothing."""
|
|
231
235
|
with StateStore() as store:
|
|
232
236
|
session = store.get_session(tandem_id)
|
|
233
237
|
if session is None:
|
|
@@ -264,7 +268,18 @@ def _switch(
|
|
|
264
268
|
_flip_debug(f"standby-gate side={new_active} fresh={fresh}")
|
|
265
269
|
if not fresh:
|
|
266
270
|
carry["standby"] = None
|
|
267
|
-
|
|
271
|
+
if standby.recipe.side == new_active:
|
|
272
|
+
# It resumed the very session the cold spawn is about
|
|
273
|
+
# to resume, and codex (0.153+) holds a per-thread
|
|
274
|
+
# writer lock for as long as the process lives: a
|
|
275
|
+
# relaunch that overlaps its ladder fails with
|
|
276
|
+
# `thread ... already has an active writer` and ends
|
|
277
|
+
# the session. Killed inline — the ladder's seconds
|
|
278
|
+
# are the price of a flip that lands at all.
|
|
279
|
+
_kill(standby)
|
|
280
|
+
_flip_debug(f"standby-reaped side={new_active} inline")
|
|
281
|
+
else:
|
|
282
|
+
_reap(standby, carry) # off-thread: nothing in common
|
|
268
283
|
code, flip, launched = _try_enter(tandem_id, run_harness)
|
|
269
284
|
if launched:
|
|
270
285
|
return code, flip
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Claude Code adapter.
|
|
2
2
|
|
|
3
|
-
Session format observed on claude 2.1.220
|
|
3
|
+
Session format observed on claude 2.1.220, rechecked on 2.1.261
|
|
4
|
+
(docs/formats.md):
|
|
4
5
|
- transcript: ~/.claude/projects/<munged-cwd>/<sessionId>.jsonl
|
|
5
6
|
- entries chained by uuid/parentUuid; types: user, assistant, attachment,
|
|
6
7
|
queue-operation, last-prompt, summary, system
|
|
@@ -116,12 +117,28 @@ _CLAUDE_ENTRY_TYPES = {
|
|
|
116
117
|
"user", "assistant", "attachment", "system", "summary",
|
|
117
118
|
"queue-operation", "last-prompt", "progress", "file-history-snapshot",
|
|
118
119
|
"mode", # {"type":"mode","mode":"normal",...} observed on --resume runs
|
|
119
|
-
# uuid-less metadata
|
|
120
|
+
# uuid-less session metadata claude interleaves with the conversation;
|
|
121
|
+
# none of it is portable content. 2.1.220 batch, then the 2.1.26x batch
|
|
122
|
+
# (session latch, remote-bridge identity, accumulated cost/timing, a
|
|
123
|
+
# user-set title).
|
|
120
124
|
"permission-mode", "ai-title", "file-history-delta", "pr-link",
|
|
121
125
|
"relocated", "worktree-state",
|
|
126
|
+
"atis-latch", "bridge-session", "cost-state", "custom-title",
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
|
|
130
|
+
# claude's own tag for an assistant entry no model produced (its API-error
|
|
131
|
+
# stubs carry it). `claude --resume` (2.1.261) restores the session model from
|
|
132
|
+
# the last assistant entry and warns "Session model X could not be restored"
|
|
133
|
+
# for anything it does not recognise; "<synthetic>" is the one value that scan
|
|
134
|
+
# skips, so rendered entries carry it until claude itself has run and a real
|
|
135
|
+
# model can be copied instead (live-verified 2026-09-05: no warning, synced
|
|
136
|
+
# turns still render and reach the model).
|
|
137
|
+
SYNTHETIC_MODEL = "<synthetic>"
|
|
138
|
+
# what tandem <= 0.5.1 wrote in that slot; still skipped when deriving
|
|
139
|
+
LEGACY_SYNCED_MODEL = "<synced>"
|
|
140
|
+
|
|
141
|
+
|
|
125
142
|
class ClaudeCodeAdapter(HarnessAdapter):
|
|
126
143
|
id = "claude"
|
|
127
144
|
display_name = "Claude Code"
|
|
@@ -153,6 +170,10 @@ class ClaudeCodeAdapter(HarnessAdapter):
|
|
|
153
170
|
etype = e.get("type")
|
|
154
171
|
if etype not in _CLAUDE_ENTRY_TYPES:
|
|
155
172
|
problems.append(f"line {i}: unknown entry type {etype!r}")
|
|
173
|
+
if e.get("uuid"):
|
|
174
|
+
# still a chain participant: one unknown type must not
|
|
175
|
+
# cascade into a dangling-parent problem per later entry
|
|
176
|
+
seen_uuids.add(e["uuid"])
|
|
156
177
|
continue
|
|
157
178
|
if etype in ("user", "assistant"):
|
|
158
179
|
convo += 1
|
|
@@ -425,7 +446,7 @@ class ClaudeCodeAdapter(HarnessAdapter):
|
|
|
425
446
|
st["run_msg_id"] = f"msg_tandem_{entry['uuid'][:8]}"
|
|
426
447
|
return {
|
|
427
448
|
"role": "assistant",
|
|
428
|
-
"model": st.get("model") or
|
|
449
|
+
"model": st.get("model") or SYNTHETIC_MODEL,
|
|
429
450
|
"id": st["run_msg_id"],
|
|
430
451
|
"type": "message",
|
|
431
452
|
"content": content,
|
|
@@ -478,14 +499,15 @@ class ClaudeCodeAdapter(HarnessAdapter):
|
|
|
478
499
|
|
|
479
500
|
def derive_last_model(self, transcript: Path) -> str | None:
|
|
480
501
|
"""Model of the last main-thread assistant entry with a real model
|
|
481
|
-
name. Skips
|
|
482
|
-
|
|
502
|
+
name. Skips tandem's own tags (SYNTHETIC_MODEL now, the older
|
|
503
|
+
"<synced>"), claude's synthetic stubs, and sidechain entries
|
|
504
|
+
(subagents may run on a different model)."""
|
|
483
505
|
model = None
|
|
484
506
|
for obj in self._tail_objects(transcript):
|
|
485
507
|
if obj.get("type") != "assistant" or obj.get("isSidechain"):
|
|
486
508
|
continue
|
|
487
509
|
m = (obj.get("message") or {}).get("model")
|
|
488
|
-
if m and m
|
|
510
|
+
if m and m not in (SYNTHETIC_MODEL, LEGACY_SYNCED_MODEL):
|
|
489
511
|
model = m
|
|
490
512
|
return model
|
|
491
513
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Codex CLI adapter.
|
|
2
2
|
|
|
3
|
-
Session format observed on codex-cli 0.145.0
|
|
3
|
+
Session format observed on codex-cli 0.145.0, rechecked on 0.153.4
|
|
4
|
+
(docs/formats.md):
|
|
4
5
|
- rollout: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuidv7>.jsonl
|
|
5
6
|
- each line {timestamp, type, payload}; model-facing history is the
|
|
6
7
|
response_item lines, UI history is the event_msg lines
|
|
@@ -76,9 +77,25 @@ class _CodexUsageMeter(UsageMeter):
|
|
|
76
77
|
_CODEX_LINE_TYPES = {
|
|
77
78
|
"session_meta", "response_item", "event_msg", "turn_context",
|
|
78
79
|
"world_state", "compacted",
|
|
80
|
+
"token_usage_record", # 0.153.4 per-response accounting (not model history)
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
|
|
84
|
+
def output_text(output: Any) -> str:
|
|
85
|
+
"""Flatten a `*_output` payload to the text the model saw. Codex writes
|
|
86
|
+
either a plain string or (0.153: about half the time) a list of
|
|
87
|
+
`{"type": "input_text", "text": ...}` blocks — consecutive chunks of one
|
|
88
|
+
output, so they concatenate without a separator."""
|
|
89
|
+
if isinstance(output, str):
|
|
90
|
+
return output
|
|
91
|
+
if isinstance(output, list):
|
|
92
|
+
return "".join(
|
|
93
|
+
b["text"] for b in output
|
|
94
|
+
if isinstance(b, dict) and isinstance(b.get("text"), str)
|
|
95
|
+
)
|
|
96
|
+
return "" if output is None else str(output)
|
|
97
|
+
|
|
98
|
+
|
|
82
99
|
class CodexAdapter(HarnessAdapter):
|
|
83
100
|
id = "codex"
|
|
84
101
|
display_name = "Codex CLI"
|
|
@@ -298,13 +315,11 @@ class CodexAdapter(HarnessAdapter):
|
|
|
298
315
|
call_id = payload.get("call_id")
|
|
299
316
|
pending = ctx.pending_calls.get(call_id or "", {})
|
|
300
317
|
structured = pending.pop("_structured", None) if pending else None
|
|
301
|
-
output = payload.get("output", "")
|
|
302
|
-
if not isinstance(output, str):
|
|
303
|
-
output = str(output)
|
|
304
318
|
return [
|
|
305
319
|
ToolResult(
|
|
306
320
|
source="codex", timestamp=ts, turn_index=ctx.turn_index,
|
|
307
|
-
call_id=call_id, output=output,
|
|
321
|
+
call_id=call_id, output=output_text(payload.get("output")),
|
|
322
|
+
structured=structured,
|
|
308
323
|
)
|
|
309
324
|
]
|
|
310
325
|
return [sysev(f"response_item:{ptype}")]
|
|
@@ -24,6 +24,7 @@ from pathlib import Path
|
|
|
24
24
|
|
|
25
25
|
from . import paths
|
|
26
26
|
from .harness import get_adapter
|
|
27
|
+
from .harness.codex import output_text
|
|
27
28
|
from .runner import TailLoop, await_codex_rollout
|
|
28
29
|
from .state import PairedSession, StateStore
|
|
29
30
|
from .sync import SyncEngine, SyncSetupError
|
|
@@ -492,18 +493,6 @@ _PATCH_TARGET_RE = re.compile(
|
|
|
492
493
|
r"""\*\*\* (?:Add|Update|Delete) File: (.+?)(?=\\n|[\n"']|$)""")
|
|
493
494
|
|
|
494
495
|
|
|
495
|
-
def _output_text(output) -> str:
|
|
496
|
-
"""Flatten a custom_tool_call_output payload: codex writes either a plain
|
|
497
|
-
string or a list of {"type": "input_text", "text": …} blocks."""
|
|
498
|
-
if isinstance(output, str):
|
|
499
|
-
return output
|
|
500
|
-
if isinstance(output, list):
|
|
501
|
-
return "\n".join(
|
|
502
|
-
b["text"] for b in output
|
|
503
|
-
if isinstance(b, dict) and isinstance(b.get("text"), str))
|
|
504
|
-
return ""
|
|
505
|
-
|
|
506
|
-
|
|
507
496
|
def blocked_write_paths(sub_path: Path, *, since: int = 0) -> list[str]:
|
|
508
497
|
"""Paths whose apply_patch the sandbox rejected during this run, in event
|
|
509
498
|
order. Anything unreadable or unexpected yields [] — detection is
|
|
@@ -565,7 +554,7 @@ def blocked_write_paths(sub_path: Path, *, since: int = 0) -> list[str]:
|
|
|
565
554
|
continue
|
|
566
555
|
# ... and one grep for both literals passes that gate on its own,
|
|
567
556
|
# so the marker must also sit where codex puts it: line-anchored.
|
|
568
|
-
if not _PATCH_REJECTED_RE.search(
|
|
557
|
+
if not _PATCH_REJECTED_RE.search(output_text(p.get("output"))):
|
|
569
558
|
continue
|
|
570
559
|
add(_PATCH_TARGET_RE.findall(src))
|
|
571
560
|
return rejected
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Shapes observed with Claude 2.1.261 / Codex 0.153.4 (September 2026)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from conftest import claude_assistant, claude_user, write_line
|
|
6
|
+
from tandem.cli import _resolve_participants
|
|
7
|
+
from tandem.converter import ReferenceConverter
|
|
8
|
+
from tandem.doctor import validate_transcript
|
|
9
|
+
from tandem.events import SessionContext
|
|
10
|
+
from tandem.harness import ADAPTERS
|
|
11
|
+
from tandem.ops import drain_source, switch_session
|
|
12
|
+
from tandem.util import read_jsonl
|
|
13
|
+
|
|
14
|
+
# uuid-less session metadata claude 2.1.26x interleaves with the conversation
|
|
15
|
+
_CLAUDE_METADATA = [
|
|
16
|
+
{"type": "atis-latch", "atis": ""},
|
|
17
|
+
{"type": "bridge-session", "bridgeSessionId": "cse_probe",
|
|
18
|
+
"lastSequenceNum": 0, "ownerAccountUuid": "account",
|
|
19
|
+
"ownerOrganizationUuid": "organization"},
|
|
20
|
+
{"type": "cost-state", "totalCostUSD": 0.01, "totalAPIDuration": 100,
|
|
21
|
+
"totalAPIDurationWithoutRetries": 100, "totalToolDuration": 0,
|
|
22
|
+
"totalLinesAdded": 0, "totalLinesRemoved": 0, "totalDuration": 150,
|
|
23
|
+
"startTime": 1788624000000, "modelUsage": {}, "hasUnknownModelCost": False},
|
|
24
|
+
{"type": "custom-title", "customTitle": "Renamed by the user"},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize("record", _CLAUDE_METADATA, ids=lambda r: r["type"])
|
|
29
|
+
def test_claude_session_metadata_is_valid_and_not_conversation(env_factory, record):
|
|
30
|
+
env = env_factory()
|
|
31
|
+
sid = env.session.native_id("claude")
|
|
32
|
+
record = dict(record, sessionId=sid) # never mutate the parametrize table
|
|
33
|
+
write_line(env.claude_shadow, record)
|
|
34
|
+
assert validate_transcript("claude", env.claude_shadow, sid) == []
|
|
35
|
+
ctx = SessionContext(tandem_id="probe", cwd=env.cwd, direction="claude->codex")
|
|
36
|
+
assert ReferenceConverter().translate_entry(record, ctx.direction, ctx) == []
|
|
37
|
+
assert ctx.turn_index == 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_claude_model_fallback_block_is_dropped_not_text(env_factory):
|
|
41
|
+
"""2.1.26x records a mid-session model switch as an assistant content
|
|
42
|
+
block; it carries no prose, so it must translate to nothing rather than
|
|
43
|
+
to an assistant message or a translation failure."""
|
|
44
|
+
env = env_factory()
|
|
45
|
+
entry = claude_assistant(
|
|
46
|
+
[{"type": "fallback", "from": {"model": "claude-fable-5"},
|
|
47
|
+
"to": {"model": "claude-opus-4-8"}}], uuid="a-fallback")
|
|
48
|
+
ctx = SessionContext(tandem_id="probe", cwd=env.cwd, direction="claude->codex")
|
|
49
|
+
assert ReferenceConverter().translate_entry(entry, ctx.direction, ctx) == []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_unknown_uuid_carrying_entry_does_not_cascade_into_parent_problems(env_factory):
|
|
53
|
+
"""A future chain participant claude has not told us about yet is ONE
|
|
54
|
+
problem, not one plus a dangling-parent complaint for every entry chained
|
|
55
|
+
after it (which would bury the real cause under doctor's `... N more`)."""
|
|
56
|
+
env = env_factory()
|
|
57
|
+
sid = env.session.native_id("claude")
|
|
58
|
+
write_line(env.claude_shadow, {"type": "novel-chain-entry", "uuid": "novel-1",
|
|
59
|
+
"sessionId": sid})
|
|
60
|
+
write_line(env.claude_shadow, {**claude_user("after the novelty", uuid="u-after"),
|
|
61
|
+
"parentUuid": "novel-1"})
|
|
62
|
+
problems = validate_transcript("claude", env.claude_shadow, sid)
|
|
63
|
+
assert problems == ["line 1: unknown entry type 'novel-chain-entry'"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def usage_record():
|
|
67
|
+
usage = {"input_tokens": 100, "cached_input_tokens": 64,
|
|
68
|
+
"cache_write_input_tokens": 0, "output_tokens": 10,
|
|
69
|
+
"reasoning_output_tokens": 2, "total_tokens": 110}
|
|
70
|
+
return {"timestamp": "2026-09-05T16:00:00.000Z", "type": "token_usage_record",
|
|
71
|
+
"payload": {"thread_id": "thread", "turn_id": "turn",
|
|
72
|
+
"session_id": "thread", "root_turn_id": "turn",
|
|
73
|
+
"response_id": "response", "usage": usage,
|
|
74
|
+
"turn_token_usage": usage, "thread_token_usage": usage}}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_codex_accounting_record_is_valid_and_not_conversation(env_factory):
|
|
78
|
+
env = env_factory()
|
|
79
|
+
record = usage_record()
|
|
80
|
+
write_line(env.codex_shadow, record)
|
|
81
|
+
assert validate_transcript("codex", env.codex_shadow,
|
|
82
|
+
env.session.native_id("codex")) == []
|
|
83
|
+
ctx = SessionContext(tandem_id="probe", cwd=env.cwd, direction="codex->claude")
|
|
84
|
+
assert ReferenceConverter().translate_entry(record, ctx.direction, ctx) == []
|
|
85
|
+
assert ctx.turn_index == 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _blocks(text_parts):
|
|
89
|
+
"""codex 0.153 writes roughly half of its tool outputs as a list of
|
|
90
|
+
input_text blocks rather than one string (531 of ~5000 local outputs)."""
|
|
91
|
+
return [{"type": "input_text", "text": t} for t in text_parts]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_codex_list_shaped_tool_output_parses_to_joined_text():
|
|
95
|
+
adapter = ADAPTERS["codex"]
|
|
96
|
+
ctx = SessionContext(tandem_id="probe", cwd="/tmp", direction="codex->claude")
|
|
97
|
+
(ev,) = adapter.parse_entry(
|
|
98
|
+
{"type": "response_item", "payload": {
|
|
99
|
+
"type": "custom_tool_call_output", "call_id": "c1",
|
|
100
|
+
"output": _blocks(["Script completed\nOutput:\n", "cobalt\n"])}},
|
|
101
|
+
ctx)
|
|
102
|
+
assert ev.kind == "tool_result"
|
|
103
|
+
assert ev.output == "Script completed\nOutput:\ncobalt\n"
|
|
104
|
+
assert "input_text" not in ev.output
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _codex_turn_with_tools(user_text, assistant_text):
|
|
108
|
+
"""One 0.153 turn: a code-mode `exec` call (list-shaped output), an
|
|
109
|
+
exec_command whose list-shaped output reports a failure, the new
|
|
110
|
+
accounting record, then the final answer."""
|
|
111
|
+
return [
|
|
112
|
+
{"timestamp": "t", "type": "event_msg",
|
|
113
|
+
"payload": {"type": "task_started", "turn_id": "x"}},
|
|
114
|
+
{"timestamp": "t", "type": "event_msg",
|
|
115
|
+
"payload": {"type": "user_message", "message": user_text}},
|
|
116
|
+
{"timestamp": "t", "type": "response_item", "payload": {
|
|
117
|
+
"type": "custom_tool_call", "id": "tool-id", "status": "completed",
|
|
118
|
+
"call_id": "call-exec", "name": "exec",
|
|
119
|
+
"input": 'text(await tools.exec_command({cmd:"printf cobalt"}));',
|
|
120
|
+
"internal_chat_message_metadata_passthrough": {}}},
|
|
121
|
+
{"timestamp": "t", "type": "response_item", "payload": {
|
|
122
|
+
"type": "custom_tool_call_output", "call_id": "call-exec",
|
|
123
|
+
"output": _blocks(["Script completed\nOutput:\n", "cobalt"])}},
|
|
124
|
+
{"timestamp": "t", "type": "response_item", "payload": {
|
|
125
|
+
"type": "function_call", "name": "exec_command",
|
|
126
|
+
"arguments": '{"cmd": "false"}', "call_id": "call-shell"}},
|
|
127
|
+
{"timestamp": "t", "type": "response_item", "payload": {
|
|
128
|
+
"type": "function_call_output", "call_id": "call-shell",
|
|
129
|
+
"output": _blocks(["Process exited with code 1\nOutput:\n", "boom"])}},
|
|
130
|
+
usage_record(),
|
|
131
|
+
{"timestamp": "t", "type": "response_item",
|
|
132
|
+
"payload": {"type": "message", "role": "assistant", "phase": "final_answer",
|
|
133
|
+
"content": [{"type": "output_text", "text": assistant_text}]}},
|
|
134
|
+
{"timestamp": "t", "type": "event_msg",
|
|
135
|
+
"payload": {"type": "task_complete", "turn_id": "x",
|
|
136
|
+
"last_agent_message": assistant_text}},
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _claude_tool_blocks(transcript):
|
|
141
|
+
blocks = [b for e in read_jsonl(transcript)
|
|
142
|
+
for b in (e.get("message") or {}).get("content") or []
|
|
143
|
+
if isinstance(b, dict)]
|
|
144
|
+
return ([b for b in blocks if b["type"] == "tool_use"],
|
|
145
|
+
[b for b in blocks if b["type"] == "tool_result"])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_current_formats_survive_repeated_switches_without_echo(env_factory):
|
|
149
|
+
env = env_factory()
|
|
150
|
+
write_line(env.claude_shadow, claude_user("remember apricot", uuid="u-probe"))
|
|
151
|
+
write_line(env.claude_shadow, {"type": "atis-latch", "atis": ""})
|
|
152
|
+
write_line(env.claude_shadow, claude_assistant(
|
|
153
|
+
[{"type": "text", "text": "apricot remembered"}], uuid="a-probe"))
|
|
154
|
+
assert switch_session(env.store, env.session, "codex")[1] == []
|
|
155
|
+
env.refresh()
|
|
156
|
+
records = _codex_turn_with_tools("remember cobalt", "cobalt remembered")
|
|
157
|
+
for record in records:
|
|
158
|
+
write_line(env.codex_shadow, record)
|
|
159
|
+
assert switch_session(env.store, env.session, "claude")[1] == []
|
|
160
|
+
env.refresh()
|
|
161
|
+
|
|
162
|
+
calls, results = _claude_tool_blocks(env.claude_shadow)
|
|
163
|
+
assert [c["name"] for c in calls] == ["exec", "Bash"]
|
|
164
|
+
assert [r["tool_use_id"] for r in results] == [c["id"] for c in calls]
|
|
165
|
+
# code-mode call: JavaScript kept verbatim, list output flattened to text
|
|
166
|
+
assert calls[0]["input"]["input"] == records[2]["payload"]["input"]
|
|
167
|
+
assert results[0]["content"] == "Script completed\nOutput:\ncobalt"
|
|
168
|
+
assert results[0]["is_error"] is False
|
|
169
|
+
# exec_command with a list-shaped failure still maps to a failed Bash
|
|
170
|
+
assert calls[1]["input"] == {"command": "false"}
|
|
171
|
+
assert results[1]["content"] == "boom"
|
|
172
|
+
assert results[1]["is_error"] is True
|
|
173
|
+
|
|
174
|
+
before = env.codex_shadow.read_bytes(), env.claude_shadow.read_bytes()
|
|
175
|
+
drain_source(env.store, env.session, "claude")
|
|
176
|
+
assert switch_session(env.store, env.session, "codex")[1] == []
|
|
177
|
+
assert before == (env.codex_shadow.read_bytes(), env.claude_shadow.read_bytes())
|
|
178
|
+
for source, target in [("claude", "codex"), ("codex", "claude")]:
|
|
179
|
+
assert env.store.get_cursor(env.session.tandem_id, source, target).failed_turns == 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_installed_versions_start_without_format_warning(monkeypatch, capsys):
|
|
183
|
+
monkeypatch.setattr("tandem.config.load_harnesses", lambda: ["claude", "codex"])
|
|
184
|
+
for hid, version in [("claude", "2.1.261 (Claude Code)"),
|
|
185
|
+
("codex", "codex-cli 0.153.4")]:
|
|
186
|
+
monkeypatch.setattr(type(ADAPTERS[hid]), "detect_version",
|
|
187
|
+
lambda self, _v=version: _v)
|
|
188
|
+
assert _resolve_participants()[0] == ["claude", "codex"]
|
|
189
|
+
assert capsys.readouterr().err == ""
|