Chat: the Interrupt button does nothing #38

Open
opened 2026-07-08 01:24:51 +02:00 by dominik.polakovics · 3 comments

This was generated by AI during triage.

Summary

In the embedded chat (/runs/:id), while the agent is working the Send button
morphs into the pulsing Interrupt button. Clicking it has no visible effect
— the agent keeps going and nothing appears to happen.

Reproduction

  1. Open a live run's chat at /runs/:id.
  2. While the agent is working (the button is pulsing "Interrupt"), click it.
  3. Observed: no visible effect; the turn continues.
    Expected: the current turn is interrupted (the agent stops).

Notes for triage

The interrupt call path is fully wired and covered by passing tests, so this is
likely a runtime / version-coupling problem rather than a broken path:

  • FE button + handler: web/src/routes/RunChat.tsx:901-923 (onClick :913),
    action createInterrupt at RunChat.tsx:754-769; the Interrupt-vs-Send gate
    is working = () => state === 'working' at RunChat.tsx:793.
  • API: interruptRunPOST /runs/:id/interrupt (web/src/api.ts:534-536).
  • BE: handleRunInterrupt (internal/httpapi/chat.go:198-209) →
    chat.Interrupt (internal/chat/chat.go:324-333) → Provider.Interrupt
    (internal/provider/claudecode/interact.go:62-67) → SendNamedKeys(…, "Escape")
    tmux send-keys -t <session> -- Escape (internal/tmuxx/tmuxx.go:294-303).
  • API errors surface via the error banner, so a silent no-op implies the POST
    returns 204 but the Escape has no downstream effect.

Leading suspects to confirm:

  1. Single-Escape gesture is pinned to Claude Code 2.1.198 and is explicitly
    not test-covered (internal/compat/compat.md:346-357). If the installed
    Claude Code changed its interrupt gesture (e.g. double-Escape, or Escape now
    only clears the composer line), the lone Escape at interact.go:63 is a
    no-op.
  2. Stale working gate — the working state comes from the transcript-tail
    deriveState on a ~1s poll; a stale tail can keep the button pulsing after
    the agent has already idled, so the Escape lands on an idle prompt.
  3. tmux pane targeting — Escape targets the session's active pane by name; if
    the claude pane isn't the active pane, it is silently dropped.

The working / deriveState coupling is shared with the separate report that a
running Claude Code workflow is not recognized as "working".

> *This was generated by AI during triage.* ## Summary In the embedded chat (`/runs/:id`), while the agent is working the Send button morphs into the pulsing **Interrupt** button. Clicking it has no visible effect — the agent keeps going and nothing appears to happen. ## Reproduction 1. Open a live run's chat at `/runs/:id`. 2. While the agent is working (the button is pulsing "Interrupt"), click it. 3. **Observed:** no visible effect; the turn continues. **Expected:** the current turn is interrupted (the agent stops). ## Notes for triage The interrupt call path is fully wired and covered by passing tests, so this is likely a runtime / version-coupling problem rather than a broken path: - FE button + handler: `web/src/routes/RunChat.tsx:901-923` (onClick :913), action `createInterrupt` at `RunChat.tsx:754-769`; the Interrupt-vs-Send gate is `working = () => state === 'working'` at `RunChat.tsx:793`. - API: `interruptRun` → `POST /runs/:id/interrupt` (`web/src/api.ts:534-536`). - BE: `handleRunInterrupt` (`internal/httpapi/chat.go:198-209`) → `chat.Interrupt` (`internal/chat/chat.go:324-333`) → `Provider.Interrupt` (`internal/provider/claudecode/interact.go:62-67`) → `SendNamedKeys(…, "Escape")` → `tmux send-keys -t <session> -- Escape` (`internal/tmuxx/tmuxx.go:294-303`). - API errors surface via the error banner, so a *silent* no-op implies the POST returns 204 but the Escape has no downstream effect. Leading suspects to confirm: 1. **Single-Escape gesture is pinned to Claude Code 2.1.198** and is explicitly not test-covered (`internal/compat/compat.md:346-357`). If the installed Claude Code changed its interrupt gesture (e.g. double-Escape, or Escape now only clears the composer line), the lone `Escape` at `interact.go:63` is a no-op. 2. **Stale `working` gate** — the working state comes from the transcript-tail `deriveState` on a ~1s poll; a stale tail can keep the button pulsing after the agent has already idled, so the Escape lands on an idle prompt. 3. **tmux pane targeting** — Escape targets the session's active pane by name; if the claude pane isn't the active pane, it is silently dropped. The `working` / `deriveState` coupling is shared with the separate report that a running Claude Code workflow is not recognized as "working".
Author
Owner

Root cause confirmed: suspect #2 (stale/false working), triggered by a /clear command echo

Reproduced and live-verified against real transcripts on the host. This is your suspect #2 — a false working state where Escape lands on an idle prompt — with a concrete, deterministic trigger. Suspect #1 (the single-Escape gesture) is not at fault in this case: the interrupt POST works, there is simply nothing to interrupt.

Trigger

Type /clear (or /rewind) in the composer. After issue #34 made lab follow the transcript rotation, the chat correctly empties — but the composer then gets stuck showing the pulsing Interrupt with no Send, and tapping it does nothing (stays Interrupt). It generalizes to any trailing local command echo with no following model turn.

Mechanism (evidence chain)

  1. /clear calls setConversationId(newUUID) → a fresh transcript file B (compat.md §5). lab now follows the rotation, so it reads B.
  2. B has no assistant line at all (the model is never invoked). From a real post-/clear session on the host (acc89838…), B's line types are mode, user, user, system, user, deferred_tools_delta, attachment, ….
  3. One of those user lines is the /clear echo, and its isMeta is absent → false:
    {"type":"user","message":{"role":"user","content":
      "<command-name>/clear</command-name>\n <command-message>clear</command-message>\n <command-args></command-args>"}}
    
    So the isMeta skip at internal/provider/claudecode/chat.go:191 does not catch it — it is emitted as a user:text, and it is the last content-bearing event (only skipped deltas/attachments follow).
  4. deriveState maps a trailing user:textStateWorking (internal/provider/claudecode/chat.go:238-242). Already codified by the existing test internal/provider/claudecode/chat_test.go:85 — the case literally named "user waits" asserts StateWorking.
  5. svc.Read() (internal/chat/chat.go:200-227) therefore returns working on every read of B (the live-signal overlay only ever forces question/needs_input, never working).

Why both symptoms follow

  • No Send button: the SPA gates the Send↔Interrupt morph purely on state === 'working' (web/src/routes/RunChat.tsx:1158, morph button :1278-1300), so a false working shows the pulsing Interrupt and hides Send.
  • Interrupt does nothing / stays Interrupt (this issue): the Interrupt POST fires Escape fine, but Claude is idle at the fresh prompt (nothing to interrupt), and B's tail is still the /clear user line — so the next deriveState is working again and the button never flips back. This is exactly "Escape lands on an idle prompt" from suspect #2, and it's why a refetch never self-corrects.

Proposed fix

In the claudecode parser, treat a slash-command echo (user text wrapped in <command-name> / <command-message>, plus the <local-command-stdout> output wrapper) as non-conversational — skip it exactly like isMeta at chat.go:191. Then B has no trailing user turn → deriveStateStateIdle → composer shows Send, and Interrupt is correctly absent. Bonus: it stops the raw <command-name>/clear…> tag from rendering as a user bubble. Rotation detection stays "by effect" (compat §5) — this only reclassifies a command breadcrumb, it does not parse the command to detect the rotation.

Regression seam: add a case to TestParseTranscript_stateEdges (chat_test.go:79) — a transcript ending in a /clear echo → expect StateIdle. Fails today (working), passes after.

Relationship to #39

Same deriveState coupling, opposite direction: #39 is a false idle (real background work misread as needs-input/idle); this is a false working (a command breadcrumb misread as working). Both are "state derived from only the last transcript block."

Note: the FE line references in the original triage notes (RunChat.tsx:901-923, :793, :754-769) have drifted with the recent chat refactors; current equivalents are :1278-1300 (morph button), :1158 (working() gate), :1116-1131 (createInterrupt).

Analysis by an AI agent; not yet implemented.

## Root cause confirmed: suspect #2 (stale/false `working`), triggered by a `/clear` command echo Reproduced and **live-verified against real transcripts on the host**. This is your suspect #2 — a *false* `working` state where Escape lands on an idle prompt — with a concrete, deterministic trigger. Suspect #1 (the single-Escape gesture) is **not** at fault in this case: the interrupt POST works, there is simply nothing to interrupt. ### Trigger Type `/clear` (or `/rewind`) in the composer. After issue #34 made lab follow the transcript rotation, the chat correctly empties — but the composer then gets stuck showing the pulsing **Interrupt** with no Send, and tapping it does nothing (stays Interrupt). It generalizes to any trailing *local* command echo with no following model turn. ### Mechanism (evidence chain) 1. `/clear` calls `setConversationId(newUUID)` → a fresh transcript file B (compat.md §5). lab now follows the rotation, so it reads B. 2. **B has no `assistant` line at all** (the model is never invoked). From a real post-`/clear` session on the host (`acc89838…`), B's line types are `mode, user, user, system, user, deferred_tools_delta, attachment, …`. 3. One of those `user` lines is the `/clear` echo, and its `isMeta` is **absent → false**: ```json {"type":"user","message":{"role":"user","content": "<command-name>/clear</command-name>\n <command-message>clear</command-message>\n <command-args></command-args>"}} ``` So the `isMeta` skip at `internal/provider/claudecode/chat.go:191` does **not** catch it — it is emitted as a `user:text`, and it is the **last content-bearing event** (only skipped deltas/attachments follow). 4. `deriveState` maps a trailing `user:text` → `StateWorking` (`internal/provider/claudecode/chat.go:238-242`). Already codified by the existing test `internal/provider/claudecode/chat_test.go:85` — the case literally named `"user waits"` asserts `StateWorking`. 5. `svc.Read()` (`internal/chat/chat.go:200-227`) therefore returns `working` on **every** read of B (the live-signal overlay only ever forces `question`/`needs_input`, never `working`). ### Why both symptoms follow - **No Send button:** the SPA gates the Send↔Interrupt morph purely on `state === 'working'` (`web/src/routes/RunChat.tsx:1158`, morph button `:1278-1300`), so a false `working` shows the pulsing Interrupt and hides Send. - **Interrupt does nothing / stays Interrupt (this issue):** the Interrupt POST fires Escape fine, but Claude is idle at the fresh prompt (nothing to interrupt), and B's tail is still the `/clear` user line — so the next `deriveState` is `working` again and the button never flips back. This is exactly "Escape lands on an idle prompt" from suspect #2, and it's why a refetch never self-corrects. ### Proposed fix In the claudecode parser, treat a **slash-command echo** (user text wrapped in `<command-name>` / `<command-message>`, plus the `<local-command-stdout>` output wrapper) as non-conversational — skip it exactly like `isMeta` at `chat.go:191`. Then B has no trailing user turn → `deriveState` → `StateIdle` → composer shows Send, and Interrupt is correctly absent. Bonus: it stops the raw `<command-name>/clear…>` tag from rendering as a user bubble. Rotation detection stays "by effect" (compat §5) — this only reclassifies a command breadcrumb, it does not parse the command to detect the rotation. Regression seam: add a case to `TestParseTranscript_stateEdges` (`chat_test.go:79`) — a transcript ending in a `/clear` echo → expect `StateIdle`. Fails today (`working`), passes after. ### Relationship to #39 Same `deriveState` coupling, opposite direction: #39 is a *false idle* (real background work misread as needs-input/idle); this is a *false working* (a command breadcrumb misread as working). Both are "state derived from only the last transcript block." > Note: the FE line references in the original triage notes (`RunChat.tsx:901-923`, `:793`, `:754-769`) have drifted with the recent chat refactors; current equivalents are `:1278-1300` (morph button), `:1158` (`working()` gate), `:1116-1131` (`createInterrupt`). *Analysis by an AI agent; not yet implemented.*
Author
Owner

This was generated by AI during triage.

Ready for Human — re-scoped

The false-working case that /clear triggers has been split into #45 (agent-ready, root cause confirmed). With that removed, this issue is now specifically about interrupt failing while the agent is genuinely working — the original repro here (the turn continues after clicking Interrupt).

Category: bug
Summary: Clicking Interrupt while an agent is genuinely mid-turn does not stop the turn.

Current behavior:
With a genuinely-working agent, the composer correctly shows the Interrupt morph. Clicking it POSTs the interrupt, which sends the interrupt gesture to the agent's terminal — a single Escape, pinned to Claude Code 2.1.198 and explicitly not test-covered (compat §5). The turn continues; nothing visible happens. (Note: many "interrupt does nothing" observations are actually the #45 false-working state, where Escape lands on an idle prompt. This issue is the residue: the genuine mid-turn case.)

Desired behavior:
Interrupting a genuinely-working agent stops the current turn.

Why human (not agent-delegable):
The fix hinges on the correct interrupt gesture for the installed Claude Code, which can only be established by driving a live, genuinely-working agent session and observing what actually stops it — single vs double Escape, timing between keystrokes, and whether the interrupt targets the agent's active pane. This is interactive, environment-coupled verification an AFK agent cannot do headlessly. Splitting off #45 is what leaves the gesture and pane-targeting suspects as the remaining explanation for the genuine case.

What to verify:

  • Reproduce with a genuinely-working agent (not the post-/clear stuck state — that's #45).
  • Confirm whether the single-Escape gesture interrupts the installed Claude Code version; if not, find the gesture that does.
  • Confirm the interrupt keystroke targets the agent's actual pane (the pane-targeting suspect).

Acceptance criteria:

  • With a genuinely-working agent, clicking Interrupt stops the current turn.
  • The correct interrupt gesture for the installed Claude Code is captured and pinned in the provider compat notes, with the version it was verified against.
  • A documented manual verification procedure (or an automated test, if a seam exists) accompanies the fix.

Out of scope:

  • The /clear / command-echo false-working case → #45.
  • The workflow / background-subagent false-idle case → #39.
> *This was generated by AI during triage.* ## Ready for Human — re-scoped The false-`working` case that `/clear` triggers has been split into #45 (agent-ready, root cause confirmed). With that removed, this issue is now specifically about **interrupt failing while the agent is genuinely working** — the original repro here (the turn continues after clicking Interrupt). **Category:** bug **Summary:** Clicking Interrupt while an agent is genuinely mid-turn does not stop the turn. **Current behavior:** With a genuinely-working agent, the composer correctly shows the Interrupt morph. Clicking it POSTs the interrupt, which sends the interrupt *gesture* to the agent's terminal — a single `Escape`, pinned to Claude Code 2.1.198 and explicitly not test-covered (compat §5). The turn continues; nothing visible happens. (Note: many "interrupt does nothing" observations are actually the #45 false-`working` state, where Escape lands on an idle prompt. This issue is the residue: the *genuine* mid-turn case.) **Desired behavior:** Interrupting a genuinely-working agent stops the current turn. **Why human (not agent-delegable):** The fix hinges on the correct interrupt *gesture* for the installed Claude Code, which can only be established by driving a live, genuinely-working agent session and observing what actually stops it — single vs double `Escape`, timing between keystrokes, and whether the interrupt targets the agent's active pane. This is interactive, environment-coupled verification an AFK agent cannot do headlessly. Splitting off #45 is what leaves the gesture and pane-targeting suspects as the remaining explanation for the genuine case. **What to verify:** - Reproduce with a genuinely-working agent (not the post-`/clear` stuck state — that's #45). - Confirm whether the single-`Escape` gesture interrupts the installed Claude Code version; if not, find the gesture that does. - Confirm the interrupt keystroke targets the agent's actual pane (the pane-targeting suspect). **Acceptance criteria:** - [ ] With a genuinely-working agent, clicking Interrupt stops the current turn. - [ ] The correct interrupt gesture for the installed Claude Code is captured and pinned in the provider compat notes, with the version it was verified against. - [ ] A documented manual verification procedure (or an automated test, if a seam exists) accompanies the fix. **Out of scope:** - The `/clear` / command-echo false-`working` case → #45. - The workflow / background-subagent false-*idle* case → #39.
Author
Owner

Root fix designed: #62 now carries the full decision record (2026-07-10 grill) — liveness edges make the false-working lockout impossible by construction (a Stop edge ends the turn regardless of the transcript tail; state table row 3), and the honest state also fixes the Interrupt affordance showing when nothing is interruptible. This issue stays open as the symptom tracker / prose regression test; #62's implementation closes it. Prerequisites filed: #92 (seam refactor), #93 (dead-session sweep — covers the crashed-session variant of the lockout).

Root fix designed: #62 now carries the full decision record (2026-07-10 grill) — liveness edges make the false-`working` lockout impossible by construction (a Stop edge ends the turn regardless of the transcript tail; state table row 3), and the honest state also fixes the Interrupt affordance showing when nothing is interruptible. This issue stays open as the symptom tracker / prose regression test; #62's implementation closes it. Prerequisites filed: #92 (seam refactor), #93 (dead-session sweep — covers the crashed-session variant of the lockout).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/coding-lab#38
No description provided.