"""Unit tests for `history_injection` helpers. Pure functions, no claude / no filesystem. The seed-JSONL shape is regression tested against the same minimal contract that `probe_jsonl_injection.py` proved out empirically (see FINDINGS § *Native JSONL injection works on --resume*). """ from __future__ import annotations import json import pytest from claude_code_api.injection import ( build_concat_prompt, build_seed_jsonl, hash_history, ) # --- hash_history --------------------------------------------------------- def test_hash_history_empty_is_stable() -> None: assert hash_history([]) == hash_history([]) def test_hash_history_distinguishes_content() -> None: a = [{"role": "user", "content": "hi"}] b = [{"role": "user", "content": "bye"}] assert hash_history(a) != hash_history(b) def test_hash_history_ignores_block_key_order() -> None: """Two clients that serialize the same block in different key orders must collide. Canonical-JSON serialization handles this.""" a = [ { "role": "assistant", "content": [{"type": "tool_use", "id": "t1", "name": "echo", "input": {"x": 1}}], } ] b = [ { "role": "assistant", "content": [{"input": {"x": 1}, "name": "echo", "id": "t1", "type": "tool_use"}], } ] assert hash_history(a) == hash_history(b) def test_hash_history_rejects_unknown_role() -> None: with pytest.raises(ValueError, match="role"): hash_history([{"role": "system", "content": "x"}]) def test_hash_history_text_blocks_collide_with_string_form() -> None: """A bare string `content` and the equivalent single text block hash to DIFFERENT values. They represent the same semantic content but appear on the wire differently — the gateway must pick one form per role and stay consistent. We don't try to paper over that here.""" a = [{"role": "user", "content": "hello"}] b = [{"role": "user", "content": [{"type": "text", "text": "hello"}]}] assert hash_history(a) != hash_history(b) # --- build_seed_jsonl ----------------------------------------------------- def test_build_seed_jsonl_empty_is_empty_string() -> None: assert build_seed_jsonl([], session_id="s", cwd="/tmp") == "" def test_build_seed_jsonl_two_records_for_one_turn() -> None: seed = build_seed_jsonl( [ {"role": "user", "content": "My name is Beaver."}, {"role": "assistant", "content": "Got it."}, ], session_id="sid-1", cwd="/work", ) lines = [json.loads(line) for line in seed.strip().splitlines()] assert len(lines) == 2 user_rec, asst_rec = lines assert user_rec["type"] == "user" assert user_rec["sessionId"] == "sid-1" assert user_rec["cwd"] == "/work" assert user_rec["parentUuid"] is None assert user_rec["message"] == {"role": "user", "content": "My name is Beaver."} assert user_rec["isMeta"] is False assert "uuid" in user_rec and "timestamp" in user_rec assert asst_rec["type"] == "assistant" assert asst_rec["parentUuid"] == user_rec["uuid"] assert asst_rec["message"]["role"] == "assistant" assert asst_rec["message"]["content"] == [{"type": "text", "text": "Got it."}] assert asst_rec["message"]["stop_reason"] == "end_turn" assert asst_rec["sessionId"] == "sid-1" def test_build_seed_jsonl_chains_parent_uuids_across_turns() -> None: """The parentUuid graph must form a linear chain across turns — that's how claude reconstructs conversation order on resume.""" seed = build_seed_jsonl( [ {"role": "user", "content": "u1"}, {"role": "assistant", "content": "a1"}, {"role": "user", "content": "u2"}, {"role": "assistant", "content": "a2"}, ], session_id="s", cwd="/tmp", ) recs = [json.loads(line) for line in seed.strip().splitlines()] assert len(recs) == 4 assert recs[0]["parentUuid"] is None assert recs[1]["parentUuid"] == recs[0]["uuid"] assert recs[2]["parentUuid"] == recs[1]["uuid"] assert recs[3]["parentUuid"] == recs[2]["uuid"] def test_build_seed_jsonl_passes_list_content_through_for_user() -> None: """A user record with a tool_result block (the only list-form user content claude itself writes) must round-trip verbatim.""" seed = build_seed_jsonl( [ { "role": "user", "content": [ {"type": "tool_result", "tool_use_id": "t1", "content": "42"}, ], } ], session_id="s", cwd="/tmp", ) rec = json.loads(seed.strip()) assert rec["message"]["content"] == [ {"type": "tool_result", "tool_use_id": "t1", "content": "42"}, ] def test_build_seed_jsonl_rejects_unknown_role() -> None: with pytest.raises(ValueError, match="role"): build_seed_jsonl([{"role": "system", "content": "x"}], session_id="s", cwd="/tmp") # --- build_concat_prompt -------------------------------------------------- def test_build_concat_prompt_empty_history_returns_just_last_user() -> None: assert build_concat_prompt([], "hello") == "hello" def test_build_concat_prompt_renders_alternating_history() -> None: out = build_concat_prompt( [ {"role": "user", "content": "u1"}, {"role": "assistant", "content": "a1"}, {"role": "user", "content": "u2"}, {"role": "assistant", "content": "a2"}, ], "u3", ) assert "Previous conversation context:" in out assert "[User]: u1" in out assert "[Assistant]: a1" in out assert "[User]: u2" in out assert "[Assistant]: a2" in out assert "Continue from here. New user message: u3" in out # The new prompt must come after the history, not interleaved. assert out.index("[Assistant]: a2") < out.index("Continue from here") def test_build_concat_prompt_flattens_text_blocks_and_skips_tools() -> None: """Content-as-list with text blocks gets flattened; tool blocks are skipped (they don't round-trip through stdin in any useful form).""" out = build_concat_prompt( [ { "role": "assistant", "content": [ {"type": "text", "text": "hello"}, {"type": "tool_use", "id": "t1", "name": "x", "input": {}}, {"type": "text", "text": "world"}, ], }, ], "ping", ) assert "[Assistant]: hello world" in out