From 3cb8dd0976cb68d16dc59653f4409c99fafe1cd9 Mon Sep 17 00:00:00 2001 From: "J.H.S. Schlepers" Date: Wed, 7 Oct 2026 22:14:02 +0200 Subject: [PATCH 1/2] feat(codex): project self-contained paginated child histories --- CHANGELOG.md | 7 +- docs/format-compatibility.md | 27 +- docs/troubleshooting.md | 11 +- src/session_migrate/catalog.py | 42 ++- src/session_migrate/formats/codex.py | 182 ++++++++++++- tests/test_catalog.py | 8 +- tests/test_codex_paginated_history_mode.py | 8 +- tests/test_codex_subagent_projection.py | 289 +++++++++++++++++++++ 8 files changed, 553 insertions(+), 21 deletions(-) create mode 100644 tests/test_codex_subagent_projection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d75a84..d8007d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,12 @@ here. Native format compatibility is documented separately in - Read root Codex rollouts written with `history_mode: "paginated"` by using canonical completed user and agent turn items while keeping provider envelopes out of the portable transcript. -- Reject paginated history bases, subagent projections, mixed or conflicting +- Support complete self-contained paginated subagent projections using primary + child metadata and the exact ordinal boundary; exclude inherited parent + context, preserve readable targeted correspondence, and account for encrypted + fragments without restoring live multi-agent orchestration. Catalog discovery + agrees with the bounded reader. +- Reject external paginated history bases, invalid/incomplete child projections, mixed or conflicting history modes, unknown modes, and non-contiguous ordinals instead of guessing at a resumable conversation. - Add a sanitized real-structure Codex 0.153.4 paginated fixture and regressions diff --git a/docs/format-compatibility.md b/docs/format-compatibility.md index 25e070d..35f2051 100644 --- a/docs/format-compatibility.md +++ b/docs/format-compatibility.md @@ -294,6 +294,24 @@ reasoning data. Direct image/audio user inputs are retained when represented by the canonical TurnItem. Local media paths and other TurnItem blocks remain explicit opaque losses. +Self-contained paginated subagent rollouts are also supported. The first session +metadata identifies the child and declares `subagent_history_start_ordinal`. +Every ordinal must remain contiguous from zero and the entire inherited prefix +must be present. Parent records below the boundary, including inherited metadata, +messages, tools, and compactions, are excluded and counted explicitly. Child +tools/results retain their linkage; a result referring only to an inherited +parent call is excluded rather than synthesized as child activity. + +Readable `agent_message` fragments addressed exactly to the child's `agent_path` +are retained as ordinary user messages, without system/developer privileges. +Encrypted fragments and envelope/routing metadata are explicit omissions. +Foreign recipients and ambiguous envelopes remain opaque; malformed targeted +blocks fail closed. Duplicate suppression requires an authoritative completed +message with the same ID, role, and exact text. No decryption is attempted, and +partial plaintext does not establish recovery of the complete task payload. +The result is an independent conversation, not restored live parent/child +orchestration. Catalog discovery applies the same bounded reader checks. + Current legacy rollouts can contain `compacted.replacement_history`. Codex installs that array as the effective history at the checkpoint and replays only later items. In a bounded structural audit, replacement arrays ended in a @@ -312,14 +330,14 @@ Those records are not all portable conversation history. Every ordered pair among the eighteen formats is implemented, for 324 routes: -- full portable adapters: Claude, Codex legacy and root paginated, Pi, OMP, OpenCode, Copilot, +- full portable adapters: Claude, Codex legacy and self-contained paginated, Pi, OMP, OpenCode, Copilot, Antigravity, Vibe, Muse, Qwen, Kimi, Grok, Kilo, OpenHands, Hermes, MastraCode, and Devin; - experimental text-only adapter: Cursor. Same-format routes are portable rewrites into new sessions, not byte copies. -Codex `history_base` lineage and paginated subagent projections remain -fail-closed. Cursor is experimental, build-pinned, and deliberately transfers +Codex external `history_base` lineage remains fail-closed. Incomplete or invalid +child projection boundaries are also rejected. Cursor is experimental, build-pinned, and deliberately transfers only ordered user/assistant text. The detailed table below explains the original Claude/Codex pair; target-specific behavior is documented in [Additional native formats](additional-target-formats.md) @@ -361,7 +379,8 @@ Legend: | Claude sidechains/subagents | **Unsupported** | N/A | The catalog indexes nested sidechains as unsupported, but direct lookup/conversion does not import them; transfer the parent session. | | Codex legacy linear history | N/A | **Supported** | Ordered response items become one linear Claude UUID graph. | | Codex root paginated history | N/A | **Supported** | Contiguous canonical completed TurnItems supply user/assistant turns; provider-context messages are not replayed. Replacement-history compaction uses the expanded-transcript policy above. | -| Codex paginated forks/subagents | N/A | **Unsupported** | `history_base` and `subagent_history_start_ordinal` require external or projected history and are rejected rather than silently truncating or duplicating a conversation. | +| Codex self-contained paginated subagents | N/A | **Supported** | Complete ordinal prefixes are validated; inherited parent context is excluded and readable child correspondence is retained with explicit private-state omissions. Imports are independent conversations. | +| Codex external history bases/forks | N/A | **Unsupported** | `history_base` requires external lineage resolution and is rejected rather than silently truncating a conversation. | | Codex UI-only messages | N/A | **Lossy fallback** | Used as the conversation when no response-item messages exist. In a mixed partial file, exact normalized duplicates are removed and unmatched projections are retained with `message:ui_only_projection`; fuzzy matching is never used. | | Turn context, policies, world state, snapshots | **Unsupported** | **Unsupported** | Codex `turn_context` is counted as context; `world_state` and `security_risk_score` become counted opaque events. Shell snapshots, approvals, external credential stores, MCP state, memories, goals, and configuration are outside transcript conversion. | | Unknown source records/blocks | **Unsupported** | **Unsupported** | They become content-free opaque/sentinel events where recognized and are counted at write time, including unknown nested tool-result blocks. | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a3e3bdc..c77d05a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -265,10 +265,13 @@ credentials. See the [Hermes](hermes-format.md), A complete root paginated rollout is supported when every record has an integer ordinal contiguous from zero. Missing/gapped ordinals are treated as corrupt. -`history_base` and `subagent_history_start_ordinal` mean the file depends on an -external or projected prefix and remain unsupported; `--format codex` cannot -bypass either guard. Resume or export the root session instead of flattening -the dependent file by hand. +Self-contained child rollouts with `subagent_history_start_ordinal` are supported +when their inherited prefix is complete. The reader excludes that prefix and +retains the child's projected history and readable incoming correspondence. +Encrypted message fragments remain explicit omissions. Invalid boundaries, +incomplete ordinal streams, and external `history_base` lineage are rejected; +`--format codex` cannot bypass those checks. An imported child is an independent +conversation and does not recreate live multi-agent orchestration. ## Claude sidechain/subagent diff --git a/src/session_migrate/catalog.py b/src/session_migrate/catalog.py index 2c34fe1..e2e2433 100644 --- a/src/session_migrate/catalog.py +++ b/src/session_migrate/catalog.py @@ -2079,6 +2079,7 @@ def _scan_file(path: Path, agent_format: AgentFormat, root: Path) -> _Scan: history_mode = None history_base = False codex_subagent_history = False + codex_child_start: int | None = None codex_ordinals_complete = True codex_history_mode_conflict = False codex_selected_history_mode = None @@ -2130,7 +2131,22 @@ def _scan_file(path: Path, agent_format: AgentFormat, root: Path) -> _Scan: payload = value.get("payload") if not isinstance(payload, dict): payload = {} + if ( + codex_child_start is not None + and records > 1 + and isinstance(ordinal, int) + and ordinal < codex_child_start + ): + continue if record_type == "session_meta": + if records == 1: + boundary = payload.get("subagent_history_start_ordinal") + if ( + isinstance(boundary, int) + and not isinstance(boundary, bool) + and boundary >= 0 + ): + codex_child_start = boundary has_session_meta = True session_id = session_id or _normalized_uuid( _string(payload.get("id")) or _string(payload.get("session_id")) @@ -2295,10 +2311,32 @@ def _scan_file(path: Path, agent_format: AgentFormat, root: Path) -> _Scan: elif agent_format == AgentFormat.CODEX: if not has_session_meta: status, reason = "corrupt", "missing_session_meta" + elif codex_subagent_history: + # The bounded reader owns child metadata/projection validation. + # Parsed content is transient and never stored in the catalog. + try: + child = codex.parse(path) + except SessionMigrateError as exc: + if "history_base" in str(exc): + status, reason = "unsupported", "codex_history_base" + elif "history mode" in str(exc) and "not supported" in str(exc): + status, reason = "unsupported", "codex_history_mode" + else: + status, reason = "corrupt", "codex_subagent_projection" + else: + if not any( + e.kind + in { + EventKind.MESSAGE, + EventKind.TOOL_CALL, + EventKind.TOOL_RESULT, + EventKind.COMPACTION, + } + for e in child.events + ): + status, reason = "corrupt", "no_conversation_records" elif history_base: status, reason = "unsupported", "codex_history_base" - elif codex_subagent_history: - status, reason = "unsupported", "codex_subagent_history" elif codex_history_mode_conflict: status, reason = "corrupt", "codex_history_mode_conflict" elif ( diff --git a/src/session_migrate/formats/codex.py b/src/session_migrate/formats/codex.py index e691aae..d35a0f2 100644 --- a/src/session_migrate/formats/codex.py +++ b/src/session_migrate/formats/codex.py @@ -29,8 +29,31 @@ def parse(path: Path) -> Session: records = list(iter_jsonl(path)) history_mode = _history_mode(records) + child_start = _subagent_history_start(records) if history_mode == "paginated": _validate_paginated_root(records) + if child_start is not None and child_start > len(records): + raise SessionMigrateError("Codex subagent inherited prefix is incomplete") + primary_meta = object_value(records[0].value.get("payload")) if records else {} + child_path = string(primary_meta.get("agent_path")) + child_records = [r for r in records if child_start is None or r.value["ordinal"] >= child_start] + canonical_messages = _canonical_message_ids(child_records) if child_start is not None else {} + inherited_calls = { + string(object_value(r.value.get("payload")).get("call_id")) + for r in records + if child_start is not None + and r.value["ordinal"] < child_start + and string(r.value.get("type")) == "response_item" + and string(object_value(r.value.get("payload")).get("type")) + in {"function_call", "custom_tool_call"} + } + child_calls = { + string(object_value(r.value.get("payload")).get("call_id")) + for r in child_records + if string(r.value.get("type")) == "response_item" + and string(object_value(r.value.get("payload")).get("type")) + in {"function_call", "custom_tool_call"} + } events: list[Event] = [] fallback_events: list[Event] = [] context_compacted_events: list[Event] = [] @@ -50,6 +73,9 @@ def parse(path: Path) -> Session: timestamp = string(value.get("timestamp")) payload = object_value(value.get("payload")) provenance = Provenance(record.index, record_type) + if child_start is not None and record.index != 0 and value["ordinal"] < child_start: + events.append(_projection_omission("subagent_inherited_prefix", timestamp, provenance)) + continue if record_type == "session_meta": session_id = ( session_id or string(payload.get("id")) or string(payload.get("session_id")) @@ -61,7 +87,20 @@ def parse(path: Path) -> Session: model_provider = model_provider or string(payload.get("model_provider")) continue if record_type == "response_item": - if history_mode == "paginated" and string(payload.get("type")) in { + item_type = string(payload.get("type")) + if child_start is not None and item_type == "agent_message": + parsed = _subagent_message_events( + payload, child_path, canonical_messages, timestamp, provenance + ) + elif ( + child_start is not None + and item_type in {"function_call_output", "custom_tool_call_output"} + and string(payload.get("call_id")) in inherited_calls - child_calls + ): + parsed = [ + _projection_omission("subagent_inherited_tool_result", timestamp, provenance) + ] + elif history_mode == "paginated" and item_type in { "message", "agent_message", }: @@ -230,10 +269,20 @@ def parse(path: Path) -> Session: def _history_mode(records: list[Any]) -> str: selected_mode = "legacy" metadata_seen = False + child_start = _subagent_history_start(records) + if child_start is not None: + _validate_paginated_root(records) + primary_id = None for record in records: if string(record.value.get("type")) != "session_meta": continue payload = object_value(record.value.get("payload")) + if ( + child_start is not None + and record.index != 0 + and record.value.get("ordinal", child_start) < child_start + ): + continue history_mode = string(payload.get("history_mode")) or "legacy" if history_mode not in SUPPORTED_HISTORY_MODES: raise SessionMigrateError( @@ -242,10 +291,29 @@ def _history_mode(records: list[Any]) -> str: ) if payload.get("history_base") is not None: raise SessionMigrateError("Codex history_base lineage is not supported") - if payload.get("subagent_history_start_ordinal") is not None: + if child_start is None and payload.get("subagent_history_start_ordinal") is not None: raise SessionMigrateError( - "Codex paginated subagent history projection is not supported" + "Codex subagent boundary must be declared by primary metadata" ) + if child_start is not None: + if history_mode != "paginated": + raise SessionMigrateError( + "Codex subagent history projection requires paginated mode" + ) + current_id = string(payload.get("id")) or string(payload.get("session_id")) + if not metadata_seen: + primary_id = current_id + elif current_id != primary_id: + raise SessionMigrateError("Codex child metadata changes session identity") + boundary = payload.get("subagent_history_start_ordinal", child_start) + if ( + isinstance(boundary, bool) + or not isinstance(boundary, int) + or boundary != child_start + ): + raise SessionMigrateError( + "Codex child metadata changes history projection boundary" + ) if metadata_seen and history_mode != selected_mode: raise SessionMigrateError("Codex session metadata has conflicting history modes") selected_mode = history_mode @@ -257,6 +325,114 @@ def _history_mode(records: list[Any]) -> str: return selected_mode +def _subagent_history_start(records: list[Any]) -> int | None: + if not records or string(records[0].value.get("type")) != "session_meta": + return None + start = object_value(records[0].value.get("payload")).get("subagent_history_start_ordinal") + if start is not None and (isinstance(start, bool) or not isinstance(start, int) or start < 0): + raise SessionMigrateError("Codex subagent history boundary must be a nonnegative integer") + return start + + +def _projection_omission(reason: str, timestamp: str | None, provenance: Provenance) -> Event: + return Event( + kind=EventKind.OPAQUE, + timestamp=timestamp, + payload={"reason": reason}, + provenance=provenance, + ) + + +def _canonical_message_ids(records: list[Any]) -> dict[str, tuple[Role, str]]: + result = {} + for record in records: + payload = object_value(record.value.get("payload")) + if ( + string(record.value.get("type")) != "event_msg" + or string(payload.get("type")) != "item_completed" + ): + continue + item = object_value(payload.get("item")) + if string(item.get("type")) not in {"UserMessage", "AgentMessage"}: + continue + item_id = string(item.get("id")) + if not item_id: + continue + blocks = item.get("content") + if not isinstance(blocks, list): + continue + text = "\n".join( + string(block.get("text")) or "" + for block in blocks + if isinstance(block, dict) and block.get("type") in {"text", "Text"} + ) + projection = (Role.USER if item.get("type") == "UserMessage" else Role.ASSISTANT, text) + if item_id in result and result[item_id] != projection: + raise SessionMigrateError("Codex canonical message ID has conflicting content") + result[item_id] = projection + return result + + +def _subagent_message_events( + payload: dict[str, Any], + child_path: str | None, + canonical: dict[str, tuple[Role, str]], + timestamp: str | None, + provenance: Provenance, +) -> list[Event]: + if ( + not child_path + or payload.get("recipient") != child_path + or not string(payload.get("author")) + ): + return [ + _projection_omission("subagent_foreign_or_ambiguous_message", timestamp, provenance) + ] + blocks = payload.get("content") + if not isinstance(blocks, list): + raise SessionMigrateError("Codex child agent-message content must be an array") + texts = [] + losses = [_projection_omission("subagent_message_envelope", timestamp, provenance)] + for block in blocks: + if not isinstance(block, dict): + raise SessionMigrateError("Codex child agent-message block must be an object") + kind = block.get("type") + if kind == "input_text" and isinstance(block.get("text"), str): + texts.append(block["text"]) + elif kind == "encrypted_content" and isinstance(block.get("encrypted_content"), str): + losses.append( + _projection_omission("subagent_encrypted_message_part", timestamp, provenance) + ) + else: + raise SessionMigrateError( + "Codex child agent-message block has unsupported or malformed content" + ) + text = "\n".join(texts) + item_id = string(payload.get("id")) + if item_id in canonical: + if (Role.USER, text) != canonical[item_id]: + raise SessionMigrateError("Codex child envelope and canonical message ID disagree") + losses.append( + _projection_omission("subagent_canonical_message_duplicate", timestamp, provenance) + ) + return losses + if not text.strip(): + losses.append( + _projection_omission("subagent_message_without_readable_text", timestamp, provenance) + ) + return losses + return [ + Event( + kind=EventKind.MESSAGE, + role=Role.USER, + text=text, + timestamp=timestamp, + provenance=provenance, + ), + *losses, + ] + + def _validate_paginated_root(records: list[Any]) -> None: """Fail closed if a root rollout is not a complete canonical ordinal stream.""" diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 03d019d..ba6864c 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -398,8 +398,8 @@ def test_codex_paginated_catalog_fails_closed_on_incomplete_projections( cases = { "40000000-0000-4000-8000-000000000001": ("codex_history_base", "unsupported"), "40000000-0000-4000-8000-000000000002": ( - "codex_subagent_history", - "unsupported", + "codex_subagent_projection", + "corrupt", ), "40000000-0000-4000-8000-000000000003": ( "codex_paginated_ordinals", @@ -422,8 +422,8 @@ def test_codex_paginated_catalog_fails_closed_on_incomplete_projections( "end_ordinal_exclusive": 2, "end_byte_offset": 512, } - elif reason == "codex_subagent_history": - meta["subagent_history_start_ordinal"] = 2 + elif reason == "codex_subagent_projection": + meta["subagent_history_start_ordinal"] = 1000 elif reason == "codex_paginated_ordinals": records[1]["ordinal"] = 99 elif reason == "codex_history_mode": diff --git a/tests/test_codex_paginated_history_mode.py b/tests/test_codex_paginated_history_mode.py index d4399a8..baa256f 100644 --- a/tests/test_codex_paginated_history_mode.py +++ b/tests/test_codex_paginated_history_mode.py @@ -123,13 +123,15 @@ def mutate(records): codex.parse(path) -def test_paginated_subagent_projection_still_refused(tmp_path: Path) -> None: +def test_paginated_subagent_projection_excludes_inherited_call(tmp_path: Path) -> None: def mutate(records): records[0]["payload"]["subagent_history_start_ordinal"] = 8 path = _rewrite_fixture(tmp_path, mutate) - with pytest.raises(SessionMigrateError, match="subagent history projection"): - codex.parse(path) + session = codex.parse(path) + assert not any(e.kind == EventKind.TOOL_CALL for e in session.events) + assert not any(e.kind == EventKind.TOOL_RESULT for e in session.events) + assert any(e.payload.get("reason") == "subagent_inherited_tool_result" for e in session.events) def test_paginated_metadata_must_be_first_and_consistent(tmp_path: Path) -> None: diff --git a/tests/test_codex_subagent_projection.py b/tests/test_codex_subagent_projection.py new file mode 100644 index 0000000..373071c --- /dev/null +++ b/tests/test_codex_subagent_projection.py @@ -0,0 +1,289 @@ +"""Self-contained child rollouts preserve child data without replaying parent context.""" + +import copy +import json +from pathlib import Path + +import pytest + +from session_migrate.conversion import ConversionOptions, convert_session +from session_migrate.errors import SessionMigrateError +from session_migrate.formats import codex, opencode +from session_migrate.model import AgentFormat, EventKind, Role, TargetFormat + +FIXTURE = Path(__file__).parent / "fixtures/codex-0.153.4/paginated.jsonl" +CHILD_ID = "60000000-0000-4000-8000-000000000001" +CHILD_PATH = "/root/child" + + +def records(): + original = [json.loads(line) for line in FIXTURE.read_text().splitlines()] + primary = copy.deepcopy(original[0]) + primary["payload"].update(id=CHILD_ID, session_id=CHILD_ID, agent_path=CHILD_PATH, cwd="/child") + inherited = copy.deepcopy(original[:8]) + inherited[0]["payload"]["history_mode"] = "legacy" + inherited[7]["payload"]["call_id"] = "parent_call" + inherited[7]["payload"]["arguments"] = '{"secret":"PARENT PRIVATE INPUT"}' + primary["payload"]["subagent_history_start_ordinal"] = 1 + len(inherited) + seed = { + "type": "response_item", + "timestamp": "2026-08-17T13:00:00Z", + "payload": { + "type": "agent_message", + "id": "task_seed", + "author": "/root", + "recipient": CHILD_PATH, + "content": [ + {"type": "input_text", "text": "Child task — λ\nkeep exact"}, + {"type": "encrypted_content", "encrypted_content": "PRIVATE CIPHERTEXT"}, + ], + }, + } + result = [primary, *inherited, seed, *copy.deepcopy(original[1:])] + for ordinal, item in enumerate(result): + item["ordinal"] = ordinal + return result + + +def write(tmp_path, value): + path = tmp_path / "child.jsonl" + path.write_text("".join(json.dumps(item) + "\n" for item in value)) + return path + + +def test_child_metadata_plaintext_and_tool_image_linkage_roundtrip(tmp_path): + value = records() + session = codex.parse(write(tmp_path, value)) + start = value[0]["payload"]["subagent_history_start_ordinal"] + assert session.session_id == CHILD_ID + assert session.cwd == Path("/child") + messages = [e for e in session.events if e.kind == EventKind.MESSAGE] + assert messages[0].role == Role.USER + assert messages[0].text == "Child task — λ\nkeep exact" + assert messages[0].provenance.record_index == start + calls = [e for e in session.events if e.kind == EventKind.TOOL_CALL] + results = [e for e in session.events if e.kind == EventKind.TOOL_RESULT] + assert [e.tool_call_id for e in calls] == ["call_fixture_1"] + assert [e.tool_call_id for e in results] == ["call_fixture_1"] + target = convert_session( + session, + ConversionOptions( + target_format=TargetFormat.OPENCODE, + target_cli_version="2.0.23", + cwd=tmp_path, + model_provider="fixture", + model="test", + ), + ) + assert b"PARENT PRIVATE INPUT" not in target.native_bytes + assert b"PRIVATE CIPHERTEXT" not in target.native_bytes + assert target.dropped["opaque:subagent_inherited_prefix"] == start - 1 + assert target.dropped["opaque:subagent_encrypted_message_part"] == 1 + assert target.dropped["opaque:subagent_message_envelope"] == 1 + path = tmp_path / "v2.json" + path.write_bytes(target.native_bytes) + reopened = opencode.parse_import(path) + assert any(e.text == messages[0].text and e.role == Role.USER for e in reopened.events) + assert [e.tool_call_id for e in reopened.events if e.kind == EventKind.TOOL_CALL] == [ + "call_fixture_1" + ] + assert any(e.payload.get("image_url") for e in reopened.events if e.kind == EventKind.CONTEXT) + assert any( + e.payload.get("content_blocks") for e in reopened.events if e.kind == EventKind.TOOL_RESULT + ) + + +@pytest.mark.parametrize( + "recipient,author", [("/foreign", "/root"), (CHILD_PATH, None), ("/root", CHILD_PATH)] +) +def test_foreign_outbound_or_ambiguous_correspondence_is_opaque(tmp_path, recipient, author): + value = records() + seed = value[value[0]["payload"]["subagent_history_start_ordinal"]]["payload"] + seed.update(recipient=recipient, author=author) + session = codex.parse(write(tmp_path, value)) + assert not any(e.text == "Child task — λ\nkeep exact" for e in session.events) + assert any( + e.payload.get("reason") == "subagent_foreign_or_ambiguous_message" for e in session.events + ) + + +@pytest.mark.parametrize( + "block", + [None, {"type": "input_text", "text": 3}, {"type": "encrypted_content"}, {"type": "future"}], +) +def test_malformed_targeted_envelope_fails_closed(tmp_path, block): + value = records() + value[value[0]["payload"]["subagent_history_start_ordinal"]]["payload"]["content"] = [block] + with pytest.raises(SessionMigrateError, match="agent-message"): + codex.parse(write(tmp_path, value)) + + +@pytest.mark.parametrize("boundary", [True, -1, "9", 100000]) +def test_invalid_or_truncated_prefix_fails_closed(tmp_path, boundary): + value = records() + value[0]["payload"]["subagent_history_start_ordinal"] = boundary + with pytest.raises(SessionMigrateError, match="boundary|incomplete"): + codex.parse(write(tmp_path, value)) + + +def test_complete_prefix_with_empty_child_suffix_is_valid_but_not_resumable(tmp_path): + value = records() + value = value[: value[0]["payload"]["subagent_history_start_ordinal"]] + session = codex.parse(write(tmp_path, value)) + assert not any(e.kind != EventKind.OPAQUE for e in session.events) + with pytest.raises(SessionMigrateError): + convert_session( + session, + ConversionOptions( + target_format=TargetFormat.OPENCODE, target_cli_version="2.0.23", cwd=tmp_path + ), + ) + + +def test_empty_encrypted_seed_does_not_block_child_history(tmp_path): + value = records() + seed = value[value[0]["payload"]["subagent_history_start_ordinal"]]["payload"] + seed["content"] = [{"type": "encrypted_content", "encrypted_content": "PRIVATE CIPHERTEXT"}] + session = codex.parse(write(tmp_path, value)) + assert any( + e.payload.get("reason") == "subagent_message_without_readable_text" for e in session.events + ) + assert any(e.kind == EventKind.TOOL_CALL for e in session.events) + + +def test_envelope_duplicate_requires_matching_id_and_exact_content(tmp_path): + value = records() + seed = value[value[0]["payload"]["subagent_history_start_ordinal"]]["payload"] + canonical = next( + r["payload"]["item"] + for r in value + if r.get("type") == "event_msg" + and r["payload"].get("item", {}).get("type") == "UserMessage" + ) + seed["id"] = canonical["id"] + seed["content"] = [{"type": "input_text", "text": canonical["content"][0]["text"]}] + session = codex.parse(write(tmp_path, value)) + assert any( + e.payload.get("reason") == "subagent_canonical_message_duplicate" for e in session.events + ) + seed["content"][0]["text"] = "Conflicting message" + with pytest.raises(SessionMigrateError, match="canonical message ID disagree"): + codex.parse(write(tmp_path, value)) + + +def test_parent_tool_result_in_child_is_not_replayed(tmp_path): + value = records() + item = { + "type": "response_item", + "payload": { + "type": "function_call_output", + "call_id": "parent_call", + "output": "PARENT SECRET OUTPUT", + }, + "ordinal": len(value), + } + value.append(item) + session = codex.parse(write(tmp_path, value)) + assert not any(e.text == "PARENT SECRET OUTPUT" for e in session.events) + assert any(e.payload.get("reason") == "subagent_inherited_tool_result" for e in session.events) + + +@pytest.mark.parametrize( + "change", ["id", "boundary", "boolean_boundary", "mode", "history_base", "ordinal"] +) +def test_child_metadata_updates_and_external_lineage_fail_closed(tmp_path, change): + value = records() + extra = copy.deepcopy(value[0]) + extra["ordinal"] = len(value) + value.append(extra) + if change == "id": + extra["payload"]["id"] = "other_child" + elif change == "boundary": + extra["payload"]["subagent_history_start_ordinal"] += 1 + elif change == "boolean_boundary": + extra["payload"]["subagent_history_start_ordinal"] = True + elif change == "mode": + extra["payload"]["history_mode"] = "legacy" + elif change == "history_base": + value[0]["payload"]["history_base"] = {"end_ordinal_exclusive": 100} + else: + value[4]["ordinal"] = "4" + with pytest.raises( + SessionMigrateError, match="identity|boundary|paginated|history_base|ordinal" + ): + codex.parse(write(tmp_path, value)) + + +def test_same_shaped_nonauthoritative_payload_cannot_suppress_child_task(tmp_path): + value = records() + seed = value[value[0]["payload"]["subagent_history_start_ordinal"]]["payload"] + value.append( + { + "ordinal": len(value), + "type": "world_state", + "payload": { + "type": "item_completed", + "item": { + "type": "UserMessage", + "id": seed["id"], + "content": [{"type": "text", "text": "conflicting unrelated payload"}], + }, + }, + } + ) + session = codex.parse(write(tmp_path, value)) + assert any( + e.kind == EventKind.MESSAGE and e.text == "Child task — λ\nkeep exact" + for e in session.events + ) + + +def test_same_id_opposite_canonical_role_cannot_suppress_incoming_task(tmp_path): + value = records() + seed = value[value[0]["payload"]["subagent_history_start_ordinal"]]["payload"] + value.append( + { + "ordinal": len(value), + "type": "event_msg", + "payload": { + "type": "item_completed", + "item": { + "type": "AgentMessage", + "id": seed["id"], + "content": [{"type": "Text", "text": "Child task — λ\nkeep exact"}], + }, + }, + } + ) + with pytest.raises(SessionMigrateError, match="canonical message ID disagree"): + codex.parse(write(tmp_path, value)) + + +def test_boundary_only_in_later_metadata_is_rejected(tmp_path): + value = records() + value[0]["payload"].pop("subagent_history_start_ordinal") + value[1]["payload"]["subagent_history_start_ordinal"] = 9 + with pytest.raises(SessionMigrateError, match="primary metadata"): + codex.parse(write(tmp_path, value)) + + +@pytest.mark.parametrize("kind", ["valid", "external", "invalid", "empty"]) +def test_catalog_classification_matches_bounded_child_parser(tmp_path, kind): + from session_migrate.catalog import _scan_file + + value = records() + if kind == "external": + value[0]["payload"]["history_base"] = {"end_ordinal_exclusive": 10} + elif kind == "invalid": + value[0]["payload"]["subagent_history_start_ordinal"] = True + elif kind == "empty": + value = value[: value[0]["payload"]["subagent_history_start_ordinal"]] + path = write(tmp_path, value) + scan = _scan_file(path, AgentFormat.CODEX, tmp_path) + expected = { + "valid": ("candidate", None), + "external": ("unsupported", "codex_history_base"), + "invalid": ("corrupt", "codex_subagent_projection"), + "empty": ("corrupt", "no_conversation_records"), + } + assert (scan.status, scan.reason) == expected[kind] From 68eb557d53e27969f7587272b7a28756c48d2bc9 Mon Sep 17 00:00:00 2001 From: "J.H.S. Schlepers" Date: Fri, 9 Oct 2026 19:01:16 +0200 Subject: [PATCH 2/2] test(codex): validate child projection with independent legacy native transfer --- scripts/run-native-test-client.sh | 1 + tests/test_codex_subagent_projection.py | 63 +++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/scripts/run-native-test-client.sh b/scripts/run-native-test-client.sh index d2c3dc7..b2906e6 100755 --- a/scripts/run-native-test-client.sh +++ b/scripts/run-native-test-client.sh @@ -95,6 +95,7 @@ case "$client" in tests/test_additional_formats_native.py::test_opencode_11720_official_import_and_loopback_resume tests/test_additional_formats_native.py::test_opencode_cli_import_uses_official_importer_and_rejects_native_collision tests/test_additional_formats_native.py::test_opencode_native_replay_preserves_source_order_with_decreasing_timestamps + tests/test_codex_subagent_projection.py::test_pinned_legacy_client_accepts_child_projection_without_parent_context 'tests/test_opencode_kilo_corpus_native.py::test_exact_client_captures_from_empty_through_public_surfaces[opencode]' 'tests/test_opencode_kilo_corpus_native.py::test_exact_client_cold_import_export_and_continuation_preserve_prefix[opencode]' ) diff --git a/tests/test_codex_subagent_projection.py b/tests/test_codex_subagent_projection.py index 373071c..1a1bfef 100644 --- a/tests/test_codex_subagent_projection.py +++ b/tests/test_codex_subagent_projection.py @@ -69,7 +69,7 @@ def test_child_metadata_plaintext_and_tool_image_linkage_roundtrip(tmp_path): session, ConversionOptions( target_format=TargetFormat.OPENCODE, - target_cli_version="2.0.23", + target_cli_version="1.17.20", cwd=tmp_path, model_provider="fixture", model="test", @@ -80,7 +80,7 @@ def test_child_metadata_plaintext_and_tool_image_linkage_roundtrip(tmp_path): assert target.dropped["opaque:subagent_inherited_prefix"] == start - 1 assert target.dropped["opaque:subagent_encrypted_message_part"] == 1 assert target.dropped["opaque:subagent_message_envelope"] == 1 - path = tmp_path / "v2.json" + path = tmp_path / "legacy.json" path.write_bytes(target.native_bytes) reopened = opencode.parse_import(path) assert any(e.text == messages[0].text and e.role == Role.USER for e in reopened.events) @@ -135,7 +135,7 @@ def test_complete_prefix_with_empty_child_suffix_is_valid_but_not_resumable(tmp_ convert_session( session, ConversionOptions( - target_format=TargetFormat.OPENCODE, target_cli_version="2.0.23", cwd=tmp_path + target_format=TargetFormat.OPENCODE, target_cli_version="1.17.20", cwd=tmp_path ), ) @@ -287,3 +287,60 @@ def test_catalog_classification_matches_bounded_child_parser(tmp_path, kind): "empty": ("corrupt", "no_conversation_records"), } assert (scan.status, scan.reason) == expected[kind] + + +def test_pinned_legacy_client_accepts_child_projection_without_parent_context(tmp_path): + import os + + from session_migrate.conversion import install_opencode_artifact, load_opencode_session + + binary = os.environ.get("SESSION_MIGRATE_OPENCODE_BIN") + if not binary: + pytest.skip("set SESSION_MIGRATE_OPENCODE_BIN to the pinned 1.17.20 executable") + env = { + "PATH": "/usr/bin:/bin", + "OPENCODE_DISABLE_AUTOUPDATE": "true", + "OPENCODE_CONFIG_CONTENT": '{"disabled_providers":["opencode"]}', + } + for key in [ + "HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_CACHE_HOME", + "TMPDIR", + ]: + folder = tmp_path / key.lower() + folder.mkdir() + env[key] = str(folder) + source = codex.parse(write(tmp_path, records())) + artifact = convert_session( + source, + ConversionOptions( + target_format=TargetFormat.OPENCODE, + target_cli_version="1.17.20", + cwd=tmp_path, + model_provider="fixture", + model="test", + ), + ) + install_opencode_artifact( + artifact, manifest_path=tmp_path / "manifest.json", target_cli=Path(binary), environ=env + ) + reopened = load_opencode_session(artifact.session_id, source_cli=Path(binary), environ=env) + assert any( + e.kind == EventKind.MESSAGE and e.text == "Child task — λ\nkeep exact" + for e in reopened.events + ) + assert any( + e.kind == EventKind.TOOL_CALL and e.tool_call_id == "call_fixture_1" + for e in reopened.events + ) + assert any( + e.kind == EventKind.TOOL_RESULT and e.tool_call_id == "call_fixture_1" + for e in reopened.events + ) + assert not any( + "PARENT PRIVATE INPUT" in str(e.payload) or "PRIVATE CIPHERTEXT" in str(e.payload) + for e in reopened.events + )