perf(ai-chat): snapshotOpenPage fast-path (#490)
snapshotOpenPage делал полный экспорт Markdown + upsert каждый ход. Fast-path: если снапшот уже существует на ТЕКУЩЕЙ версии страницы (тот же instant updated_at), его контент уже актуален — пропускаем экспорт+upsert целиком. Ход, не тронувший открытую страницу (частый случай), больше не делает работы по снапшоту. Зеркалит read-side fast-path в detectPageChange (sameInstant): оба доверяют, что правка страницы двигает updated_at. Когда агент/человек ПРАВИЛ страницу этим ходом, updated_at продвинулся → не совпадает → экспортируем как раньше (правки агента запекаются в снапшот, инвариант #274 сохранён). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -440,6 +440,80 @@ describe('lastAssistantContextTokens', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #490 snapshotOpenPage fast-path: skip the full Markdown export + upsert when a
|
||||
// snapshot already exists at the page's CURRENT version (same updated_at instant).
|
||||
describe('snapshotOpenPage fast-path (#490)', () => {
|
||||
function makeSvc(existingSnapshot: unknown, pageUpdatedAt: Date) {
|
||||
const exportPageMarkdown = jest.fn(async () => '# md');
|
||||
const upsert = jest.fn(async () => undefined);
|
||||
const findByChatPage = jest.fn(async () => existingSnapshot);
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: 'p1',
|
||||
workspaceId: 'ws1',
|
||||
updatedAt: pageUpdatedAt,
|
||||
})),
|
||||
};
|
||||
const svc = new AiChatService(
|
||||
{} as never, // ai
|
||||
{} as never, // aiChatRepo
|
||||
{} as never, // aiChatMessageRepo
|
||||
{ findByChatPage, upsert } as never, // aiChatPageSnapshotRepo
|
||||
{} as never, // aiSettings
|
||||
{ exportPageMarkdown } as never, // tools
|
||||
{} as never, // mcpClients
|
||||
{} as never, // aiAgentRoleRepo
|
||||
pageRepo as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
{} as never, // environment
|
||||
);
|
||||
return { svc, exportPageMarkdown, upsert, findByChatPage };
|
||||
}
|
||||
|
||||
const args = () =>
|
||||
[
|
||||
'chat1',
|
||||
'p1',
|
||||
{ id: 'ws1' } as never,
|
||||
{ id: 'u1' } as never,
|
||||
'sess',
|
||||
] as const;
|
||||
|
||||
it('skips export + upsert when the snapshot is already at this page version', async () => {
|
||||
const t = new Date('2026-07-07T10:00:00Z');
|
||||
const { svc, exportPageMarkdown, upsert } = makeSvc(
|
||||
{ pageUpdatedAt: t, contentMd: '# md' },
|
||||
t,
|
||||
);
|
||||
await (svc as unknown as { snapshotOpenPage: (...a: unknown[]) => Promise<void> })
|
||||
.snapshotOpenPage(...args());
|
||||
expect(exportPageMarkdown).not.toHaveBeenCalled();
|
||||
expect(upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exports + upserts when the page advanced since the snapshot', async () => {
|
||||
const { svc, exportPageMarkdown, upsert } = makeSvc(
|
||||
{ pageUpdatedAt: new Date('2026-07-07T10:00:00Z'), contentMd: 'old' },
|
||||
new Date('2026-07-07T11:00:00Z'),
|
||||
);
|
||||
await (svc as unknown as { snapshotOpenPage: (...a: unknown[]) => Promise<void> })
|
||||
.snapshotOpenPage(...args());
|
||||
expect(exportPageMarkdown).toHaveBeenCalledTimes(1);
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('seeds (exports + upserts) on the first turn (no snapshot yet)', async () => {
|
||||
const { svc, exportPageMarkdown, upsert } = makeSvc(
|
||||
undefined,
|
||||
new Date('2026-07-07T10:00:00Z'),
|
||||
);
|
||||
await (svc as unknown as { snapshotOpenPage: (...a: unknown[]) => Promise<void> })
|
||||
.snapshotOpenPage(...args());
|
||||
expect(exportPageMarkdown).toHaveBeenCalledTimes(1);
|
||||
expect(upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// #490 deferred-tool activation persisted across turns.
|
||||
describe('seedActivatedTools', () => {
|
||||
const valid = new Set(['Search_web', 'getPageJson', 'diffPageVersions']);
|
||||
|
||||
@@ -897,6 +897,21 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
|
||||
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;
|
||||
// Fast-path (#490): if a snapshot already exists at THIS page version
|
||||
// (same updated_at instant), its content is already current — skip the full
|
||||
// Markdown export + upsert entirely. A turn that did NOT touch the open page
|
||||
// (the common case) thus does no snapshot work. This mirrors the read-side
|
||||
// fast path in detectPageChange (sameInstant): both trust that a page edit
|
||||
// bumps updated_at. When the agent (or a human) DID edit the page this turn,
|
||||
// updated_at advanced, so this does not match and we re-export as before.
|
||||
const existing = await this.aiChatPageSnapshotRepo.findByChatPage(
|
||||
chatId,
|
||||
pageId,
|
||||
workspace.id,
|
||||
);
|
||||
if (existing && sameInstant(existing.pageUpdatedAt, freshPage.updatedAt)) {
|
||||
return;
|
||||
}
|
||||
const currentMd = await this.tools.exportPageMarkdown(
|
||||
user,
|
||||
sessionId,
|
||||
|
||||
Reference in New Issue
Block a user