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: 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"