Skip to content

fix(ui): don't evict a user message sent during local-model warm-up (#11670) - #11698

Merged
lalalune merged 1 commit into
developfrom
fix/11670-warmup-message-eviction
Jul 3, 2026
Merged

lalalune merged 1 commit into
developfrom
fix/11670-warmup-message-eviction

Conversation

@lalalune

@lalalune lalalune commented Jul 2, 2026

Copy link
Copy Markdown
Member

Closes #11670

Root cause

useChatSend's post-turn reconcile (loadConversationMessages) full-replaces the thread with server truth. When the server never persisted the user's turn — a send during local-model warm-up where the runtime-ready hold expired (503 Agent is not running from conversation-routes.ts; the user message is only persisted after runtime resolution), or a stream that completed empty with nothing persisted — the reload returned a thread without the user's message and silently evicted the optimistic bubble. No failed state, no notice on the empty-success variant, text unrecoverable. The suite never caught it because its loadConversationMessages mock was a no-op { ok: true } instead of the real full-replace.

Fix (root cause, no fallback sludge)

  • restoreEvictedUserTurn() — after every post-send reconcile, detect that the just-sent user turn is missing from server truth (text match among user turns no older than the send minus 60s clock-skew slack, so an identical message from an earlier exchange can't mask an eviction) and re-attach the user's bubble plus a retryable failed assistant turn (failureKind: "provider_issue" → the overlay's existing Retry chip). The send fails loudly; one tap re-delivers once the model is ready — mirroring the existing reattachInterruptedPartial pattern for assistant partials.
  • Wired into both reload sites of runQueuedChatSend (success + non-404 error path) and both sites of sendActionMessage. Validation-4xx rejects excluded (their draft goes back to the composer — re-attaching would duplicate). No-op when the server persisted the turn or the reload never replaced local state.
  • handleChatRetry fallback now also drops the optimistic (temp-) user turn it retries, so the resend's fresh optimistic bubble doesn't transiently duplicate the message.

Evidence

Row Artifact
Before (develop) — message evicted before
After — optimistic bubble in flight after1
After — bubble survives 503 + reconcile, Retry chip attached after2
After — Retry delivers exactly once when model ready after3
  • Regression tests (red on develop): 8 new tests in useChatSend.test.tsx that emulate the REAL full-replace reload. 6 red against develop's useChatSend.ts (verified by swapping it in: 6 failed | 2 passed), all green with the fix. 2 guard the no-false-positive side (persisted silent turn → no duplicate/no spurious failed turn; validation reject → composer restore only). Full file: 40/40 green. Two pre-existing fixtures updated from toy timestamp: 1 to realistic epoch-ms (a persisted turn's real timestamp) — intent unchanged.
  • Real-pixel e2e: new bun run --cwd packages/ui test:warmup-eviction-e2e — bundles the REAL useChatSend pipeline + real ContinuousChatOverlay, warm-up 503 simulated at the client-API boundary, real typing/Enter/click in headless chromium. 8/8 assertions green (screenshots + console log committed under src/components/shell/__e2e__/output-warmup-eviction/). Same harness against develop's useChatSend: reds exactly at "user bubble SURVIVES the warm-up 503 + reconcile reload".
  • Frontend logs: output-warmup-eviction/console.log shows the real path: send → 503 notice → reload conv-1: server holds 0 messages (the eviction moment) → retry → server holds 2 messages.
  • Suites: bunx vitest run src/state/ src/components/shell/ → 95 files, 1166 passed. bun run --cwd packages/ui typecheck clean. Biome clean on touched files. Re-verified after rebase onto origin/develop (cdd87af).
  • Backend logs / real-LLM trajectory: N/A — client-side state-management fix; no agent/prompt/model behavior change. The server's warm-up 503 contract (resolveRuntimeForChatTurn) is unchanged and is what the boundary simulation reproduces.
  • Video: N/A — the three sequential full-page frames above capture the entire flow (send → survive → retry-deliver); the e2e re-generates them deterministically on every run.

🤖 Generated with Claude Code

…11670)

The post-turn history reload (loadConversationMessages) full-replaces the
thread with server truth. When the server never persisted the user's turn —
a send during local-model warm-up where the runtime-ready hold expired
(503 "Agent is not running"), or a stream that completed with nothing
persisted — that reload returned a thread WITHOUT the user's message and
silently evicted the optimistic bubble: the user's words vanished with no
failed state and no way to recover them.

Fix (root cause, in useChatSend):
- restoreEvictedUserTurn(): after every post-send reconcile reload, detect
  that the just-sent user turn is missing from server truth (text match
  among user turns no older than the send minus clock-skew slack) and
  re-attach the user's bubble plus a retryable failed assistant turn
  (failureKind: provider_issue -> the overlay's existing Retry chip). The
  send now fails loudly and one tap re-delivers it once the model is ready.
- Wired into both reload sites of runQueuedChatSend (success path + non-404
  error path) and both sites of sendActionMessage. Validation-4xx rejects
  are excluded (their draft is restored to the composer, re-attaching would
  duplicate it); no-op when the server persisted the turn or the reload
  never replaced local state.
- handleChatRetry fallback now also drops the optimistic (temp-) user turn
  it retries, so the resend's fresh optimistic bubble doesn't transiently
  duplicate the message.

Tests:
- 8 new regression tests in useChatSend.test.tsx that emulate the REAL
  full-replace reload semantics (the previous no-op reload mock is exactly
  why this eviction never showed up in the suite). 6 of them red on
  develop's useChatSend, green with the fix; 2 guard the no-false-positive
  side (persisted turn -> no duplicate, validation reject -> composer
  restore only).
- New real-browser e2e (run-warmup-eviction-e2e.mjs): the REAL useChatSend
  pipeline + real ContinuousChatOverlay, warm-up 503 simulated at the
  client-API boundary; real typing/clicks; asserts bubble survival, Retry
  chip, and exactly-once delivery after retry. Screenshots committed.

Closes #11670

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4cc8ab4c-4682-4e68-bf05-916a5a620592

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/11670-warmup-message-eviction

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lalalune
lalalune merged commit 5ad137d into develop Jul 3, 2026
36 of 63 checks passed
@lalalune
lalalune deleted the fix/11670-warmup-message-eviction branch July 3, 2026 00:00
lalalune pushed a commit that referenced this pull request Jul 3, 2026
…cript-only)

Develop moved again mid-fix: #11698 added run-warmup-eviction-e2e.mjs with a
packages/ui test:warmup-eviction-e2e script but no workflow leg, so the
ui-e2e-runner-coverage ratchet stayed red. Add the gate leg next to its shell
siblings + the artifact upload path.

Runner verified green locally via the exact CI invocation: 8/8 checks, exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lalalune added a commit that referenced this pull request Jul 3, 2026
…cript-only) (#11704)

Develop moved again mid-fix: #11698 added run-warmup-eviction-e2e.mjs with a
packages/ui test:warmup-eviction-e2e script but no workflow leg, so the
ui-e2e-runner-coverage ratchet stayed red. Add the gate leg next to its shell
siblings + the artifact upload path.

Runner verified green locally via the exact CI invocation: 8/8 checks, exit 0.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error —— View job


I'll analyze this and get back to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(ui): user message silently evicted from the thread when sent during local-model warm-up

1 participant