From 0126b56648669526f79d915305dcf079270cb053 Mon Sep 17 00:00:00 2001 From: Sanssssssssssssssss <138884622+Sanssssssssssssssss@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:58:53 +0800 Subject: [PATCH] fix(ai): keep equal-timestamp compression boundaries intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文:避免同秒消息跨摘要边界,防止已压缩内容重复回放或后续消息丢失。 English: Keep equal-timestamp messages on one boundary side so compressed content is not replayed and later messages remain visible. --- .../compression/__tests__/compressor.test.ts | 85 +++++++++++++++++++ .../src/ai/compression/compressor.ts | 6 ++ 2 files changed, 91 insertions(+) diff --git a/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts b/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts index 89d60109..307eaf2e 100644 --- a/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts +++ b/packages/node-runtime/src/ai/compression/__tests__/compressor.test.ts @@ -26,6 +26,25 @@ function cleanup(dir: string): void { } } +function spreadMessageTimestamps(manager: AIChatManager, chatId: string): void { + const messages = manager.getMessages(chatId) + const baseTimestamp = Math.floor(Date.now() / 1000) - messages.length + messages.forEach((message, index) => { + manager.executeAiSQL(`UPDATE ai_message SET timestamp = ${baseTimestamp + index} WHERE id = '${message.id}'`) + }) +} + +function addMessageAt( + manager: AIChatManager, + chatId: string, + role: 'user' | 'assistant', + content: string, + timestamp: number +): void { + const message = manager.addMessage(chatId, role, content) + manager.executeAiSQL(`UPDATE ai_message SET timestamp = ${timestamp} WHERE id = '${message.id}'`) +} + const CONFIG: CompressionConfig = { tokenThresholdPercent: 75, bufferSizePercent: 20, @@ -74,10 +93,74 @@ function seedToolHeavyChat(manager: AIChatManager, withToolResults: boolean): st manager.addMessage(chat.id, 'assistant', 'more stats', undefined, undefined, [toolBlock('query_b', result2)]) manager.addMessage(chat.id, 'user', 'thanks') manager.addMessage(chat.id, 'assistant', 'you are welcome') + spreadMessageTimestamps(manager, chat.id) return chat.id } describe('checkAndCompress tool result token accounting', () => { + it('keeps equal-timestamp messages on the buffer side of the compression boundary', async () => { + const dir = createTempDir() + const manager = createManager(dir) + try { + const baseTimestamp = 2_000_000_000 + const chat = manager.createAIChat('session-1', 'Compression boundary', 'general_cn') + for (let index = 0; index < 4; index++) { + addMessageAt( + manager, + chat.id, + index % 2 === 0 ? 'user' : 'assistant', + `old-${index} ${'old '.repeat(150)}`, + baseTimestamp + index + ) + } + addMessageAt(manager, chat.id, 'user', `BOUNDARY_USER ${'user '.repeat(180)}`, baseTimestamp + 10) + addMessageAt(manager, chat.id, 'assistant', `BOUNDARY_ASSISTANT ${'assistant '.repeat(60)}`, baseTimestamp + 10) + + const adapter: CompressionLlmAdapter = { + contextWindow: 1000, + compress: async (prompt: string) => + prompt.includes('BOUNDARY_USER') ? 'SUMMARY_CONTAINS_BOUNDARY_USER' : 'SUMMARY_SAFE', + } + + const result = await checkAndCompress(chat.id, CONFIG, 'system', adapter, manager) + + assert.equal(result.compressed, true) + assert.deepEqual( + manager.getHistoryForAgent(chat.id).map((message) => message.content.split(' ')[0]), + ['SUMMARY_SAFE', 'BOUNDARY_USER', 'BOUNDARY_ASSISTANT'] + ) + + const largeChat = manager.createAIChat('session-1', 'Large compression boundary', 'general_cn') + for (let index = 0; index < 4; index++) { + addMessageAt( + manager, + largeChat.id, + index % 2 === 0 ? 'user' : 'assistant', + `old-${index} ${'old '.repeat(180)}`, + baseTimestamp + index + ) + } + addMessageAt(manager, largeChat.id, 'assistant', `LATEST_ASSISTANT ${'latest '.repeat(260)}`, baseTimestamp + 10) + const largeResult = await checkAndCompress( + largeChat.id, + CONFIG, + 'system', + createAdapter({ prompt: null }), + manager + ) + addMessageAt(manager, largeChat.id, 'user', 'NEXT_USER', baseTimestamp + 10) + + assert.equal(largeResult.compressed, true) + assert.deepEqual( + manager.getHistoryForAgent(largeChat.id).map((message) => message.content.split(' ')[0]), + ['COMPRESSED', 'LATEST_ASSISTANT', 'NEXT_USER'] + ) + } finally { + manager.close() + cleanup(dir) + } + }) + it('counts replayed tool results toward the compression threshold', async () => { const dir = createTempDir() const manager = createManager(dir) @@ -141,6 +224,7 @@ describe('checkAndCompress tool result token accounting', () => { ]) manager.addMessage(chat.id, 'user', 'thanks') manager.addMessage(chat.id, 'assistant', 'you are welcome') + spreadMessageTimestamps(manager, chat.id) const captured: { prompt: string | null } = { prompt: null } const result = await checkAndCompress(chat.id, CONFIG, 'system', createAdapter(captured), manager) @@ -177,6 +261,7 @@ describe('checkAndCompress tool result token accounting', () => { ]) manager.addMessage(chat.id, 'user', 'continue') manager.addMessage(chat.id, 'assistant', 'done') + spreadMessageTimestamps(manager, chat.id) const captured: { prompt: string | null } = { prompt: null } const result = await checkAndCompress(chat.id, CONFIG, 'system', createAdapter(captured), manager) diff --git a/packages/node-runtime/src/ai/compression/compressor.ts b/packages/node-runtime/src/ai/compression/compressor.ts index 6f808c34..60f4e652 100644 --- a/packages/node-runtime/src/ai/compression/compressor.ts +++ b/packages/node-runtime/src/ai/compression/compressor.ts @@ -274,6 +274,12 @@ function splitMessagesForCompression( } } + // Summary boundaries have second precision, so equal-timestamp messages must stay together. + if (splitIndex > 0) { + const boundaryTimestamp = messages[Math.min(splitIndex, messages.length - 1)]!.timestamp + while (splitIndex > 0 && messages[splitIndex - 1]!.timestamp === boundaryTimestamp) splitIndex-- + } + return { bufferMessages: messages.slice(splitIndex), messagesToCompress: messages.slice(0, splitIndex),