mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
930 lines
30 KiB
TypeScript
930 lines
30 KiB
TypeScript
// @vitest-environment node
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { AgentModel } from '@/database/models/agent';
|
|
import {
|
|
AgentDocumentModel,
|
|
buildDocumentFilename,
|
|
extractMarkdownH1Title,
|
|
} from '@/database/models/agentDocuments';
|
|
import { AgentSkillModel } from '@/database/models/agentSkill';
|
|
import { TopicDocumentModel } from '@/database/models/topicDocument';
|
|
import type { LobeChatDatabase } from '@/database/type';
|
|
|
|
import { DocumentService } from '../document';
|
|
import { SkillResourceService } from '../skill/resource';
|
|
import { AgentDocumentsService } from './index';
|
|
|
|
const headlessEditorMocks = vi.hoisted(() => ({
|
|
applyLiteXML: vi.fn(),
|
|
applyLiteXMLBatch: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/database/models/agentDocuments', () => ({
|
|
AgentDocumentModel: vi.fn(),
|
|
DocumentLoadPosition: {
|
|
BEFORE_FIRST_USER: 'before_first_user',
|
|
},
|
|
buildDocumentFilename: vi.fn(),
|
|
deriveAgentDocumentFields: vi.fn(() => ({})),
|
|
extractMarkdownH1Title: vi.fn((content: string) => ({ content })),
|
|
}));
|
|
|
|
vi.mock('@/database/models/agent', () => ({
|
|
AgentModel: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/database/models/agentSkill', () => ({
|
|
AgentSkillModel: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/database/models/topicDocument', () => ({
|
|
TopicDocumentModel: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../document', () => ({
|
|
DocumentService: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../skill/resource', () => ({
|
|
SkillResourceService: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@lobehub/editor/headless', () => ({
|
|
createHeadlessEditor: vi.fn(() => {
|
|
let markdown = '';
|
|
let litexml = '<p id="node-1">content</p>';
|
|
|
|
return {
|
|
applyLiteXML: vi.fn(async (operations) => {
|
|
headlessEditorMocks.applyLiteXML(operations);
|
|
markdown = 'xml updated';
|
|
litexml = '<p id="node-1">xml updated</p>';
|
|
}),
|
|
applyLiteXMLBatch: vi.fn(async (operations) => {
|
|
headlessEditorMocks.applyLiteXMLBatch(operations);
|
|
markdown = 'xml updated';
|
|
litexml = '<p id="node-1">xml updated</p>';
|
|
}),
|
|
destroy: vi.fn(),
|
|
export: vi.fn((options?: { litexml?: boolean }) => ({
|
|
editorData: { root: { children: [] } },
|
|
litexml: options?.litexml ? litexml : undefined,
|
|
markdown,
|
|
})),
|
|
hydrateEditorData: vi.fn(() => {
|
|
markdown = 'projected';
|
|
}),
|
|
hydrateMarkdown: vi.fn((content: string) => {
|
|
markdown = content;
|
|
}),
|
|
};
|
|
}),
|
|
}));
|
|
|
|
describe('AgentDocumentsService', () => {
|
|
const db = {} as LobeChatDatabase;
|
|
const userId = 'user-1';
|
|
|
|
const mockModel = {
|
|
associate: vi.fn(),
|
|
copy: vi.fn(),
|
|
create: vi.fn(),
|
|
findById: vi.fn(),
|
|
findByAgent: vi.fn(),
|
|
findContextByAgent: vi.fn(),
|
|
findByDocumentIds: vi.fn(),
|
|
findByFilename: vi.fn(),
|
|
findSkillDocsByAgent: vi.fn(),
|
|
hasByAgent: vi.fn(),
|
|
rename: vi.fn(),
|
|
update: vi.fn(),
|
|
upsert: vi.fn(),
|
|
};
|
|
const mockDocumentService = {
|
|
createDocument: vi.fn(),
|
|
deleteDocument: vi.fn(),
|
|
trySaveCurrentDocumentHistory: vi.fn(),
|
|
updateDocument: vi.fn(),
|
|
};
|
|
const mockAgentModel = {
|
|
getAgentConfigById: vi.fn(),
|
|
};
|
|
const mockSkillModel = {
|
|
findAll: vi.fn(),
|
|
findById: vi.fn(),
|
|
findByName: vi.fn(),
|
|
};
|
|
const mockTopicDocumentModel = {
|
|
associate: vi.fn(),
|
|
findByTopicId: vi.fn(),
|
|
};
|
|
const mockSkillResourceService = {
|
|
readResource: vi.fn(),
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(AgentDocumentModel as any).mockImplementation(() => mockModel);
|
|
(AgentModel as any).mockImplementation(() => mockAgentModel);
|
|
(AgentSkillModel as any).mockImplementation(() => mockSkillModel);
|
|
(DocumentService as any).mockImplementation(() => mockDocumentService);
|
|
(SkillResourceService as any).mockImplementation(() => mockSkillResourceService);
|
|
(TopicDocumentModel as any).mockImplementation(() => mockTopicDocumentModel);
|
|
vi.mocked(buildDocumentFilename).mockImplementation((title: string) => title);
|
|
vi.mocked(extractMarkdownH1Title).mockImplementation((content: string) => ({ content }));
|
|
});
|
|
|
|
describe('createDocument', () => {
|
|
it('should append a numeric suffix when the base filename already exists', async () => {
|
|
mockModel.findByFilename
|
|
.mockResolvedValueOnce({ id: 'existing-doc' })
|
|
.mockResolvedValueOnce(undefined);
|
|
mockModel.create.mockResolvedValue({ id: 'new-doc', filename: 'note-2' });
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.createDocument('agent-1', 'note', 'content');
|
|
|
|
expect(mockModel.findByFilename).toHaveBeenNthCalledWith(1, 'agent-1', 'note');
|
|
expect(mockModel.findByFilename).toHaveBeenNthCalledWith(2, 'agent-1', 'note-2');
|
|
expect(mockModel.create).toHaveBeenCalledWith('agent-1', 'note-2', 'content', {
|
|
editorData: { root: { children: [] } },
|
|
title: 'note',
|
|
});
|
|
expect(result).toEqual({ id: 'new-doc', filename: 'note-2' });
|
|
});
|
|
|
|
it('should append collision suffix before the filename extension', async () => {
|
|
mockModel.findByFilename
|
|
.mockResolvedValueOnce({ id: 'existing-doc' })
|
|
.mockResolvedValueOnce(undefined);
|
|
mockModel.create.mockResolvedValue({ id: 'new-doc', filename: 'Untitled document-2.md' });
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.createDocument('agent-1', 'Untitled document.md', 'content');
|
|
|
|
expect(mockModel.findByFilename).toHaveBeenNthCalledWith(
|
|
1,
|
|
'agent-1',
|
|
'Untitled document.md',
|
|
);
|
|
expect(mockModel.findByFilename).toHaveBeenNthCalledWith(
|
|
2,
|
|
'agent-1',
|
|
'Untitled document-2.md',
|
|
);
|
|
expect(mockModel.create).toHaveBeenCalledWith(
|
|
'agent-1',
|
|
'Untitled document-2.md',
|
|
'content',
|
|
{
|
|
editorData: { root: { children: [] } },
|
|
title: 'Untitled document.md',
|
|
},
|
|
);
|
|
expect(result).toEqual({ id: 'new-doc', filename: 'Untitled document-2.md' });
|
|
});
|
|
|
|
it('should throw after too many filename collisions', async () => {
|
|
mockModel.findByFilename.mockResolvedValue({ id: 'existing-doc' });
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
|
|
await expect(service.createDocument('agent-1', 'note', 'content')).rejects.toThrow(
|
|
'Unable to generate a unique filename for "note" after 1000 attempts.',
|
|
);
|
|
expect(mockModel.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should extract H1 from markdown content as the document title', async () => {
|
|
vi.mocked(extractMarkdownH1Title).mockReturnValueOnce({
|
|
content: 'body',
|
|
title: 'My Title',
|
|
});
|
|
mockModel.findByFilename.mockResolvedValue(undefined);
|
|
mockModel.create.mockResolvedValue({ id: 'new-doc', filename: 'My Title' });
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
await service.createDocument('agent-1', 'fallback', '# My Title\n\nbody');
|
|
|
|
expect(vi.mocked(buildDocumentFilename)).toHaveBeenCalledWith('My Title');
|
|
expect(mockModel.create).toHaveBeenCalledWith('agent-1', 'My Title', 'body', {
|
|
editorData: { root: { children: [] } },
|
|
title: 'My Title',
|
|
});
|
|
});
|
|
|
|
it('persists agent signal skill hints in document metadata', async () => {
|
|
mockModel.findByFilename.mockResolvedValue(undefined);
|
|
mockModel.create.mockResolvedValue({ id: 'new-doc', filename: 'Reusable Procedure' });
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
await service.createDocument('agent-1', 'Reusable Procedure', 'content', {
|
|
hintIsSkill: true,
|
|
});
|
|
|
|
expect(mockModel.create).toHaveBeenCalledWith(
|
|
'agent-1',
|
|
expect.any(String),
|
|
'content',
|
|
expect.objectContaining({
|
|
metadata: {
|
|
agentSignal: {
|
|
hintedByTool: 'lobe-agent-documents.createDocument',
|
|
hintIsSkill: true,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createForTopic', () => {
|
|
it('should create an agent document and associate the underlying document with the topic', async () => {
|
|
mockModel.findByFilename.mockResolvedValue(undefined);
|
|
mockModel.create.mockResolvedValue({
|
|
documentId: 'documents-1',
|
|
filename: 'note',
|
|
id: 'agent-doc-1',
|
|
title: 'note',
|
|
});
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.createForTopic('agent-1', 'note', 'content', 'topic-1');
|
|
|
|
expect(result).toEqual({
|
|
documentId: 'documents-1',
|
|
filename: 'note',
|
|
id: 'agent-doc-1',
|
|
title: 'note',
|
|
});
|
|
expect(mockTopicDocumentModel.associate).toHaveBeenCalledWith({
|
|
documentId: 'documents-1',
|
|
topicId: 'topic-1',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('listDocuments', () => {
|
|
it('should return a list of documents with documentId, filename, id, and title', async () => {
|
|
mockModel.findByAgent.mockResolvedValue([
|
|
{
|
|
content: 'c1',
|
|
documentId: 'documents-1',
|
|
filename: 'a.md',
|
|
id: 'doc-1',
|
|
policy: null,
|
|
title: 'A',
|
|
},
|
|
{
|
|
content: 'c2',
|
|
documentId: 'documents-2',
|
|
filename: 'b.md',
|
|
id: 'doc-2',
|
|
policy: null,
|
|
title: 'B',
|
|
},
|
|
]);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.listDocuments('agent-1');
|
|
|
|
expect(mockModel.findByAgent).toHaveBeenCalledWith('agent-1');
|
|
expect(result).toEqual([
|
|
{
|
|
documentId: 'documents-1',
|
|
filename: 'a.md',
|
|
id: 'doc-1',
|
|
loadPosition: undefined,
|
|
title: 'A',
|
|
},
|
|
{
|
|
documentId: 'documents-2',
|
|
filename: 'b.md',
|
|
id: 'doc-2',
|
|
loadPosition: undefined,
|
|
title: 'B',
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('listDocumentsForTopic', () => {
|
|
it('should list only agent documents associated with the topic and preserve topic order', async () => {
|
|
mockTopicDocumentModel.findByTopicId.mockResolvedValue([
|
|
{ id: 'documents-2', title: 'B' },
|
|
{ id: 'documents-1', title: 'A' },
|
|
]);
|
|
mockModel.findByDocumentIds.mockResolvedValue([
|
|
{
|
|
documentId: 'documents-1',
|
|
filename: 'a.md',
|
|
id: 'agent-doc-1',
|
|
policy: null,
|
|
title: 'A',
|
|
},
|
|
{
|
|
documentId: 'documents-2',
|
|
filename: 'b.md',
|
|
id: 'agent-doc-2',
|
|
policy: null,
|
|
title: 'B',
|
|
},
|
|
]);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.listDocumentsForTopic('agent-1', 'topic-1');
|
|
|
|
expect(mockTopicDocumentModel.findByTopicId).toHaveBeenCalledWith('topic-1');
|
|
expect(mockModel.findByDocumentIds).toHaveBeenCalledWith('agent-1', [
|
|
'documents-2',
|
|
'documents-1',
|
|
]);
|
|
expect(result).toEqual([
|
|
{
|
|
documentId: 'documents-2',
|
|
filename: 'b.md',
|
|
id: 'agent-doc-2',
|
|
loadPosition: undefined,
|
|
title: 'B',
|
|
},
|
|
{
|
|
documentId: 'documents-1',
|
|
filename: 'a.md',
|
|
id: 'agent-doc-1',
|
|
loadPosition: undefined,
|
|
title: 'A',
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('getDocumentByFilename', () => {
|
|
it('should read a document by filename', async () => {
|
|
mockModel.findByFilename.mockResolvedValue({
|
|
content: 'hello',
|
|
filename: 'note.md',
|
|
id: 'doc-1',
|
|
title: 'note',
|
|
});
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.getDocumentByFilename('agent-1', 'note.md');
|
|
|
|
expect(mockModel.findByFilename).toHaveBeenCalledWith('agent-1', 'note.md');
|
|
expect(result).toEqual({
|
|
content: 'hello',
|
|
filename: 'note.md',
|
|
id: 'doc-1',
|
|
title: 'note',
|
|
});
|
|
});
|
|
|
|
it('should return undefined when filename does not exist', async () => {
|
|
mockModel.findByFilename.mockResolvedValue(undefined);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.getDocumentByFilename('agent-1', 'missing.md');
|
|
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getDocumentSnapshotById', () => {
|
|
it('should fall back to markdown content when editor data is empty', async () => {
|
|
mockModel.findById.mockResolvedValue({
|
|
agentId: 'agent-1',
|
|
content: 'fallback content',
|
|
editorData: { root: { children: [] } },
|
|
id: 'agent-doc-1',
|
|
title: 'Doc',
|
|
});
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.getDocumentSnapshotById('agent-doc-1', 'agent-1');
|
|
|
|
expect(result).toEqual({
|
|
agentId: 'agent-1',
|
|
content: 'fallback content',
|
|
editorData: { root: { children: [] } },
|
|
id: 'agent-doc-1',
|
|
litexml: '<p id="node-1">content</p>',
|
|
title: 'Doc',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('upsertDocumentByFilename', () => {
|
|
it('should create or update a document by filename', async () => {
|
|
mockModel.findByFilename.mockResolvedValue(undefined);
|
|
mockModel.upsert.mockResolvedValue({ content: 'new', filename: 'f.md', id: 'doc-1' });
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.upsertDocumentByFilename({
|
|
agentId: 'agent-1',
|
|
content: 'new',
|
|
filename: 'f.md',
|
|
});
|
|
|
|
expect(mockModel.upsert).toHaveBeenCalledWith('agent-1', 'f.md', 'new', {
|
|
editorData: { root: { children: [] } },
|
|
});
|
|
expect(result).toEqual({ content: 'new', filename: 'f.md', id: 'doc-1' });
|
|
});
|
|
|
|
it('should save history before updating an existing document by filename', async () => {
|
|
mockModel.findByFilename.mockResolvedValue({
|
|
agentId: 'agent-1',
|
|
content: 'old',
|
|
documentId: 'documents-1',
|
|
filename: 'f.md',
|
|
id: 'agent-doc-1',
|
|
});
|
|
mockModel.upsert.mockResolvedValue({ content: 'new', filename: 'f.md', id: 'agent-doc-1' });
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
await service.upsertDocumentByFilename({
|
|
agentId: 'agent-1',
|
|
content: 'new',
|
|
filename: 'f.md',
|
|
});
|
|
|
|
expect(mockDocumentService.trySaveCurrentDocumentHistory).toHaveBeenCalledWith(
|
|
'documents-1',
|
|
'llm_call',
|
|
);
|
|
expect(
|
|
mockDocumentService.trySaveCurrentDocumentHistory.mock.invocationCallOrder[0],
|
|
).toBeLessThan(mockModel.upsert.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it('should skip history when upsert content is unchanged', async () => {
|
|
mockModel.findByFilename.mockResolvedValue({
|
|
agentId: 'agent-1',
|
|
content: 'same',
|
|
documentId: 'documents-1',
|
|
filename: 'f.md',
|
|
id: 'agent-doc-1',
|
|
});
|
|
mockModel.upsert.mockResolvedValue({ content: 'same', filename: 'f.md', id: 'agent-doc-1' });
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
await service.upsertDocumentByFilename({
|
|
agentId: 'agent-1',
|
|
content: 'same',
|
|
filename: 'f.md',
|
|
});
|
|
|
|
expect(mockDocumentService.trySaveCurrentDocumentHistory).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('replaceDocumentContentById', () => {
|
|
it('should save history before editing document content', async () => {
|
|
mockModel.findById
|
|
.mockResolvedValueOnce({
|
|
agentId: 'agent-1',
|
|
content: 'old',
|
|
documentId: 'documents-1',
|
|
id: 'agent-doc-1',
|
|
title: 'Doc',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
agentId: 'agent-1',
|
|
content: 'new',
|
|
documentId: 'documents-1',
|
|
id: 'agent-doc-1',
|
|
title: 'Doc',
|
|
});
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.replaceDocumentContentById('agent-doc-1', 'new', 'agent-1');
|
|
|
|
expect(mockDocumentService.trySaveCurrentDocumentHistory).toHaveBeenCalledWith(
|
|
'documents-1',
|
|
'llm_call',
|
|
);
|
|
expect(mockModel.update).toHaveBeenCalledWith('agent-doc-1', {
|
|
content: 'new',
|
|
editorData: { root: { children: [] } },
|
|
});
|
|
expect(
|
|
mockDocumentService.trySaveCurrentDocumentHistory.mock.invocationCallOrder[0],
|
|
).toBeLessThan(mockModel.update.mock.invocationCallOrder[0]);
|
|
expect(result).toEqual({
|
|
agentId: 'agent-1',
|
|
content: 'new',
|
|
documentId: 'documents-1',
|
|
id: 'agent-doc-1',
|
|
title: 'Doc',
|
|
});
|
|
});
|
|
|
|
it('should skip history when edited content is unchanged', async () => {
|
|
mockModel.findById
|
|
.mockResolvedValueOnce({
|
|
agentId: 'agent-1',
|
|
content: 'same',
|
|
documentId: 'documents-1',
|
|
id: 'agent-doc-1',
|
|
title: 'Doc',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
agentId: 'agent-1',
|
|
content: 'same',
|
|
documentId: 'documents-1',
|
|
id: 'agent-doc-1',
|
|
title: 'Doc',
|
|
});
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
await service.replaceDocumentContentById('agent-doc-1', 'same', 'agent-1');
|
|
|
|
expect(mockDocumentService.trySaveCurrentDocumentHistory).not.toHaveBeenCalled();
|
|
expect(mockModel.update).toHaveBeenCalledWith('agent-doc-1', {
|
|
content: 'same',
|
|
editorData: { root: { children: [] } },
|
|
});
|
|
});
|
|
|
|
it('should apply LiteXML operations against editor data', async () => {
|
|
mockModel.findById
|
|
.mockResolvedValueOnce({
|
|
agentId: 'agent-1',
|
|
content: 'old',
|
|
documentId: 'documents-1',
|
|
editorData: { root: { children: [{ text: 'old' }] } },
|
|
id: 'agent-doc-1',
|
|
title: 'Doc',
|
|
})
|
|
.mockResolvedValueOnce({
|
|
agentId: 'agent-1',
|
|
content: 'xml updated',
|
|
documentId: 'documents-1',
|
|
editorData: { root: { children: [] } },
|
|
id: 'agent-doc-1',
|
|
title: 'Doc',
|
|
});
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.modifyDocumentNodesById(
|
|
'agent-doc-1',
|
|
[{ action: 'modify', litexml: '<p id="node-1">xml updated</p>' }],
|
|
'agent-1',
|
|
);
|
|
|
|
expect(mockDocumentService.trySaveCurrentDocumentHistory).toHaveBeenCalledWith(
|
|
'documents-1',
|
|
'llm_call',
|
|
);
|
|
expect(mockModel.update).toHaveBeenCalledWith('agent-doc-1', {
|
|
content: 'xml updated',
|
|
editorData: { root: { children: [] } },
|
|
});
|
|
expect(headlessEditorMocks.applyLiteXML).toHaveBeenCalledWith([
|
|
{
|
|
action: 'replace',
|
|
delay: true,
|
|
litexml: '<p id="node-1">xml updated</p>',
|
|
},
|
|
]);
|
|
expect(headlessEditorMocks.applyLiteXMLBatch).not.toHaveBeenCalled();
|
|
expect(result?.content).toBe('xml updated');
|
|
});
|
|
});
|
|
|
|
describe('renameDocumentById', () => {
|
|
it('should save history before renaming a document', async () => {
|
|
mockModel.findById.mockResolvedValue({
|
|
agentId: 'agent-1',
|
|
content: 'content',
|
|
documentId: 'documents-1',
|
|
id: 'agent-doc-1',
|
|
title: 'Old title',
|
|
});
|
|
mockModel.rename.mockResolvedValue({
|
|
agentId: 'agent-1',
|
|
content: 'content',
|
|
documentId: 'documents-1',
|
|
id: 'agent-doc-1',
|
|
title: 'New title',
|
|
});
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
await service.renameDocumentById('agent-doc-1', 'New title', 'agent-1');
|
|
|
|
expect(mockDocumentService.trySaveCurrentDocumentHistory).toHaveBeenCalledWith(
|
|
'documents-1',
|
|
'llm_call',
|
|
);
|
|
expect(
|
|
mockDocumentService.trySaveCurrentDocumentHistory.mock.invocationCallOrder[0],
|
|
).toBeLessThan(mockModel.rename.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it('should reject renaming skill-managed documents', async () => {
|
|
mockModel.findById.mockResolvedValue({
|
|
agentId: 'agent-1',
|
|
content: 'content',
|
|
documentId: 'documents-1',
|
|
id: 'agent-doc-1',
|
|
templateId: 'agent-skill',
|
|
title: 'writer',
|
|
});
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
|
|
await expect(service.renameDocumentById('agent-doc-1', 'renamed', 'agent-1')).rejects.toThrow(
|
|
'Skill VFS documents must be renamed through skill-specific APIs',
|
|
);
|
|
expect(mockModel.rename).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('copyDocumentById', () => {
|
|
it('should reject copying skill-managed documents', async () => {
|
|
mockModel.findById.mockResolvedValue({
|
|
agentId: 'agent-1',
|
|
content: 'content',
|
|
documentId: 'documents-1',
|
|
id: 'agent-doc-1',
|
|
templateId: 'agent-skill',
|
|
title: 'SKILL.md',
|
|
});
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
|
|
await expect(service.copyDocumentById('agent-doc-1', 'copy', 'agent-1')).rejects.toThrow(
|
|
'Skill VFS documents must be copied through skill-specific APIs',
|
|
);
|
|
expect(mockModel.copy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('hasDocuments', () => {
|
|
it('should use the model existence check', async () => {
|
|
mockModel.hasByAgent.mockResolvedValue(true);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.hasDocuments('agent-1');
|
|
|
|
expect(mockModel.hasByAgent).toHaveBeenCalledWith('agent-1');
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getAgentContextDocuments', () => {
|
|
it('should use the context-optimized model query and project only always-loaded docs', async () => {
|
|
mockModel.findContextByAgent.mockResolvedValue([
|
|
{
|
|
content: 'raw content',
|
|
contentCharCount: 11,
|
|
description: 'Always loaded',
|
|
editorData: { root: { children: [] } },
|
|
fileType: 'text/markdown',
|
|
filename: 'always.md',
|
|
id: 'always-doc',
|
|
isFolder: false,
|
|
loadRules: {},
|
|
metadata: { unused: true },
|
|
parentId: null,
|
|
policy: null,
|
|
policyLoad: 'always',
|
|
policyLoadFormat: 'raw',
|
|
policyLoadPosition: 'before-system',
|
|
sourceType: 'file',
|
|
templateId: null,
|
|
title: 'Always',
|
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
userId: 'user-1',
|
|
},
|
|
{
|
|
content: '',
|
|
contentCharCount: 12_000,
|
|
description: null,
|
|
documentId: 'doc-2',
|
|
editorData: { root: { children: [{ text: 'unused' }] } },
|
|
fileType: 'text/markdown',
|
|
filename: 'progressive.md',
|
|
id: 'progressive-doc',
|
|
isFolder: false,
|
|
loadRules: {},
|
|
metadata: { unused: true },
|
|
parentId: null,
|
|
policy: null,
|
|
policyLoad: 'progressive',
|
|
policyLoadFormat: 'raw',
|
|
policyLoadPosition: 'before-system',
|
|
sourceType: 'file',
|
|
templateId: null,
|
|
title: 'Progressive',
|
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
userId: 'user-1',
|
|
},
|
|
]);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.getAgentContextDocuments('agent-1');
|
|
|
|
expect(mockModel.findContextByAgent).toHaveBeenCalledWith('agent-1');
|
|
expect(result).toMatchObject([
|
|
{ content: 'raw content', id: 'always-doc' },
|
|
{ content: '', contentCharCount: 12_000, id: 'progressive-doc' },
|
|
]);
|
|
expect(result[0]).not.toHaveProperty('editorData');
|
|
expect(result[0]).not.toHaveProperty('metadata');
|
|
expect(result[0]).not.toHaveProperty('userId');
|
|
});
|
|
});
|
|
|
|
describe('associateDocument', () => {
|
|
it('should delegate to agentDocumentModel.associate', async () => {
|
|
mockModel.associate.mockResolvedValue({ id: 'ad-1' });
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.associateDocument('agent-1', 'doc-1');
|
|
|
|
expect(mockModel.associate).toHaveBeenCalledWith({ agentId: 'agent-1', documentId: 'doc-1' });
|
|
expect(result).toEqual({ id: 'ad-1' });
|
|
});
|
|
});
|
|
|
|
describe('getAgentSkills', () => {
|
|
// Inject docs with the derive flags already set so we test the
|
|
// bundle → index-child → identifier mapping in isolation, not the
|
|
// model's deriveAgentDocumentFields projection.
|
|
const stubDocs = (docs: Array<Partial<any>>): any[] =>
|
|
docs.map((doc) => ({
|
|
content: '',
|
|
description: null,
|
|
filename: '',
|
|
isSkillBundle: false,
|
|
isSkillIndex: false,
|
|
parentId: null,
|
|
title: null,
|
|
...doc,
|
|
}));
|
|
const mockSkillDocs = (docs: Array<Partial<any>>) =>
|
|
mockModel.findSkillDocsByAgent.mockResolvedValue(stubDocs(docs));
|
|
|
|
it('returns an empty list when the agent has no skill bundles', async () => {
|
|
mockSkillDocs([
|
|
{ documentId: 'doc-1', filename: 'note.md', isSkillBundle: false },
|
|
{ documentId: 'doc-2', filename: 'web.md', isSkillBundle: false },
|
|
]);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.getAgentSkills('agent-1');
|
|
|
|
expect(mockModel.findSkillDocsByAgent).toHaveBeenCalledWith('agent-1');
|
|
expect(mockModel.findByAgent).not.toHaveBeenCalled();
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it('prefixes the identifier with `agent-skills:` and pulls content from the SKILL.md index child', async () => {
|
|
mockSkillDocs([
|
|
{
|
|
content: '',
|
|
description: 'Triage workflow',
|
|
documentId: 'bundle-1',
|
|
filename: 'bug-triage',
|
|
isSkillBundle: true,
|
|
title: 'Bug Triage',
|
|
},
|
|
{
|
|
content: '# Bug triage\n\nbody',
|
|
documentId: 'index-1',
|
|
filename: 'SKILL.md',
|
|
isSkillIndex: true,
|
|
parentId: 'bundle-1',
|
|
},
|
|
// Sibling non-index child — must be ignored.
|
|
{
|
|
content: 'reference',
|
|
documentId: 'asset-1',
|
|
filename: 'reference.md',
|
|
parentId: 'bundle-1',
|
|
},
|
|
]);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.getAgentSkills('agent-1');
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
content: '# Bug triage\n\nbody',
|
|
description: 'Triage workflow',
|
|
filename: 'bug-triage',
|
|
identifier: 'agent-skills:bug-triage',
|
|
name: 'agent-skills:bug-triage',
|
|
title: 'Bug Triage',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('falls back to the bundle row content when the index child is missing', async () => {
|
|
mockSkillDocs([
|
|
{
|
|
content: 'orphan body',
|
|
description: null,
|
|
documentId: 'orphan-1',
|
|
filename: 'orphan-skill',
|
|
isSkillBundle: true,
|
|
title: 'Orphan',
|
|
},
|
|
]);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.getAgentSkills('agent-1');
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
content: 'orphan body',
|
|
description: '',
|
|
filename: 'orphan-skill',
|
|
identifier: 'agent-skills:orphan-skill',
|
|
name: 'agent-skills:orphan-skill',
|
|
title: 'Orphan',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('emits empty content for a bundle with no index child and no body', async () => {
|
|
mockSkillDocs([
|
|
{
|
|
content: '',
|
|
documentId: 'empty-1',
|
|
filename: 'empty',
|
|
isSkillBundle: true,
|
|
title: 'Empty',
|
|
},
|
|
]);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const [skill] = await service.getAgentSkills('agent-1');
|
|
|
|
expect(skill.content).toBe('');
|
|
expect(skill.identifier).toBe('agent-skills:empty');
|
|
});
|
|
|
|
it('returns one entry per skill bundle and ignores non-bundle docs', async () => {
|
|
mockSkillDocs([
|
|
{
|
|
documentId: 'b-1',
|
|
filename: 'one',
|
|
isSkillBundle: true,
|
|
title: 'One',
|
|
},
|
|
{
|
|
content: 'one body',
|
|
documentId: 'b-1-idx',
|
|
isSkillIndex: true,
|
|
parentId: 'b-1',
|
|
},
|
|
{
|
|
documentId: 'b-2',
|
|
filename: 'two',
|
|
isSkillBundle: true,
|
|
title: 'Two',
|
|
},
|
|
{
|
|
content: 'two body',
|
|
documentId: 'b-2-idx',
|
|
isSkillIndex: true,
|
|
parentId: 'b-2',
|
|
},
|
|
// Unrelated regular doc.
|
|
{ documentId: 'note', filename: 'note.md' },
|
|
]);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.getAgentSkills('agent-1');
|
|
|
|
expect(result.map((s) => s.identifier)).toEqual(['agent-skills:one', 'agent-skills:two']);
|
|
expect(result.map((s) => s.content)).toEqual(['one body', 'two body']);
|
|
});
|
|
|
|
it('matches index children strictly by parentId — does not leak across bundles', async () => {
|
|
mockSkillDocs([
|
|
{ documentId: 'b-1', filename: 'first', isSkillBundle: true },
|
|
{ documentId: 'b-2', filename: 'second', isSkillBundle: true },
|
|
// Only b-2 has an index child; b-1 must fall back to its own (empty)
|
|
// content rather than borrow b-2's content.
|
|
{
|
|
content: 'second body',
|
|
documentId: 'b-2-idx',
|
|
isSkillIndex: true,
|
|
parentId: 'b-2',
|
|
},
|
|
]);
|
|
|
|
const service = new AgentDocumentsService(db, userId);
|
|
const result = await service.getAgentSkills('agent-1');
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result.find((s) => s.filename === 'first')?.content).toBe('');
|
|
expect(result.find((s) => s.filename === 'second')?.content).toBe('second body');
|
|
});
|
|
});
|
|
});
|