import assert from 'node:assert/strict' import { promises as fs } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { artifactPathKey, classifyArtifact, extractArtifactCandidates, indexArtifactSources, type ArtifactSource } from '../src/shared/artifact-index' import type { LibraryArtifactEntry } from '../src/shared/library' import type { ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types' import { filterLibraryEntries, formatLibraryBytes, groupLibraryEntries, listLibraryWorkspaces, sortLibraryEntries } from '../src/renderer/src/library/catalog' import { FileArtifactIndexRepository, probeArtifactPath } from '../src/main/library/artifact-repository' import { ArtifactIndexService } from '../src/main/library/artifact-service' import { LibraryService } from '../src/main/library/service' import { JsonLibraryUserStateRepository } from '../src/main/library/user-state' import { collectWorkbenchArtifacts } from '../src/renderer/src/workbuddy/workbench/workbench-state' function tool( id: string, name: string, args: Record, status: ToolCallRecord['status'] = 'ok' ): ToolCallRecord { return { id, name, args, argsRaw: JSON.stringify(args), status } } function message( id: string, createdAt: number, input: Partial> = {} ): ChatMessage { return { id, role: 'assistant', content: '', createdAt, ...input } } async function writeFixture(path: string, content: string): Promise { await fs.mkdir(dirname(path), { recursive: true }) await fs.writeFile(path, content, 'utf8') } async function main(): Promise { const root = await fs.mkdtemp(join(tmpdir(), 'ycode-library-regression-')) let outsideRoot = '' try { outsideRoot = await fs.mkdtemp(join(tmpdir(), 'ycode-library-outside-')) const reportPath = join(root, 'report.md') const tablePath = join(root, 'charts', 'table.csv') const resultPath = join(root, 'notes', 'result.md') const nativeWritePath = join(root, 'native', 'native-write.md') const missingPath = join(root, 'missing.md') await writeFixture(reportPath, '# Updated report\n') await writeFixture(tablePath, 'name,value\na,1\n') await writeFixture(resultPath, 'written result') await writeFixture(nativeWritePath, 'native OMP write') const messagesA: ChatMessage[] = [ message('user-presentation', 500, { role: 'user', parts: [{ type: 'artifact', artifact: { id: 'user-artifact', name: '用户展示块', path: 'user-visible.md' } }] }), message('m1', 1_000, { parts: [{ type: 'artifact', artifact: { id: 'report-artifact', name: '旧报告', path: 'report.md' } }] }), message('m2', 2_000, { tools: [ tool('present-ok', 'present_files', { files: [{ path: 'charts/table.csv' }, { path: 'missing.md' }] }), tool('write-ok', 'write_to_file', { file_path: 'notes/result.md', content: 'written result' }), tool('read-ok', 'read_file', { path: 'notes/result.md' }), tool('write-failed', 'write_to_file', { file_path: 'failed.md', content: 'must not index' }, 'error') ], attachments: [{ id: 'attachment', name: 'attachment.md', path: 'attachment.md', mimeType: 'text/markdown', size: 12 }], resources: ['attachment.md', 'notes/result.md'] }), message('m3', 3_000, { tools: [ tool('write-newer', 'write_to_file', { file_path: 'report.md', content: '# Updated report\n' }) ] }) ] const messagesB: ChatMessage[] = [ message('m4', 2_500, { parts: [{ type: 'artifact', artifact: { id: 'report-b', name: '任务 B 报告', path: 'report.md' } }] }) ] const sessionA: ArtifactSource = { id: 'session-a', title: '任务 A', createdAt: 100, updatedAt: 3_000, workspaceId: 'workspace-a', projectId: 'project-a', cwd: root, messages: messagesA } const sessionB: ArtifactSource = { id: 'session-b', title: '任务 B', createdAt: 200, updatedAt: 2_500, workspaceId: 'workspace-a', cwd: root, messages: messagesB } const candidatesA = extractArtifactCandidates(messagesA, { sessionId: sessionA.id, cwd: root, workspaceId: sessionA.workspaceId, projectId: sessionA.projectId, sourceSessionTitle: sessionA.title }) assert.equal(candidatesA.length, 4, '只有显式 artifact、成功 present_files 和成功 write 应被提取') const candidateReport = candidatesA.find((item) => artifactPathKey(item.path) === artifactPathKey(reportPath)) assert.equal(candidateReport?.sourceKind, 'write', '同任务同路径采用最新来源') assert.equal(candidateReport?.messageId, 'm3') assert.equal(candidateReport?.workspaceId, 'workspace-a') assert.equal(candidateReport?.projectId, 'project-a') assert.equal(candidatesA.some((item) => item.path.endsWith('failed.md')), false) assert.equal(candidatesA.some((item) => item.path.endsWith('attachment.md')), false) assert.equal(candidatesA.some((item) => item.path.endsWith('user-visible.md')), false) const workbench = collectWorkbenchArtifacts(messagesA, root) assert.equal(workbench.artifacts.length, 2, '工作台非改动产物应复用同一白名单') assert.equal(workbench.changes.length, 2, '工作台写编辑产物应复用同一白名单') const entries = await indexArtifactSources([sessionA, sessionB], probeArtifactPath) assert.equal(entries.length, 5, '任务 A 四个产物与任务 B 同路径产物必须分开保留') const reportEntries = entries.filter((entry) => artifactPathKey(entry.path) === artifactPathKey(reportPath)) assert.equal(reportEntries.length, 2, '跨任务同路径不能合并') assert.deepEqual(new Set(reportEntries.map((entry) => entry.sessionId)), new Set(['session-a', 'session-b'])) assert.notEqual(reportEntries[0].id, reportEntries[1].id) const reportA = entries.find((entry) => entry.sessionId === 'session-a' && artifactPathKey(entry.path) === artifactPathKey(reportPath)) assert.equal(reportA?.availability, 'available') assert.equal(reportA?.size, Buffer.byteLength('# Updated report\n')) assert.equal(reportA?.sourceSessionTitle, '任务 A') assert.equal(reportA?.workspaceRoot, root) const missing = entries.find((entry) => artifactPathKey(entry.path) === artifactPathKey(missingPath)) assert.equal(missing?.availability, 'unavailable', '无法访问文件必须保留索引并标记不可用') assert.equal(classifyArtifact('table.csv'), 'spreadsheet') assert.equal(classifyArtifact('README.md'), 'document') assert.equal(classifyArtifact('app.ts'), 'code') const nativeWriteMessages = [message('native-write-message', 3_500, { tools: [tool('native-write', 'write', { path: 'native/native-write.md', content: 'native OMP write' })] })] const nativeCandidates = extractArtifactCandidates(nativeWriteMessages, { sessionId: 'native-write-session', cwd: root, sourceSessionTitle: '原生写入任务' }) assert.equal(nativeCandidates.length, 1, '小写 OMP write 应经 Write 别名规范化后提取') assert.equal(nativeCandidates[0].sourceKind, 'write') assert.equal(nativeCandidates[0].sourceToolCallId, 'native-write') assert.equal(artifactPathKey(nativeCandidates[0].path), artifactPathKey(nativeWritePath)) const nativeEntries = await indexArtifactSources([{ id: 'native-write-session', title: '原生写入任务', createdAt: 350, updatedAt: 3_500, cwd: root, messages: nativeWriteMessages }], probeArtifactPath) assert.equal(nativeEntries.length, 1) assert.equal(nativeEntries[0].availability, 'available') const directOutsidePath = join(outsideRoot, 'direct.md') const linkedOutsidePath = join(outsideRoot, 'linked.md') const insideDirectory = join(root, 'safe-inside') const insidePath = join(insideDirectory, 'inside.md') await writeFixture(directOutsidePath, 'outside') await writeFixture(linkedOutsidePath, 'must stay unavailable') await writeFixture(insidePath, 'safe') const escapeLink = join(root, 'escape-link') const safeLink = join(root, 'safe-link') await fs.symlink(outsideRoot, escapeLink, process.platform === 'win32' ? 'junction' : 'dir') await fs.symlink(insideDirectory, safeLink, process.platform === 'win32' ? 'junction' : 'dir') const unreferencedDirectory = join(root, 'large-unreferenced-tree') await fs.mkdir(unreferencedDirectory, { recursive: true }) await Promise.all(Array.from({ length: 64 }, (_, index) => fs.writeFile(join(unreferencedDirectory, `source-${index}.ts`), `export const value = ${index}\n`, 'utf8') )) const securitySession: ArtifactSource = { id: 'security-session', title: '边界检查任务', createdAt: 400, updatedAt: 4_000, workspaceId: 'workspace-a', cwd: root, messages: [message('security-message', 4_000, { tools: [tool('security-present', 'present_files', { files: [ { path: directOutsidePath }, { path: join(escapeLink, 'linked.md') }, { path: join(safeLink, 'inside.md') } ] })] })] } let securityProbeCalls = 0 const securityEntries = await indexArtifactSources([securitySession], async (artifactPath, context) => { securityProbeCalls += 1 return probeArtifactPath(artifactPath, context) }) assert.equal(securityEntries.length, 3, '大目录中未引用文件不能被扫描为产物') assert.equal(securityProbeCalls, 3, '每个索引条目只探测自身路径') assert.equal( securityEntries.find((entry) => entry.path === directOutsidePath)?.availability, 'unavailable', '来源 cwd 之外的显式路径必须不可用' ) assert.equal( securityEntries.find((entry) => entry.path === join(escapeLink, 'linked.md'))?.availability, 'unavailable', '指向 cwd 之外的目录链接必须不可用' ) assert.equal( securityEntries.find((entry) => entry.path === join(safeLink, 'inside.md'))?.availability, 'available', '仍位于来源 cwd realpath 内的链接应可用' ) const userStatePath = join(root, 'state', 'library-state.json') const userState = new JsonLibraryUserStateRepository(userStatePath) const repository = new FileArtifactIndexRepository(probeArtifactPath) const artifactIndex = new ArtifactIndexService({ repository, listSourceSessions: async () => [sessionA, sessionB] satisfies SessionMeta[], getMessages: async (sessionId) => sessionId === sessionA.id ? messagesA : messagesB }) const library = new LibraryService({ artifactIndex, userState, now: () => 9_000 }) const snapshot = await library.getSnapshot() assert.equal(snapshot.generatedAt, 9_000) const snapshotReportA = snapshot.artifacts.find((entry) => entry.id === reportA?.id) assert.ok(snapshotReportA) await library.setFavorite(snapshotReportA.id, true) await library.recordRecent(snapshotReportA.id, 9_100) await assert.rejects(() => library.setFavorite('missing-artifact', true), /产物不存在/) const updated = await library.getSnapshot() const updatedReportA = updated.artifacts.find((entry) => entry.id === snapshotReportA.id) assert.equal(updatedReportA?.favorite, true) assert.equal(updatedReportA?.recentAt, 9_100) const reloadedState = new JsonLibraryUserStateRepository(userStatePath) const persisted = await reloadedState.get() assert.deepEqual(persisted.favoriteArtifactIds, [snapshotReportA.id]) assert.deepEqual(persisted.recent, [{ artifactId: snapshotReportA.id, openedAt: 9_100 }]) assert.equal( (await fs.readdir(dirname(userStatePath))).some((name) => name.endsWith('.tmp')), false, '原子写入不得遗留临时文件' ) const catalogEntries: LibraryArtifactEntry[] = updated.artifacts.map((entry) => ({ ...entry, favorite: entry.id === snapshotReportA.id, ...(entry.id === snapshotReportA.id ? { recentAt: 9_100 } : {}) })) const allFilters = { query: '', kind: 'all' as const, workspaceKey: 'all', favoritesOnly: false } assert.equal(filterLibraryEntries(catalogEntries, 'recent', allFilters).length, 1) assert.equal(filterLibraryEntries(catalogEntries, 'local', { ...allFilters, query: '任务 B' }).length, 1) assert.equal(filterLibraryEntries(catalogEntries, 'local', { ...allFilters, kind: 'spreadsheet' }).length, 1) assert.equal(filterLibraryEntries(catalogEntries, 'local', { ...allFilters, favoritesOnly: true }).length, 1) const workspaceOptions = listLibraryWorkspaces(catalogEntries) assert.equal(workspaceOptions.length, 1) assert.equal(filterLibraryEntries(catalogEntries, 'local', { ...allFilters, workspaceKey: workspaceOptions[0].key }).length, 5) const groups = groupLibraryEntries(catalogEntries) assert.equal(groups.length, 1) assert.equal(groups[0].tasks.length, 2, '同一空间内必须按任务分组') assert.equal(groups[0].label, root.split(/[\\/]/).pop()) const sortedByName = sortLibraryEntries(catalogEntries, { key: 'name', direction: 'asc' }, 'local') assert.ok(sortedByName[0].name.localeCompare(sortedByName.at(-1)!.name, 'zh-CN') <= 0) assert.equal(formatLibraryBytes(1536), '1.5 KB') assert.equal(formatLibraryBytes(1024), '1 KB') assert.equal(formatLibraryBytes(undefined), '—') const corruptPath = join(root, 'state', 'corrupt.json') await fs.writeFile(corruptPath, '{not-json', 'utf8') const corrupt = new JsonLibraryUserStateRepository(corruptPath) await assert.rejects(() => corrupt.get(), /资料库收藏数据损坏|JSON|Unexpected|property name/i) console.log('library-regression: ok') console.log(JSON.stringify({ indexedEntries: entries.length, crossTaskSamePathEntries: reportEntries.length, unavailableEntries: entries.filter((entry) => entry.availability === 'unavailable').length, workspaceGroups: groups.length, taskGroups: groups[0].tasks.length, persistedRecent: persisted.recent.length, persistedFavorites: persisted.favoriteArtifactIds.length, nativeWriteEntries: nativeEntries.length, nativeWriteAvailability: nativeEntries[0]?.availability, securityEntries: securityEntries.length, securityProbeCalls, crossRootUnavailable: securityEntries.find((entry) => entry.path === directOutsidePath)?.availability, symlinkEscapeUnavailable: securityEntries.find((entry) => entry.path === join(escapeLink, 'linked.md'))?.availability, safeSymlinkAvailable: securityEntries.find((entry) => entry.path === join(safeLink, 'inside.md'))?.availability }, null, 2)) } finally { await Promise.all([ fs.rm(root, { recursive: true, force: true }), outsideRoot ? fs.rm(outsideRoot, { recursive: true, force: true }) : Promise.resolve() ]) } } main().catch((error) => { console.error(error) process.exitCode = 1 })