194 lines
6.5 KiB
Python
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
|