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 intocoleam00: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 intocoleam00:devfrom
ztech-gthb:fix/issue-1531-ui-truncation

Conversation

@ztech-gthb
Copy link
Copy Markdown
Contributor

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

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
Copy link
Copy Markdown

coderabbitai Bot commented May 2, 2026

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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
Copy link
Copy Markdown
Collaborator

Wirasm commented May 4, 2026

@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.

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