From 662976e9e98e8b44040945b6afb9222a48cf2606 Mon Sep 17 00:00:00 2001 From: TheViniAlmeida Date: Fri, 9 Oct 2026 16:41:14 -0300 Subject: [PATCH 1/2] feat(opencode): support v2 session transfers --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 5 + docs/architecture.md | 22 +- docs/cli-reference.md | 21 +- docs/credential-free-native-testing.md | 11 + docs/development.md | 3 +- docs/format-compatibility.md | 14 +- docs/opencode-v2.md | 75 +++ docs/session-catalog.md | 13 +- docs/troubleshooting.md | 9 +- scripts/install-native-test-clis.sh | 4 + scripts/run-native-test-client.sh | 6 + src/session_migrate/catalog.py | 56 +- src/session_migrate/cli.py | 12 +- src/session_migrate/conversion.py | 116 +++- src/session_migrate/formats/kilo.py | 10 + src/session_migrate/formats/opencode.py | 34 +- src/session_migrate/formats/opencode_v2.py | 694 +++++++++++++++++++++ src/session_migrate/inspection.py | 16 +- tests/test_catalog.py | 68 ++ tests/test_opencode_v2.py | 646 +++++++++++++++++++ tests/test_opencode_v2_native.py | 119 ++++ tests/test_route_matrix.py | 21 + tests/test_target_integration.py | 2 + 24 files changed, 1909 insertions(+), 69 deletions(-) create mode 100644 docs/opencode-v2.md create mode 100644 src/session_migrate/formats/opencode_v2.py create mode 100644 tests/test_opencode_v2.py create mode 100644 tests/test_opencode_v2_native.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c87a284..7a87da9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,7 @@ jobs: - muse - omp - opencode + - opencode-v2 - openhands - pi - qwen diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d75a84..f3143f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ here. Native format compatibility is documented separately in ## Unreleased +- Add version-aware OpenCode 2.0 public session transfer support, retaining the + legacy 1.17.20 adapter and native no-overwrite/dry-run controls. Preserve + portable tools, inline media, and completed compaction summaries, and account + for incomplete tools and provider-private state. + ## 0.11.0 - 2026-09-11 - Read root Codex rollouts written with `history_mode: "paginated"` by using diff --git a/docs/architecture.md b/docs/architecture.md index fb6f782..bdd6352 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -163,7 +163,7 @@ silently changed. The source is never overwritten. - Copilot reserves the complete session directory and writes events, workspace sidecar, and manifest. - OpenCode and Kilo reserve a private external manifest, invoke only the - official pinned importer, confirm the ID through official read/export + supported OpenCode or pinned Kilo importer, confirm the ID through official read/export operations, then finalize the manifest. - Antigravity and Cursor reserve the manifest, verify the exact pinned binary, invoke their clean-room atomic database installers, validate the installed @@ -178,15 +178,20 @@ that the session may already exist. Blind retry is intentionally avoided. ## Version boundaries Claude/Codex writers are pinned to the local integration image; Pi, OMP, -OpenCode, Copilot, Antigravity, Cursor, Vibe, Muse, Qwen, Kimi, Grok, Kilo, +Copilot, Antigravity, Cursor, Vibe, Muse, Qwen, Kimi, Grok, Kilo, OpenHands, Hermes, MastraCode, and Devin to exact host builds/releases. A source declaring a different version produces `unvalidated_source_version`. A -`--target-cli-version` override changes metadata only and produces -`unvalidated_target_version`; it never changes writer architecture. - -Automatic OpenCode, Kilo, Antigravity, Cursor, and Hermes installation is stricter: metadata -overrides cannot bypass exact runtime version checks. Antigravity verifies its +`--target-cli-version` override changes metadata only for most writers and +produces `unvalidated_target_version`. OpenCode selects between its legacy +and 2.0 public transfer schemas; native import detects the installed CLI, +while convert-only defaults to legacy. Exact release warnings are scoped to +the selected schema. See [OpenCode v2](opencode-v2.md). + +Automatic Kilo, Antigravity, Cursor, and Hermes installation is stricter: metadata +overrides cannot bypass exact runtime version checks. OpenCode requires legacy +1.17.20 or its supported 2.0 schema family and rejects artifact/CLI schema +mismatches. Antigravity verifies its binary digest. Cursor verifies launcher, main bundle, protobuf-bearing chunk, bundled Node, sizes, SHA-256 values, and reported version. @@ -205,7 +210,8 @@ Enumeration covers: - Claude main sessions and nested sidechains; - Codex active and archived rollouts; - Pi and OMP workspace buckets, classified by their native heads; -- every OpenCode and Kilo `session` row, including parents/archives; +- every OpenCode `session_v2`/legacy `session` and Kilo `session` row, + including parents/archives; - Copilot session directories, including missing event logs; - Antigravity conversation DBs; - Cursor workspace/chat DBs, including missing stores; diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 98d95b0..14c93ea 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -48,10 +48,11 @@ validation. `--format` bypasses automatic format selection. It does not make an unsupported schema or version safe. -Kilo and OpenCode official bundles intentionally share one schema and retain +Kilo and legacy OpenCode official bundles intentionally share one schema and retain stored metadata across cross-imports. A standalone bundle cannot be attributed reliably, so automatic inspection fails closed; pass `--format kilo` or -`--format opencode` for a trusted bundle. +`--format opencode` for a trusted legacy bundle. Flat OpenCode 2.0 bundles are +recognized distinctly; Kilo rejects that schema. ## `convert` @@ -83,7 +84,7 @@ performs conversion, native validation, and collision checks but does not install a session or manifest. OpenCode's official read/list preflight can initialize its ordinary XDG cache/database metadata during a dry run. -OpenCode and Kilo import always use their official pinned CLIs and do not accept +OpenCode and Kilo import always use their official CLIs and do not accept `--home`; isolate or select them with normal `HOME`/XDG variables. Antigravity and Cursor installs verify the exact pinned executable and its published hashes. Muse and Qwen install one native JSONL. Kimi, Grok, and OpenHands @@ -113,7 +114,7 @@ smigrate transfer fix-timeline-merging --from devin --to claude ``` Claude, Pi, OMP, Cursor, Vibe, Qwen, Kimi, and Grok can use `--source-cwd` to -select a workspace-specific store. OpenCode and Kilo are virtual: the pinned +select a workspace-specific store. OpenCode and Kilo are virtual: the supported official CLI exports the requested ID. Hermes, MastraCode, and Devin are also virtual catalog sources, but their selected native ID is parsed directly from the agent's shared database. All other sources are read from their native files. @@ -144,8 +145,8 @@ default. Every other source requires an explicit target. | `--format FORMAT` | Override source detection for file-based `inspect`, `convert`, or `import` | | `--session-id UUID` | Assign a new target UUID; generated by default | | `--cwd PATH` | Target working directory; precedence is option, source CWD, process CWD | -| `--target-cli-version VERSION` | Change emitted metadata only; the writer architecture remains pinned | -| `--target-cli PATH` | Pinned OpenCode, Kilo, Antigravity, Cursor, or Hermes executable/source for native import | +| `--target-cli-version VERSION` | Select legacy or 2.0 transfer schema for OpenCode; change metadata only for other targets | +| `--target-cli PATH` | Supported OpenCode or pinned Kilo, Antigravity, Cursor, or Hermes executable/source for native import | | `--model-provider ID` | Codex, Pi, OMP, OpenCode, or Muse target provider | | `--model ID` | Target model label for formats that persist one, including Grok, Kilo, and OpenHands | | `--home PATH` | Target native home, except OpenCode and Kilo | @@ -153,8 +154,12 @@ default. Every other source requires an explicit target. An irrelevant target-specific option may be accepted but has no effect. The manifest records the target metadata version and warns when it differs from the -validated writer pin. Automatic OpenCode, Kilo, Antigravity, Cursor, and Hermes -installation still requires the exact pinned version. +validated writer release. Native OpenCode import selects its schema from the +installed CLI unless explicitly overridden: legacy `1.17.20` or the `2.0.x` +family. Conversion without native import defaults to legacy; use +`--target-cli-version 2.0.22` for a v2 bundle. Other native schema series fail +closed. See [OpenCode v2](opencode-v2.md) for exact validation scope. Kilo, +Antigravity, Cursor, and Hermes installation requires the exact pinned version. ## Home resolution diff --git a/docs/credential-free-native-testing.md b/docs/credential-free-native-testing.md index e8e834f..5c0e16d 100644 --- a/docs/credential-free-native-testing.md +++ b/docs/credential-free-native-testing.md @@ -56,6 +56,7 @@ No production credential is read, translated, or written by this workflow. | Muse 0.2.1 | Exact Muse with the pinned OpenRouter adapter pointed locally; replay and append | | OMP 18.0.5 | Local provider; replay, append, and rename | | OpenCode 1.17.20 | Official import/export plus local provider replay and append | +| OpenCode v2 | Stock 2.0.22 locally; stock 2.0.23 CI variant; public import/export, catalog, cold reopen and local continuation | | OpenHands 1.16.0 | OpenAI-compatible override; native creation, reload, TUI replay, and append | | Pi 0.80.6 | Offline RPC with local provider; tools, images, compaction, replay, and append | | Qwen 0.22.1 | OpenAI-compatible override; imported history replays and the reply persists | @@ -82,3 +83,13 @@ harness and installations do not compete for disk space. The normal Python job separately runs the deterministic 18 by 18 route oracle. The native matrix is the independent acceptance layer: generated files must be accepted by code owned and shipped by each harness. + +## OpenCode v2 variant + +The additional `opencode-v2` job pins `@opencode/cli@2.0.23` and sets +`SESSION_MIGRATE_TEST_OPENCODE_V2`. Its runner requires every assigned test to +run without skips, exercising synthetic import/export, collisions, cold reopen +and localhost-only continuation. The same native tests accept an explicitly supplied stock 2.0.22 binary and +select its observed release in the artifact. Legacy `opencode` remains pinned +to 1.17.20. +See [the v2 transfer contract](opencode-v2.md). diff --git a/docs/development.md b/docs/development.md index e91554b..5442cd9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -28,7 +28,8 @@ pytest and Ruff are development dependencies locked by `uv.lock`. | `src/session_migrate/formats/codex.py` | Codex rollout reader and legacy writer | | `src/session_migrate/formats/pi.py` | Pi 0.80.6 v3 writer/parser/validator | | `src/session_migrate/formats/omp.py` | Oh My Pi 18.0.5 title-slot v3 adapter | -| `src/session_migrate/formats/opencode.py` | OpenCode 1.17.20 public-bundle writer/parser/validator | +| `src/session_migrate/formats/opencode.py` | OpenCode legacy/v2 public-bundle dispatch, writer/parser/validator | +| `src/session_migrate/formats/opencode_v2.py` | OpenCode 2.0 flat transfer projection and validation | | `src/session_migrate/formats/copilot.py` | Copilot CLI 1.0.70 event writer/parser/validator | | `src/session_migrate/formats/antigravity.py` | Antigravity 1.1.16 clean-room DB adapter | | `src/session_migrate/formats/cursor.py` | Experimental pinned Cursor text DB adapter | diff --git a/docs/format-compatibility.md b/docs/format-compatibility.md index 25e070d..2380d32 100644 --- a/docs/format-compatibility.md +++ b/docs/format-compatibility.md @@ -20,7 +20,8 @@ to separately installed host binaries: | Codex CLI | `0.144.4` | | Pi source and target | `0.80.6` | | Oh My Pi source and target | `18.0.5` | -| OpenCode source and target | `1.17.20` | +| OpenCode legacy source and target | `1.17.20` | +| OpenCode v2 transfer adapter | Stock Linux `2.0.22` local gate; `2.0.23` CI pin | | GitHub Copilot CLI source and target | `1.0.70` | | Antigravity CLI source and target | `1.1.16` | | Cursor Agent experimental text adapter | `2026.03.20-44cb435` | @@ -54,6 +55,13 @@ rendering without a model append; Devin proves ACP loading and the vendor-login boundary. See [Credential-free native client testing](credential-free-native-testing.md). +OpenCode 2.0.x has a separate public-transfer adapter. Its local synthetic tests +cover native import/export, tool linkage, completed compaction, dry-run and +identity conflicts; this does not extend the historical eighteen-client corpus +validation claim. See [OpenCode v2 transfers](opencode-v2.md) for exact scope and +limitations. No real user transcript migration or native Windows validation has +been performed for this adapter. + All eighteen formats are sources and targets. Their mappings, native probes, and loss keys are specified in [Additional native formats](additional-target-formats.md), [OpenCode source research](opencode-source-exploration.md), @@ -150,7 +158,7 @@ the current fixed-title-slot form. See [the exact OMP contract](omp-format.md). ### Additional native stores -- OpenCode sessions are inventoried from its read-only SQLite `session` table +- OpenCode sessions are inventoried from its read-only SQLite `session` / `session_v2` tables and exported/imported only through the pinned official CLI. - Copilot sessions use `~/.copilot/session-state//events.jsonl` plus the workspace sidecar and content-addressed assets. @@ -387,7 +395,7 @@ Claude, Codex, Pi, OMP, Copilot, Antigravity, Cursor, Vibe, Muse, Qwen, Kimi, Grok, and OpenHands native files plus content-free manifests use no-clobber private publication; if manifest creation fails after a new filesystem target is created, the error reports whether that -native session may remain. OpenCode and Kilo instead use their exact pinned +native session may remain. OpenCode and Kilo instead use their version-gated public importers and publish only a private migrator manifest after official export-based verification; the migrator never writes either SQLite database. Explicit UUID resume is the diff --git a/docs/opencode-v2.md b/docs/opencode-v2.md new file mode 100644 index 0000000..cd21658 --- /dev/null +++ b/docs/opencode-v2.md @@ -0,0 +1,75 @@ +# OpenCode 2.0 public session transfers + +OpenCode 1.17.20 uses `import/export --pure` and nested message/part bundles. +OpenCode 2.0 uses `session import/export --standalone` and flat typed messages. +Native import detects the installed client and selects its transfer schema. +Convert-only output remains legacy unless `--target-cli-version 2.0.22` or another accepted 2.0.x release is set. +Unsupported schema series fail closed. The `opencode v` version prefix and +custom suffixes are accepted; unvalidated exact releases receive schema-aware +warnings. Local native gates cover stock 2.0.22; the CI variant pins stock +2.0.23. Accepting the 2.0 schema family does not establish native compatibility +with every future patch or custom build. Unknown series such as 2.1 and 3.x +fail closed during native import/export. + +The v2 adapter preserves ordered user/assistant text, linked tool inputs and +results, inline user/tool images, and readable completed compaction summaries. +User text blocks in one native message become their exact newline join. +Private-only or whitespace-only checkpoints do not retire readable history. +Encrypted provider checkpoints, private traces, source reasoning variants and +unsupported control/metadata fields are explicitly counted as omissions. + +A streamed tool input can be an incomplete JSON string. The source projection +keeps it unchanged inside `{ "input": ORIGINAL_STRING }`; it does not replace +it with `{}` or attempt to parse/complete it. On conversion, an unfinished call +is archived as an `ImportedIncompleteTool` error with the same ID and input, +because native transfer discards unsettled assistant records. This is historical +input preservation, not an executable resumed partial tool invocation. The +source→portable→target regression includes an unterminated string input. + +OpenCode owns its database import. No private SQLite writes, credentials or +machine-specific launchers are part of the adapter. Dry run performs public +native preflight without importing or writing a migration manifest; native +preflight may initialize the client's ordinary store. Imports enforce global +identity collisions and explicit success confirmation, then verify the imported +ID using public export. A conflict can have native exit status zero and still +fails import confirmation. + +## Reproducible native and route checks + +```sh +./scripts/install-native-test-clis.sh /tmp/session-migrate-native opencode-v2 +./scripts/run-native-test-client.sh \ + opencode-v2 /tmp/session-migrate-native/session-migrate-native.env +PYTHONPATH=src python3 -m pytest -q tests/test_opencode_v2.py tests/test_route_matrix.py +``` + +The `opencode-v2` CI matrix entry installs **@opencode/cli@2.0.23** (the legacy +package is opencode-ai), supplies `SESSION_MIGRATE_TEST_OPENCODE_V2`, and rejects +skipped assigned tests. Native tests use synthetic transcripts, isolated HOME +and XDG directories, an explicit credential-free config, and a deterministic +localhost provider. They verify import/export, dry run, collisions, cold process +reopening, compaction/tool replay and a new persisted reply under the same ID. +They do not use real accounts or supplier inference credits. The default suite +can skip native tests when neither tested executable is supplied; the selected +CI job cannot silently pass those skips. + +Route tests cover legacy and v2 OpenCode source and target variants against the +other supported formats. The 19 variants remain eighteen harnesses. Existing +legacy wire behavior and native tests remain separate. + +The metadata catalog reads `session_v2` and legacy `session` within one +read-only transaction. A duplicate ID uses the v2 metadata; legacy-only rows +remain visible. A missing v2 title is represented as an untitled session. +Message bodies are never queried or indexed. Kilo 7.5.0 continues to emit and +accept only the legacy nested schema, even with a metadata version override. + +For a preinstalled stock 2.0.22 binary, no dependency installation is needed: + +```sh +SESSION_MIGRATE_TEST_OPENCODE_V2=/absolute/path/to/opencode \ + PYTHONPATH=src python3 -m pytest -q \ + tests/test_opencode_v2.py tests/test_opencode_v2_native.py +``` + +Native Windows acceptance and all historical client versions are not +established by these Linux gates. diff --git a/docs/session-catalog.md b/docs/session-catalog.md index 244bf8b..a718a12 100644 --- a/docs/session-catalog.md +++ b/docs/session-catalog.md @@ -13,7 +13,8 @@ disposable SQLite index and never changes an agent session store. An exhaustive refresh means **every recognized native session below every enabled catalog root**: every expected JSONL or per-session database, including declared Copilot/Cursor directories with missing native state, plus every -OpenCode/Kilo `session` row and every Hermes/MastraCode/Devin shared-database +OpenCode `session_v2`/legacy `session` row, Kilo `session` row and every +Hermes/MastraCode/Devin shared-database identity. It does not mean an implicit whole-disk crawl. Agent homes can have arbitrary names and locations, so discovering all of them still requires either a known root or an explicit search boundary. @@ -164,14 +165,17 @@ timestamps/version, and record count. A selected transfer then parses exactly that identity. The catalog never stores the conversation rows. OpenCode enumeration opens `HOME/opencode.db` with SQLite `mode=ro` and -`query_only`, then projects only these `session` columns: +`query_only`, then projects only these columns from `session_v2` and legacy +`session` in one read-only transaction: ```text id, title, directory, version, time_created, time_updated, parent_id, time_archived ``` -Kilo uses the same bounded read-only inventory strategy against `HOME/kilo.db`. +Duplicate IDs use v2 metadata, and a null v2 title appears as an untitled +session. Kilo uses the same bounded read-only inventory strategy against the +legacy `session` table in `HOME/kilo.db`. Neither scan runs a per-row export or inspects `message`/`part` tables. This keeps refresh work proportional to the small inventory table, not the total transcript corpus. @@ -286,7 +290,8 @@ match at least one indexed field for the same session. Search covers: - Claude sidechain `agentId` and `agent-` filename keys; - Pi `session_info.name` values and native session IDs; - OMP fixed-slot/header/`title_change` values and native session IDs; -- OpenCode and Kilo native session IDs and bounded `session.title` values; +- OpenCode native IDs and bounded `session_v2.title`/`session.title` values; +- Kilo native IDs and bounded `session.title` values; - Copilot session IDs, `session.title_changed` values, and bounded picker names from `workspace.yaml`; - Antigravity UUIDs and bounded native summary titles; diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a3e3bdc..f3d8e55 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -117,8 +117,13 @@ Do not delete native sessions merely to satisfy lookup. ## OpenCode source or target errors -OpenCode source transfer and target import require exact pinned `1.17.20` and -its official `export`/`import`/`session list` commands. +OpenCode source transfer and target import accept legacy `1.17.20` and the +`2.0.x` transfer schema family. Legacy commands use `export`/`import --pure`; +v2 uses `session export`/`session import --standalone`. Native target import +detects the installed CLI; convert-only defaults to legacy unless +`--target-cli-version 2.0.22` or another accepted v2 release is selected. +Unknown schema series fail closed and unvalidated exact releases receive +warnings. See [OpenCode v2](opencode-v2.md) for tested releases and limits. - `--source-cli` selects its source exporter. - `--target-cli` selects its target importer. diff --git a/scripts/install-native-test-clis.sh b/scripts/install-native-test-clis.sh index 08d7787..bf21eb5 100755 --- a/scripts/install-native-test-clis.sh +++ b/scripts/install-native-test-clis.sh @@ -77,6 +77,10 @@ if want opencode; then npm_cli opencode 'opencode-ai@1.17.20' \ SESSION_MIGRATE_OPENCODE_BIN node_modules/.bin/opencode fi +if want opencode-v2; then + npm_cli opencode-v2 '@opencode/cli@2.0.23' \ + SESSION_MIGRATE_TEST_OPENCODE_V2 node_modules/@opencode/cli-linux-x64/bin/opencode +fi if want pi; then npm_cli pi '@earendil-works/pi-coding-agent@0.80.6' \ SESSION_MIGRATE_PI_BIN node_modules/.bin/pi diff --git a/scripts/run-native-test-client.sh b/scripts/run-native-test-client.sh index d2c3dc7..cb2bb2d 100755 --- a/scripts/run-native-test-client.sh +++ b/scripts/run-native-test-client.sh @@ -99,6 +99,12 @@ case "$client" in 'tests/test_opencode_kilo_corpus_native.py::test_exact_client_cold_import_export_and_continuation_preserve_prefix[opencode]' ) ;; + opencode-v2) + tests=( + tests/test_opencode_v2.py::test_native_v2_isolated_import_export_dry_run_and_collision + tests/test_opencode_v2_native.py + ) + ;; openhands) tests=( tests/test_grok_kilo_openhands_native.py::test_openhands_1160_creates_native_tool_source_from_empty_state diff --git a/src/session_migrate/catalog.py b/src/session_migrate/catalog.py index 2c34fe1..95269ec 100644 --- a/src/session_migrate/catalog.py +++ b/src/session_migrate/catalog.py @@ -2948,9 +2948,14 @@ def _opencode_inventory( connection.execute("PRAGMA query_only = ON") connection.execute("PRAGMA busy_timeout = 1000") connection.execute("BEGIN") - columns = { - str(row[1]) for row in connection.execute("PRAGMA table_info(session)").fetchall() + tables = ("session_v2", "session") if label == "opencode" else ("session",) + schemas = { + table: { + str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})").fetchall() + } + for table in tables } + schemas = {table: columns for table, columns in schemas.items() if columns} required = { "id", "directory", @@ -2959,23 +2964,38 @@ def _opencode_inventory( "time_created", "time_updated", } - if not required.issubset(columns): + if not schemas or any(not required.issubset(columns) for columns in schemas.values()): raise _OpenCodeInventoryError(f"{label}_schema_unsupported") - selected = [ - "id", - f"substr(directory, 1, {PATH_VALUE_LIMIT}) AS directory", - f"substr(title, 1, {LABEL_LIMIT}) AS title", - f"substr(version, 1, {LABEL_LIMIT}) AS version", - "time_created", - "time_updated", - ( - f"substr(parent_id, 1, {LABEL_LIMIT}) AS parent_id" - if "parent_id" in columns - else "NULL AS parent_id" - ), - "time_archived" if "time_archived" in columns else "NULL AS time_archived", - ] - rows = connection.execute(f"SELECT {', '.join(selected)} FROM session ORDER BY id") # noqa: S608 + queries = [] + for table, columns in schemas.items(): + selected = [ + "id", + f"substr(directory, 1, {PATH_VALUE_LIMIT}) AS directory", + ( + f"substr(COALESCE(title, 'Untitled session'), 1, {LABEL_LIMIT}) AS title" + if table == "session_v2" + else f"substr(title, 1, {LABEL_LIMIT}) AS title" + ), + f"substr(version, 1, {LABEL_LIMIT}) AS version", + "time_created", + "time_updated", + ( + f"substr(parent_id, 1, {LABEL_LIMIT}) AS parent_id" + if "parent_id" in columns + else "NULL AS parent_id" + ), + "time_archived" if "time_archived" in columns else "NULL AS time_archived", + ] + # Upgraded stores can retain the same ID in both schemas. The v2 + # projection is authoritative for migrated sessions; legacy-only + # sessions remain discoverable without reading message bodies. + where = ( + " WHERE NOT EXISTS (SELECT 1 FROM session_v2 WHERE session_v2.id = session.id)" + if table == "session" and "session_v2" in schemas + else "" + ) + queries.append(f"SELECT {', '.join(selected)} FROM {table}{where}") # noqa: S608 + rows = connection.execute(" UNION ALL ".join(queries) + " ORDER BY id") except _OpenCodeInventoryError: if connection is not None: connection.close() diff --git a/src/session_migrate/cli.py b/src/session_migrate/cli.py index b2cc420..28e3f23 100644 --- a/src/session_migrate/cli.py +++ b/src/session_migrate/cli.py @@ -18,6 +18,7 @@ content_free_result, convert_session, default_target_home, + detect_opencode_version, ensure_target_paths_available, install_antigravity_artifact, install_copilot_artifact, @@ -567,6 +568,12 @@ def main(argv: Sequence[str] | None = None) -> int: "--target-cli only applies to OpenCode/Kilo/Antigravity/Cursor import " "and transfer, or Hermes import and transfer" ) + if ( + target_format == TargetFormat.OPENCODE + and args.command != "convert" + and not args.target_cli_version + ): + args.target_cli_version = detect_opencode_version(args.target_cli) artifact = convert_session( session, ConversionOptions( @@ -739,7 +746,10 @@ def _add_conversion_arguments( ) parser.add_argument( "--target-cli-version", - help="metadata version only; the writer schema remains pinned", + help=( + "target release; selects legacy or 2.0 transfer schema for OpenCode, " + "metadata only for other targets" + ), ) parser.add_argument( "--target-cli", diff --git a/src/session_migrate/conversion.py b/src/session_migrate/conversion.py index 46d9497..4af8f9b 100644 --- a/src/session_migrate/conversion.py +++ b/src/session_migrate/conversion.py @@ -34,6 +34,7 @@ muse, omp, opencode, + opencode_v2, openhands, pi, qwen, @@ -193,10 +194,10 @@ def load_opencode_session( values.setdefault("OPENCODE_DISABLE_PRUNE", "true") cli = _resolve_opencode_cli(source_cli, values) observed_version = _opencode_version(cli, values) - if observed_version != opencode.PINNED_OPENCODE_VERSION: + if not opencode.supported_version(observed_version): raise SessionMigrateError( "OpenCode source CLI version mismatch: expected " - f"{opencode.PINNED_OPENCODE_VERSION}, observed {observed_version}" + f"{opencode.PINNED_OPENCODE_VERSION} or supported 2.0.x, observed {observed_version}" ) temporary_root = values.get("TMPDIR") with tempfile.TemporaryDirectory( @@ -209,7 +210,9 @@ def load_opencode_session( session = load_session(export_path, AgentFormat.OPENCODE) if session.session_id != session_id: raise SessionMigrateError("OpenCode export metadata does not match the requested session") - return replace(session, source_path=Path(f"opencode:{session_id}")) + return replace( + session, source_path=Path(f"opencode:{session_id}"), cli_version=observed_version + ) def load_kilo_session( @@ -522,13 +525,25 @@ def convert_session(session: Session, options: ConversionOptions) -> ConversionA timestamp=timestamp, ) pinned_target = _pinned_target_version(target_format) - if target_version != pinned_target: + observed_target = target_version + version_message = "target metadata version differs but the writer schema remains pinned" + if target_format == TargetFormat.OPENCODE and opencode.is_v2(target_version): + pinned_target = opencode_v2.VALIDATED_VERSION + observed_target = opencode_v2.version(target_version) + version_message = ( + "selected OpenCode 2.0 transfer schema; exact target release differs " + "from the validated native integration release" + ) + target_validated = observed_target == pinned_target or ( + target_format == TargetFormat.OPENCODE and observed_target in opencode_v2.VALIDATED_VERSIONS + ) + if not target_validated: warnings.append( { "code": "unvalidated_target_version", "observed": target_version, "validated": pinned_target, - "message": "target metadata version differs but the writer schema remains pinned", + "message": version_message, } ) if not native_bytes: @@ -590,7 +605,13 @@ def convert_session(session: Session, options: ConversionOptions) -> ConversionA AgentFormat.MASTRACODE: mastracode.PINNED_MASTRACODE_VERSION, AgentFormat.DEVIN: devin.PINNED_DEVIN_VERSION, }[session.source_format] - if session.cli_version != pinned_source: + if session.source_format == AgentFormat.OPENCODE and opencode.is_v2(session.cli_version): + pinned_source = opencode_v2.VALIDATED_VERSION + source_validated = session.cli_version == pinned_source or ( + session.source_format == AgentFormat.OPENCODE + and opencode_v2.version(session.cli_version) in opencode_v2.VALIDATED_VERSIONS + ) + if not source_validated: warnings.append( { "code": "unvalidated_source_version", @@ -1405,18 +1426,19 @@ def install_opencode_artifact( values = dict(os.environ if environ is None else environ) values.setdefault("OPENCODE_DISABLE_AUTOUPDATE", "true") values.setdefault("OPENCODE_DISABLE_PRUNE", "true") - if artifact.target_cli_version != opencode.PINNED_OPENCODE_VERSION: + if not opencode.supported_version(artifact.target_cli_version): raise SessionMigrateError( - "automatic OpenCode import requires target metadata version " - f"{opencode.PINNED_OPENCODE_VERSION}; convert-only artifacts may opt into " - "unvalidated metadata versions" + "automatic OpenCode import requires target metadata version 1.17.20 or supported 2.0.x" ) cli = _resolve_opencode_cli(target_cli, values) observed_version = _opencode_version(cli, values) - if observed_version != opencode.PINNED_OPENCODE_VERSION: + if not opencode.supported_version(observed_version): raise SessionMigrateError( - "OpenCode CLI version mismatch: expected " - f"{opencode.PINNED_OPENCODE_VERSION}, observed {observed_version}" + f"OpenCode CLI version mismatch: unsupported observed version {observed_version}" + ) + if opencode.is_v2(artifact.target_cli_version) != opencode.is_v2(observed_version): + raise SessionMigrateError( + "OpenCode CLI version mismatch: artifact and CLI transfer schemas differ" ) if artifact.session_id in _opencode_session_ids(cli, values): raise SessionMigrateError( @@ -1456,12 +1478,25 @@ def install_opencode_artifact( os.chmod(directory, 0o700) bundle_path = directory / "import.json" write_private_atomic(bundle_path, artifact.native_bytes) - _invoke_opencode_import(cli, bundle_path, values) + if opencode.is_v2(observed_version): + _invoke_opencode_import(cli, bundle_path, values, cwd=artifact.cwd) + else: + _invoke_opencode_import(cli, bundle_path, values) # From this point onward, any local cleanup/finalization failure # must warn that the native session may already exist. import_succeeded = True - if artifact.session_id not in _opencode_session_ids(cli, values): + if opencode.is_v2(observed_version): + with tempfile.TemporaryDirectory( + prefix="session-migrate-verify-", dir=temporary_root + ) as name: + _invoke_opencode_export( + cli, artifact.session_id, Path(name) / "verify.json", values + ) + exported = opencode.parse_import(Path(name) / "verify.json") + if exported.session_id != artifact.session_id: + raise SessionMigrateError("OpenCode imported a different session ID") + elif artifact.session_id not in _opencode_session_ids(cli, values): raise SessionMigrateError( "OpenCode import returned success but the session was not discoverable afterward" ) @@ -1744,6 +1779,8 @@ def _native_record_count(data: bytes, target_format: TargetFormat) -> int: return data.count(b"\n") value = json.loads(data) messages = value.get("messages", []) if isinstance(value, dict) else [] + if opencode_v2.is_bundle(value): + return opencode_v2.record_count(value) return ( 1 + len(messages) @@ -1799,11 +1836,28 @@ def _opencode_version(cli: Path, environ: Mapping[str, str]) -> str: version = completed.stdout.strip() if not version or "\n" in version: raise SessionMigrateError("OpenCode CLI returned an invalid version string") + if version.startswith("opencode v"): + version = version.removeprefix("opencode v") return version +def detect_opencode_version(target_cli: Path | None = None) -> str: + values = dict(os.environ) + values.setdefault("OPENCODE_DISABLE_AUTOUPDATE", "true") + values.setdefault("OPENCODE_DISABLE_PRUNE", "true") + observed = _opencode_version(_resolve_opencode_cli(target_cli, values), values) + if not opencode.supported_version(observed): + raise SessionMigrateError(f"unsupported OpenCode CLI version: {observed}") + return observed + + def _opencode_session_ids(cli: Path, environ: Mapping[str, str]) -> set[str]: - completed = _run_opencode([str(cli), "session", "list", "--format", "json", "--pure"], environ) + flags = ( + ["--standalone", "--max-count", "1000000"] + if opencode.is_v2(_opencode_version(cli, environ)) + else ["--pure"] + ) + completed = _run_opencode([str(cli), "session", "list", "--format", "json", *flags], environ) if len(completed.stdout.encode()) > 64 * 1024 * 1024: raise SessionMigrateError("OpenCode session list exceeded the safety limit") # Pinned OpenCode 1.17.20 emits an empty stream, rather than ``[]``, when @@ -1824,8 +1878,28 @@ def _opencode_session_ids(cli: Path, environ: Mapping[str, str]) -> set[str]: return result -def _invoke_opencode_import(cli: Path, bundle_path: Path, environ: Mapping[str, str]) -> None: - _run_opencode([str(cli), "import", str(bundle_path), "--pure"], environ) +def _invoke_opencode_import( + cli: Path, bundle_path: Path, environ: Mapping[str, str], *, cwd: Path | None = None +) -> None: + if opencode.is_v2(_opencode_version(cli, environ)): + result = _run_opencode( + [ + str(cli), + "session", + "import", + str(bundle_path), + "--directory", + str(cwd or Path.cwd()), + "--standalone", + ], + environ, + ) + if not result.stdout.startswith("Imported session: "): + raise SessionMigrateError( + "OpenCode native import refused the session; no overwrite attempted" + ) + else: + _run_opencode([str(cli), "import", str(bundle_path), "--pure"], environ) def _invoke_opencode_export( @@ -1843,7 +1917,11 @@ def _invoke_opencode_export( try: descriptor = os.open(bundle_path, flags, 0o600) completed = subprocess.run( - [str(cli), "export", session_id, "--pure"], + ( + [str(cli), "session", "export", session_id, "--standalone"] + if opencode.is_v2(_opencode_version(cli, environ)) + else [str(cli), "export", session_id, "--pure"] + ), env=dict(environ), check=False, stdout=descriptor, diff --git a/src/session_migrate/formats/kilo.py b/src/session_migrate/formats/kilo.py index e567d43..f611931 100644 --- a/src/session_migrate/formats/kilo.py +++ b/src/session_migrate/formats/kilo.py @@ -11,6 +11,7 @@ from dataclasses import replace from pathlib import Path +from session_migrate.errors import SessionMigrateError from session_migrate.formats import opencode from session_migrate.jsonl import file_sha256 from session_migrate.model import AgentFormat, Session @@ -48,12 +49,19 @@ def serialize( agent=agent, timestamp=timestamp, title=title, + legacy_schema=True, ) def parse(path: Path) -> opencode.ParsedOpenCodeSession: """Parse a bundle produced by ``kilo export``.""" + try: + value = opencode._decode_import_bundle(path.read_bytes()) + except OSError as exc: + raise SessionMigrateError("cannot read Kilo import bundle") from exc + if opencode.opencode_v2.is_bundle(value): + raise SessionMigrateError("Kilo 7.5.0 requires the legacy nested import/export schema") return opencode.parse_import(path) @@ -74,6 +82,8 @@ def parse_session(path: Path) -> Session: def validate_native_bytes(data: bytes, session_id: str) -> None: """Validate Kilo's supported import document without invoking the CLI.""" + if opencode.opencode_v2.is_bundle(opencode._decode_import_bundle(data)): + raise SessionMigrateError("Kilo 7.5.0 requires the legacy nested import/export schema") opencode.validate_native_bytes(data, session_id) diff --git a/src/session_migrate/formats/opencode.py b/src/session_migrate/formats/opencode.py index ea51086..4252acb 100644 --- a/src/session_migrate/formats/opencode.py +++ b/src/session_migrate/formats/opencode.py @@ -1,4 +1,4 @@ -"""OpenCode 1.17.20 import/export bundle adapter. +"""OpenCode 1.17.20 and 2.0 public import/export adapters. The writer emits the public JSON shape consumed by ``opencode import``. It never writes OpenCode's SQLite database directly. @@ -18,6 +18,7 @@ from typing import Any from session_migrate.errors import SessionMigrateError +from session_migrate.formats import opencode_v2 from session_migrate.formats.common import portable_data_image, string, valid_rfc3339 from session_migrate.jsonl import file_sha256 from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session @@ -49,6 +50,14 @@ class ParsedOpenCodeSession: losses: tuple[tuple[str, int], ...] = () +def is_v2(value: str | None) -> bool: + return value is not None and opencode_v2.version(value) is not None + + +def supported_version(value: str | None) -> bool: + return value == PINNED_OPENCODE_VERSION or is_v2(value) + + def session_id_from_uuid(value: str) -> str: """Create a valid OpenCode session ID from a UUID string.""" @@ -71,6 +80,7 @@ def serialize( timestamp: str | None = None, title: str | None = None, slug: str | None = None, + legacy_schema: bool = False, ) -> tuple[bytes, dict[str, int]]: """Serialize portable events as an official OpenCode import bundle.""" @@ -417,6 +427,8 @@ def queue_part(event: Event, role: Role, part: dict[str, Any]) -> None: }, "messages": messages, } + if not legacy_schema and opencode_v2.version(cli_version): + export_data = opencode_v2.from_legacy(export_data, dropped) data = (json.dumps(export_data, ensure_ascii=False, indent=2) + "\n").encode() return data, dict(sorted(dropped.items())) @@ -437,7 +449,10 @@ def parse_import(path: Path) -> ParsedOpenCodeSession: except OSError as exc: raise SessionMigrateError("cannot read OpenCode import bundle") from exc value = _decode_import_bundle(data) + native_v2 = value if opencode_v2.is_bundle(value) else None _validate_import_bundle(value) + if native_v2 is not None: + value = opencode_v2.to_legacy(native_v2) info = value["info"] session_id = string(info.get("id")) cwd = string(info.get("directory")) @@ -470,6 +485,9 @@ def parse_import(path: Path) -> ParsedOpenCodeSession: ) ) + if native_v2 is not None: + events.extend(opencode_v2.loss_events(native_v2)) + compaction_parents: dict[str, dict[str, Any]] = {} summary_parents: set[str] = set() for message in messages: @@ -679,8 +697,12 @@ def parse_import(path: Path) -> ParsedOpenCodeSession: started_at=_iso_from_ms(created), title=title, events=tuple(events), - raw_record_count=1 + len(messages) + part_count, - cli_version=string(info.get("version")), + raw_record_count=( + opencode_v2.record_count(native_v2) + if native_v2 is not None + else 1 + len(messages) + part_count + ), + cli_version=None if native_v2 is not None else string(info.get("version")), model=latest_model or _session_model(info, "id"), provider=latest_provider or _session_model(info, "providerID"), parent_session=string(info.get("parentID")), @@ -735,6 +757,12 @@ def _decode_import_bundle(data: bytes) -> dict[str, Any]: def _validate_import_bundle(value: dict[str, Any], expected_session_id: str | None = None) -> None: + if opencode_v2.is_bundle(value): + projected = opencode_v2.to_legacy(value) + if expected_session_id is not None and value["info"].get("id") != expected_session_id: + raise SessionMigrateError("OpenCode bundle ID does not match the target ID") + _validate_import_bundle(projected) + return if not isinstance(value.get("info"), dict): raise SessionMigrateError("OpenCode import bundle is missing session info") info = value["info"] diff --git a/src/session_migrate/formats/opencode_v2.py b/src/session_migrate/formats/opencode_v2.py new file mode 100644 index 0000000..bee1d23 --- /dev/null +++ b/src/session_migrate/formats/opencode_v2.py @@ -0,0 +1,694 @@ +"""Public OpenCode 2.0 transfer schema support; no private database access. + +Legacy bundles are an internal portable projection only. Native v2 files always +use SessionTransfer.Data (flat messages), not legacy message info/parts. +""" + +from __future__ import annotations + +import base64 +import copy +import re +from typing import Any + +from session_migrate.errors import SessionMigrateError + +_VERSION = re.compile(r"(?:opencode v)?(2\.0\.\d+(?:[-+][0-9A-Za-z.-]+)?)") +VALIDATED_VERSION = "2.0.23" +VALIDATED_VERSIONS = frozenset({"2.0.22", VALIDATED_VERSION}) + + +def version(value: str) -> str | None: + match = _VERSION.fullmatch(value) + return match.group(1) if match else None + + +def is_bundle(value: dict[str, Any]) -> bool: + return isinstance(value.get("info"), dict) and "location" in value["info"] + + +def _file(part: dict[str, Any]) -> dict[str, Any]: + uri = part["url"] + if not uri.startswith("data:") or ";base64," not in uri: + raise SessionMigrateError("OpenCode v2 inline images must have a base64 data URI") + return {"data": uri.split(";base64,", 1)[1], "mime": part["mime"], "source": {"type": "inline"}} + + +def from_legacy(bundle: dict[str, Any], dropped: Any) -> dict[str, Any]: + old = bundle["info"] + ref: dict[str, Any] = {} + messages: list[dict[str, Any]] = [] + compact: set[str] = set() + for message in bundle["messages"]: + info, parts = message["info"], message["parts"] + common = {"id": info["id"], "time": info["time"]} + if info["role"] == "user": + ref = {"id": info["model"]["modelID"], "providerID": info["model"]["providerID"]} + if any(part["type"] == "compaction" for part in parts): + compact.add(info["id"]) + continue + native = { + **common, + "type": "user", + "text": "\n".join(part["text"] for part in parts if part["type"] == "text"), + } + files = [_file(part) for part in parts if part["type"] == "file"] + if files: + native["files"] = files + else: + ref = {"id": info["modelID"], "providerID": info["providerID"]} + if info.get("summary") and info["parentID"] in compact: + messages.append( + { + **common, + "type": "compaction", + "status": "completed", + "reason": "auto", + "model": ref, + "summary": "\n".join(p["text"] for p in parts if p["type"] == "text"), + "recent": "", + } + ) + continue + content: list[dict[str, Any]] = [] + for part in parts: + kind = part["type"] + if kind in {"text", "reasoning"}: + content.append({"type": kind, "text": part["text"]}) + elif kind == "tool": + state = part["state"] + status = state["status"] + tool_state: dict[str, Any] = {"status": status, "input": state["input"]} + if status == "completed": + tool_state["content"] = [{"type": "text", "text": state["output"]}] + tool_state["content"].extend( + {"type": "file", "uri": p["url"], "mime": p["mime"]} + for p in state.get("attachments", []) + ) + elif status == "error": + tool_state["error"] = {"type": "ToolError", "message": state["error"]} + else: + # Native export/import discards unsettled assistant steps. Archive + # incomplete calls as explicit errors so their input survives. + tool_state = { + "status": "error", + "input": state["input"], + "error": { + "type": "ImportedIncompleteTool", + "message": "Imported call had no completed result", + }, + } + dropped["tool_call:v2_incomplete_archived"] += 1 + times = state.get("time", {}) + content.append( + { + "type": "tool", + "id": part["callID"], + "name": part["tool"], + "state": tool_state, + "time": { + "created": times.get("start", info["time"]["created"]), + **({"completed": times["end"]} if "end" in times else {}), + }, + } + ) + native = { + **common, + "type": "assistant", + "agent": info["agent"], + "model": ref, + "content": content, + "finish": info["finish"], + "cost": info["cost"], + "tokens": info["tokens"], + } + messages.append(native) + return { + "info": { + "id": old["id"], + "projectID": old["projectID"], + "title": old["title"], + "agent": next( + (m.get("agent", "build") for m in reversed(messages) if m.get("agent")), "build" + ), + "model": ref, + "location": {"directory": old["directory"]}, + "cost": 0, + "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, + "time": old["time"], + }, + "messages": messages, + } + + +def _to_legacy(bundle: dict[str, Any]) -> dict[str, Any]: + """Validate the v2 portable content projection and reuse the mature reader.""" + from session_migrate.formats import opencode + + info = bundle["info"] + _validate_native(bundle) + if not isinstance(info.get("location"), dict): + raise SessionMigrateError("OpenCode v2 session is missing location") + opencode._validate_tokens(info.get("tokens"), "v2 session") + if not opencode._is_finite_number(info.get("cost")): + raise SessionMigrateError("OpenCode v2 session has invalid cost") + model = info.get("model") or {"id": "unknown", "providerID": "unknown"} + messages = bundle.get("messages") + if not isinstance(messages, list) or len(messages) > opencode.MAX_NATIVE_MESSAGES: + raise SessionMigrateError("OpenCode v2 session has invalid messages") + output: list[dict[str, Any]] = [] + ids: set[str] = set() + latest_user = "msg_import_root" + part_index = 0 + + def part(data: dict[str, Any], message_id: str) -> dict[str, Any]: + nonlocal part_index + part_index += 1 + if part_index > opencode.MAX_NATIVE_PARTS: + raise SessionMigrateError("OpenCode v2 session contains too much content") + return { + **data, + "id": f"prt_v2_projection_{part_index}", + "sessionID": info["id"], + "messageID": message_id, + } + + for message in messages: + if not isinstance(message, dict): + raise SessionMigrateError("OpenCode v2 session contains a malformed message") + mid = message.get("id") + if not isinstance(mid, str) or not mid.startswith("msg_") or mid in ids: + raise SessionMigrateError("OpenCode v2 session has invalid message IDs") + ids.add(mid) + clock = message.get("time") + if not isinstance(clock, dict): + raise SessionMigrateError("OpenCode v2 message is missing time") + opencode._iso_from_ms(clock.get("created")) + kind = message.get("type") + if kind == "model-switched": + model = message["model"] + continue + if kind == "compaction": + if message.get("status") != "completed": + continue + # A provider-private checkpoint without portable text must not retire + # earlier readable history when replayed in another harness. + if not message["summary"].strip() and not message["recent"].strip(): + continue + synthetic_id = mid + "_compaction" + output.append( + { + "info": { + "id": synthetic_id, + "sessionID": info["id"], + "role": "user", + "time": clock, + "agent": "build", + "model": {"modelID": model["id"], "providerID": model["providerID"]}, + }, + "parts": [ + part( + {"type": "compaction", "auto": message.get("reason") == "auto"}, + synthetic_id, + ) + ], + } + ) + latest_user = synthetic_id + kind = "assistant" + message = { + **message, + "model": message.get("model") or model, + "agent": "build", + "content": [ + { + "type": "text", + "text": message.get("summary", "") + + ( + "\n\nRecent context:\n" + message["recent"] + if message.get("recent") + else "" + ), + } + ], + "_summary": True, + } + if kind not in {"user", "assistant"}: + continue + parts: list[dict[str, Any]] = [] + common = {"id": mid, "sessionID": info["id"], "role": kind, "time": clock} + if kind == "user": + if not isinstance(message.get("text"), str): + raise SessionMigrateError("OpenCode v2 user message has invalid text") + parts.append(part({"type": "text", "text": message["text"]}, mid)) + for attachment in message.get("files", []): + if not isinstance(attachment, dict) or not isinstance(attachment.get("data"), str): + raise SessionMigrateError("OpenCode v2 file attachment is invalid") + try: + base64.b64decode(attachment["data"], validate=True) + except ValueError as exc: + raise SessionMigrateError("OpenCode v2 file attachment is not base64") from exc + parts.append( + part( + { + "type": "file", + "mime": attachment["mime"], + "url": f"data:{attachment['mime']};base64,{attachment['data']}", + }, + mid, + ) + ) + common.update( + { + "agent": "build", + "model": {"modelID": model["id"], "providerID": model["providerID"]}, + } + ) + latest_user = mid + else: + model = message.get("model", model) + if not isinstance(message.get("content"), list): + raise SessionMigrateError("OpenCode v2 assistant message has invalid content") + for content in message["content"]: + if not isinstance(content, dict): + raise SessionMigrateError("OpenCode v2 assistant content is malformed") + ptype = content.get("type") + if ptype in {"text", "reasoning"}: + parts.append( + part( + { + "type": ptype, + "text": content.get("text"), + **( + {"time": {"start": clock["created"], "end": clock["created"]}} + if ptype == "reasoning" + else {} + ), + }, + mid, + ) + ) + elif ptype == "tool": + state = copy.deepcopy(content.get("state")) + if not isinstance(state, dict): + raise SessionMigrateError("OpenCode v2 tool state is invalid") + status = state.get("status") + if status == "completed": + blocks = state.pop("content", None) + if not isinstance(blocks, list) or not blocks: + raise SessionMigrateError("OpenCode v2 completed tool needs content") + state["output"] = "\n".join( + b["text"] for b in blocks if b.get("type") == "text" + ) + state["title"] = content["name"] + state["metadata"] = state.get("metadata", {}) + attachments = [ + part({"type": "file", "mime": b["mime"], "url": b["uri"]}, mid) + for b in blocks + if b.get("type") == "file" + ] + if attachments: + state["attachments"] = attachments + elif status == "error": + error = state.get("error") + if not isinstance(error, dict) or not isinstance(error.get("message"), str): + raise SessionMigrateError("OpenCode v2 tool error is invalid") + state["error"] = error["message"] + elif status == "streaming": + state = { + "status": "pending", + "input": {"input": state["input"]}, + "raw": state["input"], + } + tool_time = content.get("time", clock) + state["time"] = { + "start": tool_time.get("created", clock["created"]), + "end": tool_time.get("completed", clock["created"]), + } + parts.append( + part( + { + "type": "tool", + "callID": content.get("id"), + "tool": content.get("name"), + "state": state, + }, + mid, + ) + ) + else: + raise SessionMigrateError("OpenCode v2 has unsupported assistant content") + common.update( + { + "parentID": latest_user, + "modelID": model["id"], + "providerID": model["providerID"], + "agent": message.get("agent", "build"), + "mode": message.get("agent", "build"), + "path": { + "cwd": info["location"]["directory"], + "root": info["location"]["directory"], + }, + "cost": message.get("cost", 0), + "tokens": message.get("tokens", info["tokens"]), + "finish": message.get("finish", "stop"), + } + ) + if message.get("_summary"): + common["summary"] = True + output.append({"info": common, "parts": parts}) + return { + "info": { + "id": info["id"], + "directory": info["location"].get("directory"), + "title": info.get("title") or "Imported session", + "slug": "v2-export", + "projectID": info.get("projectID"), + "version": "2.0", + "time": info.get("time"), + **({"parentID": info["parentID"]} if "parentID" in info else {}), + }, + "messages": output, + } + + +_MESSAGE_FIELDS = { + "user": {"text", "files", "agents", "skills"}, + "assistant": { + "agent", + "model", + "content", + "snapshot", + "finish", + "rawFinish", + "providerState", + "cost", + "tokens", + "error", + "retry", + }, + "compaction": { + "status", + "reason", + "summary", + "recent", + "model", + "providerState", + "providerContext", + "cost", + "tokens", + "error", + }, + "agent-switched": {"agent", "previous"}, + "model-switched": {"model", "previous"}, + "location-switched": {"location", "projectID", "subpath", "previous"}, + "synthetic": {"text", "description"}, + "system": {"text", "description"}, + "skill": {"skill", "name", "text"}, + "shell": {"shellID", "command", "status", "exit", "output"}, + "idle": {"outcome"}, +} + + +def loss_events(bundle: dict[str, Any]) -> list[Any]: + """Account for provider-private state and nonportable v2 control records.""" + from session_migrate.formats.opencode import _iso_from_ms + from session_migrate.model import Event, EventKind, Provenance + + events = [] + if (bundle["info"].get("model") or {}).get("variant"): + events.append( + Event( + kind=EventKind.OPAQUE, + timestamp=_iso_from_ms(bundle["info"]["time"]["created"]), + payload={"reason": "opencode_v2_session_model_variant"}, + provenance=Provenance(0, "session"), + ) + ) + for name in ("metadata", "permissions", "revert", "fork"): + if bundle["info"].get(name): + events.append( + Event( + kind=EventKind.OPAQUE, + timestamp=_iso_from_ms(bundle["info"]["time"]["created"]), + payload={"reason": f"opencode_v2_session_{name}"}, + provenance=Provenance(0, "session"), + ) + ) + for index, message in enumerate(bundle["messages"]): + reasons = [] + kind = message.get("type") + if kind not in {"user", "assistant", "compaction"}: + reasons.append(f"opencode_v2_{kind}_record") + elif kind == "compaction" and message.get("status") != "completed": + reasons.append(f"opencode_v2_compaction_{message.get('status')}") + elif ( + kind == "compaction" + and not message["summary"].strip() + and not message["recent"].strip() + ): + reasons.append("opencode_v2_compaction_empty_portable_summary") + if isinstance(message.get("model"), dict) and message["model"].get("variant"): + reasons.append("opencode_v2_message_model_variant") + for name in ("providerState", "providerContext", "metadata", "snapshot", "retry", "error"): + if message.get(name): + reasons.append(f"opencode_v2_{name}") + for name in ("agents", "skills"): + if message.get(name): + reasons.append(f"opencode_v2_user_{name}") + if kind == "user": + for attachment in message.get("files", []): + if ( + any(attachment.get(name) for name in ("name", "description", "mention")) + or attachment.get("source", {}).get("type") == "uri" + ): + reasons.append("opencode_v2_file_metadata") + allowed = _MESSAGE_FIELDS.get(str(kind), set()) | {"id", "metadata", "time", "type"} + if set(message) - allowed: + reasons.append("opencode_v2_unknown_message_fields") + for content in message["content"] if kind == "assistant" else []: + allowed_content = ( + {"type", "text", "state", "time"} + if content.get("type") != "tool" + else { + "type", + "id", + "name", + "executed", + "providerState", + "providerResultState", + "state", + "time", + } + ) + if set(content) - allowed_content: + reasons.append("opencode_v2_unknown_assistant_content_fields") + if content.get("type") == "tool": + for block in content["state"].get("content", []): + if block.get("type") == "file" and block.get("name") is not None: + reasons.append("opencode_v2_tool_file_metadata") + if ( + content.get("type") == "tool" + and content.get("state", {}).get("status") == "error" + and content["state"].get("content") + ): + reasons.append("opencode_v2_error_tool_content") + for name in ("state", "providerState", "providerResultState"): + if content.get(name) and (name != "state" or content.get("type") != "tool"): + reasons.append(f"opencode_v2_content_{name}") + for reason in reasons: + events.append( + Event( + kind=EventKind.OPAQUE, + timestamp=_iso_from_ms(message["time"]["created"]), + payload={"reason": reason}, + provenance=Provenance(index, str(kind)), + ) + ) + return events + + +def to_legacy(bundle: dict[str, Any]) -> dict[str, Any]: + try: + return _to_legacy(bundle) + except (KeyError, TypeError, ValueError, AttributeError) as exc: + raise SessionMigrateError( + "OpenCode v2 transfer has malformed required metadata/content" + ) from exc + + +def _validate_native(bundle: dict[str, Any]) -> None: + """Check portable v2 invariants before projection can erase native fields.""" + from session_migrate.formats.opencode import ( + _is_finite_number, + _is_non_negative_int, + _validate_tokens, + ) + + def ref(value: Any) -> None: + if not isinstance(value, dict) or not all( + isinstance(value.get(k), str) for k in ("id", "providerID") + ): + raise SessionMigrateError("OpenCode v2 has invalid model reference") + if value.get("variant") is not None and not isinstance(value["variant"], str): + raise SessionMigrateError("OpenCode v2 has invalid model variant") + + def error(value: Any) -> None: + if not isinstance(value, dict) or not all( + isinstance(value.get(key), str) for key in ("type", "message") + ): + raise SessionMigrateError("OpenCode v2 has invalid structured error") + status = value.get("status") + if status is not None and (not _is_non_negative_int(status) or not 100 <= status <= 599): + raise SessionMigrateError("OpenCode v2 has invalid error status") + response = value.get("response") + if response is not None and ( + not isinstance(response, dict) or not isinstance(response.get("body"), str) + ): + raise SessionMigrateError("OpenCode v2 has invalid error response") + + info = bundle["info"] + location = info.get("location") + directory = location.get("directory") if isinstance(location, dict) else None + if not isinstance(directory, str) or not directory or "\x00" in directory: + raise SessionMigrateError("OpenCode v2 has invalid location directory") + if location.get("workspaceID") is not None and not isinstance(location["workspaceID"], str): + raise SessionMigrateError("OpenCode v2 has invalid location workspace") + if info.get("metadata") is not None and not isinstance(info["metadata"], dict): + raise SessionMigrateError("OpenCode v2 has invalid session metadata") + if info.get("outcome") is not None and info["outcome"] not in { + "succeeded", + "failed", + "interrupted", + }: + raise SessionMigrateError("OpenCode v2 has invalid session outcome") + permissions = info.get("permissions") + if permissions is not None and ( + not isinstance(permissions, list) + or any( + not isinstance(rule, dict) + or not isinstance(rule.get("action"), str) + or not isinstance(rule.get("resource"), str) + or rule.get("effect") not in {"allow", "deny", "ask"} + for rule in permissions + ) + ): + raise SessionMigrateError("OpenCode v2 has invalid session permissions") + fork = info.get("fork") + if fork is not None: + boundary = fork.get("boundary") if isinstance(fork, dict) else None + if ( + not isinstance(fork, dict) + or not isinstance(fork.get("sessionID"), str) + or not fork["sessionID"].startswith("ses_") + or not isinstance(boundary, dict) + or boundary.get("type") not in {"before", "through"} + or not isinstance(boundary.get("messageID"), str) + or not boundary["messageID"].startswith("msg_") + ): + raise SessionMigrateError("OpenCode v2 has invalid session fork") + revert = info.get("revert") + if revert is not None and ( + not isinstance(revert, dict) + or not isinstance(revert.get("messageID"), str) + or not revert["messageID"].startswith("msg_") + ): + raise SessionMigrateError("OpenCode v2 has invalid session revert") + if info.get("model") is not None: + ref(info["model"]) + if info.get("agent") is not None and not isinstance(info["agent"], str): + raise SessionMigrateError("OpenCode v2 has invalid session agent") + for m in bundle.get("messages", []): + if not isinstance(m, dict): + raise SessionMigrateError("OpenCode v2 has malformed message") + if m.get("metadata") is not None and not isinstance(m["metadata"], dict): + raise SessionMigrateError("OpenCode v2 has invalid message metadata") + if m.get("cost") is not None and not _is_finite_number(m["cost"]): + raise SessionMigrateError("OpenCode v2 has invalid message cost") + if m.get("tokens") is not None: + _validate_tokens(m["tokens"], "v2 message") + if m.get("error") is not None: + error(m["error"]) + kind = m.get("type") + if kind not in { + "user", + "assistant", + "compaction", + "agent-switched", + "model-switched", + "location-switched", + "synthetic", + "system", + "skill", + "shell", + "idle", + }: + raise SessionMigrateError("OpenCode v2 has unsupported message type") + clock = m.get("time") + if not isinstance(clock, dict) or not _is_non_negative_int(clock.get("created")): + raise SessionMigrateError("OpenCode v2 has invalid message time") + if clock.get("completed") is not None and not _is_non_negative_int(clock["completed"]): + raise SessionMigrateError("OpenCode v2 has invalid completion time") + if kind in {"assistant", "model-switched"} or ( + kind == "compaction" and m.get("model") is not None + ): + ref(m.get("model")) + if kind == "user": + files = m.get("files", []) + if not isinstance(files, list): + raise SessionMigrateError("OpenCode v2 user has invalid file attachments") + for attachment in files: + source = attachment.get("source") if isinstance(attachment, dict) else None + if not isinstance(source, dict) or source.get("type") not in {"inline", "uri"}: + raise SessionMigrateError("OpenCode v2 file has invalid source") + if source["type"] == "uri" and not isinstance(source.get("uri"), str): + raise SessionMigrateError("OpenCode v2 file has invalid source URI") + if kind == "assistant": + if not isinstance(m.get("agent"), str) or not isinstance(m.get("content"), list): + raise SessionMigrateError("OpenCode v2 assistant has invalid runtime metadata") + for c in m["content"]: + if not isinstance(c, dict): + raise SessionMigrateError("OpenCode v2 has malformed assistant content") + if c.get("type") != "tool": + continue + if not isinstance(c.get("time"), dict) or not _is_non_negative_int( + c["time"].get("created") + ): + raise SessionMigrateError("OpenCode v2 tool has invalid time") + state = c.get("state") + if not isinstance(state, dict): + raise SessionMigrateError("OpenCode v2 tool has invalid state") + if state.get("status") == "running" and not isinstance(state.get("metadata"), dict): + raise SessionMigrateError("OpenCode v2 running tool has invalid metadata") + if state.get("status") == "error": + error(state.get("error")) + for block in state.get("content", []): + if not isinstance(block, dict) or block.get("type") not in {"text", "file"}: + raise SessionMigrateError("OpenCode v2 tool has invalid content") + fields = ("text",) if block["type"] == "text" else ("uri", "mime") + if not all(isinstance(block.get(k), str) for k in fields): + raise SessionMigrateError("OpenCode v2 tool has invalid content fields") + if kind == "compaction": + if m.get("status") not in {"running", "completed", "failed"}: + raise SessionMigrateError("OpenCode v2 compaction has invalid status") + if m.get("reason") not in {"auto", "manual"}: + raise SessionMigrateError("OpenCode v2 compaction has invalid reason") + if m["status"] in {"running", "completed"} and not all( + isinstance(m.get(k), str) for k in ("summary", "recent") + ): + raise SessionMigrateError("OpenCode v2 compaction has invalid summary") + + +def record_count(bundle: dict[str, Any]) -> int: + return ( + 1 + + len(bundle["messages"]) + + sum( + len(m["content"]) + if m["type"] == "assistant" + else len(m.get("files", [])) + if m["type"] == "user" + else 0 + for m in bundle["messages"] + ) + ) diff --git a/src/session_migrate/inspection.py b/src/session_migrate/inspection.py index 05040d2..a02edd3 100644 --- a/src/session_migrate/inspection.py +++ b/src/session_migrate/inspection.py @@ -19,6 +19,7 @@ kimi, mastracode, muse, + opencode, openhands, qwen, vibe, @@ -166,8 +167,11 @@ def inspect_session(path: Path, *, source_format: AgentFormat | None = None) -> if source_format in {AgentFormat.OPENCODE, AgentFormat.KILO} or source_format is None: document = _load_json_document(path, before.size) if document is not None and source_format is None and _is_opencode_document(document): - ensure_file_unchanged(path, before) - _raise_opencode_kilo_ambiguity() + if opencode.opencode_v2.is_bundle(document): + source_format = AgentFormat.OPENCODE + else: + ensure_file_unchanged(path, before) + _raise_opencode_kilo_ambiguity() if document is not None and source_format in { AgentFormat.OPENCODE, AgentFormat.KILO, @@ -506,6 +510,10 @@ def detect_path_format(path: Path) -> AgentFormat: before = file_snapshot(path) document = _load_json_document(path, before.size) if document is not None and _is_opencode_document(document): + if opencode.opencode_v2.is_bundle(document): + opencode.parse_session(path) + ensure_file_unchanged(path, before) + return AgentFormat.OPENCODE ensure_file_unchanged(path, before) _raise_opencode_kilo_ambiguity() else: @@ -579,6 +587,10 @@ def _inspect_opencode( info = value.get("info") messages = value.get("messages") assert isinstance(info, dict) and isinstance(messages, list) + if opencode.opencode_v2.is_bundle(value): + if source_format == AgentFormat.KILO: + raise FormatDetectionError("OpenCode v2 transfer requires --format opencode, not kilo") + return _inspect_portable_database(opencode.parse_session(path)) record_types: Counter[str] = Counter({"session": 1}) roles: Counter[str] = Counter() blocks: Counter[str] = Counter() diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 03d019d..9da67b1 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -621,6 +621,74 @@ def test_opencode_inventory_is_complete_virtual_private_and_incremental( assert b"forbidden OpenCode transcript marker" not in raw_catalog +@pytest.mark.parametrize("legacy", [False, True]) +def test_opencode_v2_inventory_supports_coexisting_schemas_and_nullable_title( + tmp_path: Path, legacy: bool +) -> None: + home = tmp_path / "opencode-data" + home.mkdir() + if legacy: + connection = _opencode_database(home) + _insert_opencode_session(connection, OPENCODE_ID, "Legacy duplicate title") + _insert_opencode_session(connection, OPENCODE_ARCHIVED_ID, "Legacy only title") + else: + connection = sqlite3.connect(home / "opencode.db") + connection.executescript( + """ + CREATE TABLE session_v2 ( + id TEXT PRIMARY KEY, directory TEXT NOT NULL, title TEXT, + version TEXT NOT NULL, time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, parent_id TEXT, time_archived INTEGER + ); + CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, data TEXT); + """ + ) + connection.executemany( + "INSERT INTO session_v2 VALUES (?, '/synthetic/v2-work', ?, '2.0.22', ?, ?, ?, NULL)", + [ + (OPENCODE_ID, "V2 authoritative title", 1_787_050_800_000, 1_787_054_400_000, None), + (OPENCODE_CHILD_ID, None, 1_787_050_800_000, 1_787_054_400_000, OPENCODE_ID), + ], + ) + connection.execute( + "INSERT INTO session_message VALUES ('msg_private', ?, 'private v2 body marker')", + (OPENCODE_ID,), + ) + connection.commit() + connection.close() + with _catalog(tmp_path) as catalog: + result = catalog.refresh(opencode_roots=(home,), include_auto=False) + assert result.root_errors == 0 + assert result.files_seen == 2 + int(legacy) + assert result.statuses == {"candidate": 2 + int(legacy)} + assert catalog.list_sessions(query="Legacy duplicate") == [] + entry = catalog.list_sessions(query="V2 authoritative", include_paths=True)[0] + assert entry.session_id == OPENCODE_ID + assert entry.cli_version == "2.0.22" + assert entry.cwd == "/synthetic/v2-work" + assert catalog.session_source_for_transfer(entry.catalog_id).session_id == OPENCODE_ID + untitled = catalog.list_sessions(query=OPENCODE_CHILD_ID)[0] + assert untitled.kind == "subagent" + assert catalog.refresh(include_auto=False).unchanged == 2 + int(legacy) + assert catalog.list_sessions(query="private v2 body") == [] + assert ( + b"private v2 body marker" not in (tmp_path / "private-state/catalog.sqlite3").read_bytes() + ) + + +def test_opencode_inventory_rejects_incomplete_v2_schema(tmp_path: Path) -> None: + home = tmp_path / "opencode-data" + connection = _opencode_database(home) + _insert_opencode_session(connection, OPENCODE_ID, "Valid legacy title") + connection.execute("CREATE TABLE session_v2 (id TEXT PRIMARY KEY)") + connection.commit() + connection.close() + with _catalog(tmp_path) as catalog: + result = catalog.refresh(opencode_roots=(home,), include_auto=False) + assert result.root_errors == 1 + assert catalog.roots()[0].last_error == "opencode_schema_unsupported" + + def test_opencode_inventory_failures_retain_rows_and_reject_database_symlink( tmp_path: Path, ) -> None: diff --git a/tests/test_opencode_v2.py b/tests/test_opencode_v2.py new file mode 100644 index 0000000..642e193 --- /dev/null +++ b/tests/test_opencode_v2.py @@ -0,0 +1,646 @@ +"""Portable v2 transfers, without native database writes or real providers.""" + +import json +from pathlib import Path + +import pytest + +from session_migrate import conversion +from session_migrate.conversion import ConversionOptions, convert_session, load_session +from session_migrate.errors import SessionMigrateError +from session_migrate.formats import opencode +from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, TargetFormat + +FIXTURES = Path(__file__).parent / "fixtures" + + +def artifact(tmp_path: Path, release: str = "2.0.23"): + session = load_session(FIXTURES / "codex-0.144.4/basic.jsonl", AgentFormat.CODEX) + return convert_session( + session, + ConversionOptions( + target_format=TargetFormat.OPENCODE, + target_cli_version=release, + cwd=tmp_path, + model_provider="fixture", + model="test", + ), + ) + + +def test_streamed_tool_source_retains_partial_input_through_portable_target(tmp_path): + partial = '{"command":"UNIQUE_PARTIAL_INPUT' + value = json.loads(artifact(tmp_path).native_bytes) + tool = next( + c + for m in value["messages"] + if m["type"] == "assistant" + for c in m["content"] + if c["type"] == "tool" + ) + tool["state"] = {"status": "streaming", "input": partial} + source_path = tmp_path / "streaming-source.json" + source_path.write_text(json.dumps(value)) + source = opencode.parse_session(source_path) + call = next(e for e in source.events if e.kind == EventKind.TOOL_CALL) + assert call.payload["input"] == {"input": partial} + target = convert_session( + source, + ConversionOptions( + target_format=TargetFormat.OPENCODE, + target_cli_version="2.0.23", + cwd=tmp_path, + model_provider="fixture", + model="test", + ), + ) + exported = json.loads(target.native_bytes) + archived = next( + c + for m in exported["messages"] + if m["type"] == "assistant" + for c in m["content"] + if c["type"] == "tool" + ) + assert archived["id"] == tool["id"] + assert archived["state"]["input"] == {"input": partial} + assert archived["state"]["status"] == "error" + assert archived["state"]["error"]["type"] == "ImportedIncompleteTool" + assert target.dropped["tool_call:v2_incomplete_archived"] == 1 + + +@pytest.mark.parametrize( + ("release", "validated", "warning_expected"), + [ + ("1.17.20", "1.17.20", False), + ("2.0.22", "2.0.23", False), + ("2.0.23", "2.0.23", False), + ("opencode v2.0.23", "2.0.23", False), + ("2.0.24", "2.0.23", True), + ("2.0.23-gateway.custom", "2.0.23", True), + ], +) +def test_target_version_warning_matches_selected_schema( + tmp_path, release, validated, warning_expected +): + session = load_session(FIXTURES / "codex-0.144.4/basic.jsonl", AgentFormat.CODEX) + target = convert_session( + session, + ConversionOptions( + target_format=TargetFormat.OPENCODE, + target_cli_version=release, + cwd=tmp_path, + model_provider="fixture", + model="test", + ), + ) + warnings = [w for w in target.warnings if w["code"] == "unvalidated_target_version"] + assert bool(warnings) is warning_expected + if warning_expected: + assert warnings[0]["validated"] == validated + assert warnings[0]["observed"] == release + assert "selected OpenCode 2.0 transfer schema" in warnings[0]["message"] + assert "remains pinned" not in warnings[0]["message"] + native = json.loads(target.native_bytes) + assert ("location" in native["info"]) is opencode.is_v2(release) + + +def test_v2_flat_schema_portable_roundtrip(tmp_path): + target = artifact(tmp_path) + value = json.loads(target.native_bytes) + assert value["info"]["location"] == {"directory": str(tmp_path)} + assert value["info"]["model"] == {"id": "test", "providerID": "fixture"} + assert all("info" not in m and "parts" not in m for m in value["messages"]) + opencode.validate_native_bytes(target.native_bytes, target.session_id) + path = tmp_path / "bundle.json" + path.write_bytes(target.native_bytes) + parsed = opencode.parse_import(path) + source = load_session(FIXTURES / "codex-0.144.4/basic.jsonl", AgentFormat.CODEX) + assert [e.text for e in parsed.events if e.kind == EventKind.MESSAGE] == [ + e.text for e in source.events if e.kind == EventKind.MESSAGE + ] + assert [e.tool_call_id for e in parsed.events if e.kind == EventKind.TOOL_CALL] == [ + e.tool_call_id for e in source.events if e.kind == EventKind.TOOL_CALL + ] + + +def test_freeform_tool_input_is_wrapped_without_losing_call_or_result(tmp_path): + from dataclasses import replace + + session = load_session(FIXTURES / "codex-0.144.4/basic.jsonl", AgentFormat.CODEX) + original = next(e for e in session.events if e.kind == EventKind.TOOL_CALL) + freeform = "*** Begin Patch\n+Unicode: é λ\n*** End Patch\n" + session = replace( + session, + events=tuple( + replace(e, payload={"input": freeform}) if e is original else e for e in session.events + ), + ) + target = convert_session( + session, + ConversionOptions( + target_format=TargetFormat.OPENCODE, + target_cli_version="2.0.23", + cwd=tmp_path, + model_provider="fixture", + model="test", + ), + ) + native = json.loads(target.native_bytes) + tool = next( + item + for message in native["messages"] + if message["type"] == "assistant" + for item in message["content"] + if item["type"] == "tool" and item["id"] == original.tool_call_id + ) + assert tool["state"]["input"] == {"input": freeform} + assert tool["state"]["status"] == "completed" + result = next(e for e in session.events if e.kind == EventKind.TOOL_RESULT) + assert [item for item in tool["state"]["content"] if item["type"] == "text"] == [ + {"type": "text", "text": result.text} + ] + assert target.dropped["tool_call:non_object_input"] == 1 + + +def test_cli_help_explains_opencode_schema_selection(capsys): + from session_migrate.cli import build_parser + + with pytest.raises(SystemExit) as exc: + build_parser().parse_args(["convert", "--help"]) + assert exc.value.code == 0 + help_text = " ".join(capsys.readouterr().out.split()) + assert "selects legacy or 2.0 transfer schema for OpenCode" in help_text + assert "metadata version only; the writer schema remains pinned" not in help_text + + +def test_v2_tools_images_compaction_and_incomplete_call(tmp_path): + from dataclasses import replace + + base = load_session(FIXTURES / "codex-0.144.4/basic.jsonl", AgentFormat.CODEX) + image = "data:image/png;base64,aGVsbG8=" + events = ( + Event( + kind=EventKind.MESSAGE, role=Role.USER, text="look", provenance=Provenance(1, "user") + ), + Event( + kind=EventKind.CONTEXT, + role=Role.USER, + payload={"block_type": "image", "image_url": image}, + provenance=Provenance(1, "user"), + ), + Event( + kind=EventKind.TOOL_CALL, + role=Role.ASSISTANT, + tool_call_id="call_one", + tool_name="read", + payload={"input": {"path": "a"}}, + provenance=Provenance(2, "assistant"), + ), + Event( + kind=EventKind.TOOL_RESULT, + role=Role.TOOL, + tool_call_id="call_one", + text="result", + payload={ + "content_blocks": [ + {"type": "text", "text": "result"}, + {"type": "image", "image_url": image}, + ] + }, + provenance=Provenance(3, "tool"), + ), + Event( + kind=EventKind.COMPACTION, + text="portable summary", + provenance=Provenance(4, "compaction"), + ), + Event( + kind=EventKind.TOOL_CALL, + role=Role.ASSISTANT, + tool_call_id="unfinished", + tool_name="shell", + payload={"input": {"cmd": "echo x"}}, + provenance=Provenance(5, "assistant"), + ), + ) + data, losses = opencode.serialize( + replace(base, events=events), session_id="ses_fixture", cwd=tmp_path, cli_version="2.0.23" + ) + opencode.validate_native_bytes(data, "ses_fixture") + value = json.loads(data) + assert value["messages"][0]["files"][0]["data"] == "aGVsbG8=" + completed = value["messages"][1]["content"][0] + assert completed["id"] == "call_one" + assert completed["state"]["content"][1]["uri"] == image + assert value["messages"][2]["type"] == "compaction" + assert value["messages"][2]["summary"] == "portable summary" + assert value["messages"][3]["content"][0]["state"]["status"] == "error" + assert losses["tool_call:v2_incomplete_archived"] == 1 + path = tmp_path / "rich.json" + path.write_bytes(data) + parsed = opencode.parse_import(path) + assert any( + e.kind == EventKind.COMPACTION and e.text == "portable summary" for e in parsed.events + ) + assert any( + e.kind == EventKind.TOOL_RESULT and e.tool_call_id == "call_one" and e.text == "result" + for e in parsed.events + ) + assert any( + e.kind == EventKind.CONTEXT and e.payload["image_url"] == image for e in parsed.events + ) + + +@pytest.mark.parametrize("change", ["duplicate", "tool", "location", "tokens", "base64"]) +def test_v2_rejects_malformed_portable_content(tmp_path, change): + value = json.loads(artifact(tmp_path).native_bytes) + if change == "duplicate": + value["messages"].append(value["messages"][0]) + elif change == "location": + value["info"]["location"] = None + elif change == "tokens": + value["info"]["tokens"] = {} + elif change == "base64": + value["messages"][0]["files"] = [{"data": "!!!", "mime": "image/png"}] + else: + value["messages"][1]["content"] = [ + {"type": "tool", "state": {"status": "completed", "input": {}, "content": []}} + ] + with pytest.raises(SessionMigrateError): + opencode.validate_native_bytes(json.dumps(value).encode(), value["info"]["id"]) + + +def test_v2_private_provider_state_accounted(tmp_path): + value = json.loads(artifact(tmp_path).native_bytes) + assistant = next(m for m in value["messages"] if m["type"] == "assistant") + assistant["providerState"] = {"encrypted": "private"} + path = tmp_path / "state.json" + path.write_text(json.dumps(value)) + parsed = opencode.parse_import(path) + assert ("opencode_v2_providerState", 1) in parsed.losses + assert all("private" not in (e.text or "") for e in parsed.events) + + +@pytest.mark.parametrize( + "value, expected", + [ + ("2.0.23", True), + ("2.0.22", True), + ("opencode v2.0.23", True), + ("2.0.23-gateway.3648f00e", True), + ("2.1.0", False), + ("3.0.0", False), + ("9.9.9", False), + ("1.17.20", True), + ], +) +def test_version_schema_gate(value, expected): + assert opencode.supported_version(value) is expected + + +def test_v2_cli_commands_and_conflict_not_success(tmp_path, monkeypatch): + import subprocess + + monkeypatch.setattr(conversion, "_opencode_version", lambda *args: "2.0.23") + calls = [] + + def run(command, environ): + calls.append(command) + return subprocess.CompletedProcess(command, 0, "Session already exists\n", "") + + monkeypatch.setattr(conversion, "_run_opencode", run) + with pytest.raises(SessionMigrateError, match="refused"): + conversion._invoke_opencode_import( + Path("opencode"), tmp_path / "bundle.json", {}, cwd=tmp_path + ) + assert calls[0][1:3] == ["session", "import"] + assert calls[0][-3:] == ["--directory", str(tmp_path), "--standalone"] + + +def test_native_v2_isolated_import_export_dry_run_and_collision(tmp_path): + """Opt-in actual native CLI integration; only synthetic fixture data.""" + import os + from dataclasses import replace + + native = os.environ.get("SESSION_MIGRATE_TEST_OPENCODE_V2") + if not native: + pytest.skip("set SESSION_MIGRATE_TEST_OPENCODE_V2 to a real v2 binary") + env = { + "PATH": os.environ.get("PATH", ""), + "HOME": str(tmp_path / "home"), + "TMPDIR": str(tmp_path), + "OPENCODE_DISABLE_AUTOUPDATE": "true", + "OPENCODE_CONFIG_CONTENT": '{"disabled_providers":["opencode"]}', + } + for key, name in [ + ("XDG_CONFIG_HOME", "config"), + ("XDG_DATA_HOME", "data"), + ("XDG_CACHE_HOME", "cache"), + ("XDG_STATE_HOME", "state"), + ]: + env[key] = str(tmp_path / name) + for value in [env["HOME"], *(env[k] for k in env if k.startswith("XDG_"))]: + Path(value).mkdir() + release = conversion._opencode_version(Path(native), env) + assert release in {"2.0.22", "2.0.23"} + target = artifact(tmp_path, release) + # Include a native completed compaction and linked tool output from the Codex fixture. + value = json.loads(target.native_bytes) + image = "data:image/png;base64,aGVsbG8=" + user = next(m for m in value["messages"] if m["type"] == "user") + user["files"] = [{"data": "aGVsbG8=", "mime": "image/png", "source": {"type": "inline"}}] + tool = next( + c + for m in value["messages"] + if m["type"] == "assistant" + for c in m["content"] + if c["type"] == "tool" + ) + tool["state"]["content"].append({"type": "file", "uri": image, "mime": "image/png"}) + value["messages"].append( + { + "id": "msg_native_compaction", + "type": "compaction", + "time": {"created": value["info"]["time"]["updated"]}, + "status": "completed", + "reason": "auto", + "summary": "Retain forty one.", + "recent": "Latest request.", + } + ) + target = replace(target, native_bytes=(json.dumps(value) + "\n").encode()) + manifest = tmp_path / "manifest.json" + cli = Path(native) + conversion.install_opencode_artifact( + target, manifest_path=manifest, target_cli=cli, environ=env, dry_run=True + ) + assert not manifest.exists() + conversion.install_opencode_artifact( + target, manifest_path=manifest, target_cli=cli, environ=env + ) + assert manifest.exists() + exported = conversion.load_opencode_session(target.session_id, source_cli=cli, environ=env) + assert exported.cli_version == release + assert any(e.kind == EventKind.COMPACTION and "forty one" in e.text for e in exported.events) + assert any(e.kind == EventKind.TOOL_RESULT for e in exported.events) + assert any( + e.kind == EventKind.CONTEXT and e.payload.get("image_url") == image for e in exported.events + ) + # Native global conflict is also protected when the project-filtered preflight misses it. + other = tmp_path / "other-manifest.json" + with pytest.raises(SessionMigrateError, match="overwrite|refused"): + conversion.install_opencode_artifact( + target, manifest_path=other, target_cli=cli, environ=env + ) + assert not other.exists() + from session_migrate.catalog import Catalog + + with Catalog(tmp_path / "catalog.sqlite3") as catalog: + result = catalog.refresh( + opencode_roots=(Path(env["XDG_DATA_HOME"]) / "opencode",), include_auto=False + ) + assert result.root_errors == 0 + entries = catalog.list_sessions(agent_format=AgentFormat.OPENCODE, include_paths=True) + assert [entry.session_id for entry in entries] == [target.session_id] + assert entries[0].cli_version == release + assert entries[0].cwd == str(tmp_path) + + +def test_v2_content_free_inspection(tmp_path): + from session_migrate.inspection import inspect_session + + path = tmp_path / "inspection.json" + path.write_bytes(artifact(tmp_path).native_bytes) + result = inspect_session(path, source_format=AgentFormat.OPENCODE) + assert result.cwd == str(tmp_path) + assert result.tool_calls > 0 and result.tool_results > 0 + assert result.roles.get("user", 0) > 0 + assert result.records > 0 + + +def test_v2_autodetection_uses_flat_schema_while_legacy_remains_ambiguous(tmp_path): + from session_migrate.errors import FormatDetectionError + from session_migrate.inspection import detect_path_format, inspect_session + + path = tmp_path / "auto.json" + path.write_bytes(artifact(tmp_path).native_bytes) + assert detect_path_format(path) == AgentFormat.OPENCODE + assert inspect_session(path).format == AgentFormat.OPENCODE.value + assert load_session(path).source_format == AgentFormat.OPENCODE + session = load_session(FIXTURES / "codex-0.144.4/basic.jsonl", AgentFormat.CODEX) + legacy = convert_session(session, ConversionOptions(TargetFormat.OPENCODE)) + path.write_bytes(legacy.native_bytes) + with pytest.raises(FormatDetectionError, match="no reliable producer marker"): + detect_path_format(path) + + +@pytest.mark.parametrize("whitespace", ["", " ", "\n\t"]) +def test_private_only_compaction_preserves_readable_history(tmp_path, whitespace): + value = json.loads(artifact(tmp_path).native_bytes) + value["messages"] = [m for m in value["messages"] if m["type"] != "compaction"] + value["messages"].append( + { + "id": "msg_private_compaction", + "type": "compaction", + "time": {"created": value["info"]["time"]["updated"]}, + "status": "completed", + "reason": "auto", + "summary": whitespace, + "recent": whitespace, + "providerContext": {"encrypted": "private"}, + } + ) + path = tmp_path / "private-compaction.json" + path.write_text(json.dumps(value)) + source = opencode.parse_session(path) + assert not any(e.kind == EventKind.COMPACTION for e in source.events) + assert any(e.kind == EventKind.MESSAGE and e.role == Role.USER for e in source.events) + target = convert_session( + source, + ConversionOptions( + target_format=TargetFormat.OPENCODE, target_cli_version="2.0.23", cwd=tmp_path + ), + ) + native = json.loads(target.native_bytes) + assert not any(m["type"] == "compaction" for m in native["messages"]) + assert target.dropped["opaque:opencode_v2_compaction_empty_portable_summary"] == 1 + assert target.dropped["opaque:opencode_v2_providerContext"] == 1 + assert b'"encrypted"' not in target.native_bytes + + +def test_v2_reasoning_variant_loss_is_explicit(tmp_path): + value = json.loads(artifact(tmp_path).native_bytes) + value["info"]["model"]["variant"] = "high" + assistant = next(m for m in value["messages"] if m["type"] == "assistant") + assistant["model"]["variant"] = "max" + path = tmp_path / "variants.json" + path.write_text(json.dumps(value)) + parsed = opencode.parse_import(path) + assert ("opencode_v2_session_model_variant", 1) in parsed.losses + assert ("opencode_v2_message_model_variant", 1) in parsed.losses + + +@pytest.mark.parametrize("text", [None, 3, {}, []]) +def test_v2_invalid_user_text_fails_closed(tmp_path, text): + value = json.loads(artifact(tmp_path).native_bytes) + user = next(m for m in value["messages"] if m["type"] == "user") + user["text"] = text + with pytest.raises(SessionMigrateError, match="user message has invalid text"): + opencode.validate_native_bytes(json.dumps(value).encode(), value["info"]["id"]) + + +def test_v2_unknown_message_type_fails_closed(tmp_path): + value = json.loads(artifact(tmp_path).native_bytes) + value["messages"][0]["type"] = "future-user" + with pytest.raises(SessionMigrateError, match="unsupported message type"): + opencode.validate_native_bytes(json.dumps(value).encode(), value["info"]["id"]) + + +@pytest.mark.parametrize("extra_content", [None, 3, "unknown", [{"type": "future-part"}]]) +def test_unknown_user_extension_is_accounted_without_crashing(tmp_path, extra_content): + value = json.loads(artifact(tmp_path).native_bytes) + user = next(m for m in value["messages"] if m["type"] == "user") + user["content"] = extra_content + path = tmp_path / "extension.json" + path.write_text(json.dumps(value)) + parsed = opencode.parse_import(path) + assert ("opencode_v2_unknown_message_fields", 1) in parsed.losses + assert any(e.kind == EventKind.MESSAGE and e.role == Role.USER for e in parsed.events) + + +def test_file_metadata_omission_is_explicit(tmp_path): + value = json.loads(artifact(tmp_path).native_bytes) + user = next(m for m in value["messages"] if m["type"] == "user") + user["files"] = [ + { + "data": "aGVsbG8=", + "mime": "image/png", + "source": {"type": "uri", "uri": "file:///private/image.png"}, + "name": "image.png", + } + ] + path = tmp_path / "file-metadata.json" + path.write_text(json.dumps(value)) + parsed = opencode.parse_import(path) + assert ("opencode_v2_file_metadata", 1) in parsed.losses + assert any(e.kind == EventKind.CONTEXT for e in parsed.events) + + +@pytest.mark.parametrize("named_files", [1, 2]) +def test_tool_file_metadata_loss_is_counted_per_block_through_conversion(tmp_path, named_files): + value = json.loads(artifact(tmp_path).native_bytes) + tool = next( + content + for message in value["messages"] + if message["type"] == "assistant" + for content in message["content"] + if content["type"] == "tool" + ) + uri = "data:image/png;base64,aGVsbG8=" + file_block = {"type": "file", "uri": uri, "mime": "image/png"} + tool["state"]["content"].extend( + {**file_block, "name": f"synthetic-filename-{index}.png"} for index in range(named_files) + ) + tool["state"]["content"].append(file_block) + expected_uris = [block["uri"] for block in tool["state"]["content"] if block["type"] == "file"] + path = tmp_path / "tool-file-metadata.json" + path.write_text(json.dumps(value)) + source = opencode.parse_session(path) + target = convert_session( + source, + ConversionOptions(TargetFormat.OPENCODE, target_cli_version="2.0.23", cwd=tmp_path), + ) + assert target.dropped["opaque:opencode_v2_tool_file_metadata"] == named_files + target_tool = next( + content + for message in json.loads(target.native_bytes)["messages"] + if message["type"] == "assistant" + for content in message["content"] + if content["type"] == "tool" and content["id"] == tool["id"] + ) + files = [block for block in target_tool["state"]["content"] if block["type"] == "file"] + assert [block["uri"] for block in files] == expected_uris + assert b"synthetic-filename" not in target.native_bytes + + +def test_kilo_metadata_override_keeps_legacy_schema(tmp_path): + session = load_session(FIXTURES / "codex-0.144.4/basic.jsonl", AgentFormat.CODEX) + target = convert_session( + session, + ConversionOptions(TargetFormat.KILO, target_cli_version="2.0.23", cwd=tmp_path), + ) + value = json.loads(target.native_bytes) + assert value["info"]["version"] == "2.0.23" + assert "directory" in value["info"] and "location" not in value["info"] + assert all("info" in message and "parts" in message for message in value["messages"]) + + +def test_kilo_rejects_opencode_v2_schema(tmp_path): + from session_migrate.formats import kilo + from session_migrate.inspection import inspect_session + + target = artifact(tmp_path) + with pytest.raises(SessionMigrateError, match="legacy nested"): + kilo.validate_native_bytes(target.native_bytes, target.session_id) + path = tmp_path / "opencode-v2.json" + path.write_bytes(target.native_bytes) + with pytest.raises(SessionMigrateError, match="legacy nested"): + kilo.parse_session(path) + with pytest.raises(SessionMigrateError, match="requires --format opencode"): + inspect_session(path, source_format=AgentFormat.KILO) + + +@pytest.mark.parametrize("field, malformed", [("cost", "invalid"), ("tokens", {}), ("model", {})]) +def test_v2_compaction_validates_native_metadata_before_projection(tmp_path, field, malformed): + value = json.loads(artifact(tmp_path).native_bytes) + compaction = next(message for message in value["messages"] if message["type"] == "compaction") + compaction[field] = malformed + with pytest.raises(SessionMigrateError): + opencode.validate_native_bytes(json.dumps(value).encode(), value["info"]["id"]) + + +def test_v2_running_tool_requires_native_metadata(tmp_path): + value = json.loads(artifact(tmp_path).native_bytes) + tool = next( + content + for message in value["messages"] + if message["type"] == "assistant" + for content in message["content"] + if content["type"] == "tool" + ) + tool["state"] = {"status": "running", "input": {}} + with pytest.raises(SessionMigrateError, match="running tool has invalid metadata"): + opencode.validate_native_bytes(json.dumps(value).encode(), value["info"]["id"]) + + +@pytest.mark.parametrize( + "field, malformed", + [ + ("permissions", 42), + ("permissions", [{"permission": "read", "pattern": "*", "action": "allow"}]), + ("metadata", []), + ("fork", 42), + ("outcome", "not-outcome"), + ("location", {"directory": 42}), + ], +) +def test_v2_native_session_metadata_cannot_disappear_before_validation(tmp_path, field, malformed): + value = json.loads(artifact(tmp_path).native_bytes) + value["info"][field] = malformed + with pytest.raises(SessionMigrateError): + opencode.validate_native_bytes(json.dumps(value).encode(), value["info"]["id"]) + + +def test_v2_tool_error_requires_structured_type(tmp_path): + value = json.loads(artifact(tmp_path).native_bytes) + tool = next( + content + for message in value["messages"] + if message["type"] == "assistant" + for content in message["content"] + if content["type"] == "tool" + ) + tool["state"] = {"status": "error", "input": {}, "error": {"message": "missing type"}} + with pytest.raises(SessionMigrateError, match="invalid structured error"): + opencode.validate_native_bytes(json.dumps(value).encode(), value["info"]["id"]) diff --git a/tests/test_opencode_v2_native.py b/tests/test_opencode_v2_native.py new file mode 100644 index 0000000..2a68d5b --- /dev/null +++ b/tests/test_opencode_v2_native.py @@ -0,0 +1,119 @@ +"""Pinned v2 native import/export, cold reopen and localhost-only continuation.""" + +import json +import os +import subprocess +from dataclasses import replace +from pathlib import Path + +import pytest +from offline_provider import offline_provider + +from session_migrate.conversion import ConversionOptions, convert_session, install_opencode_artifact +from session_migrate.formats import codex +from session_migrate.model import EventKind, TargetFormat + + +@pytest.mark.parametrize("compaction", [False, True]) +def test_pinned_v2_native_reopens_and_continues_same_session(tmp_path, compaction): + binary = os.environ.get("SESSION_MIGRATE_TEST_OPENCODE_V2") + if not binary: + pytest.skip("set SESSION_MIGRATE_TEST_OPENCODE_V2 to a stock 2.0.22 or 2.0.23 executable") + env = {"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8", "OPENCODE_DISABLE_AUTOUPDATE": "true"} + for key in [ + "HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "TMPDIR", + ]: + folder = tmp_path / key.lower() + folder.mkdir() + env[key] = str(folder) + project = tmp_path / "project" + project.mkdir() + + def run(arguments): + result = subprocess.run( + [binary, *arguments], cwd=project, env=env, capture_output=True, text=True, timeout=90 + ) + assert result.returncode == 0, result.stderr + return result.stdout + + release = run(["--version"]).strip().removeprefix("opencode v") + assert release in {"2.0.22", "2.0.23"} + source = codex.parse(Path(__file__).parent / "fixtures/codex-0.144.4/basic.jsonl") + if not compaction: + source = replace( + source, events=tuple(e for e in source.events if e.kind != EventKind.COMPACTION) + ) + with offline_provider("PINNED-V2-CONTINUED-BETA-2048") as provider: + config = project / "opencode.json" + config.write_text( + json.dumps( + { + "warming": False, + "disabled_providers": ["opencode"], + "providers": { + "fixture": { + "package": "@opencode/ai/providers/openai-compatible", + "settings": { + "apiKey": "fake-only", + "baseURL": f"http://127.0.0.1:{provider.server_port}/v1", + }, + "models": {"test": {"limit": {"context": 64000, "output": 4096}}}, + } + }, + } + ) + ) + env["OPENCODE_CONFIG"] = str(config) + artifact = convert_session( + source, + ConversionOptions( + target_format=TargetFormat.OPENCODE, + target_cli_version=release, + cwd=project, + model_provider="fixture", + model="test", + ), + ) + install_opencode_artifact( + artifact, manifest_path=tmp_path / "manifest.json", target_cli=Path(binary), environ=env + ) + before = json.loads(run(["session", "export", artifact.session_id, "--standalone"])) + # Every invocation is a new process: continuation and final export cold-reopen storage. + run( + [ + "run", + "--session", + artifact.session_id, + "--model", + "fixture/test", + "--standalone", + "--format", + "json", + "pinned native continuation probe", + ] + ) + after = json.loads(run(["session", "export", artifact.session_id, "--standalone"])) + assert after["info"]["id"] == before["info"]["id"] == artifact.session_id + assert len(after["messages"]) > len(before["messages"]) + assert any( + c.get("text") == provider.reply + for m in after["messages"] + if m["type"] == "assistant" + for c in m["content"] + if c["type"] == "text" + ) + assert provider.requests + for path, body in provider.requests: + assert path.endswith("/chat/completions") + assert "BETA-2048" in json.dumps(body) + assert "pinned native continuation probe" in json.dumps(body) + if not compaction: + assert any( + m.get("role") == "tool" and "/work" in json.dumps(m) for m in body["messages"] + ) + assert "call_fixture_1" in json.dumps(body) diff --git a/tests/test_route_matrix.py b/tests/test_route_matrix.py index f8b0ab3..d898563 100644 --- a/tests/test_route_matrix.py +++ b/tests/test_route_matrix.py @@ -53,6 +53,19 @@ def source_sessions(tmp_path: Path) -> dict[str, Session]: FIXTURES / "copilot-source-1.0.70" / "copilot-source-native-events.jsonl" ), } + v2_source = tmp_path / "v2-source.json" + v2_artifact = convert_session( + sessions["claude"], + ConversionOptions( + target_format=TargetFormat.OPENCODE, + target_cli_version="2.0.23", + cwd=tmp_path, + model_provider="fixture", + model="test", + ), + ) + v2_source.write_bytes(v2_artifact.native_bytes) + sessions["opencode-v2"] = opencode.parse_session(v2_source) antigravity_id = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" antigravity_bytes, _ = antigravity.serialize( sessions["claude"], @@ -334,6 +347,7 @@ def hermes_bundle_signature(data: bytes, session_id: str) -> list[Any]: "pi", "omp", "opencode", + "opencode-v2", "copilot", "antigravity", "cursor", @@ -357,6 +371,7 @@ def hermes_bundle_signature(data: bytes, session_id: str) -> list[Any]: TargetFormat.PI, TargetFormat.OMP, TargetFormat.OPENCODE, + "opencode-v2", TargetFormat.COPILOT, TargetFormat.ANTIGRAVITY, TargetFormat.CURSOR, @@ -376,10 +391,16 @@ def test_every_supported_source_to_target_route_preserves_portable_timeline( source_name: str, target: TargetFormat, tmp_path: Path ) -> None: source = source_sessions(tmp_path)[source_name] + v2_target = target == "opencode-v2" + if v2_target: + target = TargetFormat.OPENCODE artifact = convert_session( source, ConversionOptions( target_format=target, + target_cli_version="2.0.23" if v2_target else None, + model_provider="fixture", + model="test", session_id=TARGET_UUID, cwd=tmp_path, ), diff --git a/tests/test_target_integration.py b/tests/test_target_integration.py index 251d599..a3b165c 100644 --- a/tests/test_target_integration.py +++ b/tests/test_target_integration.py @@ -793,6 +793,7 @@ def test_opencode_empty_official_session_list_means_no_collisions( monkeypatch: pytest.MonkeyPatch, ) -> None: completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + monkeypatch.setattr(conversion, "_opencode_version", lambda cli, env: "1.17.20") monkeypatch.setattr(conversion, "_run_opencode", lambda command, env: completed) assert conversion._opencode_session_ids(Path("/synthetic/opencode"), {}) == set() @@ -907,6 +908,7 @@ def install( return target_cli or Path("opencode") monkeypatch.setattr(cli_module, "install_opencode_artifact", install) + monkeypatch.setattr(cli_module, "detect_opencode_version", lambda cli: "1.17.20") monkeypatch.setenv("XDG_STATE_HOME", str(state)) status = main( From 2e3671dcf402159bcb456380409a103cfaaf19cf Mon Sep 17 00:00:00 2001 From: TheViniAlmeida Date: Sat, 10 Oct 2026 04:03:29 -0300 Subject: [PATCH 2/2] fix(opencode): preserve tool ordering and validate native fields --- docs/opencode-v2.md | 21 +- scripts/run-native-test-client.sh | 1 + src/session_migrate/formats/opencode.py | 75 ++- src/session_migrate/formats/opencode_v2.py | 270 +++++++-- tests/native_corpus/route_oracle.py | 23 + tests/test_opencode_v2.py | 15 +- tests/test_opencode_v2_review.py | 620 +++++++++++++++++++++ 7 files changed, 963 insertions(+), 62 deletions(-) create mode 100644 tests/test_opencode_v2_review.py diff --git a/docs/opencode-v2.md b/docs/opencode-v2.md index cd21658..3f5882b 100644 --- a/docs/opencode-v2.md +++ b/docs/opencode-v2.md @@ -13,10 +13,27 @@ fail closed during native import/export. The v2 adapter preserves ordered user/assistant text, linked tool inputs and results, inline user/tool images, and readable completed compaction summaries. +Completed tool results retain the original ordered `content_blocks`, including +text segmentation and captions interleaved with multiple images, in portable +events and v2 output. Capable targets such as Claude, Codex and Pi consume these +ordered blocks. Legacy OpenCode 1.17.20 and Kilo 7.5.0 have a single output string +plus separate attachments: flattening the ordered blocks is counted once per +result as `tool_result:content_block_order` when it changes their representation. +Structured tool errors retain their message as a failed result. Omitted native +error type, HTTP status and response are counted separately as +`opaque:opencode_v2_tool_error_type`, `opaque:opencode_v2_tool_error_status` and +`opaque:opencode_v2_tool_error_response`; type is counted when it differs from +the output's `ToolError`. Diagnostic response bodies never enter opaque-event +payloads, manifest metadata or privileged messages. User text blocks in one native message become their exact newline join. Private-only or whitespace-only checkpoints do not retire readable history. Encrypted provider checkpoints, private traces, source reasoning variants and unsupported control/metadata fields are explicitly counted as omissions. +The reader validates original session fields and all accepted typed messages +before projection, including system, synthetic, skill, switch, shell, idle and +every compaction status. Optional fields may be absent; explicit `null` is +rejected when the pinned schemas require a typed value. Message and content +limits apply before discarded control records or joined tool text can hide them. A streamed tool input can be an incomplete JSON string. The source projection keeps it unchanged inside `{ "input": ORIGINAL_STRING }`; it does not replace @@ -40,7 +57,7 @@ fails import confirmation. ./scripts/install-native-test-clis.sh /tmp/session-migrate-native opencode-v2 ./scripts/run-native-test-client.sh \ opencode-v2 /tmp/session-migrate-native/session-migrate-native.env -PYTHONPATH=src python3 -m pytest -q tests/test_opencode_v2.py tests/test_route_matrix.py +PYTHONPATH=src python3 -m pytest -q tests/test_opencode_v2.py tests/test_opencode_v2_review.py tests/test_route_matrix.py ``` The `opencode-v2` CI matrix entry installs **@opencode/cli@2.0.23** (the legacy @@ -68,7 +85,7 @@ For a preinstalled stock 2.0.22 binary, no dependency installation is needed: ```sh SESSION_MIGRATE_TEST_OPENCODE_V2=/absolute/path/to/opencode \ PYTHONPATH=src python3 -m pytest -q \ - tests/test_opencode_v2.py tests/test_opencode_v2_native.py + tests/test_opencode_v2.py tests/test_opencode_v2_review.py tests/test_opencode_v2_native.py ``` Native Windows acceptance and all historical client versions are not diff --git a/scripts/run-native-test-client.sh b/scripts/run-native-test-client.sh index cb2bb2d..cf5188e 100755 --- a/scripts/run-native-test-client.sh +++ b/scripts/run-native-test-client.sh @@ -103,6 +103,7 @@ case "$client" in tests=( tests/test_opencode_v2.py::test_native_v2_isolated_import_export_dry_run_and_collision tests/test_opencode_v2_native.py + tests/test_opencode_v2_review.py::test_stock_v2_accepts_and_cold_exports_review_sources_and_fixed_targets ) ;; openhands) diff --git a/src/session_migrate/formats/opencode.py b/src/session_migrate/formats/opencode.py index 4252acb..6514410 100644 --- a/src/session_migrate/formats/opencode.py +++ b/src/session_migrate/formats/opencode.py @@ -97,6 +97,8 @@ def serialize( seen_tool_call_ids: set[str] = set() seen_tool_result_ids: set[str] = set() tool_parts: dict[str, deque[dict[str, Any]]] = defaultdict(deque) + v2_tool_content: dict[str, list[dict[str, Any]]] = {} + native_v2_target = not legacy_schema and bool(opencode_v2.version(cli_version)) portable_boundary = 0 pending_role: Role | None = None @@ -336,13 +338,20 @@ def queue_part(event: Event, role: Role, part: dict[str, Any]) -> None: str(part["_migration_timestamp"]), ) result_timestamp = _event_timestamp(event, fallback_timestamp, dropped) - result_text, attachments = _tool_result( + result_text, attachments, ordered_content = _tool_result( event, session_id, part, dropped, attachment_id=lambda timestamp=result_timestamp: new_part_id(timestamp), ) + if native_v2_target: + v2_tool_content[part["id"]] = ordered_content + elif ordered_content != [ + *([{"type": "text", "text": result_text}] if result_text else []), + *({"type": "file", "uri": p["url"], "mime": p["mime"]} for p in attachments), + ]: + dropped["tool_result:content_block_order"] += 1 start_timestamp = str(part.pop("_migration_timestamp", result_timestamp)) if int(part.pop("_migration_boundary", portable_boundary)) < portable_boundary: dropped["tool_result:native_order_associated"] += 1 @@ -427,8 +436,8 @@ def queue_part(event: Event, role: Role, part: dict[str, Any]) -> None: }, "messages": messages, } - if not legacy_schema and opencode_v2.version(cli_version): - export_data = opencode_v2.from_legacy(export_data, dropped) + if native_v2_target: + export_data = opencode_v2.from_legacy(export_data, dropped, tool_content=v2_tool_content) data = (json.dumps(export_data, ensure_ascii=False, indent=2) + "\n").encode() return data, dict(sorted(dropped.items())) @@ -488,6 +497,14 @@ def parse_import(path: Path) -> ParsedOpenCodeSession: if native_v2 is not None: events.extend(opencode_v2.loss_events(native_v2)) + # Native states come only from the validated v2 input, never a legacy carrier. + native_tool_states: dict[tuple[str, str], deque[dict[str, Any]]] = defaultdict(deque) + if native_v2 is not None: + for message in native_v2["messages"]: + for content in message.get("content", []) if message["type"] == "assistant" else []: + if content["type"] == "tool": + native_tool_states[(message["id"], content["id"])].append(content["state"]) + compaction_parents: dict[str, dict[str, Any]] = {} summary_parents: set[str] = set() for message in messages: @@ -507,6 +524,7 @@ def parse_import(path: Path) -> ParsedOpenCodeSession: for message_index, message in enumerate(messages): message_info = message["info"] + message_id = message_info["id"] parts = message.get("parts") assert isinstance(parts, list) role_name = string(message_info.get("role")) @@ -656,7 +674,15 @@ def parse_import(path: Path) -> ParsedOpenCodeSession: ) ) elif part_type == "tool" and role_name == "assistant": - events.extend(_tool_part_events(part, timestamp, provenance)) + states = native_tool_states[(message_id, part["callID"])] + events.extend( + _tool_part_events( + part, + timestamp, + provenance, + native_state=states.popleft() if states else None, + ) + ) elif part_type == "reasoning" and role_name == "assistant": events.append( Event( @@ -1324,7 +1350,13 @@ def _session_model(info: dict[str, Any], field: str) -> str | None: return string(model.get(field)) if isinstance(model, dict) else None -def _tool_part_events(part: dict[str, Any], timestamp: str, provenance: Provenance) -> list[Event]: +def _tool_part_events( + part: dict[str, Any], + timestamp: str, + provenance: Provenance, + *, + native_state: dict[str, Any] | None = None, +) -> list[Event]: call_id = string(part.get("callID")) tool_name = string(part.get("tool")) state = part.get("state") if isinstance(part.get("state"), dict) else {} @@ -1351,7 +1383,24 @@ def _tool_part_events(part: dict[str, Any], timestamp: str, provenance: Provenan ) return result content_blocks: list[dict[str, Any]] = [] - if status == "completed": + if status == "completed" and native_state is not None: + texts = [] + for block in native_state["content"]: + if block["type"] == "text": + texts.append(block["text"]) + content_blocks.append({"type": "text", "text": block["text"]}) + else: + image_url = _portable_file_url({"url": block["uri"], "mime": block["mime"]}) + if image_url: + content_blocks.append({"type": "image", "image_url": image_url}) + else: + result.append( + _opaque_part_event( + provenance, "assistant", timestamp, "opencode_tool_attachment_non_image" + ) + ) + text = "\n".join(texts) or None + elif status == "completed": output = state.get("output") if isinstance(output, str) and output: content_blocks.append({"type": "text", "text": output}) @@ -1423,21 +1472,23 @@ def _tool_result( tool_part: dict[str, Any], dropped: Counter[str], attachment_id: Callable[[], str], -) -> tuple[str, list[dict[str, Any]]]: +) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]]]: blocks = event.payload.get("content_blocks") if not isinstance(blocks, list): blocks = [] texts: list[str] = [] attachments: list[dict[str, Any]] = [] + ordered: list[dict[str, Any]] = [] for block in blocks: if not isinstance(block, dict): dropped["tool_result:malformed_block"] += 1 continue block_type = string(block.get("type")) if block_type in {"text", "input_text", "output_text"}: - text = string(block.get("text")) - if text: + text = block.get("text") + if isinstance(text, str): texts.append(text) + ordered.append({"type": "text", "text": text}) else: dropped["tool_result:malformed_text"] += 1 elif block_type in {"image", "input_image"}: @@ -1455,11 +1506,15 @@ def _tool_result( } ) attachments.append(attachment) + ordered.append( + {"type": "file", "uri": attachment["url"], "mime": attachment["mime"]} + ) else: dropped[f"tool_result:{block_type or 'unknown_block'}"] += 1 if not texts and event.text: texts.append(event.text) - return "\n".join(texts), attachments + ordered.insert(0, {"type": "text", "text": event.text}) + return "\n".join(texts), attachments, ordered def _file_part(value: Any, dropped: Counter[str], omission_key: str) -> dict[str, Any] | None: diff --git a/src/session_migrate/formats/opencode_v2.py b/src/session_migrate/formats/opencode_v2.py index bee1d23..eb427f1 100644 --- a/src/session_migrate/formats/opencode_v2.py +++ b/src/session_migrate/formats/opencode_v2.py @@ -34,7 +34,12 @@ def _file(part: dict[str, Any]) -> dict[str, Any]: return {"data": uri.split(";base64,", 1)[1], "mime": part["mime"], "source": {"type": "inline"}} -def from_legacy(bundle: dict[str, Any], dropped: Any) -> dict[str, Any]: +def from_legacy( + bundle: dict[str, Any], + dropped: Any, + *, + tool_content: dict[str, list[dict[str, Any]]] | None = None, +) -> dict[str, Any]: old = bundle["info"] ref: dict[str, Any] = {} messages: list[dict[str, Any]] = [] @@ -85,6 +90,10 @@ def from_legacy(bundle: dict[str, Any], dropped: Any) -> dict[str, Any]: {"type": "file", "uri": p["url"], "mime": p["mime"]} for p in state.get("attachments", []) ) + if tool_content is not None and part["id"] in tool_content: + tool_state["content"] = tool_content[part["id"]] or [ + {"type": "text", "text": state["output"]} + ] elif status == "error": tool_state["error"] = {"type": "ToolError", "message": state["error"]} else: @@ -484,6 +493,14 @@ def loss_events(bundle: dict[str, Any]) -> list[Any]: if set(content) - allowed_content: reasons.append("opencode_v2_unknown_assistant_content_fields") if content.get("type") == "tool": + state = content["state"] + if state["status"] == "error": + structured = state["error"] + if structured["type"] != "ToolError": + reasons.append("opencode_v2_tool_error_type") + for field in ("status", "response"): + if field in structured: + reasons.append(f"opencode_v2_tool_error_{field}") for block in content["state"].get("content", []): if block.get("type") == "file" and block.get("name") is not None: reasons.append("opencode_v2_tool_file_metadata") @@ -520,17 +537,58 @@ def to_legacy(bundle: dict[str, Any]) -> dict[str, Any]: def _validate_native(bundle: dict[str, Any]) -> None: """Check portable v2 invariants before projection can erase native fields.""" from session_migrate.formats.opencode import ( + MAX_NATIVE_MESSAGES, + MAX_NATIVE_PARTS, _is_finite_number, _is_non_negative_int, _validate_tokens, ) + def strings(value: dict[str, Any], required: tuple[str, ...], optional=()) -> None: + if any(not isinstance(value.get(k), str) for k in required) or any( + k in value and not isinstance(value[k], str) for k in optional + ): + raise SessionMigrateError("OpenCode v2 has invalid native string fields") + + def enum(value: Any, choices: tuple[str, ...]) -> None: + if not isinstance(value, str) or value not in choices: + raise SessionMigrateError("OpenCode v2 has invalid native discriminator") + + def object_fields(value: dict[str, Any], fields: tuple[str, ...]) -> None: + if any(k in value and not isinstance(value[k], dict) for k in fields): + raise SessionMigrateError("OpenCode v2 has invalid native object fields") + + def time(value: Any, required: tuple[str, ...], optional=()) -> None: + if ( + not isinstance(value, dict) + or any(not _is_non_negative_int(value.get(k)) for k in required) + or any(k in value and not _is_non_negative_int(value[k]) for k in optional) + ): + raise SessionMigrateError("OpenCode v2 has invalid native time") + + def location_ref(value: Any) -> None: + if not isinstance(value, dict): + raise SessionMigrateError("OpenCode v2 has invalid location") + strings(value, ("directory",), ("workspaceID",)) + if not value["directory"] or "\x00" in value["directory"]: + raise SessionMigrateError("OpenCode v2 has invalid location directory") + if "workspaceID" in value and not value["workspaceID"].startswith("wrk"): + raise SessionMigrateError("OpenCode v2 has invalid location workspace") + + def mention(value: Any) -> None: + if ( + not isinstance(value, dict) + or not isinstance(value.get("text"), str) + or not all(_is_finite_number(value.get(k)) for k in ("start", "end")) + ): + raise SessionMigrateError("OpenCode v2 has invalid attachment mention") + def ref(value: Any) -> None: if not isinstance(value, dict) or not all( isinstance(value.get(k), str) for k in ("id", "providerID") ): raise SessionMigrateError("OpenCode v2 has invalid model reference") - if value.get("variant") is not None and not isinstance(value["variant"], str): + if "variant" in value and not isinstance(value["variant"], str): raise SessionMigrateError("OpenCode v2 has invalid model variant") def error(value: Any) -> None: @@ -539,29 +597,31 @@ def error(value: Any) -> None: ): raise SessionMigrateError("OpenCode v2 has invalid structured error") status = value.get("status") - if status is not None and (not _is_non_negative_int(status) or not 100 <= status <= 599): + if "status" in value and (not _is_non_negative_int(status) or not 100 <= status <= 599): raise SessionMigrateError("OpenCode v2 has invalid error status") response = value.get("response") - if response is not None and ( + if "response" in value and ( not isinstance(response, dict) or not isinstance(response.get("body"), str) ): raise SessionMigrateError("OpenCode v2 has invalid error response") info = bundle["info"] - location = info.get("location") - directory = location.get("directory") if isinstance(location, dict) else None - if not isinstance(directory, str) or not directory or "\x00" in directory: - raise SessionMigrateError("OpenCode v2 has invalid location directory") - if location.get("workspaceID") is not None and not isinstance(location["workspaceID"], str): - raise SessionMigrateError("OpenCode v2 has invalid location workspace") - if info.get("metadata") is not None and not isinstance(info["metadata"], dict): - raise SessionMigrateError("OpenCode v2 has invalid session metadata") - if info.get("outcome") is not None and info["outcome"] not in { - "succeeded", - "failed", - "interrupted", - }: - raise SessionMigrateError("OpenCode v2 has invalid session outcome") + strings(info, ("id", "projectID"), ("title", "agent", "parentID", "subpath")) + if not info["id"].startswith("ses_") or ( + "parentID" in info and not info["parentID"].startswith("ses_") + ): + raise SessionMigrateError("OpenCode v2 has invalid session identity") + time(info.get("time"), ("created", "updated"), ("idle", "viewed", "archived")) + if info["time"]["updated"] < info["time"]["created"]: + raise SessionMigrateError("OpenCode v2 has invalid update time") + location_ref(info.get("location")) + object_fields(info, ("metadata", "fork", "revert")) + if "model" in info: + ref(info["model"]) + if "outcome" in info: + enum(info["outcome"], ("succeeded", "failed", "interrupted")) + if "permissions" in info and not isinstance(info["permissions"], list): + raise SessionMigrateError("OpenCode v2 has invalid session permissions") permissions = info.get("permissions") if permissions is not None and ( not isinstance(permissions, list) @@ -594,15 +654,34 @@ def error(value: Any) -> None: or not revert["messageID"].startswith("msg_") ): raise SessionMigrateError("OpenCode v2 has invalid session revert") - if info.get("model") is not None: - ref(info["model"]) - if info.get("agent") is not None and not isinstance(info["agent"], str): - raise SessionMigrateError("OpenCode v2 has invalid session agent") - for m in bundle.get("messages", []): + if revert is not None: + strings(revert, (), ("partID", "snapshot")) + if "files" in revert: + if not isinstance(revert["files"], list): + raise SessionMigrateError("OpenCode v2 has invalid reverted files") + for diff in revert["files"]: + if not isinstance(diff, dict): + raise SessionMigrateError("OpenCode v2 has invalid reverted file") + strings(diff, ("file", "patch")) + enum(diff.get("status"), ("added", "modified", "deleted")) + if not all(_is_non_negative_int(diff.get(k)) for k in ("additions", "deletions")): + raise SessionMigrateError("OpenCode v2 has invalid reverted file counts") + messages = bundle.get("messages") + if not isinstance(messages, list) or len(messages) > MAX_NATIVE_MESSAGES: + raise SessionMigrateError("OpenCode v2 session has invalid messages") + seen_ids = set() + native_parts = 0 + for m in messages: if not isinstance(m, dict): raise SessionMigrateError("OpenCode v2 has malformed message") - if m.get("metadata") is not None and not isinstance(m["metadata"], dict): - raise SessionMigrateError("OpenCode v2 has invalid message metadata") + strings(m, ("id", "type")) + if not m["id"].startswith("msg_") or m["id"] in seen_ids: + raise SessionMigrateError("OpenCode v2 has invalid message IDs") + seen_ids.add(m["id"]) + object_fields(m, ("metadata",)) + for field in ("cost", "tokens", "error"): + if field in m and m[field] is None: + raise SessionMigrateError("OpenCode v2 has null native field") if m.get("cost") is not None and not _is_finite_number(m["cost"]): raise SessionMigrateError("OpenCode v2 has invalid message cost") if m.get("tokens") is not None: @@ -610,64 +689,141 @@ def error(value: Any) -> None: if m.get("error") is not None: error(m["error"]) kind = m.get("type") - if kind not in { - "user", - "assistant", - "compaction", - "agent-switched", - "model-switched", - "location-switched", - "synthetic", - "system", - "skill", - "shell", - "idle", - }: + if kind not in _MESSAGE_FIELDS: raise SessionMigrateError("OpenCode v2 has unsupported message type") clock = m.get("time") - if not isinstance(clock, dict) or not _is_non_negative_int(clock.get("created")): - raise SessionMigrateError("OpenCode v2 has invalid message time") - if clock.get("completed") is not None and not _is_non_negative_int(clock["completed"]): - raise SessionMigrateError("OpenCode v2 has invalid completion time") - if kind in {"assistant", "model-switched"} or ( - kind == "compaction" and m.get("model") is not None - ): + time(clock, ("created",), ("completed", "streamed")) + if kind in {"assistant", "model-switched"} or (kind == "compaction" and "model" in m): ref(m.get("model")) if kind == "user": + if not isinstance(m.get("text"), str): + raise SessionMigrateError("OpenCode v2 user message has invalid text") files = m.get("files", []) if not isinstance(files, list): raise SessionMigrateError("OpenCode v2 user has invalid file attachments") for attachment in files: + if not isinstance(attachment, dict): + raise SessionMigrateError("OpenCode v2 has invalid file attachment") + strings(attachment, ("data", "mime"), ("name", "description")) + if "mention" in attachment: + mention(attachment["mention"]) source = attachment.get("source") if isinstance(attachment, dict) else None if not isinstance(source, dict) or source.get("type") not in {"inline", "uri"}: raise SessionMigrateError("OpenCode v2 file has invalid source") if source["type"] == "uri" and not isinstance(source.get("uri"), str): raise SessionMigrateError("OpenCode v2 file has invalid source URI") + native_parts += len(files) + for field in ("agents", "skills"): + if field not in m: + continue + if not isinstance(m[field], list): + raise SessionMigrateError("OpenCode v2 has invalid user attachments") + for attachment in m[field]: + if not isinstance(attachment, dict): + raise SessionMigrateError("OpenCode v2 has invalid user attachment") + strings(attachment, ("name",) if field == "agents" else ("id", "name")) + if field == "skills": + strings(attachment, (), ("text",)) + if "mention" in attachment: + mention(attachment["mention"]) + if kind in {"system", "synthetic"}: + strings(m, ("text",), ("description",)) + if kind == "skill": + strings(m, ("skill", "name", "text")) + if kind == "agent-switched": + strings(m, ("agent",), ("previous",)) + if kind == "model-switched" and "previous" in m: + ref(m["previous"]) + if kind == "location-switched": + location_ref(m.get("location")) + strings(m, (), ("projectID", "subpath")) + if "previous" in m: + previous = m["previous"] + if not isinstance(previous, dict): + raise SessionMigrateError("OpenCode v2 has invalid previous location") + location_ref(previous.get("location")) + strings(previous, (), ("projectID", "subpath")) + if kind == "idle": + enum(m.get("outcome"), ("succeeded", "failed", "interrupted")) + if kind == "shell": + strings(m, ("shellID", "command")) + if not m["shellID"].startswith("sh_"): + raise SessionMigrateError("OpenCode v2 has invalid shell ID") + enum(m.get("status"), ("running", "exited", "timeout", "killed")) + if "exit" in m and not _is_finite_number(m["exit"]): + raise SessionMigrateError("OpenCode v2 has invalid shell exit") + if "output" in m: + output = m["output"] + if ( + not isinstance(output, dict) + or not isinstance(output.get("output"), str) + or not all(_is_non_negative_int(output.get(k)) for k in ("cursor", "size")) + or not isinstance(output.get("truncated"), bool) + ): + raise SessionMigrateError("OpenCode v2 has invalid shell output") if kind == "assistant": + strings(m, (), ("rawFinish",)) + object_fields(m, ("providerState", "snapshot", "retry")) + if "snapshot" in m: + strings(m["snapshot"], (), ("start", "end")) + files = m["snapshot"].get("files", []) + if not isinstance(files, list) or not all(isinstance(f, str) for f in files): + raise SessionMigrateError("OpenCode v2 has invalid snapshot files") + if "retry" in m: + retry = m["retry"] + if not _is_non_negative_int(retry.get("attempt")) or retry["attempt"] == 0: + raise SessionMigrateError("OpenCode v2 has invalid retry attempt") + time(retry, ("at",)) + error(retry.get("error")) + if "finish" in m: + enum( + m["finish"], + ("stop", "length", "tool-calls", "content-filter", "error", "unknown"), + ) if not isinstance(m.get("agent"), str) or not isinstance(m.get("content"), list): raise SessionMigrateError("OpenCode v2 assistant has invalid runtime metadata") for c in m["content"]: if not isinstance(c, dict): raise SessionMigrateError("OpenCode v2 has malformed assistant content") + object_fields(c, ("providerState", "providerResultState")) if c.get("type") != "tool": + enum(c.get("type"), ("text", "reasoning")) + strings(c, ("text",)) + object_fields(c, ("state",)) + if "time" in c: + time(c["time"], ("created",), ("completed",)) continue - if not isinstance(c.get("time"), dict) or not _is_non_negative_int( - c["time"].get("created") - ): - raise SessionMigrateError("OpenCode v2 tool has invalid time") + strings(c, ("id", "name")) + if "executed" in c and not isinstance(c["executed"], bool): + raise SessionMigrateError("OpenCode v2 tool has invalid executed flag") + time(c.get("time"), ("created",), ("ran", "completed")) state = c.get("state") if not isinstance(state, dict): raise SessionMigrateError("OpenCode v2 tool has invalid state") + enum(state.get("status"), ("streaming", "running", "completed", "error")) + if state["status"] == "streaming": + strings(state, ("input",)) + elif not isinstance(state.get("input"), dict): + raise SessionMigrateError("OpenCode v2 tool has invalid input") + if "metadata" in state and not isinstance(state["metadata"], dict): + raise SessionMigrateError("OpenCode v2 tool has invalid metadata") if state.get("status") == "running" and not isinstance(state.get("metadata"), dict): raise SessionMigrateError("OpenCode v2 running tool has invalid metadata") if state.get("status") == "error": error(state.get("error")) + if (state["status"] == "completed" or "content" in state) and ( + not isinstance(state.get("content"), list) or not state["content"] + ): + raise SessionMigrateError("OpenCode v2 tool has invalid content") for block in state.get("content", []): if not isinstance(block, dict) or block.get("type") not in {"text", "file"}: raise SessionMigrateError("OpenCode v2 tool has invalid content") fields = ("text",) if block["type"] == "text" else ("uri", "mime") if not all(isinstance(block.get(k), str) for k in fields): raise SessionMigrateError("OpenCode v2 tool has invalid content fields") + strings(block, (), ("name",)) + native_parts += len(state.get("content", [])) + native_parts += len(m["content"]) if kind == "compaction": if m.get("status") not in {"running", "completed", "failed"}: raise SessionMigrateError("OpenCode v2 compaction has invalid status") @@ -677,6 +833,22 @@ def error(value: Any) -> None: isinstance(m.get(k), str) for k in ("summary", "recent") ): raise SessionMigrateError("OpenCode v2 compaction has invalid summary") + if m["status"] == "failed": + error(m.get("error")) + object_fields(m, ("providerState", "providerContext")) + if "providerContext" in m: + context = m["providerContext"] + if context.get("version") != 1 or isinstance(context.get("version"), bool): + raise SessionMigrateError("OpenCode v2 has invalid provider context version") + provenance = context.get("provenance") + if not isinstance(provenance, dict) or "messages" not in context: + raise SessionMigrateError("OpenCode v2 has invalid provider context") + strings( + provenance, + ("providerID", "provider", "modelID", "route", "protocol", "endpoint"), + ) + if native_parts > MAX_NATIVE_PARTS: + raise SessionMigrateError("OpenCode v2 session contains too much content") def record_count(bundle: dict[str, Any]) -> int: diff --git a/tests/native_corpus/route_oracle.py b/tests/native_corpus/route_oracle.py index 923ee1f..f35b6be 100644 --- a/tests/native_corpus/route_oracle.py +++ b/tests/native_corpus/route_oracle.py @@ -62,6 +62,7 @@ class TargetContract: result_content_blocks: bool = True group_adjacent_messages: bool = False pair_tool_results_with_calls: bool = False + flatten_tool_result_blocks: bool = False opaque_style: str = "reason_prefixed" system_loss: str | None = "message:privileged_role" other_context_style: str = "typed" @@ -134,6 +135,7 @@ class TargetObservation: KEEP, CapabilityRule(False, ("thinking",)), pair_tool_results_with_calls=True, + flatten_tool_result_blocks=True, other_context_style="privileged_image", ), "copilot": TargetContract( @@ -228,6 +230,7 @@ class TargetObservation: KEEP, CapabilityRule(False, ("thinking",)), pair_tool_results_with_calls=True, + flatten_tool_result_blocks=True, other_context_style="privileged_image", ), "openhands": TargetContract( @@ -532,6 +535,8 @@ def expected_loss_counters(session: Session, target: str) -> dict[str, int]: continue if event.kind == EventKind.TOOL_RESULT: _apply_rule(losses, contract.tool_result) + if contract.flatten_tool_result_blocks and _tool_result_flattening_loses_blocks(event): + losses["tool_result:content_block_order"] += 1 for block in _result_blocks(event): if block.get("type") == "image": _apply_rule(losses, contract.tool_result_image) @@ -1374,6 +1379,24 @@ def _context_loss_key(event: Event, contract: TargetContract) -> str: return f"context:{context_type}" +def _tool_result_flattening_loses_blocks(event: Event) -> bool: + """One output string cannot retain text boundaries or image/text interleaving.""" + text_count = 0 + image_seen = False + for block in _result_blocks(event): + if block.get("type") in {"text", "input_text", "output_text"}: + text = block.get("text") + if not isinstance(text, str): + continue + text_count += 1 + if not text or text_count > 1 or image_seen: + return True + elif block.get("type") in {"image", "input_image"}: + if _portable_image_digest(block.get("image_url") or block.get("url")) is not None: + image_seen = True + return False + + def _result_blocks(event: Event) -> list[Mapping[str, Any]]: value = event.payload.get("content_blocks") if not isinstance(value, list): diff --git a/tests/test_opencode_v2.py b/tests/test_opencode_v2.py index 642e193..2a42baf 100644 --- a/tests/test_opencode_v2.py +++ b/tests/test_opencode_v2.py @@ -448,7 +448,19 @@ def test_private_only_compaction_preserves_readable_history(tmp_path, whitespace "reason": "auto", "summary": whitespace, "recent": whitespace, - "providerContext": {"encrypted": "private"}, + "providerContext": { + "version": 1, + "provenance": { + "providerID": "fixture", + "provider": "fixture", + "modelID": "test", + "route": "fixture", + "protocol": "fixture", + "endpoint": "fixture-digest", + }, + "messages": [], + }, + "providerState": {"encrypted": "private"}, } ) path = tmp_path / "private-compaction.json" @@ -466,6 +478,7 @@ def test_private_only_compaction_preserves_readable_history(tmp_path, whitespace assert not any(m["type"] == "compaction" for m in native["messages"]) assert target.dropped["opaque:opencode_v2_compaction_empty_portable_summary"] == 1 assert target.dropped["opaque:opencode_v2_providerContext"] == 1 + assert target.dropped["opaque:opencode_v2_providerState"] == 1 assert b'"encrypted"' not in target.native_bytes diff --git a/tests/test_opencode_v2_review.py b/tests/test_opencode_v2_review.py new file mode 100644 index 0000000..187981f --- /dev/null +++ b/tests/test_opencode_v2_review.py @@ -0,0 +1,620 @@ +"""Regression coverage for native v2 content and validation before projection.""" + +import json +import os +import subprocess +from dataclasses import replace +from pathlib import Path + +import pytest + +from session_migrate.conversion import ConversionOptions, convert_session, load_session +from session_migrate.errors import SessionMigrateError +from session_migrate.formats import opencode +from session_migrate.model import AgentFormat, EventKind, TargetFormat + +FIXTURES = Path(__file__).parent / "fixtures" +IMAGE_A = "data:image/png;base64,iVBORw0KGgo=" +IMAGE_B = "data:image/jpeg;base64,/9j/2Q==" + + +def source_bundle(tmp_path): + source = load_session(FIXTURES / "codex-0.144.4/basic.jsonl", AgentFormat.CODEX) + return json.loads(convert_session(source, options(tmp_path)).native_bytes) + + +def options(tmp_path, release="2.0.23"): + return ConversionOptions( + TargetFormat.OPENCODE, + target_cli_version=release, + cwd=tmp_path, + model_provider="fixture", + model="test", + ) + + +def tool(bundle): + return next( + c + for m in bundle["messages"] + if m["type"] == "assistant" + for c in m["content"] + if c["type"] == "tool" + ) + + +def rewrite(bundle, tmp_path, release="2.0.23"): + path = tmp_path / "source-v2.json" + path.write_text(json.dumps(bundle)) + source = opencode.parse_session(path) + artifact = convert_session(source, options(tmp_path, release)) + return json.loads(artifact.native_bytes), artifact.dropped, source + + +def mixed_blocks(): + # The basic fixture has no tool image; use independent synthetic images. + return [ + {"type": "text", "text": "First image: copper"}, + {"type": "file", "uri": IMAGE_A, "mime": "image/png"}, + {"type": "text", "text": "Second image: blue"}, + {"type": "file", "uri": IMAGE_B, "mime": "image/jpeg"}, + ] + + +def test_preserve_tool_block_order(tmp_path): + bundle = source_bundle(tmp_path) + blocks = mixed_blocks() + tool(bundle)["state"]["content"] = blocks + rewritten, losses, _ = rewrite(bundle, tmp_path) + assert tool(rewritten)["state"]["content"] == blocks, { + "actual": tool(rewritten)["state"]["content"], + "losses": losses, + } + + +def test_structured_error_preserved_or_counted(tmp_path): + bundle = source_bundle(tmp_path) + error = { + "type": "ConflictError", + "message": "same error text", + "status": 409, + "response": {"body": "specific diagnostic"}, + } + tool(bundle)["state"] = {"status": "error", "input": {"command": "true"}, "error": error} + rewritten, losses, _ = rewrite(bundle, tmp_path) + assert tool(rewritten)["state"]["error"] == error or any("error" in k for k in losses), { + "actual": tool(rewritten)["state"]["error"], + "losses": losses, + } + + +@pytest.mark.parametrize("mutation", ["missing-system-text", "array-title"]) +def test_malformed_native_fields_rejected_before_projection(tmp_path, mutation): + bundle = source_bundle(tmp_path) + if mutation == "array-title": + bundle["info"]["title"] = [] + else: + bundle["messages"].append( + { + "id": "msg_invalid_control", + "type": "system", + "time": {"created": bundle["info"]["time"]["updated"]}, + } + ) + with pytest.raises(SessionMigrateError): + opencode.validate_native_bytes(json.dumps(bundle).encode(), bundle["info"]["id"]) + + +@pytest.mark.parametrize("release", ["2.0.22", "2.0.23"]) +@pytest.mark.parametrize( + "blocks", + [mixed_blocks(), [{"type": "text", "text": ""}, *mixed_blocks(), {"type": "text", "text": ""}]], +) +def test_tool_blocks_preserve_segmentation_and_portable_payload(tmp_path, release, blocks): + bundle = source_bundle(tmp_path) + tool(bundle)["state"]["content"] = blocks + rewritten, losses, source = rewrite(bundle, tmp_path, release) + result = next(e for e in source.events if e.kind == EventKind.TOOL_RESULT) + expected = [ + {"type": "text", "text": b["text"]} + if b["type"] == "text" + else {"type": "image", "image_url": b["uri"]} + for b in blocks + ] + # Payload is compare=False: compare it explicitly instead of Event equality. + assert result.payload["content_blocks"] == expected + assert tool(rewritten)["state"]["content"] == blocks + assert losses == {} + + +@pytest.mark.parametrize("target", [TargetFormat.CLAUDE, TargetFormat.CODEX, TargetFormat.PI]) +def test_v2_tool_order_survives_capable_targets(tmp_path, target): + from session_migrate.formats import claude, codex, pi + + bundle = source_bundle(tmp_path) + tool(bundle)["state"]["content"] = mixed_blocks() + _, _, source = rewrite(bundle, tmp_path) + migrated = convert_session( + source, ConversionOptions(target, cwd=tmp_path, model_provider="fixture", model="test") + ) + path = tmp_path / "target.jsonl" + path.write_bytes(migrated.native_bytes) + parser = { + TargetFormat.CLAUDE: claude.parse, + TargetFormat.CODEX: codex.parse, + TargetFormat.PI: pi.parse_session, + }[target] + parsed = parser(path) + results = [e for e in parsed.events if e.kind == EventKind.TOOL_RESULT] + expected = [ + {"type": "text", "text": "First image: copper"}, + {"type": "image", "image_url": IMAGE_A}, + {"type": "text", "text": "Second image: blue"}, + {"type": "image", "image_url": IMAGE_B}, + ] + assert results[0].payload["content_blocks"] == expected + + +@pytest.mark.parametrize("target", [TargetFormat.OPENCODE, TargetFormat.KILO]) +def test_legacy_order_degradation_is_counted(tmp_path, target): + bundle = source_bundle(tmp_path) + tool(bundle)["state"]["content"] = mixed_blocks() + _, _, source = rewrite(bundle, tmp_path) + migrated = convert_session( + source, + ConversionOptions( + target, + target_cli_version="1.17.20" if target == TargetFormat.OPENCODE else "7.5.0", + cwd=tmp_path, + ), + ) + value = json.loads(migrated.native_bytes) + state = next(p["state"] for m in value["messages"] for p in m["parts"] if p["type"] == "tool") + assert state["output"] == "First image: copper\nSecond image: blue" + assert [a["url"] for a in state["attachments"]] == [IMAGE_A, IMAGE_B] + assert migrated.dropped["tool_result:content_block_order"] == 1 + + +def test_tool_error_fields_have_individual_safe_provenance_and_manifest_losses(tmp_path): + bundle = source_bundle(tmp_path) + tool(bundle)["state"] = { + "status": "error", + "input": {}, + "error": { + "type": "ConflictError", + "message": "same error text", + "status": 409, + "response": {"body": "SYNTHETIC_DIAGNOSTIC_NOT_PORTABLE"}, + }, + } + rewritten, losses, source = rewrite(bundle, tmp_path) + expected = { + "opencode_v2_tool_error_type", + "opencode_v2_tool_error_status", + "opencode_v2_tool_error_response", + } + omitted = [e for e in source.events if e.kind == EventKind.OPAQUE] + assert {e.payload["reason"] for e in omitted} == expected + source_index = next( + i + for i, m in enumerate(bundle["messages"]) + if m["type"] == "assistant" and any(c["type"] == "tool" for c in m["content"]) + ) + assert all(e.provenance.record_index == source_index for e in omitted) + assert losses == {f"opaque:{reason}": 1 for reason in expected} + assert all("SYNTHETIC_DIAGNOSTIC" not in str(e.payload) for e in source.events) + assert all("SYNTHETIC_DIAGNOSTIC" not in (e.text or "") for e in source.events) + assert tool(rewritten)["state"]["error"] == {"type": "ToolError", "message": "same error text"} + artifact = convert_session(source, options(tmp_path)) + manifest = artifact.manifest(output_path=tmp_path / "target.json") + assert manifest["dropped_events"] == losses + assert "SYNTHETIC_DIAGNOSTIC" not in json.dumps(manifest) + + +CONTROLS = [ + ("system", {"text": "fixture update", "description": "fixture summary"}, ("text",)), + ("synthetic", {"text": "fixture", "description": "fixture"}, ("text",)), + ( + "skill", + {"skill": "fixture", "name": "fixture", "text": "fixture"}, + ("skill", "name", "text"), + ), + ("agent-switched", {"agent": "build", "previous": "plan"}, ("agent",)), + ( + "model-switched", + { + "model": {"id": "test", "providerID": "fixture"}, + "previous": {"id": "previous", "providerID": "fixture"}, + }, + ("model",), + ), + ( + "location-switched", + { + "location": {"directory": "/fixture"}, + "previous": {"location": {"directory": "/previous"}}, + }, + ("location",), + ), + ( + "shell", + { + "shellID": "sh_fixture", + "command": "fixture", + "status": "exited", + "exit": 0, + "output": {"output": "fixture", "cursor": 7, "size": 7, "truncated": False}, + }, + ("shellID", "command", "status"), + ), + ("idle", {"outcome": "succeeded"}, ("outcome",)), + ( + "compaction", + {"status": "running", "reason": "auto", "summary": "", "recent": ""}, + ("status", "reason", "summary", "recent"), + ), + ( + "compaction", + { + "status": "failed", + "reason": "manual", + "error": {"type": "FixtureError", "message": "fake"}, + }, + ("status", "reason", "error"), + ), +] +REQUIRED_CONTROLS = [ + (kind, fields, required) for kind, fields, keys in CONTROLS for required in keys +] + + +def append_control(bundle, kind, fields): + control = { + "id": "msg_fixture_control", + "type": kind, + "time": {"created": bundle["info"]["time"]["updated"]}, + **fields, + } + bundle["messages"].append(control) + return control + + +@pytest.mark.parametrize("kind,fields,required", REQUIRED_CONTROLS) +@pytest.mark.parametrize("malformed", ["missing", None, []]) +def test_all_control_required_fields_validated_before_projection( + tmp_path, kind, fields, required, malformed +): + bundle = source_bundle(tmp_path) + control = append_control(bundle, kind, fields) + if malformed == "missing": + control.pop(required) + else: + control[required] = malformed + data = json.dumps(bundle).encode() + path = tmp_path / "malformed.json" + path.write_bytes(data) + with pytest.raises(SessionMigrateError): + opencode.validate_native_bytes(data, bundle["info"]["id"]) + with pytest.raises(SessionMigrateError): + opencode.parse_session(path) + + +@pytest.mark.parametrize("kind,fields,required", CONTROLS) +def test_valid_control_variants_are_accepted_and_omissions_counted( + tmp_path, kind, fields, required +): + bundle = source_bundle(tmp_path) + append_control(bundle, kind, fields) + _, losses, source = rewrite(bundle, tmp_path) + reason = ( + f"opencode_v2_compaction_{fields['status']}" + if kind == "compaction" + else f"opencode_v2_{kind}_record" + ) + assert losses[f"opaque:{reason}"] == 1 + assert not any( + e.text == fields.get("text") for e in source.events if e.kind == EventKind.MESSAGE + ) + + +@pytest.mark.parametrize( + "field", + [ + "title", + "parentID", + "agent", + "model", + "outcome", + "subpath", + "metadata", + "permissions", + "fork", + "revert", + ], +) +def test_native_optional_session_fields_accept_absence_but_reject_null(tmp_path, field): + bundle = source_bundle(tmp_path) + bundle["info"].pop(field, None) + opencode.validate_native_bytes(json.dumps(bundle).encode(), bundle["info"]["id"]) + bundle["info"][field] = None + with pytest.raises(SessionMigrateError): + opencode.validate_native_bytes(json.dumps(bundle).encode(), bundle["info"]["id"]) + + +@pytest.mark.parametrize("field", ["id", "projectID", "cost", "tokens", "time", "location"]) +def test_native_required_session_fields_cannot_be_defaulted(tmp_path, field): + bundle = source_bundle(tmp_path) + bundle["info"].pop(field) + with pytest.raises(SessionMigrateError): + opencode.validate_native_bytes(json.dumps(bundle).encode(), "ses_fixture") + + +@pytest.mark.parametrize( + "mutation", + [ + "null-description", + "bad-previous-model", + "bad-previous-location", + "bad-shell-output", + "bad-clock", + "duplicate-control-id", + "bad-model-variant", + ], +) +def test_dropped_control_nested_metadata_is_validated(tmp_path, mutation): + bundle = source_bundle(tmp_path) + if mutation == "null-description": + append_control(bundle, "system", {"text": "fixture", "description": None}) + elif mutation == "bad-previous-model": + append_control( + bundle, + "model-switched", + {"model": {"id": "test", "providerID": "fixture"}, "previous": {"id": "fake"}}, + ) + elif mutation == "bad-previous-location": + append_control( + bundle, + "location-switched", + {"location": {"directory": "/fixture"}, "previous": {"location": {"directory": []}}}, + ) + elif mutation == "bad-shell-output": + append_control( + bundle, + "shell", + { + "shellID": "sh_fixture", + "command": "fixture", + "status": "exited", + "output": {"output": "fixture"}, + }, + ) + elif mutation == "bad-model-variant": + append_control( + bundle, + "model-switched", + {"model": {"id": "test", "providerID": "fixture", "variant": None}}, + ) + else: + control = append_control(bundle, "idle", {"outcome": "succeeded"}) + if mutation == "bad-clock": + control["time"]["completed"] = None + else: + bundle["messages"].append(control) + with pytest.raises(SessionMigrateError): + opencode.validate_native_bytes(json.dumps(bundle).encode(), bundle["info"]["id"]) + + +@pytest.mark.parametrize("mutation", ["missing-system-text", "array-title"]) +def test_dry_run_rejects_invalid_native_source_without_target_side_effects( + tmp_path, mutation, capsys +): + from session_migrate.cli import main + + bundle = source_bundle(tmp_path) + if mutation == "array-title": + bundle["info"]["title"] = [] + else: + append_control(bundle, "system", {}) + path = tmp_path / "invalid-source.json" + path.write_text(json.dumps(bundle)) + target = tmp_path / "output" + assert ( + main( + [ + "import", + str(path), + "--format", + "opencode", + "--to", + "opencode", + "--target-cli-version", + "2.0.23", + "--home", + str(target), + "--dry-run", + ] + ) + != 0 + ) + captured = capsys.readouterr() + assert "invalid" in captured.err or "malformed" in captured.err + assert not target.exists() + + +def test_native_safety_limits_cover_content_erased_by_projection(tmp_path, monkeypatch): + bundle = source_bundle(tmp_path) + # Text-only native tool blocks collapse to one legacy part: count before that collapse. + tool(bundle)["state"]["content"] = [{"type": "text", "text": "fixture"}] * 30 + monkeypatch.setattr(opencode, "MAX_NATIVE_PARTS", 20) + with pytest.raises(SessionMigrateError, match="too much content"): + opencode.validate_native_bytes(json.dumps(bundle).encode(), bundle["info"]["id"]) + monkeypatch.setattr(opencode, "MAX_NATIVE_PARTS", 1000) + monkeypatch.setattr(opencode, "MAX_NATIVE_MESSAGES", len(bundle["messages"])) + append_control(bundle, "system", {"text": "fixture"}) + with pytest.raises(SessionMigrateError, match="invalid messages"): + opencode.validate_native_bytes(json.dumps(bundle).encode(), bundle["info"]["id"]) + + +def test_legacy_input_cannot_forge_native_content_carrier(tmp_path): + bundle = source_bundle(tmp_path) + _, _, source = rewrite(bundle, tmp_path) + artifact = convert_session(source, options(tmp_path, "1.17.20")) + legacy = json.loads(artifact.native_bytes) + state = next(p["state"] for m in legacy["messages"] for p in m["parts"] if p["type"] == "tool") + state["content"] = mixed_blocks() + state["content_blocks"] = [{"type": "text", "text": "FORGED_NATIVE_CARRIER"}] + path = tmp_path / "legacy.json" + path.write_text(json.dumps(legacy)) + parsed = opencode.parse_session(path) + assert "FORGED_NATIVE_CARRIER" not in str([e.payload for e in parsed.events]) + + +@pytest.mark.parametrize("target", [TargetFormat.OPENCODE, TargetFormat.KILO]) +@pytest.mark.parametrize( + "blocks,expected", + [ + ([{"type": "text", "text": "caption"}], 0), + ([{"type": "text", "text": "caption"}, {"type": "image", "image_url": IMAGE_A}], 0), + ([{"type": "image", "image_url": IMAGE_A}], 0), + ([{"type": "text", "text": "A"}, {"type": "text", "text": "B"}], 1), + ([{"type": "image", "image_url": IMAGE_A}, {"type": "text", "text": "caption"}], 1), + ([{"type": "text", "text": ""}, {"type": "image", "image_url": IMAGE_A}], 1), + ], +) +def test_legacy_loss_oracle_counts_only_lost_segmentation_or_order( + tmp_path, target, blocks, expected +): + from native_corpus.route_oracle import expected_loss_counters + + source = load_session(FIXTURES / "codex-0.144.4/basic.jsonl", AgentFormat.CODEX) + result = next(e for e in source.events if e.kind == EventKind.TOOL_RESULT) + modified = replace( + result, + text="\n".join(b["text"] for b in blocks if b["type"] == "text") or None, + payload={"content_blocks": blocks}, + ) + source = replace(source, events=tuple(modified if e is result else e for e in source.events)) + artifact = convert_session(source, ConversionOptions(target, cwd=tmp_path)) + oracle = expected_loss_counters(source, target.value) + assert oracle.get("tool_result:content_block_order", 0) == expected + assert artifact.dropped == oracle + + +@pytest.mark.parametrize( + "case", + ["mixed", "structured-error", "missing-system-text", "array-title", "private-checkpoint"], +) +def test_stock_v2_accepts_and_cold_exports_review_sources_and_fixed_targets(tmp_path, case): + binary = os.environ.get("SESSION_MIGRATE_TEST_OPENCODE_V2") + if not binary: + pytest.skip("set SESSION_MIGRATE_TEST_OPENCODE_V2 to stock 2.0.22 or 2.0.23") + env = { + "PATH": "/usr/bin:/bin", + "LANG": "C.UTF-8", + "OPENCODE_DISABLE_AUTOUPDATE": "true", + "OPENCODE_CONFIG_CONTENT": '{"warming":false,"disabled_providers":["opencode"]}', + } + for key in ( + "HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "TMPDIR", + ): + directory = tmp_path / key.lower() + directory.mkdir() + env[key] = str(directory) + + def run(*args): + result = subprocess.run( + [binary, *args], cwd=tmp_path, env=env, capture_output=True, text=True, timeout=90 + ) + assert result.returncode == 0, result.stderr + return result.stdout + + assert run("--version").strip().removeprefix("opencode v") in {"2.0.22", "2.0.23"} + bundle = source_bundle(tmp_path) + if case == "mixed": + tool(bundle)["state"]["content"] = mixed_blocks() + elif case == "structured-error": + tool(bundle)["state"] = { + "status": "error", + "input": {}, + "error": { + "type": "ConflictError", + "message": "same error text", + "status": 409, + "response": {"body": "specific synthetic diagnostic"}, + }, + } + elif case == "array-title": + bundle["info"]["title"] = [] + elif case == "private-checkpoint": + bundle["messages"] = [m for m in bundle["messages"] if m["type"] != "compaction"] + append_control( + bundle, + "compaction", + { + "status": "completed", + "reason": "auto", + "summary": "", + "recent": "", + "providerState": {"encrypted": "SYNTHETIC_PRIVATE_CHECKPOINT"}, + "providerContext": { + "version": 1, + "provenance": { + "providerID": "fixture", + "provider": "fixture", + "modelID": "test", + "route": "fixture", + "protocol": "fixture", + "endpoint": "fixture-digest", + }, + "messages": [], + }, + }, + ) + else: + append_control(bundle, "system", {}) + path = tmp_path / "stock-source.json" + path.write_text(json.dumps(bundle)) + if case in {"missing-system-text", "array-title"}: + rejected = subprocess.run( + [binary, "session", "import", str(path), "--directory", str(tmp_path), "--standalone"], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=90, + ) + assert rejected.returncode != 0 + assert "SchemaError" in rejected.stderr + return + run("session", "import", str(path), "--directory", str(tmp_path), "--standalone") + accepted = json.loads(run("session", "export", bundle["info"]["id"], "--standalone")) + assert tool(accepted)["state"] == tool(bundle)["state"] + path.write_text(json.dumps(accepted)) + source = opencode.parse_session(path) + migrated = convert_session(source, options(tmp_path)) + path.write_bytes(migrated.native_bytes) + run("session", "import", str(path), "--directory", str(tmp_path), "--standalone") + exported = json.loads(run("session", "export", migrated.session_id, "--standalone")) + if case == "mixed": + assert tool(exported)["state"]["content"] == mixed_blocks() + # Stock may add its default model variant on import; this existing + # metadata omission is independent of the ordered tool result. + expected_losses = ( + {"opaque:opencode_v2_session_model_variant": 1} + if accepted["info"].get("model", {}).get("variant") + else {} + ) + assert migrated.dropped == expected_losses + elif case == "structured-error": + assert tool(exported)["state"]["error"]["message"] == "same error text" + assert migrated.dropped["opaque:opencode_v2_tool_error_response"] == 1 + else: + assert not any(m["type"] == "compaction" for m in exported["messages"]) + for reason in ("providerContext", "providerState", "compaction_empty_portable_summary"): + assert migrated.dropped[f"opaque:opencode_v2_{reason}"] == 1 + assert "SYNTHETIC_PRIVATE_CHECKPOINT" not in migrated.native_bytes.decode()