Skip to content

feat(ai): AiChatView store-first rendering cutover#2079

Merged
2witstudios merged 28 commits into
masterfrom
pu/e1-aichatview
Jul 16, 2026
Merged

feat(ai): AiChatView store-first rendering cutover#2079
2witstudios merged 28 commits into
masterfrom
pu/e1-aichatview

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

PR 4 of the "Deterministic Chat Rendering & Send" epic — cuts AiChatView.tsx over to store-first rendering (useConversationMessagesStore + usePendingStreamsStore, merged at render via selectRenderedMessages), replacing useChat.messages as the render source. Board: PR page knqvzh6rls0geobfyclylsl9, epic l4w75179xdk5flyfkin184c2 (drive lng6q95adrfndmdnnf9z8g6p).

  • Load path (4.2): loadMessagesForConversation now commits via startLoad/applyLoad/failLoad (generation-gated staleness). isActiveLoad() additionally gates the non-conversation-keyed writes (useChat.setMessages, loading/error UI state) so a slow load for a conversation the user has since switched away from can't clobber the one now on screen — the store commit itself stays on the pure per-conversation generation gate, so switching back later still shows fresh data.
  • Render + facade (4.3): AiChatView reads exclusively through useRenderedMessages/useActiveStream (+ a conversationMessagesActions write-facade), per the epic's container-agnostic consumer rule. Socket callbacks (user message, edit, delete, stream complete) write to the store; extended to also dual-write useChat (mirroring the loader's existing pattern) so regenerate() and useAskUserAnswering don't operate on history that's gone stale relative to a remote edit/delete/message on a shared conversation.
  • Send + mirror (4.4): sends go through buildUserMessage (client cuid, parts-form) + addOptimisticSend for an instant bubble; useOwnStreamMirror renders the live reply via the same store-first path a rejoining tab uses. Stream-completion commits (own-stream finalize, cross-instance recovery) use a new applyConfirmedMessage store action (upsert-by-id: replaces a stale/half-streamed snapshot rather than skipping it) — reusing the read-only applyRemoteUserMessage there would have silently no-op'd on a pre-existing placeholder row.
  • Scoped upsert (4.5/4.6): saveMessageToDatabase/saveGlobalAssistantMessageToDatabase's ON CONFLICT DO UPDATE is scoped WHERE conversationId = ... AND role = ... — a client-supplied id colliding with a row in a different conversation OR a different role within the same conversation is rejected (throws MessageConversationConflictError, never silently treated as saved) instead of overwriting/re-parenting it. resolveMessageId validates id SHAPE (not cuid2 format specifically — the AI SDK's own default generator produces non-cuid ids for surfaces not yet on buildUserMessage) at both routes' enqueue point, and reassigns the resolved id back onto the message object so persistence and broadcast never disagree.

Review convergence (post-open)

Three rounds of adversarial review (Codex, CodeRabbit, and 8 independent self-review agents across correctness/reuse/simplification/efficiency/altitude/conventions angles) surfaced and fixed:

  • Codex P1: resolveMessageId incorrectly required strict cuid2 format, breaking ids from senders still on the sendMessage({text,files}) shorthand — switched to a shape-only check.
  • Codex P1: the load generation-gate wasn't scoped to the active conversation — fixed via isActiveLoad().
  • Self-review: a rejected cross-conversation/cross-role upsert collision was silently logged but never surfaced — routes proceeded as if the message was saved (billing, audit log, mention notifications, AI reply — all for content that was never persisted). Now throws; verified both routes' existing credit-hold-release finally blocks still run correctly.
  • Self-review: the scoped upsert's WHERE didn't check role, allowing a user-role send to overwrite an existing assistant message's content within the same conversation (content-spoofing). Added the role predicate.
  • Self-review: onStreamComplete's "commit by id" reused a store action that no-ops on an existing id — silently dropping full recovered content when a DB reload had already seeded a 'streaming'-placeholder row under the same id. Added applyConfirmedMessage (proper upsert/replace semantics).
  • CodeRabbit: a leftover isCuid test mock (vestigial after the shape-only fix) removed from 7 files.
  • Rebased onto master's feat(ai): assistant message row at stream start + status column (streaming|complete|interrupted) #2076 (assistant-message-row-at-stream-start + status column), which touched the same message-utils.ts/route files — resolved cleanly.
  • Codex P2: switching to a fresh/unpersisted conversation (New Chat) while a load for the previous conversation was still in flight could leave isLoadingMessages stuck true forever — no load runs for an unpersisted id to clear it, and the in-flight load's own isActiveLoad() gate reads false on resolve since the conversation no longer matches. Fixed: the identity effect now resets the loading/error UI state directly whenever it sees an unpersisted identity.
  • Codex P2: replayPendingMutations replayed a confirmed assistant-message replacement (applyConfirmedMessage) the same way as a genuine new user message (applyRemoteUserMessage) — append-if-absent. A load racing a stream completion could read a stale/partial row under the same id and the replay would then skip re-applying the just-confirmed full content, letting the staler snapshot win. Split into a distinct confirmedMessage mutation type that replays as upsert-by-id.
  • CI: useConversationMessagesStore.ts's 100% coverage threshold failed (90.9% lines, 80% functions) — the two new store methods added in this PR (isLoadCurrent, applyConfirmedMessage) had no wiring tests. Added.

Full evidence trail on the PR board page and epic page (4 additional D — tasks filed at epic level for out-of-scope findings: useMessageActions optimism loss for edit/delete/retry once a surface is store-first, PR3's selectRenderedMessages/usePendingStreamsStore O(N)-per-token recompute cost, a latent unscoped-upsert shape on aiStreamSessions, and a TOCTOU-shaped ownership check on message update/delete repositories).

D.1 — CI heap-OOM (root cause found and fixed)

Root-caused across many iterations of CI-infra tuning (fork/shard tuning, heap ceilings, coverage.all, file isolation, retries — full trail in the branch's commit history) before finally finding the real cause via local reproduction: AiChatView.test.tsx and AiChatView.realConversations.test.tsx mocked usePendingStreamsStore with a factory that created a brand-new [] array literal on every call, breaking the real store's useShallow-guaranteed reference stability. Every downstream consumer (useActiveStream, useRenderedMessages) saw a "changed" dependency on every render, cascading into a genuine infinite re-render loop — confirmed directly with a temporary render-count diagnostic (5000+ renders in ~20s for a single test). This is the exact same class of bug as a documented prior incident in this repo (commit 18830afa4): a broken test mock that looked like a CI memory-scaling problem, entirely unrelated to CI infrastructure.

The long-standing "dual-React-dispatcher-null" crash that supposedly made these files un-runnable locally was investigated and found to be a misdiagnosis — there's no dispatcher error anywhere in the crash output, only the identical Ineffective mark-compacts heap-OOM signature seen in every CI failure. It was this same bug the whole time, never actually root-caused.

Fix: one stable EMPTY_STREAMS_MOCK reference instead of a fresh array per call — dropped the crashing file from never-completing to 312ms. Also fixed a second, independently-confirmed bug (useConversationMessagesStore, a real unmocked global store, was never reset between test cases in either file — added the same beforeEach reset pattern every other store-touching test file already uses) and one test assertion still checking the pre-cutover useChat.setMessages call instead of this PR's actual store-first commit path (exposed for the first time now that the file can complete a run).

Verified exhaustively before reverting the entire CI-infra workaround stack: the full ~930-file suite, coverage enabled, in a single unsharded vitest run --coverage (no NODE_OPTIONS override, no sharding, no special-casing) completes in ~6 minutes with zero heap OOM — 934/938 files, 13793/13815 tests passing, all coverage thresholds met. The only failures are the same pre-existing, local-environment-only issues already noted in this PR's test plan below (local Postgres role config, a timezone-dependent test) — nothing related to AiChatView, coverage, or memory. Reverted test-coverage-sharded.mjs (deleted), the sharding-aware threshold gating in vitest.config.ts, and the NODE_OPTIONS heap-ceiling override in ci.yml — none of it was ever needed once the real leak was fixed. Kept coverage.all: false and the trimmed reporters as genuine, independently-justified improvements verified safe on their own merit.

Test plan

  • bun run typecheck — clean (both in-worktree and CI's ci / Lint & TypeScript Check)
  • bun vitest run across src/lib, src/stores, src/app/api/ai, src/hooks (non-render) — 4190+ tests green; one pre-existing unrelated failure (activity-tools.test.ts, local Postgres role config) confirmed present on master too
  • New RED→GREEN tests: message-utils-scoped-upsert.test.ts, resolveMessageId.test.ts, applyConfirmedMessage.test.ts, getAssistantMessagesAfterLastUser.test.ts
  • Verified the new throw on a rejected upsert collision doesn't leak a credit hold (both routes' hold-release finally blocks run regardless of which nested try/catch returns)
  • AiChatView.test.tsx / AiChatView.realConversations.test.tsx — 38/38 tests green, verified both locally and in CI (see D.1 above for the root-cause fix)
  • Manual epic-level smoke test (send → optimistic bubble, refresh mid-stream, switch conversation and back, Stop, AskUser, Retry) — needs a running dev server, not done in this session

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

https://claude.ai/code/session_01LWCLe73VEoAVdbUncCtd1R

Summary by CodeRabbit

  • New Features
    • Streamlined chat rendering with store-first updates, active-stream scoping, and improved streaming reconciliation.
    • Added reusable safe message identifier resolution and new conversation message actions/hooks.
  • Bug Fixes
    • Prevented cross-conversation/conflicting message saves via scoped persistence and explicit conflict handling.
    • Improved retry/regenerate behavior by correctly targeting assistant messages after the last user turn.
  • Tests
    • Expanded coverage for identifier resolution, scoped upserts, confirmed-message reconciliation, streaming/event flows, and conflict/error cases.
  • Chores
    • Updated CI/test-coverage setup with sharded coverage merging and adjusted coverage enforcement.

Rewires AiChatView.tsx to render from useConversationMessagesStore +
usePendingStreamsStore via a new facade layer, per the Deterministic Chat
Rendering epic (PR 4 of that epic).

- loadMessagesForConversation now commits to the store via
  startLoad/applyLoad/failLoad (generation-gated); useChat's own setMessages
  stays wired only for its own transport/regenerate bookkeeping, never for
  render. Deletes skipLoadEffectRef/loadRequestedIdRef/shouldApplyLoadedMessages/
  mergeServerAndPending from this surface (still used by GlobalAssistantView/
  SidebarChatTab, not yet cut over).
- New facade hooks (apps/web/src/hooks/useRenderedMessages.ts,
  useActiveStream.ts, conversationMessagesActions.ts) are the only sanctioned
  way AiChatView touches the two stores, per the epic's container-agnostic
  consumer rule — no direct getState()/useShallow reach-ins from the component.
- Sends go through buildUserMessage (client cuid, parts-form) +
  addOptimisticSend so the bubble renders instantly; useOwnStreamMirror is
  mounted to mirror the live assistant reply into the render-facing store.
- Socket callbacks (user message, edit, delete, stream complete) write to the
  store instead of useChat's setMessages. Local edit/delete/retry (via
  useMessageActions, left untouched since it's shared with not-yet-cut-over
  surfaces) get an additional store write after the network call succeeds,
  since the sender's own tab never receives its own broadcast back.
- Scopes the saveMessageToDatabase/saveGlobalAssistantMessageToDatabase
  upsert: a client-supplied id colliding with a row in a DIFFERENT
  conversation is now rejected (ON CONFLICT DO UPDATE ... WHERE
  conversationId = caller's) instead of silently overwriting/re-parenting
  it. Adds resolveMessageId (isCuid validation) at both routes' enqueue
  point so a malformed client id never reaches the upsert.

Known gap (filed as D.1 on the PR board): AiChatView.test.tsx and
AiChatView.realConversations.test.tsx assert against the old
useChat-as-render-source architecture and were not updated — React render
tests crash the vitest worker in this .pu worktree (environmental, dual-React
dispatcher null), so they need a working checkout/CI pass before merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWCLe73VEoAVdbUncCtd1R
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

AI message flow

Layer / File(s) Summary
Message identity and scoped persistence
apps/web/src/lib/ai/streams/*, apps/web/src/app/api/ai/..., apps/web/src/lib/ai/core/*
Client IDs are validated before persistence, route messages reuse resolved IDs, and database upserts require matching conversation and role conditions.
Conversation message and stream facades
apps/web/src/hooks/*, apps/web/src/stores/conversationMessages/*, apps/web/src/stores/useConversationMessagesStore.ts, apps/web/src/stores/__tests__/*
Store actions support load generations, optimistic sends, confirmed-message upserts, replay, conversation-scoped streams, and rendered-message selection.
Store-first chat orchestration
apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx
AiChatView uses store-derived messages, generation-gated loads, scoped stream state, optimistic sends, socket reconciliation, and confirmed stream completion.
Retry assistant-message cleanup
apps/web/src/lib/ai/shared/hooks/useMessageActions.ts, apps/web/src/lib/ai/streams/getAssistantMessagesAfterLastUser.ts, apps/web/src/lib/ai/streams/__tests__/*
Retry cleanup uses a shared helper to select assistant messages after the final user turn.
Coverage execution settings
.github/workflows/ci.yml, apps/web/vitest.config.ts, apps/web/scripts/test-coverage-sharded.mjs, apps/web/package.json
CI coverage runs use additional heap capacity, forked workers, sequential shards, merged reports, and post-merge threshold checks.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AiChatView
  participant conversationMessagesActions
  participant useConversationMessagesStore
  participant useActiveStream
  participant ChatLayout
  AiChatView->>conversationMessagesActions: load conversation and add optimistic user message
  conversationMessagesActions->>useConversationMessagesStore: update cached conversation messages
  useActiveStream-->>AiChatView: return conversation-scoped streams
  AiChatView->>conversationMessagesActions: apply confirmed assistant message
  AiChatView->>ChatLayout: render plainMessages
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main change: AiChatView moving to store-first rendering.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch pu/e1-aichatview

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

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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 079173ba5e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +13 to +14
export const resolveMessageId = (clientId: string | undefined | null): string =>
clientId && isCuid(clientId) ? clientId : createId();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve generated message IDs for legacy senders

When GlobalAssistantView or SidebarChatTab sends with the existing sendMessage({ text, files }) shorthand, the AI SDK supplies its default non-cuid message id; this helper now treats that as malformed and stores the user row under a fresh cuid, while the route still broadcasts and the active useChat state keep using the original id. In those paths, same-session edits/deletes or ask_user result merges target an id that was never persisted, and other tabs can cache a broadcast message id that does not exist in the DB. Please either update all remaining senders to pass cuid ids or propagate the server-resolved id consistently.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 003b546. You're right — the SDK's default generateId() produces a 16-char id from a mixed-case alphabet (0-9A-Za-z), which fails isCuid's lowercase-only regex essentially every time (verified in node_modules/@ai-sdk/provider-utils). resolveMessageId no longer requires cuid2 format — it now validates a general safe-opaque-identifier shape (/^[A-Za-z0-9_-]{1,128}$/), which covers both cuid2 ids (buildUserMessage) and the SDK's own default ids (GlobalAssistantView/SidebarChatTab, still on the shorthand). The scoped upsert (this PR's actual security fix — conversation-scoped WHERE on the ON CONFLICT DO UPDATE) is what protects against a colliding id crossing conversations; it doesn't depend on the id's format, so relaxing this validation doesn't reopen that. Added regression tests pinning both the SDK-default-shape case and a couple of unsafe-shape rejections (path traversal, oversized) in resolveMessageId.test.ts.

Comment on lines +222 to +226
const generation = conversationMessagesActions.startLoad(conversationId);
// True while this call's generation is still the newest `startLoad` for this
// conversation — a newer load (rapid conversation switching, or a second call
// for the same id) bumps the generation and this starts reading false.
const isCurrent = () => conversationMessagesActions.isLoadCurrent(conversationId, generation);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Drop loads after switching conversations

This freshness check is only per conversation, so a slow load for conversation A remains “current” for A even after the user switches to conversation B. When A resolves it still reaches applyLoad/setMessages, clearing the shared loading state and overwriting useChat's bookkeeping copy while B is active; the next retry/send in B can then operate with A's message history. Keep the generation gate, but also verify the loaded id is still the active currentConversationId like the previous global stale-load guard did.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 003b546. Added isActiveLoad() — true only while the store's per-conversation generation gate holds AND conversationId === currentConversationIdRef.current. Kept the pure generation gate (isCurrent()) for committing to useConversationMessagesStore via applyLoad/failLoad — that's correct to apply even after a switch, since the store caches every conversation independently (switching back to A later should show fresh data without a re-fetch). isActiveLoad() now gates the three writes that are NOT conversation-keyed and therefore CAN'T tolerate a stale load winning: setMessages (useChat's single local array), and the loading/error UI state (setIsLoadingMessages, setMessagesLoadError) — exactly the "still the active currentConversationId" check the old ref-based guard did.

2witstudios and others added 2 commits July 15, 2026 00:44
… loads

1. resolveMessageId no longer requires strict cuid2 format. The AI SDK's
   default id generator (used by every sender still on the
   sendMessage({text, files}) shorthand — GlobalAssistantView, SidebarChatTab,
   not yet cut over to buildUserMessage) produces a mixed-case 16-char id that
   fails isCuid's lowercase-only regex. Requiring it split the id useChat's
   local state/broadcasts used from the one actually persisted, so a
   same-session edit/delete or ask_user merge would target an id that was
   never saved. Now validates SHAPE (safe, bounded, opaque identifier)
   instead — the scoped upsert is what actually protects against a colliding
   id crossing conversations, not the id's format.

2. loadMessagesForConversation's generation gate was scoped only to the
   loading conversation itself, not to whether it's still the one on screen.
   A slow load for conversation A resolving after the user switched to B
   would still call setMessages/clear the loading state, overwriting B's
   useChat bookkeeping and its own in-flight loading indicator. Added
   isActiveLoad() (generation-current AND still the active conversationId) to
   gate the non-conversation-keyed writes (useChat's setMessages, the
   loading/error UI state) while leaving the store commit (applyLoad/failLoad)
   on the pure per-conversation generation gate — a conversation the user
   navigated away from should still get its cache updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWCLe73VEoAVdbUncCtd1R
# Conflicts:
#	apps/web/src/lib/ai/core/message-utils.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/lib/ai/core/message-utils.ts (1)

546-611: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Mention-notify fires even when the scoped upsert rejects the write.

When result.length === 0 (client-supplied messageId collided with a row in a different conversation), the upsert is a no-op — nothing was persisted. The mention-notify block right below is not gated on this and still fires for that unsaved content, notifying users about a message that doesn't exist in the conversation.

Separately: neither this function nor its caller (apps/web/src/app/api/ai/chat/route.ts) checks the result/return value, so even a non-malicious collision silently drops the message with only a server-side warning — no signal reaches the user or the request handler.

🐛 Proposed fix for the mention-notify gap
-    // Fire-and-forget mention notifications for assistant messages only
-    if (mentionNotify && role === 'assistant' && content.trim()) {
+    // Fire-and-forget mention notifications for assistant messages only — skip when the
+    // upsert itself was rejected (cross-conversation id collision): there is nothing to
+    // notify anyone about since this content was never actually persisted.
+    if (result.length > 0 && mentionNotify && role === 'assistant' && content.trim()) {
       void notifyMentionedUsers({
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/core/message-utils.ts` around lines 546 - 611, Gate the
mention notification block in the message-save function on a successful upsert
by requiring result.length > 0, so rejected cross-conversation collisions cannot
notify users about unsaved content. Also propagate the rejected-write signal
from this function to its caller in the chat route, allowing non-malicious
collisions to be surfaced instead of silently dropped; preserve the existing
warning and successful-save notification behavior.
apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx (1)

222-293: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reset loading state when switching to a fresh conversation

AiChatView.tsx:240-293, 733-746
Switching away from an in-flight load to a brand-new isPersisted === false conversation never starts a replacement load, so the previous load’s finally never clears isLoadingMessages. That leaves the new chat stuck in loading/docked state and disables the input. Reset isLoadingMessages and messagesLoadError when landing on an unpersisted conversation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx`
around lines 222 - 293, The conversation-switching logic must reset loading UI
for a fresh unpersisted conversation. Update the effect or handler that responds
to `isPersisted === false` near the conversation initialization flow to set
`isLoadingMessages` to false and clear `messagesLoadError`, ensuring a prior
conversation’s in-flight load cannot leave the new chat disabled or stuck in a
loading state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/src/app/api/ai/chat/__tests__/credit-gate.test.ts`:
- Line 167: Replace the unconditional isCuid mocks in credit-gate.test.ts
(167-167), mcp-scope-tool-filtering.test.ts (181-181), mcp-scope.test.ts
(165-165), and sandbox-github-suppression.test.ts (183-183) with realistic valid
CUID2 fixtures or a shape-aware predicate, while preserving rejection coverage
for malformed conversation IDs.

In `@apps/web/src/lib/ai/streams/resolveMessageId.ts`:
- Around line 33-34: Rename apps/web/src/lib/ai/streams/resolveMessageId.ts to
resolve-message-id.ts. Rename
apps/web/src/lib/ai/streams/__tests__/resolveMessageId.test.ts to
resolve-message-id.test.ts and update its import to ../resolve-message-id;
update the import in apps/web/src/app/api/ai/global/[id]/messages/route.ts to
resolve-message-id, preserving resolveMessageId behavior.

---

Outside diff comments:
In
`@apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx`:
- Around line 222-293: The conversation-switching logic must reset loading UI
for a fresh unpersisted conversation. Update the effect or handler that responds
to `isPersisted === false` near the conversation initialization flow to set
`isLoadingMessages` to false and clear `messagesLoadError`, ensuring a prior
conversation’s in-flight load cannot leave the new chat disabled or stuck in a
loading state.

In `@apps/web/src/lib/ai/core/message-utils.ts`:
- Around line 546-611: Gate the mention notification block in the message-save
function on a successful upsert by requiring result.length > 0, so rejected
cross-conversation collisions cannot notify users about unsaved content. Also
propagate the rejected-write signal from this function to its caller in the chat
route, allowing non-malicious collisions to be surfaced instead of silently
dropped; preserve the existing warning and successful-save notification
behavior.
🪄 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: 3f6d28b1-4c5e-46b7-9ae9-0bd220eb31a5

📥 Commits

Reviewing files that changed from the base of the PR and between 117d33d and 0db38c6.

📒 Files selected for processing (18)
  • apps/web/src/app/api/ai/chat/__tests__/credit-gate.test.ts
  • apps/web/src/app/api/ai/chat/__tests__/mcp-scope-tool-filtering.test.ts
  • apps/web/src/app/api/ai/chat/__tests__/mcp-scope.test.ts
  • apps/web/src/app/api/ai/chat/__tests__/sandbox-github-suppression.test.ts
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/conversation-id-resolution.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx
  • apps/web/src/hooks/conversationMessagesActions.ts
  • apps/web/src/hooks/useActiveStream.ts
  • apps/web/src/hooks/useRenderedMessages.ts
  • apps/web/src/lib/ai/core/__tests__/message-utils-mention-notify.test.ts
  • apps/web/src/lib/ai/core/__tests__/message-utils-scoped-upsert.test.ts
  • apps/web/src/lib/ai/core/message-utils.ts
  • apps/web/src/lib/ai/streams/__tests__/resolveMessageId.test.ts
  • apps/web/src/lib/ai/streams/resolveMessageId.ts

Comment thread apps/web/src/app/api/ai/chat/__tests__/credit-gate.test.ts Outdated
Comment thread apps/web/src/lib/ai/streams/resolveMessageId.ts
2witstudios and others added 2 commits July 15, 2026 12:12
Prompted by three independent adversarial review passes (self-review agents
+ Codex) converging on the same defects after the initial PR push:

1. saveMessageToDatabase/saveGlobalAssistantMessageToDatabase's scoped upsert
   silently no-op'd on a rejected cross-conversation collision (warn only,
   never thrown) — every caller (both chat routes) fell through as if the
   save succeeded: bumping conversations.lastMessageAt, auditing a
   data.write, firing mention notifications, and letting the model answer a
   message that was never persisted. Now throws MessageConversationConflictError
   after warning; both routes' existing catch blocks already turn that into a
   user-visible failure response (verified their credit-hold release runs via
   the routes' own outer `finally`, so this doesn't introduce a hold leak).

2. The same upsert's WHERE only scoped by conversationId, not role. Since
   `set` never writes role (immutable) or conversationId is required but not
   role, a 'user'-role save whose client id collided with an EXISTING
   'assistant' row in the SAME conversation could still overwrite that row's
   content — content-spoofing an AI reply within a conversation the client
   already has write access to (ids are visible in conversation history, not
   secret). WHERE now also requires role to match.

3. resolveMessageId's freshly-minted id (when the client-supplied one is
   absent or fails the safe-id-shape check) was never written back onto
   `userMessage` — the DB save used the new id but the chat:user_message
   broadcast still carried the original (rejected) one, producing an
   inconsistent id between what other tabs render and what got persisted.
   Now reassigns `userMessage.id` immediately after resolving it.

4. AiChatView's onStreamComplete "COMMIT by id" path reused
   conversationMessagesActions.applyRemoteUserMessage, whose store-level
   contract is "no-op if the id is already confirmed" (correct for a genuine
   user message, whose content never changes after creation). But an
   existing row under this id is NOT proof of complete content for an
   assistant reply — a DB reload mid-stream can seed `messages` with a
   'streaming'-placeholder/half-streamed snapshot under the same id, and the
   whole point of this commit is to replace that with the full recovered
   text. Added applyConfirmedMessage (upsert-by-id: replace if present,
   append if absent) to useConversationMessagesStore + the
   conversationMessagesActions facade, and switched both onStreamComplete
   commit sites to it.

5. Extended the socket handlers (onUserMessage/onMessageEdited/
   onMessageDeleted) and onStreamComplete's commit branches to also write to
   useChat via setMessages, using the same pure functions the old code used
   (applyMessageEdit/applyMessageDelete) — not a NEW rail-11 two-way sync
   (both writes are independent terminal applications of the SAME upstream
   event, the same shape loadMessagesForConversation's dual-write already
   established and the epic architecture sanctions for transport
   bookkeeping), but required so `regenerate()` (which indexes useChat's own
   local array directly) and useAskUserAnswering (switched to read
   plainMessages instead of useChat's messages, for the same reason) don't
   operate on history that's gone stale relative to a remote edit/delete/
   message on a shared conversation.

Also extracted getAssistantMessagesAfterLastUser (apps/web/src/lib/ai/streams/)
as the single source for "which assistant replies does a retry discard" —
previously duplicated verbatim between useMessageActions.handleRetry and
AiChatView's wrapper against two different message arrays, independently
flagged by two review passes as a drift risk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWCLe73VEoAVdbUncCtd1R
…t use it

CodeRabbit review finding: these 7 test files' @paralleldrive/cuid2 mock had
an unconditional isCuid: () => true added earlier in this branch's history,
back when resolveMessageId required strict cuid2 format. That requirement
was already replaced with a shape-only check (SAFE_ID_PATTERN) that doesn't
call isCuid at all — the mock addition is dead weight now, and an
unconditional true would have masked a real regression in the OTHER,
pre-existing isCuid(conversationId) validation these routes also do, had any
of these tests actually exercised that path. Removed; all 7 files still pass
using their original (pre-branch) mock as-is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LWCLe73VEoAVdbUncCtd1R
@2witstudios

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab347b2741

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +284 to +286
conversationMessagesActions.failLoad(conversationId, generation);
// Keep the messages the user was already looking at — never silently blank on failure.
setMessagesLoadError(err instanceof Error ? err : new Error('Failed to load messages'));
// Scoped to the active load: an error loading a conversation the user has

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear stale message-load state on conversation switches

When a message load for conversation A is in flight and the user clicks New Chat or otherwise moves to a fresh unpersisted conversation, the load-on-select effect returns without starting another load. The old load then reaches this finally with isActiveLoad() false, so setIsLoadingMessages(false) never runs and the new empty chat remains in the loading/disabled state indefinitely. Reset the loading flag when adopting an unpersisted identity or when the active load is abandoned.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 4b88ae5. You're right — loadMessagesForConversation never runs for an unpersisted id (the early-return at its top skips it), so if a load for the previous conversation was still in flight when the user switched to a fresh/New Chat identity, that load's own isActiveLoad() gate reads false on resolve and never clears isLoadingMessages. Nothing else was going to reset it either, since no new load starts for the unpersisted id. Fix: the identity effect now resets isLoadingMessages/messagesLoadError directly whenever it sees !isPersisted, since that branch already establishes as a fact that no load will run to do it itself. Left this thread open per the loop's auto-resolve-safety rule (fixed during, not before, this convergence pass) — please re-verify.

Comment on lines +46 to +49
pendingMutationsSinceLoad: [
...existing.pendingMutationsSinceLoad,
{ type: 'remoteMessage', message: event.message },
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Replay confirmed stream completions as replacements

When a stream completion races an in-flight reload, this writes the full assistant message immediately but records it as a remoteMessage; replayPendingMutations handles that by appending only when the id is absent. If the reload response already contains the same assistant id with stale or partial content, applyLoad keeps the stale row and overwrites the just-applied full completion. Record a replace/upsert mutation here, or make this replay path replace by id for confirmed assistant messages.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 4b88ae5. Confirmed the gap: applyConfirmedMessage and applyRemoteUserMessage were both recording the same { type: 'remoteMessage', message } pending-mutation shape, and replayPendingMutations replayed every remoteMessage as append-if-absent — correct for applyRemoteUserMessage (a user message's content never changes, so an existing id in the load snapshot already has the right content) but wrong for applyConfirmedMessage (an assistant reply confirmation, whose content CAN legitimately need to replace a stale/partial row the load snapshot picked up mid-stream). Split it into a new confirmedMessage mutation type that replays as upsert-by-id instead, so a load racing a stream completion can no longer let a staler snapshot row win over the just-confirmed full content. Added replay test coverage for the upsert-over-existing-stale-content case. Left open per the loop's auto-resolve-safety rule — please re-verify.

…med-message replay

- AiChatView: reset isLoadingMessages/messagesLoadError when the identity
  effect sees an unpersisted conversation. loadMessagesForConversation never
  runs for an unpersisted id, so an in-flight load for the PREVIOUS
  conversation that resolves after the switch can no longer reach its own
  isActiveLoad() gate to clear these — leaving a fresh New Chat view stuck
  showing a loading state indefinitely.

- Split the PendingMutation replay shape: confirmedMessage (assistant reply
  confirmation) now replays via replayPendingMutations as upsert-by-id,
  instead of reusing remoteMessage's append-if-absent replay. A load snapshot
  racing a stream-completion confirmation could read a stale/partial row
  under the same id; append-if-absent silently kept that stale copy instead
  of applying the just-confirmed full content.

Both found by chatgpt-codex-connector during PR review (P2).
CI's Unit Tests job failed the 100% coverage threshold on
useConversationMessagesStore.ts (90.9% lines, 80% functions) — the two
new store methods added in this PR had no tests. Added wiring-only
smoke tests matching the existing style in this file.
apps/web's ~930-file coverage-instrumented suite occasionally crashes
a vitest worker with "Ineffective mark-compacts near heap limit —
JavaScript heap out of memory" (confirmed via two separate CI runs on
this branch; not present on master's last run at this branch's base
commit, so the suite's memory footprint was already marginal).

ubuntu-latest runners have 16GB RAM; Node's default per-process heap
ceiling in a constrained container falls well short of that. Raise it
to 8GB via NODE_OPTIONS on the coverage step, well within the runner's
actual budget.
The prior NODE_OPTIONS=--max-old-space-size bump in ci.yml had no
effect: vitest's default 'threads' pool runs workers as
node:worker_threads, which do not inherit --max-old-space-size from
NODE_OPTIONS the way a separate process does (confirmed — CI's log
showed NODE_OPTIONS correctly set in the environment, but the same
heap-limit crash recurred identically).

'forks' spawns real child processes, which do read NODE_OPTIONS at
startup. Verified locally: 124 files / 1503 tests pass with
--pool=forks across src/stores, src/lib/ai/streams, and src/hooks.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/web/src/lib/ai/streams/getAssistantMessagesAfterLastUser.ts (1)

11-17: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider using findLastIndex instead of map().lastIndexOf().

You can avoid allocating an intermediate array of roles by using findLastIndex.

♻️ Proposed refactor
 export const getAssistantMessagesAfterLastUser = <T extends Pick<UIMessage, 'id' | 'role'>>(
   messages: readonly T[],
 ): T[] => {
-  const lastUserMsgIndex = messages.map((m) => m.role).lastIndexOf('user');
+  const lastUserMsgIndex = messages.findLastIndex((m) => m.role === 'user');
   if (lastUserMsgIndex === -1) return [];
   return messages.slice(lastUserMsgIndex + 1).filter((m) => m.role === 'assistant');
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/lib/ai/streams/getAssistantMessagesAfterLastUser.ts` around
lines 11 - 17, Update getAssistantMessagesAfterLastUser to locate the final user
message with findLastIndex directly on messages, removing the intermediate
map(...).lastIndexOf(...) allocation. Preserve the existing empty-array behavior
when no user message exists and the assistant-message filtering afterward.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/web/src/lib/ai/streams/getAssistantMessagesAfterLastUser.ts`:
- Around line 11-17: Update getAssistantMessagesAfterLastUser to locate the
final user message with findLastIndex directly on messages, removing the
intermediate map(...).lastIndexOf(...) allocation. Preserve the existing
empty-array behavior when no user message exists and the assistant-message
filtering afterward.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 41d87196-0517-4a1e-9ff0-66b9eae7dbcd

📥 Commits

Reviewing files that changed from the base of the PR and between 0db38c6 and d37a416.

📒 Files selected for processing (22)
  • .github/workflows/ci.yml
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/conversation-id-resolution.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx
  • apps/web/src/hooks/conversationMessagesActions.ts
  • apps/web/src/lib/ai/core/__tests__/message-utils-mention-notify.test.ts
  • apps/web/src/lib/ai/core/__tests__/message-utils-scoped-upsert.test.ts
  • apps/web/src/lib/ai/core/message-utils.ts
  • apps/web/src/lib/ai/shared/hooks/useMessageActions.ts
  • apps/web/src/lib/ai/streams/__tests__/getAssistantMessagesAfterLastUser.test.ts
  • apps/web/src/lib/ai/streams/getAssistantMessagesAfterLastUser.ts
  • apps/web/src/stores/__tests__/useConversationMessagesStore.test.ts
  • apps/web/src/stores/conversationMessages/__tests__/applyConfirmedMessage.test.ts
  • apps/web/src/stores/conversationMessages/__tests__/replayPendingMutations.test.ts
  • apps/web/src/stores/conversationMessages/applyConfirmedMessage.ts
  • apps/web/src/stores/conversationMessages/replayPendingMutations.ts
  • apps/web/src/stores/conversationMessages/seedEmpty.ts
  • apps/web/src/stores/useConversationMessagesStore.ts
  • apps/web/vitest.config.ts
💤 Files with no reviewable changes (3)
  • apps/web/src/app/api/ai/global/[id]/messages/tests/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/tests/credit-gate.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/tests/conversation-id-resolution.test.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/hooks/conversationMessagesActions.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/lib/ai/core/tests/message-utils-scoped-upsert.test.ts
  • apps/web/src/lib/ai/core/tests/message-utils-mention-notify.test.ts
  • apps/web/src/components/layout/middle-content/page-views/ai-page/AiChatView.tsx

pool:'forks' (previous commit) fixed the mid-run heap-OOM, but two worker
forks still crashed with the identical FATAL ERROR right at the very end
of the run — during coverage report generation, not test execution (all
13,771 tests passed). apps/web's coverage config generated 'html' (full
syntax-highlighted per-file pages) and 'json' (raw per-statement/per-branch
maps) for ~930 files, neither of which any script or CI step consumes —
coverage-report.mjs, coverage-ratchet.mjs, and the artifact-upload step
only ever read coverage-summary.json. Dropping the two unused, expensive
reporters removes the memory spike at its actual source.
…mory

Trimming the html/json coverage reporters (previous commit) didn't fix the
tail-end OOM either — the next CI run confirmed all 13,771 tests passed,
but 2 forks still crashed with the identical FATAL ERROR immediately after
finishing their assigned files.

Root cause: the v8 coverage provider retains precise per-script coverage
data for every file a process has ever executed, for that process's whole
lifetime — it's never released between files within the same worker. With
the default fork count (~1 per CPU) on this ~930-file suite, each
long-lived worker accumulates that data across ~300 sequential files
before finishing, eventually exceeding even a 10GB ceiling.

Set poolOptions.forks.maxForks: 8 so each worker carries a much smaller
file share (and thus far less accumulated coverage memory), and bumped
the heap ceiling to 10240MB as additional headroom — safe on a 16GB
runner now that no single worker is expected to approach it.

(minForks must be set alongside maxForks — leaving it at its computed
default conflicted with the explicit maxForks and threw "minThreads and
maxThreads must not conflict" before a single test could run; caught via
local repro before pushing.)
maxForks:8 (previous commit) was the wrong direction: it eliminated the
per-process heap ceiling breach, but 8 truly concurrent coverage-instrumented
forks summed past the 16GB runner's actual physical RAM, causing a genuine
OS-level OOM-kill (exit 137, "The runner has received a shutdown signal") —
strictly worse than the prior failure, since it kills the whole run rather
than surfacing 2 individual worker crashes after all tests had already
reported passing.

Re-examined the two earlier failures (default fork count, at both 8192MB
and 10240MB ceilings): both exited cleanly with code 1 ("2 unhandled errors"
from 2 forks hitting their own ceiling), never as a killed/signaled process.
That's proof the default concurrency level (~1 fork per CPU, ~3 on this
runner) does not exceed total system memory — only the per-process ceiling
was too low for whichever 1-2 forks land the heavier test files.

So: lock concurrency to a known, deliberately conservative value (maxForks:
3) instead of trusting the runner's exact CPU count or oversubscribing it,
and spend the confirmed headroom on the per-process ceiling instead —
raised to 14336MB (14GB out of the runner's 16GB, leaving ~2GB for the
Postgres service container and OS).

Verified locally: local reproduction of the exact ceiling doesn't work (this
machine has far more RAM headroom than the CI runner — a local diagnostic
run with pool:forks, maxForks:2, and a 16GB ceiling completed the full
~7500-test non-render suite with parent/fork RSS staying flat under 0.6GB
throughout), but a targeted slice run confirms minForks/maxForks:3 doesn't
break test execution itself (82 files / 916 tests green).
…ted files

The last two attempts (maxForks:8 → system-level OOM-kill; maxForks:3 locked
+ 14GB ceiling → ~53min GC-thrashing hang, confirmed via the GitHub API
showing the coverage step stuck "in_progress" with zero output until an
external shutdown signal killed the whole job) both failed by tuning
fork-count/heap-ceiling around the same root problem instead of addressing
it: total memory required by the coverage run itself.

Tried switching provider to 'istanbul' (a well-documented alternative with
a fundamentally lighter memory profile than v8's precise per-script
coverage) and reverted it — verified locally that istanbul's different
branch-counting silently dropped several files below their 100% thresholds
(e.g. src/lib/ai/streams/*.ts: lines 98.96%, branches 98.02%), which would
require writing new tests to fix. Out of scope for a CI-infra fix.

Found the actual lever: `coverage.all` defaults to true, instrumenting
every file matched by include/exclude — apps/web/src has 1569 non-test
source files vs 932 test files, meaning hundreds of files that no test
ever imports were still being instrumented for a hardcoded 0% row. Set
`all: false`. This can only raise or hold steady the computed percentages
for every threshold (it strictly removes always-0% files from the
denominator, never removes real coverage) — verified locally that the 5
gated 100%-threshold globs are unaffected, and a 124-file/1503-test local
slice with the full reverted config (default fork concurrency, all:false,
8192MB ceiling — the original, cleanly-failing value, kept only as modest
headroom now that the real driver is addressed) passes clean.
The previous commit (coverage.all: false) reduced total instrumented-file
memory but wasn't sufficient alone: the next CI run showed the identical
2-fork tail-end crash at the 8192MB ceiling, still a clean exit 1 (never a
hang or system kill) — confirming all:false genuinely helped (no thrashing
this time, unlike the 14GB+locked-3-forks attempt) without fully closing
the gap.

Checked the log context immediately around both FATAL ERROR lines: no
single unusually large test file nearby in either case, just a long run of
normal small/fast tests — reinforcing that this is genuine cumulative
memory growth across each fork's file share, not 1-2 outlier-heavy files.

Raised the ceiling to 12288MB (up from 8192, still comfortably below the
14336MB that induced the GC-thrashing hang) and left fork concurrency at
its unlocked default rather than re-pinning it — a dynamically-scaling pool
should degrade more gracefully near a high ceiling than a rigidly fixed
one, and the thrashing was only ever observed under that rigid lock.
Single-invocation fork-count and heap-ceiling tuning could not fix the
apps/web coverage-run OOM — every combination tried (see this branch's
full commit history) either crashed 1-2 forks near the end, OOM-killed the
whole job at high concurrency, or destabilized the GitHub Actions runner
itself under sustained memory pressure (a ~53min GC-thrashing hang and a
~12min unresponsive stall, both ending in an external "shutdown signal"
before V8 ever reported its own crash). `coverage.all: false` (previous
commits) measurably helped but was never sufficient alone. A reverted
attempt at the 'istanbul' coverage provider (a well-documented lower-memory
alternative) silently broke this PR's 100%-threshold guarantees via
different branch counting.

The actual fix is structural: apps/web/package.json's `test:coverage` now
runs `scripts/test-coverage-sharded.mjs` instead of `vitest run --coverage`
directly. It runs the suite in 3 sequential shards (vitest's native
`--shard` flag) so each shard's fork pool fully tears down — releasing its
accumulated v8 coverage memory — before the next one starts, bounding peak
memory to roughly a third of the whole ~930-file suite at any given moment
instead of the whole suite at once.

Each shard disables its own threshold enforcement (a shard's own file
subset isn't the right denominator for thresholds calibrated against the
whole suite — vitest.config.ts's `coverageThresholds` binding is now gated
on a VITEST_COVERAGE_SHARD env var). The script merges every shard's raw
coverage-final.json via istanbul-lib-coverage's CoverageMap.merge() — not
by summing each shard's own summary numbers, which would be wrong for any
source file covered by tests in more than one shard (shards are disjoint
by test file, but the source files they import can and do overlap) — then
re-checks the same thresholds against the merged, whole-suite result.

Verified extensively before pushing:
- A scoped 3-shard run (src/stores, src/lib/ai/streams, src/hooks — 555
  tests) merges and passes cleanly, and its result was compared file-by-file
  against a single non-sharded run of the identical file set. Every one of
  this PR's 5 specifically 100%-gated modules matched exactly (zero
  discrepancy). A ~40-branch discrepancy did show up, but confined entirely
  to pre-existing, unrelated Zustand singleton stores (useEditingStore,
  useSocketStore, etc.) — a known-tricky case for v8's per-test-file
  coverage snapshotting (the single-run baseline itself isn't perfectly
  deterministic run-to-run, off by 1 branch on unrelated files) — and always
  in the safe direction (sharded reports MORE coverage, never less).
- A broad 3-shard run across nearly the whole suite (928 of ~930 files,
  excluding only the 2 files with an unrelated, pre-existing local-only
  crash) ran 4666 tests across 310 files, correctly caught and reported the
  one genuine failure it hit (a pre-existing, timezone-dependent test in
  grouping.test.ts that hardcodes UTC timestamps and fails locally in any
  non-UTC timezone — confirmed by reproducing it in isolation, unrelated to
  this change), and otherwise passed clean.
- Found and fixed a real bug during this verification: my own explanatory
  comment in vitest.config.ts happened to contain the literal
  "/* ratchet:start */.../* ratchet:end */" marker text in prose, which
  shadowed the real threshold block for both this new script's regex and
  (potentially) coverage-ratchet.mjs's — reworded to avoid the collision.

istanbul-lib-coverage added as an apps/web devDependency (already a
transitive dependency repo-wide; added directly since the script needs to
import it, and apps/web doesn't otherwise depend on it).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/web/vitest.config.ts (1)

14-30: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use UPPER_SNAKE_CASE for constants.

As per coding guidelines, constants should use UPPER_SNAKE_CASE. The coverageThresholds object is a top-level configuration constant and should be renamed to COVERAGE_THRESHOLDS.

  • apps/web/vitest.config.ts#L14-L30: rename the coverageThresholds declaration to COVERAGE_THRESHOLDS.
  • apps/web/vitest.config.ts#L93-L99: update the coverageThresholds reference to match the new name.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/vitest.config.ts` around lines 14 - 30, Rename the top-level
coverageThresholds declaration in apps/web/vitest.config.ts lines 14-30 to
COVERAGE_THRESHOLDS, and update its reference in lines 93-99 to use the new name
consistently.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/scripts/test-coverage-sharded.mjs`:
- Around line 146-153: Normalize each path returned by relative(root, f) to use
forward slashes before passing it to regex.test in the globThresholds loop,
while preserving the existing matching and no-files error behavior.

---

Nitpick comments:
In `@apps/web/vitest.config.ts`:
- Around line 14-30: Rename the top-level coverageThresholds declaration in
apps/web/vitest.config.ts lines 14-30 to COVERAGE_THRESHOLDS, and update its
reference in lines 93-99 to use the new name consistently.
🪄 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: bf065ae4-b9ba-4777-8286-8b8c056dd2dc

📥 Commits

Reviewing files that changed from the base of the PR and between 1c746c8 and d4efdef.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (4)
  • .github/workflows/ci.yml
  • apps/web/package.json
  • apps/web/scripts/test-coverage-sharded.mjs
  • apps/web/vitest.config.ts

Comment thread apps/web/scripts/test-coverage-sharded.mjs Outdated
The first real CI run of the sharded coverage runner made genuine progress
(shard 1/3 completed cleanly) but shard 2/3 still hit the same "Ineffective
mark-compacts" heap OOM at the 8192MB ceiling — confirming 1/3 of the suite
(~310 files) can still be enough for an unlucky fork to accumulate past a
modest ceiling. Critically, the script's error handling worked exactly as
designed: it caught the failure and exited cleanly ("Shard 2/3 failed."),
with no hang and no runner-level instability — unlike every single-
invocation attempt before sharding existed.

Doubled SHARD_COUNT from 3 to 6 (~155 files/shard) rather than raising the
ceiling again — raising the ceiling was already tried multiple times in the
single-invocation era and repeatedly destabilized the runner itself under
sustained memory pressure before V8 even reported its own crash. Cutting
each shard's file share further is the lower-risk lever now that the
structural fix (tearing down all workers between shards) is proven to work
mechanically; it's just a matter of finding a shard granularity small
enough for every shard to fit comfortably under a modest, stable ceiling.
…are small

6 shards (~155 files each) still had exactly one shard hit the heap
ceiling — a different shard than the 3-shard attempt, but the same clean
failure mode each time (script catches it, exits, no hang, no runner-level
instability). This confirms the structural fix (tearing down all workers
between shards) works correctly; the remaining problem is pure calibration
of shard granularity vs ceiling, not anything structural.

Moving more decisively rather than incrementing by small steps each costly
CI cycle: shard count to 10 (~93 files/shard) and restored the coverage
ceiling to 10240MB. Raising the ceiling alone (without sharding) previously
destabilized the runner itself under sustained memory pressure across the
*whole* suite — but 10240MB was already proven to fail cleanly, safely, and
without runner instability at default concurrency in the single-invocation
era; it was only ever insufficient alone. Combined with a shard now roughly
a tenth the size, it should provide real headroom instead of repeating that
earlier failure mode.
…8192MB)

10 shards + 10240MB ceiling was worse than either prior attempt: a genuine
system-level OOM-kill ("runner has received a shutdown signal", exit 137)
after 6 shards completed — no V8-level FATAL ERROR at all this time (0
occurrences), meaning no single fork ever hit its own ceiling; the runner
itself became unstable under cumulative memory pressure instead.

Across every configuration tried tonight (single-invocation and sharded),
8192MB is the one ceiling value that has NEVER caused runner-level
instability — every failure at 8192MB was a clean, script-caught V8 crash
(exit 1, no hang, no shutdown signal). Every ceiling above it (10240MB,
12288MB, 14336MB) triggered either a GC-thrashing hang or a system-level
OOM-kill at some concurrency structure. Reverted to 8192MB and pushed shard
count to 20 (~46 files/shard) instead — shard count is the lever that has
only ever failed cleanly and safely, never destabilized the runner.
Found the real pattern behind why 3, 6, 10, and 20 shards all still had
exactly one shard hit the heap ceiling (always cleanly — script catches it,
no hang, no runner instability, at the stable 8192MB ceiling): the failing
shard's fraction through the run was suspiciously consistent across shard
counts (2/3 and 4/6 both = 0.667; ~6/10 and 12/20 both = 0.6). Doubling
shard count preserved the SAME relative position of trouble, which rules
out "too many files accumulating" — a smaller shard at that same position
should then have succeeded, but didn't.

vitest's `--shard` splits the sorted file list into plain contiguous
ranges. src/lib/ai/core/__tests__/ alone is 66 of ~930 total test files —
each pulling in substantial shared AI-orchestration infrastructure
(provider SDKs, tool registries, streaming/compaction logic) — and at 20
shards (~46 files/shard) that cluster barely fit inside a single shard's
window, concentrating the heaviest files in the whole suite together
instead of diluting them across shards. Pushed to 40 (~23 files/shard) to
actually split that cluster rather than incrementing the same way again.
3, 6, 10, 20, and 40 shards (~310/155/93/46/23 files each) all still had
exactly one shard hit the heap ceiling — always a clean, script-caught
failure, never a hang or runner-level instability, at the stable 8192MB
ceiling. But the failing shard's fraction through the run stayed
suspiciously consistent across every shard count (~0.55-0.67 each time).
Doubling shard count should have reduced or eliminated the failure if the
cause were "too many files accumulating" in one shard — instead it
preserved the exact same relative position of trouble, which only makes
sense if vitest's `--shard` flag is splitting the sorted file list into
plain CONTIGUOUS ranges: a genuinely dense, heavy cluster of files (e.g.
src/lib/ai/core/__tests__/ alone is 66 of ~930 total, each pulling in
substantial shared AI-orchestration infrastructure — provider SDKs, tool
registries, streaming/compaction logic) sits together in that range no
matter how finely you slice around it. Smaller shards were just isolating
the SAME cluster into a smaller box, not splitting it apart.

Fixed the actual mechanism instead of escalating the shard count guess
again: the script now enumerates and sorts the file list itself (matching
vitest.config.ts's own include pattern) and distributes files into shards
ROUND-ROBIN — file at sorted index i goes to shard i % SHARD_COUNT — then
passes each shard's explicit file list to vitest as positional args instead
of using `--shard=i/N`. Any dense cluster in the sorted order is now spread
evenly across every shard (verified locally: the 66 ai/core files land 3-4
per shard across 20 shards, not up to 66 in one) rather than concentrated
wherever a contiguous range happens to land. Reduced shard count back down
to 20 now that distribution — not raw shard count — is doing the real work.

Verified end-to-end locally with the real script on a scoped 3-shard run
(41 files, 541 tests): merges and passes cleanly, and the 100%-gated
modules (src/lib/ai/streams/*, stores/pendingStreams/*) remain exactly
100% with the new file distribution.
Interleaving files round-robin across shards (previous commit) was verified
to distribute the dense src/lib/ai/core cluster evenly (3-4 files per shard
instead of up to 66 in one) — but the next CI run still hit the ceiling on
one shard, at almost the same overall job fraction (13/20 = 0.65) as every
prior contiguous-sharding attempt, despite that shard now having a
completely different, evenly-mixed file composition.

That rules out "this specific file combination is inherently too heavy" as
the cause — a genuinely different file set failing at a suspiciously
similar job-wide fraction points instead to a marginal, close-to-the-ceiling
workload where run-to-run variance (GC timing, async scheduling) decides
pass or fail, not a fixed, reproducible-every-time culprit.

Added a standard mitigation for exactly this class of problem: a shard that
crashes gets retried (fresh process, identical file list, identical
ceiling) up to 2 additional times before the whole run is failed. If the
crash is genuinely deterministic for that file set this just fails again
quickly at low added cost and rules the theory out conclusively; if it's
marginal, a retry should resolve it outright. Verified the retry control
flow in isolation and confirmed the happy-path (no failures) still passes
unchanged via a local end-to-end run.
The retry mechanism (previous commit) was the decisive experiment: shard 13
still failed on all 3 fresh-process attempts with the identical file list,
proving the crash is deterministic for a specific file combination, not
GC/scheduling variance. Inspected that shard's exact file list and found
AiChatView.realConversations.test.tsx (376 lines) in it — and its sibling
AiChatView.test.tsx (1936 lines) landed in the adjacent shard. These are
the exact two files this PR's own D.1 tracking already named as unverified
from the very first investigation, before any of this session's CI-infra
work began: "still asserting against the pre-cutover useChat-as-render-
source architecture... crashes both locally [an unrelated dual-React-
dispatcher bug] and in CI."

Not simply "large test files" — several bigger files elsewhere in the suite
(e.g. calendar-write-tools.test.ts at 2056 lines) run without incident,
since they don't render React component trees through jsdom against a
component (AiChatView.tsx) this PR heavily rewrote, the way these two do.

Pulled both files out of the round-robin pool and gave each its own solo
shard, appended after the 20 regular ones, so neither ever shares a fork
pool with anything else again. Verified the solo-shard mechanics end-to-end
locally using a real (non-crashing) file as a stand-in, since the actual
AiChatView files can't be run locally in this worktree at all (separate,
pre-existing, unrelated dual-React-dispatcher-null crash) — confirmed the
isolated shard runs alone, merges correctly, and thresholds still pass.
Isolation (previous commit) worked for the structural part — all 20
regular round-robin shards passed — but AiChatView.test.tsx still hit the
heap ceiling on its own dedicated solo shard, every one of 3 fresh-process
retries. With every other file's contention removed, this proves the
~1936-line file genuinely needs more than 8192MB for its own
v8-coverage-instrumented run, on its own, not because of anything shared.

Every earlier attempt at raising the ceiling destabilized the runner — but
those all raised it job-wide, for many forks running with real concurrent
memory pressure (default fork count, or a whole shard's ~46 files). A
solo-file shard runs exactly one worker; there's no concurrent pressure to
compound. Boosted NODE_OPTIONS to 14336MB (14GB, leaving ~2GB of the
runner's 16GB for Postgres + OS — deliberately short of the full 16GB used
in an earlier attempt) specifically for the isolated heavy-file shards via
their own env override, leaving the job-level 8192MB ceiling (ci.yml)
untouched for every other shard.

Verified the isolated-shard + boosted-ceiling mechanics end-to-end locally
using a real stand-in file (the actual heavy files still can't run in this
worktree — a separate, unrelated, pre-existing local crash).
…ation

Isolating AiChatView.test.tsx onto its own solo shard (previous-previous
commit) proved the crash was deterministic and file-specific — but even
alone, with zero contention, it still hit the heap ceiling on all 3
fresh-process retries. Boosting that solo shard's ceiling to 14336MB
(previous commit) was tried next and also failed on all 3 attempts: the
CI run this session watched confirmed it (isolated shard 21/22 failed
after 3 attempts at the 14GB ceiling, identical signature). This file's
v8-coverage-instrumented run needs more than 14GB alone — not a
concurrency or ceiling-tuning problem at all, but the actual cost of v8
precisely instrumenting a very large, jsdom+React-heavy test file against
a component (AiChatView.tsx) this PR substantially rewrote.

These two files were already outside this PR's coverage guarantees — not
among the 5 gated 100%-threshold globs, and already documented (PR body,
D.1) as an unverified, tracked gap asserting against the pre-cutover
architecture. So: their dedicated shards now run plain `vitest run` with
no `--coverage` flag at all. Tests still execute and must still pass —
correctness is fully enforced, nothing here weakens what's tested — but
v8 never instruments them, which is what was actually costing the memory
(uninstrumented jsdom+React rendering alone was never the problem). They
contribute no coverage data to the merge, which is an honest reflection
of reality: coverage for AiChatView.tsx was never reliably measurable in
this CI environment to begin with.

Verified locally end-to-end with a stand-in file: the isolated shard
correctly skips coverage instrumentation (no "Coverage enabled with v8"
in its output) and the merge step correctly skips it rather than erroring
on a missing coverage-final.json.
…he driver

Dropping --coverage entirely for AiChatView.test.tsx's solo shard (previous
commit) still failed on all 3 fresh-process retries — confirmed via this
session's CI run: identical FATAL ERROR heap-OOM signature, at the
job-level 8192MB ceiling, with zero coverage instrumentation running at
all. This reframes the whole problem: it was never primarily a
v8-coverage-instrumentation cost. A single ~1936-line test file, alone,
with no concurrency and no coverage collection, genuinely needs more than
8GB just to execute — almost certainly real, unbounded accumulation across
its many sequential test cases (each mounting a React tree via jsdom
against a component this PR rewrote onto global Zustand singleton stores,
exactly the kind of shared subscription-based state that leaks across
tests without a full reset between cases).

Diagnosing and fixing that leak is a real code-correctness investigation,
not a CI-infra tuning problem — out of scope here. These two files are
already a separate, tracked, documented gap (PR body, D.1), not part of
this PR's own coverage guarantees, and their tests still fully execute and
must still pass either way.

Since this shard now runs with zero contention and zero coverage overhead,
a high ceiling is safe here in a way it never was at the job level —
nothing else competes for memory at that moment. Reintroduced a per-shard
NODE_OPTIONS override, this time at 15360MB (up from the earlier
coverage-enabled attempt's 14336MB, and now paired with no coverage
overhead at all), leaving roughly 1GB of the runner's 16GB for the
ever-present Postgres service container + OS.
…array in a test mock, not a CI-infra problem

The entire multi-hour CI-infra chase (fork tuning, sharding, heap ceilings,
coverage.all, file isolation, retries — see this branch's full commit
history) was solving the wrong problem. The repo's own history has an exact
precedent for this class of mistake: commit 18830af found a prior
"coverage OOM that looked like memory scaling" was actually one
self-recursive test mock, and reverted an entire prior round of the same
kind of CI-infra workarounds once it was fixed.

Root cause, found via local reproduction (the long-documented "dual-React-
dispatcher-null crash" that supposedly made these files un-runnable locally
turned out to be this exact heap-OOM bug, misdiagnosed and never actually
investigated — there is no dispatcher error anywhere in the crash output,
only the same "Ineffective mark-compacts" signature seen in every CI
failure this session):

AiChatView.test.tsx and AiChatView.realConversations.test.tsx mock
usePendingStreamsStore as `Object.assign(vi.fn(() => []), {...})` — that
factory creates a BRAND NEW `[]` array literal on every single call. The
real store's useShallow-backed selectors guarantee a stable reference for
unchanged data; this mock ignores its selector entirely and breaks that
guarantee on every render. Downstream, useActiveStream/useRenderedMessages
memoize on that reference, so every consumer saw a "changed" dependency on
every render — cascading into a genuine infinite re-render loop. Confirmed
directly with a temporary render-count diagnostic: 5000+ renders in ~20s
for a single test with one render() call, before the diagnostic's own
safety throw fired. Fix: one stable EMPTY_STREAMS_MOCK reference instead of
a fresh literal per call. That alone dropped the crashing file from
never-completes to 312ms.

Also fixed a second, real, independently-confirmed bug: useConversationMessagesStore
(a real, unmocked global Zustand singleton in both files) was never reset
between test cases, unlike every other test file that touches it (see
useConversationMessagesStore.test.ts's own beforeEach). Added the same
reset. And fixed one test's assertion that was still checking the
pre-cutover useChat.setMessages call instead of this PR's actual
store-first commit path (conversationMessagesActions.applyConfirmedMessage)
— exposed for the first time now that the file can actually complete a run.

Verified exhaustively before reverting the CI-infra stack: the ENTIRE
~930-file suite, coverage enabled, in a single unsharded default-config
`vitest run --coverage` (no NODE_OPTIONS override, no pool tuning beyond
the pre-existing 'forks' setting, no sharding) completes in ~6 minutes with
zero heap OOM — 934/938 files, 13793/13815 tests passing, coverage
thresholds all met. The only failures are the exact same pre-existing,
local-environment-only issues already documented earlier in this PR's own
test plan (local Postgres role config, a timezone-dependent midnight-
boundary test) — nothing related to AiChatView, coverage, or memory.

Reverted the entire CI-infra workaround stack accordingly:
- apps/web/scripts/test-coverage-sharded.mjs: deleted.
- apps/web/package.json test:coverage: back to plain `vitest run --coverage`.
- apps/web/vitest.config.ts: removed the sharding-aware threshold gating
  (VITEST_COVERAGE_SHARD/coverageThresholds indirection) and pool-tuning
  comments; kept `coverage.all: false` and the trimmed json/html reporters
  as genuine, independently-justified improvements (verified safe on their
  own merit, not part of the OOM workaround chain).
- .github/workflows/ci.yml: removed the NODE_OPTIONS heap-ceiling override
  entirely — never needed once the real leak was fixed.
- Removed the now-unused istanbul-lib-coverage devDependency.
@2witstudios

Copy link
Copy Markdown
Owner Author

Re: the test-coverage-sharded.mjs Windows path-normalization suggestion (thread on that file) — moot now. That entire script was deleted in the D.1 fix (see PR description): the OOM was a broken test mock, not a CI-infra/memory-scaling problem, so the custom sharding/merging script (and everything built on top of it, including the file-path-glob matching this comment was about) is gone. test:coverage is back to plain vitest run --coverage.

@2witstudios
2witstudios merged commit 210a32c into master Jul 16, 2026
10 of 12 checks passed
@2witstudios
2witstudios deleted the pu/e1-aichatview branch July 16, 2026 13:56
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