Files
kandev/scripts/probe-claude-midturn-fold
2026-08-24 11:09:03 +00:00

442 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
# probe-claude-midturn-fold — does the Claude CLI fold a stream-json message
# into a turn that is already running?
#
# Context: docs/specs/platform/requirements/background-work-liveness.md scopes out "mid-turn
# steering for agents without concurrent-prompt capability", and ADR-0049 admits
# a prompt handoff only for adapters "fixture-tested to accept the next human
# prompt echo while the earlier RPC remains open". Issue #1607 assumes the
# capability is absent for Claude over ACP. This probe settles it against the
# real binary, with no Kandev and no ACP bridge in the path: raw stream-json on
# stdin/stdout.
#
# Method: prompt A runs two sequential `sleep`s. While the first one is
# executing, prompt B is written to stdin with a client-supplied uuid. Whether
# the CLI folds B into the running turn or holds it for the next turn is decided
# by three independent observations, in descending order of trust:
#
# 1. result count — a fold answers TWO prompts with ONE `result` frame.
# 2. B's replayed echo — arrives before the first `result` iff B was folded
# (requires --replay-user-messages).
# 3. command_lifecycle — B's uuid reaching `started` before the first `result`.
# Corroboration only: the frame is marked @internal and
# may be gated or absent on older CLIs.
#
# Observation 3 is raw CLI observation only. It is NOT Kandev's admission gate:
# Kandev negotiates steering over ACP, on the `initialize` advertisement
# `agentCapabilities._meta.claudeCode.promptQueueing` (read in
# acp/prompt_queueing.go). `msg_lifecycle_v1` on the stream-json init frame is
# not advertised over ACP at all, so it can corroborate the fold here without
# ever being something Kandev could gate on.
#
# Usage:
# scripts/probe-claude-midturn-fold [options]
#
# --out <path> JSONL frame log (default: ./midturn-fold-<ts>.jsonl)
# --model <name> Model for the probe session (default: sonnet)
# --sleep <seconds> Length of each sleep in prompt A (default: 20)
# --delay <seconds> Wait after seeing A's first tool_use before sending B
# (default: 3)
# --after-seconds <n> Send B n seconds after start instead of waiting for a
# tool_use. Use when A is not tool-shaped.
# --linger <seconds> After the first result, how long to wait for B to run
# as its own turn (default: 90)
# --timeout <seconds> Hard kill (default: 300)
# --budget <usd> Passed to --max-budget-usd (default: 1.00)
# --bypass Use --permission-mode bypassPermissions instead of the
# narrow Bash(sleep:*) allowlist. Only for a sandbox.
# --prompt-a <text> Override prompt A (e.g. to probe run_in_background)
# --prompt-b <text> Override prompt B
# --claude <path> claude binary (default: first on PATH)
# --dry-run Print the command and the two prompts, spend nothing.
#
# Output: the JSONL log plus a verdict block on stdout. One paid turn per run.
#
# To probe the background-work half of the question instead — do a predecessor's
# backgrounded tool calls survive the boundary? — keep the same machinery and
# swap the prompt:
#
# scripts/probe-claude-midturn-fold \
# --prompt-a 'Start `sleep 45` with run_in_background, then tell me its task id.' \
# --after-seconds 12 --bypass
#
# then read the log for tool frames on that task id arriving after B's echo.
import argparse
import json
import os
import queue
import shutil
import signal
import subprocess
import sys
import threading
import time
import uuid
def _kill_process_group(proc):
"""SIGKILL the CLI's whole process group, falling back to the direct child.
The Claude CLI spawns tool subprocesses that outlive a plain proc.kill();
signalling the group reaps the entire tree."""
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
PROMPT_A = (
"Run exactly this shell command with the Bash tool: sleep {s}\n"
"When it finishes, run exactly this one: sleep {s}\n"
"Then reply with the single word DONE. Do not run anything else and do not "
"combine the two commands."
)
PROMPT_B = (
"Change of plan: skip the second sleep and reply with the single word "
"STEERED instead."
)
def parse_args():
p = argparse.ArgumentParser(add_help=True)
p.add_argument("--out")
p.add_argument("--model", default="sonnet")
p.add_argument("--sleep", type=int, default=20)
p.add_argument("--delay", type=float, default=3.0)
p.add_argument("--after-seconds", type=float, default=None)
p.add_argument("--linger", type=float, default=90.0)
p.add_argument("--timeout", type=float, default=300.0)
p.add_argument("--budget", default="1.00")
p.add_argument("--bypass", action="store_true")
p.add_argument("--prompt-a")
p.add_argument("--prompt-b")
p.add_argument("--claude")
p.add_argument("--dry-run", action="store_true")
return p.parse_args()
def build_command(args, session_id):
claude = args.claude or shutil.which("claude")
if not claude:
sys.exit("probe: no `claude` on PATH; pass --claude <path>")
cmd = [
claude,
"--print",
"--input-format", "stream-json",
"--output-format", "stream-json",
"--replay-user-messages",
"--verbose",
# Asks for a minimal session and writes nothing to session history.
# Note: an observed 2.1.220 run still listed skills and plugins on the
# init frame, so treat this as best-effort isolation, not a guarantee —
# check the init frame in the log if a customization could skew a result.
"--safe-mode",
"--no-session-persistence",
"--session-id", session_id,
"--model", args.model,
"--tools", "Bash",
"--max-budget-usd", str(args.budget),
]
if args.bypass:
cmd += ["--permission-mode", "bypassPermissions"]
else:
# Narrow enough that `sleep N` runs unattended and nothing else does.
cmd += ["--allowedTools", "Bash(sleep:*)"]
return cmd
def user_frame(text, session_id, msg_uuid):
return {
"type": "user",
"message": {"role": "user", "content": [{"type": "text", "text": text}]},
"parent_tool_use_id": None,
"session_id": session_id,
"uuid": msg_uuid,
}
class Recorder:
"""Frame log with a monotonic clock shared by both directions."""
def __init__(self):
self.t0 = time.monotonic()
self.frames = []
self.lock = threading.Lock()
def ms(self):
return round((time.monotonic() - self.t0) * 1000)
def add(self, direction, frame, raw=None):
entry = {"t_ms": self.ms(), "dir": direction}
if frame is not None:
entry["frame"] = frame
if raw is not None:
entry["raw"] = raw
with self.lock:
self.frames.append(entry)
return entry
def note(self, event, **fields):
return self.add("probe", {"probe_event": event, **fields})
def write(self, path):
with open(path, "w", encoding="utf-8") as fh:
with self.lock:
for entry in self.frames:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
class StreamEOF:
"""One reader thread reached EOF.
Carries whether it was the protocol (JSON) stream. A bare sentinel would be
indistinguishable between stdout and stderr, and the CLI routinely closes
stderr first — breaking the main loop there would end the probe while stdout
can still deliver the very frames the verdict is read from.
"""
__slots__ = ("direction", "is_protocol")
def __init__(self, direction, is_protocol):
self.direction = direction
self.is_protocol = is_protocol
def reader(stream, direction, rec, events, is_json):
# readline(), not `for line in stream`: iteration fills an 8KB read-ahead
# buffer, which arrives as one burst and flattens the timing this probe is
# measuring. readline returns as soon as a newline lands.
while True:
line = stream.readline()
if line == "":
break
line = line.rstrip("\n")
if not line:
continue
if not is_json:
rec.add(direction, None, raw=line)
continue
try:
frame = json.loads(line)
except json.JSONDecodeError:
rec.add(direction, None, raw=line)
continue
entry = rec.add(direction, frame)
events.put((entry["t_ms"], frame))
events.put(StreamEOF(direction, is_json))
def tool_use_names(frame):
if frame.get("type") != "assistant":
return []
content = frame.get("message", {}).get("content") or []
if not isinstance(content, list):
return []
return [b.get("name") for b in content if isinstance(b, dict) and b.get("type") == "tool_use"]
def main():
args = parse_args()
session_id = str(uuid.uuid4())
uuid_a, uuid_b = str(uuid.uuid4()), str(uuid.uuid4())
prompt_a = args.prompt_a or PROMPT_A.format(s=args.sleep)
prompt_b = args.prompt_b or PROMPT_B
cmd = build_command(args, session_id)
if args.dry_run:
print(" ".join(cmd))
print("\n--- prompt A ---\n" + prompt_a)
print("\n--- prompt B ---\n" + prompt_b)
return 0
out_path = args.out or os.path.abspath(f"midturn-fold-{int(time.time())}.jsonl")
rec = Recorder()
rec.note("start", cmd=cmd, session_id=session_id,
uuid_a=uuid_a, uuid_b=uuid_b, cli_cwd=os.getcwd())
# start_new_session puts the CLI in its own process group so a forced
# cleanup can signal the whole tree (CLI + tool subprocesses), not just the
# direct child.
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
start_new_session=True,
)
events = queue.Queue()
readers = [
threading.Thread(target=reader, args=(proc.stdout, "out", rec, events, True),
daemon=True),
threading.Thread(target=reader, args=(proc.stderr, "err", rec, events, False),
daemon=True),
]
for t in readers:
t.start()
def send(frame, label):
rec.add("in", frame)
rec.note(label)
try:
proc.stdin.write(json.dumps(frame) + "\n")
proc.stdin.flush()
return True
except (BrokenPipeError, ValueError):
rec.note("send_failed", label=label)
return False
send(user_frame(prompt_a, session_id, uuid_a), "sent_a")
# --- observation state -------------------------------------------------
init = None
sent_b_at = None
first_tool_at = None
result_times = []
b_echo_at = None
b_lifecycle = [] # [(t_ms, state)]
tool_results_after_b = 0
verdict_deadline = None
hard_deadline = rec.t0 + args.timeout
# Arrival order, not elapsed milliseconds, decides fold vs queue: a fold can
# put B's echo and the result in the same millisecond, and order is what the
# protocol actually guarantees.
pre_result_echo = False
pre_result_start = False
def maybe_send_b(now_ms):
nonlocal sent_b_at
if sent_b_at is not None:
return
if init is None:
# Before the init frame the session does not exist yet, so B would
# not be landing mid-turn — it would be racing startup.
return
if args.after_seconds is not None:
if now_ms >= args.after_seconds * 1000:
if send(user_frame(prompt_b, session_id, uuid_b), "sent_b"):
sent_b_at = rec.ms()
return
if first_tool_at is not None and now_ms >= first_tool_at + args.delay * 1000:
if send(user_frame(prompt_b, session_id, uuid_b), "sent_b"):
sent_b_at = rec.ms()
while True:
if time.monotonic() > hard_deadline:
rec.note("hard_timeout")
break
try:
item = events.get(timeout=0.25)
except queue.Empty:
maybe_send_b(rec.ms())
if verdict_deadline is not None and time.monotonic() > verdict_deadline:
rec.note("linger_expired")
break
continue
if isinstance(item, StreamEOF):
rec.note("stream_eof", stream=item.direction)
if item.is_protocol:
break
continue
t_ms, frame = item
ftype = frame.get("type")
if ftype == "system" and frame.get("subtype") == "init":
init = frame
elif ftype == "result":
result_times.append(t_ms)
if len(result_times) == 1:
# Either B already landed inside this turn (fold) or it has to
# run as its own turn (queue). A fold is already decided, so
# only drain briefly; a queue needs the full second-turn window.
folded = pre_result_echo or pre_result_start
verdict_deadline = time.monotonic() + (3.0 if folded else args.linger)
else:
rec.note("second_result")
break
elif ftype == "command_lifecycle" and frame.get("command_uuid") == uuid_b:
b_lifecycle.append((t_ms, frame.get("state")))
if frame.get("state") == "started" and not result_times:
pre_result_start = True
elif ftype == "user":
if frame.get("uuid") == uuid_b and b_echo_at is None:
b_echo_at = t_ms
pre_result_echo = not result_times
rec.note("b_echo_observed", before_first_result=pre_result_echo)
content = frame.get("message", {}).get("content") or []
if b_echo_at is not None and isinstance(content, list):
if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content):
tool_results_after_b += 1
elif ftype == "assistant":
if first_tool_at is None and tool_use_names(frame):
first_tool_at = t_ms
rec.note("first_tool_use_observed", tools=tool_use_names(frame))
maybe_send_b(rec.ms())
if verdict_deadline is not None and time.monotonic() > verdict_deadline:
rec.note("linger_expired")
break
# --- shutdown ----------------------------------------------------------
try:
proc.stdin.close()
except (BrokenPipeError, ValueError):
pass
try:
proc.wait(timeout=20)
except subprocess.TimeoutExpired:
rec.note("kill_after_stdin_close")
_kill_process_group(proc)
proc.wait(timeout=10)
# Drain the readers before the log is written: frames that arrived between
# the loop breaking and the process exiting are still being appended, and a
# probe that silently drops its last frames is worse than no probe.
for t in readers:
t.join(timeout=5)
first_result = result_times[0] if result_times else None
started = [t for t, s in b_lifecycle if s == "started"]
b_started = started[0] if started else None
if sent_b_at is None:
verdict = "INCONCLUSIVE — prompt B was never sent (no tool_use seen; try --after-seconds)"
elif not result_times:
verdict = "INCONCLUSIVE — no result frame; check the log for auth, permission or budget errors"
elif pre_result_echo or pre_result_start:
verdict = "FOLD — B entered the running turn before it ended (mid-turn steering works)"
elif len(result_times) >= 2 or b_started is not None or b_echo_at is not None:
verdict = "QUEUE — B ran only after the first turn ended (turn-boundary drain)"
elif b_lifecycle:
verdict = "DISCARDED — B was acknowledged but never ran; see lifecycle states"
else:
verdict = "NO_LIFECYCLE — no echo and no lifecycle for B; it was dropped, or this CLI does not report either"
rec.note("verdict", text=verdict)
rec.write(out_path)
caps = (init or {}).get("capabilities")
print(f"\nverdict: {verdict}\n")
version = (init or {}).get("claude_code_version") or (init or {}).get("version", "?")
print(f" cli version {version}")
print(f" permission mode {(init or {}).get('permissionMode', '?')}")
print(f" capabilities {caps if caps is not None else 'not advertised'}")
print(f" msg_lifecycle_v1 {'yes' if caps and 'msg_lifecycle_v1' in caps else 'no'}")
print(f" result frames {len(result_times)} (1 = one turn answered both prompts)")
print(f" B sent at {sent_b_at} ms")
print(f" first tool_use at {first_tool_at} ms")
print(f" first result at {first_result} ms")
print(f" B echo at "
f" {b_echo_at} ms" + (" (before first result)" if pre_result_echo else ""))
print(f" B lifecycle {b_lifecycle or 'none'}")
print(f" tool_results after B {tool_results_after_b} "
f"(>0 = predecessor tool work continued past the boundary)")
print(f"\n frames: {out_path}\n")
return 0
if __name__ == "__main__":
sys.exit(main())