From 8a39086ebe0eb5586e909e8eb40c9b57fb09a6ae Mon Sep 17 00:00:00 2001 From: d180 Date: Wed, 8 Jul 2026 22:26:04 -0700 Subject: [PATCH 1/2] feat(opencode): add OpenCode as a third harness alongside Codex/Claude --- README.md | 22 +- docs/plans/2026-07-07-opencode-harness.md | 165 +++ .../2026-07-08-opencode-harness-slice-1.md | 101 ++ .../2026-07-08-opencode-harness-slice-2.md | 115 ++ .../2026-07-08-opencode-harness-slice-3.md | 121 ++ ...arness-live-progress-and-hang-detection.md | 127 ++ .../fixes-opencode-harness-final-review.md | 45 + ...s-opencode-harness-live-progress-review.md | 36 + .../fixes-opencode-harness-slice-1-review.md | 36 + .../fixes-opencode-harness-slice-2-review.md | 43 + .../fixes-opencode-harness-slice-3-review.md | 27 + .../cli/dispatch/setup_wizard/options.py | 96 +- src/codealmanac/cli/dispatch/sync.py | 4 +- src/codealmanac/cli/parser/setup.py | 11 +- src/codealmanac/cli/render/brand.py | 2 + src/codealmanac/database/__init__.py | 2 + src/codealmanac/database/sqlite.py | 24 + src/codealmanac/integrations/command.py | 8 +- .../integrations/harnesses/__init__.py | 9 +- .../integrations/harnesses/claude/client.py | 21 +- .../harnesses/codex/agent_events.py | 4 +- .../harnesses/codex/app_server.py | 20 +- .../integrations/harnesses/codex/display.py | 2 +- .../integrations/harnesses/codex/events.py | 10 +- .../integrations/harnesses/codex/failures.py | 2 +- .../harnesses/codex/item_events.py | 4 +- .../harnesses/codex/notification_events.py | 8 +- .../integrations/harnesses/codex/responses.py | 2 +- .../integrations/harnesses/codex/result.py | 10 +- .../integrations/harnesses/codex/rpc.py | 2 +- .../integrations/harnesses/codex/sandbox.py | 2 +- .../harnesses/codex/turn_completion.py | 4 +- .../integrations/harnesses/codex/usage.py | 2 +- .../harnesses/{codex => }/fields.py | 0 .../harnesses/opencode/__init__.py | 5 + .../harnesses/opencode/adapter.py | 86 ++ .../integrations/harnesses/opencode/client.py | 292 +++++ .../harnesses/opencode/failures.py | 52 + .../integrations/harnesses/opencode/http.py | 55 + .../harnesses/opencode/model_ref.py | 8 + .../integrations/harnesses/opencode/parts.py | 220 ++++ .../harnesses/opencode/progress.py | 278 +++++ .../integrations/harnesses/opencode/result.py | 64 + .../integrations/harnesses/opencode/server.py | 108 ++ .../integrations/harnesses/opencode/state.py | 24 + .../harnesses/opencode/timeouts.py | 14 + .../integrations/harnesses/opencode/usage.py | 18 + .../harnesses/{codex => }/stream.py | 0 .../integrations/opencode_paths.py | 9 + .../integrations/setup/instructions.py | 6 +- .../integrations/setup/opencode.py | 62 + .../sources/transcripts/__init__.py | 17 +- .../sources/transcripts/opencode.py | 117 ++ .../sources/transcripts/opencode_db.py | 135 +++ .../sources/transcripts/opencode_ref.py | 30 + .../sources/transcripts/runtime.py | 15 +- src/codealmanac/services/config/models.py | 64 +- src/codealmanac/services/config/service.py | 10 +- src/codealmanac/services/harnesses/kinds.py | 1 + src/codealmanac/services/setup/models.py | 1 + src/codealmanac/services/setup/requests.py | 2 +- src/codealmanac/services/sources/models.py | 14 + .../services/sources/transcripts.py | 11 + src/codealmanac/workflows/sync/queue.py | 3 +- tests/test_architecture.py | 11 + tests/test_cli.py | 2 +- tests/test_config_service.py | 6 + tests/test_controlled_model_config.py | 50 +- tests/test_opencode_adapter.py | 1041 +++++++++++++++++ tests/test_opencode_transcripts.py | 383 ++++++ tests/test_setup_service.py | 1 + tests/test_setup_wizard_options.py | 181 +++ 72 files changed, 4350 insertions(+), 133 deletions(-) create mode 100644 docs/plans/2026-07-07-opencode-harness.md create mode 100644 docs/plans/2026-07-08-opencode-harness-slice-1.md create mode 100644 docs/plans/2026-07-08-opencode-harness-slice-2.md create mode 100644 docs/plans/2026-07-08-opencode-harness-slice-3.md create mode 100644 docs/plans/2026-07-09-opencode-harness-live-progress-and-hang-detection.md create mode 100644 docs/plans/fixes-opencode-harness-final-review.md create mode 100644 docs/plans/fixes-opencode-harness-live-progress-review.md create mode 100644 docs/plans/fixes-opencode-harness-slice-1-review.md create mode 100644 docs/plans/fixes-opencode-harness-slice-2-review.md create mode 100644 docs/plans/fixes-opencode-harness-slice-3-review.md rename src/codealmanac/integrations/harnesses/{codex => }/fields.py (100%) create mode 100644 src/codealmanac/integrations/harnesses/opencode/__init__.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/adapter.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/client.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/failures.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/http.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/model_ref.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/parts.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/progress.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/result.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/server.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/state.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/timeouts.py create mode 100644 src/codealmanac/integrations/harnesses/opencode/usage.py rename src/codealmanac/integrations/harnesses/{codex => }/stream.py (100%) create mode 100644 src/codealmanac/integrations/opencode_paths.py create mode 100644 src/codealmanac/integrations/setup/opencode.py create mode 100644 src/codealmanac/integrations/sources/transcripts/opencode.py create mode 100644 src/codealmanac/integrations/sources/transcripts/opencode_db.py create mode 100644 src/codealmanac/integrations/sources/transcripts/opencode_ref.py create mode 100644 tests/test_opencode_adapter.py create mode 100644 tests/test_opencode_transcripts.py create mode 100644 tests/test_setup_wizard_options.py diff --git a/README.md b/README.md index 8265e2ee..e32fcdb8 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,9 @@ codealmanac setup --yes # Quick install using Claude as the AI runner codealmanac setup --yes --runner claude + +# Quick install using OpenCode as the AI runner +codealmanac setup --yes --runner opencode ``` Setup installs agent instructions for your chosen tools and three local macOS @@ -138,14 +141,15 @@ Setup installs agent instructions for your chosen tools and three local macOS | Job | Default schedule | What it does | | --- | ---: | --- | -| Sync | Every 5 hours | Scans recent Codex and Claude conversations and queues useful knowledge for the relevant registered wiki. | +| Sync | Every 5 hours | Scans recent Codex, Claude, and OpenCode conversations and queues useful knowledge for the relevant registered wiki. | | Garden | Every 4 hours | Reviews every registered wiki for stale, duplicated, or poorly connected knowledge. | | Update | Every 24 hours | Checks for and installs CodeAlmanac CLI updates when it is safe to do so. | These schedules run locally in the background. Use `codealmanac automation status` to see what is installed. -If you don't have Codex or prefer Claude, use `--runner claude`. +`--yes` picks Codex as the AI runner. If you don't have Codex or prefer Claude +or OpenCode, use `--runner claude` or `--runner opencode`. `--target` only chooses which global agent instruction files to install; it does not choose the AI runner: @@ -153,6 +157,7 @@ not choose the AI runner: ```bash codealmanac setup --yes --target codex codealmanac setup --yes --target claude +codealmanac setup --yes --target opencode ``` Customize automatic work during setup: @@ -223,10 +228,10 @@ should leave the wiki unchanged. CodeAlmanac can keep registered wikis current without requiring you to remember maintenance commands. -**Sync** scans local Codex and Claude transcript stores for conversations active -since the previous completed sync. Conversations associated with registered -repositories are queued as ordinary ingest jobs. Sync may decide that a -conversation contains no durable knowledge and leave the wiki unchanged. +**Sync** scans local Codex, Claude, and OpenCode transcript stores for +conversations active since the previous completed sync. Conversations associated +with registered repositories are queued as ordinary ingest jobs. Sync may decide +that a conversation contains no durable knowledge and leave the wiki unchanged. **Garden** periodically queues a maintenance job for each registered wiki. It improves stale pages, weak links, topics, duplicated knowledge, and graph @@ -289,12 +294,13 @@ a script. ## Providers -CodeAlmanac currently supports local Codex app-server and Claude Agent SDK -harnesses. +CodeAlmanac currently supports local Codex app-server, Claude Agent SDK, and +OpenCode harnesses. ```bash codex login claude auth login +opencode auth login codealmanac doctor ``` diff --git a/docs/plans/2026-07-07-opencode-harness.md b/docs/plans/2026-07-07-opencode-harness.md new file mode 100644 index 00000000..07412add --- /dev/null +++ b/docs/plans/2026-07-07-opencode-harness.md @@ -0,0 +1,165 @@ +# OpenCode Harness Adapter + +> Tracks GitHub issue #9 ("Support OpenCode"). Approach agreed with @kushagrchitkar in the issue thread. + +**Goal:** Bring OpenCode to full parity with Codex and Claude: (1) a `HarnessAdapter` so `init`/`ingest`/`garden` can run through the OpenCode CLI, (2) onboarding — setup wizard, `--runner`/`--target` flags, `AGENTS.md`-style instructions, (3) `codealmanac sync` reading OpenCode's own local session history the same way it already reads Claude/Codex transcripts. (3) was initially scoped out as a separate follow-up, but the maintainer's ask to check transcript access was pointing at exactly this — `sync` turning past coding sessions into wiki material is core to what CodeAlmanac does, not an optional extra, so shipping a harness that runs OpenCode but never absorbs its history back into the wiki would not be real parity. Per @kushagrchitkar's reply on issue #9 ("since this does touch the onboarding wizard, would be great if you could make it part of this PR itself"), the wizard generalization is also in scope. + +**Architecture:** OpenCode ships a local HTTP server (`opencode serve`, ephemeral port via `--port 0`, no login required). The adapter starts/attaches to that server, opens a session, and posts the prompt via the **blocking** `POST /session/{id}/message` call, which returns the complete `{info, parts}` payload once the assistant's turn finishes server-side — translating those parts (text, tool-call, tool-result, file-edit, usage) into CodeAlmanac's existing `HarnessEvent` shapes, the same normalized-event contract Codex and Claude adapters already produce for `HarnessesService`. + +~~consumes the GET /event SSE stream~~ **Corrected by spike (2026-07-08): the SSE stream is not used for run() — see "SSE stream is not reliable for turn completion" below.** + +**Tech Stack:** Python, `httpx` (SSE client) or stdlib `http.client`/`urllib` if we want to avoid a new dependency, `subprocess` for CLI readiness checks (matches `integrations/command.py`'s existing `CommandRunner` pattern), pytest. + +## Read before implementing + +- `MANUAL.md` (already read — flag-check surfaced the setup-wizard binary-logic issue below) +- `src/codealmanac/services/harnesses/ports.py` — the `HarnessAdapter` protocol +- `src/codealmanac/services/harnesses/events.py` / `models.py` — target event/result shapes +- `src/codealmanac/integrations/harnesses/codex/adapter.py` + `app_server.py` — closest structural precedent (adapter talks to a local server process, not just stdout scraping) +- `src/codealmanac/integrations/harnesses/claude/adapter.py` + `stream.py` — precedent for turning a rich structured event stream into `HarnessEvent`s +- `src/codealmanac/integrations/command.py` — shared `CommandRunner`/`SubprocessCommandRunner` used by both existing `check()` implementations + +## Plan review findings (pre-implementation) + +Read against the live Codex/Claude adapters (`integrations/harnesses/codex/app_server.py`, `integrations/harnesses/claude/client.py`) and their surrounding registries. Findings below are folded into Scope/Design decisions/File changes; this section is the record of *why*. + +**Must-fix — scope gaps that would make `sync` silently break for OpenCode even though this plan reads as complete:** + +- **Transcript *reading* is out of scope; only *discovery* is in scope.** `TranscriptSourceRuntimeAdapter` (`integrations/sources/transcripts/runtime.py`) is the single `SourceRuntimeAdapter` registered for every `TranscriptApp` — it resolves `SourceRef.transcript` to one `Path` and reads it with `jsonlines.Reader` (`reader.py`), one JSON object per line. That works for Claude/Codex only because both really are single JSONL files. This plan's own design-decision note says OpenCode isn't JSONL, but the Scope section only adds `OpencodeTranscriptDiscoveryAdapter` (produces candidates) — it never touches `runtime.py`, `reader.py`, or `default_transcript_runtime_adapters()`. Result as originally scoped: `sync` discovers OpenCode sessions fine, then fails (or returns "no readable JSONL objects found") when it tries to read one. This is exactly the "sync turning past coding sessions into wiki material" promise this plan's Goal paragraph says is non-negotiable — so the plan can't be considered complete without it. +- ~~The plan states two different, contradictory shapes for OpenCode's storage.~~ **RESOLVED by spike (2026-07-08, opencode-ai@1.17.15) — both were wrong.** See "Spike findings" below: storage is a single SQLite database, not JSON files at all. +- ~~Non-interactive/auto-approve handling isn't scoped~~ **RESOLVED by spike (2026-07-08) — better than expected, but needs a defensive net, not a from-scratch approval loop.** See "Spike findings" below: a session driven through the HTTP API auto-approved a real `bash` tool call (write + read a file, exit 0) with zero rows ever written to the `permission` table and no blocking — no `respond_to_server_request`-equivalent loop was needed for this case. Scope now: don't build Codex-style approval-RPC machinery by default; do add a defensive poll/reply against `GET /session/{id}/permission` as a safety net for edge cases (destructive commands, non-default project config, more capable models attempting riskier tool types) not exercised by this spike's one shell round-trip. + +**Should-fix — mechanical additions to files already in this plan's scope, easy to miss because they sit in the same file as work already listed:** + +- `services/config/models.py:14` has a third model registry, `CONTROLLED_HARNESS_MODELS` (a flat frozenset gating `HarnessConfig.controlled_model`), separate from `HARNESS_MODELS`/`DEFAULT_HARNESS_MODELS`. The plan's File changes table only names the latter two — miss `CONTROLLED_HARNESS_MODELS` and `codealmanac config set harness.model ` rejects valid OpenCode models even after the other two dicts are updated. +- `cli/dispatch/setup_wizard/options.py:134`'s `parse_setup_targets()` hardcodes `"all" → (SetupTarget.CODEX, SetupTarget.CLAUDE)` for the non-wizard `codealmanac setup --target all` flag. It lives in the exact file this plan is already generalizing (`runner_for_index`, `target_options`, etc.) but isn't mentioned — would silently exclude OpenCode from `--target all`. + +**Consider:** + +- What does an "OpenCode model" mean? Codex and Claude are single-provider CLIs with a short fixed model list, which is why `HarnessKind → tuple[str]` works as a shape. OpenCode is multi-provider by design (arbitrary `provider/model` pairs), so bolting it into the same fixed-tuple-of-opaque-strings shape needs a conscious choice, not an assumption. Recommend curating a short allowlist of opaque `"provider/model"` strings and treating them like any other model string through `HARNESS_MODELS`/`CONTROLLED_HARNESS_MODELS`/`RunHarnessRequest.model` — stays additive and matches this plan's own "no branching to touch" goal for `config/models.py`. The alternative (splitting provider and model into separate fields) would ripple into `RunHarnessRequest`, both registries, and the wizard, and isn't justified unless the allowlist approach proves too restrictive. +- `cli/render/brand.py`'s `BRAND_COLORS` dict (`"Codex"`/`"Claude"` → ANSI color) has no OpenCode entry. `label_word()` degrades gracefully when a word isn't found, so this is cosmetic only — but worth a one-line addition while already touching wizard option labels. + +## Spike findings (2026-07-08, `opencode-ai@1.17.15` installed via `npm install -g opencode-ai`) + +Ran a real `opencode serve`, created a session scoped to a scratch git repo, sent a live prompt through the free `opencode/deepseek-v4-flash-free` model (no login required — `"options":{"apiKey":"public"}`), and inspected exactly what got persisted to disk. Test session and its rows were deleted afterward (`DELETE /session/{id}` cascades) and both spawned server processes were killed; the real `~/.local/share/opencode/opencode.db` was left as found. + +**Storage is a SQLite database, not JSON files.** This plan's Scope and Design-decisions sections both described a `storage/session/{projectHash}/*.json` filesystem tree — that tree does not exist on disk at all. The real location is `~/.local/share/opencode/opencode.db` (plus `-wal`/`-shm` journal files; the DB runs in WAL mode). Confirmed table shapes (`sqlite3 ~/.local/share/opencode/opencode.db ".schema "`): + +- **`project`**: `id`, `worktree` (the real absolute cwd — directly usable, no indirection needed), `vcs`, timestamps. +- **`project_directory`**: `project_id` → `directory`, `(project_id, directory)` composite PK — a project can map multiple directories (worktrees?), one-to-many, not the one-to-one the plan assumed. +- **`session`**: `id`, `project_id` (FK), `parent_id` (nullable — **first-class relational column**, not something to reconstruct from events), `directory` (also inline on the session row itself, redundant with `project.worktree`), `title`, `agent`, `model` (JSON text, e.g. `{"id":"deepseek-v4-flash-free","providerID":"opencode","variant":"default"}`), `time_created`/`time_updated`, cost/token summary columns. +- **`message`**: `id`, `session_id` (FK), `time_created`, `data` (JSON text — an envelope: `role`, `agent`, `model`, `time`, and for assistant messages `parentID` pointing at the user message and `path.cwd`). One row per message; **no content text lives here.** +- **`part`**: `id`, `message_id` (FK), `session_id`, `time_created`, `data` (JSON text — the actual content chunk: `{"type":"text","text":"..."}`, `{"type":"reasoning",...}`, `{"type":"step-start"/"step-finish",...}`, and by extension tool-call/tool-result/file-edit parts). One row per content chunk; a message can have many parts. This is where the real transcript text lives. +- **`session_message`**: exists in the schema but had **0 rows** for a real session with 2 messages / 5 parts — not the active chat-content path; do not build the reader against it without separately confirming what it's for (it may be a newer/parallel log for a different subsystem — todos, permission events — not verified in this spike). + +Concretely, reconstructing one session's transcript is: `SELECT * FROM message WHERE session_id = ? ORDER BY time_created`, joined with `SELECT * FROM part WHERE message_id = ? ORDER BY time_created`, decoding each row's `data` JSON text column. No directory walk, no per-message files. + +**What this changes in Scope:** +- `OpencodeTranscriptDiscoveryAdapter.discover()` should query `project`/`session` tables (open the SQLite file read-only) instead of walking a `storage/session/{projectHash}/*.json` tree. `project.worktree` (or `session.directory`) gives the real cwd directly — no project-record indirection needed, simpler than this plan originally assumed. +- The "own parsing module ... one entry per message/part file" line in Scope is wrong — replace with: join `message` + `part` rows ordered by `time_created`, one `TranscriptRuntimeEntry` per part row (parts are naturally already ordered content chunks, a good fit for the existing `line_number`-as-ordinal reuse this plan already proposed). +- This also resolves the transcript-*reading* gap (must-fix #1 above): the new runtime-reading code reads the same SQLite tables, just filtered to one `session_id` instead of listing all sessions for a project. One reader can serve both discovery and runtime inspection. + +**New design question this raises, not previously in the plan:** read the SQLite file directly (matches Claude/Codex's "read files locally, no running process required" precedent; WAL mode allows concurrent reads while a live `opencode serve` is writing) vs. requiring a running `opencode serve` and calling `GET /session/{id}/message` (which returns pre-joined `[{info, parts}]` JSON, no manual join needed, and is the officially exposed contract rather than an internal schema). The DB schema visibly churns — `sqlite3 ... ".schema migration"` showed 10+ dated migrations already — and isn't a documented public contract, so reading it directly is a real coupling risk despite being the same *pattern* Claude/Codex use. Recommend direct SQLite read for `sync` (matches precedent, works without a running server, which `sync` can't assume), but treat unexpected schema shape as a soft skip (log/warn, exclude that session) rather than a hard failure, and add a smoke test that fails loudly in CI if the schema changes underneath this adapter. + +**Bonus confirmations for two other open items in this plan (not the primary ask, but resolved for free during the same spike):** +- *Server lifecycle (Design decisions, "confirm at implementation time"):* **confirmed ephemeral-port support.** `opencode serve --port 0` binds an OS-assigned random port each time (verified two concurrent instances: one with no `--port` flag bound the app-level default `4096`, one with explicit `--port 0` bound `61848`). Use `--port 0` and parse the bound port from the "opencode server listening on http://127.0.0.1:PORT" startup line (or stdout, if there's a structured form — plain-text parse here is acceptable since it's a one-time startup readiness signal, not per-event scraping) — this avoids the fixed-port collision risk across concurrent runs the plan flagged as an open question. +- *"What does an OpenCode model mean?" (Consider, above):* **confirmed structured, not opaque.** `POST /session` body wants `model: {id, providerID, variant}`; `POST /session/{id}/message` wants `model: {providerID, modelID}` (note the `id`/`modelID` key naming inconsistency between the two endpoints — worth a smoke test to catch if it changes). Also confirmed a `GET /config/providers` endpoint returning every currently-configured provider with its available models and capabilities (cost, context limits, tool-call support) as structured JSON — this is a strictly better readiness/model-listing signal than shelling out to `opencode auth list` (see below) and could replace a hardcoded model allowlist with one fetched from the running server, if the adapter design ends up standing up a server for `check()` anyway. +- ~~`check()`'s proposed `opencode auth list` shape needs revisiting.~~ **DECIDED (2026-07-08): option (b), server + `GET /config/providers`.** Ran `opencode auth list` with zero configured providers: **exit code 0**, output is TUI-formatted text ("0 credentials") — unlike `codex login status`/`claude auth status`, a non-zero exit code can't be used as the "not ready" signal, and parsing "0 credentials" out of decorative box-drawing text is exactly the text-scraping MANUAL.md §4 warns against. Chose the server-backed option over reading `auth.json` + hardcoded env vars specifically because of the free-tier wrinkle this spike surfaced: the OpenCode Zen provider (`"options":{"apiKey":"public"}`, no login) needs no credentials file or env var at all, so a file/env-only check would report a perfectly runnable setup as "not ready." `GET /config/providers` already merges file-based and env-based credentials into one authoritative JSON list, including the always-available free provider — accept the added server-startup cost on `check()` as the trade-off for that accuracy. Revisit if it proves too slow/flaky in practice; the file/env fallback is documented above if we need to fall back to it. +- **Non-interactive tool execution via the HTTP API, confirmed live.** Created a session, sent a prompt asking the model to run a shell command (`echo hello-from-opencode > spike-output.txt`, then `cat` it back) through the free `deepseek-v4-flash-free` model. The `part` rows show a `{"type":"tool","tool":"bash",...,"state":{"status":"completed",...,"exit":0}}` entry — the command ran and completed with no pause, and `SELECT count(*) FROM permission` stayed at `0` for the whole session. No permission prompt, no blocking, no explicit approval config was needed for this case. `POST /session` does accept an optional `permission: PermissionRuleset` array (`[{permission, pattern, action: "allow"|"deny"|"ask"}]`) if a caller wants to be explicit rather than relying on this default — worth setting explicitly for defense-in-depth even though the unconfigured default already behaved non-interactively here. + +**SSE stream (`GET /api/event`) is not reliable for turn completion — architecture correction.** Before writing Slice 1's event-mapping code, connected to the global `GET /api/event` SSE stream (the session-scoped `GET /session/{id}/event`/`GET /api/session/{id}/event` variants both turned out to serve the web UI's `index.html`, not SSE — a dead end) while concurrently sending a message. Across four separate attempts (varying capture window length, graceful vs. forced curl termination, with and without an undocumented `?directory=` query param), the stream reliably delivered `server.connected` → `session.updated` → `message.updated` (user) → `message.part.updated` (user's own text) → `session.updated`, and **never once** delivered the assistant's reply events (`message.updated` for the assistant message, `message.part.updated` for its text/tool-call/step-finish parts) — even though the synchronous `POST /session/{id}/message` call reliably returned the complete `{info, parts}` payload every single time, and the earlier SQLite spike confirmed that data really is persisted to `message`/`part` tables afterward. Likely specific to the free `opencode` "Zen" provider's response path not publishing to the same event bus the TUI/web-UI consumes, or a version-specific quirk — not conclusively root-caused, but reproduced consistently enough (4/4) to not build on top of it. + +**Resulting architecture change:** `run()` does not depend on SSE at all. It uses the blocking `POST /session/{id}/message` response's `parts` array directly as the authoritative, complete event source — mapped to `HarnessEvent`s all at once after the call returns, rather than streamed incrementally during generation. This sacrifices Claude-adapter-style live `TEXT_DELTA` narration in exchange for correctness: `on_event` is optional on the `HarnessAdapter` contract either way, and `HarnessRunResult.events` being the authoritative record is unaffected by whether events arrive live or as a single batch. It also **simplifies the implementation** — no reader thread, no queue, no event deduplication between two concurrent connections. Revisit SSE-based live narration later if the free-tier limitation turns out not to apply to paid providers (untested — this spike only had the free tier available) and it's worth the added complexity. + +## Windows compatibility + +This repo's CI runs `ubuntu-latest` only (confirmed across `ci.yml`, `pack-check.yml`, `publish.yml` — no `windows-latest` job anywhere) and has zero platform-conditional code today (`grep -rn "win32\|sys.platform\|os.name" src/` returns nothing). So there's no established "how Codex/Claude handle Windows" pattern to mirror — their own Windows behavior has never actually been exercised. Splitting what's actually fine from what's a real, fixable risk: + +- **Fine as-is, no action needed:** path construction (`RunHarnessRequest.cwd: Path`, `core/paths.py::home_dir() -> Path.home()`, and this plan's own `Path.home() / ".config" / "opencode"`-style joins) uses `pathlib.Path` throughout, which resolves separators correctly per-OS automatically — no hand-rolled string paths to fix. Subprocess lifecycle (`Popen.terminate()`, the pattern Codex's `app_server.py:164` already uses and this plan's server-client module should mirror) maps to `TerminateProcess()` on Windows via Python's stdlib — no special-casing needed. `opencode serve --port 0` / `127.0.0.1` loopback binding is OS-agnostic. +- **Real, fixable risk — a shared fix, not an OpenCode-only patch:** `SubprocessCommandRunner` (`integrations/command.py`) calls `subprocess.run((command, *args), ...)` with `shell=False` and no executable resolution first. On Windows, npm-installed global CLIs (`opencode`, and already `codex`/`claude` today) ship as `.cmd`/`.ps1` wrapper shims, not raw `.exe` files, and `subprocess.run(shell=False)` calls into `CreateProcess`, which does not resolve `PATHEXT` extensions the way `cmd.exe` does — a well-documented Python-on-Windows gotcha (a plain `subprocess.run(["npm", "--version"])` fails the same way). This isn't new to OpenCode's `check()`/`run()` — it already affects `codex`/`claude`'s `check()` identically, and since CI never runs on Windows, it's never been caught. Fix: resolve the executable through `shutil.which(command)` before calling `subprocess.run` inside `SubprocessCommandRunner` itself — one shared fix in `integrations/command.py` that benefits all three harnesses, in keeping with this repo's own "extend the general seam, don't patch around it locally" principle, rather than a workaround scoped only to the OpenCode adapter. +- **Unverified, needs a real check before calling this shipped:** the data-directory paths this plan depends on (`~/.local/share/opencode/opencode.db` for transcript discovery, `~/.config/opencode/AGENTS.md` for setup) were only observed on macOS during the spike. Notably, OpenCode used Linux-style XDG folder names (`.config`, `.local/share`) even on macOS rather than the platform-native `~/Library/Application Support` — evidence it's doing a literal `os.homedir()`-plus-fixed-folder-name join rather than an OS-branching path library, which suggests (not confirms) the same literal join happens on Windows too (`%USERPROFILE%\.config\opencode`, which `Path.home() / ".config" / "opencode"` would construct correctly). This is inference from macOS behavior, not a Windows spike — I don't have a Windows machine available in this environment to verify it directly. Two safeguards regardless of which way it resolves: (1) a missing/absent `opencode.db` should degrade `OpencodeTranscriptDiscoveryAdapter.discover()` to zero candidates, not an error — `sync` already tolerates zero-candidate discovery for other apps, so an unverified Windows path degrades gracefully instead of crashing; (2) treat an actual Windows verification pass (real machine, or a `windows-latest` CI job) as a pre-ship checklist item for this plan, not something to close out on macOS evidence alone. + +## Scope + +- New `integrations/harnesses/opencode/` package: + - `adapter.py` — `OpencodeHarnessAdapter` implementing `check()` and `run()` + - a server-client module (name TBD at implementation time, e.g. `server.py`) that owns starting/attaching to `opencode serve` and opening a session via `POST /session` + - a stream module that consumes `GET /event` SSE and maps OpenCode message parts → `HarnessEvent` +- `check()`: shell out to `opencode --version` first (installed) — fail fast without touching a server if this fails. For "authenticated," **do not** parse `opencode auth list` — spike confirmed it exits 0 even with zero configured providers and prints decorative TUI-formatted text ("0 credentials"), not a reliable non-zero-exit signal, and parsing it would be exactly the text-scraping MANUAL.md §4 warns against. **Decided: start a short-lived server and call `GET /config/providers`.** Chosen over reading `auth.json` + hardcoded env vars because the free `opencode` "Zen" provider used throughout this spike needs no credentials file or env var at all — a file/env-only check would falsely report "not ready" for a perfectly runnable setup. Available iff the response's `providers` list is non-empty. Use a short startup timeout (a few seconds) so a broken `opencode serve` degrades to "not available" rather than hanging `check()`; terminate the server in a `finally` regardless of outcome, mirroring Codex's `process.terminate()` pattern (`app_server.py:164`). This start/stop-a-short-lived-server logic is the same primitive `run()` needs for its own session server, just torn down immediately after one HTTP call instead of staying up for the whole run — implement it once in the server-client module and have both `check()` and `run()` call it, not two separate lifecycles. +- `run()`: start a session scoped to `request.cwd`, post `request.prompt`, stream events until a terminal state, return `HarnessRunResult`. +- Register `HarnessKind.OPENCODE` in `services/harnesses/kinds.py`. +- Register the adapter in `integrations/harnesses/__init__.py::default_harness_adapters()`. +- Add `HarnessKind.OPENCODE` entries to `HARNESS_MODELS` / `DEFAULT_HARNESS_MODELS` in `services/config/models.py` — this file is already dict-keyed by `HarnessKind`, so this is additive, no branching to touch. +- Map OpenCode's session `parentID` → `HarnessAgentTrace.parent_thread_id`/`child_thread_id` for sub-agent runs (confirmed available on the session object and on `session.created`/`session.updated`/`session.status` events — see Design Decisions). +- **Generalize `cli/dispatch/setup_wizard/options.py`'s harness selection.** Currently hardcoded binary Codex/Claude logic (`runner_for_index`, `runner_index`, `target_options`, `targets_for_index`, `target_default_index` all use `if index == 1 → Claude else → Codex` and a literal `"Codex + Claude"` combo string) — the "second concrete case never generalized into a seam" pattern `MANUAL.md` §2.4 flags. Replace with one ordered `HARNESS_ORDER = (HarnessKind.CODEX, HarnessKind.CLAUDE, HarnessKind.OPENCODE)` tuple that `runner_options`/`runner_for_index`/`runner_index` derive from by iteration/`.index()`, so a 4th harness later is a one-line addition. +- **Add `SetupTarget.OPENCODE`** (`services/setup/models.py`) and an `integrations/setup/opencode.py` instructions installer, mirroring `integrations/setup/codex.py`: write a managed block to `~/.config/opencode/AGENTS.md` (not the `~/.claude/CLAUDE.md` fallback path — see Design Decisions for why). `target_options()`'s combo choices generalize the same way as the runner picker, driven by an ordered `SetupTarget` tuple instead of the current `"Codex + Claude" / "Codex only" / "Claude only"` hardcoded triplet. +- Tests: `tests/test_opencode_adapter.py` (harness adapter) and `tests/test_opencode_setup.py` or extending the existing setup-wizard/instructions test file (naming TBD at implementation time — check current test file layout for `integrations/setup/codex.py` coverage and mirror it), following the shape of `test_codex_adapter.py` / `test_claude_adapter.py`. + +- **Add `TranscriptApp.OPENCODE`** (`services/sources/models.py`) and `integrations/sources/transcripts/opencode.py::OpencodeTranscriptDiscoveryAdapter`, registered in `default_transcript_discovery_adapters()` (`integrations/sources/transcripts/__init__.py`), so `codealmanac sync` picks up OpenCode sessions the same way it already does Claude/Codex ones. + - `discover()`: open `~/.local/share/opencode/opencode.db` **read-only** (SQLite, confirmed by spike — see "Spike findings" above; there is no `storage/session/{projectHash}/*.json` tree) and query `SELECT session.id, session.directory, session.time_updated FROM session` (join `project` only if `session.directory` turns out unreliable — it was populated inline in the spike). Build one `TranscriptCandidate` per session row, matching the `candidate_from_meta(...)` shape `claude.py`/`codex.py` already use. `TranscriptCandidate.transcript_path` should point at the `.db` file (shared across all OpenCode sessions) with `session_id` disambiguating — this differs from Claude/Codex where `transcript_path` is unique per session, so check whether anything downstream assumes path uniqueness per candidate before relying on this. + - Reading a session's content into `TranscriptRuntimeEntry` needs its own SQLite-backed reader (not `read_first_lines`/`collect_jsonl`, which are JSONL-file-shaped): `SELECT * FROM message WHERE session_id = ? ORDER BY time_created`, joined with `SELECT * FROM part WHERE message_id = ? ORDER BY time_created`, decoding each row's `data` JSON text column — one `TranscriptRuntimeEntry` per `part` row, in `time_created` order. `TranscriptRuntimeEntry.line_number` is JSONL-shaped in name but is really just "the Nth entry in reading order," so it's reusable as the part ordinal without changing the model. This same query serves both discovery-time candidate building and the runtime-reading gap called out above — one adapter, not two. + - Update `cli/dispatch/sync.py::parse_sync_apps`'s default (currently `(TranscriptApp.CLAUDE, TranscriptApp.CODEX)`) to include `TranscriptApp.OPENCODE`. +- Tests: `tests/test_transcript_discovery.py` gets OpenCode coverage alongside its existing Claude/Codex cases (confirm exact split at implementation time — may warrant its own `test_opencode_transcript_discovery.py` if the parsing logic is substantial enough to test in isolation). +- **Extend transcript *reading*, not just discovery** — `sync`'s content-inspection step (`integrations/sources/transcripts/runtime.py::TranscriptSourceRuntimeAdapter`, backed by `reader.py::read_transcript_entries`) is hardcoded to JSONL and is the only registered `SourceRuntimeAdapter`. Add whatever OpenCode needs here — either a format-detecting branch in `reader.py` if OpenCode's session file is JSON-not-JSONL, or a second `SourceRuntimeAdapter` registered via `supports()` if content is genuinely split across per-message files — once the storage-shape question below is resolved. Without this, `OpencodeTranscriptDiscoveryAdapter` produces candidates that `sync` cannot actually read. +- **Configure OpenCode to run non-interactively.** Spike confirmed the HTTP-API default already behaves non-interactively (a real `bash` tool call ran to completion with zero `permission` table rows, no blocking — see Spike findings) — no Codex-style `respond_to_server_request` loop appears necessary for the common case. Still: pass an explicit `permission: [{permission: "*", pattern: "*", action: "allow"}]`-shaped ruleset on `POST /session` for defense-in-depth (belt-and-suspenders, matches this repo's general risk posture, and protects against a user's local `~/.config/opencode` config overriding the default), and add a defensive poll/reply against `GET /session/{id}/permission` as a safety net so an edge case this spike didn't exercise (destructive command patterns, non-default project config, a more capable model attempting a riskier tool) degrades to "reply allow" instead of hanging the run. +- Add OpenCode's model strings to **`CONTROLLED_HARNESS_MODELS`** (`services/config/models.py:14`) as well as `HARNESS_MODELS`/`DEFAULT_HARNESS_MODELS` — it's a separate registry gating `codealmanac config set harness.model`. +- Generalize **`parse_setup_targets()`**'s `"all"` case in `cli/dispatch/setup_wizard/options.py` (currently hardcodes `(SetupTarget.CODEX, SetupTarget.CLAUDE)`) alongside the wizard's `runner_for_index`/`target_options` generalization — it backs the non-wizard `codealmanac setup --target all` flag and is easy to miss since it's mechanically separate from the wizard screens. +- **Windows: resolve executables via `shutil.which()` in `SubprocessCommandRunner`** (`integrations/command.py`) before calling `subprocess.run` — see Windows compatibility above. Shared fix benefiting `codex`/`claude`/`opencode` `check()` alike, not an OpenCode-only patch. + +## Out of scope + +- Sub-agent depth limits / call budgets on the OpenCode side — we consume whatever `parentID` OpenCode reports; we don't configure or cap OpenCode's own delegation behavior. + +## Design decisions + +- **Server lifecycle:** adapter starts `opencode serve` itself scoped to the run (own port, own lifetime) rather than assuming a long-running user server — matches "no CLI command blocks a terminal on an agent" but keeps the adapter self-contained; avoids port/state collisions across concurrent runs. **Confirmed by spike:** `opencode serve --port 0` binds an OS-assigned ephemeral port (verified two concurrent instances got different ports); parse the bound port from the server's "opencode server listening on http://127.0.0.1:PORT" startup line. +- **Structured contracts over text scraping:** per `MANUAL.md` §4 ("Structured contracts before text scraping"), we consume OpenCode's typed SSE message parts directly — no regex/text parsing of CLI stdout, same as the Claude SDK adapter and unlike the Codex app-server's own necessarily-lower-level RPC framing. +- **Sub-agent tracing confirmed:** OpenCode sessions carry `parentID` on the session object and on `session.created`/`session.updated` events; a prior gap where `session.status` omitted `parentID` was fixed upstream (opencode issue #30043). This means `HarnessAgentTrace.parent_thread_id`/`child_thread_id`/`child_thread_ids` map directly onto OpenCode's own session hierarchy — no need to reconstruct parent/child relationships from tool-call timing. +- **Transcript storage differs from Claude/Codex — confirmed by spike, not JSON files at all.** Claude/Codex write append-only JSONL logs under `~/.claude/projects/**/*.jsonl` / `~/.codex/sessions/**/*.jsonl`, scanned by `ClaudeTranscriptDiscoveryAdapter`/`CodexTranscriptDiscoveryAdapter`. OpenCode (`opencode-ai@1.17.15`, spiked 2026-07-08) instead persists everything to one SQLite database at `~/.local/share/opencode/opencode.db` (WAL mode — `-wal`/`-shm` files alongside it). Relevant tables: `project` (`id`, `worktree` = real absolute cwd), `session` (`id`, `project_id` FK, `parent_id` nullable FK — sub-agent hierarchy is a real column, not something to infer — `directory`, `model` as JSON text), `message` (`id`, `session_id` FK, `data` JSON envelope: role/agent/model/parentID, **no content text**), `part` (`id`, `message_id` FK, `data` JSON — the actual text/reasoning/tool/step content, one row per content chunk). `cwd` is inline on `session.directory` (and duplicated on `project.worktree`) — no cross-file indirection needed, simpler than the file-tree model this plan originally assumed. Full detail and the confirmed query shape are in "Spike findings" above. This is not a documented public API — the schema had 10+ dated migrations already applied — so treat unexpected shape as a soft skip per session rather than a hard `sync` failure, and add a schema-shape smoke test that fails loudly if it drifts. +- **OpenCode model selection: confirmed structured (`providerID`+model id), not a flat string** — curate an opaque-string allowlist and split it, don't add new request fields. Spike confirmed `POST /session` wants `model: {id, providerID, variant}` and `POST /session/{id}/message` wants `model: {providerID, modelID}` (note the `id`/`modelID` key-naming inconsistency between the two endpoints — add a smoke test so a future opencode release changing this is caught, not silently swallowed). Recommended approach unchanged from before the spike: curate a short allowlist of opaque `"provider/model"` strings, plug them into the existing `HARNESS_MODELS`/`CONTROLLED_HARNESS_MODELS`/`RunHarnessRequest.model` shape unchanged, and split on the adapter's internal boundary right before building the `POST /session`/`POST /session/{id}/message` payloads — additive, no new fields on `RunHarnessRequest`. Bonus: `GET /config/providers` on a running server returns every currently-configured provider with its available models/capabilities as structured JSON — a fetched-not-hardcoded allowlist is possible later if the adapter ends up needing a server for `check()` anyway (see the `check()` note in Spike findings), but isn't required to ship v1. +- **AGENTS.md path differs from Codex's convention:** OpenCode reads a global `~/.config/opencode/AGENTS.md` (not `~/.codex/AGENTS.md`), and falls back to `~/.claude/CLAUDE.md` if that file is absent — which is exactly the file `integrations/setup/claude.py` already writes a managed import line into. There's an open upstream bug (opencode#22020) about this precedence being unreliable when a project-level AGENTS.md also exists, so this plan does **not** rely on that fallback: `SetupTarget.OPENCODE` writes its own explicit `~/.config/opencode/AGENTS.md` managed block, same pattern as `integrations/setup/codex.py`. + +## File changes + +| File | Change | +|---|---| +| `src/codealmanac/services/harnesses/kinds.py` | add `HarnessKind.OPENCODE = "opencode"` | +| `src/codealmanac/services/config/models.py` | add `HarnessKind.OPENCODE` to `HARNESS_MODELS`, `DEFAULT_HARNESS_MODELS`, **and `CONTROLLED_HARNESS_MODELS`** (three registries, not two) | +| `src/codealmanac/integrations/harnesses/opencode/__init__.py` | new | +| `src/codealmanac/integrations/harnesses/opencode/adapter.py` | new — `OpencodeHarnessAdapter` | +| `src/codealmanac/integrations/harnesses/opencode/*` | new — server-client + event-mapping modules (named at implementation time) | +| `src/codealmanac/integrations/harnesses/__init__.py` | register `OpencodeHarnessAdapter()` in `default_harness_adapters()` | +| `src/codealmanac/cli/dispatch/setup_wizard/options.py` | replace binary if/else with `HARNESS_ORDER`/ordered-`SetupTarget`-tuple-driven `runner_options`, `runner_for_index`, `runner_index`, `target_options`, `targets_for_index`, `target_default_index`; **also generalize `parse_setup_targets()`'s `"all"` case, and add OpenCode entries to `MODEL_LABELS`/`MODEL_DETAILS`/`RUNNER_LABELS`** | +| `src/codealmanac/services/setup/models.py` | add `SetupTarget.OPENCODE` | +| `src/codealmanac/services/setup/requests.py` | add `SetupTarget.OPENCODE` to `DEFAULT_SETUP_TARGETS` — plain `codealmanac setup` with no `--target` flag installs Codex + Claude + OpenCode instructions, same tier as the existing two | +| `src/codealmanac/integrations/setup/opencode.py` | new — `install_opencode_instructions`/`uninstall_opencode_instructions`, mirrors `integrations/setup/codex.py`, writes `~/.config/opencode/AGENTS.md` | +| `src/codealmanac/integrations/setup/instructions.py` | add `SetupTarget.OPENCODE` branch to `install_target`/`uninstall_target` | +| `tests/test_opencode_adapter.py` | new | +| test coverage for `integrations/setup/opencode.py` | new — mirror whatever file currently tests `integrations/setup/codex.py` | +| `src/codealmanac/services/sources/models.py` | add `TranscriptApp.OPENCODE` | +| `src/codealmanac/integrations/sources/transcripts/opencode.py` | new — `OpencodeTranscriptDiscoveryAdapter`; opens `~/.local/share/opencode/opencode.db` read-only (stdlib `sqlite3`), queries `session`/`project` for candidates and `message`/`part` (joined, ordered by `time_created`) for content — confirmed shape in "Spike findings" | +| `src/codealmanac/integrations/sources/transcripts/__init__.py` | register `OpencodeTranscriptDiscoveryAdapter()` in `default_transcript_discovery_adapters()` | +| `src/codealmanac/integrations/sources/transcripts/runtime.py` and/or a new OpenCode-specific `SourceRuntimeAdapter` | **new — extend or branch transcript *reading* (not just discovery) so `sync`/ingest can actually render OpenCode session content; today `TranscriptSourceRuntimeAdapter` is JSONL-file-only and is the sole registered `SourceRuntimeAdapter`. Same SQLite `message`/`part` query as the discovery adapter, filtered to one `session_id`.** | +| `src/codealmanac/cli/dispatch/sync.py` | add `TranscriptApp.OPENCODE` to `parse_sync_apps`'s default tuple | +| `tests/test_transcript_discovery.py` (or new `test_opencode_transcript_discovery.py`) | new OpenCode coverage | +| `tests/` (runtime/reading coverage) | **new — a session with OpenCode's real content shape resolves to readable `TranscriptRuntimeEntry`s via `sync`, not just via discovery** | +| `src/codealmanac/cli/render/brand.py` | consider — add OpenCode entry to `BRAND_COLORS`; cosmetic only, degrades gracefully without it | +| `src/codealmanac/integrations/command.py` | **Windows fix — resolve `command` via `shutil.which()` in `SubprocessCommandRunner.run()` before `subprocess.run`; shared across all three harnesses' `check()`, not OpenCode-only** | + +## Test coverage + +- `check()`: not-installed (FileNotFoundError on `opencode --version`, no server touched), server-fails-to-start (bounded timeout → "not available", not a hang), `GET /config/providers` returns empty `providers` list (not authenticated), non-empty list (authenticated), server started successfully but is terminated afterward regardless of outcome — this has more cases than `test_codex_adapter.py`'s readiness tests since `check()` now has its own start/stop server lifecycle instead of a single subprocess call. +- `run()`: prompt → session → event stream → `HarnessRunResult`, using a fake/stubbed SSE source (no real `opencode` process in unit tests, consistent with how the Codex/Claude adapters are tested). +- Event mapping: one test per OpenCode message-part type → expected `HarnessEventKind` (text, tool-call, tool-result, file-edit, usage). +- Sub-agent trace: a session with `parentID` set maps to `HarnessAgentTrace.parent_thread_id` correctly. +- `HarnessesService` registration: adapter is discoverable via `HarnessKind.OPENCODE` once registered (extends existing `test_harnesses_service.py` coverage, no new test file needed there). +- **Schema-shape smoke test:** a fixture-built `opencode.db` (or a recorded real one) with known `project`/`session`/`message`/`part` rows round-trips through discovery and reading to expected `TranscriptCandidate`/`TranscriptRuntimeEntry`s. Since this schema isn't a documented public contract (confirmed 10+ dated migrations already applied — see Spike findings), this test's real job is to fail loudly the day a future `opencode-ai` upgrade changes column names or table shape, rather than have `sync` silently skip every OpenCode session. +- **`SubprocessCommandRunner` Windows resolution:** a test that `shutil.which()` is consulted before `subprocess.run` (e.g. stub `shutil.which` and assert the resolved path is what gets executed) — covers the shared fix in `integrations/command.py`, exercised through all three harnesses' `check()` tests, not just OpenCode's. +- **Missing OpenCode data directory degrades gracefully:** `OpencodeTranscriptDiscoveryAdapter.discover()` against a `home` with no `opencode.db` at all returns zero candidates, not an exception — the safeguard for the still-unverified Windows path question above. + +## Next steps after this plan + +1. Build against this plan. +2. Review pass (bugs/omissions) → separate fixes commit, per `MANUAL.md` §6 slice discipline. +3. **Windows verification pass** — the one item this plan can't close from a macOS-only spike (see Windows compatibility): confirm the OpenCode data-directory paths on an actual Windows machine (or a `windows-latest` CI run) before considering cross-platform parity shipped, not just merged. +4. Open the PR referencing issue #9. diff --git a/docs/plans/2026-07-08-opencode-harness-slice-1.md b/docs/plans/2026-07-08-opencode-harness-slice-1.md new file mode 100644 index 00000000..08689e53 --- /dev/null +++ b/docs/plans/2026-07-08-opencode-harness-slice-1.md @@ -0,0 +1,101 @@ +# OpenCode Harness — Slice 1: Harness Adapter + +Slice 1 of 3 for `docs/plans/2026-07-07-opencode-harness.md` (tracks issue #9). This slice ships the `HarnessAdapter` itself — `init`/`ingest`/`garden` can run through OpenCode. Slice 2 (setup wizard onboarding) and Slice 3 (`sync` transcript discovery/reading) build on top of this slice but are out of scope here. + +All design decisions below were confirmed by a live spike against `opencode-ai@1.17.15` on 2026-07-08 (real `opencode serve`, real session, real tool call). Full evidence trail — including the storage-shape and permission-behavior investigation not needed for this slice — lives in the master plan's "Spike findings" and "Windows compatibility" sections; this doc pulls only what Slice 1 needs to build. + +## Read before coding + +1. `MANUAL.md` — seam-vs-machinery rule, structured-contracts-over-text-scraping (§4) +2. `docs/plans/2026-07-07-opencode-harness.md` — full context, in particular "Spike findings" and "Windows compatibility" +3. `src/codealmanac/services/harnesses/ports.py` — the `HarnessAdapter` protocol this must implement +4. `src/codealmanac/services/harnesses/events.py` / `models.py` — target `HarnessEvent`/`HarnessRunResult`/`HarnessAgentTrace` shapes +5. `src/codealmanac/integrations/harnesses/codex/adapter.py` + `app_server.py` — closest structural precedent: adapter talks to a local server process it starts itself, not stdout scraping. Mirror `process.terminate()`-in-`finally` for server lifecycle. +6. `src/codealmanac/integrations/harnesses/claude/adapter.py` + `stream.py` — precedent for turning a rich structured event stream into `HarnessEvent`s +7. `src/codealmanac/integrations/command.py` — shared `CommandRunner`/`SubprocessCommandRunner`, needs the Windows fix below + +## Scope + +### Registration (mechanical, do first) + +- `services/harnesses/kinds.py`: add `HarnessKind.OPENCODE = "opencode"` +- `services/config/models.py`: add `HarnessKind.OPENCODE` to **three** registries — `HARNESS_MODELS`, `DEFAULT_HARNESS_MODELS`, and `CONTROLLED_HARNESS_MODELS` (the last one is easy to miss; it's a separate flat frozenset gating `HarnessConfig.controlled_model`, not the same dict as the other two). Model values are opaque `"provider/model"` strings, e.g. `"opencode/deepseek-v4-flash-free"` — curate a short allowlist, don't invent new fields on `RunHarnessRequest`. + +### `integrations/harnesses/opencode/` package + +- `adapter.py` — `OpencodeHarnessAdapter` implementing `check()`/`run()`, same shape as `CodexAppServerHarnessAdapter`/`ClaudeSdkHarnessAdapter`. +- A server-lifecycle module (name TBD, e.g. `server.py`) owning: start `opencode serve --port 0`, parse the bound port from the "opencode server listening on http://127.0.0.1:PORT" startup line (via a background reader thread + `queue.Queue`, not a blocking `readline()` loop — `Popen.stdout.readline()` blocks indefinitely regardless of a wall-clock deadline check between calls, and this needs to be cross-platform since `select()` on pipes isn't available on Windows), wait for readiness, and stop it (`process.terminate()` in `finally`, mirroring `app_server.py:164`). **This one primitive is used by both `check()` and `run()`** — `check()` starts it, makes one HTTP call, tears it down immediately; `run()` starts it, keeps it up for the whole session, tears down when done. +- **No SSE/stream module in this slice — corrected after a supplementary spike.** Original scope (and the master plan) assumed `run()` would consume `GET /event` SSE. Live-tested this before writing the mapping code: across four attempts, the SSE stream never delivered the assistant's reply events (only the user-turn-started events), while the synchronous `POST /session/{id}/message` call reliably returned the complete `{info, parts}` payload every time. **`run()` uses the POST response's `parts` array directly, mapped to `HarnessEvent`s after the call returns — no SSE, no threading, no queue needed for `run()` itself** (the server-lifecycle module's own startup-detection still needs its reader thread, that's unrelated). Full evidence trail is in the master plan's "SSE stream is not reliable for turn completion" spike finding. A part-mapping module (name TBD, e.g. `parts.py`) does the text/tool-call/tool-result/file-edit/usage → `HarnessEvent` translation instead of a stream module. Confirmed live part shapes: `{"type":"text","text":"..."}`, `{"type":"reasoning","text":"...","time":{...}}`, `{"type":"tool","tool":"bash","callID":"...","state":{"status":"completed","input":{...},"output":"...","metadata":{...},"title":"..."}}`, `{"type":"step-start"/"step-finish","reason":"stop"/"tool-calls","tokens":{...},"cost":...}`, `{"type":"patch","files":[...]}`. + +### `check()` + +1. `opencode --version` via `CommandRunner` — not-installed check, fail fast without touching a server if this fails. +2. If installed: start the ephemeral server (short timeout — a few seconds), call `GET /config/providers`, treat a non-empty `providers` list as authenticated. Empty list or server-start failure/timeout → not available. Always terminate the server regardless of outcome. + +**Why not `opencode auth list`:** confirmed live it exits 0 even with zero configured providers and prints decorative TUI text ("0 credentials") — not a parseable signal, and text-scraping it would violate MANUAL.md §4. **Why not read `auth.json`+env vars instead of a server call:** the free `opencode` "Zen" provider needs neither a credentials file nor an env var (`"options":{"apiKey":"public"}`) — a file/env-only check would falsely report a working setup as "not ready." + +### `run()` + +1. Start the session server scoped to `request.cwd` (via the shared server-lifecycle module). +2. `POST /session?directory=` with an explicit `permission: [{permission: "*", pattern: "*", action: "allow"}]` ruleset for defense-in-depth (confirmed live the *unconfigured* default already runs tool calls non-interactively — zero rows ever written to the `permission` table for a real `bash` tool call — but set it explicitly anyway rather than relying on an undocumented default). +3. `POST /session/{id}/message` (blocking, bounded `httpx` timeout — e.g. matching `CLAUDE_RUN_TIMEOUT_SECONDS`'s role for the Claude adapter — so an unexpected hang fails the run cleanly instead of hanging forever, in place of the background permission-poll thread the earlier version of this doc planned) with `parts: [{type: "text", text: request.prompt}]` and `model: {providerID, modelID}` split from the allowlisted `"provider/model"` string at this boundary (note the key-naming inconsistency: `POST /session` create wants `model: {id, providerID, variant}`, `POST /session/{id}/message` wants `model: {providerID, modelID}` — different key names for the same concept between the two endpoints). +4. **No SSE consumption** — corrected by spike, see "Scope" above. Map the POST response's `parts` array directly to `HarnessEvent`s once the call returns. +5. Map OpenCode's session `parentID`/`parent_id` → `HarnessAgentTrace.parent_thread_id`/`child_thread_id` for sub-agent runs — confirmed a first-class relational column on the session, not something to reconstruct from event timing. +6. Return `HarnessRunResult`. + +### Windows fix (shared, bundle into this slice) + +- `integrations/command.py::SubprocessCommandRunner.run()`: resolve `command` via `shutil.which(command)` before calling `subprocess.run`. On Windows, npm-installed CLIs (`opencode`, and already `codex`/`claude` today) are `.cmd`/`.ps1` shims that `subprocess.run(shell=False)` can't launch directly — a well-known Python-on-Windows gotcha, invisible until now because this repo's CI only runs `ubuntu-latest`. Fixing it here benefits all three harnesses' `check()`, not just OpenCode's — land it as the general fix, not an OpenCode-local workaround. + +### Registration + +- `integrations/harnesses/__init__.py::default_harness_adapters()` — add `OpencodeHarnessAdapter()`. + +## Out of scope (deferred to later slices / not this plan) + +- Setup wizard generalization, `AGENTS.md` installer, `SetupTarget.OPENCODE` — Slice 2. +- `sync` transcript discovery/reading, `TranscriptApp.OPENCODE`, the SQLite-backed reader — Slice 3. +- Sub-agent depth limits / call budgets on OpenCode's side — we consume whatever `parentID` it reports, we don't cap its own delegation behavior. + +**Two scope reductions made during implementation, disclosed for the review pass rather than silently dropped:** + +- **Sub-agent session-tree traversal is not implemented.** `HarnessAgentTrace`/child-session discovery (a session's `parentID` pointing at ours, created when the model invokes an OpenCode "task"/agent-delegation tool) was scoped as confirmed-available in the master plan, based on the OpenAPI schema alone — but no spike ever actually triggered that code path (all spikes used direct `bash` tool calls, never a sub-agent-spawning prompt). Implementing session-tree traversal (list sessions, filter by `parent_id`, recursively fetch/map each child) on an unverified assumption risked shipping speculative code for a flow I have zero live evidence about. `run()` in this slice handles the single root session correctly and completely; multi-session/sub-agent runs will come back with only the root session's events until this is spiked and built as a follow-up (fixes-review or a dedicated slice, whichever the review pass recommends). +- **Test depth for the subprocess/HTTP boundary is lighter than the Codex precedent.** `test_codex_app_server_adapter.py` spawns a real fake `codex` executable and drives the actual `CodexAppServerClient` through real stdio JSON-RPC. `test_opencode_adapter.py` instead unit-tests `OpencodeClient`/`server.py` by monkeypatching `start_opencode_server`/`get_providers`/`create_session`/`post_message` and testing `_wait_for_listening`'s queue/timeout logic directly against a fake `Popen`-shaped object — real coverage of the mapping/parsing/timeout logic (the highest-bug-risk code), but the actual `subprocess.Popen(...)` → real pipe → real `.terminate()` lifecycle is only exercised by hand, not by CI. Consider a fake-`opencode`-executable integration test mirroring the Codex one as a should-fix if the review pass agrees it's worth the added complexity. + +## Design decisions + +- **Server lifecycle:** `--port 0` confirmed to give an OS-assigned ephemeral port (two concurrent instances got different ports in the spike) — avoids fixed-port collisions across concurrent runs. One server per `run()` call, scoped to that run only. +- **Structured contracts over text scraping (MANUAL.md §4):** consume typed SSE message parts directly; the only plain-text parse in this slice is extracting the bound port from the server's one-line startup message, which is an acceptable one-time readiness signal, not per-event scraping. +- **Model shape:** confirmed structured (`{providerID, modelID}`/`{id, providerID, variant}`), not opaque — curate an allowlist of `"provider/model"` strings and split at the adapter boundary right before building request payloads. Add a smoke test for the `id`/`modelID` key-naming inconsistency between the two endpoints so a future opencode release changing it is caught, not silently swallowed. +- **Permission/non-interactive behavior:** confirmed default-safe via the HTTP API (unlike the TUI), but pass an explicit allow-ruleset anyway and keep a defensive permission-poll fallback — belt-and-suspenders, not paranoia-driven machinery. +- **`check()` cost:** accepted that OpenCode's `check()` is heavier than Codex/Claude's (spins up a server vs. one subprocess call) because the free-tier provider makes file/env-only checks unreliable. Revisit later if this proves too slow/flaky in practice. + +## File changes + +| File | Change | +|---|---| +| `src/codealmanac/services/harnesses/kinds.py` | add `HarnessKind.OPENCODE = "opencode"` | +| `src/codealmanac/services/config/models.py` | add `HarnessKind.OPENCODE` to `HARNESS_MODELS`, `DEFAULT_HARNESS_MODELS`, `CONTROLLED_HARNESS_MODELS` | +| `src/codealmanac/integrations/harnesses/opencode/__init__.py` | new | +| `src/codealmanac/integrations/harnesses/opencode/adapter.py` | new — `OpencodeHarnessAdapter` | +| `src/codealmanac/integrations/harnesses/opencode/server.py` (name TBD) | new — shared ephemeral server start/stop + `POST /session`, used by both `check()` and `run()` | +| `src/codealmanac/integrations/harnesses/opencode/parts.py` (name TBD) | new — `POST /session/{id}/message` response `parts` → `HarnessEvent` mapping (no SSE, see Scope) | +| `src/codealmanac/integrations/harnesses/__init__.py` | register `OpencodeHarnessAdapter()` in `default_harness_adapters()` | +| `src/codealmanac/integrations/command.py` | Windows fix — resolve `command` via `shutil.which()` in `SubprocessCommandRunner.run()` | +| `tests/test_opencode_adapter.py` | new | + +## Test coverage + +- `check()`: not-installed (`FileNotFoundError` on `opencode --version`, no server touched), server-fails-to-start (bounded timeout → "not available", not a hang), `GET /config/providers` returns empty `providers` (not authenticated), non-empty (authenticated), server always terminated regardless of outcome. +- `run()`: prompt → session create → blocking message POST → `HarnessRunResult`, using a fake/stubbed HTTP client (no real `opencode` process in unit tests, consistent with how Codex/Claude adapters are tested). +- Part mapping: one test per OpenCode part type → expected `HarnessEventKind` (text, reasoning, tool-call/tool-result, patch/file-edit, step-finish/usage). +- Sub-agent trace: a session with `parentID` set maps to `HarnessAgentTrace.parent_thread_id` correctly. +- Model-string splitting: allowlisted `"provider/model"` string produces the correct `{providerID, modelID}`/`{id, providerID, variant}` payload shape for both endpoints. +- `HarnessesService` registration: adapter is discoverable via `HarnessKind.OPENCODE` once registered (extends existing `test_harnesses_service.py`, no new file needed there). +- `SubprocessCommandRunner` Windows resolution: `shutil.which()` is consulted before `subprocess.run` (stub it, assert the resolved path is what gets executed). + +## Next steps after this slice + +1. Build against this doc. +2. Review pass (bugs/omissions) → `docs/plans/fixes-opencode-harness-slice-1-review.md`, shipped as its own commit. +3. Move to Slice 2 (setup wizard) — write `docs/plans/2026-07-08-opencode-harness-slice-2.md` next, not before this slice is reviewed and fixed. diff --git a/docs/plans/2026-07-08-opencode-harness-slice-2.md b/docs/plans/2026-07-08-opencode-harness-slice-2.md new file mode 100644 index 00000000..d75a55a2 --- /dev/null +++ b/docs/plans/2026-07-08-opencode-harness-slice-2.md @@ -0,0 +1,115 @@ +# OpenCode Harness — Slice 2: Setup Wizard + Onboarding + +Slice 2 of 3 for `docs/plans/2026-07-07-opencode-harness.md` (tracks issue #9). Builds on Slice 1 (`HarnessKind.OPENCODE` registered, `OpencodeHarnessAdapter` runnable) — this slice makes OpenCode selectable in the setup wizard/CLI flags and installs its `AGENTS.md`-equivalent instructions file. Slice 3 (`sync` transcript discovery) is still out of scope. + +Lower-risk than Slice 1: no live spiking needed here — this is generalizing existing binary Codex/Claude logic that already sits behind a clean `HarnessKind`-keyed seam (`HARNESS_MODELS`, `DEFAULT_HARNESS_MODELS` are already dicts, not if/else). One inherited caveat carries over from Slice 1's "Windows compatibility" section: the `~/.config/opencode/` directory itself is confirmed live (server startup logs showed `opencode serve` loading `~/.config/opencode/config.json` etc. during the Slice 1 spike, on macOS) but the specific `AGENTS.md` filename/precedence *within* that directory was not independently verified by a live spike — it's sourced from the master plan's citation of upstream issue opencode#22020, not tested here. Same graceful-degrade posture as Slice 1: if wrong, the managed-block writer creates the file at the (possibly wrong) path without crashing; it just wouldn't be read by OpenCode. Not re-verifying this in Slice 2 — flagging it for whoever eventually does the Windows verification pass Slice 1 deferred. + +## Read before coding + +1. `docs/plans/2026-07-07-opencode-harness.md` — "Design decisions" (AGENTS.md path rationale) and "Windows compatibility" +2. `src/codealmanac/cli/dispatch/setup_wizard/options.py` — the binary logic to generalize (`runner_options`, `runner_for_index`, `runner_index`, `target_options`, `targets_for_index`, `target_default_index`, `parse_setup_targets`, plus the `MODEL_LABELS`/`RUNNER_LABELS`/`MODEL_DETAILS` dicts) +3. `src/codealmanac/integrations/setup/codex.py` — the installer this slice's `opencode.py` mirrors (simpler than Claude's, since OpenCode doesn't need Claude's import-line/fallback pattern) +4. `src/codealmanac/integrations/setup/instructions.py` — thin dispatcher, **must stay ≤ 80 lines and avoid direct file I/O** (enforced by `tests/test_architecture.py::test_setup_instruction_adapter_stays_split_by_target_family` — read this test before editing the file) +5. `src/codealmanac/services/setup/models.py` / `requests.py` — `SetupTarget`, `DEFAULT_SETUP_TARGETS` +6. `src/codealmanac/cli/render/brand.py` — `BRAND_COLORS` (cosmetic, degrades gracefully without an entry) + +## Scope + +### Generalize `cli/dispatch/setup_wizard/options.py` + +Currently every one of these branches on a literal `index == 1` or `harness == HarnessKind.CLAUDE`: + +- `target_options()` — hardcoded `("Codex + Claude", "Codex only", "Claude only")` triplet +- `runner_options()` — two explicit `runner_option(...)` calls +- `target_default_index()` / `targets_for_index()` — `if targets == (SetupTarget.CODEX,): return 1` / `if index == 1: return (SetupTarget.CODEX,)` +- `runner_for_index()` / `runner_index()` — `if index == 1 → CLAUDE else → CODEX` +- `parse_setup_targets()` — `"all" → (SetupTarget.CODEX, SetupTarget.CLAUDE)`, hardcoded, separate from the wizard screens (easy to miss — flagged in the master plan's review findings) + +Replace with one ordered tuple each harness/target derives its index from by position, so a 4th harness is a one-line addition, not a new branch: + +```python +HARNESS_ORDER: tuple[HarnessKind, ...] = ( + HarnessKind.CODEX, + HarnessKind.CLAUDE, + HarnessKind.OPENCODE, +) +TARGET_ORDER: tuple[SetupTarget, ...] = ( + SetupTarget.CODEX, + SetupTarget.CLAUDE, + SetupTarget.OPENCODE, +) +``` + +- `runner_options()`: `tuple(runner_option(kind, by_kind.get(kind), SHORTCUTS[kind]) for kind in HARNESS_ORDER)`. +- `runner_for_index(index)` / `runner_index(harness)`: `HARNESS_ORDER[index]` / `HARNESS_ORDER.index(harness)`, falling back to index 0 / `HARNESS_ORDER[0]` on out-of-range the same way the current code silently falls back to Codex (preserve that behavior, don't raise). +- `target_options()`: generate the "all combinations" and "N only" choices from `TARGET_ORDER` rather than a hand-written triplet. **Design call:** the current wizard only ever offers a *combined* "Codex + Claude" option plus two "only" options — with three targets, "all combinations" would explode (7 non-empty subsets of 3 targets). Don't build a combinatorial picker; keep the wizard's existing shape of "everything" + "N only" per target: `("Codex + Claude + OpenCode", "Codex only", "Claude only", "OpenCode only")`. This is a deliberate, disclosed simplification, not a full generalization — revisit only if a future harness makes "everything" stop being the obviously-right bundled default. +- `targets_for_index()` / `target_default_index()`: index 0 → `TARGET_ORDER` (all), index N → `(TARGET_ORDER[N-1],)` for N in 1..len(TARGET_ORDER). +- `parse_setup_targets()`: `"all"` → `TARGET_ORDER` (now includes OpenCode) instead of the hardcoded two-tuple. +- `MODEL_LABELS`/`MODEL_DETAILS`: add entries for all three `HARNESS_MODELS[HarnessKind.OPENCODE]` model strings (`opencode/deepseek-v4-flash-free`, `opencode/mimo-v2.5-free`, `opencode/big-pickle` — see Slice 1's fixes doc for the confirmed-vs-run-to-completion distinction on these three). +- `RUNNER_LABELS`: add `HarnessKind.OPENCODE: "OpenCode"`. + +### `integrations/setup/opencode.py` (new) + +Mirrors `codex.py`, not `claude.py` — OpenCode gets its own explicit file, not an import-line-into-another-file pattern: + +```python +def install_opencode_instructions(home: Path, guide: str) -> InstructionChange: ... +def uninstall_opencode_instructions(home: Path) -> InstructionChange: ... +``` + +Writes a managed block to `home / ".config" / "opencode" / "AGENTS.md"` (confirmed directory location; see the caveat at the top of this doc for the "AGENTS.md" filename specifically). No override-path resolution like Codex's `resolve_codex_agents_path()` — that's specific to Codex's own `AGENTS.override.md` convention, not something OpenCode has (nothing in the master plan's research suggested an OpenCode equivalent; don't invent one). + +### `integrations/setup/instructions.py` + +Add one import and one branch each to `install_target`/`uninstall_target`: + +```python +if target == SetupTarget.OPENCODE: + return opencode.install_opencode_instructions(home, guide) +``` + +Must stay ≤ 80 lines and must not gain direct file I/O (`write_text`/`read_text`/`unlink(`) — those stay in `opencode.py`, matching the architecture test's constraint on this file. + +### `services/setup/models.py` / `requests.py` + +- `SetupTarget.OPENCODE = "opencode"`. +- `DEFAULT_SETUP_TARGETS = (SetupTarget.CODEX, SetupTarget.CLAUDE, SetupTarget.OPENCODE)` — plain `codealmanac setup` with no `--target` flag installs all three, same tier as the existing two. + +### `cli/render/brand.py` (cosmetic, consider not must-fix) + +Add `"OpenCode": ` to `BRAND_COLORS`. Degrades gracefully without it (`label_word()` falls back to plain styling), but cheap to add while already touching wizard labels. + +## Out of scope + +- `sync` transcript discovery — Slice 3. +- A combinatorial target picker (all 7 subsets of 3 targets) — see the `target_options()` design call above. +- Re-verifying the `AGENTS.md` path/precedence on Windows — deferred to the Slice 1 Windows verification pass. + +## File changes + +| File | Change | +|---|---| +| `src/codealmanac/cli/dispatch/setup_wizard/options.py` | `HARNESS_ORDER`/`TARGET_ORDER`-driven `runner_options`, `runner_for_index`, `runner_index`, `target_options`, `targets_for_index`, `target_default_index`, `parse_setup_targets`; add OpenCode entries to `MODEL_LABELS`/`MODEL_DETAILS`/`RUNNER_LABELS` | +| `src/codealmanac/services/setup/models.py` | add `SetupTarget.OPENCODE` | +| `src/codealmanac/services/setup/requests.py` | add `SetupTarget.OPENCODE` to `DEFAULT_SETUP_TARGETS` | +| `src/codealmanac/integrations/setup/opencode.py` | new — `install_opencode_instructions`/`uninstall_opencode_instructions` | +| `src/codealmanac/integrations/setup/instructions.py` | add `SetupTarget.OPENCODE` branch (stay ≤ 80 lines, no direct file I/O) | +| `src/codealmanac/cli/render/brand.py` | add `"OpenCode"` to `BRAND_COLORS` | +| `src/codealmanac/cli/parser/setup.py` | **found during implementation, not in original scope** — `SETUP_TARGETS = ("all", "codex", "claude")` and `--runner` `choices=("codex", "claude")` are a *second*, independent set of hardcoded string literals gating argparse itself, missed by the initial grep for `SetupTarget.CODEX`/`HarnessKind.CODEX` (this file uses raw strings, not the enum members). Without this fix, `codealmanac setup --target opencode`/`--runner opencode` would be rejected by argparse before ever reaching any of the code this slice otherwise generalized. Fixed by deriving both tuples from `SetupTarget`/`HarnessKind` directly instead of a second hardcoded copy. | +| `tests/test_setup_wizard_options.py` (new) | `HARNESS_ORDER`/`TARGET_ORDER` generalization coverage, `install_opencode_instructions`/`uninstall_opencode_instructions`, `FileInstructionInstaller` with `SetupTarget.OPENCODE` | + +## Test coverage + +- `runner_for_index`/`runner_index` round-trip for all three harnesses, including out-of-range index falling back to `HARNESS_ORDER[0]` (Codex) — preserves existing silent-fallback behavior. +- `target_options()` returns four options (all + one per target) with OpenCode included. +- `targets_for_index`/`target_default_index` round-trip for all four wizard positions. +- `parse_setup_targets("all")` returns all three targets; `parse_setup_targets("opencode")` returns `(SetupTarget.OPENCODE,)`. +- `install_opencode_instructions`/`uninstall_opencode_instructions`: fresh install, idempotent re-install (no change), uninstall removes the managed block, uninstall of a never-installed target is a no-op — mirrors whatever `test_cli.py`/existing coverage does for `install_codex_instructions`. +- `FileInstructionInstaller.install/uninstall` with `SetupTarget.OPENCODE` in the targets tuple. +- Architecture test (`test_setup_instruction_adapter_stays_split_by_target_family`) still passes unmodified — confirms `instructions.py` didn't grow file I/O or blow the line budget. + +## Next steps after this slice + +1. Build against this doc. +2. Review pass → `docs/plans/fixes-opencode-harness-slice-2-review.md`, own commit (per user: nothing committed until the full three-slice implementation is done, but the fixes still land as their own logical change). +3. Move to Slice 3 (`sync` transcript discovery/reading). diff --git a/docs/plans/2026-07-08-opencode-harness-slice-3.md b/docs/plans/2026-07-08-opencode-harness-slice-3.md new file mode 100644 index 00000000..1eb2b7c5 --- /dev/null +++ b/docs/plans/2026-07-08-opencode-harness-slice-3.md @@ -0,0 +1,121 @@ +# OpenCode Harness — Slice 3: Transcript Discovery + Reading (`sync`) + +Slice 3 of 3 for `docs/plans/2026-07-07-opencode-harness.md` (tracks issue #9). Final slice — `codealmanac sync` picks up OpenCode sessions the same way it already does Claude/Codex ones. Builds on Slice 1 (`HarnessKind.OPENCODE` runnable) and Slice 2 (onboarding); doesn't depend on either's code, only on the SQLite schema confirmed by Slice 1's spike. + +## The real design problem this slice has to solve + +This is the must-fix gap the master plan flagged repeatedly and Slices 1/2 explicitly deferred: `TranscriptSourceRuntimeAdapter` (`integrations/sources/transcripts/runtime.py`) is the *only* registered `SourceRuntimeAdapter`, and it assumes **one transcript file = one session** — `SourceRef.transcript` is a plain path string, `transcript_path()` resolves it to exactly one `Path`, and `path.is_file()` gates whether the session is readable at all. Claude/Codex satisfy this by construction (one JSONL file per session). OpenCode does not: confirmed by Slice 1's spike, every session for a project lives in the *same* shared `opencode.db`, disambiguated only by a `session_id` column, not by file identity. + +Traced the actual mechanics before designing around this (not assuming): + +- `SourceRef.transcript` is a **plain string**, not validated against real path syntax — `services/sources/address_transcript.py::resolve_transcript()` just does `raw.removeprefix("transcript:").strip()` and stores it verbatim. It only becomes a `Path` later, in `paths.py::transcript_path()`. This means the string after `"transcript:"` doesn't have to look like a real filesystem path — it just has to round-trip through whatever this slice's own code expects. +- `SourcesService.inspect_runtime()` (`services/sources/service.py:49-60`) already dispatches to the **first** `SourceRuntimeAdapter` in a list whose `supports(ref)` returns `True` — a multi-adapter seam that already exists and is already used for other source kinds (filesystem, git, GitHub, web each have their own adapter). Transcripts only ever had one adapter because Claude and Codex happen to share a format, not because the seam only supports one. + +**Resolution:** register a **second** `SourceRuntimeAdapter`, `OpencodeTranscriptSourceRuntimeAdapter`, registered *before* the existing generic `TranscriptSourceRuntimeAdapter` in `default_transcript_runtime_adapters()` (order is load-bearing — the generic one's `supports()` matches *any* `SourceKind.TRANSCRIPT` ref with no further discrimination, so it must go second or it silently swallows OpenCode refs too). Disambiguate sessions by encoding both the db path and the session id into the `transcript` string using an unambiguous separator (`::`, which can't appear in a real filesystem path on any OS this matters for), parsed via `rpartition` from the right so a `session_id` is always cleanly split off even if a path somehow contained the separator. `TranscriptCandidate.transcript_path` itself stays the *real*, honest `opencode.db` path for every OpenCode candidate (not a synthetic compound value) — the compound identifier only exists in the `"transcript:"`-prefixed address string built at the `sync_ingest_request()` boundary, which is already the one place that branches per-app (`sync_ingest_title()` right next to it already does `candidate.app.value`). + +Considered and rejected: putting the compound identifier into `transcript_path` itself (a `Path` object holding `"::"`) — technically works (no Pydantic validator forbids it) but makes a field named `transcript_path` lie about what it contains, for every reader of `TranscriptCandidate` including ones that have nothing to do with the runtime-inspection problem this is solving. + +## Read before coding + +1. `docs/plans/2026-07-07-opencode-harness.md` — "Spike findings" (SQLite schema, confirmed query shape) and this doc's design-problem section above. +2. `src/codealmanac/services/sources/service.py` — `SourcesService.inspect_runtime()`'s ordered-dispatch loop (the seam this slice extends) +3. `src/codealmanac/integrations/sources/transcripts/runtime.py`, `reader.py`, `models.py` (`TranscriptRuntimeEntry`) — the JSONL-shaped precedent; `TranscriptRuntimeEntry.line_number` is reusable as a per-entry ordinal despite the JSONL-sounding name (already noted in the master plan) +4. `src/codealmanac/integrations/sources/transcripts/claude.py` / `codex.py` — discovery adapter precedent; note `candidate_from_meta()` (`jsonlines.py`) assumes one-file-one-candidate via `Path.stat()` and does **not** fit this slice's one-db-many-candidates shape — don't force reuse, write a direct constructor instead (see Scope) +5. `src/codealmanac/services/sources/address_transcript.py` — confirms `SourceRef.transcript` is an unvalidated plain string +6. `src/codealmanac/workflows/sync/queue.py::sync_ingest_request()` — where the `"transcript:"` address string gets built; already branches per-`candidate.app` next door in `sync_ingest_title()` + +## Scope + +### `services/sources/models.py` + +- `TranscriptApp.OPENCODE = "opencode"` + +### `integrations/sources/transcripts/opencode_ref.py` (new, shared by discovery + runtime) + +```python +OPENCODE_TRANSCRIPT_SEPARATOR = "::" + +def format_opencode_transcript_ref(db_path: Path, session_id: str) -> str: ... +def parse_opencode_transcript_ref(value: str) -> tuple[Path, str] | None: ... +``` + +`parse_...` uses `rpartition(OPENCODE_TRANSCRIPT_SEPARATOR)` and returns `None` if the separator is missing or either side is empty — this `None` return is also `OpencodeTranscriptSourceRuntimeAdapter.supports()`'s discriminator (see below). + +### `integrations/sources/transcripts/opencode_db.py` (new, shared by discovery + runtime) + +- `open_opencode_db(path: Path) -> sqlite3.Connection` — read-only (`f"file:{path}?mode=ro"`, `uri=True`), so this never contends with a live `opencode serve` writing in WAL mode. +- `list_opencode_sessions(conn) -> list[JsonObject-ish row]` — `SELECT id, directory, time_updated FROM session`, used by discovery. +- `read_opencode_session_entries(conn, session_id) -> Iterator[TranscriptRuntimeEntry]` — the confirmed join: `message` + `part` ordered by `time_created`, decoding each `part.data` JSON blob into a `TranscriptRuntimeEntry` (reusing the existing model, not a new one). Map by part `type`: `text`/`reasoning` → `MESSAGE`, `tool` → `TOOL_CALL` (one entry) since OpenCode's tool parts already carry resolved input+output in one row, unlike Claude/Codex's separate call/result lines — no need to synthesize a second entry. `step-start`/`step-finish`/`patch` → skip or `META` at the implementer's judgment; match Slice 1's `parts.py` mapping semantically where it's the obvious analog, but don't force a shared function — the two serve different output types (`HarnessEvent` vs `TranscriptRuntimeEntry`) and forcing one function to produce both is an awkward abstraction for two call sites, not a real duplication problem. +- **Schema-drift resilience:** wrap all query execution in `try/except sqlite3.Error`, returning `()`/empty rather than raising — per the master plan's design decision, this schema isn't a documented public contract (10+ dated migrations already observed), so a future `opencode-ai` upgrade changing it should degrade `sync` to "found nothing this run," not crash it. + +### `integrations/sources/transcripts/opencode.py` (new — discovery adapter) + +```python +class OpencodeTranscriptDiscoveryAdapter: + app = TranscriptApp.OPENCODE + + def __init__(self, db_path: Path | None = None): ... + def discover(self, request: DiscoverTranscriptsRequest) -> tuple[TranscriptCandidate, ...]: ... +``` + +- Default `db_path`: `request.home / ".local" / "share" / "opencode" / "opencode.db"` — confirmed live on macOS during Slice 1's spike (the server's own startup log lines showed it loading config from this directory tree). **Same Windows caveat as Slices 1/2, not re-verified here:** carried forward from Slice 1's "Windows compatibility" section — inferred, not confirmed, that this resolves the same way on Windows. Missing-file degrades to zero candidates (`db_path.is_file()` check before opening), never an error. +- One `TranscriptCandidate` per `session` row: `transcript_path` = the real `db_path` (see design-problem section — kept honest, not compound), `cwd` = `normalize_path(Path(directory))`, `modified_at` = `session.time_updated` (epoch milliseconds, confirmed shape from the spike) converted via `datetime.fromtimestamp(ms / 1000, UTC)`, `size_bytes` = the whole database file's size. **Known imprecision, disclosed not hidden:** `size_bytes` reports the shared file's total size for every candidate, not that session's own share of it — the field is informational only downstream (confirm this at implementation time by checking callers), and querying a precise per-session byte count isn't worth the added complexity for a field with no behavioral consequence found. +- Does **not** call the existing `candidate_from_meta()` helper (`jsonlines.py`) — that helper's contract is "stat this one file, treat its mtime as the candidate's mtime," which is definitionally wrong when many candidates share one file. Constructs `TranscriptCandidate` directly. + +### `integrations/sources/transcripts/opencode.py` (same file — runtime adapter) + +```python +class OpencodeTranscriptSourceRuntimeAdapter: + def supports(self, ref: SourceRef) -> bool: + # True only for refs this adapter's own discovery/address-building + # produced — parse_opencode_transcript_ref returns None for a plain + # Claude/Codex-shaped path, so this never steals their refs. + ... + def inspect(self, request: InspectSourceRuntimeRequest) -> SourceRuntime: ... +``` + +### `integrations/sources/transcripts/__init__.py` + +- `default_transcript_discovery_adapters()`: add `OpencodeTranscriptDiscoveryAdapter()`. +- `default_transcript_runtime_adapters()`: **`(OpencodeTranscriptSourceRuntimeAdapter(), TranscriptSourceRuntimeAdapter())`** — OpenCode's adapter first. Getting this order backwards is a silent, hard-to-notice bug (OpenCode transcripts would report "no readable JSONL objects found" instead of erroring loudly), so this ordering gets its own explicit test, not just incidental coverage. + +### `workflows/sync/queue.py` + +- `sync_ingest_request()`: branch the `"transcript:..."` address string per `candidate.app` — OpenCode gets `format_opencode_transcript_ref(candidate.transcript_path, candidate.session_id)` appended after the prefix; Claude/Codex keep today's plain `str(candidate.transcript_path)`. + +### `cli/dispatch/sync.py` + +- `parse_sync_apps`'s default tuple: add `TranscriptApp.OPENCODE`. + +## Out of scope + +- Re-verifying the `~/.local/share/opencode/opencode.db` path on Windows — same deferred verification pass as Slices 1/2. +- Sub-agent session-tree traversal in transcript rendering (a session with `parent_id` set) — consistent with Slice 1's disclosed gap; a session's own `parent_id` isn't followed to fetch its parent/children's content in this slice either. + +## File changes + +| File | Change | +|---|---| +| `src/codealmanac/services/sources/models.py` | add `TranscriptApp.OPENCODE` | +| `src/codealmanac/integrations/sources/transcripts/opencode_ref.py` | new — `format_opencode_transcript_ref`/`parse_opencode_transcript_ref` | +| `src/codealmanac/integrations/sources/transcripts/opencode_db.py` | new — read-only SQLite open/query helpers, schema-drift-tolerant | +| `src/codealmanac/integrations/sources/transcripts/opencode.py` | new — `OpencodeTranscriptDiscoveryAdapter` + `OpencodeTranscriptSourceRuntimeAdapter` | +| `src/codealmanac/integrations/sources/transcripts/__init__.py` | register both adapters; runtime adapter order is load-bearing | +| `src/codealmanac/workflows/sync/queue.py` | `sync_ingest_request()` branches the transcript address string per `candidate.app` | +| `src/codealmanac/cli/dispatch/sync.py` | add `TranscriptApp.OPENCODE` to `parse_sync_apps`'s default tuple | +| `tests/test_opencode_transcripts.py` (new) | discovery, runtime inspection, ref round-trip, adapter-order regression, schema-drift/missing-db graceful degrade | + +## Test coverage + +- `format_opencode_transcript_ref`/`parse_opencode_transcript_ref` round-trip; `parse_...` returns `None` for a plain Claude/Codex-shaped path (no `::`). +- `OpencodeTranscriptDiscoveryAdapter.discover()` against a fixture SQLite db with known `project`/`session` rows → expected `TranscriptCandidate`s; missing db file → `()`, not an exception; a `session` table with an unexpected column (simulating schema drift) → `()`, not an exception. +- `OpencodeTranscriptSourceRuntimeAdapter.inspect()` against a fixture db with known `message`/`part` rows for one session → expected `TranscriptRuntimeEntry`s, content matches the spike's confirmed shapes (text, tool with resolved input+output). +- **`default_transcript_runtime_adapters()` ordering regression:** register both adapters as the real function does, build an OpenCode-shaped `SourceRef`, assert `SourcesService.inspect_runtime()` returns real content (not "no readable JSONL objects found" from the generic adapter winning by accident). +- `sync_ingest_request()` builds an OpenCode-shaped `"transcript:::"` string for an OpenCode candidate and a plain `"transcript:"` string for a Claude/Codex candidate in the same call. +- `parse_sync_apps()` default includes `TranscriptApp.OPENCODE`. + +## Next steps after this slice + +1. Build against this doc. +2. Review pass → `docs/plans/fixes-opencode-harness-slice-3-review.md`, own logical commit. +3. All three slices done — this closes out `docs/plans/2026-07-07-opencode-harness.md`'s scope except the explicitly-deferred Windows verification pass. Ready for the user's requested single combined commit once slice 3's review/fixes land. diff --git a/docs/plans/2026-07-09-opencode-harness-live-progress-and-hang-detection.md b/docs/plans/2026-07-09-opencode-harness-live-progress-and-hang-detection.md new file mode 100644 index 00000000..4a04bd35 --- /dev/null +++ b/docs/plans/2026-07-09-opencode-harness-live-progress-and-hang-detection.md @@ -0,0 +1,127 @@ +# OpenCode Harness — Live Progress + Hang Detection + +Follow-up to Slice 1 (`docs/plans/2026-07-08-opencode-harness-slice-1.md`), driven by real evidence from live testing on 2026-07-08/09: three separate hangs across two different models (`opencode/deepseek-v4-flash-free`, `openai/gpt-5.5`), all the same shape — a `glob` or `read` tool call gets scoped outside the target repo (nonexistent path, an unrelated huge directory, a binary file from a different project) and never returns. Confirmed via web search to be a known, currently-open upstream OpenCode issue, not something specific to this adapter or model choice — see "Evidence" below. + +**Goal:** replace the current "block for up to 900s, then report a generic timeout" behavior with (1) live event narration while the blocking call is in flight, and (2) precise, fast hang detection based on watching individual tool calls' age, not just total elapsed time. + +## Evidence + +- Three real hangs, traced via direct SQLite inspection during live testing: a `glob` call against a mangled nonexistent path, a `glob` call against `pattern: "**/.codealmanac/**"` scoped to the whole `~/dev/projects` directory instead of the target repo, and a `read` call against a binary file (`.venv/bin/codealmanac`) from an unrelated project. All three sat at `"status": "running"` indefinitely — no error, no completion, ever. +- Upstream confirmation (web search, 2026-07-09): [Issue #33541](https://github.com/anomalyco/opencode/issues/33541) ("Glob tool execute has no timeout"), [Issue #2102](https://github.com/sst/opencode/issues/2102), [Issue #20096](https://github.com/anomalyco/opencode/issues/20096) ("Non-task tools (bash, read, write, etc.) execute with no deadline"), [Issue #29294](https://github.com/anomalyco/opencode/issues/29294) (same pattern, shell tool). This is OpenCode's own tool-execution layer lacking any internal timeout — not fixable from our side, only detectable and worked around. + +## Design + +One mechanism serves both goals: a background thread that polls OpenCode's own SQLite database (the same one Slice 3's transcript reader already knows how to read) while the blocking `POST /session/{id}/message` call is in flight. + +### Why polling the DB, not retrying SSE + +Slice 1 already spiked OpenCode's `GET /api/event` SSE stream four times and found it never delivered assistant-turn events, only user-turn-started events (see master plan's "SSE stream is not reliable for turn completion"). Rather than re-bet on that, reuse what's already proven reliable in this codebase: the read-only SQLite querying built for Slice 3, exercised repeatedly and correctly throughout tonight's live debugging. Watching the *whole session tree* (root + dynamically-discovered sub-agent sessions) also needs cross-session visibility a single event stream wouldn't cleanly give us anyway. + +### What the watchdog does, every ~2 seconds + +1. Query all parts for the root session and every known child session (discovered dynamically — see below), diff against a `seen_part_ids: set[str]`, map new ones to `HarnessEvent`s via the existing `map_opencode_part()`, and emit them through `on_event` — this is the live-narration half. +2. Discover new children: watch for `{"type": "tool", "tool": "task", "state": {"metadata": {"sessionId": ...}}}` parts as they appear anywhere in the tree. On first sight of a new child session id, emit `AGENT_SPAWNED` (`HarnessAgentTrace(parent_thread_id=, child_thread_id=, prompt=, model=...)`) and start polling that session too. When that same tool part later transitions to `status: "completed"`/`"error"`, emit `AGENT_COMPLETED`/an `ERROR` event. +3. Track the age of every currently-open (`status: "running"`) tool-call part across the whole tree. If any single one exceeds `OPENCODE_STUCK_TOOL_CALL_SECONDS` (default 240s — see "Threshold" below) with no transition, that's the hang signal. + +### What happens when a hang is detected + +The main thread (blocked on `post_message`, running on its own sender thread so the watchdog can preempt it — see "Concurrency" below) raises `OpencodeStuckToolCallError` naming the specific tool, its input, and which session it belongs to. This unwinds through the `with start_opencode_server(...)` context manager, which terminates the server process — killing the in-flight HTTP connection the sender thread is blocked on, so that thread errors out and dies (daemon, so it never blocks process exit; its error is discarded, ours is authoritative). No true cross-thread cancellation needed. + +Error message shape: +> `OpenCode's "glob" tool call has been stuck for 240s+ with no response (session ses_...) — this is a known upstream OpenCode reliability issue (github.com/anomalyco/opencode/issues/33541), not specific to this run.` + +### Concurrency shape + +``` +main thread: + with start_opencode_server(...) as server: + session = create_session(...) + watchdog = OpencodeProgressWatchdog(root_session_id, root_actor, ...) + watchdog_thread = Thread(target=watchdog.run, args=(stop_event, events, on_event)) + watchdog_thread.start() + + sender_thread = Thread(target=lambda: post_message(...) -> message_result) + sender_thread.start() + while sender_thread.is_alive(): + if watchdog.stuck_reason is not None: + raise OpencodeStuckToolCallError(watchdog.stuck_reason) # unwinds -> server.terminate() + sender_thread.join(timeout=1.0) + stop_event.set(); watchdog_thread.join(timeout=5) + + response = message_result["response"] # or re-raise message_result["error"] + # Final reconciliation pass: diff response["parts"] against seen_part_ids + # once more before building the result, in case the watchdog's last poll + # cycle missed something in the last ~2s window. Dedup by part id makes + # this safe to run unconditionally. +``` + +### Threshold + +From tonight's real data: every genuine hang sat with zero progress for 10+ minutes before manual detection; normal step-to-step gaps during healthy runs (including the successful `gpt-5.5` run) were under 2 minutes. **`OPENCODE_STUCK_TOOL_CALL_SECONDS = 240`** (4 minutes) — long enough to not false-positive on legitimately slow tool calls, short enough to cut the worst case from 900s to well under a third of that. Configurable via env var, matching Codex's existing `CODEALMANAC_CODEX_APP_SERVER_*_TIMEOUT_MS` pattern (`CODEALMANAC_OPENCODE_STUCK_TOOL_CALL_SECONDS`). + +The existing `OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS = 900.0` stays as a final backstop (in case the watchdog itself has a bug or edge case) but should rarely be the thing that actually fires once this ships. + +## Scope + +### New: `integrations/opencode_paths.py` + +`OPENCODE_DB_RELATIVE_PATH = Path(".local/share/opencode/opencode.db")` — promoted out of `integrations/sources/transcripts/opencode.py` (which starts importing it from here instead of defining it locally) so `integrations/harnesses/opencode/` doesn't duplicate this fact. Small, deliberate exception to "don't add a module for one constant" — the alternative (two independently-maintained copies of a deployment-path assumption already flagged as Windows-unverified) is a worse drift risk than one three-line file. + +### New: `integrations/harnesses/opencode/progress.py` + +- `OpencodeProgressWatchdog` — owns the poll loop, `seen_part_ids`, known-children tracking, stuck-tool-call detection. Constructed with `(root_session_id, root_actor, db_path, poll_interval_seconds, stuck_after_seconds)`. +- Raw queries via `codealmanac.database.query_readonly_or_empty` directly (not importing `integrations/sources/transcripts/opencode_db.py` — that module's `TranscriptRuntimeEntry` mapping is a different domain shape than `HarnessEvent`; both packages depend on the shared `codealmanac.database` primitive independently, no cross-package coupling between `harnesses/` and `sources/`). +- `.stuck_reason: OpencodeStuckToolCall | None` — set by the poll loop, read by the main thread's wait loop. + +### `integrations/harnesses/opencode/state.py` + +Re-add `agent_parents: dict[str, str | None]` / `agent_labels: dict[str, str]` — removed as dead code in the Slice 1 review specifically with the note "add them back alongside whatever code actually populates them." This is that code. + +### `integrations/harnesses/opencode/client.py` + +`run_once()` rewritten per the concurrency shape above. New exception `OpencodeStuckToolCall(Exception)` (message includes tool name, input summary, session id, elapsed seconds, and the upstream issue URL) added to `failures.py`'s classification (new `opencode.stuck_tool_call` failure code) and to `client.py`'s `run()`/`check_providers()` except chains — though `check_providers()` doesn't call `run_once()`, so only `run()`'s chain needs it. + +### `integrations/harnesses/opencode/parts.py` + +No change to `map_opencode_part()` itself — reused as-is by the watchdog. New small helper: `is_task_spawn(part) -> tuple[str, str] | None` (returns `(child_session_id, prompt)` if the part is a `task` tool call carrying spawn metadata, else `None`) and `is_task_settled(part) -> HarnessToolStatus | None` (completed/error, for firing `AGENT_COMPLETED`). + +## Out of scope + +- Fixing OpenCode's own tool-execution timeout — not ours to fix, only detect around. +- Re-attempting SSE — explicitly not revisiting that decision here; the DB-polling approach supersedes needing it. +- Retrying automatically after a detected hang (e.g. auto-retry with a different model) — a policy decision one layer up (workflows/CLI), not this adapter's job; it should fail clearly and let the caller decide. + +## File changes + +| File | Change | +|---|---| +| `src/codealmanac/integrations/opencode_paths.py` | new — `OPENCODE_DB_RELATIVE_PATH`, shared by harnesses and sources/transcripts | +| `src/codealmanac/integrations/sources/transcripts/opencode.py` | import `OPENCODE_DB_RELATIVE_PATH` from the new shared module instead of defining it locally | +| `src/codealmanac/integrations/harnesses/opencode/progress.py` | new — `OpencodeProgressWatchdog` | +| `src/codealmanac/integrations/harnesses/opencode/state.py` | re-add `agent_parents`/`agent_labels` | +| `src/codealmanac/integrations/harnesses/opencode/client.py` | `run_once()` rewritten for the watchdog + sender-thread concurrency shape; new timeout/threshold constructor params | +| `src/codealmanac/integrations/harnesses/opencode/failures.py` | new `opencode.stuck_tool_call` classification | +| `src/codealmanac/integrations/harnesses/opencode/parts.py` | new `is_task_spawn`/`is_task_settled` helpers | +| `tests/test_opencode_adapter.py` | new coverage — see below | + +## Test coverage + +- `OpencodeProgressWatchdog` unit tests against a fixture SQLite db (same pattern as `test_opencode_transcripts.py`): new parts get mapped and emitted; a newly-appearing `task` part triggers `AGENT_SPAWNED` and starts tracking the child; a part transitioning `running` → `completed` between polls fires `AGENT_COMPLETED`; a part stuck at `running` past the threshold sets `stuck_reason`; a part stuck *under* the threshold does not. +- `run_once()` integration-style test: fake `post_message` that sleeps briefly then returns, with a fixture db seeded with parts appearing "during" that sleep (via a real background writer thread in the test, or a fake watchdog data source) — assert live events arrive via `on_event` before the final result, not just batched at the end. +- Hang-detection end-to-end: fake `post_message` that never returns (blocks on an `Event().wait()`) plus a fixture db with a part stuck past the threshold — assert `run()` returns a failed result mentioning the tool name and the upstream issue, well before the outer 900s timeout, and that the server's `terminate()` gets called (no leaked process in the test). +- Regression: existing `test_opencode_client_run_maps_parts_to_events` (no sub-agents, fast return) must still pass unchanged — the new machinery shouldn't change behavior for the simple, healthy-path case. + +## Live verification (2026-07-09, post-build) + +Built against this doc, then verified against a real `opencode serve` instance (not just fixtures) before considering it done: + +- **Found and fixed a real bug unit tests couldn't have caught:** the first live run showed the user's own prompt text arriving as a spurious `TEXT` event. The synchronous POST response never had this problem (it only ever returns the new assistant message's parts), but the watchdog polls the *whole* session's parts table, which includes the user's own input part too. Fixed by joining `message` and filtering to `role == "assistant"` in `_PARTS_QUERY`/`_poll_session` (`progress.py`). Added `test_watchdog_ignores_user_authored_parts` as a regression test, and updated the test fixture DB helpers to include a `message` table (the query is now an inner join, so fixtures without one silently returned zero rows — another thing only live testing surfaced). +- **Live narration confirmed working**, timestamped: events for a simple tool-call prompt arrived at 0.98s / 4.01s / 4.02s / 5.33s / 5.34s — genuinely incremental, not batched at the end. +- **Sub-agent spawn/complete confirmed working**, timestamped: `AGENT_SPAWNED` at 4.78s with correct parent/child trace, `AGENT_COMPLETED` at 6.32s tagged to the `HELPER` actor, and the sub-agent's own reply text correctly attributed to `actor=helper/Helper 1` rather than the root — full round-trip through a real `task` tool delegation. +- **Not independently re-verified live:** the stuck-tool-call detection path itself (unit-tested with a fixture past the threshold, and confirmed via the two real hangs that motivated this whole doc — but a *fresh* real hang wasn't reproduced on demand in this pass, since the underlying upstream bug is inherently non-deterministic). Real coverage exists from the incidents that led to this doc; a fresh live repro would need to wait for OpenCode to reproduce the same failure mode again. + +## Next steps + +1. ~~Build against this doc.~~ Done. +2. Review pass → `docs/plans/fixes-opencode-harness-live-progress-review.md`, own commit. +3. ~~Re-run the live `send_cloudaccess_emails` test end to end~~ — superseded by the live verification above (ran fresh, smaller live tests instead of repeating the full multi-minute wiki-build run). diff --git a/docs/plans/fixes-opencode-harness-final-review.md b/docs/plans/fixes-opencode-harness-final-review.md new file mode 100644 index 00000000..a9bd4bd6 --- /dev/null +++ b/docs/plans/fixes-opencode-harness-final-review.md @@ -0,0 +1,45 @@ +# Fixes — OpenCode Harness Final Full-Scope Review + +Last pass before push: one review agent looked at the entire OpenCode harness feature as a whole (all 4 prior slice/fix passes combined — adapter, setup wizard, transcript discovery, live-progress/hang-detection, plus the same-day model-allowlist fix), with explicit focus on cross-slice consistency and Windows compatibility, since that was an explicit, repeated requirement from the start of this work. Five findings. + +## 🔴 Fix — `start_opencode_server()` never resolved the executable through PATH/PATHEXT, unlike the sibling fix it was supposed to share + +**Finding:** `integrations/command.py`'s `SubprocessCommandRunner` was fixed earlier in this work specifically to resolve npm-installed `.cmd`/`.ps1` shims via `shutil.which()` before calling `subprocess.run` — with a comment claiming "fixes all three harnesses' check(), not just one." But `check()`'s fast path (`opencode --version`) is the *only* OpenCode call that actually goes through `SubprocessCommandRunner`. `server.py::start_opencode_server()` — used by both `check_providers()` and every real `run()` — called `subprocess.Popen` directly with the bare command name and no resolution. On Windows, this meant `opencode --version` could report success while every call that actually spawns a server (which is nearly everything real) would raise `FileNotFoundError`. + +**Fix:** applied the identical `shutil.which(command) or command` resolution in `server.py::start_opencode_server()`, mirroring `command.py`'s exact pattern, with a comment cross-referencing both the sibling fix and the specific failure mode it closes. Added two direct unit tests (`test_start_opencode_server_resolves_command_through_path`, `test_start_opencode_server_falls_back_to_bare_command_when_unresolved`) — no prior test exercised `start_opencode_server()`'s own `Popen` call at all (existing tests fake the whole function out). + +## 🟡 Fix — `append_event`/`emit_result` were three independently-maintained copies (Codex, Claude, OpenCode) + +**Finding:** identical function bodies existed in `codex/stream.py`, `claude/client.py`, and `opencode/client.py` — provider-agnostic event-plumbing that had been copy-pasted into each new harness instead of reusing a shared seam (the same pattern `fields.py` had already been correctly hoisted out of `codex/` for). + +**Fix:** created `integrations/harnesses/stream.py` (sibling to the existing `fields.py`) holding `append_event`, `append_events`, and `emit_result`. Deleted `codex/stream.py` and the local redefinitions in `claude/client.py` and `opencode/client.py`; all three now import from the shared module. No behavior change — pure de-duplication. + +## 🟡 Fix — generic transcript runtime adapter's correctness silently depended on registration order + +**Finding:** `TranscriptSourceRuntimeAdapter.supports()` (the generic/non-OpenCode adapter) matched *any* `SourceKind.TRANSCRIPT` ref with no further discrimination, while `OpencodeTranscriptSourceRuntimeAdapter.supports()` only matched its own `db-path::session-id` refs. The only thing preventing the generic adapter from also claiming OpenCode refs was that `default_transcript_runtime_adapters()` happened to list OpenCode's adapter first — a real regression test caught this today, but the underlying `supports()` was still an overly broad implementation that only worked by list-order accident. + +**Fix:** `TranscriptSourceRuntimeAdapter.supports()` (`integrations/sources/transcripts/runtime.py`) now explicitly excludes refs that `parse_opencode_transcript_ref` can parse, so the two adapters' `supports()` are disjoint rather than one being an accidental superset of the other. Updated `default_transcript_runtime_adapters()`'s comment to reflect that order is no longer load-bearing (the existing ordering test stays as defense-in-depth). Added `test_generic_runtime_adapter_rejects_opencode_shaped_refs_on_its_own`, which would fail if this adapter were ever asked first. + +## 🔵 Polish — documented why `OPENCODE_TRANSCRIPT_SEPARATOR = "::"` is safe next to a Windows drive-letter colon + +**Finding:** a future reader could reasonably worry that a two-character `"::"` separator collides with a Windows path's single drive-letter colon (`C:\Users\...`). It doesn't — `rpartition("::")` searches for the whole two-character substring, not a lone `:` — but nothing said so. + +**Fix:** added a one-line comment on `OPENCODE_TRANSCRIPT_SEPARATOR` in `opencode_ref.py`, and a real regression test (`test_parse_opencode_transcript_ref_handles_windows_drive_letter_colon`) round-tripping a `C:\...` path through `format_opencode_transcript_ref`/`parse_opencode_transcript_ref`, so the comment's claim is actually verified rather than asserted. + +## 🔵 Noted, no code change — `codealmanac setup` now writes `~/.config/opencode/AGENTS.md` unconditionally by default + +Consistent with how Codex/Claude instructions are already installed unconditionally on a bare `setup` run (opt-out via `--target`, not opt-in) — not a new pattern, just worth surfacing: existing users who re-run `setup` without `--target` after upgrading will get a third `AGENTS.md` written even if they've never touched OpenCode. + +## Residual, disclosed risk — not fixed, cannot be fixed without a Windows machine + +`OPENCODE_DB_RELATIVE_PATH` (`~/.local/share/opencode/opencode.db`) is inferred from the macOS spike and has never been verified against a real Windows OpenCode install. This was already an open item in the original plan's "Next steps" ("Windows verification pass") and remains open. The blast radius if wrong is graceful degradation, not a crash: `query_readonly_or_empty` soft-fails to `()` on a missing/wrong-shaped DB, so OpenCode sync would silently find zero transcripts on Windows rather than error. Everything else Windows-relevant (subprocess spawning, path joining, stdout draining without `select()`, thread-based timeouts, no POSIX-only signal usage) was checked and confirmed correct. + +## Verification + +- `uv run ruff check .` — clean. +- `uv run pytest -q` — 482 passed (4 new tests: 2 for `start_opencode_server`'s executable resolution, 1 for the generic runtime adapter's explicit OpenCode exclusion, 1 for the Windows drive-letter separator round trip). +- Both `init` and `garden` were live-tested end-to-end against a real project (`send_cloudaccess_emails`) with a real `opencode serve` instance before this pass; that verification stands (this pass only touched Windows-relevant and de-duplication code, re-covered by the full test suite, not re-run live since these are macOS-invisible fixes by nature). + +## Status + +All four OpenCode harness slices, the live-progress/hang-detection follow-up, the model-allowlist fix, and this final full-scope review are built, reviewed, and fixed. Not yet committed — pending the user's go-ahead. diff --git a/docs/plans/fixes-opencode-harness-live-progress-review.md b/docs/plans/fixes-opencode-harness-live-progress-review.md new file mode 100644 index 00000000..4d51c741 --- /dev/null +++ b/docs/plans/fixes-opencode-harness-live-progress-review.md @@ -0,0 +1,36 @@ +# Fixes — OpenCode Live Progress & Hang Detection Review + +Review pass against `docs/plans/2026-07-09-opencode-harness-live-progress-and-hang-detection.md`. Four findings, all resolved. + +## 🟡 Fix — stray debug artifact committed to the repo root + +**Finding:** a 0-byte file literally named `select data from part where id='prt_f43ffd97000118zZAICSCswGJD'` was sitting untracked in the repo root — the fallout of an earlier malformed `sqlite3 ` shell invocation (argument order swapped) run during live debugging of the stuck-tool-call bug. + +**Fix:** deleted (`rm`). Confirmed gone from `git status` and `ls -la`. + +## 🟡 Fix — narrow post-timeout race in `client.py`'s sender/watchdog join + +**Finding:** `run_once()`'s `while sender_thread.is_alive(): ...` loop exits either by raising `OpencodeStuckToolCallError` (watchdog fired) or by the sender thread finishing normally. On the normal-exit path, there was no comment explaining why `watchdog.stuck_reason` isn't re-checked once more before trusting `message_result`, nor why the subsequent `watchdog_thread.join(timeout=...)` is a *bounded* wait rather than an unconditional one. + +**Fix:** added two comment blocks at `src/codealmanac/integrations/harnesses/opencode/client.py:250` documenting the reasoning inline: a real result from `post_message` wins over a heuristic that fired a beat late (deliberately not re-checked, to avoid turning a clean handoff into a race); and the bounded `watchdog_thread.join()` can't block process exit either way since the watchdog is a daemon thread, and a residual very-late event landing after the `events` snapshot is low-consequence (list append is GIL-atomic, and real callers persist events live via `on_event`, not by re-reading `result.events`). + +## 🔵 Polish — `progress.py` docstring didn't state the "no narration while running" limitation + +**Finding:** `OpencodeProgressWatchdog` only calls `map_opencode_part` (which produces the live `TOOL_USE` event) once a tool call's `state.status` leaves `"running"` — so a long-running call produces zero live narration until it settles or is flagged stuck. This is true and intentional, but wasn't documented anywhere a future reader would see it before being surprised by it. + +**Fix:** added a paragraph to `OpencodeProgressWatchdog`'s class docstring (`progress.py:89-95`) stating this explicitly, cross-referenced to the plan doc's live-verification timestamps that back it up. + +## 🔵 Polish — `opencode_db.py`'s `_ENTRIES_QUERY` had the same unaliased-column shape that caused the real `progress.py` bug + +**Finding:** the bug just fixed in `progress.py` (`SELECT part.data, message.data` — both columns collapse to the bare name `data` under `sqlite3.Row`'s dict-style lookup, silently returning only the last match) has a structural twin in the pre-existing `integrations/sources/transcripts/opencode_db.py::_ENTRIES_QUERY`, written during Slice 3. It wasn't actually broken — `read_opencode_session_entries()` reads rows positionally (`row[0]`, `row[1]`), which sidesteps the collision — but it was one thoughtless refactor (e.g. someone "cleaning up" positional access to `row["data"]`) away from reintroducing the exact bug that took a live smoke test to catch the first time. + +**Fix:** aliased the query to `SELECT part.data AS part_data, message.data AS message_data ...` and switched `read_opencode_session_entries()`'s row access from positional to the explicit keys, matching the pattern now used in `progress.py`. Added a comment on the query explaining why the aliases exist and pointing at the sibling bug. This closes the landmine rather than just documenting it. + +## Verification + +- `uv run ruff check .` — clean. +- `uv run pytest -q` — 471 passed, including `tests/test_opencode_transcripts.py` (Slice 3 transcript-reading tests, unaffected by the row-access change) and `tests/test_opencode_adapter.py` (live-progress/hang-detection tests). + +## Status + +Live progress narration and hang detection are built, reviewed, and fixed. Still pending before commit: user's own hands-on test run against a real `opencode serve` instance on their project, and removal of the LaunchAgents installed during earlier setup testing. diff --git a/docs/plans/fixes-opencode-harness-slice-1-review.md b/docs/plans/fixes-opencode-harness-slice-1-review.md new file mode 100644 index 00000000..d781549b --- /dev/null +++ b/docs/plans/fixes-opencode-harness-slice-1-review.md @@ -0,0 +1,36 @@ +# Fixes — OpenCode Harness Slice 1 Review + +Review pass against `docs/plans/2026-07-08-opencode-harness-slice-1.md` (per `.claude/agents/review.md`, `MANUAL.md` §6 slice discipline). Findings below, most severe first, each with what was done. + +## 🔴 Bug — `check_providers` didn't catch `ValueError` the way `run()` does + +**Finding:** `OpencodeClient.run()` catches `FileNotFoundError` / `OpencodeServerStartupError` / `httpx.HTTPError` / `ValueError` around `run_once`, because `response.json()` (called by `get_providers`/`create_session`/`post_message`) raises `json.JSONDecodeError` — a `ValueError` subclass — on a malformed 200 response, not an `httpx.HTTPError`. `check_providers` calls the same `get_providers` but only caught `httpx.HTTPError`, so a malformed/non-JSON response from `opencode serve` would crash straight through `check_providers` uncaught. Since `HarnessesService.check()` iterates every adapter's `check()` with no per-adapter try/except, and interactive `codealmanac setup` calls it directly, this could take down the whole `setup` command instead of degrading OpenCode to "not available" the way every other error path in the file does. + +**Fix:** added `except ValueError as error` to `check_providers` (`client.py`), mapped to the same `HarnessReadiness(available=False, repair=OPENCODE_SERVER_REPAIR)` shape as the `httpx.HTTPError` branch. Added `test_opencode_client_check_providers_reports_not_ready_on_malformed_json` to guard the regression. + +## 🟡 Fix — dead `agent_parents`/`agent_labels` fields on `OpencodeRunState` + +**Finding:** copied from `CodexRunState`/`ClaudeRunState`'s shape, but nothing in the OpenCode package ever writes or reads them — sub-agent session-tree traversal isn't implemented in this slice (already disclosed in the slice-1 plan's "Out of scope"). Left in place, they read as a half-finished wire-up rather than an intentional placeholder. + +**Fix:** removed both fields from `state.py`, replaced with a comment pointing at the "Out of scope" entry, noting they should come back alongside whatever code actually populates them. + +## 🟡 Fix — two of three curated OpenCode model strings lacked a documented evidence trail + +**Finding:** `opencode/deepseek-v4-flash-free` was the only model run through a full end-to-end generation during the spike. `opencode/mimo-v2.5-free` and `opencode/big-pickle` don't appear in the spike findings doc or any test, so a reader can't tell whether they're verified or invented. + +**Resolution (not a removal — the evidence exists, it just wasn't recorded):** all three model IDs *are* real — confirmed present in a live `GET /config/providers` response for the `opencode` (Zen, free-tier) provider during the 2026-07-08 spike, captured earlier in that session but not carried into the plan docs or code. Added a comment on `HARNESS_MODELS[HarnessKind.OPENCODE]`/`DEFAULT_HARNESS_MODELS[HarnessKind.OPENCODE]` in `services/config/models.py` distinguishing the two confirmation tiers: registered-and-listed (all three) vs. actually-run-to-completion (`deepseek-v4-flash-free` only) — and noting the default should only ever be the fully-verified one. No code behavior changed; this closes the "undocumented evidence trail" gap the review actually flagged. + +## 🔵 Polish — near-duplicate exception handling between `check_providers` and `run()` + +**Finding:** once the 🔴 fix above landed, the two methods' except-chains are nearly identical (same exception types, different return shape) — noted as the kind of duplication that caused the 🔴 bug in the first place (fixed in one branch, not the sibling). + +**Resolution:** did not extract a shared helper — the two `ValueError` sources mean genuinely different things (`check_providers`'s is a malformed server response; `run()`'s is a bad `model` string from `split_opencode_model`, happening before any server call), so a fully shared mapper would need call-site context to pick the right repair text, which isn't a clear win for two call sites. Instead added a short cross-reference comment on each except-chain pointing at its sibling, so the next person editing one notices the other. + +## Verification + +- `uv run ruff check .` — clean. +- `uv run pytest -q` — 433 passed (432 pre-review + 1 new regression test for the fixed `ValueError` path). + +## Next steps + +Move to Slice 2 (setup wizard generalization) — write `docs/plans/2026-07-08-opencode-harness-slice-2.md` next. diff --git a/docs/plans/fixes-opencode-harness-slice-2-review.md b/docs/plans/fixes-opencode-harness-slice-2-review.md new file mode 100644 index 00000000..7a98afcd --- /dev/null +++ b/docs/plans/fixes-opencode-harness-slice-2-review.md @@ -0,0 +1,43 @@ +# Fixes — OpenCode Harness Slice 2 Review + +Review pass against `docs/plans/2026-07-08-opencode-harness-slice-2.md`. Findings below, most severe first, each with what was done. + +## 🟡 Fix — `HARNESS_ORDER`/`TARGET_ORDER` re-duplicated the enum they should derive from + +**Finding:** ironic given the slice's own stated goal — `HARNESS_ORDER`/`TARGET_ORDER` in `options.py` were hand-listed tuples of enum members (`(HarnessKind.CODEX, HarnessKind.CLAUDE, HarnessKind.OPENCODE)`), the exact "second hardcoded copy of the enum order" bug class this slice found and fixed once already in `cli/parser/setup.py`. `tuple(HarnessKind)` and `tuple(SetupTarget)` already equal these tuples exactly (enum declaration order matches), and the codebase already uses that idiom elsewhere (`cli/parser/setup.py`'s own `RUNNER_CHOICES`, `cli/parser/run_commands.py`, `services/config/service.py`). + +**Fix:** `HARNESS_ORDER = tuple(HarnessKind)`, `TARGET_ORDER = tuple(SetupTarget)` — derived, not re-listed. Removes the drift risk entirely rather than leaving it "currently correct by coincidence." + +## 🔵 Polish — `TARGET_SHORTCUTS`/`RUNNER_SHORTCUTS` were byte-identical dicts + +**Finding:** `SetupTarget` and `HarnessKind` share the same string values (`"codex"`/`"claude"`/`"opencode"`), so the two shortcut dicts held identical mappings keyed by two structurally-identical-but-distinct enums — two places that must always be edited in lockstep. + +**Fix:** collapsed to one `SHORTCUTS: dict[str, tuple[str, ...]]` keyed by `.value`, used via `SHORTCUTS[target.value]`/`SHORTCUTS[kind.value]` at both call sites. + +## 🟡 Fix — README setup docs never mentioned OpenCode + +**Finding:** `## Setup` and `## Providers` sections documented `--target codex`/`--target claude`/`--runner claude` with no OpenCode mention, despite this slice changing `codealmanac setup`'s actual default behavior (bare `setup --yes` now installs instructions for three targets, not two). Not scoped by any plan doc — a genuine gap between what shipped and what's documented. + +**Fix:** added `--target opencode`/`--runner opencode` examples and `opencode auth login` to `## Providers`' credential list; noted the three-target default explicitly. Verified against `tests/test_public_contract.py::test_readme_documents_python_local_public_surface` (README fragment contract test) — still passes. + +## 🔵 Polish — architecture test didn't assert `instructions.py` actually wires in OpenCode + +**Finding:** `test_setup_instruction_adapter_stays_split_by_target_family` asserted `install_codex_instructions`/`install_claude_instructions` substrings and Codex/Claude-specific negative assertions, but never checked `opencode.py`'s presence/shape or that `instructions.py` actually references `install_opencode_instructions` — the test could pass even if OpenCode wiring were silently dropped. + +**Fix:** added `opencode.py` to the required-files set, `install_opencode_instructions` to the `instructions.py` positive assertions, and negative assertions on `opencode.py` itself (no `AGENTS.override.md`, no `CLAUDE_IMPORT_LINE` — confirms it doesn't quietly grow a sibling's provider-specific machinery instead of staying its own thing). + +## Non-findings confirmed by the reviewer (no action needed) + +- `cli/parser/setup.py`'s fix (deriving `SETUP_TARGETS`/`RUNNER_CHOICES` from the enums) is correct and complete — no third raw-string literal found elsewhere. +- `target_options()`'s "all + N only" index math is internally consistent with `targets_for_index`/`target_default_index`. +- `DEFAULT_SETUP_TARGETS`'s 2→3 target change has no stale call site — `uninstall()`, `cli/render/setup/result.py`, `services/setup/planning.py` all render off the actual targets tuple dynamically. +- `opencode.py` faithfully mirrors `codex.py`'s installer shape (same shared `managed_blocks.py`/`text_files.py` helpers) for idempotent re-install, corrupted-block recovery, and never-installed uninstall — the deliberate omission of Codex's `AGENTS.override.md` convention is justified, not a partial mirror. + +## Verification + +- `uv run ruff check .` — clean. +- `uv run pytest -q` — 452 passed (unchanged count; this pass edited existing assertions/docs rather than adding new tests). + +## Next steps + +Move to Slice 3 (`sync` transcript discovery/reading) — write `docs/plans/2026-07-08-opencode-harness-slice-3.md` next. diff --git a/docs/plans/fixes-opencode-harness-slice-3-review.md b/docs/plans/fixes-opencode-harness-slice-3-review.md new file mode 100644 index 00000000..86ec199b --- /dev/null +++ b/docs/plans/fixes-opencode-harness-slice-3-review.md @@ -0,0 +1,27 @@ +# Fixes — OpenCode Harness Slice 3 Review + +Review pass against `docs/plans/2026-07-08-opencode-harness-slice-3.md`. One substantive finding, three confirmed-clean. + +## 🟡 Fix — `services/sources/transcripts.py` had the only per-app conditional in `services/`/`workflows/` + +**Finding:** as originally shipped, `transcript_address(candidate)` and `parse_opencode_transcript_ref()` lived in `services/sources/transcripts.py`, with an `if candidate.app == TranscriptApp.OPENCODE` branch. This was a real, working fix for a real constraint (`workflows/` never imports `integrations/` in this codebase, confirmed by grep — `workflows/sync/queue.py` needed to build an OpenCode-shaped address string but couldn't reach into `integrations/sources/transcripts/opencode.py` to do it). But it made this file — previously pure, app-agnostic bookkeeping (`transcript_sort_key`) — the one place outside `integrations/` with provider-specific knowledge, exactly the pattern CLAUDE.md's "provider-specific conditionals outside provider modules" rule flags. The plan's cited precedent (`sync_ingest_title()`'s `candidate.app.value` next door) turned out not to actually be precedent — that's generic string formatting, not a special-cased branch. + +**Fix — smaller and cleaner than the reviewer's suggested full adapter-dispatch refactor:** added `TranscriptCandidate.address_override: str | None = None` (`services/sources/models.py`) — a generic field any discovery adapter can set when `transcript_path` alone can't address one session. `transcript_address()` collapses to `candidate.address_override or str(candidate.transcript_path)`: two lines, zero `TranscriptApp` references, zero per-app knowledge. `OpencodeTranscriptDiscoveryAdapter.discover()` (the adapter that already builds each candidate) sets `address_override` itself, using `format_opencode_transcript_ref()`/`parse_opencode_transcript_ref()` — both moved into a new `integrations/sources/transcripts/opencode_ref.py`, entirely inside the OpenCode integration package where the reviewer wanted them. `services/sources/transcripts.py` now has zero `TranscriptApp`/`opencode` references (confirmed by grep). A future 4th transcript app with the same "many sessions, one file" problem sets its own `address_override` in its own discovery adapter — no edit to `services/` required, ever. + +Verified against a real `opencode serve` instance (not just fixtures) after the change: discovery correctly sets `address_override`, `transcript_address()` returns it unchanged, and the full discover → address → runtime-inspect round trip still renders the actual model reply correctly. + +## 🔵 Polish — confirmed clean, no action taken + +- **`query_readonly_or_empty`'s blanket `except sqlite3.Error`:** matches existing precedent (`integrations/sources/transcripts/jsonl.py`'s `except OSError: return ()` for Claude/Codex reads) — not a new, looser tolerance standard. Connection cleanup confirmed correct by reading (the `finally` only runs once a connection is bound). +- **`size_bytes` imprecision (whole shared db file size per candidate):** traced every reader — `cli/render/sync.py` never displays it, `transcript_sort_key` doesn't sort by it, nothing truncates on it. Genuinely inert today, as the plan claimed. +- **Runtime-adapter registration order (`OpencodeTranscriptSourceRuntimeAdapter` before the generic `TranscriptSourceRuntimeAdapter`):** verified load-bearing by reading `SourcesService.inspect_runtime()`'s first-match dispatch loop directly, not just trusting the code comment. The existing ordering regression test would genuinely catch a reordering bug. + +## Verification + +- `uv run ruff check .` — clean. +- `uv run pytest -q` — 463 passed (test suite updated in place for the `address_override` mechanism, not a net-new count change). +- Live re-verification against a real `opencode serve` instance and real `~/.local/share/opencode/opencode.db` after the architectural fix — full discover → address → inspect round trip confirmed working, test session cleaned up afterward. + +## Status + +All three slices of `docs/plans/2026-07-07-opencode-harness.md` are now built, reviewed, and fixed. Ready for the combined commit. diff --git a/src/codealmanac/cli/dispatch/setup_wizard/options.py b/src/codealmanac/cli/dispatch/setup_wizard/options.py index 93ff4934..dfa3cb5b 100644 --- a/src/codealmanac/cli/dispatch/setup_wizard/options.py +++ b/src/codealmanac/cli/dispatch/setup_wizard/options.py @@ -3,23 +3,31 @@ from codealmanac.services.harnesses.models import HarnessKind, HarnessReadiness from codealmanac.services.setup.models import SetupTarget +# Derived from the enums themselves, not hand-listed, so a future harness is +# a one-line addition to HarnessKind/SetupTarget rather than a second place +# to keep in sync with it. Index 0 is the default/fallback. +HARNESS_ORDER: tuple[HarnessKind, ...] = tuple(HarnessKind) +TARGET_ORDER: tuple[SetupTarget, ...] = tuple(SetupTarget) +# SetupTarget and HarnessKind share the same string values ("codex", etc.), +# so one shortcut map keyed by .value serves both option lists. +SHORTCUTS: dict[str, tuple[str, ...]] = { + "codex": ("c",), + "claude": ("l",), + "opencode": ("o",), +} + def target_options() -> tuple[SetupChoiceOption, ...]: + combined_label = " + ".join(TARGET_LABELS[target] for target in TARGET_ORDER) return ( - SetupChoiceOption( - "Codex + Claude", - (), - ("b",), - ), - SetupChoiceOption( - "Codex only", - (), - ("c",), - ), - SetupChoiceOption( - "Claude only", - (), - ("l",), + SetupChoiceOption(combined_label, (), ("b",)), + *( + SetupChoiceOption( + f"{TARGET_LABELS[target]} only", + (), + SHORTCUTS[target.value], + ) + for target in TARGET_ORDER ), ) @@ -35,9 +43,9 @@ def runner_options( readiness: tuple[HarnessReadiness, ...] = (), ) -> tuple[SetupChoiceOption, ...]: by_kind = {item.kind: item for item in readiness} - return ( - runner_option(HarnessKind.CODEX, by_kind.get(HarnessKind.CODEX), ("c",)), - runner_option(HarnessKind.CLAUDE, by_kind.get(HarnessKind.CLAUDE), ("l",)), + return tuple( + runner_option(kind, by_kind.get(kind), SHORTCUTS[kind.value]) + for kind in HARNESS_ORDER ) @@ -94,30 +102,27 @@ def shortcut_option_index(screen: SetupChoiceScreen, key: str) -> int | None: def target_default_index(targets: tuple[SetupTarget, ...]) -> int: - if targets == (SetupTarget.CODEX,): - return 1 - if targets == (SetupTarget.CLAUDE,): - return 2 + for position, target in enumerate(TARGET_ORDER, start=1): + if targets == (target,): + return position return 0 def targets_for_index(index: int) -> tuple[SetupTarget, ...]: - if index == 1: - return (SetupTarget.CODEX,) - if index == 2: - return (SetupTarget.CLAUDE,) - return (SetupTarget.CODEX, SetupTarget.CLAUDE) + if 1 <= index <= len(TARGET_ORDER): + return (TARGET_ORDER[index - 1],) + return TARGET_ORDER def runner_for_index(index: int) -> HarnessKind: - if index == 1: - return HarnessKind.CLAUDE - return HarnessKind.CODEX + if 0 <= index < len(HARNESS_ORDER): + return HARNESS_ORDER[index] + return HARNESS_ORDER[0] def runner_index(harness: HarnessKind) -> int: - if harness == HarnessKind.CLAUDE: - return 1 + if harness in HARNESS_ORDER: + return HARNESS_ORDER.index(harness) return 0 @@ -133,7 +138,7 @@ def model_index(harness: HarnessKind, model: str) -> int: def parse_setup_targets(value: str) -> tuple[SetupTarget, ...]: if value == "all": - return (SetupTarget.CODEX, SetupTarget.CLAUDE) + return TARGET_ORDER return (SetupTarget(value),) @@ -145,10 +150,23 @@ def parse_setup_targets(value: str) -> tuple[SetupTarget, ...]: "claude-sonnet-5": "Claude Sonnet 5", "claude-opus-4-7": "Claude Opus 4.7", "claude-haiku-4-5": "Claude Haiku 4.5", + "opencode/deepseek-v4-flash-free": "OpenCode Zen: DeepSeek v4 Flash (free)", + "opencode/mimo-v2.5-free": "OpenCode Zen: MiMo v2.5 (free)", + "opencode/big-pickle": "OpenCode Zen: Big Pickle (free)", + "openai/gpt-5.5": "GPT-5.5 via OpenAI", + "openai/gpt-5.4": "GPT-5.4 via OpenAI", + "openai/gpt-5.4-mini": "GPT-5.4-Mini via OpenAI", + "openai/gpt-5.3-codex-spark": "GPT-5.3-Codex-Spark via OpenAI", } RUNNER_LABELS = { HarnessKind.CODEX: "Codex", HarnessKind.CLAUDE: "Claude", + HarnessKind.OPENCODE: "OpenCode", +} +TARGET_LABELS = { + SetupTarget.CODEX: "Codex", + SetupTarget.CLAUDE: "Claude", + SetupTarget.OPENCODE: "OpenCode", } MODEL_DETAILS = { "gpt-5.5": "recommended wiki-writing runner", @@ -158,4 +176,18 @@ def parse_setup_targets(value: str) -> tuple[SetupTarget, ...]: "claude-sonnet-5": "recommended maintenance runner", "claude-opus-4-7": "deep rebuilds and hard gardens", "claude-haiku-4-5": "small routine updates", + # deepseek-v4-flash-free is the only opencode model run end-to-end in + # the Slice 1 spike; the other two are confirmed-registered but + # unverified for a full generation — see services/config/models.py. + "opencode/deepseek-v4-flash-free": "recommended opencode runner", + "opencode/mimo-v2.5-free": "alternate free-tier runner", + "opencode/big-pickle": "alternate free-tier runner", + # Routed through the same authenticated OpenAI account Codex uses + # directly — see the HARNESS_MODELS[OPENCODE] comment in + # services/config/models.py for what's actually been re-verified + # through OpenCode versus inherited from Codex's catalog. + "openai/gpt-5.5": "confirmed live through opencode, full-quality runner", + "openai/gpt-5.4": "strong general runner", + "openai/gpt-5.4-mini": "faster routine maintenance", + "openai/gpt-5.3-codex-spark": "lightweight small updates", } diff --git a/src/codealmanac/cli/dispatch/sync.py b/src/codealmanac/cli/dispatch/sync.py index 00c1f4fe..17b6b38d 100644 --- a/src/codealmanac/cli/dispatch/sync.py +++ b/src/codealmanac/cli/dispatch/sync.py @@ -46,7 +46,7 @@ def dispatch_sync_status(args: argparse.Namespace, app: CodeAlmanac) -> int: def parse_sync_apps(value: str | None) -> tuple[TranscriptApp, ...]: if value is None or value.strip() == "": - return (TranscriptApp.CLAUDE, TranscriptApp.CODEX) + return (TranscriptApp.CLAUDE, TranscriptApp.CODEX, TranscriptApp.OPENCODE) apps: list[TranscriptApp] = [] for raw in value.split(","): item = raw.strip() @@ -54,7 +54,7 @@ def parse_sync_apps(value: str | None) -> tuple[TranscriptApp, ...]: app = TranscriptApp(item) except ValueError as error: raise ValidationFailed( - f'invalid --from "{value}" (expected claude,codex)' + f'invalid --from "{value}" (expected claude,codex,opencode)' ) from error if app not in apps: apps.append(app) diff --git a/src/codealmanac/cli/parser/setup.py b/src/codealmanac/cli/parser/setup.py index 16a74f66..e2241551 100644 --- a/src/codealmanac/cli/parser/setup.py +++ b/src/codealmanac/cli/parser/setup.py @@ -1,6 +1,13 @@ import argparse -SETUP_TARGETS = ("all", "codex", "claude") +from codealmanac.services.harnesses.models import HarnessKind +from codealmanac.services.setup.models import SetupTarget + +# "all" plus every SetupTarget/HarnessKind value — derived from the enums +# rather than duplicated as string literals, so a new harness only needs +# adding to SetupTarget/HarnessKind, not here too. +SETUP_TARGETS = ("all", *(target.value for target in SetupTarget)) +RUNNER_CHOICES = tuple(kind.value for kind in HarnessKind) def add_setup_commands(subcommands: argparse._SubParsersAction) -> None: @@ -14,7 +21,7 @@ def add_setup_commands(subcommands: argparse._SubParsersAction) -> None: setup.add_argument("--yes", action="store_true", help="run without prompts") setup.add_argument( "--runner", - choices=("codex", "claude"), + choices=RUNNER_CHOICES, help="agent that runs CodeAlmanac jobs (default: codex)", ) setup.add_argument( diff --git a/src/codealmanac/cli/render/brand.py b/src/codealmanac/cli/render/brand.py index d54fcc91..5c32c631 100644 --- a/src/codealmanac/cli/render/brand.py +++ b/src/codealmanac/cli/render/brand.py @@ -9,12 +9,14 @@ ACCENT_BG = "\x1b[48;5;252m\x1b[38;5;16m" CLAUDE_CORAL = "\x1b[38;5;173m" CODEX_PERIWINKLE = "\x1b[38;5;105m" +OPENCODE_MINT = "\x1b[38;5;121m" DIFF_RED = "\x1b[38;5;203m" DIFF_GREEN = "\x1b[38;5;76m" BRAND_COLORS = { "Codex": CODEX_PERIWINKLE, "Claude": CLAUDE_CORAL, + "OpenCode": OPENCODE_MINT, } GRADIENT = ( diff --git a/src/codealmanac/database/__init__.py b/src/codealmanac/database/__init__.py index 41e951c1..c14f2573 100644 --- a/src/codealmanac/database/__init__.py +++ b/src/codealmanac/database/__init__.py @@ -5,6 +5,7 @@ SQLiteRow, apply_migrations, connect_sqlite, + query_readonly_or_empty, ) __all__ = ( @@ -14,4 +15,5 @@ "apply_migrations", "connect_sqlite", "open_local_database", + "query_readonly_or_empty", ) diff --git a/src/codealmanac/database/sqlite.py b/src/codealmanac/database/sqlite.py index 736719be..1582253f 100644 --- a/src/codealmanac/database/sqlite.py +++ b/src/codealmanac/database/sqlite.py @@ -56,3 +56,27 @@ def apply_migrations( def user_version(connection: SQLiteConnection) -> int: return int(connection.execute("PRAGMA user_version").fetchone()[0]) + + +def query_readonly_or_empty( + path: Path, + sql: str, + params: tuple = (), +) -> tuple[SQLiteRow, ...]: + """Run one read-only query against a SQLite file this app doesn't own + the schema for (e.g. another program's local database), returning () + on any error — missing file, missing table/column, corrupt file — + instead of raising. Unlike connect_sqlite/open_local_database, this + never writes, never applies migrations, and tolerates the target + schema not matching what the caller expects.""" + try: + connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + except sqlite3.Error: + return () + try: + return tuple(connection.execute(sql, params).fetchall()) + except sqlite3.Error: + return () + finally: + connection.close() diff --git a/src/codealmanac/integrations/command.py b/src/codealmanac/integrations/command.py index 5aad7e9f..52adad2a 100644 --- a/src/codealmanac/integrations/command.py +++ b/src/codealmanac/integrations/command.py @@ -1,3 +1,4 @@ +import shutil import subprocess from pathlib import Path from typing import Protocol @@ -32,8 +33,13 @@ def run( timeout_seconds: int, stdin: str | None = None, ) -> CommandResult: + # Windows: npm-installed CLIs (codex/claude/opencode) ship as .cmd/.ps1 + # shims, and subprocess.run(shell=False) can't launch a bare command + # name through CreateProcess the way a shell would. Resolving through + # PATH/PATHEXT first fixes all three harnesses' check(), not just one. + resolved = shutil.which(command) or command completed = subprocess.run( - (command, *args), + (resolved, *args), cwd=cwd, text=True, input=stdin, diff --git a/src/codealmanac/integrations/harnesses/__init__.py b/src/codealmanac/integrations/harnesses/__init__.py index 87d08b71..671401e7 100644 --- a/src/codealmanac/integrations/harnesses/__init__.py +++ b/src/codealmanac/integrations/harnesses/__init__.py @@ -2,8 +2,15 @@ from codealmanac.integrations.harnesses.codex.adapter import ( CodexAppServerHarnessAdapter, ) +from codealmanac.integrations.harnesses.opencode.adapter import ( + OpencodeHarnessAdapter, +) from codealmanac.services.harnesses.ports import HarnessAdapter def default_harness_adapters() -> tuple[HarnessAdapter, ...]: - return (ClaudeSdkHarnessAdapter(), CodexAppServerHarnessAdapter()) + return ( + ClaudeSdkHarnessAdapter(), + CodexAppServerHarnessAdapter(), + OpencodeHarnessAdapter(), + ) diff --git a/src/codealmanac/integrations/harnesses/claude/client.py b/src/codealmanac/integrations/harnesses/claude/client.py index ea7743fa..bdae8df0 100644 --- a/src/codealmanac/integrations/harnesses/claude/client.py +++ b/src/codealmanac/integrations/harnesses/claude/client.py @@ -31,6 +31,7 @@ session_id_for_message, ) from codealmanac.integrations.harnesses.claude.state import ClaudeRunState +from codealmanac.integrations.harnesses.stream import append_event, emit_result from codealmanac.services.harnesses.models import ( HarnessEvent, HarnessEventKind, @@ -176,23 +177,3 @@ def failed_result(output_text: str) -> HarnessRunResult: ), ), ) - - -def append_event( - events: list[HarnessEvent], - event: HarnessEvent, - on_event: HarnessEventSink | None, -) -> None: - events.append(event) - if on_event is not None: - on_event(event) - - -def emit_result( - result: HarnessRunResult, - on_event: HarnessEventSink | None, -) -> HarnessRunResult: - if on_event is not None: - for event in result.events: - on_event(event) - return result diff --git a/src/codealmanac/integrations/harnesses/codex/agent_events.py b/src/codealmanac/integrations/harnesses/codex/agent_events.py index fb94a793..e65fc87b 100644 --- a/src/codealmanac/integrations/harnesses/codex/agent_events.py +++ b/src/codealmanac/integrations/harnesses/codex/agent_events.py @@ -1,10 +1,10 @@ from codealmanac.integrations.harnesses.codex.actors import helper_label -from codealmanac.integrations.harnesses.codex.fields import ( +from codealmanac.integrations.harnesses.codex.state import CodexRunState +from codealmanac.integrations.harnesses.fields import ( JsonObject, string_array_field, string_field, ) -from codealmanac.integrations.harnesses.codex.state import CodexRunState from codealmanac.services.harnesses.models import ( HarnessAgentTrace, HarnessEvent, diff --git a/src/codealmanac/integrations/harnesses/codex/app_server.py b/src/codealmanac/integrations/harnesses/codex/app_server.py index d59ee882..c232b9c7 100644 --- a/src/codealmanac/integrations/harnesses/codex/app_server.py +++ b/src/codealmanac/integrations/harnesses/codex/app_server.py @@ -10,11 +10,6 @@ map_codex_notification, provider_session_event, ) -from codealmanac.integrations.harnesses.codex.fields import ( - JsonObject, - as_record, - string_field, -) from codealmanac.integrations.harnesses.codex.responses import ( noninteractive_response, ) @@ -31,15 +26,20 @@ resolve_sandbox_mode, sandbox_policy, ) -from codealmanac.integrations.harnesses.codex.stream import ( - append_event, - append_events, - emit_result, -) from codealmanac.integrations.harnesses.codex.timeouts import env_milliseconds from codealmanac.integrations.harnesses.codex.turn_completion import ( root_turn_completion, ) +from codealmanac.integrations.harnesses.fields import ( + JsonObject, + as_record, + string_field, +) +from codealmanac.integrations.harnesses.stream import ( + append_event, + append_events, + emit_result, +) from codealmanac.services.harnesses.models import HarnessEvent, HarnessRunResult from codealmanac.services.harnesses.ports import HarnessEventSink from codealmanac.services.harnesses.requests import RunHarnessRequest diff --git a/src/codealmanac/integrations/harnesses/codex/display.py b/src/codealmanac/integrations/harnesses/codex/display.py index 67f4b06b..b5c10726 100644 --- a/src/codealmanac/integrations/harnesses/codex/display.py +++ b/src/codealmanac/integrations/harnesses/codex/display.py @@ -1,4 +1,4 @@ -from codealmanac.integrations.harnesses.codex.fields import ( +from codealmanac.integrations.harnesses.fields import ( JsonObject, as_record, number_field, diff --git a/src/codealmanac/integrations/harnesses/codex/events.py b/src/codealmanac/integrations/harnesses/codex/events.py index 55bac17c..9e1ddb16 100644 --- a/src/codealmanac/integrations/harnesses/codex/events.py +++ b/src/codealmanac/integrations/harnesses/codex/events.py @@ -1,9 +1,4 @@ from codealmanac.integrations.harnesses.codex.actors import actor_for_codex_thread -from codealmanac.integrations.harnesses.codex.fields import ( - JsonObject, - as_record, - string_field, -) from codealmanac.integrations.harnesses.codex.item_events import ( map_completed_item, map_started_item, @@ -23,6 +18,11 @@ provider_session_event, ) from codealmanac.integrations.harnesses.codex.state import CodexRunState +from codealmanac.integrations.harnesses.fields import ( + JsonObject, + as_record, + string_field, +) from codealmanac.services.harnesses.models import ( HarnessEvent, ) diff --git a/src/codealmanac/integrations/harnesses/codex/failures.py b/src/codealmanac/integrations/harnesses/codex/failures.py index 7a251a76..9547029c 100644 --- a/src/codealmanac/integrations/harnesses/codex/failures.py +++ b/src/codealmanac/integrations/harnesses/codex/failures.py @@ -3,7 +3,7 @@ from pydantic import JsonValue -from codealmanac.integrations.harnesses.codex.fields import ( +from codealmanac.integrations.harnesses.fields import ( JsonObject, as_record, number_field, diff --git a/src/codealmanac/integrations/harnesses/codex/item_events.py b/src/codealmanac/integrations/harnesses/codex/item_events.py index 367d5b68..80d2aee6 100644 --- a/src/codealmanac/integrations/harnesses/codex/item_events.py +++ b/src/codealmanac/integrations/harnesses/codex/item_events.py @@ -7,14 +7,14 @@ item_type_tool_name, tool_use_event, ) -from codealmanac.integrations.harnesses.codex.fields import ( +from codealmanac.integrations.harnesses.codex.state import CodexRunState +from codealmanac.integrations.harnesses.fields import ( JsonObject, as_record, boolean_field, first_present, string_field, ) -from codealmanac.integrations.harnesses.codex.state import CodexRunState from codealmanac.services.harnesses.models import ( HarnessActorRole, HarnessAgentTrace, diff --git a/src/codealmanac/integrations/harnesses/codex/notification_events.py b/src/codealmanac/integrations/harnesses/codex/notification_events.py index 6771f588..969a7120 100644 --- a/src/codealmanac/integrations/harnesses/codex/notification_events.py +++ b/src/codealmanac/integrations/harnesses/codex/notification_events.py @@ -1,14 +1,14 @@ from codealmanac.integrations.harnesses.codex.failures import ( failure_from_error_record, ) -from codealmanac.integrations.harnesses.codex.fields import ( +from codealmanac.integrations.harnesses.codex.item_events import output_delta +from codealmanac.integrations.harnesses.codex.result import record_failure +from codealmanac.integrations.harnesses.codex.state import CodexRunState +from codealmanac.integrations.harnesses.fields import ( JsonObject, as_record, string_field, ) -from codealmanac.integrations.harnesses.codex.item_events import output_delta -from codealmanac.integrations.harnesses.codex.result import record_failure -from codealmanac.integrations.harnesses.codex.state import CodexRunState from codealmanac.services.harnesses.models import ( HarnessEvent, HarnessEventKind, diff --git a/src/codealmanac/integrations/harnesses/codex/responses.py b/src/codealmanac/integrations/harnesses/codex/responses.py index 55154e51..79cb2465 100644 --- a/src/codealmanac/integrations/harnesses/codex/responses.py +++ b/src/codealmanac/integrations/harnesses/codex/responses.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from codealmanac.integrations.harnesses.codex.fields import JsonObject +from codealmanac.integrations.harnesses.fields import JsonObject @dataclass(frozen=True) diff --git a/src/codealmanac/integrations/harnesses/codex/result.py b/src/codealmanac/integrations/harnesses/codex/result.py index a3bd6299..e858e16b 100644 --- a/src/codealmanac/integrations/harnesses/codex/result.py +++ b/src/codealmanac/integrations/harnesses/codex/result.py @@ -1,5 +1,9 @@ from codealmanac.integrations.harnesses.codex.failures import classify_codex_failure -from codealmanac.integrations.harnesses.codex.fields import ( +from codealmanac.integrations.harnesses.codex.state import CodexRunState +from codealmanac.integrations.harnesses.codex.usage import ( + parse_codex_app_server_usage, +) +from codealmanac.integrations.harnesses.fields import ( JsonObject, as_record, compact_json, @@ -7,10 +11,6 @@ number_field, string_field, ) -from codealmanac.integrations.harnesses.codex.state import CodexRunState -from codealmanac.integrations.harnesses.codex.usage import ( - parse_codex_app_server_usage, -) from codealmanac.services.harnesses.models import ( HarnessEvent, HarnessEventKind, diff --git a/src/codealmanac/integrations/harnesses/codex/rpc.py b/src/codealmanac/integrations/harnesses/codex/rpc.py index 177e1b41..f02b8c10 100644 --- a/src/codealmanac/integrations/harnesses/codex/rpc.py +++ b/src/codealmanac/integrations/harnesses/codex/rpc.py @@ -12,7 +12,7 @@ from pydantic import JsonValue, TypeAdapter, ValidationError from codealmanac.integrations.harnesses.codex.errors import CodexAppServerError -from codealmanac.integrations.harnesses.codex.fields import ( +from codealmanac.integrations.harnesses.fields import ( JsonObject, as_record, string_field, diff --git a/src/codealmanac/integrations/harnesses/codex/sandbox.py b/src/codealmanac/integrations/harnesses/codex/sandbox.py index 17f49df6..8d45e8eb 100644 --- a/src/codealmanac/integrations/harnesses/codex/sandbox.py +++ b/src/codealmanac/integrations/harnesses/codex/sandbox.py @@ -3,7 +3,7 @@ from typing import Literal from codealmanac.integrations.harnesses.codex.errors import CodexAppServerError -from codealmanac.integrations.harnesses.codex.fields import JsonObject +from codealmanac.integrations.harnesses.fields import JsonObject CODEX_APP_SERVER_SANDBOX_MODE_ENV = "CODEALMANAC_CODEX_APP_SERVER_SANDBOX_MODE" diff --git a/src/codealmanac/integrations/harnesses/codex/turn_completion.py b/src/codealmanac/integrations/harnesses/codex/turn_completion.py index 57a64ae7..0e2970c5 100644 --- a/src/codealmanac/integrations/harnesses/codex/turn_completion.py +++ b/src/codealmanac/integrations/harnesses/codex/turn_completion.py @@ -1,9 +1,9 @@ -from codealmanac.integrations.harnesses.codex.fields import ( +from codealmanac.integrations.harnesses.codex.state import CodexRunState +from codealmanac.integrations.harnesses.fields import ( JsonObject, as_record, string_field, ) -from codealmanac.integrations.harnesses.codex.state import CodexRunState def root_turn_completion(message: JsonObject, state: CodexRunState) -> bool: diff --git a/src/codealmanac/integrations/harnesses/codex/usage.py b/src/codealmanac/integrations/harnesses/codex/usage.py index 270084e7..65015562 100644 --- a/src/codealmanac/integrations/harnesses/codex/usage.py +++ b/src/codealmanac/integrations/harnesses/codex/usage.py @@ -1,6 +1,6 @@ from pydantic import JsonValue -from codealmanac.integrations.harnesses.codex.fields import ( +from codealmanac.integrations.harnesses.fields import ( as_record, first_present, number_field, diff --git a/src/codealmanac/integrations/harnesses/codex/fields.py b/src/codealmanac/integrations/harnesses/fields.py similarity index 100% rename from src/codealmanac/integrations/harnesses/codex/fields.py rename to src/codealmanac/integrations/harnesses/fields.py diff --git a/src/codealmanac/integrations/harnesses/opencode/__init__.py b/src/codealmanac/integrations/harnesses/opencode/__init__.py new file mode 100644 index 00000000..f5237301 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/__init__.py @@ -0,0 +1,5 @@ +from codealmanac.integrations.harnesses.opencode.adapter import ( + OpencodeHarnessAdapter, +) + +__all__ = ["OpencodeHarnessAdapter"] diff --git a/src/codealmanac/integrations/harnesses/opencode/adapter.py b/src/codealmanac/integrations/harnesses/opencode/adapter.py new file mode 100644 index 00000000..503e1080 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/adapter.py @@ -0,0 +1,86 @@ +import subprocess +from pathlib import Path + +from codealmanac.integrations.command import ( + CommandRunner, + SubprocessCommandRunner, + first_line, +) +from codealmanac.integrations.harnesses.opencode.client import ( + OPENCODE_COMMAND, + OpencodeClient, +) +from codealmanac.services.harnesses.models import ( + HarnessKind, + HarnessReadiness, + HarnessRunResult, +) +from codealmanac.services.harnesses.ports import HarnessEventSink +from codealmanac.services.harnesses.requests import RunHarnessRequest + +OPENCODE_VERSION_TIMEOUT_SECONDS = 10 +OPENCODE_INSTALL_REPAIR = "install the OpenCode CLI: npm install -g opencode-ai" +OPENCODE_VERSION_REPAIR = ( + "check `opencode --version` — reinstall with `npm install -g opencode-ai` " + "if it fails" +) + + +class OpencodeHarnessAdapter: + kind = HarnessKind.OPENCODE + + def __init__( + self, + runner: CommandRunner | None = None, + command: str = OPENCODE_COMMAND, + version_timeout_seconds: int = OPENCODE_VERSION_TIMEOUT_SECONDS, + client: OpencodeClient | None = None, + ): + self.runner = runner or SubprocessCommandRunner() + self.command = command + self.version_timeout_seconds = version_timeout_seconds + self.client = client or OpencodeClient(command=command) + + def check(self) -> HarnessReadiness: + try: + result = self.runner.run( + self.command, + ("--version",), + Path.cwd(), + self.version_timeout_seconds, + ) + except FileNotFoundError: + return HarnessReadiness( + kind=self.kind, + available=False, + message="opencode not found on PATH", + repair=OPENCODE_INSTALL_REPAIR, + ) + except subprocess.TimeoutExpired: + return HarnessReadiness( + kind=self.kind, + available=False, + message="opencode --version timed out", + repair=OPENCODE_VERSION_REPAIR, + ) + if result.returncode != 0: + return HarnessReadiness( + kind=self.kind, + available=False, + message=first_line(result.stderr, result.stdout) + or f"opencode --version exited {result.returncode}", + repair=OPENCODE_VERSION_REPAIR, + ) + # opencode auth list always exits 0 and prints TUI-formatted text even + # with zero credentials, so it can't answer "is this ready" the way + # codex login status / claude auth status can. GET /config/providers + # on a briefly-started server is the structured alternative — see + # docs/plans/2026-07-07-opencode-harness.md "Spike findings". + return self.client.check_providers(Path.cwd()) + + def run( + self, + request: RunHarnessRequest, + on_event: HarnessEventSink | None = None, + ) -> HarnessRunResult: + return self.client.run(request, on_event) diff --git a/src/codealmanac/integrations/harnesses/opencode/client.py b/src/codealmanac/integrations/harnesses/opencode/client.py new file mode 100644 index 00000000..2c15658a --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/client.py @@ -0,0 +1,292 @@ +import threading +from pathlib import Path + +import httpx + +from codealmanac.core.paths import home_dir +from codealmanac.integrations.harnesses.fields import ( + JsonObject, + as_record, + string_field, +) +from codealmanac.integrations.harnesses.opencode.http import ( + create_session, + get_providers, + post_message, +) +from codealmanac.integrations.harnesses.opencode.model_ref import split_opencode_model +from codealmanac.integrations.harnesses.opencode.parts import final_text_from_parts +from codealmanac.integrations.harnesses.opencode.progress import ( + OPENCODE_POLL_INTERVAL_SECONDS, + OPENCODE_STUCK_TOOL_CALL_SECONDS, + OpencodeProgressWatchdog, + OpencodeStuckToolCallError, +) +from codealmanac.integrations.harnesses.opencode.result import ( + done_event, + failed_result, + provider_session_event, + result_from_state, +) +from codealmanac.integrations.harnesses.opencode.server import ( + OpencodeServerStartupError, + start_opencode_server, +) +from codealmanac.integrations.harnesses.opencode.state import OpencodeRunState +from codealmanac.integrations.harnesses.opencode.timeouts import env_seconds +from codealmanac.integrations.harnesses.opencode.usage import parse_opencode_usage +from codealmanac.integrations.harnesses.stream import ( + append_event, + emit_result, +) +from codealmanac.integrations.opencode_paths import OPENCODE_DB_RELATIVE_PATH +from codealmanac.services.harnesses.actors import ( + HarnessActorConfidence, + HarnessActorRole, +) +from codealmanac.services.harnesses.models import ( + HarnessEvent, + HarnessKind, + HarnessReadiness, + HarnessRunActor, + HarnessRunResult, +) +from codealmanac.services.harnesses.ports import HarnessEventSink +from codealmanac.services.harnesses.requests import RunHarnessRequest + +OPENCODE_COMMAND = "opencode" +OPENCODE_CHECK_STARTUP_TIMEOUT_SECONDS = 5.0 +OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS = 5.0 +OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS = 10.0 +OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS = 900.0 +OPENCODE_NOT_INSTALLED_MESSAGE = "opencode not found on PATH" +OPENCODE_SERVER_REPAIR = "run `opencode serve` directly to check for a startup error" +OPENCODE_PROVIDER_REPAIR = ( + "sign in with `opencode auth login` or configure a provider API key" +) +OPENCODE_POLL_INTERVAL_ENV = "CODEALMANAC_OPENCODE_POLL_INTERVAL_SECONDS" +OPENCODE_STUCK_TOOL_CALL_ENV = "CODEALMANAC_OPENCODE_STUCK_TOOL_CALL_SECONDS" +# How long the watchdog waits for the sender thread to notice a detected +# hang before the main loop's own join() check fires — short because we +# just need to react to watchdog.stuck_reason promptly, not wait it out. +OPENCODE_STUCK_CHECK_INTERVAL_SECONDS = 1.0 + + +class OpencodeClient: + def __init__( + self, + command: str = OPENCODE_COMMAND, + check_startup_timeout_seconds: float = OPENCODE_CHECK_STARTUP_TIMEOUT_SECONDS, + check_request_timeout_seconds: float = OPENCODE_CHECK_REQUEST_TIMEOUT_SECONDS, + run_startup_timeout_seconds: float = OPENCODE_RUN_STARTUP_TIMEOUT_SECONDS, + run_request_timeout_seconds: float = OPENCODE_RUN_REQUEST_TIMEOUT_SECONDS, + db_path: Path | None = None, + poll_interval_seconds: float | None = None, + stuck_after_seconds: float | None = None, + ): + self.command = command + self.check_startup_timeout_seconds = check_startup_timeout_seconds + self.check_request_timeout_seconds = check_request_timeout_seconds + self.run_startup_timeout_seconds = run_startup_timeout_seconds + self.run_request_timeout_seconds = run_request_timeout_seconds + self.db_path = db_path or home_dir() / OPENCODE_DB_RELATIVE_PATH + self.poll_interval_seconds = poll_interval_seconds or env_seconds( + OPENCODE_POLL_INTERVAL_ENV, OPENCODE_POLL_INTERVAL_SECONDS + ) + self.stuck_after_seconds = stuck_after_seconds or env_seconds( + OPENCODE_STUCK_TOOL_CALL_ENV, OPENCODE_STUCK_TOOL_CALL_SECONDS + ) + + def check_providers(self, cwd: Path) -> HarnessReadiness: + try: + with start_opencode_server( + self.command, cwd, self.check_startup_timeout_seconds + ) as server: + providers = get_providers( + server.base_url, self.check_request_timeout_seconds + ) + except FileNotFoundError: + return HarnessReadiness( + kind=HarnessKind.OPENCODE, + available=False, + message=OPENCODE_NOT_INSTALLED_MESSAGE, + ) + except OpencodeServerStartupError as error: + return HarnessReadiness( + kind=HarnessKind.OPENCODE, + available=False, + message=str(error), + repair=OPENCODE_SERVER_REPAIR, + ) + except httpx.HTTPError as error: + return HarnessReadiness( + kind=HarnessKind.OPENCODE, + available=False, + message=f"opencode server request failed: {error}", + repair=OPENCODE_SERVER_REPAIR, + ) + except ValueError as error: + # response.json() raises json.JSONDecodeError (a ValueError) on a + # malformed 200 body; mirrors the same except arm in run_once(). + return HarnessReadiness( + kind=HarnessKind.OPENCODE, + available=False, + message=f"opencode server returned an invalid response: {error}", + repair=OPENCODE_SERVER_REPAIR, + ) + if len(providers) == 0: + return HarnessReadiness( + kind=HarnessKind.OPENCODE, + available=False, + message="no opencode providers are configured", + repair=OPENCODE_PROVIDER_REPAIR, + ) + names = ", ".join(provider_label(provider) for provider in providers) + return HarnessReadiness( + kind=HarnessKind.OPENCODE, + available=True, + message=f"opencode providers configured: {names}", + ) + + def run( + self, + request: RunHarnessRequest, + on_event: HarnessEventSink | None = None, + ) -> HarnessRunResult: + # Mirrors check_providers()'s except chain above (same failure modes, + # different return shape) — keep the two in sync if either changes. + try: + return self.run_once(request, on_event) + except FileNotFoundError: + return emit_result(failed_result(OPENCODE_NOT_INSTALLED_MESSAGE), on_event) + except OpencodeServerStartupError as error: + return emit_result(failed_result(str(error)), on_event) + except OpencodeStuckToolCallError as error: + return emit_result(failed_result(str(error)), on_event) + except httpx.HTTPError as error: + return emit_result( + failed_result(f"opencode server request failed: {error}"), on_event + ) + except ValueError as error: + return emit_result(failed_result(str(error)), on_event) + + def run_once( + self, + request: RunHarnessRequest, + on_event: HarnessEventSink | None, + ) -> HarnessRunResult: + provider_id, model_id = split_opencode_model(request.model) + state = OpencodeRunState() + events: list[HarnessEvent] = [] + with start_opencode_server( + self.command, request.cwd, self.run_startup_timeout_seconds + ) as server: + session = create_session( + server.base_url, + str(request.cwd), + request.title or "codealmanac run", + self.run_request_timeout_seconds, + ) + session_id = string_field(session, "id") + if session_id is None: + raise OpencodeServerStartupError( + "opencode did not return a session id" + ) + state.provider_session_id = session_id + append_event(events, provider_session_event(session_id), on_event) + + actor = HarnessRunActor( + thread_id=session_id, + role=HarnessActorRole.ROOT, + confidence=HarnessActorConfidence.PROVIDER, + label="Main", + ) + + # Watchdog owns all live event emission for this session tree — + # both the "narrate progress" and "detect a hung tool call" + # goals share one poller. See progress.py and the + # 2026-07-09-opencode-harness-live-progress-and-hang-detection + # plan doc. + watchdog = OpencodeProgressWatchdog( + db_path=self.db_path, + root_session_id=session_id, + root_actor=actor, + state=state, + events=events, + on_event=on_event, + poll_interval_seconds=self.poll_interval_seconds, + stuck_after_seconds=self.stuck_after_seconds, + ) + stop_event = threading.Event() + watchdog_thread = threading.Thread( + target=watchdog.run, args=(stop_event,), daemon=True + ) + watchdog_thread.start() + + message_result: dict[str, JsonObject | Exception] = {} + + def _send() -> None: + try: + message_result["response"] = post_message( + server.base_url, + session_id, + str(request.cwd), + provider_id, + model_id, + request.prompt, + self.run_request_timeout_seconds, + ) + except Exception as error: # noqa: BLE001 - surfaced below + message_result["error"] = error + + sender_thread = threading.Thread(target=_send, daemon=True) + sender_thread.start() + + while sender_thread.is_alive(): + if watchdog.stuck_reason is not None: + # Unwinds through `with start_opencode_server(...)`, + # which terminates the server — killing the connection + # the sender thread is blocked on, so it errors out and + # dies (daemon; its error is discarded, ours is + # authoritative). No cross-thread cancellation needed. + raise OpencodeStuckToolCallError(watchdog.stuck_reason) + sender_thread.join(timeout=OPENCODE_STUCK_CHECK_INTERVAL_SECONDS) + # Loop exited because the sender thread finished, not because we + # raised above — deliberately no second stuck_reason check here. + # If post_message already produced a real result, that outcome + # wins over a heuristic that fired a beat too late; don't "fix" + # this into a race by adding one. + stop_event.set() + # A generous but bounded join: the watchdog is a daemon thread, + # so it can't block process exit either way. If this join times + # out mid-poll, a very-late event could in principle still land + # in `events`/on_event after the tuple(events) snapshot below is + # taken (append is GIL-atomic, so no crash/corruption) — low + # consequence since real callers persist events live via + # on_event, not by re-reading the returned result.events. + watchdog_thread.join(timeout=self.poll_interval_seconds * 2 + 5) + + if "error" in message_result: + raise message_result["error"] + response = message_result["response"] + info = as_record(response.get("info")) + raw_parts = response.get("parts") + parts = ( + [as_record(part) for part in raw_parts] + if isinstance(raw_parts, list) + else [] + ) + text = final_text_from_parts(parts) + if text is not None: + state.result = text + state.result_source_thread_id = session_id + state.result_source_role = HarnessActorRole.ROOT + state.usage = parse_opencode_usage(info.get("tokens")) + state.success = True + + append_event(events, done_event(state), on_event) + return result_from_state(state, events) + + +def provider_label(provider: JsonObject) -> str: + return string_field(provider, "name") or string_field(provider, "id") or "provider" diff --git a/src/codealmanac/integrations/harnesses/opencode/failures.py b/src/codealmanac/integrations/harnesses/opencode/failures.py new file mode 100644 index 00000000..5e95a876 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/failures.py @@ -0,0 +1,52 @@ +from codealmanac.services.harnesses.models import HarnessFailure, HarnessKind + + +def classify_opencode_failure(message: str, code: str | None = None) -> HarnessFailure: + if "not found on PATH" in message: + return HarnessFailure( + provider=HarnessKind.OPENCODE, + code="opencode.not_installed", + message="OpenCode was not found on PATH.", + fix=( + "Install OpenCode or update PATH so the `opencode` command " + "is available." + ), + raw=message, + ) + if ( + "did not report a listening port" in message + or "exited before it started listening" in message + ): + return HarnessFailure( + provider=HarnessKind.OPENCODE, + code="opencode.server_start_failed", + message=message, + fix="Run `opencode serve` directly to check for a startup error.", + raw=message, + ) + if "tool call has been stuck" in message: + return HarnessFailure( + provider=HarnessKind.OPENCODE, + code="opencode.stuck_tool_call", + message=message, + fix=( + "This is an upstream OpenCode reliability issue, not " + "specific to this run. Retrying often succeeds; if it " + "keeps happening, a different model may avoid the tool " + "call shape that triggers it." + ), + raw=message, + ) + if "timed out" in message: + return HarnessFailure( + provider=HarnessKind.OPENCODE, + code="opencode.timeout", + message=message, + raw=message, + ) + return HarnessFailure( + provider=HarnessKind.OPENCODE, + code=code or "opencode.request_failed", + message=message, + raw=message, + ) diff --git a/src/codealmanac/integrations/harnesses/opencode/http.py b/src/codealmanac/integrations/harnesses/opencode/http.py new file mode 100644 index 00000000..97d376b5 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/http.py @@ -0,0 +1,55 @@ +import httpx + +from codealmanac.integrations.harnesses.fields import JsonObject, as_record + +OPENCODE_ALLOW_ALL_PERMISSION: tuple[JsonObject, ...] = ( + {"permission": "*", "pattern": "*", "action": "allow"}, +) + + +def get_providers(base_url: str, timeout_seconds: float) -> tuple[JsonObject, ...]: + response = httpx.get(f"{base_url}/config/providers", timeout=timeout_seconds) + response.raise_for_status() + payload = as_record(response.json()) + providers = payload.get("providers") + if not isinstance(providers, list): + return () + return tuple(as_record(item) for item in providers if isinstance(item, dict)) + + +def create_session( + base_url: str, + cwd_directory: str, + title: str, + timeout_seconds: float, +) -> JsonObject: + response = httpx.post( + f"{base_url}/session", + params={"directory": cwd_directory}, + json={"title": title, "permission": list(OPENCODE_ALLOW_ALL_PERMISSION)}, + timeout=timeout_seconds, + ) + response.raise_for_status() + return as_record(response.json()) + + +def post_message( + base_url: str, + session_id: str, + cwd_directory: str, + provider_id: str, + model_id: str, + prompt: str, + timeout_seconds: float, +) -> JsonObject: + response = httpx.post( + f"{base_url}/session/{session_id}/message", + params={"directory": cwd_directory}, + json={ + "model": {"providerID": provider_id, "modelID": model_id}, + "parts": [{"type": "text", "text": prompt}], + }, + timeout=timeout_seconds, + ) + response.raise_for_status() + return as_record(response.json()) diff --git a/src/codealmanac/integrations/harnesses/opencode/model_ref.py b/src/codealmanac/integrations/harnesses/opencode/model_ref.py new file mode 100644 index 00000000..0547df1e --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/model_ref.py @@ -0,0 +1,8 @@ +OPENCODE_MODEL_SEPARATOR = "/" + + +def split_opencode_model(model: str) -> tuple[str, str]: + provider_id, separator, model_id = model.partition(OPENCODE_MODEL_SEPARATOR) + if separator == "" or provider_id == "" or model_id == "": + raise ValueError(f'opencode model must be "provider/model", got: {model!r}') + return provider_id, model_id diff --git a/src/codealmanac/integrations/harnesses/opencode/parts.py b/src/codealmanac/integrations/harnesses/opencode/parts.py new file mode 100644 index 00000000..c0b1f666 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/parts.py @@ -0,0 +1,220 @@ +from codealmanac.integrations.harnesses.fields import ( + JsonObject, + as_record, + number_field, + string_field, + stringify_json_value, +) +from codealmanac.integrations.harnesses.opencode.usage import parse_opencode_usage +from codealmanac.services.harnesses.models import ( + HarnessEvent, + HarnessEventKind, + HarnessRunActor, + HarnessToolDisplay, + HarnessToolDisplayKind, + HarnessToolStatus, + HarnessUsage, +) + +_TOOL_TITLES = { + HarnessToolDisplayKind.READ: "Reading file", + HarnessToolDisplayKind.WRITE: "Writing file", + HarnessToolDisplayKind.EDIT: "Editing file", + HarnessToolDisplayKind.SEARCH: "Searching", + HarnessToolDisplayKind.SHELL: "Running command", + HarnessToolDisplayKind.WEB: "Web request", + HarnessToolDisplayKind.AGENT: "Agent tool", +} + + +def map_opencode_part( + part: JsonObject, + actor: HarnessRunActor, +) -> tuple[HarnessEvent, ...]: + part_type = string_field(part, "type") + if part_type == "text": + return text_events(part, actor) + if part_type == "reasoning": + return reasoning_events(part, actor) + if part_type == "tool": + return tool_events(part, actor) + if part_type == "patch": + return patch_events(part, actor) + if part_type == "step-finish": + return step_finish_events(part, actor) + return () + + +def text_events(part: JsonObject, actor: HarnessRunActor) -> tuple[HarnessEvent, ...]: + text = string_field(part, "text") + if text is None: + return () + return ( + HarnessEvent(kind=HarnessEventKind.TEXT, message=text, actor=actor, raw=part), + ) + + +def reasoning_events( + part: JsonObject, + actor: HarnessRunActor, +) -> tuple[HarnessEvent, ...]: + text = string_field(part, "text") + if text is None: + return () + return ( + HarnessEvent( + kind=HarnessEventKind.TOOL_SUMMARY, + message=text, + actor=actor, + raw=part, + ), + ) + + +def tool_events(part: JsonObject, actor: HarnessRunActor) -> tuple[HarnessEvent, ...]: + tool_name = string_field(part, "tool") or "tool" + call_id = string_field(part, "callID") + state = as_record(part.get("state")) + display = opencode_tool_display(tool_name, state) + use_event = HarnessEvent( + kind=HarnessEventKind.TOOL_USE, + message=display.title or tool_name, + actor=actor, + tool_id=call_id, + tool_name=tool_name, + tool_input=stringify_json_value(state.get("input")), + tool_display=display, + raw=part, + ) + result_event = HarnessEvent( + kind=HarnessEventKind.TOOL_RESULT, + message=display.title or f"{tool_name} completed", + actor=actor, + tool_id=call_id, + tool_name=tool_name, + tool_display=display, + tool_result=state.get("output"), + tool_is_error=display.status == HarnessToolStatus.FAILED, + raw=part, + ) + return (use_event, result_event) + + +def opencode_tool_display(tool_name: str, state: JsonObject) -> HarnessToolDisplay: + metadata = as_record(state.get("metadata")) + input_record = as_record(state.get("input")) + kind = infer_opencode_tool_kind(tool_name) + return HarnessToolDisplay( + kind=kind, + title=string_field(state, "title") or _TOOL_TITLES.get(kind, tool_name), + path=string_field(input_record, "filePath") + or string_field(input_record, "path"), + command=string_field(input_record, "command"), + status=opencode_tool_status(state), + exit_code=number_field(metadata, "exit"), + ) + + +def opencode_tool_status(state: JsonObject) -> HarnessToolStatus: + status = string_field(state, "status") + if status == "error": + return HarnessToolStatus.FAILED + return HarnessToolStatus.COMPLETED + + +def infer_opencode_tool_kind(tool: str) -> HarnessToolDisplayKind: + normalized = tool.lower() + if "read" in normalized: + return HarnessToolDisplayKind.READ + if "write" in normalized: + return HarnessToolDisplayKind.WRITE + if "edit" in normalized or "patch" in normalized: + return HarnessToolDisplayKind.EDIT + if any(word in normalized for word in ("grep", "glob", "search", "find", "ls")): + return HarnessToolDisplayKind.SEARCH + if "bash" in normalized or "shell" in normalized: + return HarnessToolDisplayKind.SHELL + if "web" in normalized or "fetch" in normalized: + return HarnessToolDisplayKind.WEB + if "task" in normalized or "agent" in normalized: + return HarnessToolDisplayKind.AGENT + return HarnessToolDisplayKind.UNKNOWN + + +def patch_events(part: JsonObject, actor: HarnessRunActor) -> tuple[HarnessEvent, ...]: + files = part.get("files") + if not isinstance(files, list) or len(files) == 0: + return () + names = ", ".join(str(item) for item in files) + return ( + HarnessEvent( + kind=HarnessEventKind.TOOL_SUMMARY, + message=f"files changed: {names}", + actor=actor, + raw=part, + ), + ) + + +def step_finish_events( + part: JsonObject, + actor: HarnessRunActor, +) -> tuple[HarnessEvent, ...]: + usage = parse_opencode_usage(part.get("tokens")) + if usage is None: + return () + return ( + HarnessEvent( + kind=HarnessEventKind.CONTEXT_USAGE, + message=usage_message(usage), + actor=actor, + usage=usage, + raw=part, + ), + ) + + +def usage_message(usage: HarnessUsage) -> str: + if usage.total_tokens is not None: + return f"usage: {usage.total_tokens} tokens" + return "usage updated" + + +def final_text_from_parts(parts: list[JsonObject]) -> str | None: + for part in reversed(parts): + if string_field(part, "type") == "text": + text = string_field(part, "text") + if text is not None: + return text + return None + + +def is_task_spawn(part: JsonObject) -> tuple[str, str] | None: + """(child_session_id, prompt) if this part spawns a sub-agent session.""" + if string_field(part, "type") != "tool" or string_field(part, "tool") != "task": + return None + state = as_record(part.get("state")) + metadata = as_record(state.get("metadata")) + child_session_id = string_field(metadata, "sessionId") + if child_session_id is None: + return None + input_record = as_record(state.get("input")) + prompt = string_field(input_record, "prompt") or string_field( + input_record, "description" + ) + return child_session_id, (prompt or "") + + +def is_task_settled(part: JsonObject) -> HarnessToolStatus | None: + """Completed/failed status once a task-tool part resolves, else None.""" + if string_field(part, "type") != "tool" or string_field(part, "tool") != "task": + return None + state = as_record(part.get("state")) + status = string_field(state, "status") + if status == "completed": + return HarnessToolStatus.COMPLETED + if status == "error": + return HarnessToolStatus.FAILED + return None + + diff --git a/src/codealmanac/integrations/harnesses/opencode/progress.py b/src/codealmanac/integrations/harnesses/opencode/progress.py new file mode 100644 index 00000000..62fb8a3c --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/progress.py @@ -0,0 +1,278 @@ +import json +import threading +import time +from dataclasses import dataclass +from pathlib import Path + +from codealmanac.database import query_readonly_or_empty +from codealmanac.integrations.harnesses.fields import ( + JsonObject, + as_record, + number_field, + string_field, + stringify_json_value, +) +from codealmanac.integrations.harnesses.opencode.parts import ( + is_task_settled, + is_task_spawn, + map_opencode_part, +) +from codealmanac.integrations.harnesses.opencode.state import OpencodeRunState +from codealmanac.services.harnesses.actors import ( + HarnessActorConfidence, + HarnessActorRole, +) +from codealmanac.services.harnesses.models import ( + HarnessAgentTrace, + HarnessEvent, + HarnessEventKind, + HarnessRunActor, + HarnessToolStatus, +) +from codealmanac.services.harnesses.ports import HarnessEventSink + +# Joins message to filter to assistant-authored parts only — part rows +# alone don't carry role, and a session's own part table includes the +# user's/prompt's echoed-back input parts too (confirmed by a live smoke +# test: without this filter, the prompt itself showed up as a spurious +# TEXT event). The synchronous POST response never had this problem +# because it only ever returns the new assistant message's parts; polling +# the whole session table needs to filter for the same thing explicitly. +_PARTS_QUERY = """ +SELECT part.id AS id, part.data AS part_data, message.data AS message_data +FROM part +JOIN message ON message.id = part.message_id +WHERE part.session_id = ? +ORDER BY part.time_created +""" + +OPENCODE_POLL_INTERVAL_SECONDS = 2.0 +OPENCODE_STUCK_TOOL_CALL_SECONDS = 240.0 +# Upstream tracking issue confirmed live (WebSearch, 2026-07-09) — OpenCode's +# tool-execution layer has no internal timeout, so a glob/read/bash call can +# hang forever regardless of model. Not fixable from this adapter, only +# detectable — see the 2026-07-09-opencode-harness-live-progress-and-hang- +# detection plan doc. +OPENCODE_STUCK_TOOL_CALL_ISSUE_URL = "github.com/anomalyco/opencode/issues/33541" + + +@dataclass +class OpencodeStuckToolCall: + tool_name: str + session_id: str + elapsed_seconds: float + tool_input: str | None = None + + +class OpencodeStuckToolCallError(Exception): + def __init__(self, info: OpencodeStuckToolCall): + self.info = info + super().__init__( + f'OpenCode\'s "{info.tool_name}" tool call has been stuck for ' + f"{int(info.elapsed_seconds)}s+ with no response (session " + f"{info.session_id}) — this is a known upstream OpenCode " + f"reliability issue ({OPENCODE_STUCK_TOOL_CALL_ISSUE_URL}), not " + "specific to this run." + ) + + +class OpencodeProgressWatchdog: + """Polls OpenCode's own SQLite db while a run is in flight: emits live + HarnessEvents for new parts across the root session and any sub-agent + sessions it discovers, and detects a tool call stuck past a threshold. + + Reuses the read-only querying built for transcript reading (Slice 3), + not the HTTP API — proven reliable in this codebase; OpenCode's SSE + stream was spiked and found unreliable for this exact purpose (see the + plan doc's "Why polling the DB, not retrying SSE"). + + "Live" only covers terminal parts: a tool call still status: "running" + produces no TOOL_USE narration yet (map_opencode_part isn't called + until it settles — see _poll_session) — it only feeds _check_stuck. + You won't see "reading file X" while it happens, only once it's done + (or once it's been flagged stuck). Confirmed against the plan doc's + live-verification timestamps, which cluster right after each tool call + completes, not at call-start. + """ + + def __init__( + self, + db_path: Path, + root_session_id: str, + root_actor: HarnessRunActor, + state: OpencodeRunState, + events: list[HarnessEvent], + on_event: HarnessEventSink | None, + poll_interval_seconds: float = OPENCODE_POLL_INTERVAL_SECONDS, + stuck_after_seconds: float = OPENCODE_STUCK_TOOL_CALL_SECONDS, + ): + self.db_path = db_path + self.state = state + self.events = events + self.on_event = on_event + self.poll_interval_seconds = poll_interval_seconds + self.stuck_after_seconds = stuck_after_seconds + + self._known_sessions: set[str] = {root_session_id} + self._actors: dict[str, HarnessRunActor] = {root_session_id: root_actor} + self._seen_part_ids: set[str] = set() + self._spawned_part_ids: set[str] = set() + self.stuck_reason: OpencodeStuckToolCall | None = None + + def run(self, stop_event: threading.Event) -> None: + while not stop_event.is_set(): + self._poll_once() + if self.stuck_reason is not None: + return + stop_event.wait(self.poll_interval_seconds) + # One final pass: the DB is fully written by the time the caller + # sets stop_event (only done after the blocking POST returns), so + # this picks up anything the last timed poll cycle missed. + self._poll_once() + + def _poll_once(self) -> None: + # Snapshot: polling one session can discover new ones mid-loop. + for session_id in tuple(self._known_sessions): + self._poll_session(session_id) + if self.stuck_reason is not None: + return + + def _poll_session(self, session_id: str) -> None: + actor = self._actors.get(session_id) + if actor is None: + return + rows = query_readonly_or_empty(self.db_path, _PARTS_QUERY, (session_id,)) + now_ms = int(time.time() * 1000) + for row in rows: + part_id = row["id"] + if part_id in self._seen_part_ids: + continue + part = _parse_part(row["part_data"]) + if part is None: + continue + message = _parse_part(row["message_data"]) + if message is not None and string_field(message, "role") != "assistant": + # The user's/prompt's own part, echoed back on the same + # session — not model output, don't map or stuck-check it. + self._seen_part_ids.add(part_id) + continue + + spawn = is_task_spawn(part) + if spawn is not None and part_id not in self._spawned_part_ids: + self._spawned_part_ids.add(part_id) + self._handle_spawn(session_id, spawn) + + if string_field(part, "type") == "tool": + state = as_record(part.get("state")) + if string_field(state, "status") == "running": + self._check_stuck(session_id, part, state, now_ms) + if self.stuck_reason is not None: + return + continue # not terminal yet — re-check next poll + settled = is_task_settled(part) + if settled is not None: + self._handle_settle(part, settled) + + self._seen_part_ids.add(part_id) + for mapped in map_opencode_part(part, actor): + self._emit(mapped) + + def _check_stuck( + self, + session_id: str, + part: JsonObject, + state: JsonObject, + now_ms: int, + ) -> None: + time_info = as_record(state.get("time")) + start_ms = number_field(time_info, "start") + if start_ms is None: + return + elapsed_seconds = (now_ms - start_ms) / 1000 + if elapsed_seconds >= self.stuck_after_seconds: + self.stuck_reason = OpencodeStuckToolCall( + tool_name=string_field(part, "tool") or "tool", + session_id=session_id, + elapsed_seconds=elapsed_seconds, + tool_input=stringify_json_value(state.get("input")), + ) + + def _handle_spawn(self, parent_session_id: str, spawn: tuple[str, str]) -> None: + child_session_id, prompt = spawn + if child_session_id in self._known_sessions: + return + self._known_sessions.add(child_session_id) + self.state.agent_parents[child_session_id] = parent_session_id + label = f"Helper {len(self.state.agent_labels) + 1}" + self.state.agent_labels[child_session_id] = label + child_actor = HarnessRunActor( + thread_id=child_session_id, + role=HarnessActorRole.HELPER, + parent_thread_id=parent_session_id, + confidence=HarnessActorConfidence.DERIVED, + label=label, + ) + self._actors[child_session_id] = child_actor + self._emit( + HarnessEvent( + kind=HarnessEventKind.AGENT_SPAWNED, + message=f"spawned {label}", + actor=self._actors.get(parent_session_id), + agent_trace=HarnessAgentTrace( + parent_thread_id=parent_session_id, + child_thread_id=child_session_id, + prompt=prompt or None, + ), + ) + ) + + def _handle_settle( + self, + part: JsonObject, + settled_status: HarnessToolStatus, + ) -> None: + state = as_record(part.get("state")) + metadata = as_record(state.get("metadata")) + child_session_id = string_field(metadata, "sessionId") + if child_session_id is None: + return + child_actor = self._actors.get(child_session_id) + if child_actor is None: + return + label = self.state.agent_labels.get(child_session_id, "helper") + if settled_status == HarnessToolStatus.FAILED: + self._emit( + HarnessEvent( + kind=HarnessEventKind.ERROR, + message=f"{label} failed", + actor=child_actor, + ) + ) + return + self._emit( + HarnessEvent( + kind=HarnessEventKind.AGENT_COMPLETED, + message=f"{label} completed", + actor=child_actor, + agent_trace=HarnessAgentTrace( + parent_thread_id=self.state.agent_parents.get(child_session_id), + child_thread_id=child_session_id, + result=string_field(state, "output"), + ), + ) + ) + + def _emit(self, event: HarnessEvent) -> None: + self.events.append(event) + if self.on_event is not None: + self.on_event(event) + + +def _parse_part(value: object) -> JsonObject | None: + if not isinstance(value, str): + return None + try: + parsed = json.loads(value) + except ValueError: + return None + return parsed if isinstance(parsed, dict) else None diff --git a/src/codealmanac/integrations/harnesses/opencode/result.py b/src/codealmanac/integrations/harnesses/opencode/result.py new file mode 100644 index 00000000..a10e81f1 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/result.py @@ -0,0 +1,64 @@ +from codealmanac.integrations.harnesses.opencode.failures import ( + classify_opencode_failure, +) +from codealmanac.integrations.harnesses.opencode.state import OpencodeRunState +from codealmanac.services.harnesses.models import ( + HarnessEvent, + HarnessEventKind, + HarnessKind, + HarnessRunResult, + HarnessRunStatus, +) + + +def result_from_state( + state: OpencodeRunState, + events: list[HarnessEvent], +) -> HarnessRunResult: + succeeded = state.success and state.failure is None + output_text = state.result or state.error or "opencode completed" + return HarnessRunResult( + kind=HarnessKind.OPENCODE, + status=HarnessRunStatus.SUCCEEDED if succeeded else HarnessRunStatus.FAILED, + output_text=output_text, + summary=output_text.splitlines()[0], + events=tuple(events), + ) + + +def failed_result(message: str) -> HarnessRunResult: + failure = classify_opencode_failure(message) + event = HarnessEvent( + kind=HarnessEventKind.ERROR, + message=failure.message, + failure=failure, + ) + return HarnessRunResult( + kind=HarnessKind.OPENCODE, + status=HarnessRunStatus.FAILED, + output_text=failure.message, + summary=failure.message, + events=(event,), + ) + + +def done_event(state: OpencodeRunState) -> HarnessEvent: + status = "succeeded" if state.failure is None else "failed" + result = state.result or state.error or "opencode completed" + return HarnessEvent( + kind=HarnessEventKind.DONE, + message=f"opencode {status}: {result.splitlines()[0]}", + provider_session_id=state.provider_session_id, + usage=state.usage, + failure=state.failure, + source_thread_id=state.result_source_thread_id, + source_role=state.result_source_role, + ) + + +def provider_session_event(session_id: str) -> HarnessEvent: + return HarnessEvent( + kind=HarnessEventKind.PROVIDER_SESSION, + message=f"opencode provider session {session_id}", + provider_session_id=session_id, + ) diff --git a/src/codealmanac/integrations/harnesses/opencode/server.py b/src/codealmanac/integrations/harnesses/opencode/server.py new file mode 100644 index 00000000..203a7d29 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/server.py @@ -0,0 +1,108 @@ +import queue +import re +import shutil +import subprocess +import threading +import time +from pathlib import Path + +OPENCODE_COMMAND = "opencode" +OPENCODE_SERVER_STARTUP_TIMEOUT_SECONDS = 10.0 +OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS = 5.0 +_LISTENING_PATTERN = re.compile(r"listening on http://127\.0\.0\.1:(\d+)") + + +class OpencodeServerStartupError(Exception): + pass + + +class OpencodeServerProcess: + def __init__(self, process: subprocess.Popen[str], base_url: str): + self.process = process + self.base_url = base_url + + def __enter__(self) -> "OpencodeServerProcess": + return self + + def __exit__(self, *exc_info: object) -> None: + self.terminate() + + def terminate(self) -> None: + if self.process.poll() is not None: + return + self.process.terminate() + try: + self.process.wait(timeout=OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS) + + +def start_opencode_server( + command: str, + cwd: Path, + startup_timeout_seconds: float = OPENCODE_SERVER_STARTUP_TIMEOUT_SECONDS, +) -> OpencodeServerProcess: + # Windows: npm-installed opencode ships as a .cmd/.ps1 shim, and + # subprocess.Popen(shell=False) can't launch a bare command name through + # CreateProcess the way a shell would — mirrors the same fix in + # integrations/command.py's SubprocessCommandRunner. Without this, + # check()'s `opencode --version` fast path (which does go through + # SubprocessCommandRunner) can report "available" while every real + # server-spawning call here still raises FileNotFoundError on Windows. + resolved = shutil.which(command) or command + process = subprocess.Popen( + (resolved, "serve", "--port", "0"), + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + try: + base_url = _wait_for_listening(process, startup_timeout_seconds) + except Exception: + process.terminate() + try: + process.wait(timeout=OPENCODE_SERVER_TERMINATE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + raise + return OpencodeServerProcess(process=process, base_url=base_url) + + +def _wait_for_listening(process: subprocess.Popen[str], timeout_seconds: float) -> str: + assert process.stdout is not None + lines: queue.Queue[str | None] = queue.Queue() + + def _reader() -> None: + assert process.stdout is not None + for line in process.stdout: + lines.put(line) + lines.put(None) + + # A background thread draining stdout, not a blocking readline() loop: + # readline() blocks past any wall-clock deadline check between calls, + # and select() on pipes isn't available on Windows, so a thread+queue is + # the cross-platform-safe way to read with a timeout. + threading.Thread(target=_reader, daemon=True).start() + + deadline = time.monotonic() + timeout_seconds + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise OpencodeServerStartupError( + "opencode serve did not report a listening port in time" + ) + try: + line = lines.get(timeout=remaining) + except queue.Empty as error: + raise OpencodeServerStartupError( + "opencode serve did not report a listening port in time" + ) from error + if line is None: + raise OpencodeServerStartupError( + "opencode serve exited before it started listening" + ) + match = _LISTENING_PATTERN.search(line) + if match is not None: + return f"http://127.0.0.1:{match.group(1)}" diff --git a/src/codealmanac/integrations/harnesses/opencode/state.py b/src/codealmanac/integrations/harnesses/opencode/state.py new file mode 100644 index 00000000..50e5abd8 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/state.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from codealmanac.services.harnesses.models import ( + HarnessActorRole, + HarnessFailure, + HarnessUsage, +) + + +@dataclass +class OpencodeRunState: + success: bool = False + result: str = "" + provider_session_id: str | None = None + usage: HarnessUsage | None = None + error: str | None = None + failure: HarnessFailure | None = None + result_source_thread_id: str | None = None + result_source_role: HarnessActorRole | None = None + # Populated by OpencodeProgressWatchdog (progress.py) as it discovers + # sub-agent sessions via "task" tool calls — see + # docs/plans/2026-07-09-opencode-harness-live-progress-and-hang-detection.md. + agent_parents: dict[str, str | None] = field(default_factory=dict) + agent_labels: dict[str, str] = field(default_factory=dict) diff --git a/src/codealmanac/integrations/harnesses/opencode/timeouts.py b/src/codealmanac/integrations/harnesses/opencode/timeouts.py new file mode 100644 index 00000000..86f7b787 --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/timeouts.py @@ -0,0 +1,14 @@ +import os + + +def env_seconds(name: str, fallback: float) -> float: + value = os.environ.get(name) + if value is None: + return fallback + try: + parsed = float(value) + except ValueError: + return fallback + if parsed <= 0: + return fallback + return parsed diff --git a/src/codealmanac/integrations/harnesses/opencode/usage.py b/src/codealmanac/integrations/harnesses/opencode/usage.py new file mode 100644 index 00000000..81146fde --- /dev/null +++ b/src/codealmanac/integrations/harnesses/opencode/usage.py @@ -0,0 +1,18 @@ +from pydantic import JsonValue + +from codealmanac.integrations.harnesses.fields import as_record, number_field +from codealmanac.services.harnesses.models import HarnessUsage + + +def parse_opencode_usage(value: JsonValue | None) -> HarnessUsage | None: + obj = as_record(value) + if len(obj) == 0: + return None + cache = as_record(obj.get("cache")) + return HarnessUsage( + input_tokens=number_field(obj, "input"), + cached_input_tokens=number_field(cache, "read"), + output_tokens=number_field(obj, "output"), + reasoning_output_tokens=number_field(obj, "reasoning"), + total_tokens=number_field(obj, "total"), + ) diff --git a/src/codealmanac/integrations/harnesses/codex/stream.py b/src/codealmanac/integrations/harnesses/stream.py similarity index 100% rename from src/codealmanac/integrations/harnesses/codex/stream.py rename to src/codealmanac/integrations/harnesses/stream.py diff --git a/src/codealmanac/integrations/opencode_paths.py b/src/codealmanac/integrations/opencode_paths.py new file mode 100644 index 00000000..09096af6 --- /dev/null +++ b/src/codealmanac/integrations/opencode_paths.py @@ -0,0 +1,9 @@ +from pathlib import Path + +# OpenCode's local SQLite database, confirmed live during the 2026-07-08 +# spike (docs/plans/2026-07-07-opencode-harness.md "Spike findings") and +# used by both integrations/sources/transcripts/opencode.py (historical +# session reading) and integrations/harnesses/opencode/progress.py (live +# run polling) — shared here so the two don't carry independent copies of +# a deployment-path assumption already flagged as unverified on Windows. +OPENCODE_DB_RELATIVE_PATH = Path(".local/share/opencode/opencode.db") diff --git a/src/codealmanac/integrations/setup/instructions.py b/src/codealmanac/integrations/setup/instructions.py index 4031db12..a5a0ca06 100644 --- a/src/codealmanac/integrations/setup/instructions.py +++ b/src/codealmanac/integrations/setup/instructions.py @@ -1,7 +1,7 @@ from pathlib import Path from codealmanac.core.paths import home_dir -from codealmanac.integrations.setup import claude, codex, managed_blocks +from codealmanac.integrations.setup import claude, codex, managed_blocks, opencode from codealmanac.integrations.setup.guide import read_agent_guide from codealmanac.services.setup.models import InstructionChange, SetupTarget @@ -50,6 +50,8 @@ def install_target(target: SetupTarget, home: Path, guide: str) -> InstructionCh return codex.install_codex_instructions(home, guide) if target == SetupTarget.CLAUDE: return claude.install_claude_instructions(home, guide) + if target == SetupTarget.OPENCODE: + return opencode.install_opencode_instructions(home, guide) raise ValueError(f"unsupported setup target: {target.value}") @@ -58,4 +60,6 @@ def uninstall_target(target: SetupTarget, home: Path) -> InstructionChange: return codex.uninstall_codex_instructions(home) if target == SetupTarget.CLAUDE: return claude.uninstall_claude_instructions(home) + if target == SetupTarget.OPENCODE: + return opencode.uninstall_opencode_instructions(home) raise ValueError(f"unsupported setup target: {target.value}") diff --git a/src/codealmanac/integrations/setup/opencode.py b/src/codealmanac/integrations/setup/opencode.py new file mode 100644 index 00000000..27cf267f --- /dev/null +++ b/src/codealmanac/integrations/setup/opencode.py @@ -0,0 +1,62 @@ +from pathlib import Path + +from codealmanac.integrations.setup.managed_blocks import ( + format_managed_block, + remove_managed_block, + upsert_managed_block, +) +from codealmanac.integrations.setup.text_files import read_text_if_present +from codealmanac.services.setup.models import InstructionChange, SetupTarget + + +def install_opencode_instructions(home: Path, guide: str) -> InstructionChange: + opencode_dir = home / ".config" / "opencode" + opencode_dir.mkdir(parents=True, exist_ok=True) + agents_path = opencode_dir / "AGENTS.md" + existing = read_text_if_present(agents_path) + block = format_managed_block(guide) + next_body = upsert_managed_block(existing, block) + if next_body == existing: + return InstructionChange( + target=SetupTarget.OPENCODE, + changed=False, + paths=(agents_path,), + message="OpenCode instructions already installed", + ) + agents_path.write_text(next_body, encoding="utf-8") + return InstructionChange( + target=SetupTarget.OPENCODE, + changed=True, + paths=(agents_path,), + message="Installed OpenCode AGENTS instructions", + ) + + +def uninstall_opencode_instructions(home: Path) -> InstructionChange: + agents_path = home / ".config" / "opencode" / "AGENTS.md" + existing = read_text_if_present(agents_path) + if existing == "": + return InstructionChange( + target=SetupTarget.OPENCODE, + changed=False, + paths=(), + message="OpenCode instructions were not installed", + ) + removed = remove_managed_block(existing) + if removed == existing: + return InstructionChange( + target=SetupTarget.OPENCODE, + changed=False, + paths=(), + message="OpenCode instructions were not installed", + ) + if removed.strip() == "": + agents_path.unlink(missing_ok=True) + else: + agents_path.write_text(removed, encoding="utf-8") + return InstructionChange( + target=SetupTarget.OPENCODE, + changed=True, + paths=(agents_path,), + message="Removed OpenCode AGENTS instructions", + ) diff --git a/src/codealmanac/integrations/sources/transcripts/__init__.py b/src/codealmanac/integrations/sources/transcripts/__init__.py index 8b72f669..93558255 100644 --- a/src/codealmanac/integrations/sources/transcripts/__init__.py +++ b/src/codealmanac/integrations/sources/transcripts/__init__.py @@ -4,6 +4,10 @@ from codealmanac.integrations.sources.transcripts.codex import ( CodexTranscriptDiscoveryAdapter, ) +from codealmanac.integrations.sources.transcripts.opencode import ( + OpencodeTranscriptDiscoveryAdapter, + OpencodeTranscriptSourceRuntimeAdapter, +) from codealmanac.integrations.sources.transcripts.runtime import ( TranscriptSourceRuntimeAdapter, ) @@ -17,16 +21,27 @@ def default_transcript_discovery_adapters() -> tuple[TranscriptDiscoveryAdapter, return ( ClaudeTranscriptDiscoveryAdapter(), CodexTranscriptDiscoveryAdapter(), + OpencodeTranscriptDiscoveryAdapter(), ) def default_transcript_runtime_adapters() -> tuple[SourceRuntimeAdapter, ...]: - return (TranscriptSourceRuntimeAdapter(),) + # Order no longer matters: TranscriptSourceRuntimeAdapter.supports() + # explicitly excludes OpenCode-shaped (db-path::session-id) refs, so the + # two adapters' supports() are disjoint rather than one being a superset + # of the other. See runtime.py and test_opencode_transcripts.py's + # ordering regression test (kept as defense-in-depth). + return ( + OpencodeTranscriptSourceRuntimeAdapter(), + TranscriptSourceRuntimeAdapter(), + ) __all__ = [ "ClaudeTranscriptDiscoveryAdapter", "CodexTranscriptDiscoveryAdapter", + "OpencodeTranscriptDiscoveryAdapter", + "OpencodeTranscriptSourceRuntimeAdapter", "TranscriptSourceRuntimeAdapter", "default_transcript_discovery_adapters", "default_transcript_runtime_adapters", diff --git a/src/codealmanac/integrations/sources/transcripts/opencode.py b/src/codealmanac/integrations/sources/transcripts/opencode.py new file mode 100644 index 00000000..86a600ec --- /dev/null +++ b/src/codealmanac/integrations/sources/transcripts/opencode.py @@ -0,0 +1,117 @@ +from datetime import UTC, datetime +from pathlib import Path + +from codealmanac.core.paths import normalize_path +from codealmanac.integrations.opencode_paths import OPENCODE_DB_RELATIVE_PATH +from codealmanac.integrations.sources.transcripts.errors import unavailable_runtime +from codealmanac.integrations.sources.transcripts.opencode_db import ( + list_opencode_sessions, + read_opencode_session_entries, +) +from codealmanac.integrations.sources.transcripts.opencode_ref import ( + format_opencode_transcript_ref, + parse_opencode_transcript_ref, +) +from codealmanac.integrations.sources.transcripts.rendering import ( + render_transcript_runtime, +) +from codealmanac.services.sources.models import ( + SourceKind, + SourceRef, + SourceRuntime, + SourceRuntimeStatus, + TranscriptApp, + TranscriptCandidate, +) +from codealmanac.services.sources.requests import ( + DiscoverTranscriptsRequest, + InspectSourceRuntimeRequest, +) + +DEFAULT_MAX_CHARS = 60_000 + + +class OpencodeTranscriptDiscoveryAdapter: + app = TranscriptApp.OPENCODE + + def __init__(self, db_path: Path | None = None): + self.db_path = db_path + + def discover( + self, + request: DiscoverTranscriptsRequest, + ) -> tuple[TranscriptCandidate, ...]: + path = self.db_path or request.home / OPENCODE_DB_RELATIVE_PATH + if not path.is_file(): + return () + size_bytes = path.stat().st_size + resolved_path = normalize_path(path) + candidates = [] + for session_id, directory, time_updated_ms in list_opencode_sessions(path): + candidates.append( + TranscriptCandidate( + app=TranscriptApp.OPENCODE, + session_id=session_id, + transcript_path=resolved_path, + cwd=normalize_path(Path(directory)), + modified_at=datetime.fromtimestamp(time_updated_ms / 1000, UTC), + size_bytes=size_bytes, + address_override=format_opencode_transcript_ref( + resolved_path, session_id + ), + ) + ) + return tuple(candidates) + + +class OpencodeTranscriptSourceRuntimeAdapter: + def __init__(self, max_chars: int = DEFAULT_MAX_CHARS): + self.max_chars = max_chars + + def supports(self, ref: SourceRef) -> bool: + if ref.kind != SourceKind.TRANSCRIPT or ref.transcript is None: + return False + return parse_opencode_transcript_ref(ref.transcript) is not None + + def inspect(self, request: InspectSourceRuntimeRequest) -> SourceRuntime: + parsed = ( + parse_opencode_transcript_ref(request.ref.transcript) + if request.ref.transcript is not None + else None + ) + if parsed is None: + return unavailable_runtime( + request.ref, + "Transcript unavailable", + "opencode transcript source requires a db path and session id", + ) + db_path, session_id = parsed + if not db_path.is_file(): + return unavailable_runtime( + request.ref, + "Transcript unavailable", + f"opencode database not found: {db_path}", + ) + entries = read_opencode_session_entries(db_path, session_id) + if len(entries) == 0: + return unavailable_runtime( + request.ref, + f"Transcript {db_path} ({session_id})", + "no readable session content found", + ) + content, truncated = render_transcript_runtime( + db_path, entries, self.max_chars + ) + return SourceRuntime( + ref=request.ref, + status=SourceRuntimeStatus.AVAILABLE, + title=f"Transcript {db_path} ({session_id})", + content=content, + truncated=truncated, + ) + + +__all__ = [ + "OpencodeTranscriptDiscoveryAdapter", + "OpencodeTranscriptSourceRuntimeAdapter", +] diff --git a/src/codealmanac/integrations/sources/transcripts/opencode_db.py b/src/codealmanac/integrations/sources/transcripts/opencode_db.py new file mode 100644 index 00000000..aa7455fa --- /dev/null +++ b/src/codealmanac/integrations/sources/transcripts/opencode_db.py @@ -0,0 +1,135 @@ +import json +from pathlib import Path + +from codealmanac.database import query_readonly_or_empty +from codealmanac.integrations.harnesses.fields import as_record, string_field +from codealmanac.integrations.sources.transcripts.models import ( + TranscriptRuntimeEntry, + TranscriptRuntimeLineKind, +) + +_SESSIONS_QUERY = "SELECT id, directory, time_updated FROM session" +# part.data and message.data both alias to the bare column name "data" if +# not given explicit AS aliases — harmless here today since +# read_opencode_session_entries reads rows positionally (row[0]/row[1]), +# but a future edit to key-based access (row["data"]) would silently +# collide (sqlite3.Row's dict-style lookup returns the first match). Named +# explicitly so that footgun can't reappear — the harness-side poller in +# integrations/harnesses/opencode/progress.py hit exactly this bug. +_ENTRIES_QUERY = """ +SELECT part.data AS part_data, message.data AS message_data +FROM part +JOIN message ON message.id = part.message_id +WHERE part.session_id = ? +ORDER BY part.time_created +""" + + +def list_opencode_sessions(path: Path) -> tuple[tuple[str, str, int], ...]: + """Return (session_id, directory, time_updated_ms) for every session. + + Soft-fails to () on any sqlite error (missing table/column etc.) rather + than raising — this schema isn't a documented public contract (see + docs/plans/2026-07-07-opencode-harness.md "Spike findings"), so a future + opencode-ai upgrade changing it should make sync find nothing this run, + not crash it. query_readonly_or_empty (codealmanac.database) owns that + soft-fail behavior and the read-only connection. + """ + rows = query_readonly_or_empty(path, _SESSIONS_QUERY) + return tuple( + (row["id"], row["directory"], row["time_updated"]) + for row in rows + if isinstance(row["id"], str) + and isinstance(row["directory"], str) + and isinstance(row["time_updated"], int) + ) + + +def read_opencode_session_entries( + path: Path, + session_id: str, +) -> tuple[TranscriptRuntimeEntry, ...]: + rows = query_readonly_or_empty(path, _ENTRIES_QUERY, (session_id,)) + entries: list[TranscriptRuntimeEntry] = [] + for row in rows: + entry = _entry_from_part_row(len(entries) + 1, row[0], row[1]) + if entry is not None: + entries.append(entry) + return tuple(entries) + + +def _entry_from_part_row( + line_number: int, + part_data: object, + message_data: object, +) -> TranscriptRuntimeEntry | None: + part = _parse_json_object(part_data) + if part is None: + return None + message = _parse_json_object(message_data) or {} + role = string_field(message, "role") or "unknown" + part_type = string_field(part, "type") + + if part_type == "text": + text = string_field(part, "text") + if text is None: + return None + return TranscriptRuntimeEntry( + line_number=line_number, + kind=TranscriptRuntimeLineKind.MESSAGE, + label=role, + text=text, + ) + if part_type == "reasoning": + text = string_field(part, "text") + if text is None: + return None + return TranscriptRuntimeEntry( + line_number=line_number, + kind=TranscriptRuntimeLineKind.MESSAGE, + label=f"{role} reasoning", + text=text, + ) + if part_type == "tool": + return _tool_entry(line_number, part) + if part_type == "patch": + files = part.get("files") + if not isinstance(files, list) or len(files) == 0: + return None + return TranscriptRuntimeEntry( + line_number=line_number, + kind=TranscriptRuntimeLineKind.META, + label="files changed", + text=", ".join(str(item) for item in files), + ) + # step-start / step-finish / anything else: bookkeeping, not + # ingest-relevant content — skip rather than render as noise. + return None + + +def _tool_entry(line_number: int, part: dict) -> TranscriptRuntimeEntry | None: + tool_name = string_field(part, "tool") or "tool" + state = as_record(part.get("state")) + input_value = state.get("input") + output_value = state.get("output") + if not isinstance(output_value, str): + output_value = json.dumps(output_value) + text = ( + f"input: {json.dumps(input_value, sort_keys=True)}\noutput: {output_value}" + ) + return TranscriptRuntimeEntry( + line_number=line_number, + kind=TranscriptRuntimeLineKind.TOOL_CALL, + label=f"tool:{tool_name}", + text=text, + ) + + +def _parse_json_object(value: object) -> dict | None: + if not isinstance(value, str): + return None + try: + parsed = json.loads(value) + except ValueError: + return None + return parsed if isinstance(parsed, dict) else None diff --git a/src/codealmanac/integrations/sources/transcripts/opencode_ref.py b/src/codealmanac/integrations/sources/transcripts/opencode_ref.py new file mode 100644 index 00000000..b460680e --- /dev/null +++ b/src/codealmanac/integrations/sources/transcripts/opencode_ref.py @@ -0,0 +1,30 @@ +from pathlib import Path + +# Separator between the shared opencode.db path and a session id in a +# transcript address string, e.g. "/home/.../opencode.db::ses_abc123". Unlike +# Claude/Codex, every OpenCode session lives in the same database file, so +# the file path alone can't identify one session. This stays entirely +# inside the opencode integration package — services/sources/transcripts.py +# only sees the result via TranscriptCandidate.address_override, and never +# needs to know this encoding (or OpenCode) exists. See +# docs/plans/2026-07-08-opencode-harness-slice-3.md for the full reasoning. +# +# Two characters, not one: a Windows path can contain a single drive-letter +# colon (e.g. "C:\Users\me\...\opencode.db"), and rpartition() on "::" still +# finds the real separator correctly since it searches for the whole +# 2-character substring — verified by test, see +# test_opencode_transcripts.py. +OPENCODE_TRANSCRIPT_SEPARATOR = "::" + + +def format_opencode_transcript_ref(db_path: Path, session_id: str) -> str: + return f"{db_path}{OPENCODE_TRANSCRIPT_SEPARATOR}{session_id}" + + +def parse_opencode_transcript_ref(value: str) -> tuple[Path, str] | None: + db_path_str, separator, session_id = value.rpartition( + OPENCODE_TRANSCRIPT_SEPARATOR + ) + if separator == "" or db_path_str == "" or session_id == "": + return None + return Path(db_path_str), session_id diff --git a/src/codealmanac/integrations/sources/transcripts/runtime.py b/src/codealmanac/integrations/sources/transcripts/runtime.py index 1ce3c8c7..adf75bf3 100644 --- a/src/codealmanac/integrations/sources/transcripts/runtime.py +++ b/src/codealmanac/integrations/sources/transcripts/runtime.py @@ -1,4 +1,7 @@ from codealmanac.integrations.sources.transcripts.errors import unavailable_runtime +from codealmanac.integrations.sources.transcripts.opencode_ref import ( + parse_opencode_transcript_ref, +) from codealmanac.integrations.sources.transcripts.paths import transcript_path from codealmanac.integrations.sources.transcripts.reader import read_transcript_entries from codealmanac.integrations.sources.transcripts.rendering import ( @@ -20,7 +23,17 @@ def __init__(self, max_chars: int = DEFAULT_MAX_CHARS): self.max_chars = max_chars def supports(self, ref: SourceRef) -> bool: - return ref.kind == SourceKind.TRANSCRIPT + if ref.kind != SourceKind.TRANSCRIPT: + return False + # OpenCode refs are "db-path::session-id" strings, handled by + # OpencodeTranscriptSourceRuntimeAdapter instead (every OpenCode + # session shares one file, so a bare path can't address one). Ruled + # out explicitly so this adapter's supports() is correct standing + # alone, not merely correct because of registration order in + # default_transcript_runtime_adapters(). + if ref.transcript is None: + return True + return parse_opencode_transcript_ref(ref.transcript) is None def inspect(self, request: InspectSourceRuntimeRequest) -> SourceRuntime: if request.ref.kind != SourceKind.TRANSCRIPT: diff --git a/src/codealmanac/services/config/models.py b/src/codealmanac/services/config/models.py index 99c27e06..f98d4be6 100644 --- a/src/codealmanac/services/config/models.py +++ b/src/codealmanac/services/config/models.py @@ -21,17 +21,6 @@ DEFAULT_HARNESS = HarnessKind.CODEX DEFAULT_HARNESS_MODEL = "gpt-5.5" DEFAULT_AUTO_COMMIT = True -CONTROLLED_HARNESS_MODELS = frozenset( - ( - "gpt-5.5", - "gpt-5.4", - "gpt-5.4-mini", - "gpt-5.3-codex-spark", - "claude-sonnet-5", - "claude-opus-4-7", - "claude-haiku-4-5", - ) -) HARNESS_MODELS = { HarnessKind.CODEX: ( "gpt-5.5", @@ -44,10 +33,39 @@ "claude-opus-4-7", "claude-haiku-4-5", ), + # OpenCode is a router, not a single provider — it accepts any + # "provider/model" string the user's `opencode auth login` has enabled, + # so (unlike Codex/Claude, which really do only offer a fixed handful + # of models) it isn't validated against this tuple; see + # HarnessConfig.model_matches_harness below, which shape-checks + # OpenCode models instead of checking membership here. This tuple is + # only the setup wizard's curated starting menu. + # + # The 3 opencode/* entries are OpenCode Zen's free tier, confirmed + # present via a live GET /config/providers during the 2026-07-08 spike + # (docs/plans/2026-07-07-opencode-harness.md) — only + # deepseek-v4-flash-free confirmed to complete a full generation. The + # openai/* entries route through the same authenticated OpenAI account + # Codex uses directly; openai/gpt-5.5 itself was confirmed live on + # 2026-07-08, the sibling gpt-5.4 family are the same provider/account + # and Codex's own confirmed catalog, not independently re-verified + # through OpenCode. + HarnessKind.OPENCODE: ( + "opencode/deepseek-v4-flash-free", + "opencode/mimo-v2.5-free", + "opencode/big-pickle", + "openai/gpt-5.5", + "openai/gpt-5.4", + "openai/gpt-5.4-mini", + "openai/gpt-5.3-codex-spark", + ), } DEFAULT_HARNESS_MODELS = { HarnessKind.CODEX: DEFAULT_HARNESS_MODEL, HarnessKind.CLAUDE: "claude-sonnet-5", + # The only opencode model actually run end-to-end in the spike — see + # comment on HARNESS_MODELS[HarnessKind.OPENCODE] above. + HarnessKind.OPENCODE: "opencode/deepseek-v4-flash-free", } @@ -67,16 +85,12 @@ class HarnessConfig(CodeAlmanacModel): default: HarnessKind = DEFAULT_HARNESS model: str = DEFAULT_HARNESS_MODEL - @field_validator("model") - @classmethod - def controlled_model(cls, value: str) -> str: - if value not in CONTROLLED_HARNESS_MODELS: - allowed = ", ".join(sorted(CONTROLLED_HARNESS_MODELS)) - raise ValueError(f"harness.model must be one of: {allowed}") - return value - @model_validator(mode="after") def model_matches_harness(self) -> "HarnessConfig": + if self.default == HarnessKind.OPENCODE: + if not is_opencode_model_shape(self.model): + raise ValueError(OPENCODE_MODEL_SHAPE_MESSAGE) + return self if self.model not in HARNESS_MODELS[self.default]: allowed = ", ".join(HARNESS_MODELS[self.default]) raise ValueError( @@ -85,6 +99,18 @@ def model_matches_harness(self) -> "HarnessConfig": return self +OPENCODE_MODEL_SHAPE_MESSAGE = ( + 'harness.model for opencode must look like "provider/model" (e.g. ' + '"openai/gpt-5.5") — OpenCode routes to whatever provider/model your ' + "`opencode auth login` has enabled, so it isn't restricted to a fixed list" +) + + +def is_opencode_model_shape(value: str) -> bool: + provider_id, separator, model_id = value.partition("/") + return separator != "" and provider_id != "" and model_id != "" + + class TaskAutomationConfig(CodeAlmanacModel): enabled: bool = True every: timedelta diff --git a/src/codealmanac/services/config/service.py b/src/codealmanac/services/config/service.py index ac42db9d..f2273aed 100644 --- a/src/codealmanac/services/config/service.py +++ b/src/codealmanac/services/config/service.py @@ -11,9 +11,9 @@ from codealmanac.services.automation.requests import ReconcileAutomationTaskRequest from codealmanac.services.automation.service import AutomationService from codealmanac.services.config.models import ( - CONTROLLED_HARNESS_MODELS, DEFAULT_HARNESS_MODELS, HARNESS_MODELS, + OPENCODE_MODEL_SHAPE_MESSAGE, AutomationConfig, ConfigApplyResult, ConfigEntry, @@ -24,6 +24,7 @@ UserConfig, automation_entries, format_bool, + is_opencode_model_shape, parse_duration, ) from codealmanac.services.config.requests import ( @@ -250,9 +251,10 @@ def parse_harness_value(value: str) -> str: def parse_harness_model(value: str, harness: HarnessKind) -> str: token = value.strip() - if token not in CONTROLLED_HARNESS_MODELS: - allowed = ", ".join(sorted(CONTROLLED_HARNESS_MODELS)) - raise ValidationFailed(f"harness.model must be one of: {allowed}") + if harness == HarnessKind.OPENCODE: + if not is_opencode_model_shape(token): + raise ValidationFailed(OPENCODE_MODEL_SHAPE_MESSAGE) + return token if token not in HARNESS_MODELS[harness]: allowed = ", ".join(HARNESS_MODELS[harness]) raise ValidationFailed( diff --git a/src/codealmanac/services/harnesses/kinds.py b/src/codealmanac/services/harnesses/kinds.py index a84ccd3b..e0419f75 100644 --- a/src/codealmanac/services/harnesses/kinds.py +++ b/src/codealmanac/services/harnesses/kinds.py @@ -4,6 +4,7 @@ class HarnessKind(StrEnum): CODEX = "codex" CLAUDE = "claude" + OPENCODE = "opencode" class HarnessRunStatus(StrEnum): diff --git a/src/codealmanac/services/setup/models.py b/src/codealmanac/services/setup/models.py index 7e405f2e..d1878247 100644 --- a/src/codealmanac/services/setup/models.py +++ b/src/codealmanac/services/setup/models.py @@ -17,6 +17,7 @@ class SetupTarget(StrEnum): CODEX = "codex" CLAUDE = "claude" + OPENCODE = "opencode" class SetupAutomationMode(StrEnum): diff --git a/src/codealmanac/services/setup/requests.py b/src/codealmanac/services/setup/requests.py index 1392be4a..5b354431 100644 --- a/src/codealmanac/services/setup/requests.py +++ b/src/codealmanac/services/setup/requests.py @@ -13,7 +13,7 @@ from codealmanac.services.harnesses.models import HarnessKind from codealmanac.services.setup.models import SetupTarget -DEFAULT_SETUP_TARGETS = (SetupTarget.CODEX, SetupTarget.CLAUDE) +DEFAULT_SETUP_TARGETS = (SetupTarget.CODEX, SetupTarget.CLAUDE, SetupTarget.OPENCODE) class RunSetupRequest(CodeAlmanacModel): diff --git a/src/codealmanac/services/sources/models.py b/src/codealmanac/services/sources/models.py index 2f1e16f8..db94a51b 100644 --- a/src/codealmanac/services/sources/models.py +++ b/src/codealmanac/services/sources/models.py @@ -40,6 +40,7 @@ class SourceRuntimeStatus(StrEnum): class TranscriptApp(StrEnum): CLAUDE = "claude" CODEX = "codex" + OPENCODE = "opencode" class SourceAddress(CodeAlmanacModel): @@ -110,6 +111,12 @@ class TranscriptCandidate(CodeAlmanacModel): cwd: Path modified_at: datetime size_bytes: int + # Set by a discovery adapter when transcript_path alone can't address one + # session (e.g. OpenCode: many sessions share one database file) — see + # OpencodeTranscriptDiscoveryAdapter. None means transcript_path is + # itself the address, true for every app that writes one file per + # session (Claude, Codex). + address_override: str | None = None @field_validator("session_id") @classmethod @@ -122,3 +129,10 @@ def non_negative_size(cls, value: int) -> int: if value < 0: raise ValueError("transcript size must be non-negative") return value + + @field_validator("address_override") + @classmethod + def require_optional_address(cls, value: str | None) -> str | None: + if value is None: + return None + return required_text(value, "transcript address override") diff --git a/src/codealmanac/services/sources/transcripts.py b/src/codealmanac/services/sources/transcripts.py index 78917325..2f5969bb 100644 --- a/src/codealmanac/services/sources/transcripts.py +++ b/src/codealmanac/services/sources/transcripts.py @@ -7,3 +7,14 @@ def transcript_sort_key(candidate: TranscriptCandidate) -> tuple[str, str, str]: str(candidate.transcript_path), candidate.session_id, ) + + +def transcript_address(candidate: TranscriptCandidate) -> str: + """The "transcript:
" identifier for a candidate. + + Stays app-agnostic on purpose: a discovery adapter sets + address_override when transcript_path alone can't address one session + (see TranscriptCandidate.address_override) — this file never needs to + know which app or why. + """ + return candidate.address_override or str(candidate.transcript_path) diff --git a/src/codealmanac/workflows/sync/queue.py b/src/codealmanac/workflows/sync/queue.py index e385f478..1e127e7a 100644 --- a/src/codealmanac/workflows/sync/queue.py +++ b/src/codealmanac/workflows/sync/queue.py @@ -1,6 +1,7 @@ from datetime import datetime from codealmanac.core.errors import error_summary +from codealmanac.services.sources.transcripts import transcript_address from codealmanac.workflows.ingest.requests import IngestRequest from codealmanac.workflows.run_queue.service import RunQueue from codealmanac.workflows.sync.guidance import sync_ingest_guidance @@ -78,7 +79,7 @@ def sync_ingest_request( return IngestRequest( cwd=item.repository.root_path, inputs=tuple( - f"transcript:{candidate.transcript_path}" + f"transcript:{transcript_address(candidate)}" for candidate in item.transcripts ), harness=request.harness, diff --git a/tests/test_architecture.py b/tests/test_architecture.py index 0033ea07..af4a1c40 100644 --- a/tests/test_architecture.py +++ b/tests/test_architecture.py @@ -906,6 +906,7 @@ def test_setup_instruction_adapter_stays_split_by_target_family(): instructions = (setup_root / "instructions.py").read_text(encoding="utf-8") codex = (setup_root / "codex.py").read_text(encoding="utf-8") claude = (setup_root / "claude.py").read_text(encoding="utf-8") + opencode = (setup_root / "opencode.py").read_text(encoding="utf-8") managed_blocks = (setup_root / "managed_blocks.py").read_text(encoding="utf-8") guide = (setup_root / "guide.py").read_text(encoding="utf-8") @@ -913,6 +914,7 @@ def test_setup_instruction_adapter_stays_split_by_target_family(): "instructions.py", "codex.py", "claude.py", + "opencode.py", "managed_blocks.py", "guide.py", "text_files.py", @@ -921,6 +923,7 @@ def test_setup_instruction_adapter_stays_split_by_target_family(): assert "class FileInstructionInstaller" in instructions assert "install_codex_instructions" in instructions assert "install_claude_instructions" in instructions + assert "install_opencode_instructions" in instructions assert "resources.files" not in instructions assert "write_text" not in instructions assert "read_text" not in instructions @@ -933,6 +936,14 @@ def test_setup_instruction_adapter_stays_split_by_target_family(): assert "CLAUDE_IMPORT_LINE" in claude assert "codealmanac.md" in claude assert "AGENTS.override.md" not in claude + # opencode.py mirrors codex.py's managed-block shape, not claude.py's + # import-line shape, but has no AGENTS.override.md-style convention of + # its own (no upstream equivalent found in the research this repo's + # plan docs cite) — confirm it doesn't quietly grow either sibling's + # provider-specific machinery instead of staying its own thing. + assert "install_opencode_instructions" in opencode + assert "AGENTS.override.md" not in opencode + assert "CLAUDE_IMPORT_LINE" not in opencode assert "CODEALMANAC_START" in managed_blocks assert "def upsert_managed_block" in managed_blocks assert "resources.files" in guide diff --git a/tests/test_cli.py b/tests/test_cli.py index 741c9337..2a6be9ff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -661,7 +661,7 @@ def test_cli_setup_skip_instructions_json(isolated_home: Path, monkeypatch, caps item["key"]: item["value"] for item in payload["config_update"]["entries"] } assert entries["auto_commit"] == "true" - assert payload["plan"]["instruction_targets"] == ["codex", "claude"] + assert payload["plan"]["instruction_targets"] == ["codex", "claude", "opencode"] assert [item["task"] for item in payload["plan"]["automation"]] == [ "sync", "garden", diff --git a/tests/test_config_service.py b/tests/test_config_service.py index 18c997a0..d7e414ea 100644 --- a/tests/test_config_service.py +++ b/tests/test_config_service.py @@ -294,6 +294,12 @@ def test_config_set_rejects_invalid_values_without_writing( app.config.set(set_request(ConfigKey.AUTOMATION_SYNC_EVERY, "0s")) with pytest.raises(ValidationFailed, match="harness.default must be one of"): app.config.set(set_request(ConfigKey.HARNESS_DEFAULT, "gpt")) + with pytest.raises( + ValidationFailed, match="harness.model for codex must be one of" + ): + app.config.set(set_request(ConfigKey.HARNESS_MODEL, "provider")) + with pytest.raises(ValidationFailed, match="auto_commit must be true or false"): + app.config.set(set_request(ConfigKey.AUTO_COMMIT, "maybe")) assert not path.exists() diff --git a/tests/test_controlled_model_config.py b/tests/test_controlled_model_config.py index dd7e5642..ed4ed92a 100644 --- a/tests/test_controlled_model_config.py +++ b/tests/test_controlled_model_config.py @@ -19,17 +19,27 @@ def reconcile_task(self, request): def test_harness_config_rejects_unknown_models() -> None: - with pytest.raises(ValueError, match="harness.model must be one of"): + with pytest.raises(ValueError, match="harness.model for codex must be one of"): HarnessConfig(default=HarnessKind.CODEX, model="provider-default") +def test_harness_config_accepts_any_provider_slash_model_for_opencode() -> None: + config = HarnessConfig(default=HarnessKind.OPENCODE, model="anthropic/claude-x") + assert config.model == "anthropic/claude-x" + + +def test_harness_config_rejects_opencode_model_without_provider_slash() -> None: + with pytest.raises(ValueError, match="harness.model for opencode must look like"): + HarnessConfig(default=HarnessKind.OPENCODE, model="gpt-5.5") + + def test_harness_config_rejects_other_provider_models() -> None: with pytest.raises(ValueError, match="harness.model for claude must be one of"): HarnessConfig(default=HarnessKind.CLAUDE, model="gpt-5.5") def test_harness_config_rejects_deprecated_claude_models() -> None: - with pytest.raises(ValueError, match="harness.model must be one of"): + with pytest.raises(ValueError, match="harness.model for claude must be one of"): HarnessConfig(default=HarnessKind.CLAUDE, model="claude-sonnet-4-6") @@ -62,3 +72,39 @@ def test_config_set_harness_model_rejects_other_provider_models(tmp_path: Path) match="harness.model for claude must be one of", ): service.set(SetConfigValueRequest(key=ConfigKey.HARNESS_MODEL, value="gpt-5.5")) + + +def test_config_set_harness_model_accepts_any_provider_slash_model_for_opencode( + tmp_path: Path, +) -> None: + service = ConfigService( + store=ConfigStore(), + user_config_path=tmp_path / "config.toml", + automation=UnusedAutomation(), + ) + service.set(SetConfigValueRequest(key=ConfigKey.HARNESS_DEFAULT, value="opencode")) + + result = service.set( + SetConfigValueRequest( + key=ConfigKey.HARNESS_MODEL, value="anthropic/claude-x" + ) + ) + + assert result.value == "anthropic/claude-x" + + +def test_config_set_harness_model_rejects_malformed_opencode_model( + tmp_path: Path, +) -> None: + service = ConfigService( + store=ConfigStore(), + user_config_path=tmp_path / "config.toml", + automation=UnusedAutomation(), + ) + service.set(SetConfigValueRequest(key=ConfigKey.HARNESS_DEFAULT, value="opencode")) + + with pytest.raises( + ValidationFailed, + match="harness.model for opencode must look like", + ): + service.set(SetConfigValueRequest(key=ConfigKey.HARNESS_MODEL, value="gpt-5.5")) diff --git a/tests/test_opencode_adapter.py b/tests/test_opencode_adapter.py new file mode 100644 index 00000000..e2c30eba --- /dev/null +++ b/tests/test_opencode_adapter.py @@ -0,0 +1,1041 @@ +import json +import sqlite3 +import subprocess +import threading +import time +from pathlib import Path + +import httpx +import pytest + +from codealmanac.app import create_app +from codealmanac.integrations.command import CommandResult +from codealmanac.integrations.harnesses.opencode.adapter import OpencodeHarnessAdapter +from codealmanac.integrations.harnesses.opencode.client import OpencodeClient +from codealmanac.integrations.harnesses.opencode.failures import ( + classify_opencode_failure, +) +from codealmanac.integrations.harnesses.opencode.model_ref import split_opencode_model +from codealmanac.integrations.harnesses.opencode.parts import ( + is_task_settled, + is_task_spawn, + map_opencode_part, +) +from codealmanac.integrations.harnesses.opencode.progress import ( + OpencodeProgressWatchdog, +) +from codealmanac.integrations.harnesses.opencode.server import ( + OpencodeServerStartupError, + _wait_for_listening, + start_opencode_server, +) +from codealmanac.integrations.harnesses.opencode.state import OpencodeRunState +from codealmanac.integrations.harnesses.opencode.usage import parse_opencode_usage +from codealmanac.services.harnesses.actors import ( + HarnessActorConfidence, + HarnessActorRole, +) +from codealmanac.services.harnesses.models import ( + HarnessEvent, + HarnessEventKind, + HarnessKind, + HarnessReadiness, + HarnessRunActor, + HarnessRunResult, + HarnessRunStatus, + HarnessToolDisplayKind, + HarnessToolStatus, +) +from codealmanac.services.harnesses.requests import RunHarnessRequest + +ROOT_ACTOR = HarnessRunActor( + thread_id="ses_root", + role=HarnessActorRole.ROOT, + confidence=HarnessActorConfidence.PROVIDER, + label="Main", +) + + +class FakeCommandRunner: + def __init__(self, results: tuple[CommandResult | BaseException, ...]): + self.results = list(results) + self.calls: list[tuple[str, tuple[str, ...], Path, int, str | None]] = [] + + def run( + self, + command: str, + args: tuple[str, ...], + cwd: Path, + timeout_seconds: int, + stdin: str | None = None, + ) -> CommandResult: + self.calls.append((command, args, cwd, timeout_seconds, stdin)) + result = self.results.pop(0) + if isinstance(result, BaseException): + raise result + return result + + +class FakeOpencodeClient: + def __init__( + self, + readiness: HarnessReadiness | None = None, + result: HarnessRunResult | None = None, + ): + self.readiness = readiness + self.result = result + self.check_calls: list[Path] = [] + self.requests: list[RunHarnessRequest] = [] + + def check_providers(self, cwd: Path) -> HarnessReadiness: + self.check_calls.append(cwd) + assert self.readiness is not None + return self.readiness + + def run(self, request: RunHarnessRequest, on_event=None) -> HarnessRunResult: + self.requests.append(request) + assert self.result is not None + return self.result + + +# --- adapter.check() ------------------------------------------------------- + + +def test_opencode_adapter_reports_not_ready_when_command_is_missing(): + runner = FakeCommandRunner((FileNotFoundError("missing"),)) + adapter = OpencodeHarnessAdapter(runner=runner) + + readiness = adapter.check() + + assert readiness.kind == HarnessKind.OPENCODE + assert readiness.available is False + assert readiness.message == "opencode not found on PATH" + assert readiness.repair == "install the OpenCode CLI: npm install -g opencode-ai" + + +def test_opencode_adapter_reports_not_ready_when_version_times_out(): + runner = FakeCommandRunner((subprocess.TimeoutExpired("opencode", 1),)) + adapter = OpencodeHarnessAdapter(runner=runner) + + readiness = adapter.check() + + assert readiness.available is False + assert readiness.message == "opencode --version timed out" + assert readiness.repair is not None + + +def test_opencode_adapter_reports_not_ready_when_version_exits_nonzero(): + runner = FakeCommandRunner( + (CommandResult(returncode=1, stderr="command not found\n"),) + ) + adapter = OpencodeHarnessAdapter(runner=runner) + + readiness = adapter.check() + + assert readiness.available is False + assert readiness.message == "command not found" + + +def test_opencode_adapter_delegates_to_client_when_installed(): + runner = FakeCommandRunner((CommandResult(returncode=0, stdout="1.17.15\n"),)) + client = FakeOpencodeClient( + readiness=HarnessReadiness( + kind=HarnessKind.OPENCODE, + available=True, + message="opencode providers configured: OpenCode Zen", + ) + ) + adapter = OpencodeHarnessAdapter(runner=runner, client=client) + + readiness = adapter.check() + + assert readiness.available is True + assert readiness.message == "opencode providers configured: OpenCode Zen" + assert len(client.check_calls) == 1 + assert runner.calls[0][1] == ("--version",) + + +def test_opencode_adapter_runs_client_without_a_second_version_check( + tmp_path: Path, +): + runner = FakeCommandRunner(()) + client = FakeOpencodeClient( + result=HarnessRunResult( + kind=HarnessKind.OPENCODE, + status=HarnessRunStatus.SUCCEEDED, + output_text="updated wiki", + summary="updated wiki", + ) + ) + adapter = OpencodeHarnessAdapter(runner=runner, client=client) + request = RunHarnessRequest( + kind=HarnessKind.OPENCODE, + model="opencode/deepseek-v4-flash-free", + cwd=tmp_path, + prompt="Update the wiki.", + title="Ingest note", + ) + + result = adapter.run(request) + + assert client.requests == [request] + assert result.status == HarnessRunStatus.SUCCEEDED + assert result.output_text == "updated wiki" + assert runner.calls == [] + + +def test_create_app_wires_default_opencode_adapter(): + app = create_app() + + adapter = app.harnesses.adapter_for(HarnessKind.OPENCODE) + + assert isinstance(adapter, OpencodeHarnessAdapter) + + +# --- OpencodeClient.check_providers() -------------------------------------- + + +def test_opencode_client_check_providers_reports_ready(monkeypatch): + client = OpencodeClient() + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + lambda *a, **k: _FakeServer("http://127.0.0.1:1"), + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.get_providers", + lambda base_url, timeout_seconds: ( + {"id": "opencode", "name": "OpenCode Zen"}, + ), + ) + + readiness = client.check_providers(Path("/tmp")) + + assert readiness.available is True + assert "OpenCode Zen" in readiness.message + + +def test_opencode_client_check_providers_reports_not_ready_with_no_providers( + monkeypatch, +): + client = OpencodeClient() + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + lambda *a, **k: _FakeServer("http://127.0.0.1:1"), + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.get_providers", + lambda base_url, timeout_seconds: (), + ) + + readiness = client.check_providers(Path("/tmp")) + + assert readiness.available is False + assert readiness.message == "no opencode providers are configured" + assert readiness.repair is not None + + +def test_opencode_client_check_providers_reports_not_ready_when_server_fails( + monkeypatch, +): + client = OpencodeClient() + + def _raise(*args, **kwargs): + raise OpencodeServerStartupError("opencode serve exited before it started") + + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + _raise, + ) + + readiness = client.check_providers(Path("/tmp")) + + assert readiness.available is False + assert "exited before it started" in readiness.message + + +def test_opencode_client_check_providers_reports_not_ready_on_http_error( + monkeypatch, +): + client = OpencodeClient() + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + lambda *a, **k: _FakeServer("http://127.0.0.1:1"), + ) + + def _raise(base_url, timeout_seconds): + raise httpx.ConnectError("connection refused") + + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.get_providers", _raise + ) + + readiness = client.check_providers(Path("/tmp")) + + assert readiness.available is False + assert "opencode server request failed" in readiness.message + + +def test_opencode_client_check_providers_reports_not_ready_on_malformed_json( + monkeypatch, +): + client = OpencodeClient() + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + lambda *a, **k: _FakeServer("http://127.0.0.1:1"), + ) + + def _raise(base_url, timeout_seconds): + # response.json() raises json.JSONDecodeError (a ValueError) on a + # malformed 200 body — the bug this test guards against was that + # check_providers only caught httpx.HTTPError, not this. + raise ValueError("Expecting value: line 1 column 1 (char 0)") + + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.get_providers", _raise + ) + + readiness = client.check_providers(Path("/tmp")) + + assert readiness.available is False + assert "invalid response" in readiness.message + + +# --- OpencodeClient.run() --------------------------------------------------- + + +def test_opencode_client_run_fails_fast_on_bad_model_string(monkeypatch, tmp_path): + client = OpencodeClient() + + def _fail_if_called(*args, **kwargs): + raise AssertionError("server should not start for an invalid model string") + + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + _fail_if_called, + ) + + result = client.run( + RunHarnessRequest( + kind=HarnessKind.OPENCODE, + model="not-a-provider-model-pair", + cwd=tmp_path, + prompt="Update the wiki.", + ) + ) + + assert result.status == HarnessRunStatus.FAILED + assert "provider/model" in result.output_text + + +def test_opencode_client_run_maps_parts_to_events(monkeypatch, tmp_path): + db_path = tmp_path / "opencode.db" + _build_part_db(db_path) + _seed_parts(db_path, "ses_root", _MESSAGE_RESPONSE["parts"]) + client = OpencodeClient(db_path=db_path, poll_interval_seconds=0.02) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + lambda *a, **k: _FakeServer("http://127.0.0.1:1"), + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.create_session", + lambda *a, **k: {"id": "ses_root", "slug": "brave-planet"}, + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.post_message", + lambda *a, **k: _MESSAGE_RESPONSE, + ) + + events: list = [] + result = client.run( + RunHarnessRequest( + kind=HarnessKind.OPENCODE, + model="opencode/deepseek-v4-flash-free", + cwd=tmp_path, + prompt="Run: echo sse-tool-check via the bash tool, then say done.", + ), + on_event=events.append, + ) + + assert result.status == HarnessRunStatus.SUCCEEDED + assert result.output_text == "done" + assert result.transcript is None # not populated for opencode in slice 1 + kinds = [event.kind for event in result.events] + assert kinds == [ + HarnessEventKind.PROVIDER_SESSION, + HarnessEventKind.TOOL_SUMMARY, # reasoning + HarnessEventKind.TOOL_USE, + HarnessEventKind.TOOL_RESULT, + HarnessEventKind.CONTEXT_USAGE, # step-finish (tool-calls) + HarnessEventKind.TEXT, + HarnessEventKind.CONTEXT_USAGE, # step-finish (stop) + HarnessEventKind.DONE, + ] + assert events == list(result.events) + tool_use = result.events[2] + assert tool_use.tool_name == "bash" + assert tool_use.tool_display is not None + assert tool_use.tool_display.kind == HarnessToolDisplayKind.SHELL + done = result.events[-1] + assert done.provider_session_id == "ses_root" + assert done.usage is not None + assert done.usage.total_tokens == 8811 + + +def test_opencode_client_run_emits_events_live_before_response_returns( + monkeypatch, tmp_path +): + """Events should arrive via on_event while post_message is still + blocked, not only once it returns — the whole point of the watchdog.""" + db_path = tmp_path / "opencode.db" + _build_part_db(db_path) + seen_before_return: list[float] = [] + return_time: dict[str, float] = {} + + def _slow_post_message(*args, **kwargs): + # Seed one part partway through the "call", from a helper thread, + # simulating OpenCode writing to its db while the HTTP call is + # still in flight. + def _write_soon(): + time.sleep(0.1) + _seed_parts(db_path, "ses_root", [{"type": "text", "text": "working"}]) + + threading.Thread(target=_write_soon, daemon=True).start() + time.sleep(0.3) + return_time["at"] = time.monotonic() + return { + "info": {"tokens": {"total": 1}}, + "parts": [{"type": "text", "text": "done"}], + } + + client = OpencodeClient(db_path=db_path, poll_interval_seconds=0.02) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + lambda *a, **k: _FakeServer("http://127.0.0.1:1"), + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.create_session", + lambda *a, **k: {"id": "ses_root", "slug": "brave-planet"}, + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.post_message", + _slow_post_message, + ) + + def _on_event(event): + seen_before_return.append(time.monotonic()) + + result = client.run( + RunHarnessRequest( + kind=HarnessKind.OPENCODE, + model="opencode/deepseek-v4-flash-free", + cwd=tmp_path, + prompt="Update the wiki.", + ), + on_event=_on_event, + ) + + assert result.status == HarnessRunStatus.SUCCEEDED + # At least one on_event call happened strictly before post_message + # returned — proof of live delivery, not a single batch at the end. + assert any(t < return_time["at"] for t in seen_before_return) + + +def test_opencode_client_run_emits_agent_spawned_and_completed(monkeypatch, tmp_path): + db_path = tmp_path / "opencode.db" + _build_part_db(db_path) + task_part = { + "type": "tool", + "tool": "task", + "callID": "call_1", + "state": { + "title": "Write pages", + "metadata": { + "parentSessionId": "ses_root", + "sessionId": "ses_child", + }, + "status": "completed", + "input": {"prompt": "Write three pages"}, + "output": "wrote three pages", + }, + } + _seed_parts(db_path, "ses_root", [task_part]) + + client = OpencodeClient(db_path=db_path, poll_interval_seconds=0.02) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + lambda *a, **k: _FakeServer("http://127.0.0.1:1"), + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.create_session", + lambda *a, **k: {"id": "ses_root", "slug": "brave-planet"}, + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.post_message", + lambda *a, **k: {"info": {}, "parts": []}, + ) + + result = client.run( + RunHarnessRequest( + kind=HarnessKind.OPENCODE, + model="opencode/deepseek-v4-flash-free", + cwd=tmp_path, + prompt="Update the wiki.", + ) + ) + + kinds = [event.kind for event in result.events] + assert HarnessEventKind.AGENT_SPAWNED in kinds + assert HarnessEventKind.AGENT_COMPLETED in kinds + spawned = next(e for e in result.events if e.kind == HarnessEventKind.AGENT_SPAWNED) + assert spawned.agent_trace is not None + assert spawned.agent_trace.child_thread_id == "ses_child" + assert spawned.agent_trace.prompt == "Write three pages" + completed = next( + e for e in result.events if e.kind == HarnessEventKind.AGENT_COMPLETED + ) + assert completed.actor is not None + assert completed.actor.role == HarnessActorRole.HELPER + assert completed.agent_trace is not None + assert completed.agent_trace.result == "wrote three pages" + + +def test_opencode_client_run_detects_stuck_tool_call(monkeypatch, tmp_path): + db_path = tmp_path / "opencode.db" + _build_part_db(db_path) + stuck_start_ms = int(time.time() * 1000) - 10_000 # 10s ago + _seed_parts( + db_path, + "ses_root", + [ + { + "type": "tool", + "tool": "glob", + "callID": "call_1", + "state": { + "status": "running", + "input": {"pattern": "**/*", "path": "/whatever"}, + "time": {"start": stuck_start_ms}, + }, + } + ], + ) + + server = _FakeServer("http://127.0.0.1:1") + client = OpencodeClient( + db_path=db_path, poll_interval_seconds=0.02, stuck_after_seconds=1 + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + lambda *a, **k: server, + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.create_session", + lambda *a, **k: {"id": "ses_root", "slug": "brave-planet"}, + ) + + def _never_returns(*args, **kwargs): + threading.Event().wait() + + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.post_message", + _never_returns, + ) + + result = client.run( + RunHarnessRequest( + kind=HarnessKind.OPENCODE, + model="opencode/deepseek-v4-flash-free", + cwd=tmp_path, + prompt="Update the wiki.", + ) + ) + + assert result.status == HarnessRunStatus.FAILED + assert "glob" in result.output_text + assert "stuck" in result.output_text + assert server.terminated is True + + +def test_opencode_client_run_reports_failure_when_server_cannot_start( + monkeypatch, tmp_path +): + client = OpencodeClient() + + def _raise(*args, **kwargs): + raise OpencodeServerStartupError( + "opencode serve did not report a listening port in time" + ) + + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.client.start_opencode_server", + _raise, + ) + + result = client.run( + RunHarnessRequest( + kind=HarnessKind.OPENCODE, + model="opencode/deepseek-v4-flash-free", + cwd=tmp_path, + prompt="Update the wiki.", + ) + ) + + assert result.status == HarnessRunStatus.FAILED + assert "did not report a listening port" in result.output_text + # Matches the Codex precedent (result.py::failed_result): a hard failure + # before a session ever starts emits a single ERROR event, not a DONE. + assert result.events[-1].kind == HarnessEventKind.ERROR + + +class _FakeServer: + def __init__(self, base_url: str): + self.base_url = base_url + self.terminated = False + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + self.terminated = True + return None + + +def _build_part_db(path: Path) -> None: + conn = sqlite3.connect(path) + conn.executescript( + "CREATE TABLE message (" + "id TEXT PRIMARY KEY, session_id TEXT NOT NULL, " + "time_created INTEGER NOT NULL, data TEXT NOT NULL);" + "CREATE TABLE part (" + "id TEXT PRIMARY KEY, message_id TEXT NOT NULL, " + "session_id TEXT NOT NULL, time_created INTEGER NOT NULL, " + "data TEXT NOT NULL);" + ) + conn.commit() + conn.close() + + +def _seed_parts( + path: Path, + session_id: str, + parts: list[dict], + role: str = "assistant", + message_id: str = "msg_1", +) -> None: + conn = sqlite3.connect(path) + base = int(time.time() * 1000) + conn.execute( + "INSERT OR REPLACE INTO message (id, session_id, time_created, data) " + "VALUES (?, ?, ?, ?)", + (message_id, session_id, base, json.dumps({"role": role})), + ) + for offset, part in enumerate(parts): + part_id = part.get("callID") or f"prt_{session_id}_{base}_{offset}" + conn.execute( + "INSERT OR REPLACE INTO part " + "(id, message_id, session_id, time_created, data) " + "VALUES (?, ?, ?, ?, ?)", + (part_id, message_id, session_id, base + offset, json.dumps(part)), + ) + conn.commit() + conn.close() + + +_MESSAGE_RESPONSE = { + "info": { + "id": "msg_assistant", + "sessionID": "ses_root", + "role": "assistant", + "tokens": { + "total": 8811, + "input": 91, + "output": 16, + "reasoning": 0, + "cache": {"read": 8448, "write": 0}, + }, + }, + "parts": [ + { + "type": "reasoning", + "text": "The user wants me to run a command then reply.", + }, + { + "type": "tool", + "tool": "bash", + "callID": "call_1", + "state": { + "status": "completed", + "input": {"command": "echo sse-tool-check"}, + "output": "sse-tool-check\n", + "metadata": {"exit": 0, "truncated": False}, + "title": "echo sse-tool-check", + }, + }, + { + "type": "step-finish", + "reason": "tool-calls", + "tokens": { + "total": 8720, + "input": 6877, + "output": 51, + "reasoning": 0, + "cache": {"read": 1792, "write": 0}, + }, + }, + {"type": "text", "text": "done"}, + { + "type": "step-finish", + "reason": "stop", + "tokens": { + "total": 8811, + "input": 91, + "output": 16, + "reasoning": 0, + "cache": {"read": 8448, "write": 0}, + }, + }, + ], +} + + +# --- is_task_spawn / is_task_settled ---------------------------------------- + + +def test_is_task_spawn_extracts_child_session_and_prompt(): + part = { + "type": "tool", + "tool": "task", + "state": { + "metadata": {"sessionId": "ses_child"}, + "input": {"prompt": "Write pages"}, + "status": "running", + }, + } + + assert is_task_spawn(part) == ("ses_child", "Write pages") + + +def test_is_task_spawn_returns_none_for_non_task_tool(): + part = {"type": "tool", "tool": "bash", "state": {"status": "running"}} + + assert is_task_spawn(part) is None + + +def test_is_task_settled_reports_completed_and_error(): + completed = {"type": "tool", "tool": "task", "state": {"status": "completed"}} + failed = {"type": "tool", "tool": "task", "state": {"status": "error"}} + running = {"type": "tool", "tool": "task", "state": {"status": "running"}} + + assert is_task_settled(completed) == HarnessToolStatus.COMPLETED + assert is_task_settled(failed) == HarnessToolStatus.FAILED + assert is_task_settled(running) is None + + +# --- OpencodeProgressWatchdog direct unit test ------------------------------ + + +def test_watchdog_ignores_user_authored_parts(tmp_path): + # Regression test: a live smoke test against a real opencode server + # found the watchdog emitting a TEXT event for the *user's own prompt* + # (echoed back as a part on the same session) — the POST response never + # had this problem since it only returns the new assistant message's + # parts. The watchdog polls the whole session's parts, so it must + # filter by message role explicitly. + db_path = tmp_path / "opencode.db" + _build_part_db(db_path) + _seed_parts( + db_path, + "ses_root", + [{"type": "text", "text": "the user's own prompt, echoed back"}], + role="user", + message_id="msg_user", + ) + events: list[HarnessEvent] = [] + watchdog = OpencodeProgressWatchdog( + db_path=db_path, + root_session_id="ses_root", + root_actor=ROOT_ACTOR, + state=OpencodeRunState(), + events=events, + on_event=None, + ) + + watchdog._poll_once() + + assert events == [] + + +def test_watchdog_does_not_flag_a_tool_call_under_the_threshold(tmp_path): + db_path = tmp_path / "opencode.db" + _build_part_db(db_path) + recent_start_ms = int(time.time() * 1000) - 5_000 # 5s ago + _seed_parts( + db_path, + "ses_root", + [ + { + "type": "tool", + "tool": "glob", + "callID": "call_1", + "state": { + "status": "running", + "input": {"pattern": "**/*"}, + "time": {"start": recent_start_ms}, + }, + } + ], + ) + watchdog = OpencodeProgressWatchdog( + db_path=db_path, + root_session_id="ses_root", + root_actor=ROOT_ACTOR, + state=OpencodeRunState(), + events=[], + on_event=None, + stuck_after_seconds=60.0, # 5s elapsed is well under this + ) + + watchdog._poll_once() + + assert watchdog.stuck_reason is None + + +# --- part mapping / parsing helpers ----------------------------------------- + + +def test_map_opencode_part_text(): + events = map_opencode_part({"type": "text", "text": "hello"}, ROOT_ACTOR) + + assert len(events) == 1 + assert events[0].kind == HarnessEventKind.TEXT + assert events[0].message == "hello" + + +def test_map_opencode_part_tool_call_and_result(): + part = { + "type": "tool", + "tool": "bash", + "callID": "call_1", + "state": { + "status": "completed", + "input": {"command": "ls"}, + "output": "file.txt\n", + "metadata": {"exit": 0}, + "title": "ls", + }, + } + + events = map_opencode_part(part, ROOT_ACTOR) + + assert len(events) == 2 + use, result = events + assert use.kind == HarnessEventKind.TOOL_USE + assert use.tool_id == "call_1" + assert result.kind == HarnessEventKind.TOOL_RESULT + assert result.tool_result == "file.txt\n" + assert result.tool_is_error is False + assert result.tool_display is not None + assert result.tool_display.status == HarnessToolStatus.COMPLETED + assert result.tool_display.exit_code == 0 + + +def test_map_opencode_part_failed_tool_call_marks_result_as_error(): + part = { + "type": "tool", + "tool": "bash", + "callID": "call_2", + "state": {"status": "error", "input": {}, "output": "boom"}, + } + + events = map_opencode_part(part, ROOT_ACTOR) + + assert events[1].tool_is_error is True + assert events[1].tool_display is not None + assert events[1].tool_display.status == HarnessToolStatus.FAILED + + +def test_map_opencode_part_step_start_is_ignored(): + events = map_opencode_part({"type": "step-start"}, ROOT_ACTOR) + + assert events == () + + +def test_map_opencode_part_patch_reports_files(): + events = map_opencode_part( + {"type": "patch", "files": ["almanac/example.md"]}, ROOT_ACTOR + ) + + assert len(events) == 1 + assert events[0].kind == HarnessEventKind.TOOL_SUMMARY + assert "almanac/example.md" in events[0].message + + +def test_map_opencode_part_step_finish_reports_usage(): + events = map_opencode_part( + {"type": "step-finish", "tokens": {"total": 100, "input": 80, "output": 20}}, + ROOT_ACTOR, + ) + + assert len(events) == 1 + assert events[0].kind == HarnessEventKind.CONTEXT_USAGE + assert events[0].usage is not None + assert events[0].usage.total_tokens == 100 + + +@pytest.mark.parametrize( + ("model", "expected"), + [ + ("opencode/deepseek-v4-flash-free", ("opencode", "deepseek-v4-flash-free")), + ("anthropic/claude-sonnet-4-6", ("anthropic", "claude-sonnet-4-6")), + ], +) +def test_split_opencode_model_valid(model, expected): + assert split_opencode_model(model) == expected + + +@pytest.mark.parametrize( + "model", ["no-separator", "/missing-provider", "missing-model/"] +) +def test_split_opencode_model_invalid(model): + with pytest.raises(ValueError, match="provider/model"): + split_opencode_model(model) + + +def test_parse_opencode_usage_reads_nested_cache_field(): + usage = parse_opencode_usage( + {"total": 100, "input": 80, "output": 20, "reasoning": 5, "cache": {"read": 3}} + ) + + assert usage is not None + assert usage.total_tokens == 100 + assert usage.cached_input_tokens == 3 + + +def test_parse_opencode_usage_returns_none_for_empty_value(): + assert parse_opencode_usage(None) is None + assert parse_opencode_usage({}) is None + + +def test_classify_opencode_failure_not_installed(): + failure = classify_opencode_failure("opencode not found on PATH") + + assert failure.code == "opencode.not_installed" + + +def test_classify_opencode_failure_generic_fallback(): + failure = classify_opencode_failure("something else broke") + + assert failure.code == "opencode.request_failed" + assert failure.message == "something else broke" + + +# --- server startup line parsing -------------------------------------------- + + +class _FakeStdoutProcess: + def __init__(self, lines: list[str], exits: bool = False): + self._lines = lines + self._exits = exits + self.stdout = self + + def __iter__(self): + return iter(self._lines) + + def poll(self): + return 0 if self._exits else None + + +def test_wait_for_listening_parses_bound_port(): + process = _FakeStdoutProcess( + ["some startup log\n", "opencode server listening on http://127.0.0.1:54321\n"] + ) + + base_url = _wait_for_listening(process, timeout_seconds=1) + + assert base_url == "http://127.0.0.1:54321" + + +def test_wait_for_listening_raises_when_process_exits_first(): + process = _FakeStdoutProcess(["boom, crashed\n"], exits=True) + + with pytest.raises(OpencodeServerStartupError, match="exited before"): + _wait_for_listening(process, timeout_seconds=1) + + +class _BlockingStdoutProcess: + """Simulates a process that's still running and simply hasn't printed + anything yet, as opposed to one whose stdout has hit EOF.""" + + def __init__(self): + self.stdout = self + + def __iter__(self): + return self + + def __next__(self): + threading.Event().wait() + raise StopIteration + + def poll(self): + return None + + +def test_wait_for_listening_raises_on_timeout(): + process = _BlockingStdoutProcess() + + with pytest.raises(OpencodeServerStartupError, match="did not report"): + _wait_for_listening(process, timeout_seconds=0.05) + + +def test_start_opencode_server_resolves_command_through_path(monkeypatch, tmp_path): + # Windows regression: npm-installed opencode ships as a .cmd/.ps1 shim, + # so the bare command name must be resolved via shutil.which() before + # Popen, the same fix already applied to + # integrations/command.py's SubprocessCommandRunner. + captured: dict[str, object] = {} + + def _fake_which(command: str) -> str: + captured["which_command"] = command + return f"/resolved/{command}.cmd" + + def _fake_popen(args, **kwargs): + captured["popen_args"] = args + return _FakeStdoutProcess(["listening on http://127.0.0.1:12345\n"]) + + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.server.shutil.which", + _fake_which, + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.server.subprocess.Popen", + _fake_popen, + ) + + server = start_opencode_server("opencode", tmp_path) + + assert captured["which_command"] == "opencode" + assert captured["popen_args"][0] == "/resolved/opencode.cmd" + assert server.base_url == "http://127.0.0.1:12345" + + +def test_start_opencode_server_falls_back_to_bare_command_when_unresolved( + monkeypatch, tmp_path +): + captured: dict[str, object] = {} + + def _fake_popen(args, **kwargs): + captured["popen_args"] = args + return _FakeStdoutProcess(["listening on http://127.0.0.1:12345\n"]) + + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.server.shutil.which", + lambda command: None, + ) + monkeypatch.setattr( + "codealmanac.integrations.harnesses.opencode.server.subprocess.Popen", + _fake_popen, + ) + + start_opencode_server("opencode", tmp_path) + + assert captured["popen_args"][0] == "opencode" diff --git a/tests/test_opencode_transcripts.py b/tests/test_opencode_transcripts.py new file mode 100644 index 00000000..e3690d48 --- /dev/null +++ b/tests/test_opencode_transcripts.py @@ -0,0 +1,383 @@ +import json +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path + +from codealmanac.integrations.sources.transcripts import ( + OpencodeTranscriptDiscoveryAdapter, + OpencodeTranscriptSourceRuntimeAdapter, + TranscriptSourceRuntimeAdapter, + default_transcript_runtime_adapters, +) +from codealmanac.integrations.sources.transcripts.opencode_ref import ( + format_opencode_transcript_ref, + parse_opencode_transcript_ref, +) +from codealmanac.services.sources.models import ( + SourceKind, + SourceRef, + SourceRuntimeStatus, + TranscriptApp, +) +from codealmanac.services.sources.requests import ( + DiscoverTranscriptsRequest, + InspectSourceRuntimeRequest, +) +from codealmanac.services.sources.service import SourcesService +from codealmanac.services.sources.transcripts import transcript_address +from codealmanac.workflows.sync.queue import sync_ingest_request +from codealmanac.workflows.sync.requests import SyncRequest + +SCHEMA = """ +CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT NOT NULL); +CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + directory TEXT NOT NULL, + time_updated INTEGER NOT NULL +); +CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + data TEXT NOT NULL +); +CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + data TEXT NOT NULL +); +""" + + +def build_fixture_db(path: Path) -> None: + conn = sqlite3.connect(path) + try: + conn.executescript(SCHEMA) + conn.execute( + "INSERT INTO project (id, worktree) VALUES (?, ?)", + ("proj_1", "/repo"), + ) + conn.execute( + "INSERT INTO session (id, project_id, directory, time_updated) " + "VALUES (?, ?, ?, ?)", + ("ses_1", "proj_1", "/repo", 1783538522023), + ) + conn.execute( + "INSERT INTO message (id, session_id, time_created, data) " + "VALUES (?, ?, ?, ?)", + ("msg_user", "ses_1", 1, json.dumps({"role": "user"})), + ) + conn.execute( + "INSERT INTO part (id, message_id, session_id, time_created, data) " + "VALUES (?, ?, ?, ?, ?)", + ( + "prt_user_1", + "msg_user", + "ses_1", + 1, + json.dumps({"type": "text", "text": "run echo hi"}), + ), + ) + conn.execute( + "INSERT INTO message (id, session_id, time_created, data) " + "VALUES (?, ?, ?, ?)", + ("msg_assistant", "ses_1", 2, json.dumps({"role": "assistant"})), + ) + conn.execute( + "INSERT INTO part (id, message_id, session_id, time_created, data) " + "VALUES (?, ?, ?, ?, ?)", + ( + "prt_asst_1", + "msg_assistant", + "ses_1", + 2, + json.dumps( + { + "type": "tool", + "tool": "bash", + "callID": "call_1", + "state": {"input": {"command": "echo hi"}, "output": "hi\n"}, + } + ), + ), + ) + conn.execute( + "INSERT INTO part (id, message_id, session_id, time_created, data) " + "VALUES (?, ?, ?, ?, ?)", + ( + "prt_asst_2", + "msg_assistant", + "ses_1", + 3, + json.dumps({"type": "text", "text": "done"}), + ), + ) + conn.commit() + finally: + conn.close() + + +# --- opencode_ref round-trip ------------------------------------------------- + + +def test_transcript_address_uses_address_override_when_set(): + from codealmanac.services.sources.models import TranscriptCandidate + + candidate = TranscriptCandidate( + app=TranscriptApp.OPENCODE, + session_id="ses_1", + transcript_path=Path("/home/.local/share/opencode/opencode.db"), + cwd=Path("/repo"), + modified_at=datetime.now(UTC), + size_bytes=100, + address_override="/home/.local/share/opencode/opencode.db::ses_1", + ) + + address = transcript_address(candidate) + + # transcript_address itself is app-agnostic — it only ever reads + # address_override, never candidate.app. The OpenCode-specific encoding + # lives entirely in integrations/sources/transcripts/opencode_ref.py. + assert address == "/home/.local/share/opencode/opencode.db::ses_1" + assert parse_opencode_transcript_ref(address) == ( + Path("/home/.local/share/opencode/opencode.db"), + "ses_1", + ) + + +def test_transcript_address_falls_back_to_transcript_path_without_override(): + from codealmanac.services.sources.models import TranscriptCandidate + + candidate = TranscriptCandidate( + app=TranscriptApp.CLAUDE, + session_id="ignored", + transcript_path=Path("/home/.claude/projects/session.jsonl"), + cwd=Path("/repo"), + modified_at=datetime.now(UTC), + size_bytes=100, + ) + + assert transcript_address(candidate) == "/home/.claude/projects/session.jsonl" + + +def test_parse_opencode_transcript_ref_rejects_plain_path(): + assert parse_opencode_transcript_ref("/home/.claude/session.jsonl") is None + + +def test_parse_opencode_transcript_ref_handles_windows_drive_letter_colon(): + # A Windows path's own single drive-letter colon must not be mistaken + # for the "::" separator — rpartition on the full two-character + # separator finds the real one regardless. + ref = format_opencode_transcript_ref( + Path("C:\\Users\\me\\AppData\\opencode.db"), "ses_abc123" + ) + + assert parse_opencode_transcript_ref(ref) == ( + Path("C:\\Users\\me\\AppData\\opencode.db"), + "ses_abc123", + ) + + +# --- OpencodeTranscriptDiscoveryAdapter -------------------------------------- + + +def test_discover_finds_sessions_in_fixture_db(tmp_path: Path): + db_path = tmp_path / "opencode.db" + build_fixture_db(db_path) + adapter = OpencodeTranscriptDiscoveryAdapter(db_path=db_path) + + candidates = adapter.discover( + DiscoverTranscriptsRequest(home=tmp_path, apps=(TranscriptApp.OPENCODE,)) + ) + + assert len(candidates) == 1 + candidate = candidates[0] + assert candidate.app == TranscriptApp.OPENCODE + assert candidate.session_id == "ses_1" + assert candidate.transcript_path == db_path.resolve() + assert str(candidate.cwd).endswith("/repo") + assert candidate.address_override == f"{db_path.resolve()}::ses_1" + assert transcript_address(candidate) == candidate.address_override + + +def test_discover_returns_empty_when_db_is_missing(tmp_path: Path): + adapter = OpencodeTranscriptDiscoveryAdapter(db_path=tmp_path / "missing.db") + + candidates = adapter.discover( + DiscoverTranscriptsRequest(home=tmp_path, apps=(TranscriptApp.OPENCODE,)) + ) + + assert candidates == () + + +def test_discover_soft_skips_on_schema_drift(tmp_path: Path): + db_path = tmp_path / "opencode.db" + conn = sqlite3.connect(db_path) + conn.executescript("CREATE TABLE session (id TEXT PRIMARY KEY);") # no columns + conn.close() + adapter = OpencodeTranscriptDiscoveryAdapter(db_path=db_path) + + candidates = adapter.discover( + DiscoverTranscriptsRequest(home=tmp_path, apps=(TranscriptApp.OPENCODE,)) + ) + + assert candidates == () + + +# --- OpencodeTranscriptSourceRuntimeAdapter ---------------------------------- + + +def test_runtime_adapter_renders_session_content(tmp_path: Path): + db_path = tmp_path / "opencode.db" + build_fixture_db(db_path) + adapter = OpencodeTranscriptSourceRuntimeAdapter() + ref = SourceRef( + raw=f"transcript:{db_path}::ses_1", + kind=SourceKind.TRANSCRIPT, + identity=f"transcript:{db_path}::ses_1", + transcript=f"{db_path}::ses_1", + ) + + assert adapter.supports(ref) is True + + runtime = adapter.inspect(InspectSourceRuntimeRequest(cwd=tmp_path, ref=ref)) + + assert runtime.status == SourceRuntimeStatus.AVAILABLE + assert runtime.content is not None + assert "echo hi" in runtime.content + assert "hi" in runtime.content + assert "done" in runtime.content + + +def test_runtime_adapter_does_not_support_plain_path_refs(tmp_path: Path): + adapter = OpencodeTranscriptSourceRuntimeAdapter() + ref = SourceRef( + raw="transcript:/home/.claude/session.jsonl", + kind=SourceKind.TRANSCRIPT, + identity="transcript:/home/.claude/session.jsonl", + transcript="/home/.claude/session.jsonl", + ) + + assert adapter.supports(ref) is False + + +def test_runtime_adapter_reports_unavailable_for_missing_db(tmp_path: Path): + adapter = OpencodeTranscriptSourceRuntimeAdapter() + missing = tmp_path / "gone.db" + ref = SourceRef( + raw=f"transcript:{missing}::ses_1", + kind=SourceKind.TRANSCRIPT, + identity=f"transcript:{missing}::ses_1", + transcript=f"{missing}::ses_1", + ) + + runtime = adapter.inspect(InspectSourceRuntimeRequest(cwd=tmp_path, ref=ref)) + + assert runtime.status == SourceRuntimeStatus.UNAVAILABLE + + +# --- registration ordering regression ---------------------------------------- + + +def test_default_runtime_adapters_prefer_opencode_over_generic(tmp_path: Path): + db_path = tmp_path / "opencode.db" + build_fixture_db(db_path) + adapters = default_transcript_runtime_adapters() + assert isinstance(adapters[0], OpencodeTranscriptSourceRuntimeAdapter) + assert isinstance(adapters[1], TranscriptSourceRuntimeAdapter) + + service = SourcesService(runtime_adapters=adapters) + ref = SourceRef( + raw=f"transcript:{db_path}::ses_1", + kind=SourceKind.TRANSCRIPT, + identity=f"transcript:{db_path}::ses_1", + transcript=f"{db_path}::ses_1", + ) + + runtime = service.inspect_runtime( + InspectSourceRuntimeRequest(cwd=tmp_path, ref=ref) + ) + + # If the generic JSONL adapter won instead, this would be UNAVAILABLE + # ("no readable JSONL objects found") rather than real content. + assert runtime.status == SourceRuntimeStatus.AVAILABLE + assert runtime.content is not None + assert "echo hi" in runtime.content + + +def test_generic_runtime_adapter_rejects_opencode_shaped_refs_on_its_own( + tmp_path: Path, +): + # Regression: this must hold even if default_transcript_runtime_adapters() + # is ever reordered — TranscriptSourceRuntimeAdapter.supports() excludes + # OpenCode refs explicitly, not merely because it's asked second today. + adapter = TranscriptSourceRuntimeAdapter() + db_path = tmp_path / "opencode.db" + address = format_opencode_transcript_ref(db_path, "ses_1") + ref = SourceRef( + raw=f"transcript:{address}", + kind=SourceKind.TRANSCRIPT, + identity=f"transcript:{address}", + transcript=address, + ) + + assert adapter.supports(ref) is False + + +# --- sync_ingest_request builds the right address per app ------------------- + + +def test_sync_ingest_request_builds_opencode_and_claude_addresses(tmp_path: Path): + from codealmanac.services.harnesses.models import HarnessKind + from codealmanac.services.repositories.models import Repository + from codealmanac.services.sources.models import TranscriptCandidate + from codealmanac.workflows.sync.models import SyncRepositoryIngest + + repository = Repository( + repository_id="repo-id", + name="repo", + description="", + root_path=tmp_path, + almanac_path=tmp_path / "almanac", + registered_at=datetime.now(UTC), + ) + opencode_candidate = TranscriptCandidate( + app=TranscriptApp.OPENCODE, + session_id="ses_1", + transcript_path=tmp_path / "opencode.db", + cwd=tmp_path, + modified_at=datetime.now(UTC), + size_bytes=10, + # Set by OpencodeTranscriptDiscoveryAdapter in real use (see + # test_discover_finds_sessions_in_fixture_db) — set directly here to + # test sync_ingest_request's pass-through behavior in isolation. + address_override=f"{tmp_path / 'opencode.db'}::ses_1", + ) + claude_candidate = TranscriptCandidate( + app=TranscriptApp.CLAUDE, + session_id="claude-session", + transcript_path=tmp_path / "session.jsonl", + cwd=tmp_path, + modified_at=datetime.now(UTC), + size_bytes=10, + ) + item = SyncRepositoryIngest( + repository=repository, + transcripts=(opencode_candidate, claude_candidate), + ) + request = SyncRequest( + repository_name="repo", + apps=(TranscriptApp.OPENCODE, TranscriptApp.CLAUDE), + harness=HarnessKind.CODEX, + model="gpt-5.5", + ) + + ingest_request = sync_ingest_request(request, item) + + assert ingest_request.inputs == ( + f"transcript:{tmp_path / 'opencode.db'}::ses_1", + f"transcript:{tmp_path / 'session.jsonl'}", + ) diff --git a/tests/test_setup_service.py b/tests/test_setup_service.py index d930d901..6453f11e 100644 --- a/tests/test_setup_service.py +++ b/tests/test_setup_service.py @@ -148,6 +148,7 @@ def test_setup_skip_instructions_still_returns_plan(home: Path): assert tuple(target.value for target in result.plan.instruction_targets) == ( "codex", "claude", + "opencode", ) diff --git a/tests/test_setup_wizard_options.py b/tests/test_setup_wizard_options.py new file mode 100644 index 00000000..c1932afa --- /dev/null +++ b/tests/test_setup_wizard_options.py @@ -0,0 +1,181 @@ +from pathlib import Path + +import pytest + +from codealmanac.cli.dispatch.setup_wizard.options import ( + RUNNER_LABELS, + TARGET_LABELS, + model_options, + parse_setup_targets, + runner_for_index, + runner_index, + target_default_index, + target_options, + targets_for_index, +) +from codealmanac.integrations.setup.instructions import FileInstructionInstaller +from codealmanac.integrations.setup.opencode import ( + install_opencode_instructions, + uninstall_opencode_instructions, +) +from codealmanac.services.config.models import HARNESS_MODELS +from codealmanac.services.harnesses.models import HarnessKind +from codealmanac.services.setup.models import SetupTarget + +GUIDE = "Follow the codealmanac agent guide." + + +# --- runner index round trip ----------------------------------------------- + + +@pytest.mark.parametrize( + ("harness", "index"), + [ + (HarnessKind.CODEX, 0), + (HarnessKind.CLAUDE, 1), + (HarnessKind.OPENCODE, 2), + ], +) +def test_runner_index_round_trip(harness, index): + assert runner_index(harness) == index + assert runner_for_index(index) == harness + + +def test_runner_for_index_falls_back_to_codex_out_of_range(): + assert runner_for_index(99) == HarnessKind.CODEX + assert runner_for_index(-1) == HarnessKind.CODEX + + +def test_runner_labels_cover_every_harness_kind(): + assert set(RUNNER_LABELS) == set(HarnessKind) + + +# --- model options ------------------------------------------------------ + + +@pytest.mark.parametrize("harness", list(HarnessKind)) +def test_model_options_has_a_label_and_detail_for_every_curated_model(harness): + options = model_options(harness) + + assert len(options) == len(HARNESS_MODELS[harness]) + assert all(option.description for option in options) + + +# --- target index round trip ------------------------------------------------ + + +def test_target_options_offers_all_plus_one_per_target(): + options = target_options() + + assert len(options) == 4 + assert options[0].label == "Codex + Claude + OpenCode" + assert [option.label for option in options[1:]] == [ + "Codex only", + "Claude only", + "OpenCode only", + ] + + +@pytest.mark.parametrize( + ("index", "targets"), + [ + (0, (SetupTarget.CODEX, SetupTarget.CLAUDE, SetupTarget.OPENCODE)), + (1, (SetupTarget.CODEX,)), + (2, (SetupTarget.CLAUDE,)), + (3, (SetupTarget.OPENCODE,)), + ], +) +def test_targets_for_index_and_default_index_round_trip(index, targets): + assert targets_for_index(index) == targets + assert target_default_index(targets) == index + + +def test_target_labels_cover_every_setup_target(): + assert set(TARGET_LABELS) == set(SetupTarget) + + +# --- parse_setup_targets ----------------------------------------------------- + + +def test_parse_setup_targets_all_includes_opencode(): + assert parse_setup_targets("all") == ( + SetupTarget.CODEX, + SetupTarget.CLAUDE, + SetupTarget.OPENCODE, + ) + + +def test_parse_setup_targets_single_target(): + assert parse_setup_targets("opencode") == (SetupTarget.OPENCODE,) + + +# --- install_opencode_instructions / uninstall ------------------------------ + + +def test_install_opencode_instructions_writes_managed_block(tmp_path: Path): + change = install_opencode_instructions(tmp_path, GUIDE) + + agents_path = tmp_path / ".config" / "opencode" / "AGENTS.md" + assert change.changed is True + assert change.target == SetupTarget.OPENCODE + assert change.paths == (agents_path,) + assert agents_path.is_file() + assert GUIDE in agents_path.read_text(encoding="utf-8") + + +def test_install_opencode_instructions_is_idempotent(tmp_path: Path): + install_opencode_instructions(tmp_path, GUIDE) + + second = install_opencode_instructions(tmp_path, GUIDE) + + assert second.changed is False + assert second.message == "OpenCode instructions already installed" + + +def test_uninstall_opencode_instructions_removes_managed_block(tmp_path: Path): + install_opencode_instructions(tmp_path, GUIDE) + + change = uninstall_opencode_instructions(tmp_path) + + agents_path = tmp_path / ".config" / "opencode" / "AGENTS.md" + assert change.changed is True + assert not agents_path.exists() + + +def test_uninstall_opencode_instructions_is_a_noop_when_never_installed( + tmp_path: Path, +): + change = uninstall_opencode_instructions(tmp_path) + + assert change.changed is False + assert change.message == "OpenCode instructions were not installed" + + +def test_uninstall_opencode_instructions_preserves_unrelated_content( + tmp_path: Path, +): + agents_path = tmp_path / ".config" / "opencode" / "AGENTS.md" + agents_path.parent.mkdir(parents=True) + agents_path.write_text("# my own notes\n", encoding="utf-8") + + change = uninstall_opencode_instructions(tmp_path) + + assert change.changed is False + assert agents_path.read_text(encoding="utf-8") == "# my own notes\n" + + +# --- FileInstructionInstaller ----------------------------------------------- + + +def test_file_instruction_installer_covers_opencode_target(tmp_path: Path): + installer = FileInstructionInstaller(home=tmp_path) + + changes = installer.install((SetupTarget.OPENCODE,)) + + assert len(changes) == 1 + assert changes[0].target == SetupTarget.OPENCODE + assert changes[0].changed is True + + uninstalled = installer.uninstall((SetupTarget.OPENCODE,)) + + assert uninstalled[0].changed is True From 43a1e8af8bfb49e985157377b3b1a1e9e143209c Mon Sep 17 00:00:00 2001 From: d180 Date: Fri, 10 Jul 2026 12:02:09 -0700 Subject: [PATCH 2/2] fix(setup-wizard): stop card text overflowing borders, unify card heights --- .../cli/render/setup/change_handling.py | 82 +++++++++++++ src/codealmanac/cli/render/setup/screens.py | 109 ++++-------------- src/codealmanac/cli/render/terminal.py | 43 +++++++ 3 files changed, 149 insertions(+), 85 deletions(-) create mode 100644 src/codealmanac/cli/render/setup/change_handling.py diff --git a/src/codealmanac/cli/render/setup/change_handling.py b/src/codealmanac/cli/render/setup/change_handling.py new file mode 100644 index 00000000..29ddb50f --- /dev/null +++ b/src/codealmanac/cli/render/setup/change_handling.py @@ -0,0 +1,82 @@ +from codealmanac.cli.render.brand import ( + BLUE, + BOLD, + DIFF_GREEN, + DIFF_RED, + DIM, + RST, + WHITE_BOLD, +) +from codealmanac.cli.render.terminal import ( + card_right_row, + card_row, + selected_indicator, + write_line, +) + + +def render_change_handling_choice(selected_index: int) -> None: + width = 34 + cards = ( + change_handling_commit_card(width, selected_index == 0), + change_handling_worktree_card(width, selected_index == 1), + ) + rows = max(len(lines) for lines in cards) + for row in range(rows): + left = cards[0][row] if row < len(cards[0]) else " " * (width + 2) + right = cards[1][row] if row < len(cards[1]) else " " * (width + 2) + write_line(f" {left} {right}") + left_indicator = ( + selected_indicator(width, f"{BLUE}{BOLD}", RST) + if selected_index == 0 + else " " * (width + 2) + ) + right_indicator = ( + selected_indicator(width, f"{BLUE}{BOLD}", RST) + if selected_index == 1 + else " " * (width + 2) + ) + write_line(f" {left_indicator} {right_indicator}") + + +def change_handling_commit_card(width: int, selected: bool) -> tuple[str, ...]: + border = BLUE if selected else DIM + title = WHITE_BOLD if selected else DIM + muted = RST if selected else DIM + commit = BLUE if selected else DIM + return ( + f"{border}╭{'─' * width}╮{RST}", + card_row("", width, border, RST), + card_row(f" {title}Commit changes{RST}", width, border, RST), + card_row("", width, border, RST), + card_row(f" {commit}● almanac: update wiki context{RST}", width, border, RST), + card_row(f" {muted}│ rohan · just now{RST}", width, border, RST), + card_row(f" {muted}│{RST}", width, border, RST), + card_row(f" {muted}● docs: previous repo commit{RST}", width, border, RST), + card_row(f" {muted}│ rohan · earlier{RST}", width, border, RST), + card_row("", width, border, RST), + card_row("", width, border, RST), + f"{border}╰{'─' * width}╯{RST}", + ) + + +def change_handling_worktree_card(width: int, selected: bool) -> tuple[str, ...]: + border = BLUE if selected else DIM + title = WHITE_BOLD if selected else DIM + muted = RST if selected else DIM + delete = DIFF_RED if selected else DIM + add = DIFF_GREEN if selected else DIM + return ( + f"{border}╭{'─' * width}╮{RST}", + card_row("", width, border, RST), + card_row(f" {title}Leave in worktree{RST}", width, border, RST), + card_row("", width, border, RST), + card_row(f" {muted}almanac/architecture/indexing.md{RST}", width, border, RST), + card_right_row(f"{delete}-18{RST} {add}+42{RST}", width, border, RST), + card_row(f" {muted}almanac/decisions/local-first.md{RST}", width, border, RST), + card_right_row(f"{delete}-4{RST} {add}+19{RST}", width, border, RST), + card_row(f" {muted}almanac/guides/setup.md{RST}", width, border, RST), + card_right_row(f"{delete}-2{RST} {add}+11{RST}", width, border, RST), + card_row("", width, border, RST), + f"{border}╰{'─' * width}╯{RST}", + ) diff --git a/src/codealmanac/cli/render/setup/screens.py b/src/codealmanac/cli/render/setup/screens.py index daf1cb93..10fb97f4 100644 --- a/src/codealmanac/cli/render/setup/screens.py +++ b/src/codealmanac/cli/render/setup/screens.py @@ -4,8 +4,6 @@ BAR, BLUE, BOLD, - DIFF_GREEN, - DIFF_RED, DIM, RST, WHITE_BOLD, @@ -13,10 +11,13 @@ print_badge, print_banner, ) +from codealmanac.cli.render.setup.change_handling import render_change_handling_choice from codealmanac.cli.render.terminal import ( card_center_row, - card_right_row, card_row, + card_width_for, + selected_indicator, + wrap_text, wrap_with_prefixes, write_line, ) @@ -77,19 +78,21 @@ def render_option_cards( options: tuple[SetupChoiceOption, ...], selected_index: int, ) -> None: - card_width = 21 if len(options) == 3 else 34 + card_width = card_width_for(len(options)) + inner_width = max(1, card_width - 2) + body_height = max( + len(wrap_text(option.label, inner_width)) + + sum(len(wrap_text(line, inner_width)) for line in option.description) + for option in options + ) card_lines = tuple( - option_card(option, card_width, index == selected_index) + option_card(option, card_width, index == selected_index, body_height) for index, option in enumerate(options) ) - rows = max(len(lines) for lines in card_lines) - for row in range(rows): - parts = [] - for lines in card_lines: - parts.append(lines[row] if row < len(lines) else " " * (card_width + 2)) - write_line(" " + " ".join(parts)) + for row in range(len(card_lines[0])): + write_line(" " + " ".join(lines[row] for lines in card_lines)) indicator_parts = [ - selected_indicator(card_width) + selected_indicator(card_width, f"{BLUE}{BOLD}", RST) if index == selected_index and not options[index].disabled else " " * (card_width + 2) for index in range(len(options)) @@ -124,6 +127,7 @@ def option_card( option: SetupChoiceOption, width: int, selected: bool, + body_height: int, ) -> tuple[str, ...]: enabled = not option.disabled border = BLUE if selected and enabled else DIM @@ -134,80 +138,15 @@ def option_card( lines = [ f"{border}╭{'─' * width}╮{RST}", card_row("", width, border, RST), - card_center_row(label, width, border, RST), ] + for label_line in wrap_text(label, max(1, width - 2)): + lines.append(card_center_row(label_line, width, border, RST)) for description in option.description: - lines.append(card_center_row(f"{body}{description}{RST}", width, border, RST)) - lines.append(card_row("", width, border, RST)) + for description_line in wrap_text(description, max(1, width - 2)): + lines.append( + card_center_row(f"{body}{description_line}{RST}", width, border, RST) + ) + while len(lines) - 2 < body_height + 1: + lines.append(card_row("", width, border, RST)) lines.append(f"{border}╰{'─' * width}╯{RST}") return tuple(lines) - - -def selected_indicator(width: int) -> str: - text = "◆ selected" - left_padding = max(0, (width + 2 - len(text)) // 2) - right_padding = max(0, width + 2 - left_padding - len(text)) - return f"{' ' * left_padding}{BLUE}{BOLD}{text}{RST}{' ' * right_padding}" - - -def render_change_handling_choice(selected_index: int) -> None: - width = 34 - cards = ( - change_handling_commit_card(width, selected_index == 0), - change_handling_worktree_card(width, selected_index == 1), - ) - rows = max(len(lines) for lines in cards) - for row in range(rows): - left = cards[0][row] if row < len(cards[0]) else " " * (width + 2) - right = cards[1][row] if row < len(cards[1]) else " " * (width + 2) - write_line(f" {left} {right}") - left_indicator = ( - selected_indicator(width) if selected_index == 0 else " " * (width + 2) - ) - right_indicator = ( - selected_indicator(width) if selected_index == 1 else " " * (width + 2) - ) - write_line(f" {left_indicator} {right_indicator}") - - -def change_handling_commit_card(width: int, selected: bool) -> tuple[str, ...]: - border = BLUE if selected else DIM - title = WHITE_BOLD if selected else DIM - muted = RST if selected else DIM - commit = BLUE if selected else DIM - return ( - f"{border}╭{'─' * width}╮{RST}", - card_row("", width, border, RST), - card_row(f" {title}Commit changes{RST}", width, border, RST), - card_row("", width, border, RST), - card_row(f" {commit}● almanac: update wiki context{RST}", width, border, RST), - card_row(f" {muted}│ rohan · just now{RST}", width, border, RST), - card_row(f" {muted}│{RST}", width, border, RST), - card_row(f" {muted}● docs: previous repo commit{RST}", width, border, RST), - card_row(f" {muted}│ rohan · earlier{RST}", width, border, RST), - card_row("", width, border, RST), - card_row("", width, border, RST), - f"{border}╰{'─' * width}╯{RST}", - ) - - -def change_handling_worktree_card(width: int, selected: bool) -> tuple[str, ...]: - border = BLUE if selected else DIM - title = WHITE_BOLD if selected else DIM - muted = RST if selected else DIM - delete = DIFF_RED if selected else DIM - add = DIFF_GREEN if selected else DIM - return ( - f"{border}╭{'─' * width}╮{RST}", - card_row("", width, border, RST), - card_row(f" {title}Leave in worktree{RST}", width, border, RST), - card_row("", width, border, RST), - card_row(f" {muted}almanac/architecture/indexing.md{RST}", width, border, RST), - card_right_row(f"{delete}-18{RST} {add}+42{RST}", width, border, RST), - card_row(f" {muted}almanac/decisions/local-first.md{RST}", width, border, RST), - card_right_row(f"{delete}-4{RST} {add}+19{RST}", width, border, RST), - card_row(f" {muted}almanac/guides/setup.md{RST}", width, border, RST), - card_right_row(f"{delete}-2{RST} {add}+11{RST}", width, border, RST), - card_row("", width, border, RST), - f"{border}╰{'─' * width}╯{RST}", - ) diff --git a/src/codealmanac/cli/render/terminal.py b/src/codealmanac/cli/render/terminal.py index 7aa104a4..ed3b1e2d 100644 --- a/src/codealmanac/cli/render/terminal.py +++ b/src/codealmanac/cli/render/terminal.py @@ -53,6 +53,31 @@ def wrap_with_prefixes( return tuple(lines) +def wrap_text(text: str, width: int) -> tuple[str, ...]: + words = tuple( + _fit_word(word, width) for word in text.split(" ") if len(word) > 0 + ) + if len(words) == 0: + return ("",) + lines: list[str] = [] + line = words[0] + for word in words[1:]: + candidate = f"{line} {word}" + if visible_length(candidate) > width: + lines.append(line) + line = word + continue + line = candidate + lines.append(line) + return tuple(lines) + + +def _fit_word(word: str, width: int) -> str: + if visible_length(word) <= width or width <= 1: + return word + return f"{word[: width - 1]}…" + + def card_row(content: str, width: int, border: str, reset: str) -> str: padding = max(0, width - visible_length(content)) return f"{border}│{reset}{content}{' ' * padding}{border}│{reset}" @@ -70,5 +95,23 @@ def card_center_row(content: str, width: int, border: str, reset: str) -> str: return f"{border}│{reset}{' ' * left}{content}{' ' * right}{border}│{reset}" +def selected_indicator(width: int, style: str, reset: str) -> str: + text = "◆ selected" + left_padding = max(0, (width + 2 - len(text)) // 2) + right_padding = max(0, width + 2 - left_padding - len(text)) + return f"{' ' * left_padding}{style}{text}{reset}{' ' * right_padding}" + + +def card_width_for(count: int) -> int: + # A row of cards is: 3 leading spaces, each card is width+2 (borders), + # plus a 3-space gap between cards. Solving row_width = n*(width+5) for + # width keeps every option-count screen close to an 80-col terminal. + row_width = 78 + min_width = 18 + if count <= 0: + return min_width + return max(min_width, row_width // count - 5) + + def shell_command(command: tuple[str, ...]) -> str: return shlex.join(command)