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:
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user