Files
lobe-chat/packages/database/src/models/agentDocuments/__tests__/agentDocument.test.ts
T
2026-06-04 16:23:51 +08:00

1099 lines
42 KiB
TypeScript

// @vitest-environment node
import { and, eq } from 'drizzle-orm';
import { beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../../core/getTestDB';
import { agentDocuments, agents, documents, users } from '../../../schemas';
import {
AGENT_SKILL_TEMPLATE_ID,
DOCUMENT_FOLDER_TYPE,
SKILL_BUNDLE_FILE_TYPE,
SKILL_INDEX_FILE_TYPE,
} from '../../../schemas/file';
import type { LobeChatDatabase } from '../../../type';
import {
AgentDocumentModel,
DocumentLoadFormat,
DocumentLoadPosition,
DocumentLoadRule,
PolicyLoad,
} from '../agentDocument';
const userId = 'agent-document-test-user';
const otherUserId = 'other-agent-document-test-user';
const agentId = 'agent-document-test-agent';
const secondAgentId = 'agent-document-test-agent-2';
const otherAgentId = 'other-agent-document-test-agent';
let agentDocumentModel: AgentDocumentModel;
let otherAgentDocumentModel: AgentDocumentModel;
const serverDB: LobeChatDatabase = await getTestDB();
beforeEach(async () => {
await serverDB.delete(users);
await serverDB.insert(users).values([{ id: userId }, { id: otherUserId }]);
await serverDB.insert(agents).values([
{ id: agentId, userId },
{ id: secondAgentId, userId },
{ id: otherAgentId, userId: otherUserId },
]);
agentDocumentModel = new AgentDocumentModel(serverDB, userId);
otherAgentDocumentModel = new AgentDocumentModel(serverDB, otherUserId);
});
describe('AgentDocumentModel', () => {
describe('associate', () => {
it('should link an existing document to an agent and return the new id', async () => {
// Create a document in the documents table directly
const [doc] = await serverDB
.insert(documents)
.values({
content: 'crawled content',
fileType: 'article',
filename: 'page.html',
source: 'https://example.com',
sourceType: 'web',
title: 'Example Page',
totalCharCount: 15,
totalLineCount: 1,
userId,
})
.returning();
const result = await agentDocumentModel.associate({ agentId, documentId: doc!.id });
expect(result.id).toBeDefined();
expect(result.id).not.toBe('');
// Verify the agentDocuments row was created
const [row] = await serverDB
.select()
.from(agentDocuments)
.where(eq(agentDocuments.id, result.id));
expect(row).toBeDefined();
expect(row?.agentId).toBe(agentId);
expect(row?.documentId).toBe(doc!.id);
expect(row?.userId).toBe(userId);
expect(row?.policyLoad).toBe(PolicyLoad.PROGRESSIVE);
});
it('should be idempotent (onConflictDoNothing)', async () => {
const [doc] = await serverDB
.insert(documents)
.values({
content: 'content',
fileType: 'article',
filename: 'dup.html',
source: 'https://example.com/dup',
sourceType: 'web',
title: 'Dup Page',
totalCharCount: 7,
totalLineCount: 1,
userId,
})
.returning();
const first = await agentDocumentModel.associate({ agentId, documentId: doc!.id });
const second = await agentDocumentModel.associate({ agentId, documentId: doc!.id });
expect(first.id).toBeDefined();
// Second call should not throw, id may be undefined due to onConflictDoNothing
expect(second).toBeDefined();
});
it('should not create documents row — only the link', async () => {
const [doc] = await serverDB
.insert(documents)
.values({
content: 'existing',
fileType: 'article',
filename: 'existing.html',
source: 'https://example.com/existing',
sourceType: 'web',
title: 'Existing',
totalCharCount: 8,
totalLineCount: 1,
userId,
})
.returning();
const countBefore = await serverDB
.select()
.from(documents)
.where(eq(documents.userId, userId));
await agentDocumentModel.associate({ agentId, documentId: doc!.id });
const countAfter = await serverDB
.select()
.from(documents)
.where(eq(documents.userId, userId));
expect(countAfter.length).toBe(countBefore.length);
});
it('should allow associating documents when a live sibling already owns the same filename', async () => {
const existing = await agentDocumentModel.create(agentId, 'associated.md', 'managed');
const [doc] = await serverDB
.insert(documents)
.values({
content: 'existing',
fileType: 'article',
filename: 'associated.md',
source: 'https://example.com/associated',
sourceType: 'web',
title: 'Associated',
totalCharCount: 8,
totalLineCount: 1,
userId,
})
.returning();
const associated = await agentDocumentModel.associate({ agentId, documentId: doc!.id });
const matched = await agentDocumentModel.listByParentAndFilename(
agentId,
null,
'associated.md',
);
expect(associated.id).toBeDefined();
expect(matched.map((item) => item.documentId)).toEqual([existing.documentId, doc!.id]);
});
});
describe('create', () => {
it('creates ordinary agent documents with agent source attribution by default', async () => {
const created = await agentDocumentModel.create(agentId, 'brief', 'content');
expect(created.sourceType).toBe('agent');
expect(created.source).toBe(`agent-document://${agentId}/brief`);
});
it('allows trusted callers to set document source attribution', async () => {
const created = await agentDocumentModel.create(agentId, 'skill-a', 'content', {
source: 'agent-signal:skill-management',
sourceType: 'agent-signal',
});
expect(created.sourceType).toBe('agent-signal');
expect(created.source).toBe('agent-signal:skill-management');
});
/**
* @example
* Higher-level services can compose multiple agent document writes in one transaction.
*/
it('rolls back createWithTx when the caller transaction fails', async () => {
let createdAgentDocumentId: string | undefined;
let createdDocumentId: string | undefined;
await expect(
serverDB.transaction(async (trx) => {
const created = await agentDocumentModel.createWithTx(
trx,
agentId,
'rollback-note',
'content',
);
createdAgentDocumentId = created.id;
createdDocumentId = created.documentId;
throw new Error('Intentional rollback');
}),
).rejects.toThrow('Intentional rollback');
if (createdAgentDocumentId) {
const [binding] = await serverDB
.select()
.from(agentDocuments)
.where(eq(agentDocuments.id, createdAgentDocumentId));
expect(binding).toBeUndefined();
}
if (createdDocumentId) {
const [doc] = await serverDB
.select()
.from(documents)
.where(eq(documents.id, createdDocumentId));
expect(doc).toBeUndefined();
}
});
it('should create an agent document with normalized policy and linked document row', async () => {
const result = await agentDocumentModel.create(agentId, 'identity.md', 'line1\nline2', {
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
loadRules: { maxTokens: 1024, priority: 2, rule: DocumentLoadRule.ALWAYS },
metadata: { description: 'Identity policy', domain: 'ops' },
templateId: 'claw',
});
expect(result.agentId).toBe(agentId);
expect(result.filename).toBe('identity.md');
expect(result.title).toBe('identity');
expect(result.content).toBe('line1\nline2');
expect(result.policy?.context?.position).toBe(DocumentLoadPosition.BEFORE_SYSTEM);
expect(result.policy?.context?.maxTokens).toBe(1024);
expect(result.policy?.context?.priority).toBe(2);
expect(result.policyLoadFormat).toBe(DocumentLoadFormat.RAW);
expect(result.policyLoadRule).toBe(DocumentLoadRule.ALWAYS);
const [doc] = await serverDB
.select()
.from(documents)
.where(eq(documents.id, result.documentId));
expect(doc).toBeDefined();
expect(doc?.title).toBe('identity');
expect(doc?.description).toBe('Identity policy');
expect(doc?.source).toBe(`agent-document://${agentId}/${encodeURIComponent('identity.md')}`);
expect(doc?.totalCharCount).toBe('line1\nline2'.length);
expect(doc?.totalLineCount).toBe(2);
});
it('should use default policy values when optional args are omitted', async () => {
const result = await agentDocumentModel.create(agentId, 'quick-note.txt', 'hello');
expect(result.policy?.context?.position).toBe(DocumentLoadPosition.BEFORE_FIRST_USER);
expect(result.policy?.context?.rule).toBe(DocumentLoadRule.ALWAYS);
expect(result.policyLoadFormat).toBe(DocumentLoadFormat.RAW);
expect(result.policyLoad).toBe(PolicyLoad.PROGRESSIVE);
expect(result.accessShared).toBe(0);
expect(result.accessPublic).toBe(0);
});
it('should allow duplicate live sibling filenames at the database boundary', async () => {
const first = await agentDocumentModel.create(agentId, 'duplicate.md', 'first', {
createdAt: new Date('2024-01-01T00:00:00.000Z'),
});
const second = await agentDocumentModel.create(agentId, 'duplicate.md', 'second', {
createdAt: new Date('2024-01-02T00:00:00.000Z'),
});
const matched = await agentDocumentModel.listByParentAndFilename(
agentId,
null,
'duplicate.md',
);
expect(second.documentId).not.toBe(first.documentId);
expect(matched.map((item) => item.documentId)).toEqual([first.documentId, second.documentId]);
});
it('should allow different agents to use the same root filename', async () => {
const first = await agentDocumentModel.create(agentId, 'shared.md', 'first');
const second = await agentDocumentModel.create(secondAgentId, 'shared.md', 'second');
expect(first.agentId).toBe(agentId);
expect(second.agentId).toBe(secondAgentId);
expect(first.documentId).not.toBe(second.documentId);
});
it('should allow recreating a filename after the previous sibling is soft deleted', async () => {
const first = await agentDocumentModel.create(agentId, 'recreated.md', 'first');
await agentDocumentModel.delete(first.id, 'replace');
const second = await agentDocumentModel.create(agentId, 'recreated.md', 'second');
expect(second.id).not.toBe(first.id);
expect(second.content).toBe('second');
});
it('should allow managed mount documents to reuse storage filenames', async () => {
const first = await agentDocumentModel.create(agentId, 'skills', '', {
metadata: { mount: { namespace: 'topic', role: 'root' } },
});
const second = await agentDocumentModel.create(agentId, 'skills', '', {
metadata: { mount: { namespace: 'agent', role: 'root' } },
});
expect(first.documentId).not.toBe(second.documentId);
});
});
describe('findById and findByFilename', () => {
it('should isolate records by user', async () => {
const ownDoc = await agentDocumentModel.create(agentId, 'own.md', 'own content');
const otherDoc = await otherAgentDocumentModel.create(
otherAgentId,
'other.md',
'other content',
);
const ownResult = await agentDocumentModel.findById(ownDoc.id);
const otherResult = await agentDocumentModel.findById(otherDoc.id);
expect(ownResult?.id).toBe(ownDoc.id);
expect(otherResult).toBeUndefined();
const byFilename = await agentDocumentModel.findByFilename(agentId, 'own.md');
expect(byFilename?.id).toBe(ownDoc.id);
});
it('should find current-agent records by underlying document ids', async () => {
const ownDoc = await agentDocumentModel.create(agentId, 'own.md', 'own content');
const secondAgentDoc = await agentDocumentModel.create(
secondAgentId,
'second.md',
'second content',
);
const otherUserDoc = await otherAgentDocumentModel.create(
otherAgentId,
'other.md',
'other content',
);
const result = await agentDocumentModel.findByDocumentIds(agentId, [
ownDoc.documentId,
secondAgentDoc.documentId,
otherUserDoc.documentId,
'missing-document',
]);
expect(result.map((doc) => doc.id)).toEqual([ownDoc.id]);
});
});
describe('update and upsert', () => {
it('should update content, metadata and policy projections', async () => {
const created = await agentDocumentModel.create(agentId, 'policy.md', 'old', {
loadPosition: DocumentLoadPosition.BEFORE_FIRST_USER,
loadRules: { maxTokens: 100, priority: 8 },
metadata: { description: 'old desc', topic: 'old' },
});
await agentDocumentModel.update(created.id, {
content: 'new\ncontent',
loadPosition: DocumentLoadPosition.AFTER_KNOWLEDGE,
loadRules: { maxTokens: 500, priority: 1 },
metadata: { description: 'new desc', topic: 'new' },
policy: { context: { policyLoadFormat: DocumentLoadFormat.FILE } },
});
const updated = await agentDocumentModel.findById(created.id);
expect(updated?.content).toBe('new\ncontent');
expect(updated?.metadata).toMatchObject({ description: 'new desc', topic: 'new' });
expect(updated?.policy?.context?.position).toBe(DocumentLoadPosition.AFTER_KNOWLEDGE);
expect(updated?.policy?.context?.maxTokens).toBe(500);
expect(updated?.policy?.context?.priority).toBe(1);
expect(updated?.policyLoadFormat).toBe(DocumentLoadFormat.FILE);
expect(updated?.policyLoadPosition).toBe(DocumentLoadPosition.AFTER_KNOWLEDGE);
const [updatedDoc] = await serverDB
.select()
.from(documents)
.where(eq(documents.id, created.documentId));
expect(updatedDoc?.totalCharCount).toBe('new\ncontent'.length);
expect(updatedDoc?.totalLineCount).toBe(2);
expect(updatedDoc?.description).toBe('new desc');
});
it('should upsert by creating a new document when filename does not exist', async () => {
const result = await agentDocumentModel.upsert(agentId, 'new-upsert.md', 'fresh', {
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
loadRules: { priority: 5 },
templateId: 'claw',
});
expect(result.filename).toBe('new-upsert.md');
expect(result.content).toBe('fresh');
expect(result.templateId).toBe('claw');
expect(result.policy?.context?.position).toBe(DocumentLoadPosition.BEFORE_SYSTEM);
});
it('should upsert by filename and merge metadata on updates', async () => {
const first = await agentDocumentModel.upsert(agentId, 'policy-upsert.md', 'v1', {
loadPosition: DocumentLoadPosition.BEFORE_FIRST_USER,
loadRules: { priority: 9 },
metadata: { a: 1, description: 'v1' },
});
const second = await agentDocumentModel.upsert(agentId, 'policy-upsert.md', 'v2', {
loadRules: { priority: 1, maxTokens: 900 },
metadata: { b: 2, description: 'v2' },
});
expect(second.id).toBe(first.id);
expect(second.content).toBe('v2');
expect(second.metadata).toMatchObject({ a: 1, b: 2, description: 'v2' });
expect(second.policy?.context?.priority).toBe(9);
expect(second.policy?.context?.maxTokens).toBe(900);
});
});
describe('rename and copy', () => {
it('should rename and preserve human-readable filename/source', async () => {
const created = await agentDocumentModel.create(agentId, 'old-name.md', 'hello');
const renamed = await agentDocumentModel.rename(created.id, 'New Name');
expect(renamed?.title).toBe('New Name');
expect(renamed?.filename).toBe('New Name');
const [doc] = await serverDB
.select()
.from(documents)
.where(eq(documents.id, created.documentId));
expect(doc?.source).toBe(`agent-document://${agentId}/${encodeURIComponent('New Name')}`);
});
it('uses the new title verbatim as filename when renaming', async () => {
const created = await agentDocumentModel.create(agentId, 'identity.md', 'hello');
const renamed = await agentDocumentModel.rename(created.id, 'IDENTITY 2');
expect(renamed?.filename).toBe('IDENTITY 2');
});
it('should move path metadata without changing agent document identity', async () => {
const folder = await agentDocumentModel.create(agentId, 'folder', '', {
fileType: DOCUMENT_FOLDER_TYPE,
title: 'folder',
});
const created = await agentDocumentModel.create(agentId, 'old.md', 'hello');
const moved = await agentDocumentModel.movePath(created.id, {
filename: 'new.md',
parentId: folder.documentId,
});
expect(moved?.id).toBe(created.id);
expect(moved?.documentId).toBe(created.documentId);
expect(moved?.filename).toBe('new.md');
expect(moved?.parentId).toBe(folder.documentId);
const [doc] = await serverDB
.select()
.from(documents)
.where(eq(documents.id, created.documentId));
expect(doc?.source).toBe(`agent-document://${agentId}/${encodeURIComponent('new.md')}`);
});
it('should allow moving a document over an existing live sibling filename', async () => {
const folder = await agentDocumentModel.create(agentId, 'move-folder', '', {
fileType: DOCUMENT_FOLDER_TYPE,
title: 'move-folder',
});
const source = await agentDocumentModel.create(agentId, 'source.md', 'source');
const target = await agentDocumentModel.create(agentId, 'target.md', 'target', {
parentId: folder.documentId,
});
const moved = await agentDocumentModel.movePath(source.id, {
filename: 'target.md',
parentId: folder.documentId,
});
const matched = await agentDocumentModel.listByParentAndFilename(
agentId,
folder.documentId,
'target.md',
);
expect(moved?.id).toBe(source.id);
expect(matched.map((item) => item.documentId)).toEqual([
source.documentId,
target.documentId,
]);
});
it('should copy into a new record and keep policy/template metadata', async () => {
const created = await agentDocumentModel.create(agentId, 'copy-source.md', 'copy me', {
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
loadRules: { maxTokens: 200, priority: 3 },
metadata: { description: 'source desc', domain: 'A' },
templateId: 'claw',
});
const copied = await agentDocumentModel.copy(created.id, 'Copied Title');
expect(copied).toBeDefined();
expect(copied?.id).not.toBe(created.id);
expect(copied?.documentId).not.toBe(created.documentId);
expect(copied?.filename).toBe('Copied Title');
expect(copied?.templateId).toBe('claw');
expect(copied?.policy?.context?.maxTokens).toBe(200);
expect(copied?.metadata).toMatchObject({ description: 'source desc', domain: 'A' });
});
it('should preserve policyLoad when copying a document', async () => {
const created = await agentDocumentModel.create(agentId, 'always-doc.md', 'content', {
policyLoad: PolicyLoad.ALWAYS,
});
const copied = await agentDocumentModel.copy(created.id, 'Always Copy');
expect(copied?.policyLoad).toBe(PolicyLoad.ALWAYS);
});
});
describe('convertAgentDocumentToSkillIndex and updateDocumentIdentity', () => {
it('converts an ordinary agent document binding into a skill index while preserving ids', async () => {
const source = await agentDocumentModel.create(agentId, 'workflow-note', '# Workflow', {
metadata: { agentSignal: { hintIsSkill: true } },
});
const bundle = await agentDocumentModel.create(agentId, 'workflow-note', '', {
fileType: 'skills/bundle',
policyLoad: PolicyLoad.DISABLED,
source: 'agent-signal:skill-management',
sourceType: 'agent-signal',
});
const converted = await agentDocumentModel.convertAgentDocumentToSkillIndex({
agentDocumentId: source.id,
content: '---\nname: workflow-note\ndescription: Workflow note\n---\n# Workflow',
editorData: { root: { children: [], type: 'root' } },
filename: 'workflow-note',
metadata: {
agentSignal: { hintIsSkill: true },
skill: { frontmatter: { description: 'Workflow note', name: 'workflow-note' } },
},
parentId: bundle.documentId,
source: 'agent-signal:skill-management',
sourceType: 'agent-signal',
title: 'Workflow Note',
});
expect(converted?.id).toBe(source.id);
expect(converted?.documentId).toBe(source.documentId);
expect(converted?.fileType).toBe('skills/index');
expect(converted?.filename).toBe('workflow-note');
expect(converted?.parentId).toBe(bundle.documentId);
expect(converted?.policyLoad).toBe(PolicyLoad.DISABLED);
expect(converted?.sourceType).toBe('agent-signal');
expect(converted?.source).toBe('agent-signal:skill-management');
expect(converted?.templateId).toBe('agent-skill');
expect(converted?.title).toBe('Workflow Note');
expect(converted?.metadata).toMatchObject({
agentSignal: { hintIsSkill: true },
skill: { frontmatter: { description: 'Workflow note', name: 'workflow-note' } },
});
const [doc] = await serverDB
.select()
.from(documents)
.where(eq(documents.id, source.documentId));
expect(doc?.description).toBe('Workflow note');
expect(doc?.totalCharCount).toBe(
'---\nname: workflow-note\ndescription: Workflow note\n---\n# Workflow'.length,
);
expect(doc?.totalLineCount).toBe(5);
});
/**
* @example
* Skill creation can convert an existing source document and still roll back as one aggregate.
*/
it('rolls back convertAgentDocumentToSkillIndexWithTx when the caller transaction fails', async () => {
const source = await agentDocumentModel.create(agentId, 'workflow-note', '# Workflow', {
metadata: { agentSignal: { hintIsSkill: true } },
});
const bundle = await agentDocumentModel.create(agentId, 'workflow-note', '', {
fileType: 'skills/bundle',
policyLoad: PolicyLoad.DISABLED,
source: 'agent-signal:skill-management',
sourceType: 'agent-signal',
});
await expect(
serverDB.transaction(async (trx) => {
await agentDocumentModel.convertAgentDocumentToSkillIndexWithTx(trx, {
agentDocumentId: source.id,
content: '---\nname: workflow-note\ndescription: Workflow note\n---\n# Workflow',
filename: 'SKILL.md',
metadata: {
agentSignal: { hintIsSkill: true },
skill: { frontmatter: { description: 'Workflow note', name: 'workflow-note' } },
},
parentId: bundle.documentId,
source: 'agent-signal:skill-management',
sourceType: 'agent-signal',
title: 'SKILL.md',
});
throw new Error('Intentional rollback');
}),
).rejects.toThrow('Intentional rollback');
const unchanged = await agentDocumentModel.findById(source.id);
expect(unchanged).toMatchObject({
documentId: source.documentId,
fileType: 'agent/document',
filename: 'workflow-note',
parentId: null,
policyLoad: PolicyLoad.PROGRESSIVE,
sourceType: 'agent',
});
});
it('updates backing document identity fields without changing the agent document binding', async () => {
const folder = await agentDocumentModel.create(agentId, 'skills', '', {
fileType: DOCUMENT_FOLDER_TYPE,
title: 'skills',
});
const created = await agentDocumentModel.create(agentId, 'old-name', 'content');
const updated = await agentDocumentModel.updateDocumentIdentity(created.id, {
filename: 'new-name',
metadata: { skill: { frontmatter: { description: 'New', name: 'new-name' } } },
parentId: folder.documentId,
title: 'New Name',
});
expect(updated?.id).toBe(created.id);
expect(updated?.documentId).toBe(created.documentId);
expect(updated?.filename).toBe('new-name');
expect(updated?.parentId).toBe(folder.documentId);
expect(updated?.title).toBe('New Name');
expect(updated?.metadata).toMatchObject({
skill: { frontmatter: { description: 'New', name: 'new-name' } },
});
const [doc] = await serverDB
.select()
.from(documents)
.where(eq(documents.id, created.documentId));
expect(doc?.description).toBe('New');
});
it('returns the existing binding when document identity update has no fields', async () => {
const created = await agentDocumentModel.create(agentId, 'unchanged', 'content');
const updated = await agentDocumentModel.updateDocumentIdentity(created.id, {});
expect(updated).toMatchObject({
documentId: created.documentId,
filename: 'unchanged',
id: created.id,
title: 'unchanged',
});
});
});
describe('findByAgent and findByTemplate', () => {
it('should return matched docs with parsed loadRules', async () => {
await agentDocumentModel.create(agentId, 'a.md', 'A', {
loadRules: { maxTokens: 100, priority: 2 },
});
await agentDocumentModel.create(agentId, 'b.md', 'B', {
loadRules: { maxTokens: 50, priority: 1 },
});
await agentDocumentModel.create(agentId, 'c.md', 'C', {
loadRules: { priority: 9 },
templateId: 'claw',
});
await agentDocumentModel.create(agentId, 'd.md', 'D', {
loadRules: { priority: 8 },
templateId: 'claw',
});
await agentDocumentModel.create(secondAgentId, 'e.md', 'E', {
loadRules: { priority: 7 },
templateId: 'claw',
});
const byAgent = await agentDocumentModel.findByAgent(agentId);
expect(byAgent).toHaveLength(4);
expect(byAgent.every((item) => item.agentId === agentId)).toBe(true);
expect(byAgent[0].loadRules).toBeDefined();
const byTemplate = await agentDocumentModel.findByTemplate(agentId, 'claw');
expect(byTemplate).toHaveLength(2);
expect(byTemplate.every((item) => item.templateId === 'claw')).toBe(true);
});
it('should return only skill-managed docs for skill registry assembly', async () => {
const bundle = await agentDocumentModel.create(agentId, 'bug-triage', 'bundle body', {
fileType: SKILL_BUNDLE_FILE_TYPE,
templateId: AGENT_SKILL_TEMPLATE_ID,
});
await agentDocumentModel.create(agentId, 'SKILL.md', 'skill body', {
fileType: SKILL_INDEX_FILE_TYPE,
parentId: bundle.documentId,
templateId: AGENT_SKILL_TEMPLATE_ID,
});
await agentDocumentModel.create(agentId, 'ordinary.md', 'ordinary body');
await agentDocumentModel.create(agentId, 'web-page', 'web body', {
fileType: 'article',
sourceType: 'web',
});
const result = await agentDocumentModel.findSkillDocsByAgent(agentId);
expect(result.map((item) => item.filename).sort()).toEqual(['SKILL.md', 'bug-triage']);
expect(result.every((item) => item.category === 'skill')).toBe(true);
});
it('should omit progressive document content for chat context hydration', async () => {
await agentDocumentModel.create(agentId, 'always.md', 'always body', {
editorData: { root: { children: [{ text: 'always body' }] } },
policyLoad: PolicyLoad.ALWAYS,
});
await agentDocumentModel.create(agentId, 'progressive.md', 'progressive body', {
editorData: { root: { children: [{ text: 'progressive body' }] } },
policyLoad: PolicyLoad.PROGRESSIVE,
});
await agentDocumentModel.create(agentId, 'web-page', 'web body', {
fileType: 'article',
policyLoad: PolicyLoad.PROGRESSIVE,
sourceType: 'web',
});
const result = await agentDocumentModel.findContextByAgent(agentId);
const byFilename = Object.fromEntries(result.map((item) => [item.filename, item]));
expect(byFilename['always.md']?.content).toBe('always body');
expect(byFilename['always.md']?.contentCharCount).toBe('always body'.length);
expect(byFilename['always.md']?.editorData).toEqual({
root: { children: [{ text: 'always body' }] },
});
expect(byFilename['progressive.md']?.content).toBe('');
expect(byFilename['progressive.md']?.contentCharCount).toBe('progressive body'.length);
expect(byFilename['progressive.md']?.editorData).toBeNull();
expect(byFilename['web-page']?.content).toBe('');
expect(byFilename['web-page']?.contentCharCount).toBe('web body'.length);
});
});
describe('hasByAgent', () => {
it('should return whether a user has visible documents for the agent', async () => {
expect(await agentDocumentModel.hasByAgent(agentId)).toBe(false);
const created = await agentDocumentModel.create(agentId, 'exists.md', 'A');
await agentDocumentModel.create(secondAgentId, 'other-agent.md', 'B');
expect(await agentDocumentModel.hasByAgent(agentId)).toBe(true);
expect(await agentDocumentModel.hasByAgent(secondAgentId)).toBe(true);
await agentDocumentModel.delete(created.id);
expect(await agentDocumentModel.hasByAgent(agentId)).toBe(false);
});
it('should keep existence checks isolated by user', async () => {
await otherAgentDocumentModel.create(otherAgentId, 'other-user.md', 'A');
expect(await agentDocumentModel.hasByAgent(otherAgentId)).toBe(false);
expect(await otherAgentDocumentModel.hasByAgent(otherAgentId)).toBe(true);
});
});
describe('updateToolLoadRule and loadable queries', () => {
it('should apply tool load rule and exclude manual docs from loadable results', async () => {
const alwaysDoc = await agentDocumentModel.create(agentId, 'always.md', 'always', {
loadPosition: DocumentLoadPosition.BEFORE_FIRST_USER,
loadRules: { priority: 2 },
});
const manualDoc = await agentDocumentModel.create(agentId, 'manual.md', 'manual', {
loadPosition: DocumentLoadPosition.BEFORE_FIRST_USER,
loadRules: { priority: 1 },
});
const updated = await agentDocumentModel.updateToolLoadRule(manualDoc.id, {
keywordMatchMode: 'all',
keywords: ['urgent', 'risk'],
maxDocuments: 3,
maxTokens: 600,
mode: 'manual',
pinnedDocumentIds: [alwaysDoc.id],
policyLoadFormat: 'file',
priority: 10,
regexp: '\\burgent\\b',
rule: DocumentLoadRule.BY_KEYWORDS,
timeRange: { from: '2026-01-01T00:00:00.000Z', to: '2026-12-31T23:59:59.000Z' },
});
expect(updated?.policyLoad).toBe(PolicyLoad.DISABLED);
expect(updated?.policyLoadFormat).toBe(DocumentLoadFormat.FILE);
expect(updated?.policy?.context?.maxDocuments).toBe(3);
expect(updated?.policy?.context?.rule).toBe(DocumentLoadRule.BY_KEYWORDS);
expect(updated?.policy?.context?.keywords).toEqual(['urgent', 'risk']);
expect(updated?.policy?.context?.keywordMatchMode).toBe('all');
expect(updated?.policy?.context?.regexp).toBe('\\burgent\\b');
expect(updated?.policy?.context?.timeRange).toEqual({
from: '2026-01-01T00:00:00.000Z',
to: '2026-12-31T23:59:59.000Z',
});
expect(updated?.policy?.context?.pinnedDocumentIds).toEqual([alwaysDoc.id]);
const loadable = await agentDocumentModel.getLoadableDocuments(agentId);
expect(loadable).toHaveLength(1);
expect(loadable[0].id).toBe(alwaysDoc.id);
const injectable = await agentDocumentModel.getInjectableDocuments(agentId);
expect(injectable.map((d) => d.id)).toEqual([alwaysDoc.id]);
const context = await agentDocumentModel.getAgentContext(agentId);
expect(context).toContain('--- always.md ---');
expect(context).not.toContain('--- manual.md ---');
});
it('should preserve progressive policyLoad when updating load rule without mode', async () => {
const doc = await agentDocumentModel.create(agentId, 'progressive.md', 'content');
expect(doc.policyLoad).toBe(PolicyLoad.PROGRESSIVE);
const updated = await agentDocumentModel.updateToolLoadRule(doc.id, {
rule: 'by-keywords',
keywords: ['test'],
});
expect(updated?.policyLoad).toBe(PolicyLoad.PROGRESSIVE);
expect(updated?.policy?.context?.keywords).toEqual(['test']);
expect(updated?.policyLoadRule).toBe(DocumentLoadRule.BY_KEYWORDS);
});
it('should group docs by position and sort by priority ascending', async () => {
await agentDocumentModel.create(agentId, 'p2.md', 'p2', {
loadPosition: DocumentLoadPosition.BEFORE_KNOWLEDGE,
loadRules: { priority: 2 },
});
await agentDocumentModel.create(agentId, 'p1.md', 'p1', {
loadPosition: DocumentLoadPosition.BEFORE_KNOWLEDGE,
loadRules: { priority: 1 },
});
const grouped = await agentDocumentModel.getDocumentsByPosition(agentId);
const docsAtPosition = grouped.get(DocumentLoadPosition.BEFORE_KNOWLEDGE) || [];
expect(docsAtPosition).toHaveLength(2);
expect(docsAtPosition[0].filename).toBe('p1.md');
expect(docsAtPosition[1].filename).toBe('p2.md');
});
});
describe('delete', () => {
it('should soft delete a single document while preserving linked documents row', async () => {
const created = await agentDocumentModel.create(agentId, 'delete-me.md', 'delete me');
await agentDocumentModel.delete(created.id, 'cleanup');
const visible = await agentDocumentModel.findById(created.id);
expect(visible).toBeUndefined();
const [rawAgentDoc] = await serverDB
.select()
.from(agentDocuments)
.where(eq(agentDocuments.id, created.id));
expect(rawAgentDoc?.deletedAt).toBeInstanceOf(Date);
expect(rawAgentDoc?.deletedByUserId).toBe(userId);
expect(rawAgentDoc?.deletedByAgentId).toBeNull();
expect(rawAgentDoc?.deleteReason).toBe('cleanup');
expect(rawAgentDoc?.policyLoad).toBe(PolicyLoad.DISABLED);
const [rawDoc] = await serverDB
.select()
.from(documents)
.where(eq(documents.id, created.documentId));
expect(rawDoc).toBeDefined();
});
it('should restore a deleted document even when a live sibling has the same filename', async () => {
const first = await agentDocumentModel.create(agentId, 'restore-conflict.md', 'first');
await agentDocumentModel.delete(first.id, 'replace');
const second = await agentDocumentModel.create(agentId, 'restore-conflict.md', 'second');
await agentDocumentModel.restore(first.id);
const matched = await agentDocumentModel.listByParentAndFilename(
agentId,
null,
'restore-conflict.md',
);
expect(matched.map((item) => item.documentId)).toEqual([first.documentId, second.documentId]);
});
it('should return empty string from getAgentContext when no loadable docs exist', async () => {
const context = await agentDocumentModel.getAgentContext(agentId);
expect(context).toBe('');
});
it('should soft delete by agent and by template', async () => {
const templateDoc = await agentDocumentModel.create(agentId, 'template-a.md', 'A', {
templateId: 'claw',
});
const otherTemplateDoc = await agentDocumentModel.create(agentId, 'template-b.md', 'B', {
templateId: 'other',
});
const secondAgentDoc = await agentDocumentModel.create(secondAgentId, 'agent-2.md', 'C');
await agentDocumentModel.deleteByTemplate(agentId, 'claw', 'template cleanup');
const clawVisible = await agentDocumentModel.findById(templateDoc.id);
const otherTemplateVisible = await agentDocumentModel.findById(otherTemplateDoc.id);
expect(clawVisible).toBeUndefined();
expect(otherTemplateVisible).toBeDefined();
await agentDocumentModel.deleteByAgent(secondAgentId, 'agent cleanup');
const secondAgentVisible = await agentDocumentModel.findById(secondAgentDoc.id);
expect(secondAgentVisible).toBeUndefined();
const [secondAgentRow] = await serverDB
.select()
.from(agentDocuments)
.where(and(eq(agentDocuments.id, secondAgentDoc.id), eq(agentDocuments.userId, userId)));
expect(secondAgentRow?.deletedByAgentId).toBe(secondAgentId);
expect(secondAgentRow?.deletedByUserId).toBeNull();
const [otherTemplateRow] = await serverDB
.select()
.from(agentDocuments)
.where(and(eq(agentDocuments.id, otherTemplateDoc.id), eq(agentDocuments.userId, userId)));
expect(otherTemplateRow?.deletedAt).toBeNull();
});
it('should support include-deleted lookups and deleted-only child listings', async () => {
const folder = await agentDocumentModel.create(agentId, 'notes', '', {
fileType: DOCUMENT_FOLDER_TYPE,
title: 'notes',
});
const visibleChild = await agentDocumentModel.create(agentId, 'visible.md', 'visible', {
parentId: folder.documentId,
});
const deletedChild = await agentDocumentModel.create(agentId, 'deleted.md', 'deleted', {
parentId: folder.documentId,
});
await agentDocumentModel.delete(deletedChild.id, 'trash it');
expect(await agentDocumentModel.findById(deletedChild.id)).toBeUndefined();
expect(
await agentDocumentModel.findById(deletedChild.id, {
includeDeleted: true,
}),
).toMatchObject({ id: deletedChild.id });
const liveChildren = await agentDocumentModel.listByParent(agentId, folder.documentId);
const allChildren = await agentDocumentModel.listByParent(agentId, folder.documentId, {
includeDeleted: true,
});
const deletedChildren = await agentDocumentModel.listByParent(agentId, folder.documentId, {
deletedOnly: true,
});
expect(liveChildren.map((item) => item.id)).toEqual([visibleChild.id]);
expect(allChildren.map((item) => item.id).sort()).toEqual(
[visibleChild.id, deletedChild.id].sort(),
);
expect(deletedChildren.map((item) => item.id)).toEqual([deletedChild.id]);
const deletedByPath = await agentDocumentModel.findByParentAndFilename(
agentId,
folder.documentId,
'deleted.md',
{
includeDeleted: true,
},
);
expect(deletedByPath?.id).toBe(deletedChild.id);
const liveByPath = await agentDocumentModel.listByParentAndFilename(
agentId,
folder.documentId,
'visible.md',
{
limit: 1,
},
);
expect(liveByPath.map((item) => item.id)).toEqual([visibleChild.id]);
});
it('should soft-delete, restore, and permanently delete a subtree by root document id', async () => {
const rootFolder = await agentDocumentModel.create(agentId, 'workspace', '', {
fileType: DOCUMENT_FOLDER_TYPE,
title: 'workspace',
});
const nestedFolder = await agentDocumentModel.create(agentId, 'drafts', '', {
fileType: DOCUMENT_FOLDER_TYPE,
parentId: rootFolder.documentId,
title: 'drafts',
});
const nestedFile = await agentDocumentModel.create(agentId, 'plan.md', 'v1', {
parentId: nestedFolder.documentId,
});
const siblingFile = await agentDocumentModel.create(agentId, 'keep.md', 'keep me');
await agentDocumentModel.deleteSubtreeByDocumentId(
agentId,
rootFolder.documentId,
'recursive cleanup',
);
expect(await agentDocumentModel.findById(rootFolder.id)).toBeUndefined();
expect(await agentDocumentModel.findById(nestedFolder.id)).toBeUndefined();
expect(await agentDocumentModel.findById(nestedFile.id)).toBeUndefined();
expect(await agentDocumentModel.findById(siblingFile.id)).toBeDefined();
const deletedTree = await agentDocumentModel.listSubtreeByDocumentId(
agentId,
rootFolder.documentId,
{
includeDeleted: true,
},
);
expect(deletedTree.map((item) => item.id).sort()).toEqual(
[rootFolder.id, nestedFolder.id, nestedFile.id].sort(),
);
const trashItems = await agentDocumentModel.listDeletedByAgent(agentId);
expect(trashItems.map((item) => item.id).sort()).toEqual(
[rootFolder.id, nestedFolder.id, nestedFile.id].sort(),
);
await agentDocumentModel.restoreSubtreeByDocumentId(agentId, rootFolder.documentId);
const restoredTree = await agentDocumentModel.listSubtreeByDocumentId(
agentId,
rootFolder.documentId,
);
expect(restoredTree.map((item) => item.id).sort()).toEqual(
[rootFolder.id, nestedFolder.id, nestedFile.id].sort(),
);
expect(await agentDocumentModel.listDeletedByAgent(agentId)).toEqual([]);
await agentDocumentModel.deleteSubtreeByDocumentId(
agentId,
rootFolder.documentId,
'recursive cleanup',
);
await agentDocumentModel.permanentlyDeleteSubtreeByDocumentId(agentId, rootFolder.documentId);
expect(
await agentDocumentModel.findByDocumentId(agentId, rootFolder.documentId, {
includeDeleted: true,
}),
).toBeUndefined();
expect(
await agentDocumentModel.findByDocumentId(agentId, nestedFolder.documentId, {
includeDeleted: true,
}),
).toBeUndefined();
expect(
await agentDocumentModel.findByDocumentId(agentId, nestedFile.documentId, {
includeDeleted: true,
}),
).toBeUndefined();
expect(await agentDocumentModel.findById(siblingFile.id)).toBeDefined();
const remainingRows = await serverDB
.select()
.from(documents)
.where(eq(documents.userId, userId));
expect(remainingRows.map((item) => item.id)).toContain(siblingFile.documentId);
expect(remainingRows.map((item) => item.id)).not.toContain(rootFolder.documentId);
expect(remainingRows.map((item) => item.id)).not.toContain(nestedFolder.documentId);
expect(remainingRows.map((item) => item.id)).not.toContain(nestedFile.documentId);
});
});
});