-
Notifications
You must be signed in to change notification settings - Fork 2
feat: 大文本文件 lazy loading + 24h 文件缓存 #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| /** | ||
| * Tests for lazy loading of large text files. | ||
| * | ||
| * Small text files (≤64KB) are embedded inline in the prompt. | ||
| * Large text files (>64KB) are saved to a cache dir and the prompt only | ||
| * carries a path pointer + instructions to use the Read tool's offset/limit. | ||
| * | ||
| * Covers both: shared file-cache helpers and the history-file processor. | ||
| */ | ||
| // @ts-nocheck — test file | ||
| import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; | ||
| import { mkdtempSync, rmSync, statSync, existsSync, utimesSync } from 'fs'; | ||
| import { tmpdir } from 'os'; | ||
| import { join } from 'path'; | ||
|
|
||
| const mockDownloadMessageFile = vi.fn(); | ||
|
|
||
| vi.mock('../feishu/client.js', () => ({ | ||
| feishuClient: { | ||
| downloadMessageFile: (...args: unknown[]) => mockDownloadMessageFile(...args), | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock('../utils/logger.js', () => ({ | ||
| logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, | ||
| })); | ||
|
|
||
| import { saveMessageFileToCache, cleanupOldDownloads, DOWNLOAD_DIR } from '../feishu/file-cache.js'; | ||
| import { _testDownloadHistoryFiles as downloadHistoryFiles } from '../feishu/event-handler.js'; | ||
|
|
||
| describe('file-cache', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| // Best-effort cleanup of any leftovers from this test | ||
| try { | ||
| rmSync(DOWNLOAD_DIR, { recursive: true, force: true }); | ||
| } catch { | ||
| /* ignore */ | ||
| } | ||
| }); | ||
|
|
||
| it('saveMessageFileToCache writes buffer and returns absolute path', async () => { | ||
| const buf = Buffer.from('hello-content'); | ||
| const filePath = await saveMessageFileToCache('om_test_save', 'file_test_save', buf, 'notes.txt'); | ||
|
|
||
| expect(filePath.startsWith(DOWNLOAD_DIR)).toBe(true); | ||
| expect(filePath.endsWith('.txt')).toBe(true); | ||
| expect(existsSync(filePath)).toBe(true); | ||
| expect(statSync(filePath).size).toBe(buf.length); | ||
| }); | ||
|
|
||
| it('saveMessageFileToCache sanitizes filename, drops weird extensions', async () => { | ||
| const buf = Buffer.from('x'); | ||
| const filePath = await saveMessageFileToCache('om_a/b', 'file_c:d', buf, 'weird.ext-with-bad-stuff!!'); | ||
| // No slashes/colons leak through; weird ext is dropped | ||
| const base = filePath.slice(DOWNLOAD_DIR.length + 1); | ||
| expect(base).not.toMatch(/[/:!]/); | ||
| expect(base.endsWith('.ext-with-bad-stuff!!')).toBe(false); | ||
| }); | ||
|
|
||
| it('cleanupOldDownloads removes files older than cutoff, keeps fresh ones', async () => { | ||
| const fresh = await saveMessageFileToCache('om_fresh', 'file_fresh', Buffer.from('new'), 'fresh.txt'); | ||
| const stale = await saveMessageFileToCache('om_stale', 'file_stale', Buffer.from('old'), 'stale.txt'); | ||
| // Backdate stale file by 25 hours | ||
| const oldTime = (Date.now() - 25 * 60 * 60 * 1000) / 1000; | ||
| utimesSync(stale, oldTime, oldTime); | ||
|
|
||
| const cleaned = await cleanupOldDownloads(24 * 60 * 60 * 1000); | ||
| expect(cleaned).toBeGreaterThanOrEqual(1); | ||
| expect(existsSync(stale)).toBe(false); | ||
| expect(existsSync(fresh)).toBe(true); | ||
| }); | ||
|
|
||
| it('cleanupOldDownloads is a no-op when dir does not exist', async () => { | ||
| const tmp = mkdtempSync(join(tmpdir(), 'fc-empty-')); | ||
| rmSync(tmp, { recursive: true, force: true }); | ||
| // Different dir — cleanup on DOWNLOAD_DIR shouldn't throw even if missing | ||
| const cleaned = await cleanupOldDownloads(1); | ||
| expect(typeof cleaned).toBe('number'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('downloadHistoryFiles lazy text threshold', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| function makeMsg(id: string, fileRefs?: Array<{ fileKey: string; fileName: string }>) { | ||
| return { messageId: id, ...(fileRefs ? { fileRefs } : {}) }; | ||
| } | ||
|
|
||
| it('embeds small text files (≤64KB) inline', async () => { | ||
| const small = Buffer.from('console.log("tiny")'); | ||
| mockDownloadMessageFile.mockResolvedValue(small); | ||
|
|
||
| const result = await downloadHistoryFiles([ | ||
| makeMsg('t1', [{ fileKey: 'fk_small', fileName: 'tiny.ts' }]), | ||
| ], 0); | ||
|
|
||
| expect(result.fileTexts).toHaveLength(1); | ||
| expect(result.fileTexts[0]).toContain('console.log("tiny")'); | ||
| expect(result.fileTexts[0]).not.toContain('Read 工具'); | ||
| }); | ||
|
|
||
| it('writes large text files (>64KB) to cache and injects path metadata only', async () => { | ||
| const big = Buffer.alloc(80 * 1024, 'a'); // 80KB | ||
| mockDownloadMessageFile.mockResolvedValue(big); | ||
|
|
||
| const result = await downloadHistoryFiles([ | ||
| makeMsg('t_big', [{ fileKey: 'fk_big', fileName: 'big.log' }]), | ||
| ], 0); | ||
|
|
||
| expect(result.fileTexts).toHaveLength(1); | ||
| const meta = result.fileTexts[0]; | ||
| expect(meta).toContain('big.log'); | ||
| expect(meta).toContain('Read 工具'); | ||
| expect(meta).toContain(DOWNLOAD_DIR); | ||
| // raw content is NOT embedded | ||
| expect(meta).not.toContain('aaaa'); | ||
|
|
||
| // The path mentioned in metadata should actually exist on disk | ||
| const match = meta.match(/已保存到本地: (\S+)/); | ||
| expect(match).not.toBeNull(); | ||
| expect(existsSync(match![1])).toBe(true); | ||
| }); | ||
|
|
||
| it('drops history text files above 30MB hard cap', async () => { | ||
| const huge = Buffer.alloc(31 * 1024 * 1024, 'a'); | ||
| mockDownloadMessageFile.mockResolvedValue(huge); | ||
|
|
||
| const result = await downloadHistoryFiles([ | ||
| makeMsg('t_huge', [{ fileKey: 'fk_huge', fileName: 'huge.log' }]), | ||
| ], 0); | ||
|
|
||
| expect(result.fileTexts).toHaveLength(0); | ||
| expect(result.documents).toHaveLength(0); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import { writeFile, mkdir, readdir, stat, unlink } from 'fs/promises'; | ||
| import { join, extname } from 'path'; | ||
| import { tmpdir } from 'os'; | ||
| import { logger } from '../utils/logger.js'; | ||
|
|
||
| export const DOWNLOAD_DIR = join(tmpdir(), 'feishu-downloads'); | ||
|
|
||
| const SAFE_NAME_RE = /[^a-zA-Z0-9_-]/g; | ||
|
|
||
| /** | ||
| * 把飞书消息附件落盘到共享缓存目录,返回绝对路径。 | ||
| * | ||
| * 用于 lazy-loading 文本类附件:上传后只把路径注入 prompt,让 agent 用 Read 工具按需 offset/limit 读取。 | ||
| */ | ||
| export async function saveMessageFileToCache( | ||
| messageId: string, | ||
| fileKey: string, | ||
| buf: Buffer, | ||
| originalFileName?: string, | ||
| ): Promise<string> { | ||
| await mkdir(DOWNLOAD_DIR, { recursive: true }); | ||
| const base = `${messageId}-${fileKey}`.replace(SAFE_NAME_RE, '_'); | ||
| const ext = originalFileName ? extname(originalFileName).toLowerCase() : ''; | ||
| const safeExt = /^\.[a-z0-9]{1,8}$/.test(ext) ? ext : ''; | ||
| const filePath = join(DOWNLOAD_DIR, base + safeExt); | ||
| await writeFile(filePath, buf); | ||
| return filePath; | ||
| } | ||
|
|
||
| /** | ||
| * 清理 DOWNLOAD_DIR 中早于 maxAgeMs 的文件。默认 24 小时。 | ||
| * | ||
| * 由 index.ts 的周期性 cleanup interval 调用,硬盘充裕,给 agent 留足回头读的窗口。 | ||
| */ | ||
| export async function cleanupOldDownloads(maxAgeMs: number = 24 * 60 * 60 * 1000): Promise<number> { | ||
| let cleaned = 0; | ||
| let entries: string[]; | ||
| try { | ||
| entries = await readdir(DOWNLOAD_DIR); | ||
| } catch (err) { | ||
| if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0; | ||
| logger.warn({ err }, 'cleanupOldDownloads: readdir failed'); | ||
| return 0; | ||
| } | ||
|
|
||
| const cutoff = Date.now() - maxAgeMs; | ||
| await Promise.all(entries.map(async (name) => { | ||
| const filePath = join(DOWNLOAD_DIR, name); | ||
| try { | ||
| const st = await stat(filePath); | ||
| if (!st.isFile()) return; | ||
| if (st.mtimeMs < cutoff) { | ||
| await unlink(filePath); | ||
| cleaned += 1; | ||
| } | ||
| } catch (err) { | ||
| logger.debug({ err, filePath }, 'cleanupOldDownloads: stat/unlink failed'); | ||
| } | ||
| })); | ||
|
|
||
| if (cleaned > 0) logger.info({ cleaned, maxAgeMs }, 'Old feishu-downloads cleaned'); | ||
| return cleaned; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.