Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/__tests__/document-dedup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Document Deduplication Tests
*
* Tests for deduplicateDocuments() which prevents duplicate PDF documents
* from exceeding the Anthropic API 30MB message size limit.
*
* Bug: In a thread where multiple messages quote/reply-to the same PDF file,
* each message independently downloads that PDF. Without dedup, the same 4.4MB
* PDF could be included 8+ times, pushing the payload to 36MB and triggering
* "message size exceeds 30.000MB limit" error.
*/
import { describe, it, expect } from 'vitest';
import { deduplicateDocuments } from '../feishu/event-handler.js';
import type { DocumentAttachment } from '../claude/types.js';

// ============================================================
// Helpers
// ============================================================

function makePdf(fileName: string, sizeBytes: number): DocumentAttachment {
// base64 encoded size is ~4/3 of raw, but we use string length as proxy
return {
data: 'A'.repeat(sizeBytes),
mediaType: 'application/pdf',
fileName,
};
}

// ============================================================
// Tests
// ============================================================

describe('deduplicateDocuments', () => {
it('removes duplicate documents by fileName', () => {
const pdf1 = makePdf('resume.pdf', 1000);
const pdf2 = makePdf('resume.pdf', 1000); // same name
const pdf3 = makePdf('report.pdf', 2000);

const result = deduplicateDocuments([pdf1, pdf2, pdf3]);

expect(result).toHaveLength(2);
expect(result[0].fileName).toBe('resume.pdf');
expect(result[1].fileName).toBe('report.pdf');
});

it('preserves order — first occurrence wins', () => {
const current = makePdf('吴亮.pdf', 5000);
const history = makePdf('吴亮.pdf', 5000);

// Current message doc comes first (higher priority)
const result = deduplicateDocuments([current, history]);

expect(result).toHaveLength(1);
expect(result[0]).toBe(current); // same reference
});

it('enforces total size limit', () => {
const big1 = makePdf('big1.pdf', 12 * 1024 * 1024); // 12MB
const big2 = makePdf('big2.pdf', 12 * 1024 * 1024); // 12MB — would exceed 20MB total
const small = makePdf('small.pdf', 100);

const result = deduplicateDocuments([big1, big2, small]);

expect(result).toHaveLength(2);
expect(result[0].fileName).toBe('big1.pdf');
expect(result[1].fileName).toBe('small.pdf'); // big2 skipped, small fits
});

it('returns empty array for empty input', () => {
expect(deduplicateDocuments([])).toEqual([]);
});

it('returns single doc unchanged', () => {
const pdf = makePdf('only.pdf', 500);
const result = deduplicateDocuments([pdf]);
expect(result).toHaveLength(1);
expect(result[0]).toBe(pdf);
});

it('handles the exact bug scenario: same PDF from quoted parent + history', () => {
// This is the exact scenario that caused the 36MB error:
// Same "吴亮.pdf" (4.4MB) downloaded from both quoted parent and history
const fromQuotedParent = makePdf('吴亮 .pdf', 4_425_740);
const fromHistory = makePdf('吴亮 .pdf', 4_425_740);

const result = deduplicateDocuments([fromQuotedParent, fromHistory]);

expect(result).toHaveLength(1);
expect(result[0]).toBe(fromQuotedParent);
});
});
54 changes: 49 additions & 5 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1238,6 +1238,41 @@ function isTextFile(fileName: string): boolean {
/** 最多从历史消息中下载的文件数 */
const MAX_HISTORY_FILES = 3;

/**
* 文档 payload 大小上限(base64 字节数)。
* Anthropic API 的 message size 上限为 30MB,预留 10MB 给 system prompt + 对话历史 + 图片等。
*/
const MAX_TOTAL_DOCUMENT_BYTES = 20 * 1024 * 1024;

/**
* 按 fileName 去重 + 总大小截断,防止重复文档撑爆 API 30MB 限制。
* 优先保留靠前的文档(当前消息 > 历史消息)。
*/
export function deduplicateDocuments(docs: DocumentAttachment[]): DocumentAttachment[] {
const seen = new Set<string>();
const result: DocumentAttachment[] = [];
let totalBytes = 0;
for (const doc of docs) {
const key = doc.fileName;
if (seen.has(key)) {
logger.info({ fileName: doc.fileName }, 'Skipping duplicate document');
continue;
}
const docBytes = doc.data.length; // base64 string length ≈ bytes
if (totalBytes + docBytes > MAX_TOTAL_DOCUMENT_BYTES) {
logger.warn({ fileName: doc.fileName, totalBytes, docBytes, limit: MAX_TOTAL_DOCUMENT_BYTES }, 'Document payload size limit reached, skipping');
continue;
}
seen.add(key);
result.push(doc);
totalBytes += docBytes;
}
if (result.length < docs.length) {
logger.info({ original: docs.length, deduplicated: result.length, totalBytes }, 'Documents deduplicated');
}
return result;
}

/**
* 从历史消息中下载文件附件(PDF → DocumentAttachment, 文本类 → 嵌入文本)
*
Expand All @@ -1256,7 +1291,15 @@ async function downloadHistoryFiles(
}
if (refs.length === 0) return { documents: [], fileTexts: [] };

const toDownload = refs.slice(-MAX_HISTORY_FILES);
// 按 fileKey 去重(同一文件可能在多条历史消息中出现,如话题内引用同一文件)
const seenKeys = new Set<string>();
const uniqueRefs = refs.filter(ref => {
if (seenKeys.has(ref.fileKey)) return false;
seenKeys.add(ref.fileKey);
return true;
});

const toDownload = uniqueRefs.slice(-MAX_HISTORY_FILES);
const MAX_PDF_SIZE = 30 * 1024 * 1024;
const MAX_TEXT_SIZE = 1 * 1024 * 1024;

Expand Down Expand Up @@ -1839,9 +1882,10 @@ export async function executeClaudeTask(
if (history.images && history.images.length > 0) {
images = [...(history.images), ...(images ?? [])];
}
// 合并历史消息中的文档(PDF)
// 合并历史消息中的文档(PDF),按 fileName 去重 + 大小截断
if (history.documents && history.documents.length > 0) {
documents = [...(history.documents), ...(documents ?? [])];
// 当前消息的文档优先(放前面),历史文档补充
documents = deduplicateDocuments([...(documents ?? []), ...(history.documents)]);
}
// 合并历史消息中的文本文件内容到 prompt
if (history.fileTexts && history.fileTexts.length > 0) {
Expand Down Expand Up @@ -2380,9 +2424,9 @@ export async function executeDirectTask(
if (history.images && history.images.length > 0) {
images = [...(history.images), ...(images ?? [])];
}
// 合并历史消息中的文档(PDF)
// 合并历史消息中的文档(PDF),按 fileName 去重 + 大小截断
if (history.documents && history.documents.length > 0) {
documents = [...(history.documents), ...(documents ?? [])];
documents = deduplicateDocuments([...(documents ?? []), ...(history.documents)]);
}
// 合并历史消息中的文本文件内容到 prompt
if (history.fileTexts && history.fileTexts.length > 0) {
Expand Down
Loading