mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(agent-management): paginate searchAgent with real totals + wire 8 packages into CI (#15448)
* ✨ feat(agent-management): paginate searchAgent with real totals and cap notice The searchAgent tool silently clamped limit to 20 with no pagination and reported totalCount as the returned page size, so models (and users) could never discover agents beyond the 20 most recently updated ones. - AgentModel: extract shared where builder, add countAgents (same conditions as queryAgents) - lambda router + client agent service: expose countAgents - server tool runtime & AgentManagerRuntime: pass offset through, report real totals (workspace + marketplace), emit explicit notes when the requested limit is capped and when more pages exist, explain out-of-range offsets instead of claiming no matches - manifest: add offset param, document pagination - agent-manager-runtime: add vitest config + test scripts (suite was previously unrunnable), repair stale store mocks * 👷 build(ci): wire 8 tested packages into the package test workflow An audit found 8 packages carrying test:coverage scripts that were never added to the CI PACKAGES allowlist, so their suites never ran: - agent-gateway-client, device-gateway-client, device-identity, eval-dataset-parser: already green, added as-is - eval-rubric, fetch-sse: had no package-level vitest config, so vitest fell back to the root config whose setup/aliases break outside src/ — added minimal configs - heterogeneous-agents: one assertion drifted (labels registry gained amp/hermes/openclaw/opencode) with nobody noticing — updated - agent-manager-runtime: wired in the previous commit All 8 verified locally with the exact CI command (bun run --filter <pkg> test:coverage). * ✅ test(agent-management): cover searchAgent error path and market totalCount fallback Codecov flagged 3 uncovered lines in the patch: the searchAgents catch block (2 misses) and the totalCount ?? items.length fallback (1 partial). Add the missing failure-path and fallback tests on both execution paths (client AgentManagerRuntime + server tool runtime).
This commit is contained in:
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Test Packages
|
||||
env:
|
||||
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank'
|
||||
PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank @lobechat/agent-gateway-client @lobechat/agent-manager-runtime @lobechat/device-gateway-client @lobechat/device-identity @lobechat/eval-dataset-parser @lobechat/eval-rubric @lobechat/fetch-sse @lobechat/heterogeneous-agents'
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest --coverage --silent='passed-only'",
|
||||
"test:update": "vitest -u"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lobechat/const": "workspace:*",
|
||||
"@lobechat/prompts": "workspace:*",
|
||||
|
||||
@@ -49,6 +49,7 @@ import type {
|
||||
InstallPluginState,
|
||||
MarketToolItem,
|
||||
SearchAgentParams,
|
||||
SearchAgentSource,
|
||||
SearchAgentState,
|
||||
SearchMarketToolsParams,
|
||||
SearchMarketToolsState,
|
||||
@@ -58,6 +59,9 @@ import type {
|
||||
UpdatePromptState,
|
||||
} from './types';
|
||||
|
||||
/** Max results per searchAgents call (mirrored in the tool manifests: "max: 20") */
|
||||
const MAX_SEARCH_AGENT_LIMIT = 20;
|
||||
|
||||
export class AgentManagerRuntime {
|
||||
private agentService: IAgentService;
|
||||
private discoverService: IDiscoverService;
|
||||
@@ -382,15 +386,20 @@ export class AgentManagerRuntime {
|
||||
async searchAgents(params: SearchAgentParams): Promise<BuiltinToolResult> {
|
||||
try {
|
||||
const source = params.source || 'all';
|
||||
const limit = Math.min(params.limit || 10, 20);
|
||||
const limit = Math.min(params.limit || 10, MAX_SEARCH_AGENT_LIMIT);
|
||||
const offset = Math.max(params.offset || 0, 0);
|
||||
const agents: AgentSearchItem[] = [];
|
||||
|
||||
let userTotal = 0;
|
||||
let marketTotal = 0;
|
||||
|
||||
// Search user's agents
|
||||
if (source === 'user' || source === 'all') {
|
||||
const userAgents = await this.agentService.queryAgents({
|
||||
keyword: params.keyword,
|
||||
limit,
|
||||
});
|
||||
const [userAgents, total] = await Promise.all([
|
||||
this.agentService.queryAgents({ keyword: params.keyword, limit, offset }),
|
||||
this.agentService.countAgents({ keyword: params.keyword }),
|
||||
]);
|
||||
userTotal = total;
|
||||
|
||||
agents.push(
|
||||
...userAgents.map(
|
||||
@@ -412,13 +421,14 @@ export class AgentManagerRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
// Search marketplace agents
|
||||
// Search marketplace agents (first page only — offset does not apply)
|
||||
if (source === 'market' || source === 'all') {
|
||||
const marketAgents = await this.discoverService.getAssistantList({
|
||||
pageSize: limit,
|
||||
q: params.keyword,
|
||||
...(params.category && { category: params.category }),
|
||||
});
|
||||
marketTotal = marketAgents.totalCount ?? marketAgents.items.length;
|
||||
|
||||
agents.push(
|
||||
...marketAgents.items.map((agent) => ({
|
||||
@@ -433,21 +443,53 @@ export class AgentManagerRuntime {
|
||||
}
|
||||
|
||||
const uniqueAgents = agents.slice(0, limit);
|
||||
const totalCount = userTotal + marketTotal;
|
||||
|
||||
const agentList = uniqueAgents
|
||||
.map((a) => `- ${a.title || 'Untitled'} (${a.id})${a.isMarket ? ' [Market]' : ''}`)
|
||||
.join('\n');
|
||||
// hasMore tracks workspace agents only: marketplace results are not offset-paged
|
||||
const shownUserCount = uniqueAgents.filter((a) => !a.isMarket).length;
|
||||
const hasMore = offset + shownUserCount < userTotal;
|
||||
|
||||
const headerBySource: Record<SearchAgentSource, string> = {
|
||||
all: `Found ${userTotal} agents in your workspace and ${marketTotal} in the marketplace, showing ${uniqueAgents.length}:`,
|
||||
market: `Found ${marketTotal} agents in the marketplace, showing the first ${uniqueAgents.length}:`,
|
||||
user: `Found ${userTotal} agents in your workspace, showing ${offset + 1}-${offset + uniqueAgents.length}:`,
|
||||
};
|
||||
|
||||
const notes: string[] = [];
|
||||
if (params.limit && params.limit > MAX_SEARCH_AGENT_LIMIT) {
|
||||
notes.push(
|
||||
`Note: requested limit ${params.limit} exceeds the maximum of ${MAX_SEARCH_AGENT_LIMIT}, so results were capped at ${MAX_SEARCH_AGENT_LIMIT} per call.`,
|
||||
);
|
||||
}
|
||||
if (hasMore) {
|
||||
notes.push(
|
||||
`More workspace agents available: call searchAgent with offset=${offset + shownUserCount}${source === 'all' ? ` and source="user"` : ''} to get the next page.`,
|
||||
);
|
||||
}
|
||||
|
||||
let content: string;
|
||||
if (uniqueAgents.length === 0) {
|
||||
content =
|
||||
totalCount === 0
|
||||
? 'No agents found matching your search criteria.'
|
||||
: `No agents at offset ${offset}; only ${totalCount} agents match. Retry with a smaller offset.`;
|
||||
} else {
|
||||
const agentList = uniqueAgents
|
||||
.map((a) => `- ${a.title || 'Untitled'} (${a.id})${a.isMarket ? ' [Market]' : ''}`)
|
||||
.join('\n');
|
||||
content = `${headerBySource[source]}\n${agentList}`;
|
||||
}
|
||||
if (notes.length > 0) content += `\n\n${notes.join('\n')}`;
|
||||
|
||||
return {
|
||||
content:
|
||||
uniqueAgents.length > 0
|
||||
? `Found ${uniqueAgents.length} agents:\n${agentList}`
|
||||
: 'No agents found matching your search criteria.',
|
||||
content,
|
||||
state: {
|
||||
agents: uniqueAgents,
|
||||
hasMore,
|
||||
keyword: params.keyword,
|
||||
offset,
|
||||
source,
|
||||
totalCount: uniqueAgents.length,
|
||||
totalCount,
|
||||
} as SearchAgentState,
|
||||
success: true,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { IAgentService, IDiscoverService } from '../types';
|
||||
|
||||
// Create mock services
|
||||
const mockAgentService: IAgentService = {
|
||||
countAgents: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
duplicateAgent: vi.fn(),
|
||||
getAgentConfigById: vi.fn(),
|
||||
@@ -30,8 +31,10 @@ const mockAgentMeta = {
|
||||
|
||||
vi.mock('@/store/agent', () => ({
|
||||
getAgentStoreState: vi.fn(() => ({
|
||||
agentMap: { 'agent-id': mockAgentConfig },
|
||||
appendStreamingSystemRole: vi.fn(),
|
||||
finishStreamingSystemRole: vi.fn(),
|
||||
internal_dispatchAgentMap: vi.fn(),
|
||||
optimisticUpdateAgentConfig: vi.fn(),
|
||||
optimisticUpdateAgentMeta: vi.fn(),
|
||||
startStreamingSystemRole: vi.fn(),
|
||||
@@ -245,6 +248,7 @@ describe('AgentManagerRuntime', () => {
|
||||
backgroundColor: null,
|
||||
},
|
||||
] as any);
|
||||
vi.mocked(mockAgentService.countAgents).mockResolvedValue(2);
|
||||
|
||||
const result = await runtime.searchAgents({
|
||||
keyword: 'test',
|
||||
@@ -252,12 +256,13 @@ describe('AgentManagerRuntime', () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('Found 2 agents');
|
||||
expect(result.content).toContain('Found 2 agents in your workspace, showing 1-2');
|
||||
expect(result.state).toMatchObject({
|
||||
agents: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'agent-1', isMarket: false }),
|
||||
expect.objectContaining({ id: 'agent-2', isMarket: false }),
|
||||
]),
|
||||
hasMore: false,
|
||||
source: 'user',
|
||||
totalCount: 2,
|
||||
});
|
||||
@@ -281,11 +286,13 @@ describe('AgentManagerRuntime', () => {
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockAgentService.countAgents).not.toHaveBeenCalled();
|
||||
expect(result.state).toMatchObject({
|
||||
agents: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'market-agent-1', isMarket: true }),
|
||||
]),
|
||||
source: 'market',
|
||||
totalCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,6 +306,7 @@ describe('AgentManagerRuntime', () => {
|
||||
description: null,
|
||||
},
|
||||
] as any);
|
||||
vi.mocked(mockAgentService.countAgents).mockResolvedValue(1);
|
||||
vi.mocked(mockDiscoverService.getAssistantList).mockResolvedValue({
|
||||
items: [{ identifier: 'market-agent', title: 'Market Agent' } as any],
|
||||
totalCount: 1,
|
||||
@@ -309,16 +317,119 @@ describe('AgentManagerRuntime', () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.state?.source).toBe('all');
|
||||
expect(result.state?.agents).toHaveLength(2);
|
||||
expect(result.state?.totalCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should return no agents found message', async () => {
|
||||
vi.mocked(mockAgentService.queryAgents).mockResolvedValue([]);
|
||||
vi.mocked(mockAgentService.countAgents).mockResolvedValue(0);
|
||||
|
||||
const result = await runtime.searchAgents({ keyword: 'nonexistent', source: 'user' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('No agents found');
|
||||
});
|
||||
|
||||
it('should report the real total and a pagination hint when more agents exist', async () => {
|
||||
const page = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `agent-${i}`,
|
||||
title: `Agent ${i}`,
|
||||
description: null,
|
||||
avatar: null,
|
||||
backgroundColor: null,
|
||||
}));
|
||||
vi.mocked(mockAgentService.queryAgents).mockResolvedValue(page as any);
|
||||
vi.mocked(mockAgentService.countAgents).mockResolvedValue(137);
|
||||
|
||||
const result = await runtime.searchAgents({ limit: 20, source: 'user' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('Found 137 agents in your workspace, showing 1-20');
|
||||
expect(result.content).toContain('call searchAgent with offset=20');
|
||||
expect(result.state).toMatchObject({ hasMore: true, offset: 0, totalCount: 137 });
|
||||
});
|
||||
|
||||
it('should pass offset through and compute the next page hint from it', async () => {
|
||||
const page = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `agent-${20 + i}`,
|
||||
title: `Agent ${20 + i}`,
|
||||
description: null,
|
||||
avatar: null,
|
||||
backgroundColor: null,
|
||||
}));
|
||||
vi.mocked(mockAgentService.queryAgents).mockResolvedValue(page as any);
|
||||
vi.mocked(mockAgentService.countAgents).mockResolvedValue(50);
|
||||
|
||||
const result = await runtime.searchAgents({ limit: 20, offset: 20, source: 'user' });
|
||||
|
||||
expect(mockAgentService.queryAgents).toHaveBeenCalledWith({
|
||||
keyword: undefined,
|
||||
limit: 20,
|
||||
offset: 20,
|
||||
});
|
||||
expect(result.content).toContain('Found 50 agents in your workspace, showing 21-40');
|
||||
expect(result.content).toContain('call searchAgent with offset=40');
|
||||
expect(result.state).toMatchObject({ hasMore: true, offset: 20 });
|
||||
});
|
||||
|
||||
it('should note when the requested limit is capped', async () => {
|
||||
vi.mocked(mockAgentService.queryAgents).mockResolvedValue([
|
||||
{
|
||||
id: 'agent-1',
|
||||
title: 'Agent One',
|
||||
description: null,
|
||||
avatar: null,
|
||||
backgroundColor: null,
|
||||
},
|
||||
] as any);
|
||||
vi.mocked(mockAgentService.countAgents).mockResolvedValue(1);
|
||||
|
||||
const result = await runtime.searchAgents({ limit: 50, source: 'user' });
|
||||
|
||||
expect(mockAgentService.queryAgents).toHaveBeenCalledWith({
|
||||
keyword: undefined,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
expect(result.content).toContain(
|
||||
'requested limit 50 exceeds the maximum of 20, so results were capped at 20',
|
||||
);
|
||||
});
|
||||
|
||||
it('should explain an out-of-range offset instead of claiming no matches', async () => {
|
||||
vi.mocked(mockAgentService.queryAgents).mockResolvedValue([]);
|
||||
vi.mocked(mockAgentService.countAgents).mockResolvedValue(37);
|
||||
|
||||
const result = await runtime.searchAgents({ offset: 200, source: 'user' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('No agents at offset 200; only 37 agents match');
|
||||
});
|
||||
|
||||
it('should fall back to item count when marketplace omits totalCount', async () => {
|
||||
vi.mocked(mockDiscoverService.getAssistantList).mockResolvedValue({
|
||||
items: [
|
||||
{ identifier: 'market-agent-1', title: 'Market Agent' } as any,
|
||||
{ identifier: 'market-agent-2', title: 'Another Agent' } as any,
|
||||
],
|
||||
totalCount: undefined,
|
||||
} as any);
|
||||
|
||||
const result = await runtime.searchAgents({ source: 'market' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.state?.totalCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle search failure', async () => {
|
||||
vi.mocked(mockAgentService.queryAgents).mockRejectedValue(new Error('DB unavailable'));
|
||||
vi.mocked(mockAgentService.countAgents).mockResolvedValue(0);
|
||||
|
||||
const result = await runtime.searchAgents({ source: 'user' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.content).toContain('Failed to search agents');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
|
||||
@@ -8,13 +8,14 @@ import type { PartialDeep } from 'type-fest';
|
||||
* Can be implemented by client-side or server-side services
|
||||
*/
|
||||
export interface IAgentService {
|
||||
countAgents: (params?: { keyword?: string }) => Promise<number>;
|
||||
createAgent: (params: { config: Record<string, unknown> }) => Promise<{
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
}>;
|
||||
duplicateAgent: (agentId: string, newTitle?: string) => Promise<{ agentId: string } | null>;
|
||||
getAgentConfigById: (agentId: string) => Promise<LobeAgentConfig | null>;
|
||||
queryAgents: (params: { keyword?: string; limit?: number }) => Promise<
|
||||
queryAgents: (params: { keyword?: string; limit?: number; offset?: number }) => Promise<
|
||||
Array<{
|
||||
avatar?: string | null;
|
||||
backgroundColor?: string | null;
|
||||
@@ -133,6 +134,8 @@ export interface SearchAgentParams {
|
||||
category?: string;
|
||||
keyword?: string;
|
||||
limit?: number;
|
||||
/** Number of workspace agents to skip, for paginating beyond the per-call limit */
|
||||
offset?: number;
|
||||
source?: SearchAgentSource;
|
||||
}
|
||||
|
||||
@@ -147,8 +150,13 @@ export interface AgentSearchItem {
|
||||
|
||||
export interface SearchAgentState {
|
||||
agents: AgentSearchItem[];
|
||||
/** Whether more workspace agents exist beyond the returned page */
|
||||
hasMore?: boolean;
|
||||
keyword?: string;
|
||||
/** The offset used for this page of workspace agents */
|
||||
offset?: number;
|
||||
source: SearchAgentSource;
|
||||
/** Real total of matching agents across the searched sources (not just the returned page) */
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// The runtime imports app stores via `@/*`; tests mock them but the
|
||||
// specifiers still need to resolve against the app src directory.
|
||||
alias: {
|
||||
'@': resolve(__dirname, '../../src'),
|
||||
},
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'lcov', 'text-summary'],
|
||||
},
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
});
|
||||
@@ -242,7 +242,7 @@ export const AgentManagementManifest: BuiltinToolManifest = {
|
||||
// ==================== Search ====================
|
||||
{
|
||||
description:
|
||||
"Search for agents in your workspace or the marketplace. Use 'user' source to find your own agents, 'market' for marketplace agents, or 'all' for both.",
|
||||
"Search for agents in your workspace or the marketplace. Use 'user' source to find your own agents, 'market' for marketplace agents, or 'all' for both. Results are paginated: the response reports the real total, and you can page through workspace agents with 'offset'.",
|
||||
name: AgentManagementApiName.searchAgent,
|
||||
parameters: {
|
||||
properties: {
|
||||
@@ -266,6 +266,12 @@ export const AgentManagementManifest: BuiltinToolManifest = {
|
||||
description: 'Maximum number of results to return (default: 10, max: 20)',
|
||||
type: 'number',
|
||||
},
|
||||
offset: {
|
||||
default: 0,
|
||||
description:
|
||||
'Number of workspace agents to skip, for pagination (e.g. offset=20 with limit=20 returns agents 21-40). Not applied to marketplace results.',
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
type: 'object',
|
||||
|
||||
@@ -193,6 +193,10 @@ export interface SearchAgentParams {
|
||||
* Maximum number of results (default: 10)
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Number of workspace agents to skip, for paginating beyond the per-call limit
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* Search source: 'user' (own agents), 'market' (marketplace), 'all' (both)
|
||||
*/
|
||||
@@ -231,16 +235,24 @@ export interface SearchAgentState {
|
||||
* List of matching agents
|
||||
*/
|
||||
agents: AgentSearchItem[];
|
||||
/**
|
||||
* Whether more workspace agents exist beyond the returned page
|
||||
*/
|
||||
hasMore?: boolean;
|
||||
/**
|
||||
* The search keyword used
|
||||
*/
|
||||
keyword?: string;
|
||||
/**
|
||||
* The offset used for this page of workspace agents
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* The search source used
|
||||
*/
|
||||
source: SearchAgentSource;
|
||||
/**
|
||||
* Total count of matching agents
|
||||
* Real total of matching agents across the searched sources (not just the returned page)
|
||||
*/
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
@@ -1749,6 +1749,70 @@ describe('AgentModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('countAgents', () => {
|
||||
it('should count all non-virtual agents regardless of pagination', async () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await agentModel.create({
|
||||
title: `Agent ${i}`,
|
||||
virtual: false,
|
||||
});
|
||||
}
|
||||
await agentModel.create({
|
||||
title: 'Virtual Agent',
|
||||
virtual: true,
|
||||
});
|
||||
|
||||
const total = await agentModel.countAgents();
|
||||
|
||||
expect(total).toBe(5);
|
||||
// count stays the full total even when queryAgents is limited
|
||||
const limitedResults = await agentModel.queryAgents({ limit: 2 });
|
||||
expect(limitedResults.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should apply the same keyword filter as queryAgents', async () => {
|
||||
await agentModel.create({
|
||||
title: 'Code Assistant',
|
||||
description: 'Helps with coding',
|
||||
virtual: false,
|
||||
});
|
||||
await agentModel.create({
|
||||
title: 'Writer',
|
||||
description: 'Helps with writing tasks',
|
||||
virtual: false,
|
||||
});
|
||||
await agentModel.create({
|
||||
title: 'Designer',
|
||||
description: 'Helps with design code review',
|
||||
virtual: false,
|
||||
});
|
||||
|
||||
// matches 'Code Assistant' (title) and 'Designer' (description)
|
||||
expect(await agentModel.countAgents({ keyword: 'code' })).toBe(2);
|
||||
expect(await agentModel.countAgents({ keyword: 'writing' })).toBe(1);
|
||||
expect(await agentModel.countAgents({ keyword: 'nonexistent' })).toBe(0);
|
||||
});
|
||||
|
||||
it('should only count agents for the current user', async () => {
|
||||
await agentModel.create({ title: 'User1 Agent', virtual: false });
|
||||
await agentModel2.create({ title: 'User2 Agent', virtual: false });
|
||||
|
||||
expect(await agentModel.countAgents()).toBe(1);
|
||||
expect(await agentModel2.countAgents()).toBe(1);
|
||||
});
|
||||
|
||||
it('should count agents with null virtual field (treat as non-virtual)', async () => {
|
||||
await serverDB.insert(agents).values({
|
||||
id: 'null-virtual-agent-count',
|
||||
title: 'Null Virtual Agent',
|
||||
userId,
|
||||
virtual: null as unknown as boolean,
|
||||
});
|
||||
|
||||
expect(await agentModel.countAgents()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkByMarketIdentifier', () => {
|
||||
it('should return true when agent with marketIdentifier exists', async () => {
|
||||
await serverDB.insert(agents).values({
|
||||
|
||||
@@ -101,12 +101,10 @@ export class AgentModel {
|
||||
};
|
||||
|
||||
/**
|
||||
* Query non-virtual agents with optional keyword filter.
|
||||
* Returns minimal agent info (id, title, description, avatar, backgroundColor).
|
||||
* Excludes virtual agents (like inbox, supervisors, etc).
|
||||
* Build the where condition shared by queryAgents / countAgents:
|
||||
* non-virtual agents of the current user, with optional keyword filter.
|
||||
*/
|
||||
queryAgents = async (params?: { keyword?: string; limit?: number; offset?: number }) => {
|
||||
const { keyword, limit = 9999, offset = 0 } = params ?? {};
|
||||
private buildQueryAgentsWhere = (keyword?: string) => {
|
||||
// Include agents where virtual is false OR null (legacy data without virtual field)
|
||||
const baseConditions = and(
|
||||
eq(agents.userId, this.userId),
|
||||
@@ -114,12 +112,22 @@ export class AgentModel {
|
||||
);
|
||||
|
||||
// Add keyword search condition if provided
|
||||
const searchCondition = keyword
|
||||
return keyword
|
||||
? and(
|
||||
baseConditions,
|
||||
or(ilike(agents.title, `%${keyword}%`), ilike(agents.description, `%${keyword}%`)),
|
||||
)
|
||||
: baseConditions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Query non-virtual agents with optional keyword filter.
|
||||
* Returns minimal agent info (id, title, description, avatar, backgroundColor).
|
||||
* Excludes virtual agents (like inbox, supervisors, etc).
|
||||
*/
|
||||
queryAgents = async (params?: { keyword?: string; limit?: number; offset?: number }) => {
|
||||
const { keyword, limit = 9999, offset = 0 } = params ?? {};
|
||||
const searchCondition = this.buildQueryAgentsWhere(keyword);
|
||||
|
||||
return this.db
|
||||
.select({
|
||||
@@ -136,6 +144,19 @@ export class AgentModel {
|
||||
.offset(offset);
|
||||
};
|
||||
|
||||
/**
|
||||
* Count non-virtual agents matching the same conditions as queryAgents.
|
||||
* Used to report real totals (and pagination) when queryAgents is limited.
|
||||
*/
|
||||
countAgents = async (params?: { keyword?: string }): Promise<number> => {
|
||||
const result = await this.db
|
||||
.select({ count: count() })
|
||||
.from(agents)
|
||||
.where(this.buildQueryAgentsWhere(params?.keyword));
|
||||
|
||||
return result[0]?.count ?? 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get minimal agent info (avatar, title, backgroundColor) by IDs.
|
||||
* For inbox agent (slug='inbox'), falls back to LobeAI defaults when avatar/title are missing.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'lcov', 'text-summary'],
|
||||
},
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'lcov', 'text-summary'],
|
||||
},
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
});
|
||||
@@ -26,8 +26,12 @@ describe('heterogeneous agent config', () => {
|
||||
|
||||
it('derives display labels from the shared config source', () => {
|
||||
expect(HETEROGENEOUS_TYPE_LABELS).toEqual({
|
||||
'amp': 'Amp',
|
||||
'claude-code': 'Claude Code',
|
||||
'codex': 'Codex',
|
||||
'hermes': 'Hermes',
|
||||
'openclaw': 'OpenClaw',
|
||||
'opencode': 'OpenCode',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,16 @@ export const agentRouter = router({
|
||||
return ctx.agentModel.checkByMarketIdentifier(input.marketIdentifier);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Count non-virtual agents with optional keyword filter, matching the
|
||||
* conditions of queryAgents. Lets paginated callers report real totals.
|
||||
*/
|
||||
countAgents: agentProcedure
|
||||
.input(z.object({ keyword: z.string().optional() }).optional())
|
||||
.query(async ({ input, ctx }) => {
|
||||
return ctx.agentModel.countAgents(input);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new agent with session
|
||||
* Returns the created agent ID and session ID
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { agentManagementRuntime } from '../agentManagement';
|
||||
|
||||
const { mockCountAgents, mockGetAssistantList, mockQueryAgents } = vi.hoisted(() => ({
|
||||
mockCountAgents: vi.fn(),
|
||||
mockGetAssistantList: vi.fn(),
|
||||
mockQueryAgents: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/agent', () => ({
|
||||
AgentModel: vi.fn(() => ({
|
||||
countAgents: mockCountAgents,
|
||||
queryAgents: mockQueryAgents,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/plugin', () => ({
|
||||
PluginModel: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/discover', () => ({
|
||||
DiscoverService: vi.fn(() => ({
|
||||
getAssistantList: mockGetAssistantList,
|
||||
})),
|
||||
}));
|
||||
|
||||
const createRuntime = () =>
|
||||
agentManagementRuntime.factory({
|
||||
serverDB: {} as never,
|
||||
toolManifestMap: {},
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
const makeAgents = (count: number, startIndex = 0) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
avatar: null,
|
||||
backgroundColor: null,
|
||||
description: null,
|
||||
id: `agent-${startIndex + i}`,
|
||||
title: `Agent ${startIndex + i}`,
|
||||
}));
|
||||
|
||||
describe('agentManagementRuntime', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('declares the agent management runtime identifier', () => {
|
||||
expect(agentManagementRuntime.identifier).toBe('lobe-agent-management');
|
||||
});
|
||||
|
||||
it('throws if required server context is missing', () => {
|
||||
expect(() => agentManagementRuntime.factory({ toolManifestMap: {} })).toThrow(
|
||||
'userId and serverDB are required for Agent Management execution',
|
||||
);
|
||||
});
|
||||
|
||||
describe('searchAgent', () => {
|
||||
it('reports the real total and a pagination hint when more agents exist', async () => {
|
||||
mockQueryAgents.mockResolvedValue(makeAgents(20));
|
||||
mockCountAgents.mockResolvedValue(137);
|
||||
|
||||
const runtime = createRuntime();
|
||||
const result = await runtime.searchAgent({ limit: 20, source: 'user' });
|
||||
|
||||
expect(mockQueryAgents).toHaveBeenCalledWith({ keyword: undefined, limit: 20, offset: 0 });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('Found 137 agents in your workspace, showing 1-20');
|
||||
expect(result.content).toContain('call searchAgent with offset=20');
|
||||
expect(result.state).toMatchObject({ hasMore: true, offset: 0, totalCount: 137 });
|
||||
});
|
||||
|
||||
it('passes offset through and computes the next page hint from it', async () => {
|
||||
mockQueryAgents.mockResolvedValue(makeAgents(20, 20));
|
||||
mockCountAgents.mockResolvedValue(50);
|
||||
|
||||
const runtime = createRuntime();
|
||||
const result = await runtime.searchAgent({ limit: 20, offset: 20, source: 'user' });
|
||||
|
||||
expect(mockQueryAgents).toHaveBeenCalledWith({ keyword: undefined, limit: 20, offset: 20 });
|
||||
expect(result.content).toContain('Found 50 agents in your workspace, showing 21-40');
|
||||
expect(result.content).toContain('call searchAgent with offset=40');
|
||||
expect(result.state).toMatchObject({ hasMore: true, offset: 20, totalCount: 50 });
|
||||
});
|
||||
|
||||
it('notes when the requested limit is capped', async () => {
|
||||
mockQueryAgents.mockResolvedValue(makeAgents(1));
|
||||
mockCountAgents.mockResolvedValue(1);
|
||||
|
||||
const runtime = createRuntime();
|
||||
const result = await runtime.searchAgent({ limit: 50, source: 'user' });
|
||||
|
||||
expect(mockQueryAgents).toHaveBeenCalledWith({ keyword: undefined, limit: 20, offset: 0 });
|
||||
expect(result.content).toContain(
|
||||
'requested limit 50 exceeds the maximum of 20, so results were capped at 20',
|
||||
);
|
||||
expect(result.state).toMatchObject({ hasMore: false });
|
||||
});
|
||||
|
||||
it('returns no agents found when nothing matches', async () => {
|
||||
mockQueryAgents.mockResolvedValue([]);
|
||||
mockCountAgents.mockResolvedValue(0);
|
||||
|
||||
const runtime = createRuntime();
|
||||
const result = await runtime.searchAgent({ keyword: 'nonexistent', source: 'user' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('No agents found matching your search criteria.');
|
||||
});
|
||||
|
||||
it('explains an out-of-range offset instead of claiming no matches', async () => {
|
||||
mockQueryAgents.mockResolvedValue([]);
|
||||
mockCountAgents.mockResolvedValue(37);
|
||||
|
||||
const runtime = createRuntime();
|
||||
const result = await runtime.searchAgent({ offset: 200, source: 'user' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.content).toContain('No agents at offset 200; only 37 agents match');
|
||||
});
|
||||
|
||||
it('searches the marketplace without counting workspace agents', async () => {
|
||||
mockGetAssistantList.mockResolvedValue({
|
||||
items: [{ identifier: 'market-agent-1', title: 'Market Agent' }],
|
||||
totalCount: 42,
|
||||
});
|
||||
|
||||
const runtime = createRuntime();
|
||||
const result = await runtime.searchAgent({ keyword: 'market', source: 'market' });
|
||||
|
||||
expect(mockCountAgents).not.toHaveBeenCalled();
|
||||
expect(result.content).toContain('Found 42 agents in the marketplace, showing the first 1');
|
||||
expect(result.state).toMatchObject({
|
||||
agents: [expect.objectContaining({ id: 'market-agent-1', isMarket: true })],
|
||||
totalCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('combines workspace and marketplace totals for source "all"', async () => {
|
||||
mockQueryAgents.mockResolvedValue(makeAgents(1));
|
||||
mockCountAgents.mockResolvedValue(30);
|
||||
mockGetAssistantList.mockResolvedValue({
|
||||
items: [{ identifier: 'market-agent-1', title: 'Market Agent' }],
|
||||
totalCount: 12,
|
||||
});
|
||||
|
||||
const runtime = createRuntime();
|
||||
const result = await runtime.searchAgent({ keyword: 'test' });
|
||||
|
||||
expect(result.content).toContain(
|
||||
'Found 30 agents in your workspace and 12 in the marketplace, showing 2',
|
||||
);
|
||||
// next-page hint should direct the model back to workspace-only pagination
|
||||
expect(result.content).toContain('call searchAgent with offset=1 and source="user"');
|
||||
expect(result.state).toMatchObject({ hasMore: true, totalCount: 42 });
|
||||
});
|
||||
|
||||
it('falls back to item count when marketplace omits totalCount', async () => {
|
||||
mockGetAssistantList.mockResolvedValue({
|
||||
items: [
|
||||
{ identifier: 'market-agent-1', title: 'Market Agent' },
|
||||
{ identifier: 'market-agent-2', title: 'Another Agent' },
|
||||
],
|
||||
totalCount: undefined,
|
||||
});
|
||||
|
||||
const runtime = createRuntime();
|
||||
const result = await runtime.searchAgent({ source: 'market' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.state).toMatchObject({ totalCount: 2 });
|
||||
});
|
||||
|
||||
it('handles search failure', async () => {
|
||||
mockQueryAgents.mockRejectedValue(new Error('DB unavailable'));
|
||||
mockCountAgents.mockResolvedValue(0);
|
||||
|
||||
const runtime = createRuntime();
|
||||
const result = await runtime.searchAgent({ source: 'user' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.content).toContain('Failed to search agents');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,9 @@ const handleError = (error: unknown, message: string): ToolExecutionResult => {
|
||||
return { content: `${message}: ${err.message}`, success: false };
|
||||
};
|
||||
|
||||
/** Max results per searchAgent call (mirrored in the tool manifest: "max: 20") */
|
||||
const MAX_SEARCH_AGENT_LIMIT = 20;
|
||||
|
||||
export const agentManagementRuntime: ServerRuntimeRegistration = {
|
||||
factory: (context) => {
|
||||
if (!context.userId || !context.serverDB) {
|
||||
@@ -205,7 +208,8 @@ export const agentManagementRuntime: ServerRuntimeRegistration = {
|
||||
searchAgent: async (params: SearchAgentParams): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
const source = params.source || 'all';
|
||||
const limit = Math.min(params.limit || 10, 20);
|
||||
const limit = Math.min(params.limit || 10, MAX_SEARCH_AGENT_LIMIT);
|
||||
const offset = Math.max(params.offset || 0, 0);
|
||||
const results: Array<{
|
||||
avatar?: string | null;
|
||||
backgroundColor?: string | null;
|
||||
@@ -215,17 +219,26 @@ export const agentManagementRuntime: ServerRuntimeRegistration = {
|
||||
title?: string | null;
|
||||
}> = [];
|
||||
|
||||
let userTotal = 0;
|
||||
let marketTotal = 0;
|
||||
|
||||
if (source === 'user' || source === 'all') {
|
||||
const userAgents = await agentModel.queryAgents({ keyword: params.keyword, limit });
|
||||
const [userAgents, total] = await Promise.all([
|
||||
agentModel.queryAgents({ keyword: params.keyword, limit, offset }),
|
||||
agentModel.countAgents({ keyword: params.keyword }),
|
||||
]);
|
||||
userTotal = total;
|
||||
results.push(...userAgents.map((a) => ({ ...a, isMarket: false })));
|
||||
}
|
||||
|
||||
// Marketplace search returns the first page only — offset does not apply
|
||||
if (source === 'market' || source === 'all') {
|
||||
const marketResult = await discoverService.getAssistantList({
|
||||
pageSize: limit,
|
||||
q: params.keyword,
|
||||
...(params.category && { category: params.category }),
|
||||
});
|
||||
marketTotal = marketResult.totalCount ?? marketResult.items.length;
|
||||
results.push(
|
||||
...marketResult.items.map((a) => ({
|
||||
avatar: a.avatar,
|
||||
@@ -239,20 +252,53 @@ export const agentManagementRuntime: ServerRuntimeRegistration = {
|
||||
}
|
||||
|
||||
const sliced = results.slice(0, limit);
|
||||
const list = sliced
|
||||
.map((a) => `- ${a.title || 'Untitled'} (${a.id})${a.isMarket ? ' [Market]' : ''}`)
|
||||
.join('\n');
|
||||
const totalCount = userTotal + marketTotal;
|
||||
|
||||
// hasMore tracks workspace agents only: marketplace results are not offset-paged
|
||||
const shownUserCount = sliced.filter((a) => !a.isMarket).length;
|
||||
const hasMore = offset + shownUserCount < userTotal;
|
||||
|
||||
const headerBySource: Record<typeof source, string> = {
|
||||
all: `Found ${userTotal} agents in your workspace and ${marketTotal} in the marketplace, showing ${sliced.length}:`,
|
||||
market: `Found ${marketTotal} agents in the marketplace, showing the first ${sliced.length}:`,
|
||||
user: `Found ${userTotal} agents in your workspace, showing ${offset + 1}-${offset + sliced.length}:`,
|
||||
};
|
||||
|
||||
const notes: string[] = [];
|
||||
if (params.limit && params.limit > MAX_SEARCH_AGENT_LIMIT) {
|
||||
notes.push(
|
||||
`Note: requested limit ${params.limit} exceeds the maximum of ${MAX_SEARCH_AGENT_LIMIT}, so results were capped at ${MAX_SEARCH_AGENT_LIMIT} per call.`,
|
||||
);
|
||||
}
|
||||
if (hasMore) {
|
||||
notes.push(
|
||||
`More workspace agents available: call searchAgent with offset=${offset + shownUserCount}${source === 'all' ? ` and source="user"` : ''} to get the next page.`,
|
||||
);
|
||||
}
|
||||
|
||||
let content: string;
|
||||
if (sliced.length === 0) {
|
||||
content =
|
||||
totalCount === 0
|
||||
? 'No agents found matching your search criteria.'
|
||||
: `No agents at offset ${offset}; only ${totalCount} agents match. Retry with a smaller offset.`;
|
||||
} else {
|
||||
const list = sliced
|
||||
.map((a) => `- ${a.title || 'Untitled'} (${a.id})${a.isMarket ? ' [Market]' : ''}`)
|
||||
.join('\n');
|
||||
content = `${headerBySource[source]}\n${list}`;
|
||||
}
|
||||
if (notes.length > 0) content += `\n\n${notes.join('\n')}`;
|
||||
|
||||
return {
|
||||
content:
|
||||
sliced.length > 0
|
||||
? `Found ${sliced.length} agents:\n${list}`
|
||||
: 'No agents found matching your search criteria.',
|
||||
content,
|
||||
state: {
|
||||
agents: sliced,
|
||||
hasMore,
|
||||
keyword: params.keyword,
|
||||
offset,
|
||||
source,
|
||||
totalCount: sliced.length,
|
||||
totalCount,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
|
||||
@@ -215,6 +215,13 @@ class AgentService {
|
||||
return lambdaClient.agent.queryAgents.query(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Count non-virtual agents with optional keyword filter, matching queryAgents conditions.
|
||||
*/
|
||||
countAgents = async (params?: { keyword?: string }) => {
|
||||
return lambdaClient.agent.countAgents.query(params);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pin or unpin an agent
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user