Bugfix refresh loses unanswered message - #574
Open
QuanCheng-QC wants to merge 4 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
2. How to reproduce
Scenario A — the question vanishes after refresh (defect 1)
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)
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
3. Root cause
Defect 1 — the history window is exhausted by step events
History hydration fetches the newest 50
workspace.messageevents. The catch: the agent's intermediate output (thinking/status/todos) shares that event type — it is only distinguished bypayload.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_sizewas unset, so nginx applied its 1MB default, while the backend'sMAX_FILE_SIZEis 50MB. Anything above 1MB never reached the backend — it was 413'd at the gateway. The frontend'scatch {}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/eventsgains an optionalexclude_message_typesparam, filtering onpayload.message_type. Events without amessage_typeare always kept — non-message events must not be caught in the filter._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)
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.nginx (defect 2)
= /v1/filesat 52m (headroom for multipart boundaries and part headers, so a file of exactly 50MB is not 413'd at the gateway) and= /v1/files/base64at 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:exclude_message_typesunset, the SQL query, the cache keys, and the invalidation logic are byte-for-byte identical to before. No agent / adapter path is affected.exclude_message_typesandaftertogether, 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.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.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
tests/test_events.py, all passing — exclusion filtering works; thelimitwindow 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.npm test(6 vitest cases covering ordering and dedup),tsc --noEmit, andnext buildall pass.