Skip to content

feat(ai): assistant message row at stream start + status column (streaming|complete|interrupted)#2076

Merged
2witstudios merged 7 commits into
masterfrom
pu/e2-message-status
Jul 15, 2026
Merged

feat(ai): assistant message row at stream start + status column (streaming|complete|interrupted)#2076
2witstudios merged 7 commits into
masterfrom
pu/e2-message-status

Conversation

@2witstudios

@2witstudios 2witstudios commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Summary

PR 2 of the "Server Stream Durability & Rejoin" epic. Adds status text NOT NULL DEFAULT 'complete' (streaming | complete | interrupted) to chat_messages and messages, and inserts an empty assistant placeholder row the moment generation starts, so history can show in-flight entries and pre-checkpoint process death doesn't silently lose the reply.

The entire risk of this PR is the readers: every consumer of these two tables (~30 sites) was inventoried and updated to correctly handle a streaming placeholder (empty content, mid-flight, excluded from model context / previews / mutations by default) and an interrupted row (real partial content, terminal, included everywhere).

Schema / insert

  • Migration 0208_motionless_thunderbolts.sql (additive, default-covered, generated via bun run db:generate only).
  • Placeholder insert lands inside startGenerationExclusive's run closure in both POST /api/ai/chat and POST /api/ai/global/[id]/messages — same critical section as takeover+lifecycle-create (PR 4's seam note). Skipped when pre-aborted.
  • Terminal writes: saveMessageToDatabase / saveGlobalAssistantMessageToDatabase accept an explicit status param. execute-end and onFinish in both routes pass 'interrupted' whenever the run was aborted (user Stop or credit gate), 'complete' otherwise — including the zero-buffered-content abort case, and a best-effort outer-catch write if createUIMessageStream itself throws before either callback runs. 'interrupted' via cross-instance abort/materialization (a stream this process didn't own) is PR 3's wiring — this PR fixes the contract and owns same-process abort, doesn't implement the cross-instance reaper.

Readers updated (exclude streaming by default)

  • Model-context/compaction loads (page + global route history loads, chatMessageRepository, ask_agent, consult route — plus fixed a pre-existing missing isActive filter on consult's fallback branch, page-read-tools, ask-user-resume's fetchers on both page and global adapters).
  • Conversation-list previews (conversation-repository's raw-SQL CTEs).
  • Mutations: edit/delete of a streaming row now 409s.
  • Converters (convertDbMessageToUIMessage / convertGlobalAssistantMessageToUIMessage, all branches) now propagate status.
  • Read APIs (chat/messages, global/[id]/messages, page-agent conversation messages, v1/conversations/[id]) gate streaming rows behind includeStreaming=1 (stale-tab rollout protection).
  • Client dedup: mergeServerAndPending (shared by AiChatView and SidebarChatTab) now replaces a server-loaded streaming placeholder with the live stream's richer content instead of losing to the empty DB row.
  • Exports: tenant-export's column lists and gdpr-export's collectUserMessages both carry status.

Open product decision (explicitly not resolved here)

Undo vs. an in-flight stream: whether undo should 409 the whole undo, allow it with a hidden terminal write, or allow it + abort the stream. Left as a blocked sub-task with evidence. The safe interim default is implemented and tested: ai-undo-service excludes status='streaming' rows from its sweep entirely (preview count + both soft-delete updates), so undo can never soft-delete a live stream's row — everything else in range still gets undone normally.

Review fixes (Codex)

  • Aborted runs were persisted as 'complete'. saveMessageToDatabase/saveGlobalAssistantMessageToDatabase always wrote status: 'complete' on their terminal upsert, even for a run the user (or credit gate) stopped — contradicting the contract that 'interrupted' rows are terminal-with-real-partial-content. Fixed: both now take an explicit status, and both routes compute aborted = agentRun?.terminalReason === 'aborted' || abortSignal.aborted at execute-end/onFinish to pick it.
  • Placeholder could get permanently orphaned. A stream stopped before any buffered content (or before createUIMessageStream even finished constructing) never called saveMessageToDatabase at all — since streaming rows are hidden from every reader by default and edit/delete 409 on them, that left an invisible, permanently-locked ghost row (no reaper exists yet; PR 3 isn't merged). Fixed in both routes' execute-end/onFinish (write an empty-content 'interrupted' row when aborted, regardless of buffered content) and in a new best-effort outer-catch cleanup (guarded so it can never downgrade a row already settled).
  • A self-review sweep (8-angle) on the fix above found the fix itself had a bug: the outer-catch cleanup read lifecycle.getBufferedParts() after calling lifecycle.finish(), which deletes the buffer getBufferedParts() reads from — so the cleanup always persisted empty content, silently discarding any real partial content it claimed to preserve. Fixed by capturing the buffer before finish() in both routes (chat/route.ts has a separate inner catch that also calls finish() first, so it captures the buffer too and hands it to the outer catch). Also deduplicated the agentRun?.terminalReason === 'aborted' || abortSignal.aborted check (previously copy-pasted 3x) into isRunAborted() in run-agent-with-retry.ts, and fixed a missing conversations.lastMessageAt bump on the aborted/no-responseMessage fallback branch.
  • CodeRabbit review, round 2: (a) a run that exhausted its retries without ever aborting or producing a responseMessage (sustained provider outage) fell through both the execute-end buffered-content-or-aborted guard and onFinish's if (responseMessage) guard — same stuck-placeholder failure class, a third path neither prior fix covered. (b) the global route had no execute-end durable-persist block at all (only chat/route.ts did) — if the client disconnected after generation (mobile backgrounding, the exact scenario PR fix(ai): deterministic stream recovery for global assistant on mobile #2065 just fixed client-side for this same route), onFinish might never fire and the row stayed 'streaming' forever. Fixed by making both routes' execute-end write unconditional (chat/route.ts) and by adding an execute-end block to the global route mirroring chat/route.ts's exactly, including the lastMessageAt bump. onFinish's no-responseMessage branch is now a no-op in both routes — execute-end has already terminalized the row by the time onFinish would run.
  • Self-review (8-angle, high-effort) on the full diff, round 4: five more genuine correctness gaps, all now fixed and covered by RED→GREEN-verified tests.
    • ask-user-resume.ts's persistFor closures (both page and global adapters) never passed status to saveMessageToDatabase/saveGlobalAssistantMessageToDatabase, which default it to 'complete'. A row correctly terminalized as 'interrupted' (stopped mid-flight with a pending ask_user call) silently flipped back to 'complete' the next time the user answered or a new message dismissed it — erasing the "this reply was cut short" signal for exactly the rows most likely to carry a pending question. Both adapters now thread the fetched row's own status through.
    • Both routes' onFinish could write a phantom row for a pre-aborted stream: the AI SDK always invokes onFinish with a non-null responseMessage (an empty {parts: []} shell) even when execute() returned immediately without writing anything, but the placeholder INSERT is deliberately skipped when preAborted — so the upsert would INSERT a brand-new empty 'interrupted' row for a request that never reached the model. Guarded on !lifecycle?.preAborted.
    • Both routes' outer-catch cleanup could fabricate a phantom row for an exception thrown before lifecycle was ever assigned (inside startGenerationExclusive's callback — e.g. takeOverConversationStreams or the placeholder INSERT itself failing). The guard checked !lifecycle?.preAborted, which is vacuously true when lifecycle is undefined. Now requires lifecycle itself.
    • Chat route's credit-gate abort (creditAbortController) was combined into the nested streamText call's abortSignal but not into runAgentWithRetry's own abortSignal param — so classifyAttempt/isRunAborted never saw it, and a run cut short by the credit gate mid-stream was misclassified and persisted as 'complete' instead of 'interrupted' (and could even retry against an already-exhausted balance). Fixed by combining the same signal at the runAgentWithRetry call site.
    • search-tools.ts's line-numbering subquery and discovery-service.ts's Global Assistant preference-extraction query both read chat_messages/messages without excluding status='streaming' — two sites the PR's own ~30-site reader inventory missed.
  • CodeRabbit review, round 3 (post round-4 self-review): the global route's new execute-end should bump conversations.lastMessageAt test only asserted that db.update's call count increased, not that any of the new calls actually targeted conversations — an unrelated db.update call in the same code path would have made the test pass without exercising the bump it claims to guard. Fixed by asserting one of the new calls targets the conversations table specifically (same mocked module reference). RED-verified by temporarily removing the lastMessageAt bump and confirming the assertion fails.

Test plan

  • bun run typecheck green across all 16 monorepo packages (including web:build)
  • bun vitest run green on every touched file (154 tests across 9 files) plus the full apps/web + packages/lib sweep otherwise unaffected — the sweep's remaining failures are two pre-existing, documented .pu-worktree environment limitations unrelated to this diff (dual-React module resolution in component tests; integration tests requiring a real Postgres connection unavailable locally), neither of which touches any file this PR changes. CI's ci / Unit Tests (real Postgres) is green.
  • New/updated tests: streaming-row 409 guards, includeStreaming=1 gating, mergeServerAndPending's live-vs-placeholder dedup, the abort-status fix, the buffered-content-preserved-through-cleanup regression guard, the global route's execute-end coverage, isRunAborted's unit tests, ask-user-resume's status-preservation on merge/dismiss, and both routes' pre-aborted-phantom-row guards (onFinish + outer-catch) — every new/changed assertion verified RED→GREEN by temporarily reverting the corresponding fix and confirming failure before restoring it.
  • Independent spec review (R task on the board, separate agent)

🤖 Generated with Claude Code

https://claude.ai/code/session_013QQTjjDoYto6GHcBBfWEnB

Summary by CodeRabbit

  • New Features
    • Added message lifecycle statuses: streaming, complete, and interrupted (also included in data exports).
    • Added includeStreaming=1 opt-in to display streaming placeholders in message listings.
    • Improved server-side handling to preserve partial responses when interrupted or errors occur.
  • Bug Fixes
    • Fixed placeholder rows being left in an incorrect streaming state.
    • Prevented editing/deleting messages that are still generating (returns 409).
    • Improved reconciliation of active streams with loaded conversation history, avoiding phantom/empty placeholder rows.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds persisted streaming, complete, and interrupted message statuses, abort-aware stream cleanup, default filtering of streaming placeholders, guarded edits/deletions, pending-stream reconciliation, and related schema, export, API, and test updates.

Changes

Streaming message durability

Layer / File(s) Summary
Status contracts and persistence
packages/db/src/schema/*, apps/web/src/lib/ai/core/*, packages/lib/src/compliance/*
Message schemas, UI types, persistence helpers, exports, and abort detection now carry lifecycle status.
Abort-aware stream lifecycle
apps/web/src/app/api/ai/chat/route.ts, apps/web/src/app/api/ai/global/.../messages/route.ts
Streaming placeholders are inserted, buffered content is captured before lifecycle cleanup, and terminal rows are persisted as complete or interrupted.
Read filtering and opt-in loading
apps/web/src/lib/repositories/*, apps/web/src/app/api/ai/*, apps/web/src/services/api/ai-undo-service.ts
Streaming placeholders are excluded from model context, repository reads, conversation summaries, and undo operations unless explicitly requested.
Mutation guards and reconciliation
apps/web/src/app/api/ai/*/messages/[messageId]/route.ts, apps/web/src/lib/ai/streams/mergeServerAndPending.ts
Streaming messages cannot be edited or deleted, and matching streaming server placeholders are replaced with pending buffered content.
Validation and fixtures
apps/web/src/**/__tests__/*
Tests cover interrupted persistence, buffered-part ordering, phantom-row prevention, query flags, mutation conflicts, reconciliation, and status-aware fixtures.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ChatRoute
  participant Lifecycle
  participant MessageDB
  Client->>ChatRoute: Start AI generation
  ChatRoute->>MessageDB: Insert streaming placeholder
  ChatRoute->>Lifecycle: Finish with abort state
  Lifecycle-->>ChatRoute: Return buffered parts
  ChatRoute->>MessageDB: Persist complete or interrupted status
  MessageDB-->>Client: Load terminalized message
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% 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
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 summarizes the main change: adding assistant placeholder rows at stream start and a new message status column.
✨ 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/e2-message-status

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: bce267e62a

ℹ️ 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".

// Terminal write: flips a 'streaming' placeholder row to 'complete'. Every caller of
// saveMessageToDatabase persists a finished message (execute-end, onFinish, ask_agent,
// consult, etc.) — never a mid-flight one — so this is unconditionally correct here.
status: 'complete',

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 Mark stopped partial replies as interrupted

When a user stops after any chunk has been buffered, the execute/onFinish persistence still goes through this helper, and this new conflict update always flips the placeholder to complete even though the route later calls lifecycle.finish(true) for aborted runs. The new status contract says interrupted rows are terminal partial output, but stopped partial replies will be stored as completed and consumers cannot tell they were cut off; the global helper has the same unconditional complete update. Please pass the aborted outcome into the message write or update the row to interrupted on stop.

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 2dac307. saveMessageToDatabase/saveGlobalAssistantMessageToDatabase now accept an explicit status param instead of always writing 'complete'. Both routes' execute-end and onFinish blocks compute aborted = agentRun?.terminalReason === 'aborted' || abortSignal.aborted and pass status: aborted ? 'interrupted' : 'complete'. New tests cover both the aborted-with-content and non-aborted cases; verified the assertions fail against the pre-fix code before committing.

isActive: true,
userId: null,
sourceAgentId: null,
status: 'streaming',

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 Terminalize placeholders stopped before content

This inserts a streaming message row for every non-preaborted stream, but the stop paths only terminalize ai_stream_sessions; if the user stops before any buffered part or responseMessage exists, no saveMessageToDatabase call ever touches this row. Because the new readers hide status='streaming' by default and edit/delete now return 409 for it, an empty stopped reply can remain an invisible/locked placeholder indefinitely. Please clear the placeholder or mark it interrupted from the abort/finish path; the global route has the same start-row issue.

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 2dac307, both routes. chat/route.ts's execute-end guard widened from bufferedParts.length > 0 to bufferedParts.length > 0 || aborted, so an aborted run with zero buffered parts still writes a (possibly empty-content) 'interrupted' row. global/[id]/messages/route.ts has no execute-end persist block at all (a pre-existing architectural difference from chat/route.ts, not introduced by this PR) — added a fallback branch in its onFinish for !responseMessage && aborted that persists using lifecycle.getBufferedParts(), mirroring chat route's execute-end pattern.

Also closed the rarer symmetric gap where createUIMessageStream itself throws before execute()/onFinish ever run: both routes' outer catch now does a best-effort terminal write (guarded by a new assistantMessagePersisted flag so it can never downgrade a row already settled by a successful execute-end/onFinish write).

New tests in both routes cover aborted-with-zero-content and the outer-catch case; verified each fails against the pre-fix code before committing.

2witstudios added a commit that referenced this pull request Jul 14, 2026
…lete

Addresses two Codex review findings on #2076:

1. saveMessageToDatabase / saveGlobalAssistantMessageToDatabase's onConflictDoUpdate
   always set status:'complete', even for a run the user (or credit gate) stopped —
   contradicting the status contract ('interrupted' = terminal, real partial output).
   Both now accept an explicit status param; execute-end and onFinish in both chat
   routes pass 'interrupted' whenever agentRun.terminalReason === 'aborted' or the
   route's abortSignal is aborted.

2. If a stream was stopped before any buffered part or responseMessage existed, no
   saveMessageToDatabase call ever touched the placeholder row — since streaming rows
   are hidden from every reader by default and edit/delete now 409 on them, that left
   an invisible, permanently-locked ghost row. Fixed by writing a (possibly
   empty-content) 'interrupted' row whenever the run was aborted, regardless of
   buffered content:
   - chat/route.ts: execute-end's guard widened from `bufferedParts.length > 0` to
     `bufferedParts.length > 0 || aborted`.
   - global/[id]/messages/route.ts: onFinish gained a fallback branch for
     `!responseMessage && aborted`, using lifecycle.getBufferedParts() the same way
     chat/route.ts's execute-end block does (this route had no execute-end persist
     block at all — a pre-existing architectural difference from chat/route.ts, not
     something this fix introduces).

Also closes the same failure class for the rarer case where createUIMessageStream
itself throws before execute()/onFinish ever run: both routes' outer catch now
does a best-effort terminal write (status:'interrupted'), guarded by a new
assistantMessagePersisted flag so it can never downgrade a row a successful
execute-end/onFinish write already settled.

New tests in both routes' stream-socket-events.test.ts covering: aborted-with-content
→ interrupted, aborted-with-zero-content → still persisted as interrupted,
non-aborted → complete (regression guard), and outer-catch cleanup. Verified each
new assertion fails against the pre-fix code (temporarily reverted, confirmed RED,
restored) before committing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QQTjjDoYto6GHcBBfWEnB
2witstudios added a commit that referenced this pull request Jul 14, 2026
…lete

Addresses two Codex review findings on #2076:

1. saveMessageToDatabase / saveGlobalAssistantMessageToDatabase's onConflictDoUpdate
   always set status:'complete', even for a run the user (or credit gate) stopped —
   contradicting the status contract ('interrupted' = terminal, real partial output).
   Both now accept an explicit status param; execute-end and onFinish in both chat
   routes pass 'interrupted' whenever agentRun.terminalReason === 'aborted' or the
   route's abortSignal is aborted.

2. If a stream was stopped before any buffered part or responseMessage existed, no
   saveMessageToDatabase call ever touched the placeholder row — since streaming rows
   are hidden from every reader by default and edit/delete now 409 on them, that left
   an invisible, permanently-locked ghost row. Fixed by writing a (possibly
   empty-content) 'interrupted' row whenever the run was aborted, regardless of
   buffered content:
   - chat/route.ts: execute-end's guard widened from `bufferedParts.length > 0` to
     `bufferedParts.length > 0 || aborted`.
   - global/[id]/messages/route.ts: onFinish gained a fallback branch for
     `!responseMessage && aborted`, using lifecycle.getBufferedParts() the same way
     chat/route.ts's execute-end block does (this route had no execute-end persist
     block at all — a pre-existing architectural difference from chat/route.ts, not
     something this fix introduces).

Also closes the same failure class for the rarer case where createUIMessageStream
itself throws before execute()/onFinish ever run: both routes' outer catch now
does a best-effort terminal write (status:'interrupted'), guarded by a new
assistantMessagePersisted flag so it can never downgrade a row a successful
execute-end/onFinish write already settled.

New tests in both routes' stream-socket-events.test.ts covering: aborted-with-content
→ interrupted, aborted-with-zero-content → still persisted as interrupted,
non-aborted → complete (regression guard), and outer-catch cleanup. Verified each
new assertion fails against the pre-fix code (temporarily reverted, confirmed RED,
restored) before committing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QQTjjDoYto6GHcBBfWEnB
@2witstudios
2witstudios force-pushed the pu/e2-message-status branch from 109e02c to 0f70674 Compare July 14, 2026 23:49
2witstudios and others added 4 commits July 14, 2026 19:03
…aming|complete|interrupted)

Adds status text NOT NULL DEFAULT 'complete' (streaming|complete|interrupted) to
chat_messages and messages, and inserts an empty assistant placeholder row the
moment generation starts (inside startGenerationExclusive's run closure, same
critical section as takeover+lifecycle-create per the PR 4 seam note).

Reader inventory (~30 sites) updated to exclude 'streaming' placeholders:
- Model-context/compaction loads: page + global history loads, chatMessageRepository
  (v1/completions, page-payload-service), ask_agent, consult route (+ fixed a
  pre-existing missing isActive filter on its fallback branch), page-read-tools,
  ask-user-resume's fetchById/fetchLastAssistant (both page + global adapters).
- Previews: conversation-repository's raw-SQL CTEs (last-message preview + count).
- Mutations: edit/delete of a streaming row now 409s (chat + global [messageId]
  routes). Undo: the undo-vs-in-flight-stream UX call (409 whole undo / allow+hidden
  write / allow+abort) is a product decision, left open — ai-undo-service now excludes
  streaming rows from its sweep entirely (preview count + both soft-delete updates) as
  the safe interim default, so undo can never soft-delete a live stream's row.
- Converters: convertDbMessageToUIMessage / convertGlobalAssistantMessageToUIMessage
  (all branches, including a reconstruction-success branch that previously dropped
  extra fields entirely) now propagate status.
- Read APIs: chat/messages, global/[id]/messages, page-agents conversation messages,
  and v1/conversations/[id] GET all gate streaming rows behind includeStreaming=1.
  Client dedup: mergeServerAndPending (shared by AiChatView and SidebarChatTab) now
  replaces a server-loaded streaming placeholder with the richer live stream version
  instead of losing to the empty DB row.
- Exports: tenant-export's column lists and gdpr-export's collectUserMessages both
  carry status now.

Terminal writes: saveMessageToDatabase / saveGlobalAssistantMessageToDatabase's
onConflictDoUpdate sets status: 'complete' (execute-end + onFinish, matching the
existing durable-persist / best-effort-refine split). 'interrupted' via abort/
materialization is PR 3's wiring, per the epic's own "contract fixed here" framing.

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

Addresses two Codex review findings on #2076:

1. saveMessageToDatabase / saveGlobalAssistantMessageToDatabase's onConflictDoUpdate
   always set status:'complete', even for a run the user (or credit gate) stopped —
   contradicting the status contract ('interrupted' = terminal, real partial output).
   Both now accept an explicit status param; execute-end and onFinish in both chat
   routes pass 'interrupted' whenever agentRun.terminalReason === 'aborted' or the
   route's abortSignal is aborted.

2. If a stream was stopped before any buffered part or responseMessage existed, no
   saveMessageToDatabase call ever touched the placeholder row — since streaming rows
   are hidden from every reader by default and edit/delete now 409 on them, that left
   an invisible, permanently-locked ghost row. Fixed by writing a (possibly
   empty-content) 'interrupted' row whenever the run was aborted, regardless of
   buffered content:
   - chat/route.ts: execute-end's guard widened from `bufferedParts.length > 0` to
     `bufferedParts.length > 0 || aborted`.
   - global/[id]/messages/route.ts: onFinish gained a fallback branch for
     `!responseMessage && aborted`, using lifecycle.getBufferedParts() the same way
     chat/route.ts's execute-end block does (this route had no execute-end persist
     block at all — a pre-existing architectural difference from chat/route.ts, not
     something this fix introduces).

Also closes the same failure class for the rarer case where createUIMessageStream
itself throws before execute()/onFinish ever run: both routes' outer catch now
does a best-effort terminal write (status:'interrupted'), guarded by a new
assistantMessagePersisted flag so it can never downgrade a row a successful
execute-end/onFinish write already settled.

New tests in both routes' stream-socket-events.test.ts covering: aborted-with-content
→ interrupted, aborted-with-zero-content → still persisted as interrupted,
non-aborted → complete (regression guard), and outer-catch cleanup. Verified each
new assertion fails against the pre-fix code (temporarily reverted, confirmed RED,
restored) before committing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QQTjjDoYto6GHcBBfWEnB
…At; dedup aborted check

Self-review (8-angle) on the previous fix commit surfaced two real bugs and one
worthwhile simplification, independently confirmed by 3 separate finder angles:

1. CRITICAL: the outer-catch cleanup wrote in the wrong order — it called
   lifecycle.finish(true) BEFORE reading lifecycle.getBufferedParts(). finish()
   deletes the multicast registry entry getBufferedParts() reads from, so the
   cleanup write always persisted EMPTY content, silently discarding any real
   partial content the fix's own comment claimed to preserve. Fixed in both
   routes by capturing getBufferedParts() before finish(). chat/route.ts has a
   SEPARATE inner catch (createUIMessageStream construction failure) that also
   calls finish() before rethrowing to the outer catch — that inner catch now
   captures the buffer too (bufferedPartsAtStreamError), and the outer catch
   prefers that capture when set, since a fresh call by the time it runs would
   already see the inner catch's cleared buffer.

2. The global route's new onFinish fallback branch (no responseMessage, but
   aborted) didn't bump conversations.lastMessageAt the way the sibling
   responseMessage branch does — a conversation whose only new activity was an
   interrupted, no-responseMessage assistant row wouldn't surface in a
   lastMessageAt-sorted conversation list. Fixed to match the sibling branch.

3. `agentRun?.terminalReason === 'aborted' || abortSignal.aborted` was
   duplicated 3x across the two routes (with a comment on one copy claiming it
   was "computed once and reused" — true within its own closure, false across
   the file). Extracted to `isRunAborted()` in run-agent-with-retry.ts, which
   already owns the terminalReason vocabulary.

New tests: both routes now have a regression test that reproduces the real
finish()-then-getBufferedParts() ordering (mocked to return [] only after
finish fires) and asserts the persisted uiMessage.parts still contain the
pre-crash content; a lastMessageAt bump assertion for the fallback branch
(call-count delta across the onFinish boundary, to avoid a false-pass from
other db.update calls elsewhere in the same request); and a unit test suite
for isRunAborted. All three new/changed assertions verified RED against the
prior code before committing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QQTjjDoYto6GHcBBfWEnB
master gained two new migrations (0208_cloudy_ozymandias, 0209_sprite_reclaim_triggers)
since this branch was last rebased, colliding with this PR's own 0208 migration number.
Rebased onto origin/master, resolved the migration collision by keeping master's
0208/0209 journal entries and dropping this PR's stale 0208 migration + snapshot, then
regenerated via `bun run db:generate` — the status column migration is now
0210_next_mariko_yashida.sql, journal-clean on top of master's latest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QQTjjDoYto6GHcBBfWEnB
@2witstudios
2witstudios force-pushed the pu/e2-message-status branch from 0f70674 to 19ad496 Compare July 15, 2026 00:06

@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 (1)
apps/web/src/app/api/ai/chat/route.ts (1)

1481-1500: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Terminalize non-aborted exhausted runs.

Both routes can leave an assistant placeholder permanently streaming when retries exhaust without producing buffered parts or a responseMessage.

  • apps/web/src/app/api/ai/chat/route.ts#L1481-L1500: persist when the run is exhausted, even with an empty buffer, using interrupted.
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts#L1369-L1397: extend the no-response branch to terminalize exhausted runs as interrupted.
🤖 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/app/api/ai/chat/route.ts` around lines 1481 - 1500, Update the
assistant persistence logic in apps/web/src/app/api/ai/chat/route.ts at lines
1481-1500 to persist exhausted runs even when bufferedParts is empty, marking
them interrupted. In apps/web/src/app/api/ai/global/[id]/messages/route.ts at
lines 1369-1397, extend the no-response branch to terminalize exhausted runs as
interrupted, preserving existing behavior for runs that produce a response.
🤖 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/global/`[id]/messages/__tests__/stream-socket-events.test.ts:
- Around line 777-783: Update the onFinish handling exercised by
stream-socket-events.test.ts so a normal run with responseMessage undefined
still persists the assistant placeholder with buffered or empty content and
terminal status complete. Revise the test around
captured.createUIMessageStreamOptions.onFinish to expect an assistant save with
role assistant and complete status, while preserving the existing aborted
fallback behavior.

In `@apps/web/src/app/api/ai/global/`[id]/messages/route.ts:
- Around line 1152-1173: Add an execute-end terminal write for the global
assistant placeholder in the stream lifecycle flow around streamLifecycle,
ensuring it runs even when the client disconnects after generation. Mirror the
page route’s idempotent execute-end upsert, using lifecycle-buffered parts to
finalize serverAssistantMessageId and avoid leaving the row in streaming;
preserve the preAborted behavior.

---

Outside diff comments:
In `@apps/web/src/app/api/ai/chat/route.ts`:
- Around line 1481-1500: Update the assistant persistence logic in
apps/web/src/app/api/ai/chat/route.ts at lines 1481-1500 to persist exhausted
runs even when bufferedParts is empty, marking them interrupted. In
apps/web/src/app/api/ai/global/[id]/messages/route.ts at lines 1369-1397, extend
the no-response branch to terminalize exhausted runs as interrupted, preserving
existing behavior for runs that produce a response.
🪄 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: f29e8bdf-bc20-43b4-8c7a-1e9cc79d740a

📥 Commits

Reviewing files that changed from the base of the PR and between b0b826a and 19ad496.

📒 Files selected for processing (47)
  • apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/chat/messages/[messageId]/__tests__/route.test.ts
  • apps/web/src/app/api/ai/chat/messages/[messageId]/route.ts
  • apps/web/src/app/api/ai/chat/messages/__tests__/route.test.ts
  • apps/web/src/app/api/ai/chat/messages/route.ts
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/app/api/ai/global/[id]/messages/[messageId]/__tests__/route.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/[messageId]/route.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/app/api/ai/page-agents/[agentId]/conversations/[conversationId]/messages/__tests__/route.test.ts
  • apps/web/src/app/api/ai/page-agents/[agentId]/conversations/[conversationId]/messages/route.ts
  • apps/web/src/app/api/ai/page-agents/consult/__tests__/conversation-continuity.test.ts
  • apps/web/src/app/api/ai/page-agents/consult/__tests__/conversation-listing-index.test.ts
  • apps/web/src/app/api/ai/page-agents/consult/__tests__/credit-gate.test.ts
  • apps/web/src/app/api/ai/page-agents/consult/__tests__/mcp-scope-tool-filtering.test.ts
  • apps/web/src/app/api/ai/page-agents/consult/__tests__/recent-history-ordering.test.ts
  • apps/web/src/app/api/ai/page-agents/consult/__tests__/step-cap-parity.test.ts
  • apps/web/src/app/api/ai/page-agents/consult/route.ts
  • apps/web/src/app/api/v1/chat/completions/__tests__/route-backfill.test.ts
  • apps/web/src/app/api/v1/chat/completions/__tests__/route.test.ts
  • apps/web/src/app/api/v1/conversations/[id]/route.ts
  • apps/web/src/app/api/v1/conversations/__tests__/route.test.ts
  • apps/web/src/lib/ai/core/__tests__/ask-user-resume.test.ts
  • apps/web/src/lib/ai/core/__tests__/run-agent-with-retry.test.ts
  • apps/web/src/lib/ai/core/ask-user-resume.ts
  • apps/web/src/lib/ai/core/message-utils.ts
  • apps/web/src/lib/ai/core/run-agent-with-retry.ts
  • apps/web/src/lib/ai/streams/__tests__/mergeServerAndPending.test.ts
  • apps/web/src/lib/ai/streams/mergeServerAndPending.ts
  • apps/web/src/lib/ai/tools/__tests__/page-read-tools.test.ts
  • apps/web/src/lib/ai/tools/agent-communication-tools.ts
  • apps/web/src/lib/ai/tools/page-read-tools.ts
  • apps/web/src/lib/repositories/chat-message-repository.ts
  • apps/web/src/lib/repositories/conversation-repository.ts
  • apps/web/src/lib/repositories/global-conversation-repository.ts
  • apps/web/src/services/api/__tests__/ai-undo-service.test.ts
  • apps/web/src/services/api/ai-undo-service.ts
  • packages/db/drizzle/0210_next_mariko_yashida.sql
  • packages/db/drizzle/meta/0210_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/schema/conversations.ts
  • packages/db/src/schema/core.ts
  • packages/lib/src/compliance/export/gdpr-export.ts
  • packages/lib/src/services/page-payload-service.ts
  • scripts/tenant-export.ts

Comment thread apps/web/src/app/api/ai/global/[id]/messages/route.ts
2witstudios and others added 2 commits July 14, 2026 19:25
…uck-streaming gaps

Addresses two CodeRabbit findings:

1. Global route had no execute-end durable-persist block at all (only chat/route.ts
   did). If the client disconnected after generation (mobile backgrounding — the exact
   scenario #2065 just fixed client-side for this same route), onFinish might never
   fire and the placeholder stayed 'streaming' forever. Added an execute-end block to
   the global route mirroring chat/route.ts's: unconditional, using whatever
   lifecycle.getBufferedParts() has, status aborted ? 'interrupted' : 'complete', plus
   the same conversations.lastMessageAt bump. onFinish's own no-responseMessage branch
   is now a no-op (execute-end has already terminalized the row by the time it would
   run) — replaced the duplicate persist logic there with a debug log.

2. Both routes' execute-end persist was still gated on `bufferedParts.length > 0 ||
   aborted`. A run that exhausted its retries without ever aborting or producing a
   responseMessage (sustained provider outage) fell through that guard AND onFinish's
   `if (responseMessage)` guard, leaving the placeholder stuck at 'streaming' forever —
   same failure class as the abort gap already fixed, just for a third path (clean exit,
   no content) neither prior fix covered. Both routes' execute-end write is now
   unconditional.

Updated/added tests in both routes' stream-socket-events.test.ts for: non-aborted
empty-buffer persists as 'complete' (chat), aborted-with-content persists via
execute-end (global, migrated off the now-inert onFinish path), lastMessageAt bump via
execute-end (global), and non-aborted-with-content persists as 'complete' via
execute-end (global). Fixed global credit-gate.test.ts's lifecycle mock (missing
getBufferedParts, now called unconditionally). Verified each new/changed assertion
against the pre-fix code before committing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QQTjjDoYto6GHcBBfWEnB
- ask-user-resume.ts's persist closures never passed status, silently
  resetting an 'interrupted' row back to 'complete' on every ask_user
  merge/dismiss write. Both page and global adapters now preserve the
  fetched row's status.
- onFinish in both routes wrote a phantom empty 'interrupted' row for
  pre-aborted streams: the AI SDK always calls onFinish with a non-null
  responseMessage (an empty shell) even when execute() wrote nothing,
  but the placeholder INSERT is deliberately skipped when preAborted.
  Guarded on lifecycle.preAborted.
- Outer-catch cleanup in both routes could fabricate a phantom row for
  an exception thrown before `lifecycle` was ever assigned (inside
  startGenerationExclusive's callback, before the placeholder INSERT
  ran) — now requires `lifecycle` itself, not just !preAborted.
- Chat route's credit-gate abort was invisible to runAgentWithRetry's
  classification: the combined AbortSignal.any([...]) was only passed
  to the nested streamText call, not to runAgentWithRetry's own
  abortSignal param, so a mid-stream credit exhaustion was misclassified
  and persisted as 'complete' instead of 'interrupted'.
- search-tools.ts and discovery-service.ts read chat_messages/messages
  without excluding status='streaming', a gap in the PR's own reader
  inventory.

All fixes verified RED->GREEN. Full typecheck+build clean.
@2witstudios

Copy link
Copy Markdown
Owner Author

Round 4: self-review (8-angle, high-effort) — 5 more findings, all fixed

Ran a fresh 8-angle diff review (line-by-line, removed-behavior, cross-file tracer, reuse, simplification, efficiency, altitude, conventions) against the full PR diff after CI went green. Found 5 genuine correctness/coverage gaps beyond what earlier rounds caught, all fixed in 21bb3f1ad:

  1. ask-user-resume.ts silently resets interruptedcomplete. Its persistFor closures (both page and global adapters) never passed status to saveMessageToDatabase/saveGlobalAssistantMessageToDatabase, which default it to 'complete'. A row correctly terminalized as 'interrupted' lost that signal the next time a pending ask_user call was answered or dismissed. Fixed: both adapters now thread the fetched row's own status through.
  2. Phantom row on pre-abort, in onFinish (both routes). The AI SDK always calls onFinish with a non-null responseMessage (an empty shell), even when execute() returned immediately for a pre-aborted stream — but the placeholder INSERT is deliberately skipped in that case. Without a guard, the upsert would INSERT a brand-new empty 'interrupted' row for a request that never reached the model. Fixed with !lifecycle?.preAborted.
  3. Phantom row from the outer-catch cleanup (both routes). The cleanup's guard checked !lifecycle?.preAborted, which is vacuously true when lifecycle is undefined — reachable if startGenerationExclusive's callback throws before lifecycle is ever assigned (e.g. takeOverConversationStreams failing). Fixed to require lifecycle itself.
  4. Credit-gate abort misclassified as complete (chat route only). The credit gate's AbortController was combined into the nested streamText call's signal but not into runAgentWithRetry's own abortSignal param, so classifyAttempt/isRunAborted never saw it — a run cut short by the credit gate persisted as 'complete'. Fixed by combining the same signal at the runAgentWithRetry call site.
  5. Two missed readers. search-tools.ts's line-numbering subquery and discovery-service.ts's Global Assistant preference-extraction query both read chat_messages/messages without excluding status='streaming'.

All verified RED→GREEN (new/updated tests for #1, #2, #3; #4 verified by source-level trace through the AI SDK's handleUIMessageStreamFinish and classifyAttempt — no new automated test, documented as a disclosed tradeoff since neither existing test file has mid-stream-abort scaffolding for the credit gate). Typecheck + build clean across all 16 packages. Full PR description updated with details.

@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/app/api/ai/global/[id]/messages/__tests__/stream-socket-events.test.ts (1)

763-779: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen the lastMessageAt assertion to check the actual update payload.

expect(vi.mocked(db.update).mock.calls.length).toBeGreaterThan(...) only proves some db.update call fired, not that it targeted conversations.lastMessageAt. Any unrelated update in the same code path (e.g. the message status write) would make this pass without exercising the behavior the test name claims to guard.

♻️ Suggested stronger assertion
-      const updateCallsBeforeExecute = vi.mocked(db.update).mock.calls.length;
       await captured.createUIMessageStreamOptions.execute?.({ write: vi.fn() });

-      expect(vi.mocked(db.update).mock.calls.length).toBeGreaterThan(updateCallsBeforeExecute);
+      const updateCalls = vi.mocked(db.update).mock.calls;
+      const conversationsUpdateCall = updateCalls.find((c) => c[0] === conversations);
+      expect(conversationsUpdateCall).toBeDefined();

Adjust to however set() payload is captured/mocked in this test harness so the lastMessageAt field itself can be asserted.

🤖 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/app/api/ai/global/`[id]/messages/__tests__/stream-socket-events.test.ts
around lines 763 - 779, Strengthen the assertion in the aborted buffered-content
test around captured execute handling to inspect the db.update set payload, not
merely the update-call count. Verify that the update targeting conversations
includes a lastMessageAt field, while preserving the existing setup and
execution flow.
🤖 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/app/api/ai/global/`[id]/messages/__tests__/stream-socket-events.test.ts:
- Around line 763-779: Strengthen the assertion in the aborted buffered-content
test around captured execute handling to inspect the db.update set payload, not
merely the update-call count. Verify that the update targeting conversations
includes a lastMessageAt field, while preserving the existing setup and
execution flow.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 64e43db6-b9c1-48ad-8397-981ed1ac174c

📥 Commits

Reviewing files that changed from the base of the PR and between 19ad496 and 21bb3f1.

📒 Files selected for processing (11)
  • apps/web/src/app/api/ai/chat/__tests__/stream-socket-events.test.ts
  • apps/web/src/app/api/ai/chat/route.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/lib/ai/core/__tests__/ask-user-resume.test.ts
  • apps/web/src/lib/ai/core/ask-user-resume.ts
  • apps/web/src/lib/ai/tools/__tests__/search-tools.test.ts
  • apps/web/src/lib/ai/tools/search-tools.ts
  • apps/web/src/lib/memory/__tests__/discovery-service.test.ts
  • apps/web/src/lib/memory/discovery-service.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/web/src/app/api/ai/global/[id]/messages/tests/credit-gate.test.ts
  • apps/web/src/app/api/ai/chat/route.ts
  • apps/web/src/lib/ai/core/ask-user-resume.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts

…ot just call count

CodeRabbit round-4 review nitpick: the assertion only checked that db.update's
call count increased, not that any of the new calls actually targeted
conversations. Any unrelated update in the same code path would have made the
test pass without exercising the lastMessageAt bump. Now asserts one of the
new update calls targets the conversations table specifically.

RED-verified by temporarily removing the lastMessageAt bump from
apps/web/src/app/api/ai/global/[id]/messages/route.ts and confirming the
assertion fails; restored and confirmed GREEN.

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

Copy link
Copy Markdown
Owner Author

Fixed in 127e49d. The assertion only checked that db.update's call count increased — any unrelated db.update call in the execute-end path would have made it pass without exercising the lastMessageAt bump specifically. Now asserts one of the new calls targets the conversations table (call[0] === conversations, using the same mocked module reference). RED-verified by temporarily removing the lastMessageAt bump from route.ts and confirming the assertion fails; restored and confirmed GREEN.

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