Skip to content

Bugfix refresh loses unanswered message - #574

Open
QuanCheng-QC wants to merge 4 commits into
developfrom
bugfix/refresh-loses-unanswered-message
Open

Bugfix refresh loses unanswered message#574
QuanCheng-QC wants to merge 4 commits into
developfrom
bugfix/refresh-loses-unanswered-message

Conversation

@QuanCheng-QC

@QuanCheng-QC QuanCheng-QC commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

fix: refresh loses the unanswered question

TL;DR

A user sent a complex task (22 screenshots + instructions) and refreshed the page while the agent was still thinking — the question they had just sent disappeared from the UI. Two independent defects combined: the 50-message history window was consumed by the agent's own intermediate step events, and nginx's default 1MB body limit 413'd the screenshot uploads while the frontend silently swallowed the failure.


1. Where this came from

  • Reported by: 〈TODO: who reported it, in what context — e.g. name / channel / ticket〉
  • User impact: the user concludes "my message was dropped / the agent's thinking got interrupted", and re-asks and re-uploads everything. The damage to trust is worse than the missing feature itself.
  • Frequency: reproduces every time under the conditions below — not intermittent.

2. How to reproduce

Scenario A — the question vanishes after refresh (defect 1)

  1. In workspace chat, send an agent a task that takes a while to process.
  2. Wait for the agent to start thinking and emitting thinking / status / todos (a complex task accumulates 50+ of these within tens of seconds).
  3. Refresh the page (F5) before the agent produces its final reply.

Before: the message you just sent is gone from the list, as if it had never been sent. It was persisted — it just never loaded back.
After: the question, its attachments, and the agent's thinking all come back intact, and subsequent output keeps streaming in.

Scenario B — sending with a large image loses the whole message (defect 2)

  1. Attach a screenshot larger than 1MB (in the 22-screenshot case, nearly every one is).
  2. Hit send.

Before: nginx returns 413; the frontend catches it and does nothing — the composer is cleared, the attachments are gone, no error is shown, and the user believes the message was sent.
After: files up to 50MB upload normally. On a genuine failure, a toast reports it and the typed text is restored as that thread's draft so nothing has to be retyped. Files over 50MB are rejected at selection time with a message.

Before / After

Before After
Refresh while agent is thinking Question disappears; history shows only the agent's step events Question + attachments + step events all restored
Attaching a >1MB image 413, entire message silently lost Uploads normally (50MB limit)
Send failure No feedback, typed content lost Toast error + draft restored
Concurrent message arrival Could reorder; UI stuck on "agent working" Sorted and deduped; state stays correct

Screen recordings: sent separately.


3. Root cause

Defect 1 — the history window is exhausted by step events

History hydration fetches the newest 50 workspace.message events. The catch: the agent's intermediate output (thinking / status / todos) shares that event type — it is only distinguished by payload.message_type. Once the agent is busy, all 50 are its own step events and the user's question is pushed off the first page. The message is in the database the whole time; it just can never be loaded back.

Defect 2 — nginx body limit was never configured

client_max_body_size was unset, so nginx applied its 1MB default, while the backend's MAX_FILE_SIZE is 50MB. Anything above 1MB never reached the backend — it was 413'd at the gateway. The frontend's catch {} was an empty block (the original comment read "Error is visible via missing message"), so the failure was swallowed entirely.


4. What changed

Backend (defect 1)

  • GET /v1/events gains an optional exclude_message_types param, filtering on payload.message_type. Events without a message_type are always kept — non-message events must not be caught in the filter.
  • Extracted _poll_filter_hash() so poll-cache key construction and cache invalidation share one hash implementation and cannot drift. The param joins the hash only when set, so existing callers that never pass it (adapter polls) keep their legacy hash and stay invalidatable.

Frontend (defects 1 + 2)

  • History hydration (channel / DM / infinite scroll) excludes step events, so the 50-message window only counts real messages.
  • One unfiltered catch-up poll runs after hydration to backfill step events emitted before the refresh (SSE only carries events published after the connection opens — it does not replay). It is skipped when no cursor was established, to avoid paging through the entire event log from the beginning.
  • All merge paths (hydration / catch-up / SSE / load-older) now go through mergeMessages: sorted by time, deduped by id, with a cursor that can only move forward. The previous naive append meant concurrent arrival order could drop an older batch at the tail of the list, which in turn left the "is the agent working" heuristic stuck on working.
  • Send failures show a toast and restore the draft; 50MB client-side per-file validation.
  • New vitest setup with unit tests for the merge/ordering logic.

nginx (defect 2)

  • Two exact-match locations: = /v1/files at 52m (headroom for multipart boundaries and part headers, so a file of exactly 50MB is not 413'd at the gateway) and = /v1/files/base64 at 70m (~4/3 base64 overhead). The precise per-file limit is still enforced by the backend.

5. Blast radius (the important part)

Backend /v1/events — this is the core polling endpoint that adapters also use, but the change is purely additive:

  • With exclude_message_types unset, the SQL query, the cache keys, and the invalidation logic are byte-for-byte identical to before. No agent / adapter path is affected.
  • Cache correctness: the new param does join the filter hash, and the invalidation enumeration does not cover combinations that include it. This is not a real risk — the frontend never sends exclude_message_types and after together, and the level-2 "at head" cache only fires when the cursor equals the tracked head; the level-1 cache TTL is 1.5s. A test pins the invariant that adapter-poll hashes remain in the invalidation set.
  • Database: no schema change, no migration. Filtering goes through payload->>'message_type'.

Frontend hooks/use-polling.ts — the largest surface here: this hook is shared by channels and DMs, so every chat view goes through it. The three paths worth regressing are message loading, upward pagination, and SSE live updates.

  • One deliberate behavior change: history no longer replays old thinking / status / todos. The current round's step events are backfilled by the catch-up poll, but scrolling far back now shows only real conversation messages — no historical thinking bubbles. That is the necessary cost of making the 50-message window usable again.
  • Opening a thread now issues one extra catch-up request (+1 HTTP; skipped when there is no cursor).

nginx — only those two exact-match upload routes are relaxed; every other /v1/ route keeps the 1MB default. GET /v1/files/{id} downloads are unaffected (the limit applies to request bodies only). Requires an nginx reload to take effect — if the config is not reloaded, the original bug reproduces unchanged.

Not affected: adapter / agent-side code, the event write path, database structure, other frontend pages.

Rollback: the three parts are independent and can be reverted separately. Frontend and nginx can roll back without backend coordination (the new param simply goes unused); reverting the backend alone requires reverting the frontend too, or the frontend will send a param the backend does not recognize.


6. Testing

  • Backend: 5 new cases in tests/test_events.py, all passing — exclusion filtering works; the limit window counts only kept messages (this is the regression test for defect 1); a blank param is a no-op; adapter-poll hashes remain in the invalidation set; the param changes the hash when set. Remaining local failures are environment issues (no Postgres) and behave identically on clean develop.
  • Frontend: npm test (6 vitest cases covering ordering and dedup), tsc --noEmit, and next build all pass.
  • Manual: scenarios A and B were both reproduced and confirmed fixed; recordings sent separately.
  • Still needs a deploy-env check: uploading a file near the 50MB boundary through nginx (the gateway path can't be reproduced locally).

Refreshing the page while an agent was still working could make the
just-sent question disappear. Two causes were fixed.

History loading counted agent thinking/status/todos events against the
50-event window, so a busy agent pushed the user's own message out of
the first page. GET /v1/events now supports exclude_message_types
(wired into both poll cache keys) and the frontend uses it for channel
history, DM history and infinite scroll. The forward poll still fetches
intermediate events, so the current-steps display is unaffected.

nginx had no client_max_body_size, so its 1MB default rejected larger
image uploads with 413 before the backend's 50MB limit ever applied.
Sending many screenshots failed silently and the whole message was lost.

The send path no longer swallows failures. It shows a toast, restores
the typed text as the thread draft, and validates file size client-side
before uploading.
Run one unfiltered catch-up poll after history hydration. SSE only
carries events published after the connection opens and history now
excludes step events, so without the catch-up poll the steps emitted
before a refresh never loaded and a working agent looked idle.

Share one filter-hash builder between poll_events and the poll-cache
invalidation. The exclude_message_types param joins the hash only when
set, so the enumerated adapter patterns keep their legacy hashes and
head/at-head keys stay invalidatable. Covered by regression tests.

Scope the nginx body limit to the /v1/files upload route instead of the
whole server, and raise it to 52m so multipart overhead cannot 413 a
file of exactly 50MB. The backend still enforces the precise per-file
limit.
Merge incoming messages sorted by timestamp with the id as tiebreaker
instead of appending. The catch-up poll and the SSE stream run
concurrently, so an older status batch could land after a newer
SSE-delivered reply, become the trailing message, and make the UI treat
a finished agent as still working. The newest-message cursor now only
advances chronologically, and the poll loop paginates on a local cursor
so a concurrent SSE jump cannot skip the batches it is backfilling.

Run the catch-up poll only when history hydration succeeded. After a
failed history request the cursor is null and the poll loop would page
through the channel's entire event log from the first message.

Use exact-match nginx locations for the two upload endpoints so
GET /v1/files/{id} and other file routes keep the default body limit.
POST /v1/files/base64 needs its own location with ~4/3 headroom for the
base64 encoding overhead, which the review's prefix-match concern did
not cover.

Revert the incidental next-env.d.ts change from the previous commit —
it was a next build artifact, not an intended edit.
Merge history hydration into live state instead of replacing it. The
SSE stream opens alongside the history request, so a reply delivered
during hydration was dropped by the replace and the cursor rolled back;
a failed catch-up would then never recover it since SSE does not
replay. The empty-history branch likewise no longer clears state the
SSE stream already delivered, and the newest cursor only moves through
bumpNewest so it stays monotonic.

Skip the catch-up poll when no cursor exists after hydration. A
successful history response over a channel containing only excluded
step events left the cursor null, and the poll loop would page through
the channel's entire event log from the beginning.

Extract the merge helpers into lib/message-merge.ts and add vitest with
unit tests covering the out-of-order status-after-reply scenario, the
hydration/SSE interleave, dedup, and the null-timestamp comparator.
The frontend previously had no test setup at all; hook-level timing
tests around the polling loop would additionally need jsdom and API
mocks and are left for follow-up.
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