diff --git a/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts b/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts index 53caba73..aff487d8 100644 --- a/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts +++ b/apps/server/src/core/ai-chat/ai-chat.prompt.spec.ts @@ -268,3 +268,58 @@ describe('buildSystemPrompt interrupt note (#198)', () => { expect(buildSystemPrompt({ workspace })).not.toContain(NOTE_MARKER); }); }); + +/** + * Page-changed note (#274). A block with the note + the unified + * diff is injected ONLY when the server passes a `pageChanged` with a non-empty + * diff (it does so after detecting the open page was edited since the agent's last + * turn). The block lives inside the safety sandwich (context section). + */ +describe('buildSystemPrompt page-changed note (#274)', () => { + const workspace = { name: 'Acme' } as unknown as Workspace; + const NOTE_MARKER = 'edited the open page AFTER your last response'; + const SAFETY_MARKER = 'Operating rules (always in effect)'; + + it('renders the page_changed block + diff when the flag is set', () => { + const prompt = buildSystemPrompt({ + workspace, + pageChanged: { + title: 'Release Notes', + diff: '@@ -1 +1 @@\n-old line\n+new line', + }, + }); + expect(prompt).toContain(' { + expect(buildSystemPrompt({ workspace })).not.toContain(' { + expect( + buildSystemPrompt({ + workspace, + pageChanged: { title: 'X', diff: ' \n ' }, + }), + ).not.toContain(' { + const prompt = buildSystemPrompt({ + workspace, + pageChanged: { title: ' ', diff: '@@ -1 +1 @@\n-a\n+b' }, + }); + expect(prompt).toContain('page="Untitled"'); + }); +}); diff --git a/apps/server/src/core/ai-chat/ai-chat.prompt.ts b/apps/server/src/core/ai-chat/ai-chat.prompt.ts index f0a9c2d0..6eba12cc 100644 --- a/apps/server/src/core/ai-chat/ai-chat.prompt.ts +++ b/apps/server/src/core/ai-chat/ai-chat.prompt.ts @@ -72,6 +72,25 @@ const INTERRUPT_NOTE = 'assume your previous response was complete, and do not silently restart the ' + 'partial work — build on it or follow the new instruction.'; +/** + * Injected on a turn where the open page was hand-edited by the user (or anyone + * else) AFTER the agent's previous response ended (#274). The server takes a + * Markdown snapshot of the page at each turn's end and, at the next turn's start, + * diffs the current page against it; when non-empty, this note + the unified diff + * go into the context section so the agent knows its earlier copy of the page is + * stale and does not blindly overwrite the human's edits. Ephemeral: the prompt + * is rebuilt every turn, so the note self-clears once the change is folded into + * the next end-of-turn snapshot (a direct twin of INTERRUPT_NOTE). + */ +const PAGE_CHANGED_NOTE = + 'NOTE: The user edited the open page AFTER your last response in this ' + + 'conversation, so any copy of that page you produced or remember from earlier ' + + 'is now STALE. The unified diff below shows exactly what changed since you last ' + + 'spoke (lines starting with "-" were removed, "+" were added) and is the source ' + + 'of truth. Preserve the user\'s edits: build on the current page, do not revert ' + + 'or overwrite their changes. If you need the full up-to-date page, re-read it ' + + 'with the getPage tool before editing.'; + export interface BuildSystemPromptInput { workspace: Workspace; /** @@ -111,6 +130,16 @@ export interface BuildSystemPromptInput { * (partial) answer was cut off by the user's new message. */ interrupted?: boolean; + /** + * Set only when the open page was edited by the user AFTER the agent's previous + * turn ended (#274), confirmed server-side by diffing the current page against + * the end-of-last-turn snapshot. When present, a `` block with the + * PAGE_CHANGED_NOTE and the unified diff is added to the context section so the + * agent treats its earlier copy of the page as stale. `title` labels the page; + * `diff` is the (already size-capped) unified Markdown diff. Null/absent => no + * block (unchanged page, page not open, or first turn). + */ + pageChanged?: { title: string; diff: string } | null; } /** @@ -156,6 +185,7 @@ export function buildSystemPrompt({ openedPage, mcpInstructions, interrupted, + pageChanged, }: BuildSystemPromptInput): string { // Persona precedence: role instructions REPLACE the admin persona / default. // effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT. @@ -191,6 +221,27 @@ export function buildSystemPrompt({ context += `\n${INTERRUPT_NOTE}`; } + // Per-turn page-change note (#274). Added to the context section (inside the + // safety sandwich), present only when the server detected that the open page + // was edited by the user since the agent's last turn ended. The diff content is + // untrusted page data wrapped in a delimited block: it informs + // the agent that its copy is stale, but the surrounding safety rules still bind + // (a diff cannot smuggle instructions). Absent => nothing is added. + if (pageChanged && pageChanged.diff.trim().length > 0) { + const title = + typeof pageChanged.title === 'string' && pageChanged.title.trim().length > 0 + ? pageChanged.title.trim() + : 'Untitled'; + context += [ + '', + ``, + PAGE_CHANGED_NOTE, + 'Unified diff of changes since your last response:', + pageChanged.diff.trim(), + '', + ].join('\n'); + } + // Per-server external-MCP tool guidance (#180). Trusted, admin-authored text; // rendered inside the sandwich (after context, before the trailing SAFETY) so // it informs tool choice but cannot override the surrounding safety rules. diff --git a/apps/server/src/core/ai-chat/ai-chat.role-resolve.spec.ts b/apps/server/src/core/ai-chat/ai-chat.role-resolve.spec.ts index ba1f3f34..3683d8c1 100644 --- a/apps/server/src/core/ai-chat/ai-chat.role-resolve.spec.ts +++ b/apps/server/src/core/ai-chat/ai-chat.role-resolve.spec.ts @@ -46,6 +46,7 @@ describe('AiChatService.resolveRoleForRequest', () => { {} as never, // ai aiChatRepo as never, {} as never, // aiChatMessageRepo + {} as never, // aiChatPageSnapshotRepo {} as never, // aiSettings {} as never, // tools {} as never, // mcpClients diff --git a/apps/server/src/core/ai-chat/ai-chat.service.lifecycle.spec.ts b/apps/server/src/core/ai-chat/ai-chat.service.lifecycle.spec.ts index 77e9d3c4..dc7cbdaf 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.lifecycle.spec.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.lifecycle.spec.ts @@ -15,6 +15,7 @@ describe('AiChatService.onModuleInit (startup sweep)', () => { {} as never, // ai {} as never, // aiChatRepo aiChatMessageRepo as never, + {} as never, // aiChatPageSnapshotRepo {} as never, // aiSettings {} as never, // tools {} as never, // mcpClients diff --git a/apps/server/src/core/ai-chat/ai-chat.service.spec.ts b/apps/server/src/core/ai-chat/ai-chat.service.spec.ts index 4e5ac72a..9050829e 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.spec.ts @@ -10,6 +10,7 @@ import { chatStreamMetadata, accumulateStepUsage, isInterruptResume, + sameInstant, MAX_AGENT_STEPS, FINAL_STEP_INSTRUCTION, } from './ai-chat.service'; @@ -573,7 +574,12 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)', const user = { id: 'u-1' } as any; function makeService(opts: { - page?: { id: string; workspaceId: string; title: string | null } | null; + page?: { + id: string; + workspaceId: string; + title: string | null; + updatedAt?: Date; + } | null; canView?: boolean | 'throw-other'; }) { const svc = Object.create(AiChatService.prototype) as AiChatService; @@ -595,6 +601,7 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)', (svc as any).resolveOpenPageContext(openPage, ws, user) as Promise<{ id: string; title: string; + updatedAt: Date; } | null>; it('returns null when no page is open (no id)', async () => { @@ -632,22 +639,246 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)', expect(await call(svc, { id: 'p-1' })).toBeNull(); }); - it('uses the AUTHORITATIVE DB title, IGNORING the client-supplied title', async () => { + it('uses the AUTHORITATIVE DB title + updatedAt, IGNORING the client-supplied title', async () => { + const updatedAt = new Date('2026-07-02T10:00:00Z'); const svc = makeService({ - page: { id: 'p-1', workspaceId: 'ws-1', title: 'Real Title B' }, + page: { id: 'p-1', workspaceId: 'ws-1', title: 'Real Title B', updatedAt }, canView: true, }); // The client claims it is on "Page A" but the id points at page B. const result = await call(svc, { id: 'p-1', title: 'Page A' }); - expect(result).toEqual({ id: 'p-1', title: 'Real Title B' }); + // updatedAt (#274 page-change fast path) is carried through from the DB row. + expect(result).toEqual({ id: 'p-1', title: 'Real Title B', updatedAt }); }); it('coerces a null DB title to an empty string', async () => { + const updatedAt = new Date('2026-07-02T10:00:00Z'); const svc = makeService({ - page: { id: 'p-1', workspaceId: 'ws-1', title: null }, + page: { id: 'p-1', workspaceId: 'ws-1', title: null, updatedAt }, canView: true, }); - expect(await call(svc, { id: 'p-1' })).toEqual({ id: 'p-1', title: '' }); + expect(await call(svc, { id: 'p-1' })).toEqual({ + id: 'p-1', + title: '', + updatedAt, + }); + }); +}); + +/** + * sameInstant (#274 page-change fast path): equal instants => the open page is + * untouched since the snapshot, so detection can skip the render + diff. A + * missing/invalid timestamp must fall through (return false) so a bad value never + * causes a false "nothing changed" skip that would lose a human edit. + */ +describe('sameInstant', () => { + it('true for identical instants (Date and equivalent string)', () => { + const d = new Date('2026-07-02T10:00:00Z'); + expect(sameInstant(d, new Date(d.getTime()))).toBe(true); + expect(sameInstant(d, '2026-07-02T10:00:00.000Z')).toBe(true); + }); + + it('false for different instants', () => { + expect( + sameInstant( + new Date('2026-07-02T10:00:00Z'), + new Date('2026-07-02T10:00:01Z'), + ), + ).toBe(false); + }); + + it('false when either side is null/undefined/invalid', () => { + const d = new Date('2026-07-02T10:00:00Z'); + expect(sameInstant(null, d)).toBe(false); + expect(sameInstant(d, undefined)).toBe(false); + expect(sameInstant(d, 'not-a-date')).toBe(false); + }); +}); + +/** + * Page-change lifecycle (#274): detectPageChange (turn start) + snapshotOpenPage + * (turn end) exercised with in-memory fakes (Object.create — no Nest graph, no + * DB). Covers detection happy path / no-change / first-turn-seed-only / fast + * path, the snapshot seed + deleted-page skip, and — the key regression — the + * abort/error branch: after an aborted turn where the AGENT edited the page, the + * snapshot must advance so the next turn does NOT mis-report the agent's own edit + * as a user edit. + */ +describe('AiChatService page-change lifecycle (#274)', () => { + const workspace = { id: 'ws-1' } as Workspace; + const user = { id: 'u-1' } as any; + const sessionId = 'sess-1'; + const T0 = new Date('2026-07-02T10:00:00Z'); + const T1 = new Date('2026-07-02T10:05:00Z'); + + function makeService(opts: { + snapshot?: { contentMd: string; pageUpdatedAt: Date }; + exportMd?: string; + // pageRepo.findById result used by snapshotOpenPage. `null` models a deleted + // page; omitted defaults to a same-workspace page at T1. + page?: { workspaceId: string; updatedAt: Date } | null; + }) { + const store = new Map(); + if (opts.snapshot) { + store.set('c1|p1', { + chatId: 'c1', + pageId: 'p1', + workspaceId: 'ws-1', + ...opts.snapshot, + }); + } + // Mutable so a test can reconfigure between the abort-snapshot phase and the + // next-turn detect phase. + const state = { + exportMd: opts.exportMd ?? '', + page: + opts.page === undefined + ? { workspaceId: 'ws-1', updatedAt: T1 } + : opts.page, + }; + const exportCalls: string[] = []; + + const svc = Object.create(AiChatService.prototype) as AiChatService; + (svc as any).logger = { warn: () => {}, error: () => {} }; + (svc as any).aiChatPageSnapshotRepo = { + findByChatPage: async (chatId: string, pageId: string) => + store.get(`${chatId}|${pageId}`), + upsert: async (v: any) => { + store.set(`${v.chatId}|${v.pageId}`, { ...v }); + return v; + }, + }; + (svc as any).tools = { + exportPageMarkdown: async ( + _u: unknown, + _s: unknown, + _ws: unknown, + _c: unknown, + pageId: string, + ) => { + exportCalls.push(pageId); + return state.exportMd; + }, + }; + (svc as any).pageRepo = { findById: async () => state.page }; + return { svc, store, state, exportCalls }; + } + + const detect = ( + svc: AiChatService, + openPage: { id: string; title: string; updatedAt: Date } | null, + ) => + (svc as any).detectPageChange( + 'c1', + openPage, + workspace, + user, + sessionId, + ) as Promise<{ title: string; diff: string } | null>; + + const snapshot = (svc: AiChatService) => + (svc as any).snapshotOpenPage( + 'c1', + 'p1', + workspace, + user, + sessionId, + ) as Promise; + + it('detect: no note when the page is not open', async () => { + const { svc } = makeService({}); + expect(await detect(svc, null)).toBeNull(); + }); + + it('detect: first turn (no snapshot) seeds only, no note', async () => { + const { svc, exportCalls } = makeService({}); + const res = await detect(svc, { id: 'p1', title: 'Doc', updatedAt: T0 }); + expect(res).toBeNull(); + // No snapshot => no render/diff at all. + expect(exportCalls).toHaveLength(0); + }); + + it('detect: fast path skips render+diff when updatedAt is unchanged', async () => { + const { svc, exportCalls } = makeService({ + snapshot: { contentMd: 'S0', pageUpdatedAt: T0 }, + }); + const res = await detect(svc, { id: 'p1', title: 'Doc', updatedAt: T0 }); + expect(res).toBeNull(); + expect(exportCalls).toHaveLength(0); + }); + + it('detect: user edit between turns yields a titled note + diff', async () => { + const { svc } = makeService({ + snapshot: { contentMd: '# Title\n\nold body', pageUpdatedAt: T0 }, + exportMd: '# Title\n\nnew body', + }); + const res = await detect(svc, { id: 'p1', title: 'Doc', updatedAt: T1 }); + expect(res).not.toBeNull(); + expect(res!.title).toBe('Doc'); + expect(res!.diff).toContain('-old body'); + expect(res!.diff).toContain('+new body'); + }); + + it('detect: no note when content is unchanged despite a bumped updatedAt', async () => { + const { svc } = makeService({ + snapshot: { contentMd: 'same content', pageUpdatedAt: T0 }, + exportMd: 'same content', + }); + expect( + await detect(svc, { id: 'p1', title: 'Doc', updatedAt: T1 }), + ).toBeNull(); + }); + + it('snapshot: seeds the current Markdown + page updatedAt', async () => { + const { svc, store } = makeService({ + exportMd: 'Sa', + page: { workspaceId: 'ws-1', updatedAt: T1 }, + }); + await snapshot(svc); + const row = store.get('c1|p1'); + expect(row.contentMd).toBe('Sa'); + expect(row.pageUpdatedAt).toBe(T1); + expect(typeof row.contentHash).toBe('string'); + }); + + it('snapshot: skips the write when the page was deleted during the turn', async () => { + const { svc, store } = makeService({ exportMd: 'X', page: null }); + await snapshot(svc); + expect(store.get('c1|p1')).toBeUndefined(); + }); + + it('abort branch: advancing the snapshot after an agent edit prevents a false note next turn', async () => { + // Previous turn ended with the page at S0 @ T0. + const { svc, store, state } = makeService({ + snapshot: { contentMd: 'S0 body', pageUpdatedAt: T0 }, + }); + + // This turn the AGENT edited the page (committed to the DB) to "Sa body", + // bumping updatedAt to T1, and then the turn ABORTED. The abort path runs the + // same snapshot, which must advance the snapshot to what the agent left. + state.exportMd = 'Sa body'; + state.page = { workspaceId: 'ws-1', updatedAt: T1 }; + await snapshot(svc); + expect(store.get('c1|p1').contentMd).toBe('Sa body'); + expect(store.get('c1|p1').pageUpdatedAt).toBe(T1); + + // Next turn: nobody edited further; the page is still Sa @ T1. The agent's OWN + // edit must NOT surface as a "user edited the page" note. + const res = await detect(svc, { id: 'p1', title: 'Doc', updatedAt: T1 }); + expect(res).toBeNull(); + }); + + it('abort branch: WITHOUT advancing the snapshot, the agent edit would wrongly surface (proves the fix)', async () => { + // Same setup but the snapshot is NOT advanced (the pre-fix behaviour where + // only onFinish snapshotted). The agent's committed edit then looks like a + // between-turns user edit — exactly the bug FIX 1 removes. + const { svc } = makeService({ + snapshot: { contentMd: 'S0 body', pageUpdatedAt: T0 }, + exportMd: 'Sa body', + }); + const res = await detect(svc, { id: 'p1', title: 'Doc', updatedAt: T1 }); + expect(res).not.toBeNull(); + expect(res!.diff).toContain('+Sa body'); }); }); diff --git a/apps/server/src/core/ai-chat/ai-chat.service.ts b/apps/server/src/core/ai-chat/ai-chat.service.ts index e4c81584..e1526527 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.ts @@ -4,6 +4,7 @@ import { Logger, OnModuleInit, } from '@nestjs/common'; +import { createHash } from 'node:crypto'; import { FastifyReply } from 'fastify'; import { streamText, @@ -18,6 +19,7 @@ import { AiSettingsService } from '../../integrations/ai/ai-settings.service'; import { describeProviderError } from '../../integrations/ai/ai-error.util'; import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo'; import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo'; +import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo'; import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageAccessService } from '../page/page-access/page-access.service'; @@ -30,6 +32,7 @@ import { import { AiChatToolsService } from './tools/ai-chat-tools.service'; import { McpClientsService } from './external-mcp/mcp-clients.service'; import { buildSystemPrompt } from './ai-chat.prompt'; +import { computePageChange } from './page-change/page-change.util'; import { roleModelOverride } from './roles/role-model-config'; import { startSseHeartbeat, @@ -113,6 +116,24 @@ export function isInterruptResume( ); } +/** + * Whether two timestamps refer to the SAME instant (#274 page-change fast path). + * The snapshot's `pageUpdatedAt` comes back from Postgres as a Date, the live + * page's `updatedAt` is a Date too; compare by epoch millis so a value that + * round-tripped through the driver as a string still matches. Either side + * missing => treat as different (fall through to the diff, never a false skip). + */ +export function sameInstant( + a: Date | string | null | undefined, + b: Date | string | null | undefined, +): boolean { + if (a == null || b == null) return false; + const ta = new Date(a).getTime(); + const tb = new Date(b).getTime(); + if (Number.isNaN(ta) || Number.isNaN(tb)) return false; + return ta === tb; +} + /** * Payload accepted from the client `useChat` POST body. We do NOT bind a strict * DTO (the global ValidationPipe whitelist would strip the useChat-specific @@ -179,6 +200,7 @@ export class AiChatService implements OnModuleInit { private readonly ai: AiService, private readonly aiChatRepo: AiChatRepo, private readonly aiChatMessageRepo: AiChatMessageRepo, + private readonly aiChatPageSnapshotRepo: AiChatPageSnapshotRepo, private readonly aiSettings: AiSettingsService, private readonly tools: AiChatToolsService, private readonly mcpClients: McpClientsService, @@ -272,7 +294,7 @@ export class AiChatService implements OnModuleInit { openPage: { id?: string; title?: string } | null | undefined, workspace: Workspace, user: User, - ): Promise<{ id: string; title: string } | null> { + ): Promise<{ id: string; title: string; updatedAt: Date } | null> { const candidatePageId = openPage?.id; if (!candidatePageId) return null; const page = await this.pageRepo.findById(candidatePageId); @@ -291,7 +313,132 @@ export class AiChatService implements OnModuleInit { } return null; } - return { id: page.id, title: page.title ?? '' }; + // updatedAt is the page's last-modified instant, used by the #274 per-turn + // page-change detection as a cheap fast path (unchanged instant => skip the + // render + diff). The system-prompt / tool consumers ignore the extra field. + return { id: page.id, title: page.title ?? '', updatedAt: page.updatedAt }; + } + + /** + * Per-turn page-change detection (#274). The agent rebuilds its context from the + * DB each turn and otherwise cannot tell that the user hand-edited the open page + * since it last spoke — so it can silently overwrite those edits. This compares + * the page's CURRENT Markdown against the snapshot taken at the END of the + * agent's previous turn (see `snapshotOpenPage`) and, when a human changed + * something in between, returns a `{ title, diff }` the caller feeds to + * `buildSystemPrompt` as an ephemeral note. + * + * Edge cases: page not open / no snapshot (first turn) / page untouched since + * the snapshot (updatedAt fast path) / empty-after-normalization diff => null + * (no note). Best-effort: any fault is logged and downgraded to "no note" so it + * never breaks the turn. + */ + private async detectPageChange( + chatId: string, + openPageContext: { id: string; title: string; updatedAt: Date } | null, + workspace: Workspace, + user: User, + sessionId: string, + ): Promise<{ title: string; diff: string } | null> { + if (!openPageContext) return null; + try { + const snapshot = await this.aiChatPageSnapshotRepo.findByChatPage( + chatId, + openPageContext.id, + workspace.id, + ); + // No snapshot yet => first turn on this page; there is nothing to diff + // against. onFinish seeds it; the note starts from the NEXT turn. + if (!snapshot) return null; + // Fast path: the page has not been touched since the snapshot instant, so + // nothing changed — skip the render + diff entirely. + if (sameInstant(snapshot.pageUpdatedAt, openPageContext.updatedAt)) { + return null; + } + // Render the current page the SAME way the snapshot end was rendered, so + // pure formatting never registers as a change. + const currentMd = await this.tools.exportPageMarkdown( + user, + sessionId, + workspace.id, + chatId, + openPageContext.id, + ); + const change = computePageChange(snapshot.contentMd, currentMd); + if (!change.changed) return null; + return { + title: openPageContext.title || 'Untitled', + diff: change.diff, + }; + } catch (err) { + this.logger.warn( + `page-change detection skipped (chat ${chatId}): ${ + err instanceof Error ? err.message : 'unknown error' + }`, + ); + return null; + } + } + + /** + * Write the end-of-turn snapshot for the open page (#274): the page's current + * Markdown after ALL of the agent's edits this turn, plus the page's + * updated_at. The agent's own edits are therefore baked into the snapshot, so + * the next turn's diff isolates exactly what a HUMAN changed in between. Also + * seeds the snapshot on the first turn. Best-effort — a deleted/foreign page or + * any fault simply skips the write (no snapshot, no note next turn). + * + * Ordering note (deliberate): read updated_at BEFORE exporting, and store that + * earlier value. This keeps the stored updated_at <= the true version of the + * stored content, which is the SAFE direction for the fast path: it can only + * ever be too conservative (force an extra diff), never falsely skip. Concretely + * — if a user edit lands in the tiny window between the read and the export, the + * export captures the NEW content while we store the OLDER updated_at; next turn + * the two updated_ats differ, so the fast path is bypassed and we diff — which + * resolves to "no change" because that edit is already baked into the stored + * content. The only cost is not emitting a page_changed note for that specific + * window edit, which is safe: the snapshot already contains it, so it can never + * be silently overwritten later. + * + * The OPPOSITE order (read updated_at AFTER the export) is what would be unsafe: + * a concurrent edit's NEWER updated_at would be stored alongside the OLDER + * exported content, and next turn's fast path would then match on updated_at and + * SKIP detection while the content genuinely diverged — a real missed edit. So + * we intentionally do NOT re-read updated_at after the export. + */ + private async snapshotOpenPage( + chatId: string, + pageId: string, + workspace: Workspace, + user: User, + sessionId: string, + ): Promise { + try { + const freshPage = await this.pageRepo.findById(pageId); + // Page deleted during the turn (or somehow foreign) => don't write. + if (!freshPage || freshPage.workspaceId !== workspace.id) return; + const currentMd = await this.tools.exportPageMarkdown( + user, + sessionId, + workspace.id, + chatId, + pageId, + ); + await this.aiChatPageSnapshotRepo.upsert({ + chatId, + pageId, + workspaceId: workspace.id, + contentMd: currentMd, + pageUpdatedAt: freshPage.updatedAt, + contentHash: createHash('sha256').update(currentMd).digest('hex'), + }); + } catch (err) { + this.logger.warn( + `page snapshot skipped (chat ${chatId}): ${ + err instanceof Error ? err.message : 'unknown error' + }`, + ); + } } async stream({ @@ -385,6 +532,19 @@ export class AiChatService implements OnModuleInit { // already in `messages` (the aborted assistant row replays via findRecent). const interrupted = isInterruptResume(history, body.interrupted); + // Per-turn page-change detection (#274): if the open page was hand-edited by + // the user since the agent's last turn ended, compute the unified diff so the + // system prompt can warn the agent its copy is stale (else it overwrites those + // edits). Best-effort (null on the fast path / first turn / any fault) — never + // blocks the turn. Snapshot is (re)written at turn end in onFinish below. + const pageChanged = await this.detectPageChange( + chatId, + openPageContext, + workspace, + user, + sessionId, + ); + // The model is resolved by the controller before hijack (clean 503 path). // Here we only need the admin-configured system prompt. const resolved = await this.aiSettings.resolve(workspace.id); @@ -440,6 +600,30 @@ export class AiChatService implements OnModuleInit { ); }; + // Turn-end snapshot of the open page (#274), run EXACTLY ONCE across the + // terminal callbacks. This MUST run on onError/onAbort too, not only on the + // successful onFinish: the write tools commit page edits to the DB + // synchronously during a step, so an agent edit followed by an abort/error + // (client disconnect, stop(), provider failure) still persists and bumps + // page.updatedAt. If the snapshot did not advance on those paths, the NEXT + // turn would diff the agent's OWN committed edit against the stale previous + // snapshot and mis-report it as a user edit — breaking the "own edits excluded + // by construction" guarantee. Best-effort (snapshotOpenPage swallows + logs); + // skipped when no page is open. + let snapshotWritten = false; + const snapshotTurnEnd = async (): Promise => { + if (snapshotWritten) return; + snapshotWritten = true; + if (!openPageContext) return; + await this.snapshotOpenPage( + chatId, + openPageContext.id, + workspace, + user, + sessionId, + ); + }; + // Build the system prompt + Docmost toolset. If either throws after the // external MCP lease was taken above, release the lease before rethrowing so // the leased transports are not leaked (#185 review). @@ -459,6 +643,9 @@ export class AiChatService implements OnModuleInit { // History-confirmed interrupt-resume flag (#198): adds the interrupt note // so the model treats the partial answer above as cut off, not finished. interrupted, + // Detected between-turns human edit to the open page (#274): adds the + // page_changed note + unified diff so the agent doesn't overwrite it. + pageChanged, }); // Pass the resolved chatId so the write tools can mint provenance tokens @@ -680,6 +867,13 @@ export class AiChatService implements OnModuleInit { // Lifecycle: release the external MCP clients leased for this turn. await closeExternalClients(); + // Turn end (#274): snapshot the open page's current Markdown (after all + // of the agent's edits this turn) so the NEXT turn can diff against it + // and detect edits a human made in between. Self-clearing — the agent's + // own edits are baked in — and this also SEEDS the snapshot on the first + // turn. Runs once across every terminal path (see snapshotTurnEnd). + await snapshotTurnEnd(); + // Generate the chat title for a freshly created chat AFTER the stream's // provider call has completed — NOT concurrently with it. The z.ai coding // endpoint stalls one of two concurrent requests to the same plan, which @@ -722,6 +916,10 @@ export class AiChatService implements OnModuleInit { }), ); await closeExternalClients(); + // Advance the page snapshot even on failure (#274): an agent edit that + // committed before the error must be baked into the snapshot, or the + // next turn would mis-report it as a user edit. + await snapshotTurnEnd(); }, onAbort: async ({ steps }) => { const partialChars = @@ -747,6 +945,10 @@ export class AiChatService implements OnModuleInit { flushAssistant(capturedSteps, inProgressText, 'aborted'), ); await closeExternalClients(); + // Advance the page snapshot even on abort (#274): an agent edit that + // committed before the client disconnect / stop() must be baked into the + // snapshot, or the next turn would mis-report it as a user edit. + await snapshotTurnEnd(); }, }); diff --git a/apps/server/src/core/ai-chat/page-change/page-change.util.spec.ts b/apps/server/src/core/ai-chat/page-change/page-change.util.spec.ts new file mode 100644 index 00000000..cce3333e --- /dev/null +++ b/apps/server/src/core/ai-chat/page-change/page-change.util.spec.ts @@ -0,0 +1,67 @@ +import { + computePageChange, + normalizeMarkdown, +} from './page-change.util'; + +/** + * Unit tests for the pure page-change diff util (#274). Covers: a real content + * change produces a non-empty unified diff; identical input produces no change; + * a whitespace-only difference normalizes away to no change; and a large diff is + * capped with the getPage hint. + */ +describe('computePageChange', () => { + it('reports a change and a unified diff when content differs', () => { + const before = '# Title\n\nHello world.'; + const after = '# Title\n\nHello brave new world.'; + + const res = computePageChange(before, after); + + expect(res.changed).toBe(true); + // Standard unified-diff markers + the actual removed/added lines. + expect(res.diff).toContain('@@'); + expect(res.diff).toContain('-Hello world.'); + expect(res.diff).toContain('+Hello brave new world.'); + }); + + it('reports no change for identical input', () => { + const md = '# Title\n\nSame content.'; + expect(computePageChange(md, md)).toEqual({ changed: false, diff: '' }); + }); + + it('normalizes whitespace-only differences to no change', () => { + // Trailing spaces, CRLF line endings, and extra leading/trailing blank lines + // are the kind of churn two renders can differ by — must NOT count as a change. + const before = 'Line one\nLine two'; + const after = '\r\n\r\nLine one \r\nLine two\t\r\n\r\n'; + + const res = computePageChange(before, after); + + expect(res.changed).toBe(false); + expect(res.diff).toBe(''); + }); + + it('caps a large diff and appends the getPage hint', () => { + const before = ''; + // A big block of distinct lines forces a diff well over the cap. + const after = Array.from({ length: 2000 }, (_, i) => `new line ${i}`).join( + '\n', + ); + + const res = computePageChange(before, after); + + expect(res.changed).toBe(true); + expect(res.diff).toContain('use getPage to read the full current page'); + // Cap (6000) + the short truncation hint; never the full multi-KB patch. + expect(res.diff.length).toBeLessThan(6200); + }); +}); + +describe('normalizeMarkdown', () => { + it('strips trailing whitespace, unifies newlines, trims blank edges', () => { + expect(normalizeMarkdown('\r\n a \r\nb\t\n\n')).toBe(' a\nb'); + }); + + it('coerces null/undefined to an empty string', () => { + expect(normalizeMarkdown(undefined as unknown as string)).toBe(''); + }); +}); diff --git a/apps/server/src/core/ai-chat/page-change/page-change.util.ts b/apps/server/src/core/ai-chat/page-change/page-change.util.ts new file mode 100644 index 00000000..7bb3481b --- /dev/null +++ b/apps/server/src/core/ai-chat/page-change/page-change.util.ts @@ -0,0 +1,84 @@ +import { createTwoFilesPatch } from 'diff'; + +/** + * Per-turn page-change detection (#274). + * + * The agent rebuilds its context from the DB each turn and does not otherwise + * know that the user hand-edited the open page since its last response. This + * pure helper diffs the Markdown snapshot taken at the END of the agent's + * previous turn against the page's CURRENT Markdown, yielding exactly what a + * human changed in between (the agent's own edits are baked into the snapshot). + * The caller surfaces the diff as an ephemeral note in the system prompt. + * + * Both ends are produced by the SAME renderer (exportPageMarkdown), so pure + * formatting never pollutes the diff. We additionally normalize whitespace here + * so trailing-space / blank-line churn between two renders does not register as a + * change. + */ + +// Upper bound on the emitted diff. Kept in the ~4–8 KB band: large enough to +// carry a substantial human edit, small enough that a wholesale rewrite of a big +// page can't blow up the system prompt. On overflow the diff is cut here and the +// model is told to read the full current page via the getPage tool instead. +const DIFF_SIZE_CAP = 6000; + +const TRUNCATION_HINT = + '\n... diff truncated — use getPage to read the full current page.'; + +/** + * Normalize a rendered Markdown blob so only meaningful content differences + * survive: unify line endings, strip trailing whitespace on every line, and drop + * leading/trailing blank lines. Two renders that differ only in whitespace + * normalize to the SAME string, so `computePageChange` reports no change. + */ +export function normalizeMarkdown(md: string): string { + return (md ?? '') + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.replace(/[ \t]+$/g, '')) + .join('\n') + .replace(/^\n+/, '') + .replace(/\n+$/, ''); +} + +export interface PageChange { + changed: boolean; + diff: string; +} + +/** + * Compute the between-turns page change. Returns `{ changed:false, diff:'' }` + * when the two renders are identical after whitespace normalization (the common + * case, and the whitespace-only case). Otherwise returns a unified Markdown diff, + * capped at DIFF_SIZE_CAP with a hint pointing the model at getPage. + */ +export function computePageChange( + snapshotMd: string, + currentMd: string, +): PageChange { + const before = normalizeMarkdown(snapshotMd); + const after = normalizeMarkdown(currentMd); + + if (before === after) { + return { changed: false, diff: '' }; + } + + // createTwoFilesPatch emits a standard unified diff (---/+++ headers + @@ + // hunks). The filenames double as human-readable labels for the two ends. + const patch = createTwoFilesPatch( + 'page (agent snapshot)', + 'page (current)', + before, + after, + '', + '', + { context: 3 }, + ); + + const diff = + patch.length > DIFF_SIZE_CAP + ? patch.slice(0, DIFF_SIZE_CAP) + TRUNCATION_HINT + : patch; + + return { changed: true, diff }; +} diff --git a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts index abe10219..2b0c2d8a 100644 --- a/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts +++ b/apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts @@ -46,23 +46,20 @@ export class AiChatToolsService { private readonly sandboxStore: SandboxStore, ) {} - async forUser( + /** + * Construct the per-user loopback `DocmostClient` used to reach Docmost's REST + * / collab surface AS the current user. Every call is scoped by the user's own + * access JWT (CASL-enforced) and carries the signed agent provenance claim + * ({ actor:'agent', aiChatId }) for both the access and collab tokens. Shared + * by `forUser` (the agent toolset) and `exportPageMarkdown` (the #274 + * page-change detection path) so they use an identical authenticated route. + */ + private async buildDocmostClient( user: User, sessionId: string, - // workspaceId scopes the provenance collab token (which is workspace-bound), - // and documents the single-workspace assumption; the loopback REST client is - // scoped by the user's JWT, not by an explicit workspace argument. workspaceId: string, - // The resolved AI chat id. Threaded into both provenance tokens so every - // agent write (REST + collab) records { actor:'agent', aiChatId } off a - // SIGNED claim — non-spoofable, never a client body field (§6.5/§6.6). aiChatId: string, - // The page the user currently has open (from the request context), exposed - // to the model via getCurrentPage. Optional and last so existing callers - // keep compiling. Kept proxy-robust: the model can CALL for the current - // page instead of relying on it surviving in the system prompt text. - openedPage?: { id?: string; title?: string } | null, - ): Promise> { + ): Promise { const apiUrl = process.env.MCP_DOCMOST_API_URL || `http://127.0.0.1:${process.env.PORT || 3000}/api`; @@ -94,13 +91,66 @@ export class AiChatToolsService { // package needs to keep its mirror counts honest under FIFO eviction (the // package never touches env or the store). asSink() centralizes the uri↔id // mapping next to putAndLink, shared with the embedded-MCP wiring site. - const { DocmostClient, sharedToolSpecs } = await loadDocmostMcp(); - const client: DocmostClientLike = new DocmostClient({ + const { DocmostClient } = await loadDocmostMcp(); + return new DocmostClient({ apiUrl, getToken, getCollabToken, sandbox: this.sandboxStore.asSink(), }); + } + + /** + * Export a page's current Markdown (meta + body + comment threads) via the + * SAME loopback path the `exportPageMarkdown` tool uses (#274). Used by the + * per-turn page-change detection to render both the snapshot end and the + * current end identically, so formatting never pollutes the diff. Access is + * CASL-enforced by the user's JWT: a page the user cannot read throws. + */ + async exportPageMarkdown( + user: User, + sessionId: string, + workspaceId: string, + aiChatId: string, + pageId: string, + ): Promise { + const client = await this.buildDocmostClient( + user, + sessionId, + workspaceId, + aiChatId, + ); + return client.exportPageMarkdown(pageId); + } + + async forUser( + user: User, + sessionId: string, + // workspaceId scopes the provenance collab token (which is workspace-bound), + // and documents the single-workspace assumption; the loopback REST client is + // scoped by the user's JWT, not by an explicit workspace argument. + workspaceId: string, + // The resolved AI chat id. Threaded into both provenance tokens so every + // agent write (REST + collab) records { actor:'agent', aiChatId } off a + // SIGNED claim — non-spoofable, never a client body field (§6.5/§6.6). + aiChatId: string, + // The page the user currently has open (from the request context), exposed + // to the model via getCurrentPage. Optional and last so existing callers + // keep compiling. Kept proxy-robust: the model can CALL for the current + // page instead of relying on it surviving in the system prompt text. + openedPage?: { id?: string; title?: string } | null, + ): Promise> { + // Build the per-user loopback client (carrying the access + collab + // provenance tokens) and load the shared tool-spec registry. Client + // construction is shared with the page-change detection path (#274) via + // buildDocmostClient so both go over the exact same authenticated route. + const { sharedToolSpecs } = await loadDocmostMcp(); + const client = await this.buildDocmostClient( + user, + sessionId, + workspaceId, + aiChatId, + ); // Build an ai-SDK tool from a shared, zod-agnostic spec. The spec owns the // canonical description + (optional) schema builder, which is invoked with diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts index da90ef35..4e9c5d13 100644 --- a/apps/server/src/database/database.module.ts +++ b/apps/server/src/database/database.module.ts @@ -31,6 +31,7 @@ import { FavoriteRepo } from '@docmost/db/repos/favorite/favorite.repo'; import { TemplateRepo } from '@docmost/db/repos/template/template.repo'; import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo'; import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo'; +import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo'; import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo'; import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo'; import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo'; @@ -104,6 +105,7 @@ import { normalizePostgresUrl } from '../common/helpers'; TemplateRepo, AiChatRepo, AiChatMessageRepo, + AiChatPageSnapshotRepo, AiProviderCredentialsRepo, AiMcpServerRepo, AiAgentRoleRepo, @@ -137,6 +139,7 @@ import { normalizePostgresUrl } from '../common/helpers'; TemplateRepo, AiChatRepo, AiChatMessageRepo, + AiChatPageSnapshotRepo, AiProviderCredentialsRepo, AiMcpServerRepo, AiAgentRoleRepo, diff --git a/apps/server/src/database/migrations/20260702T120000-ai-chat-page-snapshot.ts b/apps/server/src/database/migrations/20260702T120000-ai-chat-page-snapshot.ts new file mode 100644 index 00000000..9bc9af74 --- /dev/null +++ b/apps/server/src/database/migrations/20260702T120000-ai-chat-page-snapshot.ts @@ -0,0 +1,55 @@ +import { type Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // Per-(chat,page) snapshot of the open page's Markdown at the END of the + // agent's previous turn (#274). The next turn diffs the CURRENT Markdown + // against this snapshot to detect edits the USER (or anyone else) made between + // turns, and surfaces that unified diff as an ephemeral note in the system + // prompt so the agent does not silently overwrite those edits. The agent's own + // edits are baked into the snapshot (it is rewritten at each turn end), so the + // diff is exactly "what someone else changed since I last spoke". + // + // ON DELETE CASCADE on both FKs: the snapshot is derived, per-chat state with + // no independent value, so a hard-deleted chat or page takes its snapshots with + // it. UNIQUE(chat_id, page_id): at most one live snapshot per chat/page pair + // (the turn-end write is an upsert on this key). + await db.schema + .createTable('ai_chat_page_snapshots') + .ifNotExists() + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('chat_id', 'uuid', (col) => + col.references('ai_chats.id').onDelete('cascade').notNull(), + ) + .addColumn('page_id', 'uuid', (col) => + col.references('pages.id').onDelete('cascade').notNull(), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade').notNull(), + ) + // The rendered Markdown of the page at the snapshot instant (exportPageMarkdown). + .addColumn('content_md', 'text', (col) => col.notNull()) + // The page's updated_at at the snapshot instant. The next turn compares this + // against the live page.updated_at as a cheap fast path: equal => nothing + // changed, skip the render + diff entirely. + .addColumn('page_updated_at', 'timestamptz', (col) => col.notNull()) + // Optional content fingerprint (informational; the updated_at fast path is the + // primary change signal). Nullable so a snapshot can be written without one. + .addColumn('content_hash', 'varchar', (col) => col) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addColumn('updated_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .addUniqueConstraint('uq_ai_chat_page_snapshots_chat_page', [ + 'chat_id', + 'page_id', + ]) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('ai_chat_page_snapshots').execute(); +} diff --git a/apps/server/src/database/repos/ai-chat/ai-chat-page-snapshot.repo.spec.ts b/apps/server/src/database/repos/ai-chat/ai-chat-page-snapshot.repo.spec.ts new file mode 100644 index 00000000..f26f9303 --- /dev/null +++ b/apps/server/src/database/repos/ai-chat/ai-chat-page-snapshot.repo.spec.ts @@ -0,0 +1,142 @@ +import { AiChatPageSnapshotRepo } from './ai-chat-page-snapshot.repo'; +import type { KyselyDB } from '../../types/kysely.types'; + +/** + * Unit tests for AiChatPageSnapshotRepo (#274). These build the scoping / + * conflict query, so we assert the EXACT predicates + upsert shape over a + * chainable builder mock (no live DB): findByChatPage scopes chat + page + + * workspace; upsert writes the values, targets the (chatId, pageId) conflict key, + * and updates content/updatedAt on conflict. A live-Postgres round trip is out of + * scope for this pure unit test. + */ +describe('AiChatPageSnapshotRepo', () => { + type Recorded = { + table?: string; + wheres: Array<[string, string, unknown]>; + values?: Record; + conflictColumns?: string[]; + conflictUpdate?: Record; + }; + + function makeDb(result: unknown): { db: KyselyDB; rec: Recorded } { + const rec: Recorded = { wheres: [] }; + const builder: Record = {}; + const chain = () => builder; + builder.selectAll = chain; + builder.returningAll = chain; + builder.where = (col: string, op: string, val: unknown) => { + rec.wheres.push([col, op, val]); + return builder; + }; + builder.values = (v: Record) => { + rec.values = v; + return builder; + }; + builder.onConflict = ( + cb: (oc: { + columns: (c: string[]) => { doUpdateSet: (s: Record) => unknown }; + }) => unknown, + ) => { + cb({ + columns: (c: string[]) => { + rec.conflictColumns = c; + return { + doUpdateSet: (s: Record) => { + rec.conflictUpdate = s; + return builder; + }, + }; + }, + }); + return builder; + }; + builder.executeTakeFirst = () => Promise.resolve(result); + const db = { + selectFrom: (table: string) => { + rec.table = table; + return builder; + }, + insertInto: (table: string) => { + rec.table = table; + return builder; + }, + } as unknown as KyselyDB; + return { db, rec }; + } + + describe('findByChatPage', () => { + it('scopes by chat + page + workspace and returns the row', async () => { + const row = { id: 's1', chatId: 'c1', pageId: 'p1', workspaceId: 'ws1' }; + const { db, rec } = makeDb(row); + const repo = new AiChatPageSnapshotRepo(db); + + const res = await repo.findByChatPage('c1', 'p1', 'ws1'); + + expect(res).toBe(row); + expect(rec.table).toBe('aiChatPageSnapshots'); + expect(rec.wheres).toEqual([ + ['chatId', '=', 'c1'], + ['pageId', '=', 'p1'], + ['workspaceId', '=', 'ws1'], + ]); + }); + + it('returns undefined when no snapshot exists yet', async () => { + const { db } = makeDb(undefined); + const repo = new AiChatPageSnapshotRepo(db); + await expect( + repo.findByChatPage('c1', 'p1', 'ws1'), + ).resolves.toBeUndefined(); + }); + }); + + describe('upsert', () => { + it('inserts the values and upserts on the (chatId, pageId) key', async () => { + const { db, rec } = makeDb({ id: 's1' }); + const repo = new AiChatPageSnapshotRepo(db); + const pageUpdatedAt = new Date('2026-07-02T10:00:00Z'); + + await repo.upsert({ + chatId: 'c1', + pageId: 'p1', + workspaceId: 'ws1', + contentMd: '# hello', + pageUpdatedAt, + contentHash: 'abc', + }); + + expect(rec.table).toBe('aiChatPageSnapshots'); + expect(rec.values).toEqual({ + chatId: 'c1', + pageId: 'p1', + workspaceId: 'ws1', + contentMd: '# hello', + pageUpdatedAt, + contentHash: 'abc', + }); + expect(rec.conflictColumns).toEqual(['chatId', 'pageId']); + expect(rec.conflictUpdate).toMatchObject({ + contentMd: '# hello', + pageUpdatedAt, + contentHash: 'abc', + }); + expect(rec.conflictUpdate?.updatedAt).toBeInstanceOf(Date); + }); + + it('defaults a missing content hash to null (insert and conflict update)', async () => { + const { db, rec } = makeDb({ id: 's1' }); + const repo = new AiChatPageSnapshotRepo(db); + + await repo.upsert({ + chatId: 'c1', + pageId: 'p1', + workspaceId: 'ws1', + contentMd: 'body', + pageUpdatedAt: new Date('2026-07-02T10:00:00Z'), + }); + + expect(rec.values?.contentHash).toBeNull(); + expect(rec.conflictUpdate?.contentHash).toBeNull(); + }); + }); +}); diff --git a/apps/server/src/database/repos/ai-chat/ai-chat-page-snapshot.repo.ts b/apps/server/src/database/repos/ai-chat/ai-chat-page-snapshot.repo.ts new file mode 100644 index 00000000..c4ebf766 --- /dev/null +++ b/apps/server/src/database/repos/ai-chat/ai-chat-page-snapshot.repo.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; +import { KyselyDB, KyselyTransaction } from '../../types/kysely.types'; +import { dbOrTx } from '../../utils'; +import { AiChatPageSnapshot } from '@docmost/db/types/entity.types'; + +/** + * Repository for the per-(chat,page) Markdown snapshot taken at the end of the + * agent's previous turn (#274). Diffing the current page against this snapshot + * tells the agent what a human changed between turns, so it doesn't overwrite + * those edits. There is at most one live row per (chatId, pageId) — the turn-end + * write is an upsert on that unique key. Every lookup is workspace-scoped as + * defense-in-depth (the chat/page ids are already tenant-owned by the caller). + */ +@Injectable() +export class AiChatPageSnapshotRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + /** + * The current snapshot for a (chat, page) pair, or undefined when none exists + * yet (first turn on that page). Workspace-scoped so a foreign chat/page id can + * never surface another tenant's snapshot. + */ + async findByChatPage( + chatId: string, + pageId: string, + workspaceId: string, + ): Promise { + return this.db + .selectFrom('aiChatPageSnapshots') + .selectAll('aiChatPageSnapshots') + .where('chatId', '=', chatId) + .where('pageId', '=', pageId) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst(); + } + + /** + * Write the turn-end snapshot for a (chat, page) pair. Inserts on the first + * turn and overwrites the content/updatedAt on later turns (upsert on the + * UNIQUE(chatId, pageId) key). The agent's own edits this turn are baked into + * `contentMd`, which is exactly why the next turn's diff isolates human edits. + */ + async upsert( + values: { + chatId: string; + pageId: string; + workspaceId: string; + contentMd: string; + pageUpdatedAt: Date; + contentHash?: string | null; + }, + trx?: KyselyTransaction, + ): Promise { + const db = dbOrTx(this.db, trx); + return db + .insertInto('aiChatPageSnapshots') + .values({ + chatId: values.chatId, + pageId: values.pageId, + workspaceId: values.workspaceId, + contentMd: values.contentMd, + pageUpdatedAt: values.pageUpdatedAt, + contentHash: values.contentHash ?? null, + }) + .onConflict((oc) => + oc.columns(['chatId', 'pageId']).doUpdateSet({ + contentMd: values.contentMd, + pageUpdatedAt: values.pageUpdatedAt, + contentHash: values.contentHash ?? null, + updatedAt: new Date(), + }), + ) + .returningAll() + .executeTakeFirst(); + } +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 462a9349..89f24053 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -644,6 +644,24 @@ export interface AiChatMessages { deletedAt: Timestamp | null; } +// Per-(chat,page) snapshot of the open page's Markdown at the END of the agent's +// previous turn (#274). Mirrors migration 20260702T120000-ai-chat-page-snapshot.ts. +// The next turn diffs the CURRENT Markdown against `contentMd` to surface edits a +// human made between turns; `pageUpdatedAt` is the cheap "did anything change?" +// fast path. One live row per (chatId, pageId) — the turn-end write upserts on +// that key. Both FKs are ON DELETE CASCADE (derived, per-chat state). +export interface AiChatPageSnapshots { + id: Generated; + chatId: string; + pageId: string; + workspaceId: string; + contentMd: string; + pageUpdatedAt: Timestamp; + contentHash: string | null; + createdAt: Generated; + updatedAt: Generated; +} + export interface UserSessions { id: Generated; userId: string; @@ -663,6 +681,7 @@ export interface DB { aiAgentRoles: AiAgentRoles; aiChats: AiChats; aiChatMessages: AiChatMessages; + aiChatPageSnapshots: AiChatPageSnapshots; apiKeys: ApiKeys; attachments: Attachments; audit: Audit; diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index 36f9be46..25f2cde6 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -3,6 +3,7 @@ import { AiAgentRoles, AiChats, AiChatMessages, + AiChatPageSnapshots, Attachments, Comments, Groups, @@ -60,6 +61,15 @@ export type InsertableAiChatMessage = Omit< 'tsv' >; +// AI Chat Page Snapshot (#274): per-(chat,page) Markdown snapshot taken at the +// end of the agent's previous turn, diffed against the current page next turn to +// detect human edits made between turns. +export type AiChatPageSnapshot = Selectable; +export type InsertableAiChatPageSnapshot = Insertable; +export type UpdatableAiChatPageSnapshot = Updateable< + Omit +>; + // AI Provider Credentials // SECURITY (D9/§8.1): holds encrypted per-workspace provider API keys. // Never expose this table through workspace endpoints. diff --git a/apps/server/test/integration/ai-chat-stream.int-spec.ts b/apps/server/test/integration/ai-chat-stream.int-spec.ts index 4c630e86..103f4334 100644 --- a/apps/server/test/integration/ai-chat-stream.int-spec.ts +++ b/apps/server/test/integration/ai-chat-stream.int-spec.ts @@ -135,6 +135,9 @@ describe('AiChatService.stream [integration]', () => { { getChatModel: async () => null } as any, aiChatRepo, msgRepo, + // aiChatPageSnapshotRepo (#274) — no open page in this harness, so the + // detection/snapshot cycle never touches it; a stub is enough. + {} as any, // aiSettings.resolve — no admin system prompt / context window. { resolve: async () => null } as any, // tools.forUser — no Docmost tools for this harness.