feat: add synthesize_turn_messages
This commit is contained in:
@@ -628,6 +628,173 @@ def test_post_turn_fingerprint_matches_canonical_continuation(tmp_path: Path) ->
|
||||
assert fp_stash == fp_lookup
|
||||
|
||||
|
||||
# --- multi-message synthesis (tool-use cycles) ---------------------------
|
||||
|
||||
|
||||
def _assistant_tool_use_rec(
|
||||
name: str, tool_id: str, session_id: str
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "assistant",
|
||||
"uuid": f"a-tu-{tool_id}",
|
||||
"sessionId": session_id,
|
||||
"parentUuid": None,
|
||||
"message": {
|
||||
"id": "msg_x",
|
||||
"role": "assistant",
|
||||
"model": "claude-test",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": tool_id, "name": name, "input": {"q": "x"}}
|
||||
],
|
||||
"stop_reason": "tool_use",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _user_tool_result_rec(
|
||||
tool_id: str, content: str, session_id: str
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "user",
|
||||
"uuid": f"u-tr-{tool_id}",
|
||||
"sessionId": session_id,
|
||||
"parentUuid": None,
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": tool_id, "content": content}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_use_cycle_stashes_full_history_for_continuation(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When a turn ran through a tool_use cycle, the second `complete()`
|
||||
must reuse the same live session if the client echoes back the WHOLE
|
||||
cycle (assistant-with-tool_use + user-with-tool_result + final
|
||||
assistant). This is the contract the gateway depends on.
|
||||
"""
|
||||
scripts_per_session = [
|
||||
# ONE session handles both turns.
|
||||
[
|
||||
# turn 0: prompt → tool_use → tool_result → final text
|
||||
[
|
||||
_user_rec("look up X", "S0"),
|
||||
_assistant_tool_use_rec("Read", "tu_1", "S0"),
|
||||
_user_tool_result_rec("tu_1", "result body", "S0"),
|
||||
_assistant_rec("done", "S0"),
|
||||
],
|
||||
# turn 1: follow-up reuses same session (one write into the PTY)
|
||||
[
|
||||
_user_rec("now what", "S0"),
|
||||
_assistant_rec("ack", "S0"),
|
||||
],
|
||||
],
|
||||
]
|
||||
harness = FakeFactoryHarness(scripts_per_session)
|
||||
backend = ClaudeCodeBackend(
|
||||
BackendOptions(cwd=str(tmp_path)),
|
||||
_session_factory=harness,
|
||||
)
|
||||
|
||||
async for _ in backend.complete([{"role": "user", "content": "look up X"}]):
|
||||
pass
|
||||
|
||||
# The continuation echoes back the FULL synthesized cycle, in canonical
|
||||
# Anthropic-block shape — same as what the gateway will pull from its
|
||||
# conversation store.
|
||||
continuation = [
|
||||
{"role": "user", "content": "look up X"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "tu_1", "name": "Read", "input": {"q": "x"}}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tu_1",
|
||||
"content": "result body",
|
||||
"is_error": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "done"}]},
|
||||
{"role": "user", "content": "now what"},
|
||||
]
|
||||
async for _ in backend.complete(continuation):
|
||||
pass
|
||||
await backend.aclose()
|
||||
|
||||
# One session for both turns proves the fingerprint lookup hit.
|
||||
assert len(harness.spawned) == 1
|
||||
assert harness.spawned[0].writes == ["look up X", "now what"]
|
||||
|
||||
|
||||
def test_synthesize_turn_messages_covers_whole_cycle() -> None:
|
||||
"""Direct unit check on the public synthesizer: full cycle in, full
|
||||
canonical-block message list out, intermediate echo UserMessages
|
||||
filtered, tool_result UserMessages preserved.
|
||||
"""
|
||||
from claude_code_api import synthesize_turn_messages
|
||||
from claude_code_api.events import (
|
||||
AssistantMessage,
|
||||
ResultMessage,
|
||||
TextBlock,
|
||||
ToolResultBlock,
|
||||
ToolUseBlock,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
events = [
|
||||
UserMessage(content="look up X"), # echo, must be skipped
|
||||
AssistantMessage(
|
||||
content=[ToolUseBlock(id="tu_1", name="Read", input={"q": "x"})],
|
||||
model="claude-test",
|
||||
stop_reason="tool_use",
|
||||
),
|
||||
UserMessage(
|
||||
content=[
|
||||
ToolResultBlock(tool_use_id="tu_1", content="result body"),
|
||||
]
|
||||
),
|
||||
AssistantMessage(
|
||||
content=[TextBlock(text="done")],
|
||||
model="claude-test",
|
||||
stop_reason="end_turn",
|
||||
),
|
||||
ResultMessage(subtype="success", duration_ms=10, num_turns=1, session_id="S0"),
|
||||
]
|
||||
out = synthesize_turn_messages(events)
|
||||
assert out == [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "tu_1", "name": "Read", "input": {"q": "x"}}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "tu_1",
|
||||
"content": "result body",
|
||||
"is_error": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "done"}]},
|
||||
]
|
||||
|
||||
|
||||
# --- smoke test (real claude) --------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user