Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 12 additions & 19 deletions periscope/panes.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,18 +368,13 @@ def recency_stamps_for(target: str) -> dict:
# active-op / prose by the no-space-before-`(` pattern.
TOOL_CALL_RE = re.compile(r"^[^\x00-\x7f]\s+\w+\(")

# Tool-result lines start with `⎿`. Claude Code renders rate-limit and
# transport failures as a normal tool result whose body begins with
# "API Error:" and then STOPS — it does not auto-retry; the turn just
# ends with the error sitting there until the user types something
# (`keep going`, etc.) to restart. When the most recent `⎿` line is an
# API Error the pane is silently blocked waiting on a human nudge. A
# later non-error `⎿` means a subsequent turn succeeded and the flag
# should clear. A stream cut mid-response ("The response stopped
# arriving") renders as an assistant `⏺` line instead (THI-436), so both
# glyphs count as the error.
# A tool result (`⎿ …`). An API error is a tool result (rate limit) or an
# assistant `⏺` line (stream cut, THI-436). A collapsed tool group
# (` Called fdy 6 times (ctrl+o to expand)`) prints no `⎿` (THI-455); its
# capital excludes a result's `… +3 lines` row and `› Message from @…`.
TOOL_RESULT_RE = re.compile(r"^\s*⎿\s+")
API_ERROR_RE = re.compile(r"^\s*[⎿⏺]\s+API Error:")
COLLAPSED_TOOL_GROUP_RE = re.compile(r"^\s+[A-Z]\w* .*\(ctrl\+o to expand\)\s*$")


def all_pane_ids() -> set[str]:
Expand Down Expand Up @@ -1046,23 +1041,21 @@ def _detect_asked_question(lines: list[str]) -> bool:


def _detect_api_error(lines: list[str]) -> bool:
"""True when the most recent `⎿` tool-result line is an API Error,
or a `⏺ API Error:` line sits below it.
"""True when Claude has written nothing since the newest API Error
line: no `⎿` tool result, `⏺` line or collapsed tool group below it.

Claude Code renders rate-limit / transport failures as a normal tool
result whose body begins "API Error:" (or, for a stream cut, as an
assistant `⏺ API Error:` line) and then STOPS — no auto-retry;
the turn just ends until the user nudges it (`keep going`, etc.). The
flag tracks "pane is silently blocked". A later non-error `⎿` means a
subsequent turn succeeded and the flag clears.
A rate-limit error ends the turn until the user nudges it (`keep
going`); after a stream cut Claude Code may carry on by itself. Either
way, output below the error means the API answered and the pane isn't
blocked.

Caller invokes this only for Claude panes — prose mentioning "API
Error" in a shell pane (logs, grep output) must not trip it.
"""
for line in reversed(lines):
if API_ERROR_RE.match(line):
return True
if TOOL_RESULT_RE.match(line):
if TOOL_RESULT_RE.match(line) or COLLAPSED_TOOL_GROUP_RE.match(line) or line.lstrip().startswith("⏺"):
return False
return False

Expand Down
2 changes: 1 addition & 1 deletion static/dist/app.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion static/src/split/Detail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export function PaneHeader({ w }) {
parts.push(<><span class="hsep">·</span><span class={`header-mem ${mh.cls}`} title={mh.title}>↻ {mh.label}</span></>);
}
if (w.api_error) {
parts.push(<><span class="hsep">·</span><span class="header-api-error" title="last tool result was an API error">⚠ API error</span></>);
parts.push(<><span class="hsep">·</span><span class="header-api-error" title="Claude hit an API error and has written nothing since">⚠ API error</span></>);
}
// After ↻ and API-error: its fixed width (styles.css .header-res) can't
// shift either chip.
Expand Down
120 changes: 113 additions & 7 deletions tests/test_panes.py
Original file line number Diff line number Diff line change
Expand Up @@ -738,15 +738,16 @@
# Claude Code renders rate-limit / transport failures as a normal `⎿` tool
# result whose body starts with "API Error:". We flag panes whose MOST
# RECENT tool result is an error so the dashboard can show a stuck-on-
# retry pane at a glance. A subsequent successful tool result clears it.
# retry pane at a glance. A subsequent successful tool result clears it,
# and so does a `⏺` reply to a prompt sent after the error.

API_ERROR_CASES: list[tuple[str, str, bool]] = [
# The screenshot Tom hit: three consecutive rate-limit hits, the user
# repeatedly typed `keep going`, and the bottom shows Claude resuming.
# The latest tool result above the resumed turn is still the API
# error → flag should be on so the card surfaces the stuck retry.
# The `⏺` under the last `keep going` is a reply → the errors belong to
# earlier turns, flag off (THI-455).
(
"rate-limited-stuck",
"rate-limited-resumed",
textwrap.dedent(f"""\
Reshaping the query — payload columns are huge.

Expand All @@ -773,6 +774,57 @@

⏺ Skipping reminder. One last lookup — there's a closest_store_by_address variant I haven't traced yet.
{STATUS_LINE}
"""),
False,
),
# The same pane before anything answers the nudge: `keep going` alone
# doesn't clear it.
(
"rate-limited-nudged",
textwrap.dedent(f"""\
❯ keep going
⎿ API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited

✻ Brewed for 0s

❯ keep going

✻ Crunching…
{STATUS_LINE}
"""),
True,
),
# The nudged turn has so far printed only a collapsed tool group: the
# API answered → off.
(
"rate-limited-resuming-calls",
textwrap.dedent(f"""\
❯ keep going
⎿ API Error: Server is temporarily limiting requests (not your usage limit) · Rate limited

✻ Brewed for 0s

❯ keep going

Called fdy 6 times (ctrl+o to expand)

✻ Crunching…
{STATUS_LINE}
"""),
False,
),
# A long error's `… +N lines` row is part of the error, not output after it.
(
"error-truncated-result",
textwrap.dedent(f"""\
Called fdy (ctrl+o to expand)
⎿ API Error: 500 {{"type":"error","error":{{"type":"api_error","message":"Internal server error"}}}}
… +3 lines (ctrl+o to expand)

✻ Brewed for 2s

❯
{STATUS_LINE}
"""),
True,
),
Expand Down Expand Up @@ -818,8 +870,7 @@
"""),
False,
),
# API error mid-buffer with no later `⎿` tool results — Claude is
# presumably retrying / waiting. Flag on.
# API error, then a `⏺` reply in the same turn: the API answered → off.
(
"error-then-only-text",
textwrap.dedent(f"""\
Expand All @@ -832,7 +883,7 @@
❯
{STATUS_LINE}
"""),
True,
False,
),
# THI-436: a stream cut mid-response renders as an assistant `⏺` line,
# not a `⎿` tool result — and a successful tool result sits above it,
Expand Down Expand Up @@ -889,6 +940,61 @@
"""),
False,
),
# THI-455, from a live pane: collapsed tool calls leave no `⎿` to clear
# the flag, and a later prompt got a full reply → off.
(
"collapsed-calls-later-reply",
textwrap.dedent(f"""\
❯ compare the clinic rankings from both sheets

Called faraday_internal 2 times (ctrl+o to expand)

⏺ API Error: The response stopped arriving. The response above may be incomplete.

Called faraday_internal 2 times (ctrl+o to expand)

⏺ Rankings by headroom count are nearly identical and track catchment size.

❯ add the clinic names to these codes

Called faraday_internal (ctrl+o to expand)

⏺ Here are the clinic codes from the comparison with their names.

✻ Churned for 34s · done 8:33 AM

{RULE}
❯ add this comparison to the handoff
{RULE}
{CUSTOM_STATUS_LINE}
⏵⏵ auto mode on (shift+tab to cycle) · ← 2 agents
"""),
False,
),
# The same pane when that turn ended: Claude Code carried on after the
# stream cut with no prompt in between → off.
(
"stream-cut-recovered-in-turn",
textwrap.dedent(f"""\
❯ compare the clinic rankings from both sheets

Called faraday_internal 2 times (ctrl+o to expand)

⏺ API Error: The response stopped arriving. The response above may be incomplete.

Called faraday_internal 2 times (ctrl+o to expand)

⏺ Rankings by headroom count are nearly identical and track catchment size.

✻ Brewed for 2m 10s · done 8:07 AM

{RULE}
❯
{RULE}
{CUSTOM_STATUS_LINE}
"""),
False,
),
]


Expand Down
Loading