Files
claude-code-api/tests/test_injection.py
T

194 lines
6.5 KiB
Python

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