feat(ai-chat): notify the agent of user page edits between turns (closes #274)

The agent rebuilds context from DB each turn and didn't know the user manually
edited the open page since its last response, so it could overwrite those edits.
Add a per-turn ephemeral <page_changed> note in the system prompt (twin of
INTERRUPT_NOTE, self-clearing) carrying a unified Markdown diff of what changed
since the END of the agent's previous turn.

- New ai_chat_page_snapshots table (migration + hand-declared db.d.ts/entity
  types) storing the page Markdown per (chat,page) at each turn's end.
- Pure computePageChange util (whitespace-normalized unified diff via the
  existing jsdiff dep, 6KB cap + getPage hint).
- Turn start: if the open page's updatedAt moved past the snapshot, diff current
  vs snapshot; non-empty -> PAGE_CHANGED_NOTE in the safety sandwich.
- Turn end: upsert the snapshot on EVERY terminal path (onFinish/onError/onAbort,
  once) so the agent's own edits are excluded by construction even on aborted
  turns.
All best-effort (never breaks/latency-regresses a turn); fast path when updatedAt
is unchanged. Server-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent_coder
2026-07-02 01:54:00 +03:00
parent 2524f39a36
commit 8c5b57ebfa
16 changed files with 1074 additions and 23 deletions
@@ -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,
@@ -0,0 +1,55 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// 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<any>): Promise<void> {
await db.schema.dropTable('ai_chat_page_snapshots').execute();
}
@@ -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<string, unknown>;
conflictColumns?: string[];
conflictUpdate?: Record<string, unknown>;
};
function makeDb(result: unknown): { db: KyselyDB; rec: Recorded } {
const rec: Recorded = { wheres: [] };
const builder: Record<string, unknown> = {};
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<string, unknown>) => {
rec.values = v;
return builder;
};
builder.onConflict = (
cb: (oc: {
columns: (c: string[]) => { doUpdateSet: (s: Record<string, unknown>) => unknown };
}) => unknown,
) => {
cb({
columns: (c: string[]) => {
rec.conflictColumns = c;
return {
doUpdateSet: (s: Record<string, unknown>) => {
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();
});
});
});
@@ -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<AiChatPageSnapshot | undefined> {
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<AiChatPageSnapshot> {
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();
}
}
+19
View File
@@ -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<string>;
chatId: string;
pageId: string;
workspaceId: string;
contentMd: string;
pageUpdatedAt: Timestamp;
contentHash: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}
export interface UserSessions {
id: Generated<string>;
userId: string;
@@ -663,6 +681,7 @@ export interface DB {
aiAgentRoles: AiAgentRoles;
aiChats: AiChats;
aiChatMessages: AiChatMessages;
aiChatPageSnapshots: AiChatPageSnapshots;
apiKeys: ApiKeys;
attachments: Attachments;
audit: Audit;
@@ -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<AiChatPageSnapshots>;
export type InsertableAiChatPageSnapshot = Insertable<AiChatPageSnapshots>;
export type UpdatableAiChatPageSnapshot = Updateable<
Omit<AiChatPageSnapshots, 'id'>
>;
// AI Provider Credentials
// SECURITY (D9/§8.1): holds encrypted per-workspace provider API keys.
// Never expose this table through workspace endpoints.