"""Top-level vs nested identity coherence under manifest-placeholder shapes. Regression coverage for the bug where ``fetch_dc._resolve_identity`` falls through its strict AGENT-participant pick (fetch_dc.py:570-597) on agent shapes like MyAgent — leaving the manifest with ``agent_version="v0"`` (placeholder) — while wave 5 then materializes ``gateway_request_tags`` rows that carry the real ``v24``. The harvester ``_build_session_identity`` correctly reads ``v24`` into ``session.identity.agent_version``, but the top-level ``identity`` block was previously copied straight off the manifest and silently disagreed with the same JSON file's nested copy. These tests pin the promotion policy implemented by ``_promote_identity`` / ``_reconcile_top_identity``: - manifest "v0" + session "v24" -> top promotes to v24 - manifest "v3" + session None -> top stays v3 (manifest wins) - manifest api None + session real -> top promotes - manifest "v3" + session "v5" -> top stays v3 (manifest wins; strict AGENT pick is intentional) - manifest "v0" + session "v0" -> top stays v0 (no real value available; placeholder leak remains visible to investigator) Plus a full round-trip through ``assemble_dc.assemble`` to confirm the top-level and nested identity records agree on a real MyAgent-shape session. """ from __future__ import annotations import json import unittest from copy import deepcopy from pathlib import Path from tempfile import TemporaryDirectory from unittest import mock from . import _bootstrap # noqa: F401 — sys.path setup import assemble_dc # type: ignore from config import paths # type: ignore from .fixtures.synthetic_session import ( # type: ignore IDS, make_manifest, make_rows, write_to_disk, ) # ----------------------------------------------------------------------------- # Unit tests for _promote_identity (single-field policy) # ----------------------------------------------------------------------------- class PromoteVersionTests(unittest.TestCase): """`_promote_identity(..., kind="version")` — placeholder-only promotion.""" def test_v0_placeholder_promoted_when_session_has_real_version(self): out = assemble_dc._promote_identity("v0", "v24", kind="version") self.assertEqual(out, "v24") def test_keeps_manifest_value_when_session_identity_is_none(self): # Manifest already real; no reason to look elsewhere. out = assemble_dc._promote_identity("v3", None, kind="version") self.assertEqual(out, "v3") def test_keeps_v0_when_session_also_returns_v0(self): # No real value harvestable. Placeholder remains visible so # the investigator can still see the resolution failed. out = assemble_dc._promote_identity("v0", "v0", kind="version") self.assertEqual(out, "v0") def test_keeps_v0_when_session_value_is_garbage(self): # Session shouldn't be able to "promote" a non-version-shaped string. out = assemble_dc._promote_identity("v0", "garbage", kind="version") self.assertEqual(out, "v0") def test_keeps_v0_when_session_value_is_none(self): out = assemble_dc._promote_identity("v0", None, kind="version") self.assertEqual(out, "v0") def test_no_promotion_when_both_real_but_disagree(self): # Strict AGENT-row pick is intentional — manifest wins. out = assemble_dc._promote_identity("v3", "v5", kind="version") self.assertEqual(out, "v3") class PromoteApiNameTests(unittest.TestCase): """`_promote_identity(..., kind="api_name")` — NOT_SET-ish promotion.""" def test_none_manifest_promoted_when_session_has_value(self): out = assemble_dc._promote_identity( None, "MyAgent", kind="api_name", ) self.assertEqual(out, "MyAgent") def test_not_set_string_promoted_when_session_has_value(self): out = assemble_dc._promote_identity( "NOT_SET", "MyAgent", kind="api_name", ) self.assertEqual(out, "MyAgent") def test_empty_manifest_promoted_when_session_has_value(self): out = assemble_dc._promote_identity("", "MyAgent", kind="api_name") self.assertEqual(out, "MyAgent") def test_keeps_manifest_value_when_session_is_none(self): # Symmetric of the version case — manifest wins when session # has nothing to offer. out = assemble_dc._promote_identity( "MyAgent", None, kind="api_name", ) self.assertEqual(out, "MyAgent") def test_keeps_manifest_when_both_real(self): out = assemble_dc._promote_identity( "ManifestAgent", "SessionAgent", kind="api_name", ) self.assertEqual(out, "ManifestAgent") # ----------------------------------------------------------------------------- # Reconcile helper — composed policy across both fields + stderr note # ----------------------------------------------------------------------------- class ReconcileTopIdentityTests(unittest.TestCase): def test_reconcile_emits_stderr_note_when_version_promoted(self): manifest = {"agent_api_name": "MyAgent", "agent_version": "v0"} session_identity = { "agent_api_name": None, "agent_version": "v24", } with mock.patch("sys.stderr") as fake_stderr: top = assemble_dc._reconcile_top_identity( manifest, session_identity, "00DXX0000000ABC", ) self.assertEqual(top["agent_version"], "v24") self.assertEqual(top["agent_api_name"], "MyAgent") self.assertEqual(top["org_id_15"], "00DXX0000000ABC") # The stderr note format is part of the contract — at least # one print call mentioning "promoted" must fire. emitted = [ call.args[0] for call in fake_stderr.write.call_args_list if call.args ] self.assertTrue( any("promoted" in str(s) for s in emitted), f"expected promotion note on stderr; got writes: {emitted}", ) def test_reconcile_silent_when_no_promotion_needed(self): manifest = {"agent_api_name": "DemoAgent", "agent_version": "v5"} session_identity = { "agent_api_name": "DemoAgent", "agent_version": "v5", } with mock.patch("sys.stderr") as fake_stderr: top = assemble_dc._reconcile_top_identity( manifest, session_identity, "00DXX0000000ABC", ) self.assertEqual(top["agent_version"], "v5") emitted = [ call.args[0] for call in fake_stderr.write.call_args_list if call.args ] self.assertFalse( any("promoted" in str(s) for s in emitted), f"unexpected promotion note: {emitted}", ) # ----------------------------------------------------------------------------- # Full-fixture round-trip — MyAgent-shape session through assemble() # ----------------------------------------------------------------------------- def _serviceagent2_shape_disk_writer(tmp_root: Path) -> Path: """Materialize a MyAgent-shape session under tmp_root. Mutates the synthetic fixture so that: - manifest carries the placeholder ``agent_version="v0"`` (mirrors what ``fetch_dc._resolve_identity``'s fallback stamps). - the AGENT participant row has its ``ssot__AiAgentVersionApiName__c`` cleared to NOT_SET (so ``_build_session_identity`` doesn't pick up a version from there). - ``gateway_request_tags`` carries an ``agent_version_api_name=v24`` tag against the declared GW request, so the harvester finds the real value. The session dir lives under ``/__v0//`` to match the placeholder-stamped layout fetch_dc actually writes on this path. """ rows = make_rows() # AGENT row: clear version so _build_session_identity has no hint # via that path; the harvest must come from gateway_request_tags. for p in rows["participants"]: if p.get("ssot__AiAgentSessionParticipantRole__c") == "AGENT": p["ssot__AiAgentVersionApiName__c"] = "NOT_SET" # Add the agent_version_api_name tag against the declared GW request. rows["gateway_request_tags"].append({ "id__c": "tag-agent-version", "parent__c": IDS.GW_REQ_DECLARED, "tag__c": "agent_version_api_name", "tagValue__c": '"v24"', # quoted form mirrors live shape }) # Plus agent_developer_name so session.identity.agent_api_name # also gets a real value to test the api_name path. rows["gateway_request_tags"].append({ "id__c": "tag-agent-dev", "parent__c": IDS.GW_REQ_DECLARED, "tag__c": "agent_developer_name", "tagValue__c": '"MyAgent"', }) # Manifest: placeholder version, real api_name (matches the # MyAgent fallback's actual output — it stamps api_name from # any participant row but version is forced to "v0"). manifest = make_manifest() manifest["agent_api_name"] = "MyAgent" manifest["agent_version"] = "v0" manifest["org_id_15"] = IDS.ORG_ID_15 # Layout: /__v0// — the placeholder-shaped dir. sdir = tmp_root / IDS.ORG_ID_15 / "MyAgent__v0" / IDS.SID sdir.mkdir(parents=True, exist_ok=True) (sdir / "dc._session_manifest.json").write_text( json.dumps(manifest, indent=2) + "\n" ) for name, rs in rows.items(): if rs: (sdir / f"dc.{name}.json").write_text( json.dumps(rs, indent=2) + "\n" ) # Breadcrumb for _find_session_dir's primary path. link_dir = tmp_root / IDS.ORG_ID_15 / "_sessions" link_dir.mkdir(parents=True, exist_ok=True) (link_dir / f"{IDS.SID}.link").write_text( f"../MyAgent__v0/{IDS.SID}\n" ) return sdir class AssembleEndToEndIdentityCoherenceTests(unittest.TestCase): """The bug repro — top-level vs nested identity must agree after fix.""" def test_top_identity_promoted_when_manifest_has_v0_and_tags_have_v24(self): with TemporaryDirectory() as tmp: tmp_root = Path(tmp) _serviceagent2_shape_disk_writer(tmp_root) with mock.patch.object(paths, "DATA_ROOT", tmp_root): tree, _ = assemble_dc.assemble(IDS.SID) # The harvester (_build_session_identity) reads v24 from tags. self.assertEqual(tree["session"]["identity"]["agent_version"], "v24") # The top-level — previously v0 — must now agree. self.assertEqual(tree["identity"]["agent_version"], "v24") def test_top_and_session_identity_agree_on_agent_version(self): with TemporaryDirectory() as tmp: tmp_root = Path(tmp) _serviceagent2_shape_disk_writer(tmp_root) with mock.patch.object(paths, "DATA_ROOT", tmp_root): tree, _ = assemble_dc.assemble(IDS.SID) # The whole point of the fix. self.assertEqual( tree["identity"]["agent_version"], tree["session"]["identity"]["agent_version"], ) def test_api_name_top_level_keeps_manifest_when_session_returns_null(self): # Mirror the live live-verification observation: top-level # api_name is "MyAgent" (from manifest), session.identity # api_name is None because no agent_developer_name tag fired. # Strip the agent_developer_name tag we added in the helper. with TemporaryDirectory() as tmp: tmp_root = Path(tmp) _serviceagent2_shape_disk_writer(tmp_root) sdir = tmp_root / IDS.ORG_ID_15 / "MyAgent__v0" / IDS.SID tags_path = sdir / "dc.gateway_request_tags.json" tags = json.loads(tags_path.read_text()) tags = [t for t in tags if t.get("tag__c") != "agent_developer_name"] tags_path.write_text(json.dumps(tags, indent=2) + "\n") with mock.patch.object(paths, "DATA_ROOT", tmp_root): tree, _ = assemble_dc.assemble(IDS.SID) # session.identity.agent_api_name None — no harvestable value. self.assertIsNone(tree["session"]["identity"]["agent_api_name"]) # Top-level keeps manifest's "MyAgent" — promotion does NOT # erase a real manifest value when session has nothing to offer. self.assertEqual(tree["identity"]["agent_api_name"], "MyAgent") # ----------------------------------------------------------------------------- # Sanity: the existing happy-path fixture still produces matching identity # (regression guard against the fix mutating the simple case). # ----------------------------------------------------------------------------- class HappyPathStillCoherentTests(unittest.TestCase): def test_happy_path_unchanged(self): # The synthetic fixture's gateway_request_tags don't include # `agent_version_api_name`, so session.identity.agent_version is # None on this path. The fix's policy preserves the manifest # value ("v5") in that case — verifying the no-promote branch # doesn't accidentally null out the top-level when the harvest # layer has nothing to offer. with TemporaryDirectory() as tmp: tmp_root = Path(tmp) write_to_disk(tmp_root) with mock.patch.object(paths, "DATA_ROOT", tmp_root): tree, _ = assemble_dc.assemble(IDS.SID) self.assertEqual(tree["identity"]["agent_version"], IDS.AGENT_VERSION) self.assertEqual(tree["identity"]["agent_api_name"], IDS.AGENT_API) # session.identity may be None here (no agent_version_api_name tag); # the top-level keeps the manifest's real value, which is the point. self.assertIn( tree["session"]["identity"]["agent_version"], (None, IDS.AGENT_VERSION), ) if __name__ == "__main__": unittest.main()