Skip to content

Fix #1531: long conversations no longer truncate to oldest 200 + preserve SSE state on mid-session recovery#1538

Open
ztech-gthb wants to merge 3 commits into
coleam00:devfrom
ztech-gthb:fix/issue-1531-ui-truncation
Open

Fix #1531: long conversations no longer truncate to oldest 200 + preserve SSE state on mid-session recovery#1538
ztech-gthb wants to merge 3 commits into
coleam00:devfrom
ztech-gthb:fix/issue-1531-ui-truncation

Conversation

@ztech-gthb

@ztech-gthb ztech-gthb commented May 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: For any conversation with more than 200 persisted messages, the Web UI shows only the oldest 200 and silently drops everything newer. Three causes stack: DB query orders ASC + LIMIT, API has no pagination affordance, and the stuck-placeholder recovery in ChatInterface overwrites React state with the server snapshot — losing any client-only or post-snapshot messages.
  • Why it matters: Long agent conversations (typical for non-trivial tasks) hit this silently — the user just stops seeing recent messages, with no error, no banner, no "load more" hint. Combined with the SSE noise from git reset --hard origin/<default_branch> on source/ every message — destroys any local state that has diverged from origin for managed clones #1516's chat-tick reset bursts, mid-session recovery could also wipe live state.
  • What changed: DB query switched to ORDER BY DESC LIMIT N then reversed to chronological — listMessages now returns the newest N in oldest-first order. Stuck-placeholder hydration in ChatInterface merges by message id instead of replacing — anything in React state that the snapshot doesn't include (system banners, in-flight tool-calls, post-snapshot SSE messages) survives.
  • What did not change (scope boundary): The 200/500 server-side limit (followup if pagination is desired), the ChatInterface mount-time hydration path (already had a merge-by-id branch and isn't the source of mid-session loss), getRecentWorkflowResultMessages (different ordering contract — DESC + early termination, intentionally newest-first).

UX Journey

Before

Long conversation (>200 messages):
  user opens conversation ─────▶ getMessages(limit=200)
                                  └─▶ DB: ORDER BY ASC LIMIT 200
                                       returns oldest 200
  UI scroll-to-bottom        ◀─  rendered chronologically
                                  (last visible: msg #200, latest is #283)
  user sees                      "the conversation seems to end at the typo
                                   I asked about three days ago, even though
                                   I was just chatting yesterday"

Mid-session SSE recovery (any conversation):
  agent edits, commits, replies        — accumulating in React state
  brief SSE drop or workflow_status:completed
                                  └─▶ stuck-placeholder branch fires
                                       └─▶ getMessages(limit=200) refetch
                                            └─▶ setMessages(_ => hydrated)
  React state replaced with the (older) DB snapshot.
  Live messages and any tool-call/streaming state vanish silently.

After

Long conversation (>200 messages):
  user opens conversation ─────▶ getMessages(limit=200)
                                  └─▶ DB: ORDER BY DESC LIMIT 200
                                       returns newest 200,
                                       reversed to chronological by listMessages
  UI scroll-to-bottom        ◀─  rendered chronologically
                                  (last visible: msg #283, the latest)

Mid-session SSE recovery:
  brief SSE drop or workflow_status:completed
                                  └─▶ stuck-placeholder branch fires
                                       └─▶ getMessages(limit=200) refetch
                                            └─▶ merge by id:
                                                  hydrated ∪ (client-only state)
                                                  sort by timestamp
  React state preserved — server's view + everything still in flight.

Architecture Diagram

Before

DB (PG/SQLite)
  └─▶ messages.ts:listMessages         ── ORDER BY ASC LIMIT 200 → oldest 200
       └─▶ api.ts /messages route       ── pass-through
            └─▶ web/api.ts:getMessages
                 └─▶ ChatInterface:
                      ├─ mount fetch         ── prev-empty path returns hydrated
                      └─ stuck-placeholder   ── setMessages(_ => hydrated)   [overwrite]

After

DB (PG/SQLite)
  └─▶ messages.ts:listMessages         ── ORDER BY DESC LIMIT 200, reverse(); → newest 200, chronological  [~]
       └─▶ api.ts /messages route       ── pass-through (unchanged)
            └─▶ web/api.ts:getMessages
                 └─▶ ChatInterface:
                      ├─ mount fetch         ── unchanged
                      └─ stuck-placeholder   ── merge-by-id: prev ∪ hydrated, sort by ts   [~]

Connection inventory:

From To Status Notes
messages.ts:listMessages DB query modified DESC + reverse
messages.ts:listMessages callers (api.ts) unchanged contract still oldest-first; just newest-N now
ChatInterface:stuck-placeholder React state modified merge by id, sort by ts
ChatInterface:mount-fetch React state unchanged already had merge-by-id path; not source of bug
getRecentWorkflowResultMessages DB unchanged different contract, different sort

Label Snapshot

  • Risk: risk: low — DB query change is order/limit only (no schema, no semantics shift); React change is purely additive (anything in DB still gets through).
  • Size: size: S
  • Scope: core, web
  • Module: core:db.messages, web:chat.ChatInterface

Change Metadata

  • Change type: bug
  • Primary scope: multi

Linked Issue

Validation Evidence (required)

bun run type-check                                      # clean across all 10 packages
bun --filter @archon/core test                          # 815 pass / 0 fail (incl. updated listMessages test fixture)
bun --filter @archon/web test                           # 158 pass / 0 fail

Per-commit: lint-staged (eslint --max-warnings 0 + prettier --write) clean on every staged file.

messages.test.ts test renamed and updated to assert DESC + reversal explicitly. No other fixtures needed update — the API and UI contracts (oldest-first as caller-visible order) are unchanged.

Security Impact (required)

  • New permissions/capabilities? No.
  • New external network calls? No.
  • Secrets/tokens handling changed? No.
  • File system access scope changed? No.
  • DB query change is read-only and bound by an integer limit argument exactly as before.

Compatibility / Migration

  • Backward compatible? Yes. Caller-visible contract of listMessages unchanged: returns oldest-first up to limit rows. The change is which up-to-limit rows are returned (newest N instead of oldest N).
  • Config/env changes? No.
  • Database migration needed? No.
  • API contract: GET /api/conversations/:id/messages?limit=N is unchanged in shape — only the row selection rule differs. Existing client code that doesn't pass limit continues to work; for conversations under 200 messages, behaviour is identical.

Human Verification (required)

  • Verified scenarios:
    • bun run type-check clean.
    • messages.test.ts updated test passes (DESC ordering, reversed result).
    • Manual code-read of ChatInterface.tsx merge logic — hydratedIds set excludes DB-known ids, clientOnly filter retains the rest, sort by timestamp produces the natural chronological output.
  • Edge cases checked:
    • Empty prev (mount-time): clientOnly is empty, merged === hydrated.
    • Empty hydrated (very early in fresh conversation): early return preserved (if (rows.length === 0) return;).
    • Conflicting ids (very unlikely given synthetic msg-{ts} IDs for SSE messages and DB UUIDs for persisted): hydrated wins (in the merged array first, but sort is stable and timestamps decide; the comment in ChatInterface explicitly relies on the msg-{ts} vs DB UUID disjunction, which this PR preserves).
  • What was not verified: end-to-end UI test in a >200-message conversation reproducing the original symptom and confirming the latest 200 are now visible. Recommend a quick manual once the PR builds — left as a verification step for the reviewer/maintainer if desired.

Side Effects / Blast Radius (required)

  • Affected subsystems: messages.ts:listMessages is called only from one server route (api.ts:/messages) — verified via grep across packages/. The change there is "what subset of rows you get" without changing types or shape. Web ChatInterface change is contained to the stuck-placeholder branch.
  • Potential unintended effects:
    • getRecentWorkflowResultMessages (in the same file) is not affected — different query, different ordering contract.
    • Workflow-driven workers using the conversations layer indirectly: they consume messages via different DB helpers; the listMessages change does not alter what they see.
    • Mid-session SSE recovery: previously dropped non-DB state silently — now preserves it. No code that depended on the wholesale-replace behaviour identified.
  • Guardrails: existing logging/error paths unchanged.

Rollback Plan (required)

  • Fast rollback: git revert the two commits (a498e788 + 5260d6bd). One file each on the DB side, one on the UI side; no schema or persistent state to migrate.
  • Feature flags: none.
  • Observable failure symptoms: pre-PR symptom — long conversations show only oldest 200; SSE recovery wipes mid-session state.

Risks and Mitigations

  • Risk: A consumer somewhere expects listMessages to return oldest N rather than newest N (i.e. depends on the prior bug).
    • Mitigation: only one caller (api.ts:/messages route) consumes the function. The route serves the Web UI's chat-history fetch — newest N is what users would expect. No other consumer searched up via grep.
  • Risk: Adding pagination later might require revisiting the API surface; this PR doesn't add ?before=<id>.
    • Mitigation: explicitly out of scope here. Pagination is a separate, larger feature; the current change is the minimum to fix the user-visible symptom (latest messages invisible). Documented in the issue.
  • Risk: ChatInterface merge-by-id depends on the invariant that SSE-only messages use synthetic msg-{timestamp} ids that never collide with DB UUIDs. The existing comment at the mount-fetch path explicitly notes this; this PR maintains it.
    • Mitigation: noted in the inline comment of the changed block; any future change that breaks the synthetic-id invariant would also break the existing merge logic, so the failure mode is shared, not new.

🤖 Investigation, write-up, and implementation produced with extensive back-and-forth using Claude — final code, naming choices, and architectural decisions reviewed and accepted by ztech-gthb.

Summary by CodeRabbit

  • Bug Fixes
    • Messages now consistently display oldest-first chronological order.
    • Improved recovery for stuck/streaming messages so client-only and server-hydrated messages merge correctly.
    • Fixed visibility issues where conversations exceeding the message limit could show the wrong set of messages.

Zolto added 2 commits May 2, 2026 17:06
…#1531)

ORDER BY ASC LIMIT N returns the OLDEST N — for any conversation past the cap (default 200) the most recent messages were silently invisible to the UI. Switch to ORDER BY DESC LIMIT N (newest N) and reverse to oldest-first for the chronological-display contract callers expect. Existing test fixture updated to match.
…lacing (coleam00#1531)

When SSE   timing causes a stuck thinking placeholder, the recovery path used to overwrite the entire React message state with the fetched DB snapshot. Anything in React state that wasn't  in the server's window — client-only system messages, in-flight tool-call/streaming state, messages newer than the snapshot — was silently dropped.
Merge by id instead so non-DB state survives. Combined with the messages.ts change, this also fixes the 'jump back to oldest 200' symptom mid-session triggered by sync-burst SSE events from coleam00#1516.
@coderabbitai

coderabbitai Bot commented May 2, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 75fdaa09-5497-4ce9-8681-b2612146feeb

📥 Commits

Reviewing files that changed from the base of the PR and between 5260d6b and 6adf28b.

📒 Files selected for processing (3)
  • packages/core/src/db/messages.test.ts
  • packages/core/src/db/messages.ts
  • packages/web/src/components/chat/ChatInterface.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/web/src/components/chat/ChatInterface.tsx

📝 Walkthrough

Walkthrough

DB query for messages now selects the newest window (ORDER BY created_at DESC, id DESC) and reverses results to preserve oldest-first output; the web client’s stuck-placeholder recovery merges hydrated DB messages with existing client messages by ID/timestamp instead of replacing state.

Changes

Message Query and Recovery Merge

Layer / File(s) Summary
DB Query
packages/core/src/db/messages.ts
listMessages now queries ORDER BY created_at DESC, id DESC LIMIT $2 (newest-first window) and returns [...]result.rows].reverse() to present oldest-first to callers.
Behavioral Contract / Comment
packages/core/src/db/messages.ts
Docs updated to explain newest-first DB windowing and application-side reversal to avoid invisibility of recent messages beyond the limit.
Query Tests
packages/core/src/db/messages.test.ts
Test adjusted to mock DESC-ordered DB rows, assert the SQL ordering includes created_at DESC, id DESC, and verify listMessages reverses rows to oldest-first.
Client Recovery Merge
packages/web/src/components/chat/ChatInterface.tsx
On lock-release recovery, replace state-replace logic with an ID+timestamp-based merge: compute hydrated IDs and newest hydrated ts, preserve client-only messages meeting criteria (system, active streaming with content, tool calls, or newer-than-hydrated), concatenate hydrated + preserved, and sort by timestamp.
Behavioral Rationale / Inline Comments
packages/web/src/components/chat/ChatInterface.tsx
Removed prior simpler system-only re-insertion; new merge preserves live SSE messages and avoids silently dropping recent messages during REST re-fetch.

Sequence Diagram(s)

sequenceDiagram
  participant UI as Client(UI)
  participant API as Server(API)
  participant DB as Database
  participant SSE as SSE Stream

  UI->>SSE: receive events (streaming placeholders, live messages)
  SSE-->>UI: streaming messages (isStreaming placeholders)
  UI->>API: onLockChange(false) -> GET /messages (limit=N)
  API->>DB: SELECT ... ORDER BY created_at DESC, id DESC LIMIT N
  DB-->>API: rows (newest-first window)
  API-->>UI: hydrated rows (oldest-first after reverse)
  UI->>UI: compute hydratedIds, newestHydratedTs
  UI->>UI: filter prev by ID & timestamp (preserve streaming, system, tool-calls, newer-than-hydrated)
  UI->>UI: merge hydrated + preserved, sort by timestamp
  UI-->>UI: render merged message list (no silent loss)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰
I fetched the newest, then flipped with flair,
Kept streaming bits that danced in air,
No more vanishing messages late at night,
We merge by ID and set things right,
A hop, a fix, and all is bright!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically identifies the main changes: fixing issue #1531 (long conversation truncation) and preserving SSE state during mid-session recovery.
Description check ✅ Passed The description is comprehensive and follows the template well, including Summary with problem/why/what changed, UX Journey with before/after flows, Architecture Diagram with connection inventory, and all required sections.
Linked Issues check ✅ Passed The PR successfully addresses all three core requirements from issue #1531: (1) fixes DB query to select newest N messages instead of oldest N via DESC + reverse, (2) prevents mid-session message loss by merging fetched state by ID instead of replacing, and (3) maintains caller-visible chronological ordering.
Out of Scope Changes check ✅ Passed All changes are strictly scoped to fixing issue #1531: DB ordering logic and ChatInterface merge behavior. Cursor-based pagination and other potential enhancements are explicitly deferred as out-of-scope follow-ups.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/core/src/db/messages.test.ts (1)

106-116: ⚡ Quick win

Strengthen this ordering test with distinct timestamps.

Right now both fixtures effectively share created_at, so reversal is validated, but chronological intent is under-specified. Give rows different timestamps and assert final ID order explicitly.

Suggested patch
       const newestFirst: MessageRow[] = [
-        { ...mockMessage, id: 'msg-124', role: 'assistant', content: 'Hi!' },
-        mockMessage,
+        {
+          ...mockMessage,
+          id: 'msg-124',
+          role: 'assistant',
+          content: 'Hi!',
+          created_at: '2025-01-01T00:00:01.000Z',
+        },
+        { ...mockMessage, created_at: '2025-01-01T00:00:00.000Z' },
       ];
@@
-      expect(result).toEqual([...newestFirst].reverse());
+      expect(result.map(m => m.id)).toEqual(['msg-123', 'msg-124']);

As per coding guidelines, **/*.test.ts: “keep tests deterministic — no flaky timing or network dependence without guardrails.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/db/messages.test.ts` around lines 106 - 116, The test uses
two MessageRow fixtures (newestFirst) that currently share the same created_at,
so the reversal assertion doesn't prove chronological ordering; modify the
fixtures used in newestFirst to include distinct created_at timestamps (e.g.,
older timestamp on the second element and newer on the first) and then assert
the result by checking explicit ID order (e.g., expect(result.map(r =>
r.id)).toEqual([...])) after calling listMessages; keep
mockQuery.mockResolvedValueOnce(createQueryResult(newestFirst)) and the call to
listMessages unchanged, only change the created_at values and the final
expectation to assert IDs in oldest-first order.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/db/messages.ts`:
- Around line 68-69: The SQL ordering in the query string that currently uses
"ORDER BY created_at DESC LIMIT $2" is non-deterministic when multiple rows
share the same timestamp; update that SQL in messages.ts to add a stable
secondary key (e.g., the primary key column such as id or message_id) to the
ORDER BY clause (for example: ORDER BY created_at DESC, id DESC) so the LIMIT
window is deterministic; update any related query strings/constants in this file
(the one containing ORDER BY created_at DESC LIMIT $2) and run relevant tests.

In `@packages/web/src/components/chat/ChatInterface.tsx`:
- Around line 459-464: The merge keeps every prev message not in hydrated
(clientOnly) which preserves placeholder/optimistic duplicates (e.g., thinking-*
and msg-*) and can leave isStreaming stuck; update the setMessages merge so
clientOnly only retains items that truly have no canonical counterpart or are
actively streaming: filter prev to exclude IDs matching optimistic prefixes
(like /^thinking-/ or /^msg-/) unless the message.isStreaming is true, and also
drop optimistic entries when a hydrated row corresponds to the same
tempId/client-side id (compare a tempId or clientId field on prev items against
hydrated entries) so you don't keep duplicates; update the clientOnly
computation used in setMessages to implement these checks before merging and
sorting.

---

Nitpick comments:
In `@packages/core/src/db/messages.test.ts`:
- Around line 106-116: The test uses two MessageRow fixtures (newestFirst) that
currently share the same created_at, so the reversal assertion doesn't prove
chronological ordering; modify the fixtures used in newestFirst to include
distinct created_at timestamps (e.g., older timestamp on the second element and
newer on the first) and then assert the result by checking explicit ID order
(e.g., expect(result.map(r => r.id)).toEqual([...])) after calling listMessages;
keep mockQuery.mockResolvedValueOnce(createQueryResult(newestFirst)) and the
call to listMessages unchanged, only change the created_at values and the final
expectation to assert IDs in oldest-first order.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1822c668-c3ab-4a94-a6d6-5df456900241

📥 Commits

Reviewing files that changed from the base of the PR and between 69b2c89 and 5260d6b.

📒 Files selected for processing (3)
  • packages/core/src/db/messages.test.ts
  • packages/core/src/db/messages.ts
  • packages/web/src/components/chat/ChatInterface.tsx

Comment thread packages/core/src/db/messages.ts Outdated
Comment thread packages/web/src/components/chat/ChatInterface.tsx
@Wirasm

Wirasm commented May 4, 2026

Copy link
Copy Markdown
Collaborator

@ztech-gthb related to #1531 — conversation truncation fix.

@ztech-gthb

Copy link
Copy Markdown
Contributor Author

Both findings addressed in the latest commit: (1) ORDER BY now uses created_at DESC, id DESC so the LIMIT window is deterministic on shared timestamps; test updated to match. (2) clientOnly merge is now narrowed to entries with SSE-only state (system / actively-streaming with content / has tool-calls) or messages newer than the snapshot's newest timestamp — stale thinking-* placeholders and optimistic msg-* entries with canonical hydrated rows are dropped, no more duplicate bubbles or stuck isStreaming flags. 14/14 in messages.test.ts, type-check + lint + format clean.

@Wirasm

Wirasm commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Review Summary

Verdict: minor-fixes-needed

This PR fixes a silent truncation bug: conversations with >200 messages previously showed only the oldest 200 in the Web UI. The DB query now fetches the newest N messages (DESC + id tie-breaker, reversed to chronological) and ChatInterface hydrates via ID-based merge rather than wholesale replacement. The implementation is solid. One gap to address before merge: the client-side merge/re-hydration logic has no test coverage despite replacing non-trivial behavior.

Blocking issues

None.

Suggested fixes

  • packages/web/src/components/chat/ChatInterface.tsx:446–480: Add a test for the setMessages callback inside the SSE then() handler. The three-way filter (dedup by hydratedIds, preserve clientOnly, chronological sort) has no test despite replacing the previous interleaving logic. Cover these scenarios: id-collision replacement, system message sort, streaming message preservation, tool-call preservation, race-window message preservation, stale placeholder drop, and optimistic-ID canonicalization. A message-hydration.test.ts would work since the logic is inside a Promise.then callback.

Minor / nice-to-have

  • packages/core/src/db/messages.ts:66–72: Add a test case with two rows sharing the same created_at but different ids to exercise the tie-breaker edge case. The current test verifies the SQL clause is present but not the ordering semantics where id matters.

  • packages/core/src/db/messages.test.ts:101–125: Rename the test from its current (ASC-describing) name to "returns newest N messages in chronological order". Update the comment on line 105 to remove "queries newest-first then reverses" — that's an implementation detail callers don't need to know.

  • packages/core/src/db/messages.ts:50–57: Condense the 8-line listMessages docstring to a single paragraph: Queries newest N messages (DESC, id tie-breaker) and returns them oldest-first for the chronological-display contract.

  • packages/web/src/components/chat/ChatInterface.tsx:450–459: Trim the 12-line merge comment block — keep the first two sentences (invariants and non-duplication rules), drop the last sentence about the previous implementation (it's already fixed, so the historical reference will rot).

  • packages/web/src/components/chat/ChatInterface.tsx:465–468: Collapse the 4-line filter rationale to one line: // Filter: keep SSE-only state and race-window entries; drop stale placeholders that have hydrated counterparts.

Compliments

  • The ORDER BY created_at DESC, id DESC tie-breaker is a careful detail that keeps the LIMIT window deterministic — well worth the comment at messages.ts:67.
  • The ChatInterface merge logic documents what "client-only" means (sync banners, synthetic IDs, race-window messages) in a way that makes the invariants readable to future reviewers.

Reviewed via maintainer-review-pr workflow (Pi/Minimax). Aspects run: code-review, test-coverage, comment-quality.

@Wirasm

Wirasm commented May 25, 2026

Copy link
Copy Markdown
Collaborator

Hi @ztech-gthb — your recent commit addressed the review findings, but the PR has since gone DIRTY from dev moving on. Could you rebase onto current dev? Once it's clean I'll re-verify and merge. Ping me if you hit any blockers.

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

Labels

None yet

Projects

None yet

2 participants