Skip to content

fix(global-chat): lazy conversation creation — stop polluting history with empty chats - #1645

Merged
2witstudios merged 5 commits into
masterfrom
pu/global-assistant-new-chjats
Jun 14, 2026
Merged

2witstudios merged 5 commits into
masterfrom
pu/global-assistant-new-chjats

Conversation

@2witstudios

@2witstudios 2witstudios commented Jun 13, 2026 •

Copy link
Copy Markdown
Owner

Problem

Every time a user opened the Global Assistant or clicked "New Chat", the client eagerly called POST /api/ai/global to create a conversations row — even if the user never sent a message. Because history is ordered by lastMessageAt DESC with no message-existence filter, these stale rows appeared in the sidebar. Users with frequent new-chat clicks ended up with dozens of untitled, empty entries — making history nearly unusable.

AI SDK fact (no hallucination)

useChat({ id }) uses the id prop as a client-side React state key only. The conversation ID reaches the server via the URL path (/api/ai/global/[id]/messages), not the id prop. Source: apps/web/src/lib/ai/core/useChatTransport.ts + GlobalChatContext.tsx:266-283. The SDK does NOT require a DB-backed record before the first message is sent.

Solution — two-pronged

1. Lazy client-side ID generation (conversation-state.ts)

  • createAndSetActiveConversation() now generates a CUID2 locally — no POST /api/ai/global call
  • Returns a synthetic conversation object immediately; cookie is set for reload persistence
  • New pure function classifyConversationLoadResponse(status) maps status codes → 'ok' | 'not-found' | 'error' (tested in isolation)
  • GlobalChatContext handles 404 on GET /messages as lazy-creation (empty messages, not an error)

2. Server-side upsert on first message (messages/route.ts)

  • New pure helper resolveOrCreateConversation(userId, conversationId, db?) — fully isolated and testable
    • Validates conversationId against CUID2 regex before any DB access
    • Verifies existing.type === 'global' before reusing a row
    • Handles concurrent first-writes safely via ON CONFLICT DO NOTHING + fallback SELECT
    • Throws ConversationOwnershipError (caught at route boundary → 404) for invalid ID, ownership mismatch, or type mismatch
  • GET handler already returns 404 for unknown conversations; client now treats 404 as "not yet persisted" (empty state)

3. EXISTS filter on history (global-conversation-repository.ts)

  • hasMessages constant: exists(db.select({one:sql\1`}).from(messages).where(...isActive...)) — filters both null-lastMessageAt` rows AND any pre-existing stale rows
  • Applied to listConversations and listConversationsPaginated
  • getActiveGlobalConversation ordering fixed to lastMessageAt DESC NULLS LAST, createdAt DESC — reloading drops you back into the most recently active conversation, not a blank stub
  • createConversation no longer sets lastMessageAt: now (stays null until first message saved)

Tests

All new code is TDD (RED→GREEN):

  • __tests__/conversation-state.test.ts — 15 tests: classifyConversationLoadResponse + createAndSetActiveConversation (including CUID2 format, no network calls, cookie persistence)
  • resolve-or-create-conversation/__tests__/ — 7 tests: existing, create, ownership error, idempotent re-entry, invalid CUID2, wrong-type conversation, concurrent-insert race condition
  • __tests__/global-conversation-list-filter.test.ts — 7 tests: EXISTS filter present, result correctness, ordering args, null on empty, exists() called exactly once at module load
  • __tests__/credit-gate.test.ts — added test: ConversationOwnershipError → 404, no stream/lifecycle started

Verification

  1. Click "New Chat" 5× without sending a message → 0 new history entries
  2. Send a message → conversation appears in history
  3. Reload → lands in the most recently active conversation
  4. Existing stale empty conversations no longer appear (EXISTS filter)

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements lazy conversation creation: conversations are now auto-created on the first message instead of beforehand. A new API helper validates ownership; repositories filter by message activity and omit lastMessageAt on creation; routes integrate ownership checking; and the client generates IDs locally while treating 404 responses as lazily-creatable conversations.

Changes

Lazy Conversation Creation and Ownership Validation

Layer / File(s) Summary
Conversation ownership and lazy creation helper
apps/web/src/app/api/ai/global/[id]/messages/resolve-or-create-conversation.ts, apps/web/src/app/api/ai/global/[id]/messages/__tests__/resolve-or-create-conversation.test.ts
Introduces ConversationOwnershipError and resolveOrCreateConversation(userId, conversationId) that queries for an active conversation, validates user ownership (throwing on mismatch), and inserts a new type: 'global' conversation if none exists. Tests cover existing-conversation return, creation insertion, ownership rejection, and idempotency.
Repository message-activity filtering and lazy-creation semantics
apps/web/src/lib/repositories/global-conversation-repository.ts, apps/web/src/lib/repositories/__tests__/global-conversation-list-filter.test.ts, apps/web/src/lib/repositories/__tests__/global-conversation-hard-delete.test.ts
Repository now filters conversations via a hasMessages SQL predicate (EXISTS subquery requiring at least one active message), applied to listConversations and listConversationsPaginated. createConversation no longer sets lastMessageAt on insert (awaiting first message). getActiveGlobalConversation reorders by lastMessageAt DESC NULLS LAST then createdAt. Tests verify EXISTS clause inclusion, row matching, null filtering, and two-argument orderBy.
API route conversation resolution integration
apps/web/src/app/api/ai/global/[id]/messages/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
Route POST delegates conversation resolution to resolveOrCreateConversation, catching ConversationOwnershipError to return 404 and rethrowing other errors. Test mocks extend @pagespace/db/operators with exists and mock the resolution helper.
Client-side conversation initialization and response classification
apps/web/src/lib/ai/core/conversation-state.ts, apps/web/src/lib/ai/core/__tests__/conversation-state.test.ts
createAndSetActiveConversation now generates a CUID locally, stores it in the active-conversation cookie, and returns a constructed conversation object (no server call). New classifyConversationLoadResponse(status) maps 200/201 → 'ok', 404 → 'not-found', others → 'error'. Tests assert local generation avoids network calls, returns correctly-shaped objects, persists the ID, and generates unique IDs per call; also verify status mappings.
GlobalChatContext response classification and lazy-creation handling
apps/web/src/contexts/GlobalChatContext.tsx
GlobalChatProvider classifies message-load responses and handles lazy-creation: 'ok' loads messages and sets conversation state as before; 'not-found' sets empty messages but still activates the conversation ID, treating it as ready for first-message creation.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client Browser
  participant Context as GlobalChatContext
  participant ClientState as conversation-state
  participant APIRoute as POST /api/ai/global/[id]/messages
  participant Helper as resolveOrCreateConversation
  participant DB as Conversations DB
  
  Client->>Context: load conversation (first time or 404)
  Context->>ClientState: createAndSetActiveConversation(type)
  ClientState->>ClientState: createId() → CUID
  ClientState->>ClientState: Set activeConversationId cookie
  ClientState-->>Context: { id, type, title: null, lastMessageAt: null }
  Context->>APIRoute: POST messages { conversationId, userId, ... }
  APIRoute->>Helper: resolveOrCreateConversation(userId, conversationId)
  Helper->>DB: SELECT WHERE id AND isActive
  alt Conversation exists & user owns it
    DB-->>Helper: existing conversation row
    Helper-->>APIRoute: resolved row
  else Conversation exists & different user
    DB-->>Helper: row with different userId
    Helper-->>APIRoute: reject ConversationOwnershipError
    APIRoute-->>Context: 404 Conversation not found
    Context->>Context: classifyConversationLoadResponse(404) → 'not-found'
    Context->>Context: Set initialMessages=[], still set currentConversationId
  else No conversation exists yet
    DB-->>Helper: no rows
    Helper->>DB: INSERT new global conversation
    DB-->>Helper: inserted row
    Helper-->>APIRoute: new conversation row
    APIRoute-->>Context: 200 OK with messages
    Context->>Context: classifyConversationLoadResponse(200) → 'ok'
    Context->>Context: Parse messages, set initialMessages, set currentConversationId
  end
  Context-->>Client: Show messages or empty state, ready for first message
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • 2witstudios/PageSpace#275: Overlaps with GlobalChatContext.tsx conversation initialization logic, adding synchronization and race-condition guards to the same loading state and effect flow.

Poem

🐰 Lazy conversations bloom like clover,
IDs sprouted client-side, now the load's half-over.
Ownership gates and filters bloom with care,
First messages create what wasn't there! 🌱

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and specifically describes the main change: implementing lazy conversation creation to prevent empty conversations from appearing in history.
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.

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

✨ 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/global-assistant-new-chjats

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 and usage tips.

@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: fac1c8e374

ℹ️ 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 +30 to +33
.where(and(
eq(conversations.id, conversationId),
eq(conversations.isActive, true),
))

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 Handle soft-deleted conversation IDs before inserting

When a user reloads a bookmarked URL or stale cookie for a conversation that was soft-deleted, this isActive predicate hides the existing row, so the code proceeds to insert another row with the same primary key. The first message then fails with a database uniqueness error and a 500 response instead of rejecting the deleted conversation or assigning a fresh ID; query by ID regardless of activity before deciding to insert.

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.

Acknowledged as P2. The onConflictDoNothing() fallback SELECT does return the soft-deleted row if ownership matches, which would allow a message to attach to a soft-deleted conversation. Fixing soft-deleted ID reuse correctly requires either (a) checking winner.isActive in the fallback and returning a new error, or (b) removing isActive from the initial SELECT. Deferring as a follow-up since this is an edge case with no current code path that soft-deletes global conversations by ID, and the blast radius here is isolated to a single user reusing a stale cookie.

Comment on lines +41 to +44
const [created] = await db
.insert(conversations)
.values({
id: conversationId,

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 Make first-message conversation creation atomic

When two first-message POSTs for the same locally generated conversation ID overlap—for example from two tabs or the dashboard and sidebar—both requests can observe no existing row and reach this insert. One insert succeeds while the other violates the conversations.id primary key and loses its message with a 500 response; use an atomic conflict-safe insert followed by an ownership-aware reselect.

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 commit 7e6f1f7: the insert now uses .onConflictDoNothing(), and when that returns an empty result (a concurrent write won the race), we fall back to a SELECT to find the winning row. The race is fully handled — no 500 possible from a PK conflict.

@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: 5

🧹 Nitpick comments (3)
apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts (1)

106-111: ⚡ Quick win

Add a route-level test for ConversationOwnershipError → 404 mapping.

You now mock ConversationOwnershipError, but this suite doesn’t assert the new POST behavior when resolver ownership fails. Add one case to verify 404 response and no stream/lifecycle start.

🤖 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__/credit-gate.test.ts
around lines 106 - 111, Add a test case that when the mocked
resolveOrCreateConversation throws/returns a ConversationOwnershipError the POST
handler responds with HTTP 404 and does not initiate any stream or lifecycle.
Specifically, in the existing test suite (where resolveOrCreateConversation and
ConversationOwnershipError are mocked), make resolveOrCreateConversation reject
with new ConversationOwnershipError(), call the POST route the same way other
tests do, assert the response status is 404, and assert that the mocks for
starting the stream and starting the lifecycle (e.g., startStream /
startConversationLifecycle or the existing stream/lifecycle mock functions used
elsewhere in this file) were not called.
apps/web/src/app/api/ai/global/[id]/messages/__tests__/resolve-or-create-conversation.test.ts (1)

74-79: ⚡ Quick win

Current idempotency test does not cover the real conflict path.

Line 74 validates sequential re-entry, but it won’t catch the concurrent unique-key race on insert. Add a case that simulates insert conflict and verifies fallback resolution behavior.

🤖 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__/resolve-or-create-conversation.test.ts
around lines 74 - 79, Add a new test that simulates a concurrent unique-key
insert conflict to exercise the real idempotency path: in the test, prepare the
fake db from makeDb([CONV]) and stub db.insert to throw a
unique-constraint/conflict error on the first call (e.g., an error object with
code or message matching the DB client’s unique violation), then call
resolveOrCreateConversation('user1','conv1', db) and assert it does not rethrow
and that the function returns or resolves to the existing CONV and that
db.insert was attempted exactly once; ensure the test references
resolveOrCreateConversation, db.insert stub, and the existing CONV fixture so it
reproduces the insert-conflict fallback behavior.
apps/web/src/lib/ai/core/__tests__/conversation-state.test.ts (1)

38-41: ⚡ Quick win

Align the “no network fetch” test name with actual assertions.

This case currently asserts only post, not fetchWithAuth.

Proposed test hardening
-import { post } from '`@/lib/auth/auth-fetch`';
+import { post, fetchWithAuth } from '`@/lib/auth/auth-fetch`';

 it('does NOT call post() or any network fetch', async () => {
   await conversationState.createAndSetActiveConversation({ type: 'global' });
   expect(post).not.toHaveBeenCalled();
+  expect(fetchWithAuth).not.toHaveBeenCalled();
 });
🤖 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/__tests__/conversation-state.test.ts` around lines
38 - 41, Test name promises "no network fetch" but only asserts post was not
called; update the test for conversationState.createAndSetActiveConversation to
either rename it to only mention post or, preferably, add the missing assertion
by also checking expect(fetchWithAuth).not.toHaveBeenCalled(); so both post and
fetchWithAuth are asserted as not called after calling
createAndSetActiveConversation({ type: 'global' }).
🤖 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/resolve-or-create-conversation.ts:
- Around line 23-25: Add server-side validation for the client-supplied
conversationId in the resolveOrCreateConversation function: before any DB
access, check that conversationId is non-empty, length <= 32, and matches the
CUID2-based regex ^[a-z][a-z0-9]{1,31}$; if the value fails, short-circuit
(throw a validation error or return a 4xx-style failure) so invalid IDs never
reach storage. Ensure the check runs at the top of the function handling the
parameters (userId, conversationId, db) and use the explicit regex and max
length constraint in the condition.
- Around line 36-38: The code currently reuses an existing conversation row
without checking its type; update the early-return block that inspects existing
and userId to also validate the conversation type (existing.type) equals
'global' before returning. If existing.type !== 'global', do not reuse that row:
either throw a ConversationTypeError (create this error class if it doesn't
exist) or fall through to the existing create-new-conversation path so a new
global conversation is created; keep the ownership check (if (existing.userId
!== userId) throw new ConversationOwnershipError()) and add the type check
immediately after it.
- Around line 41-49: The blind insert of conversations
(db.insert(conversations).values({ id: conversationId, userId, type: 'global',
isActive: true }).returning()) can race and throw a unique-key error; make the
creation idempotent by using an upsert/do-nothing on conflict and falling back
to a select: change the insert to use an ON CONFLICT DO NOTHING variant (e.g.
db.insert(conversations).values(...).onConflict((oc) =>
oc.column('id').doNothing()).returning()), then if the returned row is empty,
query the existing conversation by id (e.g.
db.select().from(conversations).where(eq(conversations.id, conversationId))).
Ensure the code that expects the created result uses the selected row when
onConflict yields no returning row so concurrent first-writes resolve to a
single conversation instead of raising a 500.

In `@apps/web/src/lib/ai/core/__tests__/conversation-state.test.ts`:
- Around line 49-52: The test in conversation-state.test.ts uses a
too-permissive regex for CUID2; update the assertion in the it('returns id
matching CUID2 format'...) test that checks result.id (from
conversationState.createAndSetActiveConversation) to use the canonical CUID2
shape by replacing /^[a-z0-9]{24,}$/ with /^[a-z][a-z0-9]{1,31}$/, ensuring IDs
start with a lowercase letter, contain only lowercase alphanumerics, and are at
most 32 chars.

In
`@apps/web/src/lib/repositories/__tests__/global-conversation-list-filter.test.ts`:
- Around line 87-89: The test description says "exactly once" but the assertion
uses expect(existsCallCountAtModuleLoad).toBeGreaterThanOrEqual(1); change this
to assert exact equality by replacing the assertion with
expect(existsCallCountAtModuleLoad).toBe(1) (or .toEqual(1)) so that the test
enforces a single call to exists() during module load; locate the assertion in
the test named "calls exists() exactly once when the module loads (to build
hasMessages)" and update it accordingly.

---

Nitpick comments:
In `@apps/web/src/app/api/ai/global/`[id]/messages/__tests__/credit-gate.test.ts:
- Around line 106-111: Add a test case that when the mocked
resolveOrCreateConversation throws/returns a ConversationOwnershipError the POST
handler responds with HTTP 404 and does not initiate any stream or lifecycle.
Specifically, in the existing test suite (where resolveOrCreateConversation and
ConversationOwnershipError are mocked), make resolveOrCreateConversation reject
with new ConversationOwnershipError(), call the POST route the same way other
tests do, assert the response status is 404, and assert that the mocks for
starting the stream and starting the lifecycle (e.g., startStream /
startConversationLifecycle or the existing stream/lifecycle mock functions used
elsewhere in this file) were not called.

In
`@apps/web/src/app/api/ai/global/`[id]/messages/__tests__/resolve-or-create-conversation.test.ts:
- Around line 74-79: Add a new test that simulates a concurrent unique-key
insert conflict to exercise the real idempotency path: in the test, prepare the
fake db from makeDb([CONV]) and stub db.insert to throw a
unique-constraint/conflict error on the first call (e.g., an error object with
code or message matching the DB client’s unique violation), then call
resolveOrCreateConversation('user1','conv1', db) and assert it does not rethrow
and that the function returns or resolves to the existing CONV and that
db.insert was attempted exactly once; ensure the test references
resolveOrCreateConversation, db.insert stub, and the existing CONV fixture so it
reproduces the insert-conflict fallback behavior.

In `@apps/web/src/lib/ai/core/__tests__/conversation-state.test.ts`:
- Around line 38-41: Test name promises "no network fetch" but only asserts post
was not called; update the test for
conversationState.createAndSetActiveConversation to either rename it to only
mention post or, preferably, add the missing assertion by also checking
expect(fetchWithAuth).not.toHaveBeenCalled(); so both post and fetchWithAuth are
asserted as not called after calling createAndSetActiveConversation({ type:
'global' }).
🪄 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: 65c1f1fc-ef6f-40f5-a019-0d5c8c7ace6e

📥 Commits

Reviewing files that changed from the base of the PR and between 13d5872 and fac1c8e.

📒 Files selected for processing (11)
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/credit-gate.test.ts
  • apps/web/src/app/api/ai/global/[id]/messages/__tests__/resolve-or-create-conversation.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/resolve-or-create-conversation.ts
  • apps/web/src/app/api/ai/global/[id]/messages/route.ts
  • apps/web/src/contexts/GlobalChatContext.tsx
  • apps/web/src/lib/ai/core/__tests__/conversation-state.test.ts
  • apps/web/src/lib/ai/core/conversation-state.ts
  • apps/web/src/lib/repositories/__tests__/global-conversation-hard-delete.test.ts
  • apps/web/src/lib/repositories/__tests__/global-conversation-list-filter.test.ts
  • apps/web/src/lib/repositories/global-conversation-repository.ts

@2witstudios

Copy link
Copy Markdown
Owner Author

Addressed all 5 actionable + 3 nitpick CodeRabbit comments in commit 711389c4e. Summary:

Actionable comments fixed:

  1. resolve-or-create-conversation.ts L23-25 — Input validation: Added CUID2 regex check (/^[a-z][a-z0-9]{1,31}$/) at function entry. Invalid IDs throw ConversationOwnershipError before any DB access.

  2. resolve-or-create-conversation.ts L36-38 — Type check: Added existing.type !== 'global' guard — if a non-global conversation exists with the same ID, we throw ConversationOwnershipError rather than reusing it.

  3. resolve-or-create-conversation.ts L41-49 — Concurrent insert race: Changed to onConflictDoNothing().returning(). When the returned array is empty (another writer won the race), we fall back to a SELECT to find the winner. Pattern matches existing usage in integrations/zoom/triggers/route.ts.

  4. conversation-state.test.ts L49-52 — CUID2 regex: Updated from /^[a-z0-9]{24,}$/ to the canonical /^[a-z][a-z0-9]{1,31}$/ which correctly requires a leading lowercase letter and enforces the 32-char max.

  5. global-conversation-list-filter.test.ts L87-89 — Exact count: Changed toBeGreaterThanOrEqual(1) to toBe(1) — the test name says "exactly once" and the predicate should match.

Nitpick comments addressed:

  • credit-gate.test.ts — ConversationOwnershipError → 404: Added test case that overrides resolveOrCreateConversation to reject with ConversationOwnershipError and asserts 404 response + no stream/lifecycle start.

  • resolve-or-create-conversation.test.ts — Race condition test: Added test simulating onConflictDoNothing returning empty array (concurrent write wins), verifies fallback SELECT returns the winning row.

  • conversation-state.test.ts L38-41 — fetchWithAuth assertion: Added expect(fetchWithAuth).not.toHaveBeenCalled() to the "no network fetch" test.

Also fixed (CI was failing): GlobalChatContext.tsx was calling classifyConversationLoadResponse(messagesResponse.status) but test mocks return {ok: true, json: ...} without a .status field, causing undefined → 'error' → currentConversationId never set. Fixed by using messagesResponse.ok and messagesResponse.status === 404 directly — more idiomatic for fetch Response objects and compatible with all mock shapes.

2witstudios and others added 5 commits June 13, 2026 16:16
…de, no eager DB write

On every new-chat action or app init, a POST to /api/ai/global created a DB row
immediately (with lastMessageAt=now), polluting conversation history with empty
untitled entries. The useChat({ id }) prop is a React state key only — it does not
require a DB record before the first message.

- conversation-state: createAndSetActiveConversation generates a CUID2 locally
  (no network call); sets cookie as before; returns { id, type, title:null, lastMessageAt:null }
- Extract classifyConversationLoadResponse pure fn (ok/not-found/error) — testable
  without React; 15 unit tests green
- GlobalChatContext.loadConversation: 404 now treated as empty (not an error),
  so a locally generated ID survives a page reload before the first message is sent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sert)

Extracted resolveOrCreateConversation() — pure-ish function with 4 unit tests.
The messages route now calls it instead of the previous 404-on-missing-row check.

When a locally generated conversation ID arrives with no prior DB row, the row
is created here (with correct userId/type/isActive; lastMessageAt set later by
the existing update after the message is saved). If the row exists but belongs
to a different user, ConversationOwnershipError → 404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…EXISTS subquery

Adds hasMessages EXISTS subquery to listConversations and listConversationsPaginated
so conversations with no active messages are hidden from history. Also fixes
getActiveGlobalConversation ordering to prefer most-recently-messaged conversation
(lastMessageAt DESC NULLS LAST) and removes the premature lastMessageAt=now that
was set on conversation creation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion in route tests

Three test files mocked @pagespace/db/operators without `exists`, which now throws
at module load when global-conversation-repository.ts initialises hasMessages.
Also stub resolveOrCreateConversation in route integration tests — it has its own
unit test file; the route tests shouldn't re-test its internals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion, CI fix

- GlobalChatContext: use response.ok + status===404 directly (fixes CI — test
  mocks return {ok:true} without .status; calling classifyConversationLoadResponse
  on an undefined status caused the not-found path to never fire)
- resolveOrCreateConversation: add CUID2 regex validation before DB access;
  verify existing.type==='global' before reusing; make insert idempotent with
  ON CONFLICT DO NOTHING + fallback select for concurrent first-writes
- Tests: update CUID2 regex to canonical /^[a-z][a-z0-9]{1,31}$/; add fetchWithAuth
  assertion; fix exists() call count to toBe(1); add ConversationOwnershipError→404
  test to credit-gate suite; add race-condition and type-mismatch tests to
  resolve-or-create-conversation suite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@2witstudios
2witstudios force-pushed the pu/global-assistant-new-chjats branch from 711389c to 7e6f1f7 Compare June 13, 2026 21:16
@2witstudios
2witstudios merged commit 5f04910 into master Jun 14, 2026
10 checks passed
@2witstudios
2witstudios deleted the pu/global-assistant-new-chjats branch June 22, 2026 14:47
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