feat(stream): headless claude -p transport behind a transport flag

The PTY transport stands in for a protocol that did not exist when it
was written: it pastes bracketed text into claude's TUI, guesses when
the Ink render loop has settled, re-presses Enter when the paste is
swallowed, and tails the session JSONL at 100ms. `claude -p` with
stream-json on both pipes is that protocol, so add it as a second
transport and let callers pick with `BackendOptions.transport`.

Measured on one host, same model and prompt, cold single-reply turn:
pty burns 2.3s on TUI readiness before the prompt is even submitted
(first event 2.5s, turn 5.2s); stream_json burns none (first event
0.3s, first token 1.9s, turn 2.7s). The gap is what `startup_delay`'s
60s cap exists to survive on slow hardware.

Everything the old transport relies on carries over unchanged and was
verified against a real CLI: multi-turn over one live process, MCP via
--mcp-config, --resume, and `native_jsonl` history seeding. Two things
are new rather than equal: `result` is native (so usage is the turn's
aggregate and durations are real, not synthesized), and
`include_partial_messages` yields token-level `StreamEvent`s — which
the JSONL, holding only finished blocks, could never provide.

Notably, assistant records carry `stop_reason: null` in this mode even
at the end of a turn, so `result` is not just tidier than the PTY
path's terminal-stop_reason heuristic, it is the only correct signal.

Default stays `pty`; nothing changes for existing callers.

Also: extract process-group bookkeeping into `procgroup` so both
transports sweep claude's children on shutdown, and fix a stale
assertion in test_backend that still expected the `is_error: None` that
`_block_to_dict` deliberately stopped emitting.
This commit is contained in:
hh
2026-07-28 02:56:17 +02:00
parent aa7beea1e0
commit 76799179d9
9 changed files with 1549 additions and 111 deletions
+429
View File
@@ -0,0 +1,429 @@
"""Unit tests for the headless stream-json transport.
No real `claude` here — `StreamTurnManager` talks to a fake process that
replays a canned record list, which is enough to pin the parts that are
genuinely ours: turn-boundary detection, the content-block index rebase
across multi-request turns, bookkeeping filtering, and error mapping.
The live end-to-end path is covered by the smoke test at the bottom.
"""
from __future__ import annotations
import os
from typing import Any
import pytest
from claude_code_api.errors import AuthError, ProcessError
from claude_code_api.events import (
AssistantMessage,
ResultMessage,
StreamEvent,
UserMessage,
)
from claude_code_api.pty import PtyProcessOptions
from claude_code_api.stream import (
StreamTurnManager,
build_stream_argv,
strip_tui_only_flags,
)
class FakeStreamProcess:
"""Minimal stand-in for `StreamClaudeProcess`.
Records sent prompts, replays a scripted list of stdout records, and
returns ``None`` (EOF) once exhausted.
"""
def __init__(self, records: list[dict[str, Any] | None], *, stderr: str = "") -> None:
self._records = list(records)
self.sent: list[str] = []
self.started = False
self.closed = False
self.session_id = "sess-fake"
self._stderr = stderr
async def start(self) -> None:
self.started = True
async def send_user_message(self, text: str) -> None:
self.sent.append(text)
async def next_record(self) -> dict[str, Any] | None:
if not self._records:
return None
return self._records.pop(0)
def stderr_text(self) -> str:
return self._stderr
def captured_output(self) -> bytes:
return b""
async def aclose(self) -> None:
self.closed = True
def _assistant(*blocks: dict[str, Any], stop: str | None = None) -> dict[str, Any]:
return {
"type": "assistant",
"session_id": "sess-fake",
"uuid": "u1",
"message": {
"role": "assistant",
"model": "claude-test",
"content": list(blocks),
"stop_reason": stop,
"usage": {"input_tokens": 1, "output_tokens": 2},
},
}
def _result(**over: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"type": "result",
"subtype": "success",
"is_error": False,
"stop_reason": "end_turn",
"num_turns": 1,
"duration_ms": 1234,
"session_id": "sess-fake",
"usage": {"input_tokens": 10, "output_tokens": 20},
}
base.update(over)
return base
def _stream(etype: str, index: int | None = None, **extra: Any) -> dict[str, Any]:
event: dict[str, Any] = {"type": etype, **extra}
if index is not None:
event["index"] = index
return {
"type": "stream_event",
"session_id": "sess-fake",
"parent_tool_use_id": None,
"event": event,
}
async def _drain(tm: StreamTurnManager, text: str = "hi") -> list[Any]:
return [ev async for ev in tm.send_user_message(text)]
# --- argv ----------------------------------------------------------------
def test_build_stream_argv_is_headless_and_carries_common_flags() -> None:
opts = PtyProcessOptions(
cwd="/vault",
session_id="SID",
model="claude-opus-5",
system_prompt="SP",
effort="high",
dangerously_skip_permissions=True,
)
argv = build_stream_argv(opts, "SID")
assert argv[:7] == [
"claude",
"-p",
"--input-format",
"stream-json",
"--output-format",
"stream-json",
"--verbose",
]
assert "--include-partial-messages" not in argv
assert argv[argv.index("--session-id") + 1] == "SID"
assert argv[argv.index("--model") + 1] == "claude-opus-5"
assert argv[argv.index("--system-prompt") + 1] == "SP"
assert argv[argv.index("--effort") + 1] == "high"
assert "--dangerously-skip-permissions" in argv
def test_build_stream_argv_resume_wins_over_session_id() -> None:
opts = PtyProcessOptions(cwd="/vault", resume_session_id="RID")
argv = build_stream_argv(opts, "ignored")
assert "--session-id" not in argv
assert argv[argv.index("--resume") + 1] == "RID"
def test_build_stream_argv_include_partial_messages() -> None:
opts = PtyProcessOptions(cwd="/vault", session_id="SID")
argv = build_stream_argv(opts, "SID", include_partial_messages=True)
assert "--include-partial-messages" in argv
def test_strip_tui_only_flags_drops_interactive_escape_hatches() -> None:
kept = strip_tui_only_flags(
("--remote-control", "--keep", "--worktree=wt", "--ide", "--also-keep")
)
assert kept == ("--keep", "--also-keep")
def test_build_stream_argv_strips_tui_only_extra_args() -> None:
opts = PtyProcessOptions(
cwd="/vault", session_id="SID", extra_args=("--remote-control", "--keep")
)
argv = build_stream_argv(opts, "SID")
assert "--remote-control" not in argv
assert "--keep" in argv
# --- turn loop -----------------------------------------------------------
async def test_result_record_closes_the_turn_and_is_not_synthesized() -> None:
proc = FakeStreamProcess(
[_assistant({"type": "text", "text": "hello"}), _result()]
)
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
events = await _drain(tm)
assert [type(e).__name__ for e in events] == ["AssistantMessage", "ResultMessage"]
result = events[-1]
assert isinstance(result, ResultMessage)
# Straight off the wire, not fabricated from the last assistant.
assert result.duration_ms == 1234
assert result.stop_reason == "end_turn"
assert result.usage == {"input_tokens": 10, "output_tokens": 20}
assert result.is_error is False
assert proc.sent == ["hi"]
async def test_assistant_stop_reason_null_does_not_end_the_turn() -> None:
"""The distinguishing property of this transport.
Headless claude leaves `stop_reason` null on every assistant record,
so a terminal-stop_reason heuristic would either end the turn at the
first record or never end it. Only `result` counts.
"""
proc = FakeStreamProcess(
[
_assistant({"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}),
{
"type": "user",
"session_id": "sess-fake",
"message": {
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}
],
},
},
_assistant({"type": "text", "text": "done"}),
_result(num_turns=2),
]
)
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
events = await _drain(tm)
assert [type(e).__name__ for e in events] == [
"AssistantMessage",
"UserMessage",
"AssistantMessage",
"ResultMessage",
]
assert isinstance(events[1], UserMessage)
assert isinstance(events[2], AssistantMessage)
assert events[2].content[0].text == "done" # type: ignore[union-attr]
async def test_bookkeeping_system_records_are_dropped() -> None:
proc = FakeStreamProcess(
[
{"type": "system", "subtype": "init", "session_id": "s"},
{"type": "system", "subtype": "hook_started", "session_id": "s"},
{"type": "system", "subtype": "status", "session_id": "s"},
{"type": "rate_limit_event", "rate_limit_info": {"status": "allowed"}},
_assistant({"type": "text", "text": "x"}),
_result(),
]
)
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
events = await _drain(tm)
assert [type(e).__name__ for e in events] == ["AssistantMessage", "ResultMessage"]
async def test_partial_messages_are_suppressed_by_default() -> None:
proc = FakeStreamProcess(
[
_stream("content_block_delta", 0, delta={"type": "text_delta", "text": "a"}),
_assistant({"type": "text", "text": "a"}),
_result(),
]
)
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
events = await _drain(tm)
assert not any(isinstance(e, StreamEvent) for e in events)
async def test_partial_message_indices_are_rebased_across_requests() -> None:
"""A turn spans several API requests; the caller sees one envelope.
Each request numbers its content blocks from zero, so without a
rebase the second request's block 0 would collide with the first
request's block 0 in the consumer's accumulator.
"""
proc = FakeStreamProcess(
[
# request 1: two blocks (thinking + tool_use)
_stream("message_start", None),
_stream("content_block_start", 0, content_block={"type": "thinking"}),
_stream("content_block_stop", 0),
_stream("content_block_start", 1, content_block={"type": "tool_use"}),
_stream("content_block_stop", 1),
_stream("message_delta", None),
_stream("message_stop", None),
# request 2: one block, numbered from zero again
_stream("message_start", None),
_stream("content_block_start", 0, content_block={"type": "text"}),
_stream(
"content_block_delta", 0, delta={"type": "text_delta", "text": "hi"}
),
_stream("content_block_stop", 0),
_stream("message_stop", None),
_result(),
],
)
tm = StreamTurnManager(proc, include_partial_messages=True) # type: ignore[arg-type]
await tm.start()
events = await _drain(tm)
partials = [e for e in events if isinstance(e, StreamEvent)]
assert [(e.event["type"], e.event["index"]) for e in partials] == [
("content_block_start", 0),
("content_block_stop", 0),
("content_block_start", 1),
("content_block_stop", 1),
# request 2's block 0 lands at 2, after request 1's two blocks
("content_block_start", 2),
("content_block_delta", 2),
("content_block_stop", 2),
]
# Inner envelopes never leak — the caller owns the outer one.
assert not any(
e.event["type"].startswith("message_") for e in partials
)
# --- failures ------------------------------------------------------------
async def test_eof_before_result_raises_process_error() -> None:
proc = FakeStreamProcess([_assistant({"type": "text", "text": "partial"})])
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
with pytest.raises(ProcessError, match="exited before completing the turn"):
await _drain(tm)
async def test_error_result_with_no_content_raises() -> None:
proc = FakeStreamProcess(
[_result(subtype="error_during_execution", is_error=True, result="boom")]
)
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
with pytest.raises(ProcessError, match="error_during_execution"):
await _drain(tm)
async def test_error_result_after_content_is_surfaced_not_raised() -> None:
"""Partial output beats an exception that would discard it."""
proc = FakeStreamProcess(
[
_assistant({"type": "text", "text": "got this far"}),
_result(subtype="error_max_turns", is_error=True),
]
)
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
events = await _drain(tm)
assert isinstance(events[-1], ResultMessage)
assert events[-1].is_error is True
assert events[-1].subtype == "error_max_turns"
async def test_auth_failure_is_classified_from_stderr() -> None:
proc = FakeStreamProcess([], stderr="API Error: 403 please run /login")
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
with pytest.raises(AuthError):
await _drain(tm)
async def test_second_turn_reuses_the_same_process() -> None:
proc = FakeStreamProcess(
[
_assistant({"type": "text", "text": "one"}),
_result(),
_assistant({"type": "text", "text": "two"}),
_result(num_turns=1),
]
)
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
await _drain(tm, "first")
await _drain(tm, "second")
assert proc.sent == ["first", "second"]
assert tm.turn_count == 2
async def test_concurrent_turns_are_rejected() -> None:
proc = FakeStreamProcess([_result()])
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
gen = tm.send_user_message("a")
await anext(gen)
with pytest.raises(RuntimeError, match="turn is in progress"):
await _drain(tm, "b")
await gen.aclose()
# --- smoke (real claude) -------------------------------------------------
_SMOKE_ENV = "RUN_CLAUDE_SMOKE"
@pytest.mark.live
@pytest.mark.skipif(
not os.environ.get(_SMOKE_ENV), reason=f"set {_SMOKE_ENV}=1 to run against claude"
)
async def test_live_stream_transport_multi_turn(tmp_path: Any) -> None:
"""Two turns over one live headless claude, second recalling the first."""
from claude_code_api.backend import BackendOptions, ClaudeCodeBackend
opts = BackendOptions(
cwd=str(tmp_path),
model="sonnet",
system_prompt="Answer in one short sentence.",
dangerously_skip_permissions=True,
transport="stream_json",
include_partial_messages=True,
)
async with ClaudeCodeBackend(opts) as backend:
history: list[dict[str, Any]] = [
{"role": "user", "content": "Remember the codeword: OKAPI-13. Acknowledge."}
]
events = [ev async for ev in backend.complete(history)]
assert any(isinstance(e, StreamEvent) for e in events)
assert isinstance(events[-1], ResultMessage)
from claude_code_api.backend import synthesize_turn_messages
history = [*history, *synthesize_turn_messages(events)]
history.append({"role": "user", "content": "What was the codeword?"})
text = ""
async for ev in backend.complete(history):
if isinstance(ev, AssistantMessage):
text += "".join(
b.text for b in ev.content if hasattr(b, "text") # type: ignore[attr-defined]
)
assert "OKAPI" in text.upper()
# One process served both turns.
assert backend.live_session_count == 1