From d35956b9e97fa8816c8c14d20d189d22cb504fee Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 11:30:46 +0300 Subject: [PATCH] perf(ai-chat): snapshotOpenPage fast-path (#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/core/ai-chat/ai-chat.service.spec.ts | 74 +++++++++++++++++++ .../src/core/ai-chat/ai-chat.service.ts | 15 ++++ 2 files changed, 89 insertions(+) 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 095bb34a..7c0e4273 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 @@ -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 }) + .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 }) + .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 }) + .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']); 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 80078827..8ab879ff 100644 --- a/apps/server/src/core/ai-chat/ai-chat.service.ts +++ b/apps/server/src/core/ai-chat/ai-chat.service.ts @@ -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,