Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
079173b
feat(ai): AiChatView store-first rendering cutover
2witstudios Jul 15, 2026
003b546
fix(ai): address Codex P1 findings on message-id validation and stale…
2witstudios Jul 15, 2026
0db38c6
Merge remote-tracking branch 'origin/master' into pu/e1-aichatview
2witstudios Jul 15, 2026
c3974b2
fix(ai): close silent-collision, role-spoofing, and stream-recovery gaps
2witstudios Jul 15, 2026
ab347b2
test(ai): drop vestigial isCuid mock now that resolveMessageId doesn'…
2witstudios Jul 15, 2026
4b88ae5
fix(ai): stuck loading flag on New Chat + stale-replay race in confir…
2witstudios Jul 15, 2026
ea16763
test(ai): cover isLoadCurrent/applyConfirmedMessage store wiring
2witstudios Jul 15, 2026
c95a718
ci(web): raise coverage-run heap ceiling to fix intermittent OOM crash
2witstudios Jul 15, 2026
d37a416
fix(web): switch vitest pool to forks so CI's heap bump actually applies
2witstudios Jul 15, 2026
6117bf0
fix(ci): drop unused html/json coverage reporters to stop tail-end OOM
2witstudios Jul 15, 2026
81dc558
fix(ci): spread coverage suite across more forks to cap per-worker me…
2witstudios Jul 15, 2026
790737e
fix(ci): lock coverage fork concurrency to 3, push headroom into ceiling
2witstudios Jul 15, 2026
1c746c8
fix(ci): disable coverage.all to stop instrumenting hundreds of untes…
2witstudios Jul 15, 2026
eb958fd
fix(ci): raise coverage heap ceiling to 12288MB now that all:false helps
2witstudios Jul 15, 2026
d4efdef
fix(ci): shard the coverage run to bound peak memory structurally
2witstudios Jul 15, 2026
f64883c
chore: retrigger CI (previous push d4efdefeb never dispatched a workf…
2witstudios Jul 15, 2026
ff0bd72
fix(ci): double coverage shard count to 6 — 3 still hit the heap ceiling
2witstudios Jul 16, 2026
0d429bb
fix(ci): shard count to 10 + restore 10240MB ceiling now that shards …
2witstudios Jul 16, 2026
fb386e0
fix(ci): shard count to 20, revert ceiling to the only stable value (…
2witstudios Jul 16, 2026
926f326
fix(ci): shard count to 40 to split the dense src/lib/ai/core cluster
2witstudios Jul 16, 2026
f449d31
fix(ci): interleave files into shards myself instead of trusting --shard
2witstudios Jul 16, 2026
dbd314d
fix(ci): retry a shard that hits the heap ceiling before failing the run
2witstudios Jul 16, 2026
c461794
fix(ci): isolate the actual culprits — AiChatView's two heavy test files
2witstudios Jul 16, 2026
04db9be
fix(ci): boost heap ceiling for the isolated heavy shards only
2witstudios Jul 16, 2026
7e3a032
Merge remote-tracking branch 'origin/master' into pu/e1-aichatview
2witstudios Jul 16, 2026
c2f279f
fix(ci): run the two isolated heavy files without coverage instrument…
2witstudios Jul 16, 2026
160ee0a
fix(ci): boost isolated heavy-shard ceiling again — coverage wasn't t…
2witstudios Jul 16, 2026
524c7fc
fix(test): found and fixed the actual root cause — a self-recreating …
2witstudios Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/web/src/app/api/ai/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { createStreamLifecycle, type StreamLifecycleHandle } from '@/lib/ai/core
import { takeOverConversationStreams } from '@/lib/ai/core/stream-takeover';
import { startGenerationExclusive } from '@/lib/ai/core/start-generation-exclusive';
import { chunkToPart } from '@/lib/ai/streams/chunkToPart';
import { resolveMessageId } from '@/lib/ai/streams/resolveMessageId';
import { validateBrowserSessionIdHeader } from '@/lib/ai/core/browser-session-id-validation';
import { authenticateRequestWithOptions, isAuthError, isMCPAuthResult, checkMCPPageScope, getAllowedDriveIds, isScopedMCPAuth, canPrincipalViewPage, canPrincipalEditPage } from '@/lib/auth';

Expand Down Expand Up @@ -575,7 +576,14 @@ export async function POST(request: Request) {
let askUserSyncPromise: Promise<unknown> | undefined;
if (userMessage && userMessage.role === 'user') {
try {
const messageId = userMessage.id || createId();
const messageId = resolveMessageId(userMessage.id);
// Reassign so every downstream use of `userMessage` (the broadcast below,
// any future read) agrees with what was actually persisted — resolveMessageId
// mints a FRESH id when the client-supplied one is absent or fails the safe-id
// shape check, and without this the object stays inconsistent: saved under
// `messageId`, but still carrying the original (possibly rejected) id anywhere
// `userMessage` itself is read afterward.
userMessage.id = messageId;
const messageContent = extractMessageContent(userMessage);

// Process @mentions in the user message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ vi.mock('@pagespace/db/db', () => {
};
}),
}));
const insert = vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate: vi.fn().mockResolvedValue(undefined) })) }));
const insert = vi.fn(() => ({
values: vi.fn(() => ({
onConflictDoUpdate: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([{ id: 'msg-1' }]) })),
})),
}));
const update = vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })) }));
// startGenerationExclusive's advisory lock: always free, so takeover+lifecycle-create run
// exactly as before. Its own retry/degrade behavior is covered by
Expand Down Expand Up @@ -226,7 +230,9 @@ vi.mock('ai', () => ({
createUIMessageStreamResponse: vi.fn().mockReturnValue(new Response('', { status: 200 })),
}));

vi.mock('@paralleldrive/cuid2', () => ({ createId: vi.fn().mockReturnValue('test-message-id') }));
vi.mock('@paralleldrive/cuid2', () => ({
createId: vi.fn().mockReturnValue('test-message-id'),
}));
vi.mock('@/lib/logging/mask', () => ({ maskIdentifier: vi.fn((id: string) => `***${id.slice(-3)}`) }));
vi.mock('@pagespace/lib/monitoring/ai-monitoring', () => ({
AIMonitoring: { trackUsage: vi.fn(), trackToolUsage: vi.fn() },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,11 @@ vi.mock('@pagespace/db/db', () => {
};
}),
}));
const insert = vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate: vi.fn().mockResolvedValue(undefined) })) }));
const insert = vi.fn(() => ({
values: vi.fn(() => ({
onConflictDoUpdate: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([{ id: 'msg-1' }]) })),
})),
}));
const update = vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })) }));
// startGenerationExclusive's advisory lock: always free, so takeover+lifecycle-create run
// exactly as before. Its own retry/degrade behavior is covered by
Expand Down Expand Up @@ -236,7 +240,9 @@ vi.mock('ai', () => ({
createUIMessageStreamResponse: vi.fn().mockReturnValue(new Response('', { status: 200 })),
}));

vi.mock('@paralleldrive/cuid2', () => ({ createId: vi.fn().mockReturnValue('test-message-id') }));
vi.mock('@paralleldrive/cuid2', () => ({
createId: vi.fn().mockReturnValue('test-message-id'),
}));
vi.mock('@/lib/logging/mask', () => ({ maskIdentifier: vi.fn((id: string) => `***${id.slice(-3)}`) }));
vi.mock('@pagespace/lib/monitoring/ai-monitoring', () => ({
AIMonitoring: { trackUsage: vi.fn(), trackToolUsage: vi.fn() },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ vi.mock('@pagespace/db/db', () => {

const insert = vi.fn(() => ({
values: vi.fn(() => ({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
onConflictDoUpdate: vi.fn(() => ({
returning: vi.fn().mockResolvedValue([{ id: 'msg-1' }]),
})),
})),
}));

Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/app/api/ai/global/[id]/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { streamText, stepCountIs, hasToolCall, UIMessage, createUIMessageStream,
import type { convertToModelMessages } from 'ai';
import { finishTool, FINISH_TOOL_NAME } from '@/lib/ai/tools/finish-tool';
import { askUserTools, ASK_USER_TOOL_NAME } from '@/lib/ai/tools/ask-user-tools';
import { resolveMessageId } from '@/lib/ai/streams/resolveMessageId';
import { canUseAskUser } from '@/lib/ai/core/ask-user-gating';
import { ASK_USER_SECTION } from '@/lib/ai/core/inline-instructions';
import { buildLocationTurnPrompt } from '@/lib/ai/core/location-prompt';
Expand Down Expand Up @@ -457,7 +458,11 @@ export async function POST(
let askUserSyncPromise: Promise<unknown> | undefined;
if (userMessage && userMessage.role === 'user') {
try {
const messageId = userMessage.id || createId();
const messageId = resolveMessageId(userMessage.id);
// Reassign so every downstream use of `userMessage` (the broadcast below,
// any future read) agrees with what was actually persisted — see the
// equivalent comment in apps/web/src/app/api/ai/chat/route.ts.
userMessage.id = messageId;
const messageContent = extractMessageContent(userMessage);

// Process @mentions in the user message
Expand Down
Loading
Loading