feat(stream): warm-up turn, because MCP servers aren't up on turn one

The CLI starts connecting its MCP servers when a turn starts and does
not wait for them, so the first turn of a session sees only built-in
tools. Measured on the pi with two HTTP MCP servers: turn 1 reports both
`pending` with 29 tools available, turn 2 reports both `connected` with
90. For an agent whose job is those tools, that first reply is silently
wrong — the model doesn't see them and answers as best it can.

The PTY transport hid this: its multi-second wait for the TUI to settle
happened to cover the connect. Nothing about the headless path does, and
nothing cheap fixes it — an 8s pause before the first prompt changed
nothing, a `/status` slash command returns in 134ms without touching the
MCP client, and a `control_request`/`initialize` handshake answers with
the command list and leaves the servers pending. Only a real turn does
it, so `warmup_turn` spends one deliberately: a few tokens and a couple
of seconds, once per session, against sessions that are pooled for the
whole conversation.

The price is two short messages at the head of the transcript, which is
why the default prompt reads as procedural rather than conversational.

Also log, at WARNING, any MCP server a turn starts without — this is a
failure with no other symptom, and it should be one grep away rather
than a mystery about the agent forgetting a tool it has.
This commit is contained in:
hh
2026-07-28 03:22:30 +02:00
parent b50911c3ed
commit 621da90623
3 changed files with 184 additions and 1 deletions
+68
View File
@@ -311,6 +311,74 @@ async def test_partial_message_indices_are_rebased_across_requests() -> None:
)
async def test_warmup_spends_a_turn_without_counting_it() -> None:
"""The warm-up must not be visible in the conversation's numbering.
It is a handshake with the CLI, not something the caller asked for,
so `ResultMessage.num_turns` on the first real reply should say 1.
"""
proc = FakeStreamProcess(
[
_assistant({"type": "text", "text": "ready"}),
_result(),
_assistant({"type": "text", "text": "real answer"}),
_result(),
]
)
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
await tm.warmup("warm me")
assert proc.sent == ["warm me"]
assert tm.turn_count == 0
await _drain(tm, "the real question")
assert proc.sent == ["warm me", "the real question"]
assert tm.turn_count == 1
async def test_warmup_failure_is_swallowed() -> None:
"""A failed warm-up must not take the session down with it."""
proc = FakeStreamProcess([], stderr="boom")
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
await tm.warmup() # would raise ProcessError if it propagated
assert tm.turn_count == 0
async def test_unconnected_mcp_servers_are_logged(
caplog: Any,
) -> None:
"""A turn starting without its MCP tools has to be greppable.
Nothing else surfaces it: the model simply doesn't see those tools
and answers anyway, which reads as the agent forgetting a capability.
"""
import logging
proc = FakeStreamProcess(
[
{
"type": "system",
"subtype": "init",
"session_id": "s",
"tools": ["Bash"],
"mcp_servers": [
{"name": "telegram", "status": "pending"},
{"name": "firefly", "status": "connected"},
],
},
_assistant({"type": "text", "text": "x"}),
_result(),
]
)
tm = StreamTurnManager(proc) # type: ignore[arg-type]
await tm.start()
with caplog.at_level(logging.WARNING, logger="claude_code_api.stream"):
await _drain(tm)
assert "telegram:pending" in caplog.text
assert "firefly" not in caplog.text
# --- failures ------------------------------------------------------------