fix(chat): filter local slash-command echoes so /clear no longer strands the composer in working #47

Merged
dominik.polakovics merged 1 commit from afk/45 into main 2026-07-08 10:56:14 +02:00

Summary

After a /clear (or /rewind) in the embedded chat, the composer got stuck showing a pulsing Interrupt with no Send, and tapping Interrupt did nothing. Issue #34's transcript-rotation following works (the chat empties correctly), but the freshly-rotated run then read as working forever.

Root cause: Claude Code writes a local slash-command invocation to the fresh transcript as a user message whose content is a <command-name>… / <command-message>… echo carrying no isMeta flag. The transcript parser emitted it as a normal user turn; deriveState maps a trailing user:textworking. Because /clear never invokes the model, nothing follows the echo, so the run stayed working indefinitely and the interrupt POST fired against an idle prompt (never flipping back).

Fix (purely upstream filtering): extend the existing non-conversational text filter in foldTranscript (which already drops isMeta user text) to also skip a slash-command echo — trimmed content beginning with <command-name> or <command-message> — and local-command output (<local-command-stdout>), via a new isLocalCommandEcho helper. Skipped text emits no bubble and never sets lastKey, so a /clear transcript has no trailing turn and reduces to the idle default. A genuine plain-text reply is unchanged — it still derives working, preserving the Send→Interrupt morph (ADR-0022). The reducer's load-bearing "trailing user turn → working" rule is untouched.

Acceptance criteria

  • A transcript whose last content-bearing line is a slash-command echo derives idle, not working (added at the transcript-parse state-edge seam in chat_test.go, covering all three prefixes).
  • A slash-command echo does not render as a user-message bubble (no raw <command-…> tags) — asserted in the dedicated test and in the compat fixture leak-guard.
  • A genuine plain-text reply at the tail still derives working — asserted so the morph can't regress.
  • <local-command-stdout> output is likewise treated as non-conversational.
  • Coupling documented/pinned: real-shaped echo + output added to the 2.1.198 compat fixture (dropped, message count stays 9) and a new bullet in compat.md §5.

Out of scope (untouched)

The genuine mid-turn interrupt no-op (#38), transcript-rotation detection (stays by-effect), and the workflow false-idle case (#39). No per-command special-casing (some commands like /triage do invoke the model; the brief pre-token window reading idle/prior-state self-corrects within one poll).

Testing

  • go build ./..., go vet, gofmt clean; affected packages (internal/provider/claudecode, internal/compat) and the full suite pass.
  • Pre-existing internal/tmuxx integration tests fail on this host (they need a live tmux server the environment can't sustain) — unrelated to this change; zero tmuxx files touched.
  • Reviewed with a multi-agent adversarial pass; both confirmed findings (a missing <command-message>-prefix test case and a stale "Four coupled facts" count in §5) are addressed in this PR.

Closes #45

## Summary After a `/clear` (or `/rewind`) in the embedded chat, the composer got stuck showing a pulsing **Interrupt** with no **Send**, and tapping Interrupt did nothing. Issue #34's transcript-rotation following works (the chat empties correctly), but the freshly-rotated run then read as `working` forever. **Root cause:** Claude Code writes a local slash-command invocation to the fresh transcript as a `user` message whose content is a `<command-name>…` / `<command-message>…` echo carrying **no** `isMeta` flag. The transcript parser emitted it as a normal user turn; `deriveState` maps a trailing `user:text` → `working`. Because `/clear` never invokes the model, nothing follows the echo, so the run stayed `working` indefinitely and the interrupt POST fired against an idle prompt (never flipping back). **Fix (purely upstream filtering):** extend the existing non-conversational text filter in `foldTranscript` (which already drops `isMeta` user text) to also skip a slash-command echo — trimmed content beginning with `<command-name>` or `<command-message>` — and local-command output (`<local-command-stdout>`), via a new `isLocalCommandEcho` helper. Skipped text emits no bubble and never sets `lastKey`, so a `/clear` transcript has no trailing turn and reduces to the **idle** default. A genuine plain-text reply is unchanged — it still derives `working`, preserving the Send→Interrupt morph (ADR-0022). The reducer's load-bearing "trailing user turn → working" rule is untouched. ## Acceptance criteria - [x] A transcript whose last content-bearing line is a slash-command echo derives **idle**, not `working` (added at the transcript-parse state-edge seam in `chat_test.go`, covering all three prefixes). - [x] A slash-command echo does not render as a user-message bubble (no raw `<command-…>` tags) — asserted in the dedicated test and in the compat fixture leak-guard. - [x] A genuine plain-text reply at the tail still derives **working** — asserted so the morph can't regress. - [x] `<local-command-stdout>` output is likewise treated as non-conversational. - [x] Coupling documented/pinned: real-shaped echo + output added to the 2.1.198 compat fixture (dropped, message count stays 9) and a new bullet in `compat.md` §5. ## Out of scope (untouched) The genuine mid-turn interrupt no-op (#38), transcript-rotation detection (stays by-effect), and the workflow false-*idle* case (#39). No per-command special-casing (some commands like `/triage` do invoke the model; the brief pre-token window reading idle/prior-state self-corrects within one poll). ## Testing - `go build ./...`, `go vet`, `gofmt` clean; affected packages (`internal/provider/claudecode`, `internal/compat`) and the full suite pass. - Pre-existing `internal/tmuxx` integration tests fail on this host (they need a live tmux server the environment can't sustain) — unrelated to this change; zero tmuxx files touched. - Reviewed with a multi-agent adversarial pass; both confirmed findings (a missing `<command-message>`-prefix test case and a stale "Four coupled facts" count in §5) are addressed in this PR. Closes #45
fix(chat): filter local slash-command echoes so /clear no longer strands the composer in working
All checks were successful
ci / native (pull_request) Successful in 3m39s
f461f01dbb
After #34 made lab follow the transcript rotation on /clear (and /rewind), the
composer stayed stuck on a pulsing Interrupt with no Send: Claude Code writes a
local slash-command invocation to the fresh transcript as a user message whose
content is a `<command-name>…` / `<command-message>…` echo carrying no isMeta
flag. The parser emitted it as a normal user turn, and deriveState maps a
trailing user:text to `working`. Because /clear never invokes the model, nothing
follows the echo, so the run read as working indefinitely and the interrupt POST
fired against an idle prompt.

Extend the existing non-conversational text filter (which already drops isMeta
user text) to also skip a slash-command echo — trimmed content beginning with
`<command-name>` or `<command-message>` — and local-command output
(`<local-command-stdout>`). Skipped text emits no bubble and never sets lastKey,
so a /clear transcript has no trailing turn and reduces to the idle default; the
composer shows Send again. A genuine plain-text reply is unchanged: it still
derives `working`, preserving the Send→Interrupt morph (ADR-0022). The reducer's
load-bearing "trailing user turn → working" rule is untouched — the fix is purely
upstream filtering.

Pin the coupling: state-edge cases for all three prefixes plus a dedicated
no-bubble / genuine-reply-still-works test in chat_test.go, a real-shaped echo +
output added to the 2.1.198 compat fixture (dropped, count stays 9) with a
raw-tag leak guard, and a new bullet in compat.md §5.

Closes #45

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAPH9NzaPMnwfmuzPBB4T6
Sign in to join this conversation.
No reviewers
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!47
No description provided.