From aa7beea1e014ba0dd8cd4980fee4b9302c57b212 Mon Sep 17 00:00:00 2001 From: h Date: Sun, 26 Jul 2026 00:49:41 +0200 Subject: [PATCH] fix(turn,backend): confirm submit on turn records only, resend lost paste, longer tui wait --- src/claude_code_api/backend.py | 2 +- src/claude_code_api/turn.py | 93 ++++++++++++++++++++++++---------- 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/src/claude_code_api/backend.py b/src/claude_code_api/backend.py index a125518..d74f1b2 100644 --- a/src/claude_code_api/backend.py +++ b/src/claude_code_api/backend.py @@ -89,7 +89,7 @@ class BackendOptions: history_injection_mode: HistoryInjectionMode = "native_jsonl" wait_for_turn_duration: bool = False include_meta_user: bool = False - startup_delay: float = 10.0 + startup_delay: float = 60.0 file_wait_timeout: float = 30.0 turn_duration_timeout: float = 5.0 diff --git a/src/claude_code_api/turn.py b/src/claude_code_api/turn.py index d28562a..e34fce4 100644 --- a/src/claude_code_api/turn.py +++ b/src/claude_code_api/turn.py @@ -52,7 +52,7 @@ _TERMINAL_STOP_REASONS: frozenset[str] = frozenset( _DEFAULT_FILE_WAIT_TIMEOUT = 30.0 _DEFAULT_TURN_DURATION_TIMEOUT = 5.0 -_DEFAULT_STARTUP_DELAY = 10.0 +_DEFAULT_STARTUP_DELAY = 60.0 # DECSET 2004 — claude's TUI enables bracketed paste mode early in init. # This is necessary (without it our paste markers parse as raw ANSI and @@ -169,8 +169,7 @@ class TurnManager: """ if self._started: _log.info( - "start: session_id=%s already started, skip", - self._pty.session_id, + "start: session_id=%s already started, skip", self._pty.session_id ) return _log.info( @@ -197,12 +196,26 @@ class TurnManager: _TUI_READY_MARKER, timeout=self._startup_delay ) t_marker = loop.time() - t0 - _log.info( - "start: session_id=%s marker DECSET2004 %s after %.2fs", - self._pty.session_id, - "FOUND" if marker_found else "TIMED_OUT", - t_marker, - ) + if marker_found: + _log.info( + "start: session_id=%s marker DECSET2004 FOUND after %.2fs", + self._pty.session_id, + t_marker, + ) + else: + # Proceeding anyway is a coin flip: without bracketed + # paste enabled our ESC[200~ framing is parsed as junk + # and the prompt vanishes silently. We still continue + # rather than fail the turn — `send_user_message` + # notices the missing turn record and re-sends — but + # this is the line to grep for when a prompt is lost. + _log.warning( + "start: session_id=%s marker DECSET2004 TIMED_OUT after " + "%.2fs — TUI not ready, the first prompt may be dropped " + "(raise startup_delay if this recurs)", + self._pty.session_id, + t_marker, + ) # Phase 2: wait for the TUI render burst to settle — # consecutive seconds of no new PTY bytes. Bounded by what's # left of `startup_delay`. @@ -226,10 +239,7 @@ class TurnManager: ) await asyncio.sleep(self._startup_delay) self._started = True - _log.info( - "start: session_id=%s READY", - self._pty.session_id, - ) + _log.info("start: session_id=%s READY", self._pty.session_id) async def send_user_message(self, text: str) -> AsyncIterator[Event]: """Send `text` as a user prompt and stream events until turn-end. @@ -299,12 +309,12 @@ class TurnManager: while True: records = await self._watcher.read_once() - if records and not submit_confirmed: + if records and not submit_confirmed and _has_turn_record(records): submit_confirmed = True if submit_attempts > 1: _log.info( "send_user_message: session_id=%s SUBMIT confirmed " - "after %d Enter(s), %.1fs", + "after %d attempt(s), %.1fs", sid, submit_attempts, loop.time() - t_loop_start, @@ -354,15 +364,32 @@ class TurnManager: f"{now - t_loop_start:.0f}s ({self._watcher.path})" ), ) - _log.warning( - "send_user_message: session_id=%s no JSONL record %.1fs " - "after Enter #%d — paste likely stuck in the input box, " - "re-pressing Enter", - sid, - _SUBMIT_CONFIRM_WINDOW, - submit_attempts, - ) - await self._send_submit_key() + # Escalate: a swallowed Enter and a swallowed paste + # look identical from here, but need different + # remedies. Try the cheap, duplicate-proof one + # first (a bare Enter is a no-op on an empty input + # box); only re-send the whole prompt once that has + # failed, since the paste itself must then have + # been eaten — e.g. written before claude's TUI + # enabled bracketed paste, which turns our markers + # into discarded junk. + if submit_attempts == 1: + _log.warning( + "send_user_message: session_id=%s no turn record " + "%.1fs after Enter — re-pressing Enter", + sid, + _SUBMIT_CONFIRM_WINDOW, + ) + await self._send_submit_key() + else: + _log.warning( + "send_user_message: session_id=%s still no turn " + "record after attempt #%d — the paste itself was " + "lost, re-sending the whole prompt", + sid, + submit_attempts, + ) + await self._pty.write(text) submit_attempts += 1 submit_deadline = now + _SUBMIT_CONFIRM_WINDOW # Heartbeat log every 10s so a hung wait is visible. @@ -412,9 +439,7 @@ class TurnManager: ) except MessageParseError as exc: _log.warning( - "send_user_message: session_id=%s parse error: %s", - sid, - exc, + "send_user_message: session_id=%s parse error: %s", sid, exc ) if self._on_parse_error is not None: with contextlib.suppress(Exception): @@ -556,6 +581,20 @@ class TurnManager: await self.aclose() +def _has_turn_record(records: Iterable[JsonlRecord]) -> bool: + """Does this batch prove claude actually accepted our prompt? + + Only ``user`` / ``assistant`` records do. A session writes a burst of + bookkeeping on startup — ``mode``, ``permission-mode``, and, under + ``--remote-control``, ``bridge-session`` plus a ``system`` / + ``bridge_status`` heartbeat — none of which depend on a prompt having + been submitted. Treating "any record at all" as proof made the submit + check pass vacuously on exactly the cold-start turns it exists to + catch. + """ + return any(rec.get("type") in ("user", "assistant") for rec in records) + + def _extract_duration_ms(data: dict[str, Any]) -> int | None: """Pull a turn-duration value out of a `system.subtype=turn_duration` record.""" for key in ("durationMs", "duration_ms"):