/assets`
+9. 在一个数据库事务中写入 Agent、非资源文件和 Skill 关系,并将资源相对路径指向新目录
+10. 数据库事务失败时删除本次新增资源目录;事务成功后删除该 Agent 的旧 hash 资源目录
+11. 成功或失败后删除上传 ZIP 和解压目录
+12. 清理 Agent 列表缓存并返回导入结果
+
+### 6.2 更新规则
+
+- 相同版本允许覆盖
+- 高版本允许升级
+- 低版本禁止覆盖高版本
+- `content_hash` 相同则返回“内容未变化”,不重复写入
+- 新 ZIP 是完整快照,新包不存在的旧文件和旧关系必须删除
+- 更新失败时旧版本保持不变
+- 第一版只保存当前版本,不保留版本历史
+
+更新前端采用重新上传 ZIP,不提供直接修改数据库字段的编辑表单。首次检测到同名 Agent 时返回更新摘要并请求用户确认,确认后再次提交覆盖请求。
+
+### 6.3 删除规则
+
+删除时将 `agents.is_delete` 设置为 `1`,并物理删除对应的 `agent_files`、`agent_skills` 和服务器资源目录。不得删除任何 Skill 市场记录。重新导入相同 `metadata.name` 时恢复 Agent,并使用新 ZIP 重建文件、资源和关系。
+
+数据库删除成功但资源目录清理失败时记录错误并进入后续清理,不回滚数据库删除结果。资源目录只能根据数据库中已校验的 Agent 名称和 hash 计算,禁止接受客户端传入的任意物理路径。
+
+## 7. API 设计
+
+第一版提供:
+
+- `GET /api/agents/list`:关键词、分类、分页查询
+- `GET /api/agents/detail`:Agent 详情、核心工作流、依赖 Skills
+- `GET /api/agents/related`:相关 Agent
+- `GET /api/agents/asset`:返回 Logo 或 Demo 二进制资源
+- `POST /api/agents/import-file`:导入或确认覆盖 Agent ZIP
+- `POST /api/agents/delete`:软删除 Agent
+
+资源接口根据数据库记录定位资源相对路径,校验解析后的绝对路径仍位于资源根目录内,再以文件流返回。接口根据已保存 MIME 返回正确 `Content-Type`,并对带 hash 的资源设置长期缓存头。列表和详情接口只返回资源 URL,不内嵌 Base64,也不返回服务器绝对路径。
+
+## 8. 列表页
+
+导航栏在 `Skills` 相邻位置增加 `Agents`,路由为 `/page/agents`。
+
+列表页沿用 Skill 市场的视觉语言:
+
+- 标题“Agent 市场”
+- 副标题“发现并导入适用于不同研发场景的 Agent”
+- 搜索名称、描述、标签和作者
+- 分类筛选复用 Skills 分类
+- 三列响应式卡片,移动端单列
+- 卡片展示 Logo、名称、描述、分类、最多三个标签、版本、作者、更新时间和依赖 Skill 数量
+- 默认按更新时间倒序
+- API 保留分页,当前总数不超过一页时不展示分页器
+- 不展示 Stars、下载量、收藏、多选、复制命令和排序器
+- 提供“导入 Agent”按钮
+- 更新入口只允许重新上传 ZIP,删除行为与 Skill 市场一致
+
+## 9. 详情页
+
+详情页路由为 `/page/agents/:name`,沿用 LobeHub 的信息层级,但保持 Doraemon 的现有视觉语言。
+
+### 9.1 顶部区域
+
+展示 Logo、名称、作者、版本、分类和标签。右侧提供“安装”和“使用”按钮;第一版点击后分别执行 `message.info('安装')` 和 `message.info('使用')`。
+
+### 9.2 页签
+
+详情页包含三个页签:
+
+- 概览
+- Agent 简介
+- Agent 能力
+
+默认打开概览,页签状态不写入 URL。
+
+### 9.3 概览
+
+概览展示:
+
+- `metadata.description`
+- `spec.capabilities` 能力卡片
+- `spec.prompts` 三个示例问题
+- `spec.demo.images` Demo 图片
+
+Demo 图片按内容区宽度 `width: 100%` 展示,高度自适应,图片间距 16px,不使用轮播、缩略图或灯箱。
+
+### 9.4 Agent 简介
+
+渲染数据库中的 `profile` 字段,不直接读取或渲染 README。第一版按纯文本段落和换行展示,不启用任意 HTML。
+
+### 9.5 Agent 能力
+
+分为:
+
+- 核心工作流:展示 `spec.entrypoint` 对应的一个入口 Skill
+- 依赖 Skills:按 `spec.dependencies.skills` 顺序展示公共 Skill
+
+已收录 Skill 可跳转 Skills 详情页;未收录 Skill 显示“暂未收录”,不提供跳转。
+
+### 9.6 相关 Agent
+
+右侧最多展示三个相关 Agent。仅使用公共依赖 Skills 计算,不使用入口 Skill:
+
+1. 排除当前 Agent
+2. 计算公共依赖 Skill 交集数量
+3. 过滤交集为 0 的 Agent
+4. 按交集数量倒序
+5. 同分按更新时间倒序
+
+没有结果时隐藏整个模块。移动端将操作按钮和相关 Agent 移到正文下方。
+
+## 10. 异常与空状态
+
+- Agent 不存在或已删除:展示空状态并返回 Agent 列表
+- Logo 缺失或加载失败:使用统一默认 Agent 图标
+- 单张 Demo 加载失败:保留位置并显示“图片加载失败”,其他图片继续展示
+- 依赖 Skill 未收录:展示不可跳转的缺失状态,不阻止 Agent 导入
+- ZIP 或 Manifest 校验失败:一次返回明确错误,不写入任何 Agent 数据
+- 低版本覆盖:返回当前版本和导入版本
+- 相同内容:返回“内容未变化”
+
+## 11. 测试与验收
+
+后端测试至少覆盖:
+
+- 正常单 Agent ZIP 导入
+- 多 Agent、无 Agent、路径穿越、软链接和超限 ZIP 拒绝
+- Manifest 必填字段、分类、SemVer 和文件引用校验
+- 新增、同版本覆盖、高版本升级、低版本拒绝
+- 完整快照删除旧文件
+- 导入失败事务回滚
+- 非资源文件内容和 mode 保存
+- Logo、Demo 写入持久化目录,数据库不保存图片二进制
+- 资源路径穿越、伪造图片类型和超限图片拒绝
+- 覆盖成功切换新 hash 目录并清理旧目录
+- 数据库事务失败时清理本次新增资源目录
+- 缺失公共 Skill 仍可导入
+- 相关 Agent 交集排序和无结果隐藏
+- 删除 Agent 不影响 Skills
+
+前端验收至少覆盖:
+
+- 列表搜索、分类、空状态和响应式布局
+- 导入新增、覆盖确认、错误提示和内容未变化
+- 三个详情页签内容映射正确
+- Demo 图片宽度、高度和 16px 间距正确
+- 已收录与未收录 Skill 状态正确
+- 相关 Agent 排序、跳转和移动端布局正确
+- “安装”“使用”按钮弹出对应名称
+
+## 12. 已确认决策
+
+- 使用独立 `agents + agent_files + agent_skills`,不重构 Skills
+- 一个 ZIP 只允许一个 Agent
+- ZIP 顶层为单一 Agent 目录,`agent.yaml` 位于该目录根部
+- 仅手动 ZIP 导入,不做目录导入和自动同步
+- 相同版本覆盖,低版本禁止覆盖
+- 更新按完整快照处理
+- 不保留版本历史
+- 删除 Agent 不影响 Skills
+- 分类复用 Skills,Tags 独立保存
+- 数据库是列表、详情和资源索引的正式数据源
+- Logo、Demo 图片保存在单机持久化目录 `/data/doraemon/agent-market`,数据库不保存图片二进制
+- Demo 原图顺序纵向展示,宽度撑满,高度自适应,间距 16px
+- 详情页只渲染数据库字段,不直接渲染源文件
+- 第一版按钮只弹出按钮名称
diff --git a/env.json b/env.json
index a0a5e2b2..328489a3 100644
--- a/env.json
+++ b/env.json
@@ -6,6 +6,7 @@
"articleHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/%E6%96%87%E7%AB%A0%E8%AE%A2%E9%98%85",
"proxyHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/%E4%BB%A3%E7%90%86%E6%9C%8D%E5%8A%A1",
"skillsHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/dt-skill",
+ "agentHelpDocUrl": "https://dtstack.github.io/doraemon/docsify/#/zh-cn/guide/agent-market",
"mysql": {
"prod": {}
},
diff --git a/sql/doraemon.sql b/sql/doraemon.sql
index 30b4f373..19eb4c82 100644
--- a/sql/doraemon.sql
+++ b/sql/doraemon.sql
@@ -377,6 +377,84 @@ CREATE TABLE `skills_files` (
KEY `idx_skills_file_skill_id` (`skill_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='技能文件表';
+-- ----------------------------
+-- Table structure for agents
+-- ----------------------------
+DROP TABLE IF EXISTS `agents`;
+CREATE TABLE `agents` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `name` varchar(100) NOT NULL COMMENT 'Agent 唯一标识',
+ `display_name` varchar(255) NOT NULL COMMENT 'Agent 展示名称',
+ `version` varchar(64) NOT NULL DEFAULT '' COMMENT 'Agent 版本号',
+ `description` text COMMENT '列表摘要',
+ `profile` longtext COMMENT 'Agent 详细简介',
+ `author_name` varchar(255) DEFAULT NULL COMMENT '作者',
+ `category` varchar(64) NOT NULL DEFAULT '通用' COMMENT '分类',
+ `tags` longtext COMMENT 'JSON 字符串数组',
+ `prompts` longtext COMMENT 'JSON 字符串数组',
+ `capabilities` longtext COMMENT 'JSON 字符串数组',
+ `demo_images` longtext COMMENT 'JSON 字符串数组',
+ `entrypoint_host` varchar(64) DEFAULT NULL COMMENT '入口宿主',
+ `entrypoint_type` varchar(64) DEFAULT NULL COMMENT '入口类型',
+ `entrypoint_name` varchar(255) DEFAULT NULL COMMENT '入口名称',
+ `entrypoint_ref` varchar(1000) DEFAULT NULL COMMENT '入口路径',
+ `logo_path` varchar(1000) DEFAULT NULL COMMENT 'Logo 相对路径',
+ `logo_mime_type` varchar(100) DEFAULT NULL COMMENT 'Logo MIME',
+ `logo_size` int NOT NULL DEFAULT '0' COMMENT 'Logo 大小',
+ `logo_hash` varchar(128) DEFAULT NULL COMMENT 'Logo 哈希',
+ `content_hash` varchar(128) NOT NULL COMMENT '内容哈希',
+ `source_file_name` varchar(255) DEFAULT NULL COMMENT '上传文件名',
+ `file_count` int NOT NULL DEFAULT '0' COMMENT '文件数量',
+ `is_delete` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_agents_name` (`name`),
+ KEY `idx_agents_category` (`category`),
+ KEY `idx_agents_updated_at` (`updated_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Agent 条目表';
+
+-- ----------------------------
+-- Table structure for agent_files
+-- ----------------------------
+DROP TABLE IF EXISTS `agent_files`;
+CREATE TABLE `agent_files` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `agent_id` int NOT NULL COMMENT 'agents.id',
+ `file_path` varchar(512) NOT NULL COMMENT 'Agent 内相对路径',
+ `mime_type` varchar(100) DEFAULT NULL COMMENT '文件 MIME',
+ `size` int NOT NULL DEFAULT '0' COMMENT '文件大小',
+ `is_binary` tinyint NOT NULL DEFAULT '0' COMMENT '是否二进制',
+ `encoding` varchar(20) NOT NULL DEFAULT 'utf8' COMMENT '内容编码',
+ `mode` int NOT NULL DEFAULT '0' COMMENT 'Unix 权限',
+ `content` longtext COMMENT '文件内容',
+ `is_delete` tinyint NOT NULL DEFAULT '0',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_agent_files_agent_path` (`agent_id`,`file_path`),
+ KEY `idx_agent_files_agent_id` (`agent_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Agent 文件快照表';
+
+-- ----------------------------
+-- Table structure for agent_skills
+-- ----------------------------
+DROP TABLE IF EXISTS `agent_skills`;
+CREATE TABLE `agent_skills` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `agent_id` int NOT NULL COMMENT 'agents.id',
+ `skill_slug` varchar(255) NOT NULL COMMENT 'Skill slug',
+ `skill_id` int DEFAULT NULL COMMENT 'skills_items.id',
+ `relation_type` varchar(20) NOT NULL COMMENT 'entrypoint 或 dependency',
+ `sort_order` int NOT NULL DEFAULT '0' COMMENT '展示顺序',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_agent_skills_agent_id` (`agent_id`),
+ KEY `idx_agent_skills_skill_slug` (`skill_slug`),
+ KEY `idx_agent_skills_relation_type` (`relation_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Agent 关联 Skill 表';
+
-- ----------------------------
-- Table structure for skill_likes
-- ----------------------------
diff --git a/test/agent-capability-utils.test.js b/test/agent-capability-utils.test.js
new file mode 100644
index 00000000..0bd1309d
--- /dev/null
+++ b/test/agent-capability-utils.test.js
@@ -0,0 +1,23 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const { normalizeAgentCapabilities } = require('../app/web/pages/agents/detail/capability-utils');
+
+test('normalizeAgentCapabilities 兼容旧的字符串数组能力数据', () => {
+ const result = normalizeAgentCapabilities(['分析 Bug', '修复代码']);
+
+ assert.deepEqual(result, [
+ { id: '', name: '分析 Bug', description: '' },
+ { id: '', name: '修复代码', description: '' },
+ ]);
+});
+
+test('normalizeAgentCapabilities 保留对象型能力数据的 name 和 description', () => {
+ const result = normalizeAgentCapabilities([
+ { id: 'bug-context', name: 'Bug 信息分析', description: '获取上下文' },
+ ]);
+
+ assert.deepEqual(result, [
+ { id: 'bug-context', name: 'Bug 信息分析', description: '获取上下文' },
+ ]);
+});
diff --git a/test/agent-codex-button-utils.test.js b/test/agent-codex-button-utils.test.js
new file mode 100644
index 00000000..ea21c56a
--- /dev/null
+++ b/test/agent-codex-button-utils.test.js
@@ -0,0 +1,53 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ buildAgentDetailCodexPrompt,
+ buildCodexNewThreadUrl,
+} = require('../app/web/pages/agents/codex-button-utils');
+
+test('buildCodexNewThreadUrl builds a codex new thread deep link with encoded prompt and origin', () => {
+ const url = buildCodexNewThreadUrl({
+ prompt: '打开 Agent 市场\n使用示例',
+ originUrl: 'http://10.10.10.168:7001/page/agents?keyword=AI Agent',
+ });
+
+ assert.equal(url.startsWith('codex://threads/new?'), true);
+ assert.equal(url.includes('prompt='), true);
+ assert.equal(url.includes('originUrl='), true);
+ assert.equal(url.includes('AI+Agent'), true);
+ assert.equal(url.includes('\n'), false);
+});
+
+test('buildAgentDetailCodexPrompt returns only the opening question prompt', () => {
+ const prompt = buildAgentDetailCodexPrompt(
+ {
+ displayName: 'Bug 修复 Agent',
+ name: 'bugfix-agent',
+ description: '用于修复 Bug',
+ entrypoint: { slug: 'bugfix-workflow', name: 'Bug 修复工作流' },
+ dependencies: [
+ { slug: 'zentao-api', name: '禅道 API' },
+ { slug: 'gitlab-mr-ci-watch', name: 'MR CI 观察' },
+ ],
+ prompts: [{ title: '修复 Bug', prompt: '$bugfix-workflow 12345' }],
+ },
+ 'http://10.10.10.168:7001/page/agents/bugfix-agent'
+ );
+
+ assert.equal(prompt, '$bugfix-workflow 12345');
+});
+
+test('buildAgentDetailCodexPrompt can use the selected opening question', () => {
+ const prompt = buildAgentDetailCodexPrompt(
+ {
+ displayName: 'Bug 修复 Agent',
+ name: 'bugfix-agent',
+ prompts: [{ title: '默认问题', prompt: '$bugfix-workflow 默认' }],
+ },
+ 'http://10.10.10.168:7001/page/agents/bugfix-agent',
+ { title: '自然语言', prompt: '帮我修 bug,禅道 Bug ID 是 156343' }
+ );
+
+ assert.equal(prompt, '帮我修 bug,禅道 Bug ID 是 156343');
+});
diff --git a/test/agent-detail-layout.test.js b/test/agent-detail-layout.test.js
new file mode 100644
index 00000000..cbcfbd7a
--- /dev/null
+++ b/test/agent-detail-layout.test.js
@@ -0,0 +1,297 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const path = require('path');
+
+test('Agent 简介页不再重复渲染 category 标签,并包含消息/问题图标结构', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('{detail.category}'),
+ false,
+ '不应重复渲染 category 标签'
+ );
+ assert.equal(
+ content.includes('agent-intro-icon-wrap is-message'),
+ true,
+ '开场消息区域需要消息图标'
+ );
+ assert.equal(content.includes('agent-intro-count'), true, '开场问题数量需要和标题同行展示');
+ assert.equal(
+ content.includes('{introBlocks.openingQuestions.length} 个'),
+ true,
+ '开场问题数量需要显示为 N 个'
+ );
+});
+
+test('概览页不再重复渲染示例问题区块', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('Title level={4}>示例问题'),
+ false,
+ '概览页不应继续渲染示例问题区块'
+ );
+});
+
+test('概览描述和 Agent 简介正文字号为 16px', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/style.scss'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('.agent-overview-description') && content.includes('font-size: 16px;'),
+ true,
+ '概览描述字号需要是 16px'
+ );
+ assert.equal(
+ content.includes('.agent-profile-copy') && content.includes('font-size: 16px;'),
+ true,
+ 'Agent 简介正文字号需要是 16px'
+ );
+});
+
+test('概览描述复用 Agent 简介正文容器样式', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes(
+ ''
+ ),
+ true,
+ '概览描述需要使用与 Agent 简介相同的正文容器'
+ );
+});
+
+test('Agent 详情页自身负责滚动,避免高内容区被父层裁剪', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/style.scss'),
+ 'utf8'
+ );
+
+ assert.equal(content.includes('overflow: auto;'), true, 'Agent 详情页需要显式开启滚动');
+ assert.equal(
+ content.includes('max-width: 1300px;') && content.includes('margin: 0 auto;'),
+ true,
+ 'Agent 详情内容需要限制为 1300px 并居中展示'
+ );
+});
+
+test('Agent 演示使用缩略图切换当前图片并限制完整图宽度', () => {
+ const componentContent = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'),
+ 'utf8'
+ );
+ const styleContent = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/style.scss'),
+ 'utf8'
+ );
+
+ assert.equal(
+ componentContent.includes('agent-demo-thumbnails') &&
+ componentContent.includes('setSelectedDemoIndex(index)') &&
+ componentContent.includes('agent-demo-preview'),
+ true,
+ 'Agent 演示需要提供缩略图切换和当前图片预览'
+ );
+ assert.equal(
+ componentContent.includes('description="暂无演示图片"'),
+ true,
+ 'Agent 演示无图片时需要展示空态'
+ );
+ assert.equal(
+ styleContent.includes('width: min(80%, 960px);'),
+ true,
+ '当前演示图片需要限制展示宽度'
+ );
+});
+
+test('Agent 简介页三块内容间距为 16px,消息和问题卡片使用双列布局', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/style.scss'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('.agent-intro-sections') && content.includes('gap: 16px;'),
+ true,
+ '三块内容区域之间需要是 16px 间距'
+ );
+ assert.equal(
+ content.includes('grid-template-columns: 32px minmax(0, 1fr);'),
+ true,
+ '图标和文案需要用双列布局保持在一行'
+ );
+});
+
+test('概览页的 Agent 能力使用紧凑网格卡片,不再渲染纵向列表', () => {
+ const componentContent = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'),
+ 'utf8'
+ );
+ const styleContent = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/style.scss'),
+ 'utf8'
+ );
+
+ assert.equal(
+ componentContent.includes('agent-capability-grid'),
+ true,
+ 'Agent 能力区块需要使用紧凑网格'
+ );
+ assert.equal(componentContent.includes(' {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/style.scss'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('.agent-skill-grid') &&
+ content.includes('grid-template-columns: repeat(2, minmax(0, 1fr));'),
+ true,
+ '内置 Skills 需要使用双列紧凑网格'
+ );
+ assert.equal(
+ content.includes('.agent-skill-card') && content.includes('padding: 14px 16px;'),
+ true,
+ '内置 Skills 卡片需要收紧内边距'
+ );
+ assert.equal(
+ content.includes('.agent-skill-card-title') &&
+ content.includes('span:first-child') &&
+ content.includes('font-size: 15px;'),
+ true,
+ '内置 Skills 标题字号需要提升'
+ );
+ assert.equal(
+ content.includes('.agent-skill-card-description') && content.includes('font-size: 15px;'),
+ true,
+ '内置 Skills 描述字号需要提升'
+ );
+});
+
+test('相关 Agent 为空时展示暂无数据空态', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('related.length > 0') && content.includes('description="暂无相关 Agent"'),
+ true,
+ '相关 Agent 为空时需要展示明确的空态'
+ );
+});
+
+test('Agent 详情右侧展示可复制的自动安装命令', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'),
+ 'utf8'
+ );
+ const styleContent = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/style.scss'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('curl -fsSL ${currentOrigin}/agent-market/install.sh') &&
+ content.includes('| bash -s -- ${detail.name}'),
+ true,
+ '安装命令需要根据当前站点地址和 Agent 名称动态拼接'
+ );
+ assert.equal(
+ content.includes('agent-install-terminal') &&
+ content.includes('copyToClipboard(') &&
+ content.includes('installCommand,') &&
+ content.includes("'Agent 安装命令已复制到剪贴板'"),
+ true,
+ '右侧安装面板需要展示终端命令并支持复制'
+ );
+ assert.equal(content.includes("message.info('敬请期待')"), false, '不应继续展示安装占位按钮');
+ assert.equal(
+ styleContent.includes('.ant-btn.agent-install-copy') &&
+ styleContent.includes('border-color: transparent;') &&
+ styleContent.includes('background: transparent;') &&
+ styleContent.includes('box-shadow: none;'),
+ true,
+ '终端复制按钮需要清除 Ant Design 的默认白底、边框和阴影'
+ );
+});
+
+test('Agent 开场问题卡片提供调起 Codex 的快捷使用入口', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'),
+ 'utf8'
+ );
+ const styleContent = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/style.scss'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('buildAgentDetailCodexPrompt') &&
+ content.includes('buildCodexNewThreadUrl') &&
+ content.includes('className="agent-question-quick-use"') &&
+ content.includes('agent-question-quick-use-icon') &&
+ content.includes('onClick') &&
+ content.includes('openCodexInstall(item)') &&
+ content.includes('快捷使用'),
+ true,
+ '开场问题卡片需要提供快捷使用按钮并调起 Codex'
+ );
+ assert.equal(
+ content.includes('className="agent-quick-use"'),
+ false,
+ '右侧不应再展示独立快捷使用按钮'
+ );
+ assert.equal(
+ styleContent.includes('.agent-question-quick-use') &&
+ styleContent.includes('grid-template-columns: 32px minmax(0, 1fr);') &&
+ styleContent.includes('align-items: center;') &&
+ styleContent.includes('&:hover,') &&
+ styleContent.includes('transform: translateY(-1px);') &&
+ styleContent.includes('border-color: #D8DFEA;') &&
+ styleContent.includes('.agent-question-quick-use-icon') &&
+ !styleContent.includes('border: 1px solid #F59E0B;'),
+ true,
+ '开场问题快捷使用按钮需要左右布局、垂直居中并提供克制的 hover 样式'
+ );
+});
+
+test('Agent 详情右侧提供当前原始 ZIP 下载入口', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/detail/AgentDetailContent.tsx'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('/api/agents/download?name=${encodeURIComponent(detail.name)}') &&
+ content.includes('下载 Agent ZIP'),
+ true,
+ '详情页需要提供当前 Agent 原始 ZIP 下载按钮'
+ );
+});
diff --git a/test/agent-help-doc-config.test.js b/test/agent-help-doc-config.test.js
new file mode 100644
index 00000000..3c3081c2
--- /dev/null
+++ b/test/agent-help-doc-config.test.js
@@ -0,0 +1,14 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const envConfig = require('../env.json');
+
+test('env.json 提供 Agent 市场独立帮助文档地址', () => {
+ assert.equal(typeof envConfig.agentHelpDocUrl, 'string');
+ assert.equal(envConfig.agentHelpDocUrl.length > 0, true, '应配置 agentHelpDocUrl');
+ assert.equal(
+ envConfig.agentHelpDocUrl.includes('/zh-cn/guide/agent-market'),
+ true,
+ 'Agent 帮助文档应指向 agent-market 文档'
+ );
+});
diff --git a/test/agent-intro-utils.test.js b/test/agent-intro-utils.test.js
new file mode 100644
index 00000000..b8468ff2
--- /dev/null
+++ b/test/agent-intro-utils.test.js
@@ -0,0 +1,30 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const { buildAgentIntroBlocks } = require('../app/web/pages/agents/detail/intro-utils');
+
+test('buildAgentIntroBlocks 将 profile、description、prompts 拆成三个展示块', () => {
+ const result = buildAgentIntroBlocks({
+ profile: '第一段\n\n第二段',
+ description: '欢迎告诉我你当前要处理的 Bug。',
+ prompts: [
+ { title: '仅分析', prompt: '$bugfix-workflow 分析这个 Bug,但先不要修改代码' },
+ { title: '恢复任务', prompt: '$bugfix-workflow 继续处理上一次未完成的 Bug' },
+ ],
+ });
+
+ assert.deepEqual(result.introParagraphs, ['第一段', '第二段']);
+ assert.equal(result.openingMessage, '欢迎告诉我你当前要处理的 Bug。');
+ assert.equal(result.openingQuestions.length, 2);
+});
+
+test('buildAgentIntroBlocks 缺少开场消息时回退到列表摘要', () => {
+ const result = buildAgentIntroBlocks({
+ profile: '简介',
+ description: '',
+ summary: '这是摘要',
+ prompts: [],
+ });
+
+ assert.equal(result.openingMessage, '这是摘要');
+});
diff --git a/test/agent-list-layout.test.js b/test/agent-list-layout.test.js
new file mode 100644
index 00000000..1f0507ce
--- /dev/null
+++ b/test/agent-list-layout.test.js
@@ -0,0 +1,56 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const path = require('path');
+
+test('Agent 列表卡片不再把 category 渲染成 tag,并提供删除按钮本地开关注释', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/index.tsx'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('{agent.category}'),
+ false,
+ '列表卡片不应再把 category 渲染成 tag'
+ );
+ assert.equal(
+ content.includes('doraemon.agentMarket.deleteEnabled'),
+ true,
+ '应包含删除按钮 localStorage 开关键'
+ );
+ assert.equal(
+ content.includes("localStorage.setItem('doraemon.agentMarket.deleteEnabled', 'true')"),
+ true,
+ '应在删除按钮旁保留启用写法注释'
+ );
+ assert.equal(
+ content.includes('agent.tags.slice(0, 4)'),
+ true,
+ '列表页 tag 数量应与详情页保持一致'
+ );
+});
+
+test('Agent 列表卡片描述字号为 13px', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/style.scss'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('.agent-card-description') && content.includes('font-size: 13px;'),
+ true,
+ '列表页描述字号需要是 13px'
+ );
+});
+
+test('Agent 列表页提供帮助文档入口并读取独立配置', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/pages/agents/index.tsx'),
+ 'utf8'
+ );
+
+ assert.equal(content.includes("import helpIcon from '@/asset/images/help-icon.png';"), true);
+ assert.equal(content.includes('config.agentHelpDocUrl'), true, '应读取 Agent 独立帮助文档配置');
+ assert.equal(content.includes('title="Agent 市场帮助文档"'), true, '应提供帮助文档提示文案');
+});
diff --git a/test/agent-market-controller.test.js b/test/agent-market-controller.test.js
new file mode 100644
index 00000000..dfb9aab2
--- /dev/null
+++ b/test/agent-market-controller.test.js
@@ -0,0 +1,97 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const AgentsController = require('../app/controller/agents');
+
+function createMockCtx(query = {}, body = {}, files = []) {
+ return {
+ query,
+ request: {
+ body,
+ files,
+ },
+ logger: {
+ warn() {},
+ info() {},
+ error() {},
+ },
+ throw(status, message) {
+ const error = new Error(message);
+ error.status = status;
+ throw error;
+ },
+ set() {},
+ body: null,
+ service: {
+ agents: {},
+ },
+ };
+}
+
+function buildController(serviceMethods) {
+ const controller = Object.create(AgentsController.prototype);
+ controller.ctx = createMockCtx();
+ controller.app = {
+ utils: {
+ response(success, data, msg) {
+ return { success, data, msg };
+ },
+ },
+ };
+ controller.ctx.service.agents = serviceMethods;
+ return controller;
+}
+
+test('getAgentDetail 返回统一 response 包装', async () => {
+ const controller = buildController({
+ getAgentDetail: async (name) => {
+ assert.equal(name, 'bugfix-agent');
+ return { name: 'bugfix-agent' };
+ },
+ });
+ controller.ctx.query = { name: 'bugfix-agent' };
+
+ await controller.getAgentDetail();
+ assert.equal(controller.ctx.body.success, true);
+ assert.deepEqual(controller.ctx.body.data, { name: 'bugfix-agent' });
+});
+
+test('getRelatedAgents 透传 limit 参数', async () => {
+ const controller = buildController({
+ getRelatedAgents: async (name, limit) => {
+ assert.equal(name, 'bugfix-agent');
+ assert.equal(limit, '2');
+ return [{ name: 'review-agent' }];
+ },
+ });
+ controller.ctx.query = { name: 'bugfix-agent', limit: '2' };
+
+ await controller.getRelatedAgents();
+ assert.equal(controller.ctx.body.success, true);
+ assert.equal(controller.ctx.body.data.length, 1);
+});
+
+test('downloadAgentArchive 返回 ZIP 文件流和下载响应头', async () => {
+ const headers = {};
+ const stream = { pipe() {} };
+ const controller = buildController({
+ getAgentArchiveStream: async (name) => {
+ assert.equal(name, 'bugfix-agent');
+ return {
+ stream,
+ fileName: 'bugfix-agent.zip',
+ mimeType: 'application/zip',
+ };
+ },
+ });
+ controller.ctx.query = { name: 'bugfix-agent' };
+ controller.ctx.set = (key, value) => {
+ headers[key] = value;
+ };
+
+ await controller.downloadAgentArchive();
+
+ assert.equal(headers['Content-Type'], 'application/zip');
+ assert.equal(headers['Content-Disposition'], 'attachment; filename="bugfix-agent.zip"');
+ assert.equal(controller.ctx.body, stream);
+});
diff --git a/test/agent-market-service.test.js b/test/agent-market-service.test.js
new file mode 100644
index 00000000..0a78380a
--- /dev/null
+++ b/test/agent-market-service.test.js
@@ -0,0 +1,464 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const AdmZip = require('adm-zip');
+
+const AgentsService = require('../app/service/agents');
+
+function createService() {
+ const service = Object.create(AgentsService.prototype);
+ service.ctx = {
+ logger: {
+ info() {},
+ warn() {},
+ error() {},
+ },
+ throw(status, message) {
+ const error = new Error(message);
+ error.status = status;
+ throw error;
+ },
+ };
+ service.app = {
+ config: {
+ agentMarket: {
+ storageDir: '/data/doraemon/agent-market',
+ maxZipSize: 50 * 1024 * 1024,
+ maxExtractedSize: 200 * 1024 * 1024,
+ maxFileCount: 500,
+ maxSingleFileSize: 20 * 1024 * 1024,
+ maxImageSize: 5 * 1024 * 1024,
+ },
+ },
+ };
+ return service;
+}
+
+function createAgentZip(manifestOverrides = {}, extraEntries = []) {
+ const zip = new AdmZip();
+ const root = 'bugfix-agent';
+ const manifest = {
+ apiVersion: 'doraemon.dtstack.com/v1',
+ kind: 'Agent',
+ metadata: {
+ name: 'bugfix-agent',
+ displayName: 'Bugfix Agent',
+ version: '1.0.0',
+ logo: './assets/logo.png',
+ description: 'Agent 简短描述',
+ author: {
+ name: 'DTStack',
+ },
+ category: '工程效率',
+ tags: ['Bugfix', 'Review'],
+ },
+ spec: {
+ profile: '负责 Bug 分析、修复和回归验证',
+ capabilities: ['分析 Bug', '修复代码', '推动回归'],
+ prompts: [
+ {
+ title: '修复 Bug 并部署 OMP online 环境',
+ prompt: '$bugfix-workflow 156343 dataApi 6.0.x,使用来源分支 dataApi/release_6.0.x,并部署到匹配的 OMP online 环境',
+ },
+ {
+ title: '仅分析 Bug',
+ prompt: '分析 Bug 156372,应用 batch,版本 6.2.x,只做根因分析,先不要修改代码',
+ },
+ {
+ title: '指定 hotfix 与负责人',
+ prompt: '$bugfix-workflow 156460 stream 6.2.x hotfix zhaoge',
+ },
+ ],
+ demo: {
+ images: [
+ {
+ path: './assets/demo1.png',
+ alt: 'Bugfix Agent Demo 1',
+ },
+ {
+ path: './assets/demo2.png',
+ alt: 'Bugfix Agent Demo 2',
+ },
+ ],
+ },
+ entrypoint: {
+ host: 'codex',
+ type: 'skill',
+ name: 'bugfix-workflow',
+ ref: './skills/bugfix-workflow',
+ },
+ dependencies: {
+ skills: ['systematic-debugging', 'gitlab-mr-code-review'],
+ },
+ },
+ ...manifestOverrides,
+ };
+
+ const yaml = [
+ 'apiVersion: doraemon.dtstack.com/v1',
+ 'kind: Agent',
+ 'metadata:',
+ ` name: ${manifest.metadata.name}`,
+ ` displayName: ${manifest.metadata.displayName}`,
+ ` version: ${manifest.metadata.version}`,
+ ` logo: ${manifest.metadata.logo}`,
+ ` description: ${manifest.metadata.description}`,
+ ' author:',
+ ` name: ${manifest.metadata.author.name}`,
+ ` category: ${manifest.metadata.category}`,
+ ' tags:',
+ ...manifest.metadata.tags.map((tag) => ` - ${tag}`),
+ 'spec:',
+ ` profile: ${manifest.spec.profile}`,
+ ' capabilities:',
+ ...manifest.spec.capabilities.map((item) => ` - ${item}`),
+ ' prompts:',
+ ...manifest.spec.prompts.flatMap((item) => [
+ ` - title: ${item.title}`,
+ ` prompt: ${item.prompt}`,
+ ]),
+ ' demo:',
+ ' images:',
+ ...manifest.spec.demo.images.flatMap((item) => [
+ ` - src: ${item.path}`,
+ ` alt: ${item.alt}`,
+ ]),
+ ' entrypoint:',
+ ` host: ${manifest.spec.entrypoint.host}`,
+ ` type: ${manifest.spec.entrypoint.type}`,
+ ` name: ${manifest.spec.entrypoint.name}`,
+ ` ref: ${manifest.spec.entrypoint.ref}`,
+ ' dependencies:',
+ ' skills:',
+ ...manifest.spec.dependencies.skills.map((item) => ` - ${item}`),
+ '',
+ ].join('\n');
+
+ zip.addFile(`${root}/agent.yaml`, Buffer.from(yaml, 'utf8'));
+ zip.addFile(`${root}/README.md`, Buffer.from('# Bugfix Agent\n', 'utf8'));
+ zip.addFile(`${root}/setup.sh`, Buffer.from('#!/bin/sh\necho setup\n', 'utf8'));
+ zip.addFile(`${root}/MIGRATION.md`, Buffer.from('migration notes\n', 'utf8'));
+ zip.addFile(
+ `${root}/skills/bugfix-workflow/SKILL.md`,
+ Buffer.from('# Bugfix Workflow\n', 'utf8')
+ );
+ zip.addFile(
+ `${root}/subagents/bugfix-reviewer.toml`,
+ Buffer.from('name = "bugfix-reviewer"\n', 'utf8')
+ );
+ zip.addFile(
+ `${root}/subagents/bugfix-worker.toml`,
+ Buffer.from('name = "bugfix-worker"\n', 'utf8')
+ );
+ zip.addFile(`${root}/assets/logo.png`, Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'));
+ zip.addFile(`${root}/assets/demo1.png`, Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'));
+ zip.addFile(`${root}/assets/demo2.png`, Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'));
+
+ extraEntries.forEach((entry) => {
+ zip.addFile(entry.name, Buffer.from(entry.content || '', entry.encoding || 'utf8'));
+ });
+
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-market-test-'));
+ const zipPath = path.join(tempDir, 'bugfix-agent.zip');
+ zip.writeZip(zipPath);
+ return {
+ zipPath,
+ cleanup() {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ },
+ };
+}
+
+test('parseAgentZip 解析单 Agent ZIP 并拆出结构化字段与文件快照', async () => {
+ const service = createService();
+ const fixture = createAgentZip();
+
+ try {
+ const parsed = await service.parseAgentZip(fixture.zipPath);
+ assert.equal(parsed.agent.name, 'bugfix-agent');
+ assert.equal(parsed.agent.displayName, 'Bugfix Agent');
+ assert.equal(parsed.agent.version, '1.0.0');
+ assert.equal(parsed.agent.category, '工程效率');
+ assert.equal(parsed.agent.authorName, 'DTStack');
+ assert.equal(parsed.logo.path.startsWith('bugfix-agent/'), true);
+ assert.equal(parsed.demoImages.length, 2);
+ assert.deepEqual(
+ parsed.skillRelations.map((item) => ({
+ slug: item.skillSlug,
+ relationType: item.relationType,
+ })),
+ [
+ { slug: 'bugfix-workflow', relationType: 'entrypoint' },
+ { slug: 'systematic-debugging', relationType: 'dependency' },
+ { slug: 'gitlab-mr-code-review', relationType: 'dependency' },
+ ]
+ );
+ assert.equal(
+ parsed.files.some((item) => item.filePath === 'assets/logo.png'),
+ false,
+ '资源文件不应该写入 agent_files'
+ );
+ assert.equal(
+ parsed.files.some((item) => item.filePath === 'skills/bugfix-workflow/SKILL.md'),
+ true
+ );
+ assert.equal(
+ parsed.files.some((item) => item.filePath === 'agent.yaml'),
+ true
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('parseAgentZip 拒绝非法分类', async () => {
+ const service = createService();
+ const fixture = createAgentZip({
+ metadata: {
+ name: 'bugfix-agent',
+ displayName: 'Bugfix Agent',
+ version: '1.0.0',
+ logo: './assets/logo.png',
+ description: 'Agent 简短描述',
+ author: { name: 'DTStack' },
+ category: '未知分类',
+ tags: ['Bugfix'],
+ },
+ });
+
+ try {
+ await assert.rejects(() => service.parseAgentZip(fixture.zipPath), /category 无效/);
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('parseAgentZip 支持 demo.images 使用 src 字段', async () => {
+ const service = createService();
+ const fixture = createAgentZip();
+
+ try {
+ const parsed = await service.parseAgentZip(fixture.zipPath);
+ assert.equal(parsed.demoImages.length, 2);
+ assert.equal(parsed.demoImages[0].originalPath, 'assets/demo1.png');
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('parseAgentZip 拒绝 demo.images 使用 path 字段', async () => {
+ const service = createService();
+ const fixture = createAgentZip();
+
+ const zip = new AdmZip(fixture.zipPath);
+ const agentYamlEntry = zip.getEntry('bugfix-agent/agent.yaml');
+ const yamlContent = agentYamlEntry.getData().toString('utf8').replace(/src:/g, 'path:');
+ zip.updateFile('bugfix-agent/agent.yaml', Buffer.from(yamlContent, 'utf8'));
+ zip.writeZip(fixture.zipPath);
+
+ try {
+ await assert.rejects(
+ () => service.parseAgentZip(fixture.zipPath),
+ /spec\.demo\.images\[0\] 路径非法/
+ );
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('parseAgentZip 支持 capabilities 使用对象数组并提取 name', async () => {
+ const service = createService();
+ const fixture = createAgentZip();
+
+ const zip = new AdmZip(fixture.zipPath);
+ const yamlContent = [
+ 'apiVersion: doraemon.dtstack.com/v1',
+ 'kind: Agent',
+ 'metadata:',
+ ' name: bugfix-agent',
+ ' displayName: Bugfix Agent',
+ ' version: 1.0.0',
+ ' logo: ./assets/logo.png',
+ ' description: Agent 简短描述',
+ ' author:',
+ ' name: DTStack',
+ ' category: 工程效率',
+ ' tags:',
+ ' - Bugfix',
+ 'spec:',
+ ' profile: 负责 Bug 分析、修复和回归验证',
+ ' capabilities:',
+ ' - id: bug-context',
+ ' name: Bug 信息分析',
+ ' description: 获取 Bug 上下文',
+ ' - id: code-fix',
+ ' name: 代码修复',
+ ' description: 完成修复',
+ ' prompts:',
+ ' - title: 修复 Bug 并部署 OMP online 环境',
+ ' prompt: $bugfix-workflow 156343 dataApi 6.0.x',
+ ' demo:',
+ ' images:',
+ ' - src: ./assets/demo1.png',
+ ' alt: Demo 1',
+ ' - src: ./assets/demo2.png',
+ ' alt: Demo 2',
+ ' entrypoint:',
+ ' host: codex',
+ ' type: skill',
+ ' name: bugfix-workflow',
+ ' ref: ./skills/bugfix-workflow',
+ ' dependencies:',
+ ' skills:',
+ ' - systematic-debugging',
+ '',
+ ].join('\n');
+ zip.updateFile('bugfix-agent/agent.yaml', Buffer.from(yamlContent, 'utf8'));
+ zip.writeZip(fixture.zipPath);
+
+ try {
+ const parsed = await service.parseAgentZip(fixture.zipPath);
+ assert.deepEqual(parsed.agent.capabilities, [
+ {
+ id: 'bug-context',
+ name: 'Bug 信息分析',
+ description: '获取 Bug 上下文',
+ },
+ {
+ id: 'code-fix',
+ name: '代码修复',
+ description: '完成修复',
+ },
+ ]);
+ } finally {
+ fixture.cleanup();
+ }
+});
+
+test('normalizeCapabilities 兼容旧的字符串数组存量数据', () => {
+ const service = createService();
+
+ assert.deepEqual(service.normalizeCapabilities(['分析 Bug', '修复代码']), [
+ {
+ id: '',
+ name: '分析 Bug',
+ description: '',
+ },
+ {
+ id: '',
+ name: '修复代码',
+ description: '',
+ },
+ ]);
+});
+
+test('compareAgentVersion 按 semver 比较版本号', () => {
+ const service = createService();
+
+ assert.equal(service.compareAgentVersion('1.0.0', '1.0.0'), 0);
+ assert.equal(service.compareAgentVersion('1.0.1', '1.0.0'), 1);
+ assert.equal(service.compareAgentVersion('1.2.0', '1.10.0'), -1);
+});
+
+test('buildRelatedAgents 仅按依赖 Skills 交集排序且忽略入口 Skill', () => {
+ const service = createService();
+ const target = {
+ name: 'bugfix-agent',
+ dependencies: ['systematic-debugging', 'gitlab-mr-code-review'],
+ entrypointName: 'bugfix-workflow',
+ };
+ const related = service.buildRelatedAgents(
+ target,
+ [
+ {
+ name: 'release-conflict-agent',
+ displayName: 'Release Conflict Agent',
+ dependencies: ['systematic-debugging'],
+ entrypointName: 'bugfix-workflow',
+ updatedAt: '2026-08-10T12:00:00.000Z',
+ },
+ {
+ name: 'review-agent',
+ displayName: 'Review Agent',
+ dependencies: ['systematic-debugging', 'gitlab-mr-code-review'],
+ entrypointName: 'review-workflow',
+ updatedAt: '2026-08-09T12:00:00.000Z',
+ },
+ {
+ name: 'empty-agent',
+ displayName: 'Empty Agent',
+ dependencies: [],
+ entrypointName: 'bugfix-workflow',
+ updatedAt: '2026-08-11T12:00:00.000Z',
+ },
+ ],
+ 3
+ );
+
+ assert.deepEqual(
+ related.map((item) => item.name),
+ ['review-agent', 'release-conflict-agent']
+ );
+});
+
+test('writeAgentArchive 将原始 ZIP 保存到当前内容 hash 目录', async () => {
+ const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-archive-storage-'));
+ const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-archive-source-'));
+ const sourcePath = path.join(sourceDir, 'uploaded.zip');
+ fs.writeFileSync(sourcePath, Buffer.from('original-agent-zip'));
+ const service = createService();
+ service.app.config.agentMarket.storageDir = storageDir;
+
+ try {
+ const archiveDir = await service.writeAgentArchive(
+ {
+ name: 'bugfix-agent',
+ contentHash: 'hash-v2',
+ },
+ sourcePath
+ );
+ const archivePath = path.join(storageDir, 'bugfix-agent', 'hash-v2', 'bugfix-agent.zip');
+
+ assert.equal(archiveDir, path.dirname(archivePath));
+ assert.equal(fs.readFileSync(archivePath, 'utf8'), 'original-agent-zip');
+ } finally {
+ fs.rmSync(storageDir, { recursive: true, force: true });
+ fs.rmSync(sourceDir, { recursive: true, force: true });
+ }
+});
+
+test('getAgentArchiveStream 返回当前 hash 对应的原始 ZIP', async () => {
+ const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-archive-download-'));
+ const archiveDir = path.join(storageDir, 'bugfix-agent', 'hash-current');
+ const archivePath = path.join(archiveDir, 'bugfix-agent.zip');
+ fs.mkdirSync(archiveDir, { recursive: true });
+ fs.writeFileSync(archivePath, Buffer.from('download-agent-zip'));
+ const service = createService();
+ service.app.config.agentMarket.storageDir = storageDir;
+ service.storageReady = true;
+ service.app.model = {
+ Agent: {
+ async findOne() {
+ return {
+ name: 'bugfix-agent',
+ content_hash: 'hash-current',
+ is_delete: 0,
+ };
+ },
+ },
+ };
+
+ try {
+ const result = await service.getAgentArchiveStream('bugfix-agent');
+ const chunks = [];
+ for await (const chunk of result.stream) chunks.push(chunk);
+
+ assert.equal(Buffer.concat(chunks).toString('utf8'), 'download-agent-zip');
+ assert.equal(result.fileName, 'bugfix-agent.zip');
+ assert.equal(result.mimeType, 'application/zip');
+ } finally {
+ fs.rmSync(storageDir, { recursive: true, force: true });
+ }
+});
diff --git a/test/basic-layout-flags.test.js b/test/basic-layout-flags.test.js
new file mode 100644
index 00000000..df99a709
--- /dev/null
+++ b/test/basic-layout-flags.test.js
@@ -0,0 +1,12 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const { shouldUseSkillDetailLayout } = require('../app/web/layouts/basicLayout/layout-flags');
+
+test('skills 详情页命中专用布局', () => {
+ assert.equal(shouldUseSkillDetailLayout('/page/skills/bugfix-workflow'), true);
+});
+
+test('agents 详情页不命中 skills 专用布局,避免页面滚动被锁住', () => {
+ assert.equal(shouldUseSkillDetailLayout('/page/agents/bugfix-agent'), false);
+});
diff --git a/test/config-local-agent-market.test.js b/test/config-local-agent-market.test.js
new file mode 100644
index 00000000..cb5ee334
--- /dev/null
+++ b/test/config-local-agent-market.test.js
@@ -0,0 +1,12 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const path = require('path');
+
+const createLocalConfig = require('../config/config.local');
+
+test('config.local.js 为 Agent 市场提供仓库内本地存储目录', () => {
+ const config = createLocalConfig();
+ const expectedStorageDir = path.join(process.cwd(), 'agent-market');
+
+ assert.equal(config.agentMarket.storageDir, expectedStorageDir);
+});
diff --git a/test/header-nav-config.test.js b/test/header-nav-config.test.js
new file mode 100644
index 00000000..2b70e012
--- /dev/null
+++ b/test/header-nav-config.test.js
@@ -0,0 +1,90 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const path = require('path');
+
+const {
+ NAV_MENU_LIST,
+ getActiveNavPath,
+ getMenuState,
+ resolveMenuClickKey,
+ MORE_MENU_PATH,
+} = require('../app/web/layouts/header/nav-config');
+
+test('主机管理等四个低频入口被收纳到更多菜单', () => {
+ const moreMenu = NAV_MENU_LIST.find((item) => item.path === MORE_MENU_PATH);
+
+ assert.ok(moreMenu);
+ assert.equal(moreMenu.children.length, 3);
+ assert.deepEqual(
+ moreMenu.children.map((item) => item.path),
+ ['/page/host-management', '/page/config-center', '/page/tags']
+ );
+});
+
+test('命中更多菜单子路由时高亮更多', () => {
+ assert.equal(getActiveNavPath('/page/host-management/detail', NAV_MENU_LIST), MORE_MENU_PATH);
+ assert.equal(getActiveNavPath('/page/config-detail/12', NAV_MENU_LIST), MORE_MENU_PATH);
+});
+
+test('环境管理提升为一级导航后直接高亮自身', () => {
+ assert.equal(getActiveNavPath('/page/env-management', NAV_MENU_LIST), '/page/env-management');
+ assert.deepEqual(getMenuState('/page/env-management', NAV_MENU_LIST), {
+ selectedKeys: ['/page/env-management'],
+ openKeys: [],
+ });
+});
+
+test('命中更多菜单子路由时,selectedKeys 选中子项,openKeys 展开更多', () => {
+ assert.deepEqual(getMenuState('/page/host-management/detail', NAV_MENU_LIST), {
+ selectedKeys: ['/page/host-management'],
+ openKeys: [MORE_MENU_PATH],
+ });
+ assert.deepEqual(getMenuState('/page/config-detail/12', NAV_MENU_LIST), {
+ selectedKeys: ['/page/config-center'],
+ openKeys: [MORE_MENU_PATH],
+ });
+});
+
+test('子菜单点击时使用顶层更多菜单作为选中项', () => {
+ assert.equal(
+ resolveMenuClickKey({ key: '/page/tags', keyPath: ['/page/tags', MORE_MENU_PATH] }),
+ MORE_MENU_PATH
+ );
+ assert.equal(
+ resolveMenuClickKey({ key: '/page/agents', keyPath: ['/page/agents'] }),
+ '/page/agents'
+ );
+});
+
+test('导航配置不使用可选链语法,兼容当前前端构建链', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/layouts/header/nav-config.js'),
+ 'utf8'
+ );
+
+ assert.equal(content.includes('?.'), false, 'nav-config.js 不能使用 optional chaining 语法');
+});
+
+test('顶栏样式覆盖更多标题的 hover 和展开颜色', () => {
+ const content = fs.readFileSync(
+ path.join(__dirname, '../app/web/layouts/header/style.scss'),
+ 'utf8'
+ );
+
+ assert.equal(
+ content.includes('.ant-menu-submenu-title:hover'),
+ true,
+ 'header style 需要显式覆盖更多标题 hover 样式'
+ );
+ assert.equal(
+ content.includes('&.ant-menu-submenu-open > .ant-menu-submenu-title'),
+ true,
+ 'header style 需要显式覆盖更多标题展开样式'
+ );
+ assert.equal(
+ content.includes('color: #3F87FF'),
+ true,
+ 'header style 需要把更多标题 hover 和展开文字设为蓝色'
+ );
+});