Skip to content

fix(composer): restore the prompt when a run fails after send#87

Merged
juacker merged 1 commit into
mainfrom
fix/restore-prompt-on-async-run-failure
Jul 1, 2026
Merged

fix(composer): restore the prompt when a run fails after send#87
juacker merged 1 commit into
mainfrom
fix/restore-prompt-on-async-run-failure

Conversation

@juacker

@juacker juacker commented Jun 30, 2026

Copy link
Copy Markdown
Owner

The bug (still happening despite PR #78)

When a message fails to send because the run errors — tokens exhausted, 400, 429, etc. — the typed prompt is lost: it's gone from the composer input box and from the conversation, forcing a full retype.

PR #78 already restores the prompt, but only on synchronous send errors (no connection, off-workspace route). The failures you actually hit happen asynchronously: assistant_send_message persists the message, starts the run, and returns success immediately — the composer clears. The 429/400/token-limit error surfaces later, during run execution. At that point the backend's discard_unanswered_run_input retracts the unanswered user message (emits MessageDeleted), so the prompt disappears from both places and PR #78's path never had a chance to fire.

Ironically, the backend's own discard doc-comment claimed "the typed text stays recoverable via the input history" — a recovery path that never existed in the composer.

The fix

Capture the retracted text exactly where the deletion lands, and feed it back to the composer:

  1. Store (sessionStore.ts): removeMessage now detects when the removed message is a user message and stashes its (trimmed) text in recoverablePrompts[sessionId]. New clearRecoverablePrompt action.
  2. Wrapper (TerminalEmulatorWrapper.tsx): subscribes to recoverablePrompts[workspaceSessionId], passes it to the composer, and clears the slot once consumed.
  3. Composer (TerminalEmulator.tsx): restores the prompt into the input box via the during-render setState pattern (converges; mirrors the existing draft-swap; lint-safe vs react-hooks/set-state-in-effect), guarded by restoreFailedPrompt so it never clobbers text the user has typed since. Clears the store slot in an effect (a zustand action, not a React setter, so exempt from the effect lint).

Scope / correctness

  • Image-bearing messages are unaffected: the backend preserves and auto-retries them (it doesn't delete them), so no MessageDeleted fires and this path is never invoked — correct, since images aren't recoverable from text anyway.
  • Restoring again after an identical repeat failure works (the applied-marker resets when the slot clears).
  • Only triggers on user message retraction; assistant placeholders and empty/whitespace messages are ignored.

Verification

  • 4 new store tests: capture trims text; ignores assistant-message removal; ignores empty/whitespace; clearRecoverablePrompt works.
  • tsc / eslint --max-warnings 0 / vitest (132) / vite build — all clean.

Test locally

Trigger a run failure (e.g. exhaust tokens, or a 429) with text in the composer. After the error, the typed prompt should reappear in the input box instead of being lost.

PR #78 restored the typed prompt only on *synchronous* send errors (no
connection, off-workspace). But the common failures the user hits
(429/400/token-exhausted) happen *asynchronously* during run execution,
after assistant_send_message already returned success and the composer
already cleared. On those, the backend retracts the unanswered user
message (discard_unanswered_run_input -> MessageDeleted), so the prompt
vanished from both the composer AND the conversation, forcing a retype.
The discard's own doc even claimed the text "stays recoverable via the
input history" -- a recovery path that never existed.

Fix: capture the retracted text where the deletion lands. The store's
removeMessage now stashes a removed *user* message's text in
recoverablePrompts[sessionId]; the wrapper surfaces it to the composer,
which restores it into the empty input box (during-render setState,
converges -- mirrors the draft-swap pattern; lint-safe vs
set-state-in-effect) and clears the store slot once consumed. Guarded by
restoreFailedPrompt so it never clobbers text the user typed since.

Image-bearing messages are unaffected: the backend preserves and
auto-retries them, so no MessageDeleted fires for them.

Tests: 4 store tests (capture trims; ignores assistant/empty; clear).
tsc / eslint / vitest (132) / vite build clean.
@juacker juacker marked this pull request as ready for review July 1, 2026 16:45
@juacker juacker merged commit 57dc2d9 into main Jul 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant