From 36712c2a07f275573985b0f12d276cf49929c9c9 Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon Date: Thu, 8 Oct 2026 20:04:09 +0000 Subject: [PATCH 1/2] chore(deps): move the harbor oracle to 0.24.0 with a cooldown exemption harbor 0.24.0 (PyPI upload 2026-10-05T05:04:46Z) changes both converters atif-sql holds parity with: #3434 keeps Claude subagent ids on every ATIF step, #3467 records Codex structured tool outputs as text and images, and #2972 gives Codex web search calls an id. The ATIF models, the validator and RFC 0001 are byte-identical to 0.23.0. The workspace's seven-day cooldown admits 0.24.0 only from 2026-10-12T05:05Z, so harbor alone gets an exclude-newer-package exemption up to 2026-10-05T05:05:00Z, the instant its upload needs; the global window stays seven days. Delete the exemption in the next `uv lock --upgrade` after 2026-10-12, when the window reaches the release. `uv lock --upgrade-package harbor` moves harbor alone and adds semver 3.1.0, a new harbor dependency uploaded 2026-09-12; every other package stays where main locks it. uv 0.12.23 writes lockfile revision 5 on any re-resolution. The parity decisions for the three converter changes follow in the next commit; on this commit alone the oracle tests report them as failures. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 4 ++++ uv.lock | 21 +++++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f2aa376..590d3f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,10 @@ resolution = "highest" # No square bracket may appear in this comment: the Semgrep rule reads the table as # everything up to the next one. exclude-newer = "7 days" +# harbor 0.24.0, the parity oracle, was uploaded 2026-10-05T05:04:46Z and is admitted +# here from that upload on. Delete this exemption in the next `uv lock --upgrade` +# after 2026-10-12T05:05Z, when the global window reaches it. +exclude-newer-package = { harbor = "2026-10-05T05:05:00Z" } python-preference = "only-managed" python-downloads = "automatic" compile-bytecode = true diff --git a/uv.lock b/uv.lock index bee7b98..8bf2121 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", @@ -10,6 +10,9 @@ resolution-markers = [ exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P7D" +[options.exclude-newer-package] +harbor = "2026-10-05T05:05:00Z" + [manifest] members = [ "atif-analytics", @@ -1149,7 +1152,7 @@ wheels = [ [[package]] name = "harbor" -version = "0.23.0" +version = "0.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dirhash" }, @@ -1168,6 +1171,7 @@ dependencies = [ { name = "pyyaml" }, { name = "requests" }, { name = "rich" }, + { name = "semver" }, { name = "shortuuid" }, { name = "supabase" }, { name = "tenacity" }, @@ -1175,9 +1179,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/3a/b05d0e8752d5d531e56fbb2955f0e656c0a9b5fbcd202dbad7313b29d4df/harbor-0.23.0.tar.gz", hash = "sha256:a8b87e98db59877228bb36d2c24757cc422a3144ffaf31a1f88955c65c911e4d", size = 1907464, upload-time = "2026-09-12T04:55:11.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/7d/16e21cfd52938260dce71ff77ec1ed156bd8c327642760fe66163b0cd3ce/harbor-0.24.0.tar.gz", hash = "sha256:6c5185e6a76abc677ed3f95e688dc0dbf8597e4fa50847cb9a37a4448c91b1e5", size = 2090577, upload-time = "2026-10-05T05:04:46.252Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/c7/607ff037dff1f40d1f941b9854742d66fd43630274fd4e7b8de8480dad34/harbor-0.23.0-py3-none-any.whl", hash = "sha256:8747400dbb2a5e2298e1338e17e88eba38433c0433fd700f34d1a9021bba5c37", size = 2142100, upload-time = "2026-09-12T04:55:10.014Z" }, + { url = "https://files.pythonhosted.org/packages/9e/63/0e3d788e8d1ed116125507c6a01e3fb95cf82be71f2ea7040a1458709d21/harbor-0.24.0-py3-none-any.whl", hash = "sha256:23b7ba616a3aae4eff561ced5e2c51c7186f2981e53a770dea9c689d3969877c", size = 2331740, upload-time = "2026-10-05T05:04:42.088Z" }, ] [[package]] @@ -2637,6 +2641,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/ce/828467ddfa0d2fe473673026442d2032d552a168e42cfbf25fd0e5264e0c/scantree-0.0.4-py3-none-any.whl", hash = "sha256:7616ab65aa6b7f16fcf8e6fa1d9afaa99a27ab72bba05c61b691853b96763174", size = 20690, upload-time = "2024-08-03T20:08:58.137Z" }, ] +[[package]] +name = "semver" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/f5/e1dfe8e1d91c54ce212fd93916eb01fd1c590f413be0a0978c39a97aa1bb/semver-3.1.0.tar.gz", hash = "sha256:14bc073439513d7773662a338f4db9829cf16c12b74b9568e2d2689975fbd7fc", size = 304162, upload-time = "2026-09-12T18:38:14.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/02/bce7a1d70e9d0f6ea8623c16386138c251b5855af6cd3a11433d3fba4e66/semver-3.1.0-py3-none-any.whl", hash = "sha256:6e4998f8b4762acdfea85f9bfd5b4032e0bb50038dc04bf82011b42d5d1145c5", size = 18783, upload-time = "2026-09-12T18:38:12.457Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" From 793087b407d694bd7a855e99bb1dc98748c4c764 Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon Date: Thu, 8 Oct 2026 20:21:16 +0000 Subject: [PATCH 2/2] fix(converter): hold parity with harbor 0.24.0's converters harbor 0.24.0 changes three things the converters port. Each is decided here against the upstream diff. - harbor #3434 (6a410975), adopted: every step a Claude Code event becomes, its prompt, tool result and turn alike, carries `is_sidechain` and the event's `agentId` as `extra.agent_id`, right after `id`, through `_event_identity_extra`. harbor now reads `agentId`, so the oracle's one deliberate divergence (`steps[i].extra.agent_id` only in ours) is gone, and a snake_case `agent_id` key names no subagent, as in harbor. - harbor #3467 (d286d15d), adopted with one divergence: a Codex tool output that is a list of MCP content blocks or content items becomes its text parts joined by newlines rather than Python's str() of the list (5,124 such outputs in the 144 local rollouts); a non-string `output` is its JSON text; a JSON scalar keeps its raw text. An inline image, which harbor saves under `/images/` and returns as content parts, is the blob placeholder the pre-pass writes for the same item, because the converter is pure and the corpus keeps attachments in its blob store. The oracle forgives exactly that content path, only when each placeholder names the bytes harbor saved (`_image_content_forgiven`). - harbor #2972 (7e042f6e), adopted: a Codex `web_search_call` keeps its item id, or gets `_web_search_`, where its id was empty. `trial/sync_trajectory.py` (#3467) copies media for streamed trials and 536f533a (`CODEX_HOME` out of tmp, after 0.24.0) sets up the runtime home; neither touches a conversion. The vendored ATIF models, validator and RFC are byte-identical to 0.24.0's, so only their headers and `UPSTREAM_VERSION` move, which `meta.json` stamps as `harbor_version`. CONVERTER_SCHEMA_VERSION 7 re-converts the corpus for all of it; the digest is re-pinned. The goldens do not change: the synthetic fixtures exercise none of the three shapes, and the live oracle still matches them. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- CONTRIBUTING.md | 4 +- docs/CONTRACT.md | 4 +- packages/atif-converter/README.md | 4 +- packages/atif-converter/pyproject.toml | 2 +- .../atif_converter/domain/atif/__init__.py | 4 +- .../src/atif_converter/domain/atif/agent.py | 2 +- .../src/atif_converter/domain/atif/content.py | 2 +- .../domain/atif/final_metrics.py | 2 +- .../src/atif_converter/domain/atif/metrics.py | 2 +- .../atif_converter/domain/atif/observation.py | 2 +- .../domain/atif/observation_result.py | 2 +- .../src/atif_converter/domain/atif/step.py | 2 +- .../domain/atif/subagent_trajectory_ref.py | 2 +- .../atif_converter/domain/atif/tool_call.py | 2 +- .../atif_converter/domain/atif/trajectory.py | 2 +- .../domain/atif/trajectory_validator.py | 2 +- .../domain/claude_code_conversion.py | 38 ++-- .../atif_converter/domain/codex_conversion.py | 164 +++++++++++++- .../atif_converter/domain/schema_version.py | 14 +- .../infrastructure/harbor_adapter.py | 2 +- .../atif-converter/tests/harbor_oracle.py | 72 +++++- .../tests/test_claude_code_conversion.py | 82 ++++++- .../tests/test_codex_conversion_port.py | 208 +++++++++++++++++- .../tests/test_converter_schema_version.py | 4 +- .../tests/test_result_signals.py | 27 ++- 26 files changed, 570 insertions(+), 85 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed2d4e1..c25cd5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,12 +91,12 @@ Rules of the road: `src/` imports nothing from either, and `packages/atif-converter/tests/test_dev_only_imports_guard.py` fails on any import, in any member. The ATIF data classes and the validator are harbor - 0.23.0's, vendored under Apache-2.0 in `atif_converter.domain.atif` (each + 0.24.0's, vendored under Apache-2.0 in `atif_converter.domain.atif` (each file byte for byte upstream apart from its header and import path; ruff and ty skip the directory so it stays that way). The raw-JSONL → `Trajectory` conversion is ours (`atif_converter.domain.claude_code_conversion`, `domain.codex_conversion`, ported from harbor and held to parity with - 0.23.0). harbor survives in the tests + 0.24.0). harbor survives in the tests as two oracles: its private converters in `packages/atif-converter/tests/harbor_oracle.py` (frozen goldens under `tests/goldens/`, plus a live-corpus parity test), and diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c91d5d3..d084174 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -146,14 +146,14 @@ member's `src/` imports either one. **The ATIF models are vendored.** The data classes from `harbor.models.trajectories` (RFC 0001) and `harbor.utils.trajectory_validator` live in -`atif_converter.domain.atif`, copied from harbor 0.23.0 under Apache-2.0. Each file +`atif_converter.domain.atif`, copied from harbor 0.24.0 under Apache-2.0. Each file carries an attribution header and is otherwise upstream's file byte for byte, with the import path rewritten; ruff and ty skip the directory so nobody reformats it into ours. `UPSTREAM_VERSION` in its `__init__` records the release, and `meta.json` stamps it as `harbor_version`. The conversion from a Claude Code session or a Codex rollout to a `Trajectory` is ours, in `atif_converter.domain.claude_code_conversion` and `atif_converter.domain.codex_conversion`, ported from harbor and held to parity -with harbor 0.23.0. +with harbor 0.24.0. harbor itself is used in two places, both tests: diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index b85c68e..185c911 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -187,10 +187,10 @@ proofs are out of scope for the workspace. ## Converter (atif-converter owns the conversion; harbor's ATIF models are the contract) 0. src/ imports nothing from harbor (or litellm); both are dev dependencies. The ATIF data classes (harbor.models.trajectories, RFC 0001) and - harbor.utils.trajectory_validator are vendored from harbor 0.23.0 + harbor.utils.trajectory_validator are vendored from harbor 0.24.0 (Apache-2.0) as atif_converter.domain.atif and held to upstream by a conformance test. The raw-JSONL -> Trajectory conversion for both agents is - ours, ported from harbor 0.23.0 and held to PARITY with it by an oracle in + ours, ported from harbor and held to PARITY with harbor 0.24.0 by an oracle in atif-converter's tests: frozen goldens per synthetic fixture, plus a live-corpus diff. 1. Side-file discovery: every *.jsonl under / (including diff --git a/packages/atif-converter/README.md b/packages/atif-converter/README.md index c02b744..ef8236a 100644 --- a/packages/atif-converter/README.md +++ b/packages/atif-converter/README.md @@ -1,11 +1,11 @@ # atif-converter Converts Claude Code session JSONL and Codex rollouts into ATIF trajectories -(converters ported from harbor and held to parity with harbor 0.23.0, on harbor's ATIF models vendored in +(converters ported from harbor and held to parity with harbor 0.24.0, on harbor's ATIF models vendored in `atif_converter.domain.atif`), and owns the FIDELITY POLICY: the seven known upstream conversion gaps are encoded as `atif_converter.domain.fidelity.FidelityGap`, and every conversion is audited into a `LossReport` (raw-side census vs -converted output). harbor is a dev dependency only; the tests pin harbor 0.23.0 +converted output). harbor is a dev dependency only; the tests pin harbor 0.24.0 behavior and hold the vendored models to it, so they are the drift alarm for a harbor bump. diff --git a/packages/atif-converter/pyproject.toml b/packages/atif-converter/pyproject.toml index 07d5d26..c5e2d3e 100644 --- a/packages/atif-converter/pyproject.toml +++ b/packages/atif-converter/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - # pydantic for the ATIF models vendored from harbor 0.23.0 under + # pydantic for the ATIF models vendored from harbor 0.24.0 under # atif_converter.domain.atif; harbor itself and litellm are dev-only (the # root's [dependency-groups] dev), kept for the parity oracle and the # pricing identity test. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/__init__.py b/packages/atif-converter/src/atif_converter/domain/atif/__init__.py index cfbbfe3..1071b75 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/__init__.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/__init__.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/__init__.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/__init__.py # (Apache-2.0, Copyright the Harbor authors), with the provenance constants # below added. @@ -35,7 +35,7 @@ #: The distribution these files were vendored from. UPSTREAM_DISTRIBUTION = "harbor" #: The upstream release the files match; ``meta.json`` records it as ``harbor_version``. -UPSTREAM_VERSION = "0.23.0" +UPSTREAM_VERSION = "0.24.0" __all__ = [ "UPSTREAM_DISTRIBUTION", diff --git a/packages/atif-converter/src/atif_converter/domain/atif/agent.py b/packages/atif-converter/src/atif_converter/domain/atif/agent.py index 18c137b..7bc50f0 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/agent.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/agent.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/agent.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/agent.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/content.py b/packages/atif-converter/src/atif_converter/domain/atif/content.py index e232c30..ebf2573 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/content.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/content.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/content.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/content.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/final_metrics.py b/packages/atif-converter/src/atif_converter/domain/atif/final_metrics.py index 68d5bd0..f9a2190 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/final_metrics.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/final_metrics.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/final_metrics.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/final_metrics.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/metrics.py b/packages/atif-converter/src/atif_converter/domain/atif/metrics.py index bfca3e8..6220fbd 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/metrics.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/metrics.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/metrics.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/metrics.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/observation.py b/packages/atif-converter/src/atif_converter/domain/atif/observation.py index 69e7a31..b1956fb 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/observation.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/observation.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/observation.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/observation.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/observation_result.py b/packages/atif-converter/src/atif_converter/domain/atif/observation_result.py index 76114a7..514a8dd 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/observation_result.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/observation_result.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/observation_result.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/observation_result.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/step.py b/packages/atif-converter/src/atif_converter/domain/atif/step.py index 813e53f..9c61696 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/step.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/step.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/step.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/step.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/subagent_trajectory_ref.py b/packages/atif-converter/src/atif_converter/domain/atif/subagent_trajectory_ref.py index 9c73665..622c0f4 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/subagent_trajectory_ref.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/subagent_trajectory_ref.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/subagent_trajectory_ref.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/subagent_trajectory_ref.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/tool_call.py b/packages/atif-converter/src/atif_converter/domain/atif/tool_call.py index a5ac82f..60934d4 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/tool_call.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/tool_call.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/tool_call.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/tool_call.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/trajectory.py b/packages/atif-converter/src/atif_converter/domain/atif/trajectory.py index 4aa6938..01937a9 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/trajectory.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/trajectory.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/models/trajectories/trajectory.py +# Vendored from harbor 0.24.0, src/harbor/models/trajectories/trajectory.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/atif/trajectory_validator.py b/packages/atif-converter/src/atif_converter/domain/atif/trajectory_validator.py index e0e3a56..d9706a9 100644 --- a/packages/atif-converter/src/atif_converter/domain/atif/trajectory_validator.py +++ b/packages/atif-converter/src/atif_converter/domain/atif/trajectory_validator.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Vendored from harbor 0.23.0, src/harbor/utils/trajectory_validator.py +# Vendored from harbor 0.24.0, src/harbor/utils/trajectory_validator.py # (Apache-2.0, Copyright the Harbor authors). Byte for byte upstream apart # from these header lines and the import path, which reads # atif_converter.domain.atif where upstream reads harbor.models.trajectories. diff --git a/packages/atif-converter/src/atif_converter/domain/claude_code_conversion.py b/packages/atif-converter/src/atif_converter/domain/claude_code_conversion.py index c692fc2..b19931e 100644 --- a/packages/atif-converter/src/atif_converter/domain/claude_code_conversion.py +++ b/packages/atif-converter/src/atif_converter/domain/claude_code_conversion.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# Ported from harbor 0.23.0, src/harbor/agents/installed/claude_code.py +# Ported from harbor 0.24.0, src/harbor/agents/installed/claude_code.py # (Apache-2.0, Copyright the Harbor authors), onto the ATIF models vendored in # atif_converter.domain.atif, so atif-converter doesn't depend on harbor. @@ -559,7 +559,8 @@ def _collect_events( # Keep events in chronological order across the main chain and any # subagent sidechains, so the first user step remains the instruction # (downstream byte-identity checks rely on this) and step timestamps - # stay monotonic; sidechain steps are marked via `extra.is_sidechain`. + # stay monotonic; sidechain steps carry `extra.is_sidechain` and + # `extra.agent_id` so consumers can distinguish concurrent subagents. deduped_raw_events.sort(key=lambda e: e.get("timestamp", "")) return deduped_raw_events @@ -595,6 +596,20 @@ def _agent_extra(events: list[dict[str, Any]]) -> dict[str, Any] | None: return agent_extra or None +def _event_identity_extra(event: dict[str, Any]) -> dict[str, Any]: + """Keep each Claude session event's agent identity on its ATIF step. + + harbor 0.24.0's ``ClaudeCode._event_identity_extra`` (harbor #3434): every + step a session event becomes, the agent turn, a user message and a tool + result alike, carries ``is_sidechain`` and, when the event names one, the + ``agentId`` of the subagent that wrote it. + """ + extra: dict[str, Any] = {"is_sidechain": event.get("isSidechain", False)} + if agent_id := event.get("agentId"): + extra["agent_id"] = agent_id + return extra + + def _last_usage_by_msg_id(events: list[dict[str, Any]]) -> dict[str, Any]: last_usage_by_msg_id: dict[str, Any] = {} for ev in events: @@ -638,20 +653,11 @@ def _normalize_assistant_event( extra[key] = value if event.get("id"): extra["id"] = event["id"] - # DELIBERATE DIVERGENCE from harbor 0.22.0, which reads ``agent_id``: every - # transcript spells the field ``agentId`` (the key ``_agent_extra`` above - # already reads), so harbor's lookup never matched and no sidechain step - # carried its subagent's id. The snake_case spelling is still honored for - # a record that has it. The parity oracle names this divergence - # (``tests/harbor_oracle.py::DELIBERATE_DIVERGENCES``). - agent_id = event.get("agentId") or event.get("agent_id") - if agent_id: - extra["agent_id"] = agent_id + extra.update(_event_identity_extra(event)) if event.get("cwd"): extra.setdefault("cwd", event["cwd"]) if event.get("userType") and event.get("userType") != "external": extra["user_type"] = event["userType"] - extra["is_sidechain"] = event.get("isSidechain", False) model_name = message.get("model") or state.default_model_name @@ -777,7 +783,7 @@ def _normalize_tool_result_block( extra_val = call_info.get("extra") extra = extra_val if isinstance(extra_val, dict) else {} - extra["is_sidechain"] = event.get("isSidechain", False) + extra.update(_event_identity_extra(event)) if metadata: extra.setdefault("tool_result_metadata", metadata) if block.get("is_error") is not None: @@ -805,7 +811,7 @@ def _normalize_user_event( # first user step) hold; the strip is only the empty-skip filter. text = content if text.strip(): - extra = {"is_sidechain": event.get("isSidechain", False)} + extra = _event_identity_extra(event) state.normalized_events.append( { "kind": "message", @@ -860,7 +866,7 @@ def _normalize_user_event( "timestamp": timestamp, "role": "user", "text": text_message, - "extra": {"is_sidechain": event.get("isSidechain", False)}, + "extra": _event_identity_extra(event), } ) return @@ -876,7 +882,7 @@ def _normalize_user_event( "timestamp": timestamp, "role": "user", "text": text, - "extra": {"is_sidechain": event.get("isSidechain", False)}, + "extra": _event_identity_extra(event), } ) diff --git a/packages/atif-converter/src/atif_converter/domain/codex_conversion.py b/packages/atif-converter/src/atif_converter/domain/codex_conversion.py index 1c72c81..401cf03 100644 --- a/packages/atif-converter/src/atif_converter/domain/codex_conversion.py +++ b/packages/atif-converter/src/atif_converter/domain/codex_conversion.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # Ported from harbor 0.22.0, src/harbor/agents/installed/codex.py (Apache-2.0, -# Copyright the Harbor authors), onto the ATIF models vendored in +# Copyright the Harbor authors), brought to harbor 0.24.0, onto the ATIF models vendored in # atif_converter.domain.atif, so atif-converter doesn't depend on harbor. """Codex rollout records -> ATIF trajectory, as one pure function. @@ -15,7 +15,13 @@ FAITHFUL, NOT IMPROVED. Every quirk below is harbor's, kept so the two agree byte for byte; a behavior change is a decision for a later commit, made with the golden re-frozen on purpose. Names and structure follow harbor's so an -upstream diff can be re-applied by hand: +upstream diff can be re-applied by hand. harbor 0.24.0's two Codex changes +are applied: a ``web_search_call`` keeps its item id or gets +``_web_search_`` (harbor #2972), and a structured tool output (a +list of MCP content blocks or content items) becomes text rather than Python's +``str()`` of the list (harbor #3467). The one departure is an inline image in +such an output, which harbor saves to a file and this pure function writes as +its blob placeholder (:func:`_tool_output_content` says why): ================================= ========================================= harbor 0.22.0 here @@ -59,6 +65,7 @@ from __future__ import annotations import json +import re from typing import Any, Literal from loguru import logger @@ -75,6 +82,7 @@ ToolCall, Trajectory, ) +from atif_converter.domain.blobs import BlobCollector from atif_converter.domain.codex_enrichment import message_text #: The schema version harbor 0.22.0 stamps on a Codex trajectory. @@ -85,13 +93,33 @@ _UNKNOWN_VERSION = "unknown" -def _parse_output_blob(raw: Any) -> tuple[str | None, dict[str, Any] | None]: - """Extract textual output and metadata from Codex tool outputs. +#: harbor 0.24.0's tool output content types (``codex.py``, harbor #3467). +_TOOL_OUTPUT_TEXT_TYPES = frozenset({"text", "input_text"}) +_TOOL_OUTPUT_IMAGE_TYPES = frozenset({"image", "input_image"}) +_TOOL_OUTPUT_CONTENT_TYPES = ( + _TOOL_OUTPUT_TEXT_TYPES + | _TOOL_OUTPUT_IMAGE_TYPES + | frozenset({"audio", "input_audio", "resource", "resource_link", "encrypted_content"}) +) +#: The image types harbor stores; anything else is ``[image omitted]``. +_IMAGE_MEDIA_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"}) +_BASE64_DATA_URL = re.compile( + r"data:(?P[^;,]+)(?:;[^;,]*)*;base64,(?P.*)", re.DOTALL +) +#: harbor's text for an image it cannot reference. +_IMAGE_OMITTED = "[image omitted]" - harbor's ``Codex._parse_output_blob``. A JSON object yields its - ``output`` (or, when that key is missing, the whole object re-serialized) - plus its ``metadata`` when that is an object; a JSON scalar or array is - stringified; a non-JSON string is the output itself. + +def _parse_output_blob(raw: Any) -> tuple[str | None, dict[str, Any] | None]: + """Extract the observation content and metadata from a Codex tool output. + + harbor 0.24.0's ``Codex._parse_output_blob`` (harbor #3467). A JSON object + yields its ``output`` (JSON text when that is not a string, or, when the key + is missing, the whole object re-serialized) plus its ``metadata`` when that + is an object; a list of MCP content blocks or content items becomes the + text :func:`_tool_output_content` renders; any other JSON value keeps the + raw string, or its JSON text when the output was not a string; a non-JSON + string is the output itself. """ if raw is None: return None, None @@ -109,10 +137,117 @@ def _parse_output_blob(raw: Any) -> tuple[str | None, dict[str, Any] | None]: if output is None and parsed: # dumping remaining structure if output missing output = json.dumps(parsed, ensure_ascii=False) + elif output is not None and not isinstance(output, str): + output = json.dumps(output, ensure_ascii=False) metadata = parsed.get("metadata") return output, metadata if isinstance(metadata, dict) else None - return str(parsed), None + return _non_object_output(raw, parsed), None + + +def _non_object_output(raw: Any, parsed: Any) -> str: + """The text of a tool output that is not a JSON object (harbor's tail branches).""" + if _is_tool_output_content(parsed): + return _tool_output_content(parsed) + if isinstance(raw, str): + return raw + return json.dumps(parsed, ensure_ascii=False) + + +def _is_tool_output_content(value: Any) -> bool: + """Whether a tool output is a list of MCP content blocks or content items. + + harbor's ``Codex._is_tool_output_content``: a non-empty list whose every + element is an object with a known content ``type``. + """ + if not isinstance(value, list) or not value: + return False + for block in value: + if not isinstance(block, dict): + return False + block_type = block.get("type") + if not isinstance(block_type, str) or block_type not in _TOOL_OUTPUT_CONTENT_TYPES: + return False + return True + + +def _tool_output_content(blocks: list[dict[str, Any]]) -> str: + """Tool output content blocks -> the observation's text. + + harbor's ``Codex._tool_output_content``: text blocks keep their text, an + image becomes :func:`_tool_output_image_text`, and any other block (audio, + a resource, encrypted content) is kept as its JSON text; the parts are + joined by newlines. + + DELIBERATE DIVERGENCE from harbor 0.24.0 for an inline image harbor can + decode: harbor writes it to ``/images/codex_.`` + and returns a list of content parts with an image part pointing there. This + converter is pure and the corpus keeps attachments in its blob store, so the + image becomes the blob placeholder text the pre-pass + (:func:`~atif_converter.domain.blobs.extract_codex_blobs`) writes for the + same ``input_image``, and the content stays one string. The parity oracle + forgives exactly that (``tests/harbor_oracle.py::_image_content_forgiven``). + """ + return "\n".join(_tool_output_block_text(block) for block in blocks) + + +def _tool_output_block_text(block: dict[str, Any]) -> str: + block_type = block["type"] + text = block.get("text") + if block_type in _TOOL_OUTPUT_TEXT_TYPES and isinstance(text, str): + return text + if block_type in _TOOL_OUTPUT_IMAGE_TYPES: + return _tool_output_image_text(block) + return json.dumps(block, ensure_ascii=False) + + +def _tool_output_image_text(block: dict[str, Any]) -> str: + """The text for an MCP image block or an ``input_image`` item. + + harbor's ``Codex._tool_output_image_part`` and ``_save_tool_output_image``, + with the same texts for an image nobody can reference (``[image omitted]``) + and for a remote URL (``[image: ]``); an inline image harbor would save + becomes its blob placeholder (see :func:`_tool_output_content`). + """ + if block["type"] == "image": + data = block.get("data") + media_type = block.get("mimeType") or block.get("mime_type") + else: + data = block.get("image_url") + media_type = None + if not isinstance(data, str): + return _IMAGE_OMITTED + if "://" in data and not data.startswith("data:"): + return f"[image: {data}]" + + return _inline_image_text(data, media_type) + + +def _inline_image_text(data: str, media_type: Any) -> str: + """An inline image's placeholder, or harbor's text when harbor would not save it.""" + data_url = _BASE64_DATA_URL.fullmatch(data) + base64_text = data + if data_url is not None: + media_type, base64_text = data_url.group("media_type"), str(data_url.group("data")) + if not isinstance(media_type, str): + return _IMAGE_OMITTED + valid_media_type = _image_media_type(media_type) + if valid_media_type is None: + logger.debug("Unsupported Codex tool image type: {}", media_type) + return _IMAGE_OMITTED + ref = BlobCollector().add_base64("".join(base64_text.split()), valid_media_type) + if ref is None: + logger.debug("Failed to decode Codex tool image") + return _IMAGE_OMITTED + return ref.placeholder() + + +def _image_media_type(value: str) -> str | None: + """The supported image MIME type for ``value``, as harbor's ``Codex._image_media_type`` maps it.""" + media_type = value.strip().lower() + if media_type == "image/jpg": + media_type = "image/jpeg" + return media_type if media_type in _IMAGE_MEDIA_TYPES else None def _group_events_by_api_call_id( @@ -616,6 +751,15 @@ def finish_api_call(token_count_payload: dict[str, Any]) -> None: if "url" in action: arguments["url"] = action["url"] + # harbor 0.24.0 (harbor #2972): the call keeps Codex's own item id, + # or gets one unique within its request. + native_call_id = payload.get("id") + web_search_call_id = ( + native_call_id + if isinstance(native_call_id, str) and native_call_id.strip() + else f"{current_api_call_id}_web_search_{tool_order_counter}" + ) + normalized_events.append( { "kind": "tool_call", @@ -623,7 +767,7 @@ def finish_api_call(token_count_payload: dict[str, Any]) -> None: "codex_turn_id": codex_turn_id, "tool_order": tool_order_counter, "timestamp": timestamp, - "call_id": "", + "call_id": web_search_call_id, "tool_name": "web_search_call", "arguments": arguments, "raw_arguments": None, diff --git a/packages/atif-converter/src/atif_converter/domain/schema_version.py b/packages/atif-converter/src/atif_converter/domain/schema_version.py index 2e32d83..a5b01a7 100644 --- a/packages/atif-converter/src/atif_converter/domain/schema_version.py +++ b/packages/atif-converter/src/atif_converter/domain/schema_version.py @@ -53,12 +53,24 @@ them $0. The table also adds flex rates for gpt-5.1 and gpt-5.2 and moves o4-mini's flex cache-read rate. ``total_cost_usd`` and ``metrics.cost_usd`` change for every such session. +* 7 — harbor 0.24.0: ``meta.json`` records ``harbor_version`` 0.24.0. The + Claude Code port itself puts each event's ``agentId`` on every step the + event becomes (prompts, tool results and turns, which enrichment filled + before) and writes ``is_sidechain`` and ``agent_id`` right after ``id`` in + ``extra``, harbor's key order; a snake_case ``agent_id`` key names no + subagent (harbor #3434). A Codex tool output that is a list of content blocks or + items is its text parts joined by newlines, with an inline image as its blob + placeholder, where it was Python's ``str()`` of the list; a non-string + ``output`` inside an output object is its JSON text; a JSON scalar output + keeps its raw text (harbor #3467). A Codex ``web_search_call`` carries its + item id, or ``_web_search_``, where its id was empty (harbor + #2972). """ from __future__ import annotations from typing import Final -CONVERTER_SCHEMA_VERSION: Final = 6 +CONVERTER_SCHEMA_VERSION: Final = 7 __all__ = ["CONVERTER_SCHEMA_VERSION"] diff --git a/packages/atif-converter/src/atif_converter/infrastructure/harbor_adapter.py b/packages/atif-converter/src/atif_converter/infrastructure/harbor_adapter.py index aee2119..483ec13 100644 --- a/packages/atif-converter/src/atif_converter/infrastructure/harbor_adapter.py +++ b/packages/atif-converter/src/atif_converter/infrastructure/harbor_adapter.py @@ -7,7 +7,7 @@ itself isn't a runtime dependency). The raw-JSONL -> ``Trajectory`` conversion is ours, in :mod:`atif_converter.domain.claude_code_conversion` via :mod:`atif_converter.infrastructure.claude_code_converter`, ported from harbor -0.23.0 and held to parity with it by the oracle in this package's tests. +and held to parity with harbor 0.24.0 by the oracle in this package's tests. """ from __future__ import annotations diff --git a/packages/atif-converter/tests/harbor_oracle.py b/packages/atif-converter/tests/harbor_oracle.py index a2b70ac..11d3a42 100644 --- a/packages/atif-converter/tests/harbor_oracle.py +++ b/packages/atif-converter/tests/harbor_oracle.py @@ -47,13 +47,16 @@ #: a parity failure. Keep each entry narrow (a path AND a direction), so a #: divergence the decision did not cover still fails. #: -#: * ``steps[i].extra.agent_id`` present only in ours: harbor reads -#: ``event["agent_id"]``, a key no transcript carries (they spell it -#: ``agentId``), so its sidechain steps never name their subagent. See -#: ``_normalize_assistant_event`` in ``domain/claude_code_conversion.py``. -DELIBERATE_DIVERGENCES: tuple[re.Pattern[str], ...] = ( - re.compile(r"^\$\.steps\[\d+\]\.extra\.agent_id: only in ours = "), -) +#: Empty since harbor 0.24.0: harbor #3434 reads ``agentId``, so the +#: ``steps[i].extra.agent_id`` divergence this list named is gone and every +#: step's subagent id must agree with harbor's. +DELIBERATE_DIVERGENCES: tuple[re.Pattern[str], ...] = () + +#: The blob placeholder :class:`atif_converter.domain.blobs.BlobRef` writes for an +#: image, as ``_image_content_forgiven`` reads it. +_IMAGE_PLACEHOLDER = re.compile(r"\[image sha256:(?P[0-9a-f]{64}) (?P\S+) \d+ bytes\]") +#: harbor's path for a Codex tool output image it saved (harbor #3467). +_HARBOR_IMAGE_PATH = re.compile(r"images/codex_(?P[0-9a-f]{16})\.(?:png|jpg|gif|webp)") #: ``agent.extra`` lists harbor builds from a Python ``set``, so its order #: follows the process hash seed; ours keep first-seen order (see @@ -305,18 +308,67 @@ def _set_order_forgiven(expected: dict[str, Any], ours: dict[str, Any]) -> tuple return tuple(forgiven) +def _image_content_matches(theirs: Any, ours: Any) -> bool: + """Whether ``ours`` is harbor's image-bearing tool output, with each image as its placeholder. + + harbor 0.24.0 saves a Codex tool output's inline image to a file and returns + the content as parts; our pure converter writes the blob placeholder text + instead and joins the parts with newlines, as harbor joins a text-only list + (see ``_tool_output_content`` in ``domain/codex_conversion.py``). Each + placeholder must name the same bytes harbor saved (its SHA-256 starts with + the 16 hex digits of harbor's file name) and the same media type, and every + text part must match exactly. + """ + if not isinstance(theirs, list) or not isinstance(ours, str): + return False + if not any(isinstance(part, dict) and part.get("type") == "image" for part in theirs): + return False + texts: list[str] = [] + for part in theirs: + if not isinstance(part, dict): + return False + if part.get("type") == "text" and isinstance(part.get("text"), str): + texts.append(part["text"]) + continue + source = part.get("source") or {} + path = _HARBOR_IMAGE_PATH.fullmatch(str(source.get("path", ""))) + if part.get("type") != "image" or path is None: + return False + texts.append(f"\0{path['sha16']}\0{source.get('media_type')}\0") + # Rebuild ours with each placeholder in the same marker form, then compare whole. + rebuilt = _IMAGE_PLACEHOLDER.sub(lambda m: f"\0{m['sha'][:16]}\0{m['media']}\0", ours) + return rebuilt == "\n".join(texts) + + +def _image_content_forgiven(expected: dict[str, Any], ours: dict[str, Any]) -> frozenset[str]: + """The ``observation.results[j].content`` paths :func:`_image_content_matches` explains.""" + forgiven: set[str] = set() + harbor_steps = expected.get("steps") or [] + for index, step in enumerate(ours.get("steps") or []): + if index >= len(harbor_steps): + break + our_results = (step.get("observation") or {}).get("results") or [] + harbor_results = (harbor_steps[index].get("observation") or {}).get("results") or [] + for position, (theirs, mine) in enumerate(zip(harbor_results, our_results, strict=False)): + if _image_content_matches(theirs.get("content"), mine.get("content")): + forgiven.add(f"$.steps[{index}].observation.results[{position}].content") + return frozenset(forgiven) + + def parity_diffs(expected: dict[str, Any], ours: dict[str, Any]) -> list[str]: """:func:`diff_paths` minus the documented deliberate divergences. - Four kinds are forgiven. Every line a :data:`DELIBERATE_DIVERGENCES` - pattern matches, always; the element lines of a + Five kinds are forgiven. Every line a :data:`DELIBERATE_DIVERGENCES` + pattern matches, always; a Codex tool output's content where harbor saved + an image and ours holds its blob placeholder, only when + :func:`_image_content_matches` holds; the element lines of a :data:`SET_ORDER_DIVERGENCE_KEYS` list, only when both sides hold the same values; the :data:`PRICING_DIVERGENCE_PATHS`, only when :func:`_pricing_divergence_expected` says the pricing policy explains them; and a step's :data:`STEP_PRICING_DIVERGENCE_PATHS`, only when that step's model is one the policy prices differently. """ - step_forgiven = _step_pricing_forgiven(expected, ours) + step_forgiven = _step_pricing_forgiven(expected, ours) | _image_content_forgiven(expected, ours) order_forgiven = _set_order_forgiven(expected, ours) diffs = [ line diff --git a/packages/atif-converter/tests/test_claude_code_conversion.py b/packages/atif-converter/tests/test_claude_code_conversion.py index 65ed8a8..8656cae 100644 --- a/packages/atif-converter/tests/test_claude_code_conversion.py +++ b/packages/atif-converter/tests/test_claude_code_conversion.py @@ -484,9 +484,9 @@ def test_no_message_id_makes_one_step_per_event(self, tmp_path: Path) -> None: assert [s["message"] for s in out["steps"]] == ["go", "one", "two"] def test_event_extras_are_copied_onto_the_agent_step(self, tmp_path: Path) -> None: - """requestId is read off the MESSAGE, id/agent_id/cwd/userType off the EVENT; external userType is dropped.""" + """requestId is read off the MESSAGE, id/agentId/cwd/userType off the EVENT; external userType is dropped.""" record = _assistant( - "a1", "02", "x", id="ev-1", agent_id="ag-1", cwd="/w", userType="internal" + "a1", "02", "x", id="ev-1", agentId="ag-1", cwd="/w", userType="internal" ) record["message"]["requestId"] = "req-1" record["message"]["stop_sequence"] = "STOP" @@ -496,15 +496,89 @@ def test_event_extras_are_copied_onto_the_agent_step(self, tmp_path: Path) -> No "stop_sequence": "STOP", "requestId": "req-1", "id": "ev-1", + "is_sidechain": False, "agent_id": "ag-1", "cwd": "/w", "user_type": "internal", - "is_sidechain": False, } - assert out["agent"]["extra"] == {"cwds": ["/w"]} + # harbor 0.24.0's key order: the identity keys follow ``id``. + assert list(out["steps"][1]["extra"]) == [ + "stop_sequence", + "requestId", + "id", + "is_sidechain", + "agent_id", + "cwd", + "user_type", + ] + assert out["agent"]["extra"] == {"cwds": ["/w"], "agent_ids": ["ag-1"]} + + def test_snake_case_agent_id_is_not_an_identity(self, tmp_path: Path) -> None: + """harbor 0.24.0 reads ``agentId`` alone; a record's ``agent_id`` key names no subagent.""" + record = _assistant("a1", "02", "x", agent_id="ag-1") + out = _convert(tmp_path, [_user("u1", "01", "go"), record]) + assert out is not None + assert "agent_id" not in out["steps"][1]["extra"] class TestSidechains: + def test_every_step_of_a_subagent_names_it(self, tmp_path: Path) -> None: + """harbor #3434: a subagent's prompt, tool turn, tool result and answer all carry its ``agentId``.""" + + def side(agent_id: str, ts: int, tool_id: str) -> list[dict[str, Any]]: + common = {"isSidechain": True, "agentId": agent_id} + return [ + {**_user(f"{agent_id}-u1", f"{ts:02}", f"look at {agent_id}"), **common}, + { + **_assistant( + f"{agent_id}-a1", + f"{ts + 1:02}", + [{"type": "tool_use", "id": tool_id, "name": "Read", "input": {}}], + msg_id=f"m-{agent_id}-1", + ), + **common, + }, + { + **_user( + f"{agent_id}-u2", + f"{ts + 2:02}", + [{"type": "tool_result", "tool_use_id": tool_id, "content": "ok"}], + ), + **common, + }, + { + **_assistant( + f"{agent_id}-a2", + f"{ts + 3:02}", + f"{agent_id} done", + msg_id=f"m-{agent_id}-2", + ), + **common, + }, + ] + + out = _convert( + tmp_path, + [_user("u1", "01", "go"), _assistant("a1", "30", "all done", msg_id="m-main")], + { + "agent-helper-1.jsonl": side("helper-1", 2, "t1"), + "agent-helper-2.jsonl": side("helper-2", 10, "t2"), + }, + ) + assert out is not None + steps = out["steps"] + sidechain = [s for s in steps if s["extra"]["is_sidechain"]] + # Per subagent: its prompt, its tool turn (result attached), its answer. + assert [s["extra"].get("agent_id") for s in sidechain] == ["helper-1"] * 3 + [ + "helper-2" + ] * 3 + assert [s["source"] for s in sidechain] == ["user", "agent", "agent"] * 2 + tool_turns = [s for s in sidechain if s.get("observation")] + assert [s["observation"]["results"][0]["content"] for s in tool_turns] == ["ok", "ok"] + main = [s for s in steps if not s["extra"]["is_sidechain"]] + assert len(main) == 2 + assert not any("agent_id" in s["extra"] for s in main) + def test_main_chain_model_wins_over_an_earlier_sidechain_model(self, tmp_path: Path) -> None: """agent.model_name and the model-less fallback come from the main chain even when a sidechain is first.""" side = [ diff --git a/packages/atif-converter/tests/test_codex_conversion_port.py b/packages/atif-converter/tests/test_codex_conversion_port.py index a850c0e..5c44363 100644 --- a/packages/atif-converter/tests/test_codex_conversion_port.py +++ b/packages/atif-converter/tests/test_codex_conversion_port.py @@ -18,6 +18,10 @@ from __future__ import annotations +import base64 +import copy +import hashlib +import json from pathlib import Path from typing import Any @@ -29,8 +33,14 @@ codex_rollout_records, write_codex_rollout, ) -from harbor_oracle import diff_paths, harbor_codex_trajectory, harbor_has_private_api +from harbor_oracle import ( + diff_paths, + harbor_codex_trajectory, + harbor_has_private_api, + parity_diffs, +) +from atif_converter.domain.blobs import BlobCollector, extract_codex_blobs from atif_converter.domain.codex_conversion import convert_codex_records from atif_converter.infrastructure.codex_converter import convert_codex_rollout @@ -265,8 +275,10 @@ def test_reasoning_before_a_tool_call_lands_on_the_bundled_step(self, tmp_path: class TestToolCallShapes: - def test_web_search_call_has_empty_id_and_null_observation(self, tmp_path: Path) -> None: - """``web_search_call`` becomes a tool call with ``call_id == ""`` and no output.""" + def test_web_search_call_gets_a_request_scoped_id_and_null_observation( + self, tmp_path: Path + ) -> None: + """harbor #2972: a ``web_search_call`` with no item id is ``_web_search_``, no output.""" records = [ _session_meta(), _turn_context(), @@ -284,16 +296,44 @@ def test_web_search_call_has_empty_id_and_null_observation(self, tmp_path: Path) (agent,) = _agent_steps(_convert(tmp_path, records)) assert agent["tool_calls"] == [ { - "tool_call_id": "", + "tool_call_id": "api_call_1_web_search_0", "function_name": "web_search_call", "arguments": {"action_type": "search", "query": "harbor atif", "url": "https://x"}, } ] - # source_call_id "" -> None and content None: an empty result object. - assert agent["observation"] == {"results": [{}]} - assert agent["extra"]["tool_call_details"] == {"": {"status": "completed"}} + # content None: the result object carries its call id alone. + assert agent["observation"] == {"results": [{"source_call_id": "api_call_1_web_search_0"}]} + assert agent["extra"]["tool_call_details"] == { + "api_call_1_web_search_0": {"status": "completed"} + } assert agent["message"] == "found it" + @pytest.mark.parametrize( + ("native_id", "call_id"), [("ws_7", "ws_7"), (" ", "api_call_1_web_search_0")] + ) + def test_web_search_call_keeps_codexs_own_item_id( + self, tmp_path: Path, native_id: str, call_id: str + ) -> None: + """harbor #2972: a non-blank item ``id`` is the call id; a blank one falls back.""" + records = [ + _session_meta(), + _turn_context(), + _user("q"), + _item( + { + "type": "web_search_call", + "id": native_id, + "status": "completed", + "action": {"type": "search", "query": "q"}, + } + ), + _assistant("done"), + _token_count(10, 2), + ] + (agent,) = _agent_steps(_convert(tmp_path, records)) + assert agent["tool_calls"][0]["tool_call_id"] == call_id + assert agent["observation"]["results"] == [{"source_call_id": call_id}] + def test_custom_tool_call_input_string_becomes_the_input_argument(self, tmp_path: Path) -> None: """A non-JSON ``input`` is wrapped as ``{"input": raw}``; the raw string is kept.""" records = [ @@ -364,12 +404,19 @@ def test_function_call_arguments_shapes( ("42", "42", None), ("plain text", "plain text", None), (None, None, None), + # harbor #3467: a JSON scalar keeps its raw text, not Python's str() of it. + ("true", "true", None), + ("null", "null", None), + # harbor #3467: a non-string ``output`` is its JSON text. + ('{"output": [1, "a"], "metadata": {"b": 2}}', '[1, "a"]', {"b": 2}), + # a non-string output that is not content blocks is its JSON text. + ([1, 2], "[1, 2]", None), ], ) def test_function_call_output_blob_shapes( - self, tmp_path: Path, raw_output: str | None, content: str | None, metadata: Any + self, tmp_path: Path, raw_output: Any, content: str | None, metadata: Any ) -> None: - """``_parse_output_blob``: object -> output/metadata, scalar -> str, text -> itself.""" + """``_parse_output_blob``: object -> output/metadata, scalar -> its text, text -> itself.""" records = [ _session_meta(), _turn_context(), @@ -422,6 +469,149 @@ def test_tool_calls_sort_by_call_order_not_output_order(self, tmp_path: Path) -> assert agent["timestamp"] == "2026-09-11T17:27:02.200Z" +#: A 1x1 PNG, base64, for the inline image branches. +_PNG_BASE64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC" +) + + +class TestStructuredToolOutput: + """harbor #3467: MCP content-block lists become text, never Python's repr of the list.""" + + def _content(self, tmp_path: Path, output: Any, *, cross_check: bool = True) -> Any: + records = [ + _session_meta(), + _turn_context(), + _user("q"), + _function_call("c1", "mcp__srv__tool", "{}"), + _function_call_output("c1", output), + _token_count(10, 2), + ] + (agent,) = _agent_steps(_convert(tmp_path, records, cross_check=cross_check)) + return agent["observation"]["results"][0].get("content") + + @pytest.mark.parametrize("encode", [False, True], ids=["list", "json-string"]) + def test_text_items_join_with_newlines(self, tmp_path: Path, encode: bool) -> None: + """``input_text`` and MCP ``text`` blocks, as a list or as its JSON text, join with ``\\n``.""" + blocks = [ + {"type": "input_text", "text": "Script completed\nOutput:\n"}, + {"type": "text", "text": ""}, + {"type": "input_text", "text": '{"exit_code":0}'}, + ] + output = json.dumps(blocks) if encode else blocks + assert self._content(tmp_path, output) == 'Script completed\nOutput:\n\n\n{"exit_code":0}' + + def test_other_blocks_are_their_json_text(self, tmp_path: Path) -> None: + """A resource (or audio, or encrypted) block is kept as JSON, in place.""" + blocks = [ + {"type": "text", "text": "see"}, + {"type": "resource_link", "uri": "file:///r.txt", "name": "r"}, + ] + assert self._content(tmp_path, blocks) == ( + 'see\n{"type": "resource_link", "uri": "file:///r.txt", "name": "r"}' + ) + + def test_a_list_with_an_unknown_type_is_its_json_text(self, tmp_path: Path) -> None: + """Not content blocks: one element's ``type`` is unknown, so the list stays JSON.""" + blocks = [{"type": "text", "text": "a"}, {"type": "mystery"}] + assert self._content(tmp_path, blocks) == json.dumps(blocks) + + @pytest.mark.parametrize( + ("block", "text"), + [ + ({"type": "input_image", "image_url": "https://x/y.png"}, "[image: https://x/y.png]"), + ({"type": "input_image", "image_url": "data:image/png;base64,@@@"}, "[image omitted]"), + ({"type": "input_image", "image_url": "data:image/bmp;base64,AAAA"}, "[image omitted]"), + ({"type": "image", "data": _PNG_BASE64}, "[image omitted]"), + ({"type": "input_image", "file_id": "file-1"}, "[image omitted]"), + ], + ids=["remote-url", "undecodable", "unsupported-type", "no-media-type", "file-id"], + ) + def test_images_nobody_can_reference_are_harbors_text( + self, tmp_path: Path, block: dict[str, Any], text: str + ) -> None: + """Remote, undecodable, unsupported or id-only images: harbor's own text, exactly.""" + assert ( + self._content(tmp_path, [{"type": "text", "text": "shot:"}, block]) == f"shot:\n{text}" + ) + + @pytest.mark.parametrize( + "block", + [ + {"type": "input_image", "image_url": f"data:image/png;base64,{_PNG_BASE64}"}, + {"type": "image", "data": _PNG_BASE64, "mimeType": "image/png"}, + ], + ids=["input_image", "mcp-image"], + ) + def test_an_inline_image_is_its_blob_placeholder( + self, tmp_path: Path, block: dict[str, Any] + ) -> None: + """The one deliberate divergence: harbor saves the file, we write the blob placeholder.""" + content = self._content( + tmp_path, [{"type": "text", "text": "shot:"}, block], cross_check=False + ) + data = base64.b64decode(_PNG_BASE64) + digest = hashlib.sha256(data).hexdigest() + assert content == f"shot:\n[image sha256:{digest} image/png {len(data)} bytes]" + + def test_an_inline_image_is_the_pre_pass_placeholder(self) -> None: + """Raw and production paths agree: the converter writes what ``extract_codex_blobs`` would.""" + output = [ + {"type": "input_text", "text": "shot:"}, + {"type": "input_image", "image_url": f"data:image/png;base64,{_PNG_BASE64}"}, + ] + records = [ + _session_meta(), + _turn_context(), + _user("q"), + _function_call("c1", "tool", "{}"), + _function_call_output("c1", output), + _token_count(10, 2), + ] + raw = convert_codex_records(copy.deepcopy(records), fallback_session_id="s") + lifted = copy.deepcopy(records) + extract_codex_blobs(lifted, BlobCollector()) + assert lifted[4]["payload"]["output"][1]["type"] == "input_text" + pre_passed = convert_codex_records(lifted, fallback_session_id="s") + assert raw is not None + assert pre_passed is not None + assert raw.to_json_dict() == pre_passed.to_json_dict() + + def test_harbor_differs_only_by_the_saved_image(self, tmp_path: Path) -> None: + """While harbor ships the method: its image part and our placeholder are the ONLY diff.""" + if not harbor_has_private_api("codex"): + pytest.skip("harbor no longer ships Codex's private converter") + output = [ + {"type": "input_text", "text": "shot:"}, + {"type": "input_image", "image_url": f"data:image/png;base64,{_PNG_BASE64}"}, + ] + records = [ + _session_meta(), + _turn_context(), + _user("q"), + _function_call("c1", "tool", "{}"), + _function_call_output("c1", output), + _token_count(10, 2), + ] + rollout = write_codex_rollout(tmp_path / "sessions", records) + theirs = harbor_codex_trajectory(rollout) + ours = convert_codex_rollout(rollout) + assert theirs is not None + assert ours is not None + raw = diff_paths(theirs, ours.to_json_dict()) + assert [line.split(":", 1)[0] for line in raw] == [ + "$.steps[1].observation.results[0].content" + ] + harbor_content = theirs["steps"][1]["observation"]["results"][0]["content"] + assert [part["type"] for part in harbor_content] == ["text", "image"] + assert parity_diffs(theirs, ours.to_json_dict()) == [] + # The forgiveness is narrow: another image's bytes are not forgiven. + tampered = copy.deepcopy(ours.to_json_dict()) + result = tampered["steps"][1]["observation"]["results"][0] + result["content"] = result["content"].replace("sha256:", "sha256:0", 1)[:-1] + assert parity_diffs(theirs, tampered) != [] + + class TestApiCallGrouping: def test_token_count_closes_a_call_only_after_model_output(self, tmp_path: Path) -> None: """A ``token_count`` before any assistant output leaves the counter at 1.""" diff --git a/packages/atif-converter/tests/test_converter_schema_version.py b/packages/atif-converter/tests/test_converter_schema_version.py index b61d33b..9051683 100644 --- a/packages/atif-converter/tests/test_converter_schema_version.py +++ b/packages/atif-converter/tests/test_converter_schema_version.py @@ -28,8 +28,8 @@ #: The reviewed pair. Update BOTH in the commit that changes converter code: #: the version only when output can change, the digest always. -PINNED_VERSION = 6 -PINNED_SOURCE_DIGEST = "cc8a446eb77078bf405812bc8985436e644ee1f70f123e58387137b744f717d4" +PINNED_VERSION = 7 +PINNED_SOURCE_DIGEST = "ec3d4dd26231542d1083122e863ecf1f2286b8d5e7ab3e5b2477970b47f14338" _SRC = Path(__file__).resolve().parents[1] / "src" / "atif_converter" diff --git a/packages/atif-converter/tests/test_result_signals.py b/packages/atif-converter/tests/test_result_signals.py index ed580c2..a907f8b 100644 --- a/packages/atif-converter/tests/test_result_signals.py +++ b/packages/atif-converter/tests/test_result_signals.py @@ -89,23 +89,30 @@ def test_the_port_reads_the_camel_case_key(self) -> None: assert extra is not None assert extra["agent_id"] == "ag-7" - def test_harbor_differs_only_by_the_named_divergence( + def test_harbor_names_every_sidechain_step_the_same( self, parallel_subagent_session: Path ) -> None: - """The oracle still holds everywhere else, and the divergence is real.""" + """harbor 0.24.0 (#3434) reads ``agentId`` too: every step's subagent id agrees.""" require_harbor_private_api("claude-code") theirs = harbor_claude_code_trajectory(parallel_subagent_session) ours = convert_claude_code_session(parallel_subagent_session) assert theirs is not None assert ours is not None - raw = diff_paths(theirs, ours.to_json_dict()) - agent_id_lines = [line for line in raw if ".extra.agent_id: only in ours" in line] - assert agent_id_lines, "the fixture must exercise the divergence the filter names" + ours_dict = ours.to_json_dict() + sidechain = [s for s in theirs["steps"] if s["extra"]["is_sidechain"]] + # Not vacuous: harbor names a subagent on prompts, tool turns and answers. + assert len(sidechain) == 6 + assert all(s["extra"].get("agent_id") in {"aaa", "bbb"} for s in sidechain) + assert [s["extra"].get("agent_id") for s in theirs["steps"]] == [ + s["extra"].get("agent_id") for s in ours_dict["steps"] + ] + raw = diff_paths(theirs, ours_dict) + assert not [line for line in raw if ".extra.agent_id:" in line] # The fixture's model has no price, so the pricing policy's own named - # divergence (NULL, not harbor's $0), per session and per step, shows up - # beside it, and harbor's set-ordered ``agent.extra.agent_ids`` may come - # out reversed; nothing else may. - others = {line.split(":", 1)[0] for line in raw if line not in agent_id_lines} + # divergence (NULL, not harbor's $0), per session and per step, shows up, + # and harbor's set-ordered ``agent.extra.agent_ids`` may come out + # reversed; nothing else may. + others = {line.split(":", 1)[0] for line in raw} step_paths = { path.format(i=index) for index in range(len(ours.steps)) @@ -118,7 +125,7 @@ def test_harbor_differs_only_by_the_named_divergence( *step_paths, *order_paths, } - assert parity_diffs(theirs, ours.to_json_dict()) == [] + assert parity_diffs(theirs, ours_dict) == [] class TestSubagentLinks: