Repository navigation
fix(web): prevent user messages from vanishing on thread switch (#2409) - #2498
Conversation
… window (#2409) When loadHistory() re-renders the chat (thread switch, SSE reconnect, page reload), user messages that haven't been persisted yet disappear because the agent loop persists them after safety checks (100ms-1s delay). This fix tracks pending messages client-side and re-injects them into the DOM when loadHistory() doesn't find them in the DB yet. - Add _pendingUserMessages Map with 60s TTL - Record pending messages in sendMessage() before the fetch call - Clear pending entries when SSE events confirm agent processing - Re-inject non-persisted pending messages in loadHistory() fresh path - Suppress welcome card when pending messages exist Purely frontend fix — no backend changes, no safety pipeline bypass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements a mechanism to track and re-inject pending user messages that have not yet been persisted to the database, ensuring they remain visible during the safety-pipeline processing window. The review feedback identifies a critical race condition where clearing the entire pending message queue upon a single server event could lead to message loss if multiple messages are sent in rapid succession. Furthermore, an improvement was suggested for the message re-injection logic to use a frequency map instead of a set, correctly handling scenarios where a user sends multiple messages with identical content.
| addTrackedEventListener('response', (e) => { | ||
| const data = JSON.parse(e.data); | ||
| // Agent responded — user message is persisted, clear pending (#2409) | ||
| if (data.thread_id) _pendingUserMessages.delete(data.thread_id); |
There was a problem hiding this comment.
Clearing all pending messages for a thread upon receiving a single agent activity event introduces a race condition. If a user sends multiple messages in quick succession, the first agent event related to the first message will cause all pending messages for that thread to be deleted, including those the agent hasn't processed yet. This can lead to the later messages vanishing if the user switches threads.
A safer approach is to rely on the logic in loadHistory() to filter out already-persisted messages, and the TTL to eventually clean up the _pendingUserMessages map. Please remove this line. The same issue exists in the tool_started and stream_chunk handlers.
| addTrackedEventListener('tool_started', (e) => { | ||
| const data = JSON.parse(e.data); | ||
| // Tool started — user message is persisted, clear pending (#2409) | ||
| if (data.thread_id) _pendingUserMessages.delete(data.thread_id); |
| addTrackedEventListener('stream_chunk', (e) => { | ||
| const data = JSON.parse(e.data); | ||
| // Streaming started — user message is persisted, clear pending (#2409) | ||
| if (data.thread_id) _pendingUserMessages.delete(data.thread_id); |
| // Re-inject pending user messages not yet in DB (#2409) | ||
| const pending = _pendingUserMessages.get(currentThreadId); | ||
| if (pending && pending.length > 0) { | ||
| const now = Date.now(); | ||
| const fresh = pending.filter(p => now - p.timestamp < PENDING_MSG_TTL_MS); | ||
| if (fresh.length > 0) { | ||
| const dbContents = new Set(data.turns.map(t => t.user_input).filter(Boolean)); | ||
| for (const p of fresh) { | ||
| if (!dbContents.has(p.content)) { | ||
| addMessage('user', p.content); | ||
| } | ||
| } | ||
| _pendingUserMessages.set(currentThreadId, fresh); | ||
| } else { | ||
| _pendingUserMessages.delete(currentThreadId); | ||
| } | ||
| } | ||
| container.scrollTop = container.scrollHeight; | ||
| // Show welcome card when history is empty | ||
| if (data.turns.length === 0) { | ||
| if (data.turns.length === 0 && !(pending && pending.some(p => Date.now() - p.timestamp < PENDING_MSG_TTL_MS))) { | ||
| showWelcomeCard(); | ||
| } |
There was a problem hiding this comment.
The current implementation for re-injecting pending messages has a bug when a user sends the same message content multiple times in quick succession. By using a Set for dbContents, you lose the frequency of each message. If a user sends "ok" and then "ok" again, and loadHistory runs after the first one is persisted, the Set will contain "ok". Both pending messages will then be considered "in the DB", and the second optimistic message will not be re-injected, causing it to vanish.
Additionally, the logic can be refactored to be more DRY by calculating the fresh pending messages once and reusing the result.
Here's a suggested implementation that addresses both points by using a frequency map for DB contents and simplifying the logic:
// Re-inject pending user messages not yet in DB (#2409)
const pending = _pendingUserMessages.get(currentThreadId);
let freshPending = [];
if (pending && pending.length > 0) {
const now = Date.now();
freshPending = pending.filter(p => now - p.timestamp < PENDING_MSG_TTL_MS);
if (freshPending.length > 0) {
const dbContentsCounts = data.turns
.map(t => t.user_input)
.filter(Boolean)
.reduce((acc, content) => {
acc[content] = (acc[content] || 0) + 1;
return acc;
}, {});
for (const p of freshPending) {
if (dbContentsCounts[p.content] > 0) {
dbContentsCounts[p.content]--;
} else {
addMessage('user', p.content);
}
}
_pendingUserMessages.set(currentThreadId, freshPending);
} else {
_pendingUserMessages.delete(currentThreadId);
}
}
container.scrollTop = container.scrollHeight;
// Show welcome card when history is empty
if (data.turns.length === 0 && freshPending.length === 0) {
showWelcomeCard();
}Six scenarios covering the frontend fix for disappearing user messages: - User message visible immediately after send (optimistic display) - Pending message survives SSE reconnect (re-injected by loadHistory) - Pending messages cleared after agent response (no stale entries) - No duplicates when DB already has the message - Welcome card suppressed when pending messages exist - Full round-trip message survives page reload (DB persistence) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… map for pending dedup (#2498) Remove _pendingUserMessages.delete() from response/tool_started/stream_chunk SSE handlers to prevent race condition when user sends multiple messages in quick succession. Replace Set-based dedup in loadHistory with a frequency map so duplicate-content messages ("ok", "ok") are tracked correctly. Simplify welcome-card guard using hoisted freshPending. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Addressed review feedback in 76c62f7:
|
henrypark133
left a comment
There was a problem hiding this comment.
Verified finding:
crates/ironclaw_gateway/static/app.js:1245 now pushes every optimistic send into _pendingUserMessages, but the successful-turn path in the response SSE handler (app.js:850-880) never removes the entry after the turn is persisted. That means the pending map stays populated until the 60s TTL expires, so any history reload in that window can still re-inject a stale user message or keep the thread in a fake pending state. The new test_pending_message_cleared_after_response should fail against this head for the same reason.
Suggested fix: clear or decrement the matching pending entry when the turn completes, rather than relying on the TTL sweep during a later loadHistory().
|
Addressed @henrypark133's review — pending entries are now cleared on turn completion instead of relying solely on TTL: // Turn complete — remove oldest pending entry for this thread (#2409)
const _pending = _pendingUserMessages.get(data.thread_id);
if (_pending) {
_pending.shift();
if (_pending.length === 0) _pendingUserMessages.delete(data.thread_id);
}Added in the |
| if (!_pendingUserMessages.has(currentThreadId)) { | ||
| _pendingUserMessages.set(currentThreadId, []); | ||
| } | ||
| _pendingUserMessages.get(currentThreadId).push({ content: displayContent, timestamp: Date.now() }); |
There was a problem hiding this comment.
Medium Severity
The optimistic pending entry is not removed when /api/chat/send fails.
sendMessage() records the message in _pendingUserMessages before the POST has succeeded, but the .catch() path only marks the DOM node as failed and never removes the pending entry. If the request is rejected by rate limiting, auth expiry, or a transient network error, switching threads or reconnecting within the 60s TTL will re-inject the message from _pendingUserMessages as if it had been accepted by the agent, and the failed-send retry styling is lost.
Please track a local pending id/reference and remove that exact entry in the apiFetch(...).catch() path. A regression test should force /api/chat/send to fail, reload history, and assert the failed message is not re-injected as a normal sent message.
| .map(t => t.user_input) | ||
| .filter(Boolean) | ||
| .reduce((acc, content) => { | ||
| acc[content] = (acc[content] || 0) + 1; |
There was a problem hiding this comment.
Medium Severity
The dedupe counter uses a plain object keyed by raw user message content.
dbContentsCounts is initialized as {} and then indexed by content, which is user-controlled. Values such as __proto__, constructor, or toString collide with object prototype properties and can make dbContentsCounts[p.content] > 0 behave incorrectly. In those cases a message that is already present in history can still be re-injected as a duplicate pending message.
Please use new Map() or Object.create(null) for these counts, and add a regression test with a message such as __proto__ or toString.
| # loop persists. We avoid using the real send flow because the mock LLM | ||
| # may respond before we can test the pending window. | ||
| thread_id = await page.evaluate("() => currentThreadId") | ||
| await page.evaluate( |
There was a problem hiding this comment.
Medium Severity
This reconnect regression test bypasses the real send path.
The test manually calls addMessage() and pushes into _pendingUserMessages, so it can still pass if sendMessage() stops registering real optimistic sends correctly. It also calls _wait_for_connected() after connectSSE(), but that helper only checks sseHasConnectedBefore; after the initial connection this flag is already true, so the assertion does not prove a reconnect or history reload actually happened.
Please drive the real send path with a controlled delay or mocked /api/chat/send/history timing, then force loadHistory() or an actual SSE reconnect and wait on an observable reload/reconnect signal before asserting the message remains.
…, improve reconnect test (#2498) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address remaining review feedback: - Capture attached image data URLs in optimistic display and in the _pendingUserMessages entry so a thread switch / SSE reconnect re-injects thumbnails alongside the text instead of just an "(images attached)" placeholder. - Rewrite the SSE-reconnect test to drive the real production path: stub apiFetch so /api/chat/send hangs, send via the real UI, force a reconnect, and assert the message survives — instead of manually pre-populating the pending map. - Add coverage for the .catch() cleanup branch in sendMessage so a rejected /api/chat/send leaves _pendingUserMessages clean. - Add a FIFO-assumption comment on the response-handler shift() and drop the leading underscore on the function-local `pending` (the underscore convention in this file is for module-level state). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ai#2409) (nearai#2498) * fix(web): prevent user messages from vanishing during safety-pipeline window (nearai#2409) When loadHistory() re-renders the chat (thread switch, SSE reconnect, page reload), user messages that haven't been persisted yet disappear because the agent loop persists them after safety checks (100ms-1s delay). This fix tracks pending messages client-side and re-injects them into the DOM when loadHistory() doesn't find them in the DB yet. - Add _pendingUserMessages Map with 60s TTL - Record pending messages in sendMessage() before the fetch call - Clear pending entries when SSE events confirm agent processing - Re-inject non-persisted pending messages in loadHistory() fresh path - Suppress welcome card when pending messages exist Purely frontend fix — no backend changes, no safety pipeline bypass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test(e2e): add Playwright tests for pending message persistence (nearai#2409) Six scenarios covering the frontend fix for disappearing user messages: - User message visible immediately after send (optimistic display) - Pending message survives SSE reconnect (re-injected by loadHistory) - Pending messages cleared after agent response (no stale entries) - No duplicates when DB already has the message - Welcome card suppressed when pending messages exist - Full round-trip message survives page reload (DB persistence) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(e2e): use domcontentloaded for reload test — SSE blocks networkidle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(web): address review — remove SSE early-clear race, use frequency map for pending dedup (nearai#2498) Remove _pendingUserMessages.delete() from response/tool_started/stream_chunk SSE handlers to prevent race condition when user sends multiple messages in quick succession. Replace Set-based dedup in loadHistory with a frequency map so duplicate-content messages ("ok", "ok") are tracked correctly. Simplify welcome-card guard using hoisted freshPending. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(web): clear pending entry on turn completion — address henrypark133 review (nearai#2498) * fix(web): address review — remove pending on send fail, Map for dedup, improve reconnect test (nearai#2498) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unused imports in pending message test Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * ci: retrigger checks against updated staging base * fix(web): preserve images in pending messages, harden tests (nearai#2498) Address remaining review feedback: - Capture attached image data URLs in optimistic display and in the _pendingUserMessages entry so a thread switch / SSE reconnect re-injects thumbnails alongside the text instead of just an "(images attached)" placeholder. - Rewrite the SSE-reconnect test to drive the real production path: stub apiFetch so /api/chat/send hangs, send via the real UI, force a reconnect, and assert the message survives — instead of manually pre-populating the pending map. - Add coverage for the .catch() cleanup branch in sendMessage so a rejected /api/chat/send leaves _pendingUserMessages clean. - Add a FIFO-assumption comment on the response-handler shift() and drop the leading underscore on the function-local `pending` (the underscore convention in this file is for module-level state). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Summary
loadHistory()clears the DOM and re-fetches from DB, but the agent loop hasn't persisted the user message yet (it persists after safety checks)_pendingUserMessagesmap and re-injects them afterloadHistory()if they're not in the DB response yettool_started,stream_chunk,response) confirms the agent processed the message, or after a 60s TTLTest plan
Closes #2409
🤖 Generated with Claude Code