Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/additional-target-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ and same-format portable-rewrite targets in `session-migrate` 0.9.0.
| --- | --- | --- | --- |
| Pi | `0.80.6` | Write native v3 JSONL | Stable pinned adapter |
| Oh My Pi | `18.0.5` | Write native fixed-title-slot v3 JSONL | Stable pinned adapter |
| OpenCode | `1.17.20` | Official `export`/`import` CLI | Stable pinned adapter |
| OpenCode | `1.19.4` | Official `export`/`import` CLI | Stable pinned adapter |
| GitHub Copilot CLI | `1.0.70` | Write public session-event schema | Stable pinned adapter |
| Antigravity CLI | `1.1.16` | Clean-room SQLite/protobuf DB | Stable version-pinned adapter |
| Cursor Agent | `2026.03.20-44cb435` | Clean-room content-addressed SQLite/protobuf graph | Experimental, text only |
Expand Down Expand Up @@ -100,7 +100,7 @@ actual TUI trajectory also completed two live turns using a schema-translated
copy of existing Codex OAuth; credential translation is test-only and is not a
migration feature.

## OpenCode 1.17.20
## OpenCode 1.19.4

OpenCode exposes the strongest public contract in this group:

Expand Down
7 changes: 3 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,9 @@ 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
Automatic Kilo, Antigravity, Cursor, and Hermes installation is stricter:
metadata overrides cannot bypass exact runtime version checks or
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.

Expand Down
4 changes: 2 additions & 2 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ 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 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
installed CLI unless explicitly overridden: the 1.x `1.19.0+` family 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.
Expand Down
4 changes: 2 additions & 2 deletions docs/credential-free-native-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ No production credential is read, translated, or written by this workflow.
| MastraCode 0.37.1 | OpenAI-compatible override; replay and append |
| 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 1.19.4 | 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 |
Expand Down Expand Up @@ -91,5 +91,5 @@ The additional `opencode-v2` job pins `@opencode/cli@2.0.23` and sets
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.
to 1.19.4.
See [the v2 transfer contract](opencode-v2.md).
2 changes: 1 addition & 1 deletion docs/format-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ 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 legacy source and target | `1.17.20` |
| OpenCode legacy source and target | `1.19.0` and later 1.x releases; exact releases beyond `1.19.4` accepted with warnings |
| 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` |
Expand Down
4 changes: 2 additions & 2 deletions docs/opencode-source-exploration.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,13 @@ session sizes. The byte limit remains the primary memory bound.

## Native evidence

`tests/fixtures/opencode-source-1.17.20/comprehensive.json` is synthetic and
`tests/fixtures/opencode-source-1.19.4/comprehensive.json` is synthetic and
schema-valid. It covers text, system context, an image, reasoning, a completed
tool with an image attachment, compacted-result metadata, compaction, and
several intentionally nonportable native parts.

The native test performs this credential-free trajectory against the exact
installed 1.17.20 binary in a temporary HOME/XDG store:
pinned binary (`opencode.PINNED_OPENCODE_VERSION`) in a temporary HOME/XDG store:

1. official import of the comprehensive bundle;
2. read-only `opencode db` count check: 1 session, 4 messages, 13 top-level
Expand Down
6 changes: 3 additions & 3 deletions docs/opencode-v2.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 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.
Legacy OpenCode 1.x clients use `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
Expand All @@ -16,7 +16,7 @@ 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
ordered blocks. Legacy OpenCode 1.x 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
Expand Down
6 changes: 4 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,10 @@ Do not delete native sessions merely to satisfy lookup.

## OpenCode source or target errors

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`;
OpenCode source transfer and target import accept 1.x releases `1.19.0`
and later plus the `2.0.x` transfer schema family. Every accepted 1.x release
keeps the same public bundle shape; exact releases beyond the validated
`1.19.4` receive warnings. 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.
Expand Down
2 changes: 1 addition & 1 deletion scripts/install-native-test-clis.sh
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ if want mastracode; then
SESSION_MIGRATE_MASTRACODE_BIN node_modules/.bin/mastracode
fi
if want opencode; then
npm_cli opencode 'opencode-ai@1.17.20' \
npm_cli opencode 'opencode-ai@1.19.4' \
SESSION_MIGRATE_OPENCODE_BIN node_modules/.bin/opencode
fi
if want opencode-v2; then
Expand Down
9 changes: 5 additions & 4 deletions src/session_migrate/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,8 @@ def load_opencode_session(
observed_version = _opencode_version(cli, values)
if not opencode.supported_version(observed_version):
raise SessionMigrateError(
"OpenCode source CLI version mismatch: expected "
f"{opencode.PINNED_OPENCODE_VERSION} or supported 2.0.x, observed {observed_version}"
"OpenCode source CLI version mismatch: expected 1.19+ 1.x or supported 2.0.x, "
f"observed {observed_version}"
)
temporary_root = values.get("TMPDIR")
with tempfile.TemporaryDirectory(
Expand Down Expand Up @@ -1428,7 +1428,8 @@ def install_opencode_artifact(
values.setdefault("OPENCODE_DISABLE_PRUNE", "true")
if not opencode.supported_version(artifact.target_cli_version):
raise SessionMigrateError(
"automatic OpenCode import requires target metadata version 1.17.20 or supported 2.0.x"
"automatic OpenCode import requires target metadata version 1.19+ 1.x "
"or supported 2.0.x"
)
cli = _resolve_opencode_cli(target_cli, values)
observed_version = _opencode_version(cli, values)
Expand Down Expand Up @@ -1860,7 +1861,7 @@ def _opencode_session_ids(cli: Path, environ: Mapping[str, str]) -> set[str]:
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
# OpenCode 1.x emits an empty stream, rather than ``[]``, when
# its freshly initialized store has no sessions.
if not completed.stdout.strip():
return set()
Expand Down
17 changes: 14 additions & 3 deletions src/session_migrate/formats/opencode.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""OpenCode 1.17.20 and 2.0 public import/export adapters.
"""OpenCode 1.x (1.19+) 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.
Expand All @@ -23,8 +23,12 @@
from session_migrate.jsonl import file_sha256
from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session

PINNED_OPENCODE_VERSION = "1.17.20"
PINNED_OPENCODE_VERSION = "1.19.4"
OPENCODE_NATIVE_IMPORT_SUPPORTED = True
# Legacy 1.x releases keep the same public export, import, and session-list
# shape, so the 1.19+ family is accepted.
_LEGACY_MIN_VERSION = (19, 0)
_LEGACY_VERSION = re.compile(r"(?:opencode v)?1\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F1: _LEGACY_VERSION uses unbounded (\d+) groups. Combined with int() in is_legacy (line 65), a version component longer than CPython's int_max_str_digits limit (4300) raises an uncaught ValueError. Reproduced via CLI: import <src> --to opencode --target-cli-version "1.<5000 nines>.0" --dry-run exits 1 with a raw ValueError traceback (main exits 2 with a clean version-mismatch error); a fake source CLI printing the same string crashes the source gate at conversion.py:197 the same way. Fix: bound the groups (e.g. (\d{1,9})) and add a regression test.

— pi 1.4.0 · mistral/mistral-large-4 · @xhluca-agent

OPENCODE_IMPORT_MEDIA_TYPES = {"image/gif", "image/jpeg", "image/png", "image/webp"}
MAX_NATIVE_BYTES = 256 * 1024 * 1024
MAX_NATIVE_MESSAGES = 1_000_000
Expand Down Expand Up @@ -54,8 +58,15 @@ def is_v2(value: str | None) -> bool:
return value is not None and opencode_v2.version(value) is not None


def is_legacy(value: str | None) -> bool:
"""Whether a 1.x release keeps the pinned legacy public bundle shape."""

match = None if value is None else _LEGACY_VERSION.fullmatch(value)
return match is not None and (int(match[1]), int(match[2])) >= _LEGACY_MIN_VERSION

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

F1 (crash site): int(match[1])/int(match[2]) on the unbounded regex groups from line 31 raises ValueError for >4300-digit components, which no caller of supported_version() catches (conversion.py:197, :1429, :1436, :1850). Verified: merge rc=1 traceback vs baseline rc=2 clean error for the identical input. Fix: wrap the conversion (return False on ValueError) or bound the digits in the regex at line 31.

— pi 1.4.0 · mistral/mistral-large-4 · @xhluca-agent



def supported_version(value: str | None) -> bool:
return value == PINNED_OPENCODE_VERSION or is_v2(value)
return is_legacy(value) or is_v2(value)


def session_id_from_uuid(value: str) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"projectID": "global",
"directory": "/tmp/session-migrate-fixture",
"title": "SYNTHETIC_IMPORTED_NAME",
"version": "1.17.20",
"version": "1.19.4",
"time": {
"created": 1787054400000,
"updated": 1787054403000
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"id": "fixture-model",
"providerID": "fixture"
},
"version": "1.17.20",
"version": "1.19.4",
"metadata": {
"synthetic": true
},
Expand Down
5 changes: 5 additions & 0 deletions tests/native_corpus/route_oracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,11 @@ def assert_artifact_warning_contract(artifact: ConversionArtifact, *, same_forma
f"warnings={dropped_warnings!r}, dropped={artifact.dropped!r}"
)
expected_other = ["same_format_portable_rewrite"] if same_format else []
if (
artifact.source.source_format.value == "opencode"
and artifact.source.cli_version != opencode.PINNED_OPENCODE_VERSION
):
expected_other.append("unvalidated_source_version")
if other_codes != expected_other:
raise AssertionError(
f"unexpected non-loss warning codes: expected={expected_other!r}, "
Expand Down
4 changes: 2 additions & 2 deletions tests/test_additional_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ def portable_session(tmp_path: Path, *, compaction: bool = False) -> Session:
),
(
opencode.parse,
FIXTURES / "opencode-1.17.20" / "basic.json",
FIXTURES / "opencode-1.19.4" / "basic.json",
"ses_11111111111141118111111111111111",
8,
),
Expand Down Expand Up @@ -571,7 +571,7 @@ def test_opencode_rejects_invalid_json_metadata_and_message_time(tmp_path: Path)
with pytest.raises(SessionMigrateError, match="required metadata"):
opencode.parse(invalid_metadata)

value = json.loads((FIXTURES / "opencode-1.17.20" / "basic.json").read_text())
value = json.loads((FIXTURES / "opencode-1.19.4" / "basic.json").read_text())
value["messages"][0]["info"]["time"]["created"] = "yesterday"
invalid_time = tmp_path / "time.json"
invalid_time.write_text(json.dumps(value), encoding="utf-8")
Expand Down
92 changes: 53 additions & 39 deletions tests/test_additional_formats_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,52 @@ def log_message(self, format: str, *args: object) -> None:
del format, args


def fixture_loopback_config(port: int) -> dict[str, Any]:
return {
"model": "fixture/fixture-model",
"provider": {
"fixture": {
"npm": "@ai-sdk/openai-compatible",
"name": "Synthetic loopback",
"options": {
"baseURL": f"http://127.0.0.1:{port}/v1",
"apiKey": "synthetic-not-a-secret",
},
"models": {
"fixture-model": {
"name": "Synthetic fixture model",
"attachment": True,
"modalities": {"input": ["text", "image"], "output": ["text"]},
}
},
}
},
}


def require_fixture_provider(binary: str, env: dict[str, str]) -> None:
"""Skip when a machine-level OpenCode config hides the fixture provider.

OpenCode merges config from OS-level locations (for example
``/Library/Application Support/opencode`` on macOS) that cannot be
redirected with environment variables, and an ``enabled_providers``
whitelist there hides the synthetic loopback provider.
"""
probe_env = {**env, "OPENCODE_CONFIG_CONTENT": json.dumps(fixture_loopback_config(9))}
completed = subprocess.run(
[binary, "models", "--pure"],
env=probe_env,
check=False,
capture_output=True,
text=True,
timeout=60,
)
if completed.returncode != 0:
pytest.fail(f"opencode models probe failed: {completed.stderr.strip()}")
if "fixture/fixture-model" not in completed.stdout:
pytest.skip("a machine-level OpenCode config hides the fixture provider")


@contextmanager
def loopback_server() -> Iterator[tuple[ThreadingHTTPServer, type[LoopbackHandler]]]:
handler = LoopbackHandler
Expand All @@ -315,11 +361,12 @@ def loopback_server() -> Iterator[tuple[ThreadingHTTPServer, type[LoopbackHandle
thread.join(timeout=5)


def test_opencode_11720_official_import_and_loopback_resume(tmp_path: Path) -> None:
def test_opencode_official_import_and_loopback_resume(tmp_path: Path) -> None:
binary = exact_binary("opencode", opencode.PINNED_OPENCODE_VERSION, OPENCODE_FALLBACK)
work = tmp_path / "work"
work.mkdir()
env = isolated_env(tmp_path)
require_fixture_provider(binary, env)
data, dropped = opencode.serialize(
portable_session(work),
session_id=TARGET_OPENCODE_ID,
Expand Down Expand Up @@ -371,30 +418,10 @@ def test_opencode_11720_official_import_and_loopback_resume(tmp_path: Path) -> N

with loopback_server() as (server, handler):
port = server.server_address[1]
config = {
"model": "fixture/fixture-model",
"provider": {
"fixture": {
"npm": "@ai-sdk/openai-compatible",
"name": "Synthetic loopback",
"options": {
"baseURL": f"http://127.0.0.1:{port}/v1",
"apiKey": "synthetic-not-a-secret",
},
"models": {
"fixture-model": {
"name": "Synthetic fixture model",
"attachment": True,
"modalities": {
"input": ["text", "image"],
"output": ["text"],
},
}
},
}
},
resume_env = {
**env,
"OPENCODE_CONFIG_CONTENT": json.dumps(fixture_loopback_config(port)),
}
resume_env = {**env, "OPENCODE_CONFIG_CONTENT": json.dumps(config)}
resumed = subprocess.run(
[
binary,
Expand Down Expand Up @@ -559,6 +586,7 @@ def test_opencode_native_replay_preserves_source_order_with_decreasing_timestamp
work = tmp_path / "work"
work.mkdir()
env = isolated_env(tmp_path)
require_fixture_provider(binary, env)
base = portable_session(work)
source = replace(
base,
Expand Down Expand Up @@ -603,20 +631,6 @@ def test_opencode_native_replay_preserves_source_order_with_decreasing_timestamp

with loopback_server() as (server, handler):
port = server.server_address[1]
config = {
"model": "fixture/fixture-model",
"provider": {
"fixture": {
"npm": "@ai-sdk/openai-compatible",
"name": "Synthetic loopback",
"options": {
"baseURL": f"http://127.0.0.1:{port}/v1",
"apiKey": "synthetic-not-a-secret",
},
"models": {"fixture-model": {"name": "Synthetic fixture model"}},
}
},
}
resumed = subprocess.run(
[
binary,
Expand All @@ -631,7 +645,7 @@ def test_opencode_native_replay_preserves_source_order_with_decreasing_timestamp
"--pure",
],
cwd=work,
env={**env, "OPENCODE_CONFIG_CONTENT": json.dumps(config)},
env={**env, "OPENCODE_CONFIG_CONTENT": json.dumps(fixture_loopback_config(port))},
check=False,
capture_output=True,
text=True,
Expand Down
2 changes: 1 addition & 1 deletion tests/test_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ def _insert_opencode_session(
updated: int = 1_787_054_400_000,
) -> None:
connection.execute(
"INSERT INTO session VALUES (?, 'global', ?, 'synthetic', ?, ?, '1.17.20', ?, ?, ?)",
"INSERT INTO session VALUES (?, 'global', ?, 'synthetic', ?, ?, '1.19.4', ?, ?, ?)",
(
session_id,
parent_id,
Expand Down
Loading
Loading