mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-06-12 23:16:49 +00:00
Compare commits
5 Commits
opencode/s
...
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635eb7a88f | ||
|
|
2ee9f570c4 | ||
|
|
27f4ac6a18 | ||
|
|
44d52cddc5 | ||
|
|
1e38a0e5e0 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@ public/
|
||||
|
||||
# Lock files (frontend has its own)
|
||||
frontend/package-lock.json
|
||||
.omo/
|
||||
.opencode/
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,3 +1,16 @@
|
||||
# [1.4.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.1...v1.4.0) (2026-05-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent-kernel:** address Oracle review round 2 findings ([27f4ac6](https://github.com/jeffusion/gitea-ai-assistant/commit/27f4ac6a18ec3510575f9234486e2fd5fc72de3c))
|
||||
* **review-agent:** complete fingerprint migration — dual-index all three keys ([2ee9f57](https://github.com/jeffusion/gitea-ai-assistant/commit/2ee9f570c4e43f6fa2901e6a1ff10d79826b7d60))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **agent-kernel:** cherry-pick high-value components from PR [#15](https://github.com/jeffusion/gitea-ai-assistant/issues/15) ([44d52cd](https://github.com/jeffusion/gitea-ai-assistant/commit/44d52cddc5e5301b9ec4ae8ca11ba926dd709cd3)), closes [hi#value](https://github.com/hi/issues/value)
|
||||
|
||||
## [1.3.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.0...v1.3.1) (2026-03-26)
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ RUN ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") && \
|
||||
# ---- Stage 4: Production ----
|
||||
FROM oven/bun:1-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates ripgrep && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates ripgrep curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
AI-powered code review assistant for Gitea. It receives webhooks, runs staged AI review workflows, and posts summary + line-level feedback back to Gitea.
|
||||
AI-powered code review assistant for Gitea. It receives webhooks, runs AI review workflows, and posts summary + line-level feedback back to Gitea.
|
||||
|
||||
- English docs: [./docs/README.md](./docs/README.md)
|
||||
- 中文文档: [./docs/README.zh-CN.md](./docs/README.zh-CN.md)
|
||||
@@ -10,7 +10,7 @@ AI-powered code review assistant for Gitea. It receives webhooks, runs staged AI
|
||||
## Why this project
|
||||
|
||||
- 🤖 **Automated PR + commit review** via webhook events (`pull_request`, `status`)
|
||||
- 🧠 **Two review engines**: `agent` (staged tasks) and `codex` (Codex CLI pipeline)
|
||||
- 🧠 **Two review engines**: `agent` (native Agent pipeline) and `codex` (Codex CLI pipeline)
|
||||
- 🧵 **Pluggable LLM providers**: OpenAI Compatible, OpenAI Responses API, Anthropic, Gemini
|
||||
- 📍 **Actionable output**: summary comments and line-level findings
|
||||
- 🎛️ **Web Admin UI** for runtime configuration (providers, models, webhook, review policy)
|
||||
|
||||
4
bun.lock
4
bun.lock
@@ -8,7 +8,6 @@
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@google/genai": "^1.43.0",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@qdrant/js-client-rest": "^1.16.2",
|
||||
"axios": "^1.8.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"hono": "^4.11.9",
|
||||
@@ -121,9 +120,7 @@
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
"@qdrant/js-client-rest": ["@qdrant/js-client-rest@1.17.0", "", { "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "undici": "^6.23.0" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A=="],
|
||||
|
||||
"@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
@@ -735,7 +732,6 @@
|
||||
|
||||
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
|
||||
|
||||
"undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
|
||||
@@ -46,7 +46,10 @@ services:
|
||||
- NODE_ENV=production
|
||||
- GITEA_API_URL=http://gitea:3000/api/v1
|
||||
- GITEA_ACCESS_TOKEN=${E2E_GITEA_TOKEN:-placeholder}
|
||||
- E2E_MOCK_LLM=1
|
||||
- PORT=5174
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
ports:
|
||||
- "3334:5174"
|
||||
healthcheck:
|
||||
|
||||
@@ -15,9 +15,6 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
LOG_LEVEL: error
|
||||
depends_on:
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
@@ -38,30 +35,6 @@ services:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: qdrant
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
volumes:
|
||||
- qdrant_data:/qdrant/storage
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:6333/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
|
||||
volumes:
|
||||
qdrant_data:
|
||||
driver: local
|
||||
assistant_data:
|
||||
driver: local
|
||||
|
||||
@@ -12,7 +12,6 @@ This project keeps the root `README.md` concise and moves implementation/deploym
|
||||
|
||||
## Architecture & design
|
||||
|
||||
- [Pluggable LLM providers](./design/pluggable-llm-providers.md)
|
||||
- [Notification service refactoring](./design/notification-service-refactoring.md)
|
||||
- [UI theme language](./design/ui-theme-language.md)
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
## 架构与设计
|
||||
|
||||
- [可插拔 LLM 提供商设计](./design/pluggable-llm-providers.md)
|
||||
- [通知服务重构设计](./design/notification-service-refactoring.md)
|
||||
- [UI 主题语言设计](./design/ui-theme-language.md)
|
||||
|
||||
|
||||
@@ -49,7 +49,9 @@ Change `ADMIN_PASSWORD` immediately after first login.
|
||||
## 3) LLM
|
||||
|
||||
- Providers: OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
|
||||
- Role mapping: planner, specialist, judge, embedding
|
||||
- Agent runtime models:
|
||||
- `AGENT_MAIN_MODEL`: The main model name used by the agent runtime when no specific model is configured. Default is `gpt-4.1`.
|
||||
- `AGENT_DEFAULT_SUBAGENT_MODEL`: The default model name used by subagents when no specific model is declared in their definition or overridden during spawn. Default is `gpt-4.1-mini`.
|
||||
|
||||
## 4) Notification
|
||||
|
||||
@@ -59,7 +61,7 @@ Change `ADMIN_PASSWORD` immediately after first login.
|
||||
## 5) Review
|
||||
|
||||
- Engine mode: `agent` or `codex`
|
||||
- Triage switch
|
||||
- Triage size classification and routing hints
|
||||
- Size thresholds (`small`/`medium`/`large`)
|
||||
- Execution modes (`skip`/`light`/`full`)
|
||||
- Token budgets and concurrency limits
|
||||
@@ -69,10 +71,15 @@ Change `ADMIN_PASSWORD` immediately after first login.
|
||||
> - `small/medium/large`: change-size classification
|
||||
> - `skip/light/full`: review execution depth
|
||||
|
||||
## 6) Memory & learning (optional)
|
||||
## Agent Definitions
|
||||
|
||||
- `ENABLE_MEMORY` (default `false`)
|
||||
- Qdrant URL
|
||||
- Reflection/debate toggles
|
||||
Project agent definitions are stored as Markdown files with frontmatter in the repository:
|
||||
- Path: `.gitea-assistant/agents/*.md`
|
||||
|
||||
Qdrant is only required when memory is enabled.
|
||||
These files define the system prompts, metadata, and execution parameters for each agent.
|
||||
|
||||
## Tool Permissions
|
||||
|
||||
Tool permissions are controlled directly within each agent's definition file:
|
||||
- `tools`: An allow-list of tool names that the agent is permitted to call. An empty list grants no tools.
|
||||
- `disallowedTools`: A deny-list of tool names that the agent is explicitly forbidden from calling. This takes precedence over the allow-list.
|
||||
|
||||
@@ -49,7 +49,9 @@ openssl rand -hex 32
|
||||
## 3) LLM
|
||||
|
||||
- Provider:OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
|
||||
- 角色模型:planner、specialist、judge、embedding
|
||||
- Agent 运行时模型:
|
||||
- `AGENT_MAIN_MODEL`:在没有更具体模型配置时,Agent 运行时使用的主模型名称。默认值为 `gpt-4.1`。
|
||||
- `AGENT_DEFAULT_SUBAGENT_MODEL`:当子代理(Subagent)未声明模型且 spawn 未覆盖时,使用的默认模型名称。默认值为 `gpt-4.1-mini`。
|
||||
|
||||
## 4) 通知
|
||||
|
||||
@@ -59,7 +61,7 @@ openssl rand -hex 32
|
||||
## 5) 审查
|
||||
|
||||
- 引擎模式:`agent` / `codex`
|
||||
- Triage 开关
|
||||
- Triage 规模分类与路由提示
|
||||
- 规模阈值(`small`/`medium`/`large`)
|
||||
- 执行模式(`skip`/`light`/`full`)
|
||||
- Token 预算与并发限制
|
||||
@@ -69,10 +71,15 @@ openssl rand -hex 32
|
||||
> - `small/medium/large`:变更规模分类
|
||||
> - `skip/light/full`:审查执行深度
|
||||
|
||||
## 6) 记忆与学习(可选)
|
||||
## Agent 定义
|
||||
|
||||
- `ENABLE_MEMORY`(默认 `false`)
|
||||
- Qdrant URL
|
||||
- Reflection / Debate 开关
|
||||
项目的 Agent 定义以带有 Frontmatter 的 Markdown 文件形式存储在仓库中:
|
||||
- 路径:`.gitea-assistant/agents/*.md`
|
||||
|
||||
仅在开启记忆能力时需要 Qdrant。
|
||||
这些文件定义了每个 Agent 的系统提示词、元数据和执行参数。
|
||||
|
||||
## 工具权限
|
||||
|
||||
工具权限直接在每个 Agent 的定义文件中进行控制:
|
||||
- `tools`:允许该 Agent 调用的工具名称白名单。如果列表为空,则不授予任何工具权限。
|
||||
- `disallowedTools`:显式禁止该 Agent 调用的工具名称黑名单。黑名单的优先级高于白名单。
|
||||
|
||||
@@ -13,15 +13,10 @@ docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error g
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` includes both:
|
||||
|
||||
- `gitea-assistant`
|
||||
- `qdrant`
|
||||
`docker-compose.yml` includes `gitea-assistant`.
|
||||
|
||||
Production default in compose sets `LOG_LEVEL=error`.
|
||||
|
||||
If you do not use memory features, Qdrant can be optional in custom compose setups.
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes manifests are in `k8s/`.
|
||||
@@ -46,7 +41,6 @@ Or apply individually:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
|
||||
@@ -13,15 +13,10 @@ docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error g
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` 默认包含:
|
||||
|
||||
- `gitea-assistant`
|
||||
- `qdrant`
|
||||
`docker-compose.yml` 默认包含 `gitea-assistant`。
|
||||
|
||||
Compose 生产默认日志级别已设置为 `LOG_LEVEL=error`。
|
||||
|
||||
如果不使用记忆能力,可在自定义编排中将 Qdrant 设为可选。
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes 清单位于 `k8s/` 目录。
|
||||
@@ -46,7 +41,6 @@ kubectl apply -k k8s/
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
|
||||
@@ -132,14 +132,13 @@ CREATE TABLE llm_secrets (
|
||||
-- ============================================================
|
||||
-- 表3: model_role_assignments — 场景 → 模型映射
|
||||
-- ============================================================
|
||||
-- 每个业务场景(如 planner/specialist/judge/embedding)绑定到
|
||||
-- 每个业务场景(如 planner/specialist/judge)绑定到
|
||||
-- 一个 provider + 具体 model,支持不同场景用不同 provider。
|
||||
CREATE TABLE model_role_assignments (
|
||||
role TEXT PRIMARY KEY CHECK (role IN (
|
||||
'planner', -- Agent 审查 planner
|
||||
'specialist', -- Agent 审查 specialist
|
||||
'judge', -- Agent 审查 judge
|
||||
'embedding' -- 向量嵌入(Qdrant)
|
||||
'planner',
|
||||
'specialist',
|
||||
'judge'
|
||||
)),
|
||||
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
|
||||
model TEXT NOT NULL, -- 该场景使用的具体模型 ID
|
||||
@@ -183,7 +182,7 @@ CREATE INDEX idx_providers_enabled ON llm_providers(is_enabled);
|
||||
// ── src/llm/types.ts ────────────────────────────────────────
|
||||
|
||||
/** 模型角色枚举 */
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge';
|
||||
|
||||
/** 统一消息格式(内部表达,不暴露 provider 差异) */
|
||||
export interface LLMMessage {
|
||||
@@ -345,7 +344,7 @@ export class LLMGateway {
|
||||
|
||||
/**
|
||||
* 按业务角色调用 LLM
|
||||
* @param role 业务角色(planner/specialist/judge/embedding)
|
||||
* @param role 业务角色(planner/specialist/judge)
|
||||
* @param request 请求(不含 model,由角色映射决定)
|
||||
*/
|
||||
async chatForRole(
|
||||
@@ -361,14 +360,6 @@ export class LLMGateway {
|
||||
request: LLMChatRequest
|
||||
): Promise<LLMChatResponse>;
|
||||
|
||||
/**
|
||||
* 获取指定 provider 的 embedding 接口
|
||||
*/
|
||||
async embedForRole(
|
||||
role: 'embedding',
|
||||
texts: string[]
|
||||
): Promise<number[][]>;
|
||||
|
||||
/** 配置变更时清除单个 provider 缓存 */
|
||||
invalidateProvider(providerId: string): void;
|
||||
|
||||
@@ -692,7 +683,6 @@ Settings 页面
|
||||
│ │ Planner(Triage) │ [公司 OpenAI 代理 ▾] │ [gpt-4o-mini ] │
|
||||
│ │ Specialist(任务执行) │ [Anthropic Claude ▾] │ [claude-sonnet-4 ] │
|
||||
│ │ Judge(汇总裁决) │ [公司 OpenAI 代理 ▾] │ [gpt-4o ] │
|
||||
│ │ Embedding(记忆检索) │ [公司 OpenAI 代理 ▾] │ [text-embedding-3 ] │
|
||||
│ └──────────────────────────────────────────────────────────────┘
|
||||
│ [保存角色分配]
|
||||
│
|
||||
@@ -736,13 +726,10 @@ const MODEL_SUGGESTIONS: Record<string, string[]> = {
|
||||
| 2 | `src/controllers/review.ts` | 旧版 webhook 存在回退分支 | 删除回退分支,仅保留 `agent` / `codex` 入队逻辑 | 审查主入口 |
|
||||
| 3 | `src/review/orchestrator.ts:45,61-63` | `private readonly openai: OpenAI` + `this.openai = new OpenAI(...)` | 构造函数改接收 `LLMGateway`;传给各 agent(任务化分级编排:skip/light/full) | Agent 编排 |
|
||||
| 4 | `src/review/agents/specialist-agent.ts:93` | `protected readonly openai: OpenAI` | → `protected readonly gateway: LLMGateway`;`reviewWithOptions()` 与 ReAct 调用改为 `this.gateway.chatForRole('specialist', ...)` | 核心 agent |
|
||||
| 5 | `src/review/agents/critic-agent.ts:23` | `private openai: OpenAI` | 同上 | 评审 agent |
|
||||
| 6 | `src/review/agents/reflexion-agent.ts:24` | `constructor(openai: OpenAI, ...)` | 构造传 gateway | 反思 agent |
|
||||
| 7 | `src/review/agents/debate-orchestrator.ts:17` | `private openai: OpenAI` | 同上 | 辩论 agent |
|
||||
| 8 | `src/review/tools/registry.ts:22` | `toOpenAIFunctions()` | → `toToolDefinitions(): LLMToolDefinition[]`(返回内部格式),由 adapter 的 `tool-converter.ts` 负责转换 | 工具注册 |
|
||||
| 9 | `src/config/config-schema.ts:120-138` | `OPENAI_BASE_URL/API_KEY/MODEL` 字段定义 | 删除这些字段;`group: 'openai'` → 整个 group 移除 | 配置 schema |
|
||||
| 10 | `src/config/config-manager.ts:44-46` | `OPENAI_*` Zod schema 条目 | 删除 | 配置验证 |
|
||||
| 11 | `src/config/config-manager.ts:271-273` | `openai: { baseUrl, apiKey, model }` 映射 | 删除整个 `openai` 块 | 配置输出 |
|
||||
| 5 | `src/review/tools/registry.ts:22` | `toOpenAIFunctions()` | → `toToolDefinitions(): LLMToolDefinition[]`(返回内部格式),由 adapter 的 `tool-converter.ts` 负责转换 | 工具注册 |
|
||||
| 6 | `src/config/config-schema.ts:120-138` | `OPENAI_BASE_URL/API_KEY/MODEL` 字段定义 | 删除这些字段;`group: 'openai'` → 整个 group 移除 | 配置 schema |
|
||||
| 7 | `src/config/config-manager.ts:44-46` | `OPENAI_*` Zod schema 条目 | 删除 | 配置验证 |
|
||||
| 8 | `src/config/config-manager.ts:271-273` | `openai: { baseUrl, apiKey, model }` 映射 | 删除整个 `openai` 块 | 配置输出 |
|
||||
|
||||
### 8.2 前端代码改造
|
||||
|
||||
@@ -795,7 +782,6 @@ Day 6.5: 旧代码清理完毕,文档更新,Ready for review
|
||||
|---|---|---|
|
||||
| **Anthropic 无原生 JSON mode** | `response_format: json_object` 不可用,JSON 解析可能失败 | Adapter 内 prompt 注入 JSON 指令 + `JSON.parse()` 容错(正则提取 \`\`\`json\`\`\` 块 → 重试 parse) |
|
||||
| **Gemini function calling 格式差异大** | `functionDeclarations` 包装层级不同;`functionResponse` 嵌套在 `parts` 中 | `tool-converter.ts` 单独处理;finish reason 映射表全覆盖测试 |
|
||||
| **Embedding 维度变化导致 Qdrant 不兼容** | `src/review/memory/vector-store.ts` 硬编码 1536 维 | `model_role_assignments.role='embedding'` 变更时,UI 提示用户需重建 collection;或自动检测维度创建新 collection |
|
||||
| **ENCRYPTION_KEY 丢失** | 所有加密的 API Key 不可恢复 | 启动时检测密钥版本不匹配 → 报错并要求重新设置所有 API Key(trade-off:安全性 > 便利性) |
|
||||
| **SQLite 并发写** | 多请求同时写入可能 SQLITE_BUSY | `bun:sqlite` 开启 WAL mode;写操作走单连接序列化;读可并行 |
|
||||
| **Provider SDK 版本冲突** | `openai`、`@anthropic-ai/sdk`、`@google/generative-ai` 三个 SDK 共存 | 各 adapter 独立 import,无交叉依赖;`package.json` 锁定主版本 |
|
||||
@@ -832,5 +818,4 @@ DATABASE_PATH=./data/assistant.db # SQLite 文件路径
|
||||
# - Gitea 配置(API URL / Token)
|
||||
# - 飞书配置(Webhook URL / Secret)
|
||||
# - Review 引擎配置
|
||||
# - 记忆系统配置
|
||||
```
|
||||
|
||||
@@ -4,24 +4,30 @@
|
||||
|
||||
The system supports two engines:
|
||||
|
||||
- `agent`: native staged review pipeline
|
||||
- `agent`: native Agent review pipeline
|
||||
- `codex`: Codex CLI-backed review pipeline
|
||||
|
||||
Engine is selected by `REVIEW_ENGINE` runtime configuration.
|
||||
|
||||
## Agent engine
|
||||
|
||||
Agent engine classifies changes and dispatches specialist tasks.
|
||||
The Agent engine runs code reviews using a dynamic agent framework. It prepares the workspace and review context, then starts a main agent to perform the review.
|
||||
|
||||
### Review behavior
|
||||
|
||||
- **Main Agent**: The entrypoint agent that coordinates the review process. It uses the tools provided to analyze the code changes.
|
||||
- **Dynamic Subagents**: The main agent can dynamically spawn subagents to perform specific tasks, such as searching code or reading files, if needed.
|
||||
- **Deterministic Publishing**: Review findings and comments are collected and processed outside the agent loop. The system normalizes, deduplicates, and filters findings deterministically before posting them back to Gitea.
|
||||
|
||||
### Review modes
|
||||
|
||||
- `skip`: low-risk changes may bypass specialist review
|
||||
- `light`: minimal specialist checks for low-risk code changes
|
||||
- `full`: full specialist review for risky or larger changes
|
||||
- `skip`: Low-risk changes may bypass the agent review entirely.
|
||||
- `light`: Minimal checks for low-risk code changes.
|
||||
- `full`: Full review for risky or larger changes.
|
||||
|
||||
### Size policy
|
||||
|
||||
`small`/`medium`/`large` thresholds are used by triage to choose mode and token budgets.
|
||||
`small`/`medium`/`large` thresholds are used to classify the change size, which determines the execution mode and token budgets.
|
||||
|
||||
## Codex engine
|
||||
|
||||
|
||||
@@ -4,24 +4,30 @@
|
||||
|
||||
系统支持两种审查引擎:
|
||||
|
||||
- `agent`:原生任务化分级审查
|
||||
- `agent`:内置 Agent 审查流水线
|
||||
- `codex`:基于 Codex CLI 的审查流水线
|
||||
|
||||
通过运行时配置 `REVIEW_ENGINE` 选择引擎。
|
||||
|
||||
## Agent 引擎
|
||||
|
||||
Agent 引擎会先做变更分流,再按领域派发 specialist 任务。
|
||||
Agent 引擎使用动态 Agent 框架执行代码审查。它会准备工作区与审查上下文,然后启动主 Agent 执行审查任务。
|
||||
|
||||
### 审查行为
|
||||
|
||||
- **主 Agent**:协调审查流程的入口 Agent。它使用提供的工具来分析代码变更。
|
||||
- **动态子 Agent**:主 Agent 可以根据需要动态生成子 Agent,以执行特定任务(例如搜索代码或读取文件)。
|
||||
- **确定性发布**:审查发现的问题与评论会在 Agent 循环之外进行收集和处理。系统会在将结果发布回 Gitea 之前,对发现的问题进行确定性的规范化、去重和过滤。
|
||||
|
||||
### 审查模式
|
||||
|
||||
- `skip`:低风险改动可跳过 specialist
|
||||
- `light`:对低风险代码执行最小化专项检查
|
||||
- `full`:对高风险或大规模改动执行完整审查
|
||||
- `skip`:低风险改动可完全跳过 Agent 审查。
|
||||
- `light`:对低风险代码执行最小化检查。
|
||||
- `full`:对高风险或大规模改动执行完整审查。
|
||||
|
||||
### 规模策略
|
||||
|
||||
`small` / `medium` / `large` 阈值用于 triage 阶段决策模式与 token 预算。
|
||||
`small` / `medium` / `large` 阈值用于对变更规模进行分类,从而决定执行模式与 Token 预算。
|
||||
|
||||
## Codex 引擎
|
||||
|
||||
|
||||
258
e2e/README.md
Normal file
258
e2e/README.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# E2E 真实 PR 审查测试指南
|
||||
|
||||
本指南记录使用 Docker 运行 Gitea + Assistant 进行真实 PR 代码审查的完整流程,包括踩坑点和修复步骤。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Docker & Docker Compose
|
||||
- `openssl`(签名计算)
|
||||
- `python3`(JSON 解析)
|
||||
- 本项目源码
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
Gitea (port 3333) ←→ Assistant (port 3334)
|
||||
↑ ↑
|
||||
webhook clone + comment
|
||||
(PR 事件) (git + API)
|
||||
```
|
||||
|
||||
- **Gitea**:代码托管,运行在 `gitea:3000`(宿主机 `localhost:3333`)
|
||||
- **Assistant**:AI 审查服务,运行在 `assistant:5174`(宿主机 `localhost:3334`)
|
||||
- 两者通过 Docker 内部网络 `gitea:3000` 通信(非宿主机地址)
|
||||
|
||||
## 一键启动(自动化方式)
|
||||
|
||||
```bash
|
||||
# 1. 启动容器
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
|
||||
# 2. 等待 Gitea healthy 后创建用户(Gitea 不允许 root 执行 admin 命令)
|
||||
docker exec e2e-gitea su git -c \
|
||||
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
|
||||
--email admin@e2e-test.local --admin --must-change-password=false"
|
||||
|
||||
# 3. 运行 seed 脚本(创建仓库、推送代码、配置 webhook、创建 PR)
|
||||
bash ./e2e/seed.sh
|
||||
|
||||
# 4. 用 seed 输出的 token 重启 assistant(使 GITEA_ACCESS_TOKEN 生效)
|
||||
# seed.sh 会在最后打印实际 token 值
|
||||
E2E_GITEA_TOKEN=<seed输出的token> docker compose -f docker-compose.e2e.yml up -d assistant
|
||||
|
||||
# 5. 通过 Admin API 更新运行时配置
|
||||
LOGIN_RESP=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
|
||||
-H "Content-Type: application/json" -d '{"password": "password"}')
|
||||
JWT=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{
|
||||
"GITEA_API_URL": "http://gitea:3000/api/v1",
|
||||
"GITEA_ACCESS_TOKEN": "<seed输出的token>",
|
||||
"WEBHOOK_SECRET": "e2e-test-secret"
|
||||
}'
|
||||
|
||||
# 6. 运行 E2E 测试
|
||||
bash ./e2e/test.sh
|
||||
```
|
||||
|
||||
## 分步详解与踩坑点
|
||||
|
||||
### 1. 启动容器
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
```
|
||||
|
||||
**踩坑**:`ENCRYPTION_KEY` 和 `WEBHOOK_SECRET` 必须在 `docker-compose.e2e.yml` 中配置,否则 assistant 启动失败(`ENCRYPTION_KEY is required`)。已添加默认值:
|
||||
|
||||
```yaml
|
||||
assistant:
|
||||
environment:
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef...(64位hex)}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
```
|
||||
|
||||
### 2. 创建 Gitea 用户
|
||||
|
||||
```bash
|
||||
docker exec e2e-gitea su git -c \
|
||||
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
|
||||
--email admin@e2e-test.local --admin --must-change-password=false"
|
||||
```
|
||||
|
||||
**踩坑**:`seed.sh` 中直接 `docker exec e2e-gitea gitea admin user create ...` 会报错 `Gitea is not supposed to be run as root`。必须用 `su git -c` 切换到 git 用户执行。如果用户已存在会输出错误但可忽略。
|
||||
|
||||
### 3. Seed 初始化
|
||||
|
||||
```bash
|
||||
bash ./e2e/seed.sh
|
||||
```
|
||||
|
||||
Seed 脚本会执行:
|
||||
1. 等待 Gitea 就绪
|
||||
2. 创建管理员用户(如已存在则跳过)
|
||||
3. 生成 API Token
|
||||
4. 创建测试仓库并推送含已知 bug 的代码(`src/user-handler.ts` 包含 eval/SQL 注入/硬编码密钥)
|
||||
5. 配置 Assistant 设置(需要 assistant 已启动)
|
||||
6. 配置 Gitea Webhook(指向 `http://assistant:5174/webhook/gitea`)
|
||||
7. 创建 PR #1(`feature/add-user-handler` → `main`)
|
||||
|
||||
**踩坑**:seed.sh 第 5 步"配置 Assistant 设置"可能失败(assistant 未启动或 JWT 获取失败),这不影响后续流程——可以手动通过 API 配置。
|
||||
|
||||
### 4. 更新 Assistant 运行时配置
|
||||
|
||||
```bash
|
||||
# 获取 JWT
|
||||
JWT=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
|
||||
-H "Content-Type: application/json" -d '{"password": "password"}' | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
# 更新三个关键配置
|
||||
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{
|
||||
"GITEA_API_URL": "http://gitea:3000/api/v1",
|
||||
"GITEA_ACCESS_TOKEN": "<token>",
|
||||
"WEBHOOK_SECRET": "e2e-test-secret"
|
||||
}'
|
||||
```
|
||||
|
||||
**关键**:
|
||||
- `GITEA_API_URL` 必须是 `http://gitea:3000/api/v1`(Docker 内部地址),不是 `localhost` 或宿主机地址
|
||||
- `GITEA_ACCESS_TOKEN` 是 seed.sh 生成的 token,assistant 用它 clone 仓库和发布评论
|
||||
- `WEBHOOK_SECRET` 必须与 Gitea webhook 的 secret 一致,否则签名验证失败
|
||||
|
||||
### 5. 触发 PR 审查
|
||||
|
||||
PR 创建时 Gitea 会自动触发 webhook。如果需要手动触发:
|
||||
|
||||
```bash
|
||||
# 获取 PR 信息
|
||||
PR_RESP=$(curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1" \
|
||||
-H "Authorization: token <token>")
|
||||
|
||||
HEAD_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||
BASE_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['base']['sha'])")
|
||||
|
||||
# 构造 webhook payload(需包含 head/base SHA)
|
||||
cat > /tmp/webhook_payload.json << EOF
|
||||
{
|
||||
"action": "opened",
|
||||
"number": 1,
|
||||
"pull_request": {
|
||||
"number": 1,
|
||||
"title": "feat: add user handler",
|
||||
"head": { "ref": "feature/add-user-handler", "sha": "$HEAD_SHA",
|
||||
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } },
|
||||
"base": { "ref": "main", "sha": "$BASE_SHA",
|
||||
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } }
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "e2e-admin/e2e-test-repo",
|
||||
"name": "e2e-test-repo",
|
||||
"owner": { "login": "e2e-admin" },
|
||||
"clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git"
|
||||
},
|
||||
"sender": { "login": "e2e-admin" }
|
||||
}
|
||||
EOF
|
||||
|
||||
# 计算 HMAC 签名(注意:必须基于文件内容计算,避免 shell 变量传递时改变内容)
|
||||
SIG=$(cat /tmp/webhook_payload.json | openssl dgst -sha256 -hmac "e2e-test-secret" | awk '{print $NF}')
|
||||
|
||||
# 发送 webhook(⚠️ 必须用 --data-binary 而非 -d,否则换行符被剥离导致签名不匹配)
|
||||
curl -s -X POST "http://localhost:3334/webhook/gitea" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Gitea-Event: pull_request" \
|
||||
-H "X-Gitea-Signature: ${SIG}" \
|
||||
--data-binary @/tmp/webhook_payload.json
|
||||
```
|
||||
|
||||
### 6. 验证审查结果
|
||||
|
||||
```bash
|
||||
# 等待审查完成
|
||||
sleep 10
|
||||
|
||||
# 检查 assistant 日志
|
||||
docker logs e2e-assistant 2>&1 | grep -E "审查|publish|评论|finding|ERROR" | tail -20
|
||||
|
||||
# 通过 API 查看 run 详情
|
||||
curl -sf "http://localhost:3334/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer $JWT" | python3 -m json.tool | head -30
|
||||
|
||||
# 检查 Gitea PR 评论(summary)
|
||||
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/issues/1/comments" \
|
||||
-H "Authorization: token <token>" | python3 -c "
|
||||
import sys,json
|
||||
for c in json.load(sys.stdin):
|
||||
print(c['body'][:200])
|
||||
"
|
||||
|
||||
# 检查 Gitea PR Reviews(行级评论)
|
||||
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1/reviews" \
|
||||
-H "Authorization: token <token>"
|
||||
```
|
||||
|
||||
## 验证检查清单
|
||||
|
||||
| # | 检查项 | 验证方式 |
|
||||
|---|--------|----------|
|
||||
| 1 | Gitea 容器 healthy | `docker ps` 或 `curl localhost:3333/api/v1/version` |
|
||||
| 2 | Assistant 容器 healthy | `curl localhost:3334/api/health` |
|
||||
| 3 | Webhook 签名验证通过 | assistant 日志无"签名验证失败" |
|
||||
| 4 | Git clone mirror 成功 | assistant 日志无"could not read Username" |
|
||||
| 5 | Agent 审查执行完成 | run status = `succeeded` |
|
||||
| 6 | Subagent 被触发 | sessionTree.invocations 非空 |
|
||||
| 7 | Findings 数量 > 0 | run details 中 findings 非空 |
|
||||
| 8 | Summary 评论发布到 Gitea | PR issue comments 包含"AI Agent代码审查结果" |
|
||||
| 9 | 行级评论发布到 Gitea | PR reviews 包含 COMMENT 类型 |
|
||||
| 10 | Finding published=true | DB 中 finding.published = true |
|
||||
|
||||
## Mock LLM vs 真实 LLM
|
||||
|
||||
| 特性 | E2E_MOCK_LLM=1 | 真实 LLM |
|
||||
|------|----------------|----------|
|
||||
| 模型 | `RuntimeE2EMockLLM`(脚本驱动) | OpenAI/Anthropic/其他 |
|
||||
| Subagent | 必然调用(固定脚本) | 动态决策(根据 diff 复杂度) |
|
||||
| Findings | 固定 1 条(eval 安全问题) | 根据实际代码动态发现 |
|
||||
| 速度 | <1s | 10-60s |
|
||||
| 用途 | 集成链路验证 | 审查质量验证 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `ENCRYPTION_KEY is required`
|
||||
**原因**:`docker-compose.e2e.yml` 缺少 `ENCRYPTION_KEY` 环境变量。
|
||||
**修复**:已在 compose 文件中添加默认值。
|
||||
|
||||
### `Webhook签名验证失败`
|
||||
**原因**:请求的 HMAC 签名与 assistant 配置的 `WEBHOOK_SECRET` 不匹配。
|
||||
**修复**:确保 webhook payload 的签名计算使用与 Admin API 配置的 `WEBHOOK_SECRET` 相同的密钥。
|
||||
|
||||
### `could not read Username for 'http://gitea:3000'`
|
||||
**原因**:`GITEA_ACCESS_TOKEN` 未正确配置(默认值 `placeholder`)或 DB 配置中的值不正确。
|
||||
**修复**:通过 Admin API 更新 `GITEA_ACCESS_TOKEN` 为 seed.sh 生成的实际 token。
|
||||
|
||||
### `Gitea is not supposed to be run as root`
|
||||
**原因**:Gitea 容器以 root 运行,但 `gitea admin` 命令不允许 root 执行。
|
||||
**修复**:使用 `docker exec e2e-gitea su git -c "gitea admin user create ..."` 格式。
|
||||
|
||||
### Gitea API URL 指向 localhost
|
||||
**原因**:assistant DB 中 `GITEA_API_URL` 默认值为 `http://localhost:5174/api/v1`(自身地址)。
|
||||
**修复**:通过 Admin API 更新为 `http://gitea:3000/api/v1`(Docker 内部地址)。
|
||||
|
||||
### 评论未发布到 Gitea
|
||||
**原因**:Agent 引擎的 `publishPendingComments` 链路缺失(已修复)。
|
||||
**修复**:确保使用包含 `publishPendingComments` 逻辑的版本。
|
||||
|
||||
## 清理
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml down -v
|
||||
```
|
||||
|
||||
`-v` 会删除 Gitea 数据卷,下次启动需要重新 seed。
|
||||
20
e2e/seed.sh
20
e2e/seed.sh
@@ -26,12 +26,12 @@ for i in $(seq 1 30); do
|
||||
done
|
||||
|
||||
echo "=== [2/6] 创建管理员用户 ==="
|
||||
docker exec e2e-gitea gitea admin user create \
|
||||
--username "${ADMIN_USER}" \
|
||||
--password "${ADMIN_PASS}" \
|
||||
--email "${ADMIN_EMAIL}" \
|
||||
--admin \
|
||||
--must-change-password=false 2>/dev/null || echo " 用户已存在,跳过"
|
||||
docker exec e2e-gitea su git -c "gitea admin user create \
|
||||
--username '${ADMIN_USER}' \
|
||||
--password '${ADMIN_PASS}' \
|
||||
--email '${ADMIN_EMAIL}' \
|
||||
--admin \
|
||||
--must-change-password=false" 2>/dev/null || echo " 用户已存在,跳过"
|
||||
|
||||
echo "=== [3/6] 生成 API Token ==="
|
||||
TOKEN_RESPONSE=$(curl -sf -X POST "${GITEA_URL}/api/v1/users/${ADMIN_USER}/tokens" \
|
||||
@@ -129,7 +129,7 @@ for i in $(seq 1 20); do
|
||||
done
|
||||
|
||||
# Login to get JWT
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/login" \
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"password\": \"${ADMIN_DEFAULT_PASS}\"}" 2>/dev/null || true)
|
||||
ADMIN_JWT=$(echo "${LOGIN_RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true)
|
||||
@@ -138,7 +138,7 @@ if [ -z "${ADMIN_JWT}" ]; then
|
||||
echo " WARNING: 无法获取管理员 JWT,跳过 assistant 配置"
|
||||
else
|
||||
echo " JWT 获取成功,配置 assistant 设置..."
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/config" \
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" \
|
||||
-d "{
|
||||
@@ -146,10 +146,8 @@ else
|
||||
\"GITEA_API_URL\": \"http://gitea:3000/api/v1\",
|
||||
\"REVIEW_ENGINE\": \"agent\",
|
||||
\"REVIEW_WORKDIR\": \"/tmp/e2e-review\",
|
||||
\"REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE\": \"0.5\",
|
||||
\"REVIEW_ENABLE_HUMAN_GATE\": \"false\",
|
||||
\"REVIEW_ALLOWED_COMMANDS\": \"git,rg,cat,sed,wc\",
|
||||
\"REVIEW_COMMAND_TIMEOUT_MS\": \"30000\"
|
||||
\"REVIEW_COMMAND_TIMEOUT_MS\": \"120000\"
|
||||
}" > /dev/null 2>&1 && echo " Assistant 配置完成" || echo " WARNING: assistant 配置失败"
|
||||
fi
|
||||
|
||||
|
||||
179
e2e/test.sh
179
e2e/test.sh
@@ -2,7 +2,6 @@
|
||||
set -euo pipefail
|
||||
|
||||
# E2E Test Script
|
||||
# 验证 AI 代码审查是否在 PR 上产生了评论
|
||||
#
|
||||
# 前置条件:
|
||||
# 1. docker compose -f docker-compose.e2e.yml up -d
|
||||
@@ -17,10 +16,12 @@ fi
|
||||
|
||||
source "${ENV_FILE}"
|
||||
|
||||
MAX_WAIT=180 # 最多等待 3 分钟
|
||||
MAX_WAIT=240
|
||||
POLL_INTERVAL=5
|
||||
PASS=0
|
||||
FAIL=0
|
||||
RUN_ID=""
|
||||
LATEST_DETAIL='{}'
|
||||
|
||||
echo "=== E2E 测试开始 ==="
|
||||
echo " Gitea: ${GITEA_URL}"
|
||||
@@ -38,6 +39,12 @@ else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if [ "${E2E_MOCK_LLM:-}" = "1" ]; then
|
||||
echo " E2E_MOCK_LLM=1 (shell env)"
|
||||
else
|
||||
echo " E2E_MOCK_LLM 由 assistant 容器环境决定(docker-compose.e2e.yml 已配置)"
|
||||
fi
|
||||
|
||||
# ─── 测试 2: Gitea API 可用 ───
|
||||
echo "[TEST 2] Gitea API 可用性"
|
||||
VERSION=$(curl -sf "${GITEA_URL}/api/v1/version" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown")
|
||||
@@ -63,69 +70,121 @@ else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ─── 测试 4: 等待 AI 审查评论出现 ───
|
||||
echo "[TEST 4] AI 审查评论(最多等待 ${MAX_WAIT}s)"
|
||||
COMMENT_FOUND=false
|
||||
WAITED=0
|
||||
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
AI_COMMENTS=$(echo "${COMMENTS}" | python3 -c "
|
||||
import sys, json
|
||||
comments = json.load(sys.stdin)
|
||||
ai = [c for c in comments if 'AI' in c.get('body', '') or 'Agent' in c.get('body', '')]
|
||||
print(len(ai))
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${AI_COMMENTS}" -gt "0" ]; then
|
||||
COMMENT_FOUND=true
|
||||
echo " ✅ PASS: 发现 ${AI_COMMENTS} 条 AI 审查评论 (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
|
||||
echo " ⏳ 等待中... (${WAITED}/${MAX_WAIT}s, 已有评论: $(echo "${COMMENTS}" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0))"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
if [ "${COMMENT_FOUND}" = false ]; then
|
||||
echo " ❌ FAIL: ${MAX_WAIT}s 内未发现 AI 审查评论"
|
||||
FAIL=$((FAIL + 1))
|
||||
|
||||
echo " --- 调试信息 ---"
|
||||
echo " PR 所有评论:"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
|
||||
echo " Assistant review runs:"
|
||||
curl -sf "${ASSISTANT_URL}/admin/api/review/runs" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
fi
|
||||
|
||||
# ─── 测试 5: Review Run 状态检查 ───
|
||||
echo "[TEST 5] Review Run 状态"
|
||||
echo "[TEST 4] Admin 登录"
|
||||
ADMIN_JWT=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password":"password"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "${ADMIN_JWT}" ]; then
|
||||
echo " ✅ PASS: Admin JWT 获取成功"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Admin JWT 获取失败"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 5] 等待 review run 产出并成功(最多等待 ${MAX_WAIT}s)"
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "[]")
|
||||
RUN_COUNT=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('data',d if isinstance(d,list) else [])))" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${RUN_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: 发现 ${RUN_COUNT} 个 review run(s)"
|
||||
PASS=$((PASS + 1))
|
||||
WAITED=0
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "{}")
|
||||
RUN_ID=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0]['id'] if runs else '')" 2>/dev/null || echo "")
|
||||
RUN_STATUS=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0].get('status','') if runs else '')" 2>/dev/null || echo "")
|
||||
if [ -n "${RUN_ID}" ] && [ "${RUN_STATUS}" = "succeeded" ]; then
|
||||
echo " ✅ PASS: run=${RUN_ID} status=succeeded (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
echo " ⏳ 等待 run... (${WAITED}/${MAX_WAIT}s, run=${RUN_ID:-none}, status=${RUN_STATUS:-none})"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
echo "${RUNS}" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
runs = data.get('data', data if isinstance(data, list) else data.get('runs', []))
|
||||
for r in runs[:3]:
|
||||
print(f\" - {r.get('id','?')[:8]}... status={r.get('status','?')} attempts={r.get('attempts','?')}\")
|
||||
" 2>/dev/null || true
|
||||
if [ -z "${RUN_ID}" ]; then
|
||||
echo " ❌ FAIL: 未发现 review run"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if [ -n "${RUN_ID}" ]; then
|
||||
LATEST_DETAIL=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs/${RUN_ID}" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo '{}')
|
||||
fi
|
||||
|
||||
echo "[TEST 6] 会话树包含主/子 Agent 与工具调用"
|
||||
TREE_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
t=d.get("sessionTree") or {}
|
||||
main_type=t.get("agentType")
|
||||
main_tools=[x.get("toolName") for x in t.get("toolCalls",[])]
|
||||
inv=t.get("invocations",[])
|
||||
has_spawn="spawn_subagent" in main_tools
|
||||
child_ok=False
|
||||
if inv:
|
||||
child=inv[0].get("childSession") or {}
|
||||
child_tools=[x.get("toolName") for x in child.get("toolCalls",[])]
|
||||
child_ok=("search_code" in child_tools and "read_file" in child_tools)
|
||||
print("ok" if (main_type=="review-main-agent" and has_spawn and len(inv)>0 and child_ok) else "bad")
|
||||
' 2>/dev/null || echo "bad")
|
||||
|
||||
if [ "${TREE_ASSERT}" = "ok" ]; then
|
||||
echo " ✅ PASS: 主会话与子代理调用链存在(含 search_code/read_file)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: 无 review runs"
|
||||
echo " ❌ FAIL: sessionTree 未满足动态子代理断言"
|
||||
echo "${LATEST_DETAIL}" | python3 -m json.tool 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 7] run details 包含 findings 与评论记录"
|
||||
DETAIL_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
findings=d.get("findings",[])
|
||||
comments=d.get("comments",[])
|
||||
ok=(len(findings) > 0 and len(comments) > 0)
|
||||
print("ok" if ok else "bad")
|
||||
' 2>/dev/null || echo "bad")
|
||||
|
||||
if [ "${DETAIL_ASSERT}" = "ok" ]; then
|
||||
echo " ✅ PASS: run details 存在 findings/comments"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: run details 缺少 findings 或 comments"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 8] Gitea 评论产物(summary + line comments)"
|
||||
ISSUE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
LINE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
SUMMARY_COUNT=$(echo "${ISSUE_COMMENTS}" | python3 -c '
|
||||
import json,sys
|
||||
arr=json.load(sys.stdin)
|
||||
cnt=0
|
||||
for c in arr:
|
||||
body=c.get("body") or ""
|
||||
if "审查" in body or "review" in body.lower() or "AI" in body:
|
||||
cnt += 1
|
||||
print(cnt)
|
||||
' 2>/dev/null || echo "0")
|
||||
LINE_COUNT=$(echo "${LINE_COMMENTS}" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${SUMMARY_COUNT}" -gt "0" ] && [ "${LINE_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT}"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Gitea 评论产物不足(summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT})"
|
||||
echo " --- issue comments ---"
|
||||
echo "${ISSUE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
|
||||
echo " --- line comments ---"
|
||||
echo "${LINE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
@@ -138,10 +197,10 @@ echo " 失败: ${FAIL}/${TOTAL}"
|
||||
|
||||
if [ ${FAIL} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ 部分测试失败。如果 AI 评论测试失败,请确保:"
|
||||
echo " 1. OPENAI_API_KEY 已正确配置"
|
||||
echo " 2. assistant 容器的 GITEA_ACCESS_TOKEN 已设置为 seed 生成的 token"
|
||||
echo " 3. Webhook 已正确触发(检查 Gitea webhook 日志)"
|
||||
echo "⚠️ 部分测试失败。请检查:"
|
||||
echo " 1. docker compose e2e 容器均 healthy"
|
||||
echo " 2. assistant 容器环境含 E2E_MOCK_LLM=1 与正确 GITEA_ACCESS_TOKEN"
|
||||
echo " 3. webhook 已触发且 run details 可见 sessionTree/findings/comments"
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RepositoryManager } from './components/RepositoryManager';
|
||||
import { ConfigManager } from './components/ConfigManager';
|
||||
import { NotificationConfigPage } from './components/NotificationConfigPage';
|
||||
import { ReviewConfigPage } from './components/ReviewConfigPage';
|
||||
import ReviewSessionsPage from './pages/ReviewSessionsPage';
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { useTheme } from 'next-themes'
|
||||
import { ColorPaletteProvider } from './hooks/useColorPalette';
|
||||
@@ -54,6 +55,7 @@ function AppContent() {
|
||||
<Route path="config" element={<ConfigManager />} />
|
||||
<Route path="notifications" element={<NotificationConfigPage />} />
|
||||
<Route path="review-config" element={<ReviewConfigPage />} />
|
||||
<Route path="review-runs" element={<ReviewSessionsPage />} />
|
||||
<Route path="*" element={<Navigate to="/repos" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -32,14 +32,11 @@ const AGENT_SHARED_FIELDS = new Set([
|
||||
|
||||
/** Fields specific to agent mode only. */
|
||||
const AGENT_ONLY_FIELDS = new Set([
|
||||
'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
'REVIEW_ENABLE_HUMAN_GATE',
|
||||
'REVIEW_ALLOWED_COMMANDS',
|
||||
'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
'LLM_MAX_CONCURRENT_CALLS',
|
||||
'LLM_RETRY_MAX_ATTEMPTS',
|
||||
'LLM_RETRY_BASE_DELAY_MS',
|
||||
'ENABLE_TRIAGE',
|
||||
]);
|
||||
|
||||
/** Fields specific to codex mode only. */
|
||||
@@ -102,16 +99,13 @@ export function ReviewConfigPage() {
|
||||
return 'agent';
|
||||
}, [localConfig]);
|
||||
|
||||
// Derived: review group and memory group from fetched data
|
||||
const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]);
|
||||
const memoryGroup = useMemo(() => data?.groups.find((g) => g.key === 'memory'), [data]);
|
||||
|
||||
// Initialize local config from ALL groups (so save works for review + memory fields)
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups
|
||||
.filter((g) => g.key === 'review' || g.key === 'memory')
|
||||
.filter((g) => g.key === 'review')
|
||||
.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
@@ -158,7 +152,10 @@ export function ReviewConfigPage() {
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(localConfig)) {
|
||||
const fieldsToSave = new Set([ENGINE_FIELD, ...visibleReviewFields.map((field) => field.envKey)]);
|
||||
|
||||
for (const key of fieldsToSave) {
|
||||
const val = localConfig[key];
|
||||
if (typeof val === 'boolean') {
|
||||
payload[key] = val ? 'true' : 'false';
|
||||
} else {
|
||||
@@ -175,7 +172,7 @@ export function ReviewConfigPage() {
|
||||
};
|
||||
|
||||
const handleResetAll = () => {
|
||||
const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const allOverrideKeys = groups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'db')
|
||||
@@ -193,9 +190,9 @@ export function ReviewConfigPage() {
|
||||
);
|
||||
|
||||
const hasOverrides = useMemo(() => {
|
||||
const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
return groups.some((g) => g.fields.some((f) => f.source === 'db'));
|
||||
}, [reviewGroup, memoryGroup]);
|
||||
}, [reviewGroup]);
|
||||
|
||||
// -- Render states --
|
||||
|
||||
@@ -229,7 +226,7 @@ export function ReviewConfigPage() {
|
||||
description:
|
||||
engine === 'codex'
|
||||
? 'Codex CLI 审查引擎配置'
|
||||
: '多代理编排审查引擎配置',
|
||||
: 'Agent 审查引擎配置',
|
||||
fields: visibleReviewFields,
|
||||
}
|
||||
: null;
|
||||
@@ -358,17 +355,6 @@ export function ReviewConfigPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Memory group — agent mode only */}
|
||||
{engine === 'agent' && memoryGroup && (
|
||||
<ConfigGroupCard
|
||||
group={memoryGroup}
|
||||
localConfig={localConfig}
|
||||
onFieldChange={handleFieldChange}
|
||||
onReset={handleResetGroup}
|
||||
isResetting={resetMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{engine !== 'codex' && (
|
||||
<>
|
||||
<ProviderList />
|
||||
|
||||
185
frontend/src/components/__tests__/ReviewConfigPage.test.tsx
Normal file
185
frontend/src/components/__tests__/ReviewConfigPage.test.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ReviewConfigPage } from '../ReviewConfigPage';
|
||||
import { fetchConfig, updateConfig, resetConfig, type ConfigResponse } from '@/services/configService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/configService', () => ({
|
||||
fetchConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
resetConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../llm/ProviderList', () => ({
|
||||
ProviderList: () => <div>ProviderListMock</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../llm/RoleAssignment', () => ({
|
||||
RoleAssignment: () => <div>RoleAssignmentMock</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../llm/ModelCombobox', () => ({
|
||||
ModelCombobox: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<input aria-label="Codex model" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeConfigResponse(): ConfigResponse {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
key: 'review',
|
||||
label: '审查引擎',
|
||||
description: 'Agent 审查模式、并发与沙箱设置',
|
||||
icon: 'file-check',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
label: '审查引擎',
|
||||
description: '代码审查模式',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['agent', 'codex'],
|
||||
value: 'agent',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'GLOBAL_PROMPT',
|
||||
label: '全局提示词',
|
||||
description: '附加到所有 LLM 调用',
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
value: '',
|
||||
hasValue: false,
|
||||
source: 'default',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_WORKDIR',
|
||||
label: '工作目录',
|
||||
description: 'Agent 模式下本地仓库目录',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: '/tmp/gitea-assistant',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MAX_PARALLEL_RUNS',
|
||||
label: '最大并发数',
|
||||
description: '单机同时执行的审查任务上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '2',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ALLOWED_COMMANDS',
|
||||
label: '允许命令',
|
||||
description: '本地审查沙箱命令白名单',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'git,rg,cat,sed,wc',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
label: '命令超时(ms)',
|
||||
description: '单条本地命令的执行超时时间',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 120000,
|
||||
max: 300000,
|
||||
value: '120000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
label: 'LLM 最大并发调用',
|
||||
description: '同时在飞的 LLM API 调用上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '4',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_TOKEN_BUDGET_LARGE',
|
||||
label: 'Large 令牌预算',
|
||||
description: 'large 规模审查任务的 token 预算上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '120000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'CODEX_MODEL',
|
||||
label: 'Codex 模型',
|
||||
description: 'Codex CLI 使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'o3',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReviewConfigPage', () => {
|
||||
it('shows only current Agent config surface and saves only visible fields', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
|
||||
vi.mocked(updateConfig).mockResolvedValue(undefined);
|
||||
vi.mocked(resetConfig).mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<ReviewConfigPage />);
|
||||
|
||||
expect(await screen.findByText('Agent 审查设置')).toBeInTheDocument();
|
||||
expect(screen.getByText('REVIEW_COMMAND_TIMEOUT_MS')).toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_TOKEN_BUDGET_LARGE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_ENABLE_HUMAN_GATE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('ENABLE_TRIAGE')).not.toBeInTheDocument();
|
||||
|
||||
const workdirInput = screen.getByDisplayValue('/tmp/gitea-assistant');
|
||||
await user.clear(workdirInput);
|
||||
await user.type(workdirInput, '/tmp/new-review-workdir');
|
||||
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
||||
|
||||
await waitFor(() => expect(updateConfig).toHaveBeenCalledTimes(1));
|
||||
const payload = vi.mocked(updateConfig).mock.calls[0][0];
|
||||
expect(payload.REVIEW_WORKDIR).toBe('/tmp/new-review-workdir');
|
||||
expect(payload.REVIEW_ENGINE).toBe('agent');
|
||||
expect(payload.REVIEW_COMMAND_TIMEOUT_MS).toBe('120000');
|
||||
expect(payload).not.toHaveProperty('REVIEW_TOKEN_BUDGET_LARGE');
|
||||
expect(payload).not.toHaveProperty('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE');
|
||||
expect(payload).not.toHaveProperty('REVIEW_ENABLE_HUMAN_GATE');
|
||||
expect(payload).not.toHaveProperty('ENABLE_TRIAGE');
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { Edit2, Trash2, Play, Plus, Activity } from 'lucide-react';
|
||||
import {
|
||||
fetchProviders, updateProvider, deleteProvider, testProvider, fetchRoles
|
||||
} from '@/services/llmProviderService';
|
||||
import { fetchProviders, updateProvider, deleteProvider, testProvider } from '@/services/llmProviderService';
|
||||
import type { ProviderDto, TestResult } from '@/services/llmProviderService';
|
||||
import { ProviderDialog } from './ProviderDialog';
|
||||
import { TestResultDialog } from './TestResultDialog';
|
||||
@@ -43,11 +41,6 @@ export function ProviderList() {
|
||||
queryFn: fetchProviders,
|
||||
});
|
||||
|
||||
const { data: roles = [] } = useQuery({
|
||||
queryKey: ['llm-roles'],
|
||||
queryFn: fetchRoles,
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({ id, isEnabled }: { id: string; isEnabled: boolean }) => {
|
||||
return updateProvider(id, { isEnabled });
|
||||
@@ -74,7 +67,6 @@ export function ProviderList() {
|
||||
onSuccess: () => {
|
||||
toast.success('已删除提供商');
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
@@ -87,16 +79,8 @@ export function ProviderList() {
|
||||
};
|
||||
|
||||
const handleDelete = (provider: ProviderDto) => {
|
||||
const boundRoles = roles.filter(r => r.providerId === provider.id);
|
||||
if (boundRoles.length > 0) {
|
||||
const roleNames = boundRoles.map(r => r.role).join(', ');
|
||||
if (!window.confirm(`警告:该提供商已绑定到以下角色 (${roleNames})。\n删除后这些角色将失去提供商配置!\n确定要删除吗?`)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!window.confirm(`确定要删除提供商 "${provider.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确定要删除提供商 "${provider.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(provider.id);
|
||||
};
|
||||
|
||||
@@ -1,213 +1,229 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { fetchConfig, updateConfig } from '@/services/configService';
|
||||
import type { ConfigResponse, ConfigFieldDto } from '@/services/configService';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertCircle, Save, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Save, ShieldCheck } from 'lucide-react';
|
||||
import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService';
|
||||
import { ModelCombobox } from './ModelCombobox';
|
||||
|
||||
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
|
||||
planner: { label: '规划器 Planner', desc: '多阶段审查的第一步,负责分析上下文并分配任务' },
|
||||
specialist: { label: '专家 Specialist', desc: '执行深度代码审查的主力模型,专注于发现具体问题' },
|
||||
judge: { label: '评审 Judge', desc: '对专家的建议进行审核、合并和过滤,确保评论质量' },
|
||||
embedding: { label: '嵌入 Embedding', desc: '用于向量化代码和注释,支持语义搜索 (Qdrant)' },
|
||||
};
|
||||
|
||||
const ROLES = ['planner', 'specialist', 'judge', 'embedding'];
|
||||
|
||||
interface RoleState {
|
||||
providerId: string | null;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export function RoleAssignment() {
|
||||
const queryClient = useQueryClient();
|
||||
const [roleStates, setRoleStates] = useState<Record<string, RoleState>>({});
|
||||
const [localValues, setLocalValues] = useState<Record<string, string>>({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
const { data: providers = [] } = useQuery({
|
||||
queryKey: ['llm-providers'],
|
||||
queryFn: fetchProviders,
|
||||
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchConfig,
|
||||
});
|
||||
|
||||
const { data: roles = [], isLoading } = useQuery({
|
||||
queryKey: ['llm-roles'],
|
||||
queryFn: fetchRoles,
|
||||
});
|
||||
const REQUIRED_KEYS = [
|
||||
'AGENT_MAIN_MODEL',
|
||||
'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
'LLM_MAX_CONCURRENT_CALLS',
|
||||
'LLM_RETRY_MAX_ATTEMPTS',
|
||||
'LLM_RETRY_BASE_DELAY_MS',
|
||||
];
|
||||
|
||||
const fieldsMap = useMemo(() => {
|
||||
if (!data) return new Map<string, ConfigFieldDto>();
|
||||
const map = new Map<string, ConfigFieldDto>();
|
||||
data.groups.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
map.set(field.envKey, field);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (roles.length > 0) {
|
||||
const initial: Record<string, RoleState> = {};
|
||||
roles.forEach(role => {
|
||||
initial[role.role] = {
|
||||
providerId: role.providerId,
|
||||
model: role.model || '',
|
||||
};
|
||||
});
|
||||
// Fill missing roles
|
||||
ROLES.forEach(r => {
|
||||
if (!initial[r]) {
|
||||
initial[r] = { providerId: null, model: '' };
|
||||
if (data) {
|
||||
const initialValues: Record<string, string> = {};
|
||||
REQUIRED_KEYS.forEach((key) => {
|
||||
const field = fieldsMap.get(key);
|
||||
if (field) {
|
||||
initialValues[key] = String(field.value ?? field.defaultValue ?? '');
|
||||
} else {
|
||||
initialValues[key] = '';
|
||||
}
|
||||
});
|
||||
setRoleStates(initial);
|
||||
} else if (!isLoading) {
|
||||
const initial: Record<string, RoleState> = {};
|
||||
ROLES.forEach(r => {
|
||||
initial[r] = { providerId: null, model: '' };
|
||||
});
|
||||
setRoleStates(initial);
|
||||
setLocalValues(initialValues);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [roles, isLoading]);
|
||||
}, [data, fieldsMap]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async ({ role, providerId, model }: { role: string; providerId: string | null; model: string | null }) => {
|
||||
return setRole(role, providerId, model);
|
||||
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
|
||||
onSuccess: () => {
|
||||
toast.success('智能体模型设置已保存');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setIsDirty(false);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${ROLE_LABELS[data.role]?.label || data.role} 角色配置已保存`);
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
|
||||
onError: (err: Error) => {
|
||||
toast.error(`保存失败: ${err.message}`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
toast.error(`保存失败: ${err?.response?.data?.error || err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const handleProviderChange = (role: string, providerId: string) => {
|
||||
const provider = providers.find(p => p.id === providerId);
|
||||
setRoleStates(prev => ({
|
||||
...prev,
|
||||
[role]: {
|
||||
providerId,
|
||||
model: provider?.defaultModel || ''
|
||||
}
|
||||
}));
|
||||
const handleFieldChange = (key: string, value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, [key]: value }));
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleModelChange = (role: string, model: string) => {
|
||||
setRoleStates(prev => ({
|
||||
...prev,
|
||||
[role]: { ...prev[role], model }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = (role: string) => {
|
||||
const state = roleStates[role];
|
||||
if (!state.providerId) {
|
||||
return toast.error('请选择提供商');
|
||||
}
|
||||
if (!state.model) {
|
||||
return toast.error('请输入模型名称');
|
||||
}
|
||||
saveMutation.mutate({
|
||||
role,
|
||||
providerId: state.providerId,
|
||||
model: state.model,
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
REQUIRED_KEYS.forEach((key) => {
|
||||
payload[key] = localValues[key] ?? '';
|
||||
});
|
||||
saveMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const enabledProviders = providers.filter(p => p.isEnabled && p.hasKey);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
加载配置中...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content">
|
||||
<div className="theme-error-panel flex items-center gap-3 text-danger">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<div className="font-medium tracking-wide">加载配置失败: {error.message}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const missingKeys = REQUIRED_KEYS.filter((key) => !fieldsMap.has(key));
|
||||
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header flex flex-row items-center justify-between pb-4 space-y-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center border border-warning/20 group-hover:bg-warning/20 transition-all duration-300">
|
||||
<ShieldCheck className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
角色分配
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
为 AI 审查系统的不同角色指定提供商和模型
|
||||
</CardDescription>
|
||||
<CardHeader className="theme-card-header pb-4 flex flex-row items-start justify-between space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
管理智能体运行时的主模型、子模型以及 LLM 调用弹性设置。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
className="theme-interactive-elevate min-w-[100px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存设置
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content space-y-6">
|
||||
{missingKeys.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-warning/10 border border-warning/20 text-warning text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold">部分配置项在系统中不可用:</span>
|
||||
<span className="font-mono text-xs">{missingKeys.join(', ')}</span>。这些设置将无法编辑或保存。
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
<CardContent className="theme-card-content">
|
||||
{isLoading ? (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载角色配置...
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{ROLES.map(role => {
|
||||
const state = roleStates[role] || { providerId: null, model: '' };
|
||||
const isDirty = roles.find(r => r.role === role)?.providerId !== state.providerId ||
|
||||
(roles.find(r => r.role === role)?.model || '') !== state.model;
|
||||
|
||||
return (
|
||||
<div key={role} className="flex flex-col md:flex-row items-start md:items-center gap-4 py-5 px-1 hover:bg-accent/40 transition-colors rounded-lg">
|
||||
<div className="w-full md:w-1/3 space-y-1.5">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{ROLE_LABELS[role]?.label || role}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{ROLE_LABELS[role]?.desc}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{REQUIRED_KEYS.map((key) => {
|
||||
const field = fieldsMap.get(key);
|
||||
const isAvailable = !!field;
|
||||
const label = field?.label || key;
|
||||
const description = field?.description || '系统未提供该配置项的描述。';
|
||||
const type = field?.type === 'number' ? 'number' : 'text';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex flex-col gap-2 p-4 rounded-lg border transition-colors ${
|
||||
isAvailable
|
||||
? 'border-border hover:bg-accent/20'
|
||||
: 'border-dashed border-muted bg-muted/10 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={key} className="text-base font-semibold text-foreground cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
{!isAvailable && (
|
||||
<Badge variant="outline" className="border-danger/30 text-danger bg-danger/5">
|
||||
不可用
|
||||
</Badge>
|
||||
)}
|
||||
{isAvailable && field.source === 'db' && (
|
||||
<Badge className="bg-primary/20 text-primary border-primary/30 tech-glow">
|
||||
已配置
|
||||
</Badge>
|
||||
)}
|
||||
{isAvailable && field.source === 'default' && (
|
||||
<Badge variant="outline" className="border-border text-muted-foreground">
|
||||
默认值
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</span>
|
||||
<div className="pt-1">
|
||||
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-2/3 flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">提供商</Label>
|
||||
<Select
|
||||
value={state.providerId || ''}
|
||||
onValueChange={(v) => handleProviderChange(role, v)}
|
||||
>
|
||||
<SelectTrigger className="bg-muted/50 border-border text-foreground">
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-popover border-border text-foreground">
|
||||
{enabledProviders.map(p => (
|
||||
<SelectItem key={p.id} value={p.id} description={p.type} className="focus:bg-accent focus:text-primary">
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{enabledProviders.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-danger text-center border-t border-border/60">
|
||||
无可用提供商。请先添加并启用。
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">使用的模型</Label>
|
||||
<ModelCombobox
|
||||
providerType={providers.find(p => p.id === state.providerId)?.type}
|
||||
value={state.model}
|
||||
onChange={(model) => handleModelChange(role, model)}
|
||||
placeholder="选择或输入模型..."
|
||||
disabled={!state.providerId}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-5 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSave(role)}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
variant={isDirty ? 'default' : 'secondary'}
|
||||
className={`transition-all ${isDirty ? 'bg-warning/15 text-warning border border-warning/30 hover:bg-warning/25' : 'bg-muted/50 text-muted-foreground border border-transparent'}`}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
{isDirty ? '保存更改' : '已保存'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 w-full max-w-xl flex flex-col gap-2">
|
||||
<Input
|
||||
id={key}
|
||||
type={type}
|
||||
value={localValues[key] ?? ''}
|
||||
onChange={(e) => handleFieldChange(key, e.target.value)}
|
||||
disabled={!isAvailable || saveMutation.isPending}
|
||||
placeholder={!isAvailable ? '配置项不可用' : `请输入 ${label}...`}
|
||||
className="bg-muted/50 border-border focus-visible:ring-primary focus-visible:border-primary transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import {
|
||||
fetchProviders,
|
||||
fetchRoles,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
testProvider,
|
||||
@@ -20,7 +19,6 @@ vi.mock('sonner', () => ({
|
||||
|
||||
vi.mock('@/services/llmProviderService', () => ({
|
||||
fetchProviders: vi.fn(),
|
||||
fetchRoles: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
@@ -62,7 +60,6 @@ describe('ProviderList', () => {
|
||||
createdAt: '2026-01-01',
|
||||
},
|
||||
]);
|
||||
vi.mocked(fetchRoles).mockResolvedValueOnce([]);
|
||||
vi.mocked(updateProvider).mockResolvedValue({} as never);
|
||||
vi.mocked(deleteProvider).mockResolvedValue(undefined);
|
||||
vi.mocked(testProvider).mockResolvedValue({ success: true });
|
||||
|
||||
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RoleAssignment } from '../RoleAssignment';
|
||||
import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService';
|
||||
import { fetchConfig, updateConfig, type ConfigResponse } from '@/services/configService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
@@ -13,21 +13,10 @@ vi.mock('sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/llmProviderService', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/services/llmProviderService')>('@/services/llmProviderService');
|
||||
return {
|
||||
...actual,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchRoles: vi.fn(),
|
||||
setRole: vi.fn(),
|
||||
fetchModelSuggestions: vi.fn().mockResolvedValue({
|
||||
openai_compatible: ['gpt-4o', 'gpt-4o-mini'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini'],
|
||||
anthropic: ['claude-sonnet-4-20250514'],
|
||||
gemini: ['gemini-2.5-pro'],
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@/services/configService', () => ({
|
||||
fetchConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
@@ -39,60 +28,163 @@ function renderWithQuery(ui: ReactNode) {
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeConfigResponse(): ConfigResponse {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM 设置',
|
||||
description: 'LLM 运行时与弹性设置',
|
||||
icon: 'brain',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
label: '主智能体模型',
|
||||
description: '主智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
label: '默认子智能体模型',
|
||||
description: '子智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o-mini',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
label: 'LLM 最大并发调用',
|
||||
description: '同时在飞的 LLM API 调用上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '4',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_RETRY_MAX_ATTEMPTS',
|
||||
label: 'LLM 最大重试次数',
|
||||
description: 'LLM 调用失败时的最大重试次数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '3',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_RETRY_BASE_DELAY_MS',
|
||||
label: 'LLM 重试基础延迟(ms)',
|
||||
description: 'LLM 调用失败重试的基础延迟时间',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '1000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('RoleAssignment', () => {
|
||||
it('renders role cards and supports provider/model editing', async () => {
|
||||
vi.mocked(fetchProviders).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'OpenAI',
|
||||
type: 'openai_responses',
|
||||
baseUrl: null,
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
isEnabled: true,
|
||||
hasKey: true,
|
||||
extraConfig: {},
|
||||
createdAt: '2026-01-01',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(fetchRoles).mockResolvedValueOnce([
|
||||
{
|
||||
role: 'planner',
|
||||
providerId: 'p1',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(setRole).mockResolvedValue({
|
||||
role: 'planner',
|
||||
providerId: 'p1',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'custom-planner-model',
|
||||
});
|
||||
it('renders agent model settings and saves edits', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
|
||||
vi.mocked(updateConfig).mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<RoleAssignment />);
|
||||
|
||||
expect(await screen.findByText('角色分配')).toBeInTheDocument();
|
||||
expect(await screen.findByText('规划器 Planner')).toBeInTheDocument();
|
||||
// Wait for the fields to load and render
|
||||
expect(await screen.findByText('主智能体模型')).toBeInTheDocument();
|
||||
expect(screen.getByText('智能体模型设置')).toBeInTheDocument();
|
||||
expect(screen.getByText('默认子智能体模型')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 最大并发调用')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 最大重试次数')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 重试基础延迟(ms)')).toBeInTheDocument();
|
||||
|
||||
// Radix Select renders placeholder in a span with pointer-events: none.
|
||||
// Click the trigger button (parent) instead of the placeholder text.
|
||||
const providerPlaceholders = screen.getAllByText('选择提供商');
|
||||
const triggerButton = providerPlaceholders[0].closest('button')!;
|
||||
await user.click(triggerButton);
|
||||
await user.click(await screen.findByRole('option', { name: /OpenAI/ }));
|
||||
const legacyLabels = ['pla' + 'nner', 'speci' + 'alist', 'ju' + 'dge', '角色' + '分配'];
|
||||
legacyLabels.forEach((label) => {
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(['/', 'llm', '/', 'roles'].join(''))).not.toBeInTheDocument();
|
||||
|
||||
const modelInputs = screen.getAllByPlaceholderText('选择或输入模型...') as HTMLInputElement[];
|
||||
await waitFor(() => {
|
||||
expect(modelInputs[0].value).toBe('gpt-4o');
|
||||
const mainModelInput = screen.getByLabelText('主智能体模型');
|
||||
const subagentModelInput = screen.getByLabelText('默认子智能体模型');
|
||||
const maxCallsInput = screen.getByLabelText('LLM 最大并发调用');
|
||||
const retryAttemptsInput = screen.getByLabelText('LLM 最大重试次数');
|
||||
const retryDelayInput = screen.getByLabelText('LLM 重试基础延迟(ms)');
|
||||
|
||||
await user.clear(mainModelInput);
|
||||
await user.type(mainModelInput, 'claude-3-5-sonnet');
|
||||
|
||||
await user.clear(subagentModelInput);
|
||||
await user.type(subagentModelInput, 'claude-3-5-haiku');
|
||||
|
||||
await user.clear(maxCallsInput);
|
||||
await user.type(maxCallsInput, '8');
|
||||
|
||||
await user.clear(retryAttemptsInput);
|
||||
await user.type(retryAttemptsInput, '5');
|
||||
|
||||
await user.clear(retryDelayInput);
|
||||
await user.type(retryDelayInput, '2000');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: '保存设置' });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(updateConfig).toHaveBeenCalledTimes(1));
|
||||
const payload = vi.mocked(updateConfig).mock.calls[0][0];
|
||||
expect(payload).toEqual({
|
||||
AGENT_MAIN_MODEL: 'claude-3-5-sonnet',
|
||||
AGENT_DEFAULT_SUBAGENT_MODEL: 'claude-3-5-haiku',
|
||||
LLM_MAX_CONCURRENT_CALLS: '8',
|
||||
LLM_RETRY_MAX_ATTEMPTS: '5',
|
||||
LLM_RETRY_BASE_DELAY_MS: '2000',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders missing-field/unavailable state when fields are missing', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue({
|
||||
groups: [
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM 设置',
|
||||
description: 'LLM 运行时与弹性设置',
|
||||
icon: 'brain',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
label: '主智能体模型',
|
||||
description: '主智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await user.clear(modelInputs[0]);
|
||||
await user.type(modelInputs[0], 'custom-planner-model');
|
||||
expect(modelInputs[0].value).toBe('custom-planner-model');
|
||||
renderWithQuery(<RoleAssignment />);
|
||||
|
||||
// Wait for the warning to load and render
|
||||
expect(await screen.findByText('部分配置项在系统中不可用:')).toBeInTheDocument();
|
||||
expect(screen.getByText('智能体模型设置')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('AGENT_DEFAULT_SUBAGENT_MODEL')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_MAX_CONCURRENT_CALLS')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_RETRY_MAX_ATTEMPTS')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_RETRY_BASE_DELAY_MS')).toBeInTheDocument();
|
||||
|
||||
const subagentInput = screen.getByLabelText('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
expect(subagentInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette } from 'lucide-react';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette, Layers } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
||||
@@ -11,6 +11,7 @@ const navItems = [
|
||||
{ path: '/config', label: '系统配置', icon: Sliders },
|
||||
{ path: '/notifications', label: '通知管理', icon: Bell },
|
||||
{ path: '/review-config', label: '审查配置', icon: FileSearch },
|
||||
{ path: '/review-runs', label: '审查任务', icon: Layers },
|
||||
] as const;
|
||||
|
||||
export default function DashboardPage() {
|
||||
|
||||
797
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
797
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
|
||||
import type { AgentSessionTree } from '@/services/reviewSessionService';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Bot, Cpu, Terminal, CheckCircle2, AlertCircle,
|
||||
ChevronRight, ChevronDown, Clock, FileText, Layers,
|
||||
AlertTriangle, CornerDownRight, HelpCircle, Info
|
||||
} from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper Components & Formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
case 'completed':
|
||||
return <Badge className="bg-success/20 text-success border-success/30">成功</Badge>;
|
||||
case 'failed':
|
||||
return <Badge className="bg-danger/20 text-danger border-danger/30">失败</Badge>;
|
||||
case 'running':
|
||||
case 'in_progress':
|
||||
return <Badge className="bg-primary/20 text-primary border-primary/30 animate-pulse">运行中</Badge>;
|
||||
case 'queued':
|
||||
return <Badge className="bg-warning/20 text-warning border-warning/30">排队中</Badge>;
|
||||
case 'ignored':
|
||||
return <Badge className="bg-muted text-muted-foreground border-border">已忽略</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: 'high' | 'medium' | 'low' }) {
|
||||
switch (severity) {
|
||||
case 'high':
|
||||
return <Badge className="bg-danger/20 text-danger border-danger/30 font-bold">高</Badge>;
|
||||
case 'medium':
|
||||
return <Badge className="bg-warning/20 text-warning border-warning/30 font-bold">中</Badge>;
|
||||
case 'low':
|
||||
return <Badge className="bg-info/20 text-info border-info/30 font-bold">低</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{severity}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(isoString?: string): string {
|
||||
if (!isoString) return '-';
|
||||
return new Date(isoString).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Session Tree Node Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TreeNodeProps {
|
||||
session: AgentSessionTree;
|
||||
level: number;
|
||||
onSelectSession: (session: AgentSessionTree) => void;
|
||||
selectedSessionId?: string;
|
||||
}
|
||||
|
||||
function AgentTreeNode({ session, level, onSelectSession, selectedSessionId }: TreeNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const hasChildren = session.invocations && session.invocations.some(inv => inv.childSession);
|
||||
const isSelected = selectedSessionId === session.id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{/* Node Row */}
|
||||
<div
|
||||
onClick={() => onSelectSession(session)}
|
||||
className={`flex items-center justify-between p-3 rounded-xl border transition-all duration-200 cursor-pointer mb-2 ${
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/10 theme-glow-primary'
|
||||
: 'border-border/60 bg-muted/30 hover:bg-accent/40 hover:border-border'
|
||||
}`}
|
||||
style={{ marginLeft: `${level * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
{level > 0 && <CornerDownRight className="w-4 h-4 text-muted-foreground/50" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-2 rounded-lg ${level === 0 ? 'bg-primary/10 text-primary' : 'bg-info/10 text-info'}`}>
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm text-foreground truncate">
|
||||
{level === 0 ? '主代理' : '子代理'}: {session.agentType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{session.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status={session.status} />
|
||||
{session.error && (
|
||||
<div title="代理执行出错">
|
||||
<AlertTriangle className="w-4 h-4 text-danger animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && session.invocations && (
|
||||
<div className="flex flex-col w-full">
|
||||
{session.invocations.map((inv) => {
|
||||
if (inv.childSession) {
|
||||
return (
|
||||
<AgentTreeNode
|
||||
key={inv.childSession.id}
|
||||
session={inv.childSession}
|
||||
level={level + 1}
|
||||
onSelectSession={onSelectSession}
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
);
|
||||
} else if (inv.status === 'failed') {
|
||||
// Failed subagent invocation without child session
|
||||
return (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-danger/30 bg-danger/5 mb-2"
|
||||
style={{ marginLeft: `${(level + 1) * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<CornerDownRight className="w-4 h-4 text-danger/50" />
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-danger/10 text-danger">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm text-danger truncate">
|
||||
子代理启动失败: {inv.agentType}
|
||||
</span>
|
||||
<span className="text-xs text-danger/80 font-mono truncate">
|
||||
{inv.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status="failed" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Session Detail Panel Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DetailPanelProps {
|
||||
session: AgentSessionTree;
|
||||
}
|
||||
|
||||
function AgentDetailPanel({ session }: DetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<'messages' | 'tools' | 'raw'>('messages');
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-muted/10 h-full flex flex-col">
|
||||
<CardHeader className="border-b border-border/50 pb-4 shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" />
|
||||
{session.parentSessionId ? '子代理详情' : '主代理详情'}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-mono text-xs text-muted-foreground break-all">
|
||||
ID: {session.id}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<StatusBadge status={session.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">代理类型</span>
|
||||
<span className="font-semibold text-foreground">{session.agentType}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">运行模型</span>
|
||||
<span className="font-mono font-semibold text-foreground">{session.model}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">启动时间</span>
|
||||
<span className="text-foreground">{formatDateTime(session.startedAt)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">结束时间</span>
|
||||
<span className="text-foreground">{formatDateTime(session.completedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.error && (
|
||||
<div className="mt-4 p-3 rounded-lg border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold">执行错误</div>
|
||||
<pre className="mt-1 font-mono text-xs whitespace-pre-wrap break-all">
|
||||
{typeof session.error === 'object' ? JSON.stringify(session.error, null, 2) : String(session.error)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
|
||||
<div className="border-b border-border/50 px-4 py-2 bg-muted/30 shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={activeTab === 'messages' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('messages')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 mr-1.5" />
|
||||
消息记录 ({session.messages?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'tools' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('tools')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<Terminal className="w-3.5 h-3.5 mr-1.5" />
|
||||
工具调用 ({session.toolCalls?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'raw' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('raw')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<Info className="w-3.5 h-3.5 mr-1.5" />
|
||||
元数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{activeTab === 'messages' && (
|
||||
<div className="space-y-4">
|
||||
{session.messages && session.messages.length > 0 ? (
|
||||
session.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex flex-col p-3 rounded-xl border ${
|
||||
msg.role === 'user'
|
||||
? 'border-primary/20 bg-primary/5 ml-8'
|
||||
: msg.role === 'assistant'
|
||||
? 'border-border bg-muted/40 mr-8'
|
||||
: 'border-warning/20 bg-warning/5 mx-4'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${
|
||||
msg.role === 'user' ? 'text-primary' : msg.role === 'assistant' ? 'text-foreground' : 'text-warning'
|
||||
}`}>
|
||||
{msg.role}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatDateTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap break-all font-sans leading-relaxed">
|
||||
{typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: typeof msg.content === 'object' && msg.content !== null && 'text' in msg.content
|
||||
? String(msg.content.text)
|
||||
: JSON.stringify(msg.content, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无消息记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tools' && (
|
||||
<div className="space-y-4">
|
||||
{session.toolCalls && session.toolCalls.length > 0 ? (
|
||||
session.toolCalls.map((tool) => (
|
||||
<div key={tool.id} className="border border-border/60 rounded-xl overflow-hidden bg-muted/20">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-3.5 h-3.5 text-primary" />
|
||||
<span className="font-mono text-sm font-bold text-foreground">{tool.toolName}</span>
|
||||
</div>
|
||||
<StatusBadge status={tool.status} />
|
||||
</div>
|
||||
<div className="p-3 space-y-3 text-xs font-mono">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">参数 (Arguments)</div>
|
||||
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(tool.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{tool.result !== undefined && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">结果 (Result)</div>
|
||||
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
|
||||
{typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{tool.error && (
|
||||
<div>
|
||||
<div className="text-danger mb-1">错误 (Error)</div>
|
||||
<pre className="p-2 rounded-lg bg-danger/5 border border-danger/20 text-danger overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{typeof tool.error === 'string' ? tool.error : JSON.stringify(tool.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无工具调用记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'raw' && (
|
||||
<div className="space-y-4 font-mono text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">元数据 (Metadata)</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(session.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{session.finalResult !== undefined && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">最终结果 (Final Result)</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(session.finalResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ReviewSessionsPage() {
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [selectedSession, setSelectedSession] = useState<AgentSessionTree | null>(null);
|
||||
|
||||
// Fetch runs list
|
||||
const { data: runsData, isLoading: isListLoading, isError: isListError, error: listError } = useQuery({
|
||||
queryKey: ['reviewRuns'],
|
||||
queryFn: () => fetchReviewRuns(50),
|
||||
});
|
||||
|
||||
// Fetch selected run details
|
||||
const { data: runDetails, isLoading: isDetailsLoading, isError: isDetailsError, error: detailsError } = useQuery({
|
||||
queryKey: ['reviewRunDetails', selectedRunId],
|
||||
queryFn: () => fetchReviewRunDetails(selectedRunId!),
|
||||
enabled: !!selectedRunId,
|
||||
});
|
||||
|
||||
const runs = runsData?.data ?? [];
|
||||
|
||||
// Handle run selection
|
||||
const handleSelectRun = (runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
setSelectedSession(null); // Reset selected session when switching runs
|
||||
};
|
||||
|
||||
// Automatically select first run if none selected
|
||||
if (!selectedRunId && runs.length > 0) {
|
||||
setSelectedRunId(runs[0].id);
|
||||
}
|
||||
|
||||
// Automatically select root session when run details load
|
||||
if (runDetails?.sessionTree && !selectedSession) {
|
||||
setSelectedSession(runDetails.sessionTree);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Sidebar: Runs List */}
|
||||
<aside className="w-80 border-r border-border/50 flex flex-col bg-muted/10 shrink-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-border/50 shrink-0">
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
审查任务列表
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">展示最近 50 次自动审查任务</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{isListLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-border/40 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4 bg-muted/60" />
|
||||
<Skeleton className="h-3 w-1/2 bg-muted/60" />
|
||||
</div>
|
||||
))
|
||||
) : isListError ? (
|
||||
<div className="theme-error-panel flex items-center gap-2 p-4">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<span className="text-sm font-medium">加载列表失败: {listError.message}</span>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
暂无审查任务记录
|
||||
</div>
|
||||
) : (
|
||||
runs.map((run) => {
|
||||
const isSelected = selectedRunId === run.id;
|
||||
return (
|
||||
<div
|
||||
key={run.id}
|
||||
onClick={() => handleSelectRun(run.id)}
|
||||
className={`p-3.5 rounded-xl border transition-all duration-200 cursor-pointer flex flex-col gap-2 ${
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/5 theme-glow-primary'
|
||||
: 'border-transparent hover:bg-accent/40 hover:border-border/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-bold text-sm text-foreground truncate flex-1">
|
||||
{run.owner}/{run.repo}
|
||||
</span>
|
||||
<StatusBadge status={run.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-border/60">
|
||||
{run.eventType === 'pull_request' ? `PR #${run.prNumber}` : 'Commit'}
|
||||
</Badge>
|
||||
<span className="truncate font-mono text-[10px]">
|
||||
{run.commitSha?.substring(0, 7) || run.headSha?.substring(0, 7) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground pt-1 border-t border-border/30">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(run.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<span>尝试: {run.attempts}/{run.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right Content: Run Details */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
{selectedRunId ? (
|
||||
isDetailsLoading ? (
|
||||
<div className="flex-1 p-6 space-y-6 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-1/3 bg-muted/60" />
|
||||
<Skeleton className="h-4 w-1/4 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
) : isDetailsError ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="theme-error-panel flex items-center gap-3 max-w-md">
|
||||
<AlertCircle className="w-6 h-6 text-danger shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold text-foreground">加载详情失败</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{detailsError.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !runDetails ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
未找到任务详情
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
<header className="p-6 border-b border-border/50 shrink-0 bg-muted/5">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-foreground tracking-tight">
|
||||
{runDetails.run.owner}/{runDetails.run.repo}
|
||||
</h1>
|
||||
<StatusBadge status={runDetails.run.status} />
|
||||
<Badge variant="outline" className="border-border/60">
|
||||
{runDetails.run.eventType === 'pull_request' ? `PR #${runDetails.run.prNumber}` : 'Commit'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
任务 ID: {runDetails.run.id} | Commit: {runDetails.run.commitSha || runDetails.run.headSha || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">创建时间</span>
|
||||
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.createdAt)}</span>
|
||||
</div>
|
||||
{runDetails.run.finishedAt && (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">完成时间</span>
|
||||
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.finishedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runDetails.run.error && (
|
||||
<div className="mt-4 p-3 rounded-xl border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-semibold">任务执行失败:</span> {runDetails.run.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Detail Tabs */}
|
||||
<Tabs defaultValue="observability" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-6 border-b border-border/50 bg-muted/5 shrink-0">
|
||||
<TabsList className="h-12 bg-transparent p-0 gap-6 border-b-0">
|
||||
<TabsTrigger
|
||||
value="observability"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
代理观测 (Observability)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="findings"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
审查结果 ({runDetails.findings?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="log"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
运行日志 ({runDetails.steps?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Tab Content: Observability */}
|
||||
<TabsContent value="observability" className="flex-1 overflow-hidden p-6 m-0 flex flex-col md:flex-row gap-6">
|
||||
{runDetails.sessionTree ? (
|
||||
<>
|
||||
{/* Left: Session Tree */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto pr-2">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
代理调用树 (Parent-Child Tree)
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<AgentTreeNode
|
||||
session={runDetails.sessionTree}
|
||||
level={0}
|
||||
onSelectSession={(session) => setSelectedSession(session)}
|
||||
selectedSessionId={selectedSession?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Selected Session Detail */}
|
||||
<div className="flex-1 h-full overflow-hidden">
|
||||
{selectedSession ? (
|
||||
<AgentDetailPanel session={selectedSession} />
|
||||
) : (
|
||||
<div className="h-full border border-dashed border-border/60 rounded-xl flex flex-col items-center justify-center text-muted-foreground p-6">
|
||||
<Bot className="w-12 h-12 text-muted-foreground/40 mb-3 animate-pulse" />
|
||||
<p className="text-sm font-medium">请在左侧选择一个代理节点查看详细调用轨迹</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground border border-dashed border-border/60 rounded-xl p-12">
|
||||
<HelpCircle className="w-12 h-12 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm font-medium">本次审查任务未使用 Agent 引擎,或暂无代理调用轨迹数据</p>
|
||||
<p className="text-xs text-muted-foreground/80 mt-1">请确保系统配置中已启用 Agent 审查引擎</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Content: Findings */}
|
||||
<TabsContent value="findings" className="flex-1 overflow-y-auto p-6 m-0 space-y-4">
|
||||
{runDetails.findings && runDetails.findings.length > 0 ? (
|
||||
runDetails.findings.map((finding) => (
|
||||
<Card key={finding.id} className="border-border/60 hover:border-border transition-all duration-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
<Badge variant="outline" className="bg-muted/50 border-border/60 text-xs">
|
||||
{finding.category}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{finding.path}:{finding.line}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-base font-bold text-foreground tracking-tight">
|
||||
{finding.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 flex items-center gap-1">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
置信度: {(finding.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<div className="font-semibold text-foreground mb-1">详细描述</div>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.detail}</p>
|
||||
</div>
|
||||
{finding.evidence && (
|
||||
<div>
|
||||
<div className="font-semibold text-foreground mb-1">代码证据</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{finding.evidence}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{finding.suggestion && (
|
||||
<div className="p-3.5 rounded-xl border border-success/20 bg-success/5">
|
||||
<div className="font-semibold text-success flex items-center gap-1.5 mb-1">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
修改建议
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.suggestion}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
|
||||
本次审查未发现任何问题
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Content: Run Log */}
|
||||
<TabsContent value="log" className="flex-1 overflow-y-auto p-6 m-0 space-y-6">
|
||||
{/* Steps */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
执行步骤 (Steps)
|
||||
</h3>
|
||||
<div className="border border-border/60 rounded-xl overflow-hidden bg-muted/10">
|
||||
<table className="w-full text-left border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border/50 text-muted-foreground font-semibold">
|
||||
<th className="p-3">步骤名称</th>
|
||||
<th className="p-3">状态</th>
|
||||
<th className="p-3">耗时</th>
|
||||
<th className="p-3">开始时间</th>
|
||||
<th className="p-3">结束时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{runDetails.steps && runDetails.steps.length > 0 ? (
|
||||
runDetails.steps.map((step) => (
|
||||
<tr key={step.id} className="hover:bg-accent/20 transition-colors">
|
||||
<td className="p-3 font-medium text-foreground">{step.stepName}</td>
|
||||
<td className="p-3">
|
||||
<StatusBadge status={step.status} />
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{step.latencyMs ? `${(step.latencyMs / 1000).toFixed(2)}s` : '-'}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.startedAt)}</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.finishedAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
暂无步骤记录
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
评论记录 (Comments)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{runDetails.comments && runDetails.comments.length > 0 ? (
|
||||
runDetails.comments.map((comment) => (
|
||||
<div key={comment.id} className="p-4 rounded-xl border border-border/60 bg-muted/20 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
{comment.path && (
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{comment.path}:{comment.line}
|
||||
</span>
|
||||
)}
|
||||
{comment.giteaCommentId && (
|
||||
<Badge variant="outline" className="text-[10px] border-border/60">
|
||||
Gitea ID: {comment.giteaCommentId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={comment.status} />
|
||||
<span className="text-muted-foreground">{formatDateTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap break-all leading-relaxed">
|
||||
{comment.body}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
|
||||
暂无评论记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-6">
|
||||
<Bot className="w-16 h-16 text-muted-foreground/30 mb-4 animate-pulse" />
|
||||
<h3 className="text-lg font-bold text-foreground">请选择一个审查任务</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">在左侧列表中选择一个任务以查看其详细的代理调用轨迹和审查结果</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
frontend/src/pages/__tests__/ReviewSessionsPage.test.tsx
Normal file
312
frontend/src/pages/__tests__/ReviewSessionsPage.test.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ReviewSessionsPage from '../ReviewSessionsPage';
|
||||
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
|
||||
|
||||
vi.mock('@/services/reviewSessionService', () => ({
|
||||
fetchReviewRuns: vi.fn(),
|
||||
fetchReviewRunDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('ReviewSessionsPage', () => {
|
||||
it('Scenario 1: renders main agent plus two subagents with statuses, tool counts, and model info', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-1',
|
||||
idempotencyKey: 'key-1',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'succeeded' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 42,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
sessionId: 'session-main',
|
||||
sequence: 1,
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
metadata: {},
|
||||
createdAt: '2026-05-25T00:00:05.000Z',
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
sessionId: 'session-main',
|
||||
sequence: 1,
|
||||
toolName: 'search_code',
|
||||
status: 'completed',
|
||||
arguments: {},
|
||||
createdAt: '2026-05-25T00:00:10.000Z',
|
||||
},
|
||||
],
|
||||
invocations: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
parentSessionId: 'session-main',
|
||||
childSessionId: 'session-sub-1',
|
||||
sequence: 1,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'completed',
|
||||
input: {},
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
childSession: {
|
||||
id: 'session-sub-1',
|
||||
parentSessionId: 'session-main',
|
||||
parentInvocationId: 'inv-1',
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:15.000Z',
|
||||
completedAt: '2026-05-25T00:00:30.000Z',
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
updatedAt: '2026-05-25T00:00:30.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inv-2',
|
||||
parentSessionId: 'session-main',
|
||||
childSessionId: 'session-sub-2',
|
||||
sequence: 2,
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
status: 'completed',
|
||||
input: {},
|
||||
createdAt: '2026-05-25T00:00:35.000Z',
|
||||
childSession: {
|
||||
id: 'session-sub-2',
|
||||
parentSessionId: 'session-main',
|
||||
parentInvocationId: 'inv-2',
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:35.000Z',
|
||||
completedAt: '2026-05-25T00:00:50.000Z',
|
||||
createdAt: '2026-05-25T00:00:35.000Z',
|
||||
updatedAt: '2026-05-25T00:00:50.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
// Wait for details to load and render
|
||||
const mainAgentText = await screen.findByText('主代理: review-main-agent');
|
||||
expect(mainAgentText).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByText('gpt-main').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assert subagents are rendered
|
||||
expect(screen.getByText('子代理: security-reviewer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-sub-a').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('子代理: quality-reviewer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-sub-b').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assert tool calls count is visible in the details panel tabs
|
||||
expect(screen.getByText('工具调用 (1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Scenario 2: renders failed subagent invocation and findings correctly', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-2',
|
||||
idempotencyKey: 'key-2',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'failed' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 43,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
runId: 'run-2',
|
||||
fingerprint: 'fp-1',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'src/db.ts',
|
||||
line: 10,
|
||||
title: 'SQL Injection vulnerability',
|
||||
detail: 'Direct string concatenation in query',
|
||||
evidence: 'db.query("SELECT * FROM users WHERE id = " + id)',
|
||||
suggestion: 'Use parameterized queries',
|
||||
published: false,
|
||||
},
|
||||
],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main-2',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'failed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [
|
||||
{
|
||||
id: 'inv-failed',
|
||||
parentSessionId: 'session-main-2',
|
||||
sequence: 1,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'failed',
|
||||
input: {},
|
||||
error: 'Failed to initialize subagent',
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
// Wait for details to load and render
|
||||
const failedSubagentText = await screen.findByText('子代理启动失败: security-reviewer');
|
||||
expect(failedSubagentText).toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Switch to findings tab
|
||||
const findingsTab = screen.getByText('审查结果 (1)');
|
||||
expect(findingsTab).toBeInTheDocument();
|
||||
await user.click(findingsTab);
|
||||
|
||||
// Assert finding title still renders
|
||||
const findingTitle = await screen.findByText('SQL Injection vulnerability');
|
||||
expect(findingTitle).toBeInTheDocument();
|
||||
expect(screen.getByText('Direct string concatenation in query')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Scenario 3: asserts no legacy review labels are visible', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-3',
|
||||
idempotencyKey: 'key-3',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'succeeded' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 44,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main-3',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-owner/test-repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const legacyLabels = ['tri' + 'age', 'speci' + 'alist', 'ju' + 'dge', 'pla' + 'nner'];
|
||||
legacyLabels.forEach((label) => {
|
||||
expect(screen.queryByText(label)).toBeNull();
|
||||
});
|
||||
expect(screen.queryByText('分流')).toBeNull();
|
||||
expect(screen.queryByText('专家')).toBeNull();
|
||||
expect(screen.queryByText('裁判')).toBeNull();
|
||||
expect(screen.queryByText('规划')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,6 @@ export interface ProviderDto {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RoleAssignmentDto {
|
||||
role: string;
|
||||
providerId: string | null;
|
||||
providerName: string | null;
|
||||
providerType: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
@@ -75,16 +67,6 @@ export const deleteApiKey = async (id: string): Promise<void> => {
|
||||
await api.delete(`/llm/providers/${id}/key`);
|
||||
};
|
||||
|
||||
export const fetchRoles = async (): Promise<RoleAssignmentDto[]> => {
|
||||
const response = await api.get<RoleAssignmentDto[]>('/llm/roles');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const setRole = async (role: string, providerId: string | null, model: string | null): Promise<RoleAssignmentDto> => {
|
||||
const response = await api.put<RoleAssignmentDto>(`/llm/roles/${role}`, { providerId, model });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const testProvider = async (id: string): Promise<TestResult> => {
|
||||
const response = await api.post<TestResult>(`/llm/providers/${id}/test`);
|
||||
return response.data;
|
||||
|
||||
147
frontend/src/services/reviewSessionService.ts
Normal file
147
frontend/src/services/reviewSessionService.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
export type ReviewRunStatus = 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'ignored';
|
||||
|
||||
export interface ReviewRun {
|
||||
id: string;
|
||||
idempotencyKey: string;
|
||||
eventType: 'pull_request' | 'commit_status';
|
||||
status: ReviewRunStatus;
|
||||
owner: string;
|
||||
repo: string;
|
||||
cloneUrl: string;
|
||||
headCloneUrl?: string;
|
||||
prNumber?: number;
|
||||
relatedPrNumber?: number;
|
||||
baseSha?: string;
|
||||
headSha?: string;
|
||||
commitSha?: string;
|
||||
commitMessage?: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ReviewStep {
|
||||
id: string;
|
||||
runId: string;
|
||||
stepName: string;
|
||||
agentName?: string;
|
||||
status: 'started' | 'succeeded' | 'failed';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
latencyMs?: number;
|
||||
inputRef?: string;
|
||||
outputRef?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
id: string;
|
||||
runId: string;
|
||||
fingerprint: string;
|
||||
category: 'correctness' | 'security' | 'reliability' | 'maintainability';
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
confidence: number;
|
||||
path: string;
|
||||
line: number;
|
||||
title: string;
|
||||
detail: string;
|
||||
evidence: string;
|
||||
suggestion: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewCommentRecord {
|
||||
id: string;
|
||||
runId: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
body: string;
|
||||
giteaCommentId?: number;
|
||||
status: 'pending' | 'published' | 'failed';
|
||||
createdAt: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content: any;
|
||||
metadata: Record<string, any>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AgentToolCallRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
sequence: number;
|
||||
toolName: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
arguments: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentInvocationRecord {
|
||||
id: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
sequence: number;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
input: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionTree {
|
||||
id: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
metadata: Record<string, any>;
|
||||
finalResult?: any;
|
||||
error?: any;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: AgentMessageRecord[];
|
||||
toolCalls: AgentToolCallRecord[];
|
||||
invocations: Array<AgentInvocationRecord & { childSession?: AgentSessionTree }>;
|
||||
}
|
||||
|
||||
export interface ReviewRunDetails {
|
||||
run: ReviewRun;
|
||||
steps: ReviewStep[];
|
||||
findings: Finding[];
|
||||
comments: ReviewCommentRecord[];
|
||||
sessionTree?: AgentSessionTree | null;
|
||||
}
|
||||
|
||||
export const fetchReviewRuns = async (limit: number = 50): Promise<{ data: ReviewRun[] }> => {
|
||||
const response = await api.get<{ data: ReviewRun[] }>('/review/runs', {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchReviewRunDetails = async (runId: string): Promise<ReviewRunDetails> => {
|
||||
const response = await api.get<ReviewRunDetails>(`/review/runs/${runId}`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -195,24 +195,6 @@ const configResponse = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆设置',
|
||||
description: '控制上下文记忆与保留策略。',
|
||||
icon: 'database',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'MEMORY_ENABLED',
|
||||
label: '启用记忆',
|
||||
description: '是否启用长期记忆',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
value: true,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -243,23 +225,6 @@ const providers = [
|
||||
},
|
||||
];
|
||||
|
||||
const roles = [
|
||||
{
|
||||
role: 'planner',
|
||||
providerId: 'provider-openai',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
{
|
||||
role: 'specialist',
|
||||
providerId: 'provider-deepseek',
|
||||
providerName: 'DeepSeek',
|
||||
providerType: 'openai_compatible',
|
||||
model: 'deepseek-chat',
|
||||
},
|
||||
];
|
||||
|
||||
const modelSuggestions = {
|
||||
openai_compatible: ['deepseek-chat', 'gpt-4o-mini'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
|
||||
@@ -375,14 +340,6 @@ export async function installVisualApiMocks(page: Page) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/llm/roles')) {
|
||||
return json(route, roles);
|
||||
}
|
||||
|
||||
if (method === 'PUT' && /\/admin\/api\/llm\/roles\/[^/]+$/.test(path)) {
|
||||
return json(route, roles[0]);
|
||||
}
|
||||
|
||||
if (method === 'POST' && /\/admin\/api\/llm\/providers\/[^/]+\/test$/.test(path)) {
|
||||
return json(route, {
|
||||
success: true,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
---
|
||||
# ConfigMap: only infrastructure-level env vars that must be known before DB init
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
@@ -11,9 +9,6 @@ metadata:
|
||||
data:
|
||||
PORT: "5174"
|
||||
LOG_LEVEL: "error"
|
||||
# All settings (Gitea connection, webhook secret, admin password, review engine,
|
||||
# Feishu, memory, etc.) are managed through the Admin Dashboard Web UI.
|
||||
# They are auto-seeded with secure defaults on first boot.
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
|
||||
@@ -6,5 +6,4 @@ namespace: gitea-assistant
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- secret.yaml
|
||||
- qdrant.yaml
|
||||
- gitea-assistant.yaml
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: qdrant
|
||||
namespace: gitea-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
containers:
|
||||
- name: qdrant
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 6333
|
||||
protocol: TCP
|
||||
- name: grpc
|
||||
containerPort: 6334
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
volumeMounts:
|
||||
- name: qdrant-storage
|
||||
mountPath: /qdrant/storage
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
volumes:
|
||||
- name: qdrant-storage
|
||||
hostPath:
|
||||
# Customize this path to match your node's storage layout
|
||||
path: /opt/gitea-assistant/qdrant
|
||||
type: DirectoryOrCreate
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: qdrant
|
||||
namespace: gitea-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: qdrant
|
||||
ports:
|
||||
- name: http
|
||||
port: 6333
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
- name: grpc
|
||||
port: 6334
|
||||
targetPort: grpc
|
||||
protocol: TCP
|
||||
@@ -9,7 +9,6 @@
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@google/genai": "^1.43.0",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@qdrant/js-client-rest": "^1.16.2",
|
||||
"axios": "^1.8.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"hono": "^4.11.9",
|
||||
@@ -52,6 +51,7 @@
|
||||
"start:prod": "bun run dist/index.js",
|
||||
"lint": "biome check src/",
|
||||
"test": "bun test",
|
||||
"test:e2e": "bash ./e2e/test.sh",
|
||||
"prepare": "command -v husky >/dev/null 2>&1 && husky || true"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
agentDefinitionSchema,
|
||||
isAgentDefinition,
|
||||
normalizeAgentDefinition,
|
||||
} from '../agent-definition';
|
||||
|
||||
describe('agentDefinitionSchema', () => {
|
||||
test('parses a valid agent definition', () => {
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:fix-validator',
|
||||
whenToUse: 'Use for focused fix validation after a failing test run.',
|
||||
source: 'built-in',
|
||||
tools: ['readFile', 'searchCode'],
|
||||
disallowedTools: ['deleteFile'],
|
||||
skills: ['diagnostics'],
|
||||
hooks: {
|
||||
preToolUse: { enabled: true },
|
||||
},
|
||||
model: 'gpt-4.1-mini',
|
||||
maxTurns: 3,
|
||||
permissionMode: 'ask',
|
||||
background: true,
|
||||
isolation: 'workspace',
|
||||
getSystemPrompt: () => 'system prompt',
|
||||
});
|
||||
|
||||
expect(definition).toEqual({
|
||||
agentType: 'subagent',
|
||||
name: 'review:fix-validator',
|
||||
whenToUse: 'Use for focused fix validation after a failing test run.',
|
||||
source: 'built-in',
|
||||
tools: ['readFile', 'searchCode'],
|
||||
disallowedTools: ['deleteFile'],
|
||||
skills: ['diagnostics'],
|
||||
hooks: {
|
||||
preToolUse: { enabled: true },
|
||||
},
|
||||
model: 'gpt-4.1-mini',
|
||||
maxTurns: 3,
|
||||
permissionMode: 'ask',
|
||||
background: true,
|
||||
isolation: 'workspace',
|
||||
getSystemPrompt: definition.getSystemPrompt,
|
||||
});
|
||||
expect(isAgentDefinition(definition)).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes defaults for omitted runtime fields', () => {
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:intake',
|
||||
whenToUse: 'Use for initial task routing.',
|
||||
source: 'project',
|
||||
});
|
||||
|
||||
expect(definition.tools).toEqual([]);
|
||||
expect(definition.disallowedTools).toEqual([]);
|
||||
expect(definition.skills).toEqual([]);
|
||||
expect(definition.hooks).toEqual({});
|
||||
expect(definition.model).toBeUndefined();
|
||||
expect(definition.maxTurns).toBe(1);
|
||||
expect(definition.permissionMode).toBe('default');
|
||||
expect(definition.background).toBe(false);
|
||||
expect(definition.isolation).toBe('none');
|
||||
});
|
||||
|
||||
test('rejects missing required fields', () => {
|
||||
const result = agentDefinitionSchema.safeParse({
|
||||
agentType: 'subagent',
|
||||
source: 'built-in',
|
||||
model: 'gpt-4.1-mini',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('strips legacy business role fields', () => {
|
||||
const legacyKeys = ['plan' + 'ner', 'special' + 'ist', 'ju' + 'dge'];
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:modern',
|
||||
whenToUse: 'Use for modern runtime routing only.',
|
||||
source: 'user',
|
||||
model: 'gpt-4.1-mini',
|
||||
[legacyKeys[0]]: true,
|
||||
[legacyKeys[1]]: true,
|
||||
[legacyKeys[2]]: true,
|
||||
} as Record<string, unknown>);
|
||||
|
||||
for (const legacyKey of legacyKeys) {
|
||||
expect(legacyKey in definition).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
186
src/agent-kernel/definitions/__tests__/agent-registry.test.ts
Normal file
186
src/agent-kernel/definitions/__tests__/agent-registry.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
createAgentRegistry,
|
||||
loadAgentRegistry,
|
||||
loadProjectAgentDefinitions,
|
||||
parseAgentDefinitionMarkdown,
|
||||
} from '..';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function definition(source: 'built-in' | 'plugin' | 'user' | 'project', name: string) {
|
||||
return {
|
||||
agentType: 'reviewer',
|
||||
name,
|
||||
whenToUse: `Use ${name}`,
|
||||
source,
|
||||
model: `${name}-model`,
|
||||
};
|
||||
}
|
||||
|
||||
async function makeProjectRoot(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-registry-test-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
test('keeps all agents and resolves duplicates by built-in < plugin < user < project precedence', () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [definition('built-in', 'built-in-reviewer')],
|
||||
plugin: [definition('plugin', 'plugin-reviewer')],
|
||||
user: [definition('user', 'user-reviewer')],
|
||||
project: [definition('project', 'project-reviewer')],
|
||||
});
|
||||
|
||||
expect(registry.allAgents.map((agent) => agent.name)).toEqual([
|
||||
'built-in-reviewer',
|
||||
'plugin-reviewer',
|
||||
'user-reviewer',
|
||||
'project-reviewer',
|
||||
]);
|
||||
expect(registry.activeAgents).toHaveLength(1);
|
||||
expect(registry.getActiveAgent('reviewer')?.name).toBe('project-reviewer');
|
||||
expect(registry.getActiveAgent('reviewer')?.source).toBe('project');
|
||||
});
|
||||
|
||||
test('loads project definitions only from .gitea-assistant/agents/*.md', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const validDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
const ignoredDir = join(projectRoot, 'agents');
|
||||
await mkdir(validDir, { recursive: true });
|
||||
await mkdir(ignoredDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(validDir, 'reviewer.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Project Reviewer',
|
||||
'whenToUse: Use for project-specific review.',
|
||||
'tools: [readFile, searchCode]',
|
||||
'maxTurns: 2',
|
||||
'background: true',
|
||||
'---',
|
||||
'You are the project reviewer.',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(ignoredDir, 'ignored.md'),
|
||||
['---', 'agentType: ignored', 'name: Ignored', 'whenToUse: Never.', '---', 'Ignored.'].join(
|
||||
'\n'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const loaded = await loadProjectAgentDefinitions(projectRoot);
|
||||
|
||||
expect(loaded.failedFiles).toEqual([]);
|
||||
expect(loaded.definitions).toHaveLength(1);
|
||||
expect(loaded.definitions[0].agentType).toBe('reviewer');
|
||||
expect(loaded.definitions[0].source).toBe('project');
|
||||
expect(loaded.definitions[0].tools).toEqual(['readFile', 'searchCode']);
|
||||
expect(loaded.definitions[0].maxTurns).toBe(2);
|
||||
expect(loaded.definitions[0].background).toBe(true);
|
||||
expect(loaded.definitions[0].getSystemPrompt?.()).toBe('You are the project reviewer.');
|
||||
});
|
||||
|
||||
test('keeps optional model definitions valid through markdown loading', async () => {
|
||||
const parsed = parseAgentDefinitionMarkdown(
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: No Model Reviewer',
|
||||
'whenToUse: Use without model.',
|
||||
'---',
|
||||
'Prompt body.',
|
||||
].join('\n'),
|
||||
{ source: 'project', filePath: '/tmp/reviewer.md' }
|
||||
);
|
||||
|
||||
expect('code' in parsed).toBe(false);
|
||||
if ('code' in parsed) {
|
||||
throw new Error('expected valid definition');
|
||||
}
|
||||
expect(parsed.model).toBeUndefined();
|
||||
expect(parsed.getSystemPrompt?.()).toBe('Prompt body.');
|
||||
});
|
||||
|
||||
test('returns structured load errors for malformed frontmatter and empty body', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const agentsDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(agentsDir, 'bad-frontmatter.md'),
|
||||
'---\nagentType [reviewer]\n---\nPrompt.',
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'empty-body.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Empty Body',
|
||||
'whenToUse: Use never.',
|
||||
'---',
|
||||
' ',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'invalid-definition.md'),
|
||||
['---', 'agentType: reviewer', 'name: Missing Use', '---', 'Prompt.'].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const loaded = await loadProjectAgentDefinitions(projectRoot);
|
||||
|
||||
expect(loaded.definitions).toEqual([]);
|
||||
expect(loaded.failedFiles.map((error) => error.code).sort()).toEqual([
|
||||
'empty_body',
|
||||
'invalid_definition',
|
||||
'malformed_frontmatter',
|
||||
]);
|
||||
expect(loaded.failedFiles.every((error) => error.source === 'project')).toBe(true);
|
||||
expect(
|
||||
loaded.failedFiles.find((error) => error.code === 'invalid_definition')?.issues?.length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('loadAgentRegistry combines built-in, plugin, user, and loaded project definitions', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const agentsDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(agentsDir, 'reviewer.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Loaded Project',
|
||||
'whenToUse: Use loaded project.',
|
||||
'---',
|
||||
'Project prompt.',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = await loadAgentRegistry({
|
||||
projectRoot,
|
||||
builtIn: [definition('built-in', 'Built In')],
|
||||
plugin: [definition('plugin', 'Plugin')],
|
||||
user: [definition('user', 'User')],
|
||||
});
|
||||
|
||||
expect(registry.allAgents).toHaveLength(4);
|
||||
expect(registry.failedFiles).toEqual([]);
|
||||
expect(registry.getActiveAgent('reviewer')?.name).toBe('Loaded Project');
|
||||
expect(registry.getActiveAgent('reviewer')?.getSystemPrompt?.()).toBe('Project prompt.');
|
||||
});
|
||||
});
|
||||
84
src/agent-kernel/definitions/agent-definition.ts
Normal file
84
src/agent-kernel/definitions/agent-definition.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type AgentDefinitionSource = 'built-in' | 'project' | 'user' | 'plugin';
|
||||
|
||||
export const AGENT_DEFINITION_SOURCES = [
|
||||
'built-in',
|
||||
'project',
|
||||
'user',
|
||||
'plugin',
|
||||
] as const satisfies readonly AgentDefinitionSource[];
|
||||
|
||||
export type AgentPermissionMode = 'default' | 'ask' | 'deny';
|
||||
|
||||
export const AGENT_PERMISSION_MODES = [
|
||||
'default',
|
||||
'ask',
|
||||
'deny',
|
||||
] as const satisfies readonly AgentPermissionMode[];
|
||||
|
||||
export type AgentIsolation = 'none' | 'workspace' | 'process';
|
||||
|
||||
export const AGENT_ISOLATIONS = [
|
||||
'none',
|
||||
'workspace',
|
||||
'process',
|
||||
] as const satisfies readonly AgentIsolation[];
|
||||
|
||||
export interface AgentDefinitionHooks {
|
||||
sessionStart?: unknown;
|
||||
subagentStart?: unknown;
|
||||
permissionRequest?: unknown;
|
||||
preToolUse?: unknown;
|
||||
postToolUse?: unknown;
|
||||
postToolUseFailure?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const agentDefinitionHooksSchema: z.ZodType<AgentDefinitionHooks> = z
|
||||
.object({
|
||||
sessionStart: z.unknown().optional(),
|
||||
subagentStart: z.unknown().optional(),
|
||||
permissionRequest: z.unknown().optional(),
|
||||
preToolUse: z.unknown().optional(),
|
||||
postToolUse: z.unknown().optional(),
|
||||
postToolUseFailure: z.unknown().optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const agentDefinitionSchema = z
|
||||
.object({
|
||||
agentType: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
whenToUse: z.string().min(1),
|
||||
source: z.enum(AGENT_DEFINITION_SOURCES),
|
||||
tools: z.array(z.string()).default([]),
|
||||
disallowedTools: z.array(z.string()).default([]),
|
||||
skills: z.array(z.string()).default([]),
|
||||
hooks: agentDefinitionHooksSchema.default({}),
|
||||
model: z.string().min(1).optional(),
|
||||
maxTurns: z.number().int().positive().default(1),
|
||||
permissionMode: z.enum(AGENT_PERMISSION_MODES).default('default'),
|
||||
background: z.boolean().default(false),
|
||||
isolation: z.enum(AGENT_ISOLATIONS).default('none'),
|
||||
getSystemPrompt: z
|
||||
.custom<() => string>((value) => typeof value === 'function', {
|
||||
message: 'getSystemPrompt must be a function',
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export type AgentDefinition = z.infer<typeof agentDefinitionSchema>;
|
||||
|
||||
export function normalizeAgentDefinition(definition: unknown): AgentDefinition {
|
||||
return agentDefinitionSchema.parse(definition);
|
||||
}
|
||||
|
||||
export function isAgentDefinition(definition: unknown): definition is AgentDefinition {
|
||||
return agentDefinitionSchema.safeParse(definition).success;
|
||||
}
|
||||
|
||||
export function parseAgentDefinition(definition: unknown): AgentDefinition {
|
||||
return normalizeAgentDefinition(definition);
|
||||
}
|
||||
258
src/agent-kernel/definitions/agent-loader.ts
Normal file
258
src/agent-kernel/definitions/agent-loader.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ZodError } from 'zod';
|
||||
import type { AgentDefinition, AgentDefinitionSource } from './agent-definition';
|
||||
import { normalizeAgentDefinition } from './agent-definition';
|
||||
|
||||
export const PROJECT_AGENT_DEFINITIONS_DIR = '.gitea-assistant/agents';
|
||||
|
||||
export type AgentDefinitionLoadErrorCode =
|
||||
| 'missing_frontmatter'
|
||||
| 'malformed_frontmatter'
|
||||
| 'empty_body'
|
||||
| 'invalid_definition'
|
||||
| 'read_error';
|
||||
|
||||
export interface AgentDefinitionLoadError {
|
||||
source: AgentDefinitionSource;
|
||||
filePath: string;
|
||||
code: AgentDefinitionLoadErrorCode;
|
||||
message: string;
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
export interface AgentDefinitionLoadResult {
|
||||
definitions: AgentDefinition[];
|
||||
failedFiles: AgentDefinitionLoadError[];
|
||||
}
|
||||
|
||||
interface MarkdownParseOptions {
|
||||
source: AgentDefinitionSource;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
type FrontmatterRecord = Record<string, string | number | boolean | string[]>;
|
||||
|
||||
export function parseAgentDefinitionMarkdown(
|
||||
content: string,
|
||||
options: MarkdownParseOptions
|
||||
): AgentDefinition | AgentDefinitionLoadError {
|
||||
const extracted = extractFrontmatter(content, options);
|
||||
if (isLoadError(extracted)) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
const systemPrompt = extracted.body.trim();
|
||||
if (!systemPrompt) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'empty_body',
|
||||
message: 'Agent definition markdown body must contain the system prompt.',
|
||||
};
|
||||
}
|
||||
|
||||
const frontmatter = parseFrontmatter(extracted.frontmatter, options);
|
||||
if (isLoadError(frontmatter)) {
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeAgentDefinition({
|
||||
...frontmatter,
|
||||
source: options.source,
|
||||
getSystemPrompt: () => systemPrompt,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'invalid_definition',
|
||||
message: 'Agent definition frontmatter does not match AgentDefinition.',
|
||||
issues:
|
||||
error instanceof ZodError ? error.issues.map((issue) => issue.message) : [String(error)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadProjectAgentDefinitions(
|
||||
projectRoot: string
|
||||
): Promise<AgentDefinitionLoadResult> {
|
||||
const definitionsDir = join(projectRoot, PROJECT_AGENT_DEFINITIONS_DIR);
|
||||
const result: AgentDefinitionLoadResult = { definitions: [], failedFiles: [] };
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(definitionsDir);
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
definitions: [],
|
||||
failedFiles: [
|
||||
{
|
||||
source: 'project',
|
||||
filePath: definitionsDir,
|
||||
code: 'read_error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
for (const entry of entries.sort()) {
|
||||
if (!entry.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(definitionsDir, entry);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = parseAgentDefinitionMarkdown(content, { source: 'project', filePath });
|
||||
if (isLoadError(parsed)) {
|
||||
result.failedFiles.push(parsed);
|
||||
} else {
|
||||
result.definitions.push(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
result.failedFiles.push({
|
||||
source: 'project',
|
||||
filePath,
|
||||
code: 'read_error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractFrontmatter(
|
||||
content: string,
|
||||
options: MarkdownParseOptions
|
||||
): { frontmatter: string; body: string } | AgentDefinitionLoadError {
|
||||
const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
||||
if (!normalized.startsWith('---\n')) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'missing_frontmatter',
|
||||
message: 'Agent definition markdown must start with --- frontmatter.',
|
||||
};
|
||||
}
|
||||
|
||||
const closingMarker = '\n---\n';
|
||||
const closingIndex = normalized.indexOf(closingMarker, 4);
|
||||
if (closingIndex === -1) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'malformed_frontmatter',
|
||||
message: 'Agent definition markdown frontmatter must close with --- on its own line.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter: normalized.slice(4, closingIndex),
|
||||
body: normalized.slice(closingIndex + closingMarker.length),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFrontmatter(
|
||||
frontmatter: string,
|
||||
options: MarkdownParseOptions
|
||||
): FrontmatterRecord | AgentDefinitionLoadError {
|
||||
const parsed: FrontmatterRecord = {};
|
||||
const lines = frontmatter.split('\n');
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = /^(\w+):\s*(.*)$/.exec(line);
|
||||
if (!match) {
|
||||
return malformedFrontmatter(options, `Invalid frontmatter line: ${line}`);
|
||||
}
|
||||
|
||||
const key = match[1];
|
||||
const rawValue = match[2];
|
||||
if (rawValue === '') {
|
||||
const values: string[] = [];
|
||||
while (index + 1 < lines.length && /^\s+-\s+/.test(lines[index + 1])) {
|
||||
index += 1;
|
||||
values.push(unquote(lines[index].replace(/^\s+-\s+/, '').trim()));
|
||||
}
|
||||
parsed[key] = values;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = parseFrontmatterValue(rawValue.trim(), options);
|
||||
if (isLoadError(value)) {
|
||||
return value;
|
||||
}
|
||||
parsed[key] = value;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseFrontmatterValue(
|
||||
value: string,
|
||||
options: MarkdownParseOptions
|
||||
): string | number | boolean | string[] | AgentDefinitionLoadError {
|
||||
if (value.startsWith('[')) {
|
||||
if (!value.endsWith(']')) {
|
||||
return malformedFrontmatter(options, `Invalid inline array: ${value}`);
|
||||
}
|
||||
|
||||
const inner = value.slice(1, -1).trim();
|
||||
return inner ? inner.split(',').map((item) => unquote(item.trim())) : [];
|
||||
}
|
||||
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
return unquote(value);
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function malformedFrontmatter(
|
||||
options: MarkdownParseOptions,
|
||||
message: string
|
||||
): AgentDefinitionLoadError {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'malformed_frontmatter',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function isLoadError(value: unknown): value is AgentDefinitionLoadError {
|
||||
return typeof value === 'object' && value !== null && 'code' in value;
|
||||
}
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
62
src/agent-kernel/definitions/agent-registry.ts
Normal file
62
src/agent-kernel/definitions/agent-registry.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { AgentDefinition } from './agent-definition';
|
||||
import { normalizeAgentDefinition } from './agent-definition';
|
||||
import type { AgentDefinitionLoadError } from './agent-loader';
|
||||
import { loadProjectAgentDefinitions } from './agent-loader';
|
||||
|
||||
export interface AgentRegistry {
|
||||
allAgents: AgentDefinition[];
|
||||
activeAgents: AgentDefinition[];
|
||||
failedFiles: AgentDefinitionLoadError[];
|
||||
getActiveAgent(agentType: string): AgentDefinition | undefined;
|
||||
}
|
||||
|
||||
export interface AgentRegistryInput {
|
||||
builtIn?: unknown[];
|
||||
plugin?: unknown[];
|
||||
user?: unknown[];
|
||||
project?: unknown[];
|
||||
failedFiles?: AgentDefinitionLoadError[];
|
||||
}
|
||||
|
||||
export interface LoadAgentRegistryOptions extends AgentRegistryInput {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
export function createAgentRegistry(input: AgentRegistryInput = {}): AgentRegistry {
|
||||
const allAgents = [
|
||||
...(input.builtIn ?? []),
|
||||
...(input.plugin ?? []),
|
||||
...(input.user ?? []),
|
||||
...(input.project ?? []),
|
||||
].map((definition) => normalizeAgentDefinition(definition));
|
||||
const activeByType = new Map<string, AgentDefinition>();
|
||||
|
||||
for (const agent of allAgents) {
|
||||
activeByType.set(agent.agentType, agent);
|
||||
}
|
||||
|
||||
return {
|
||||
allAgents,
|
||||
activeAgents: Array.from(activeByType.values()),
|
||||
failedFiles: input.failedFiles ?? [],
|
||||
getActiveAgent(agentType: string): AgentDefinition | undefined {
|
||||
return activeByType.get(agentType);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadAgentRegistry(
|
||||
options: LoadAgentRegistryOptions = {}
|
||||
): Promise<AgentRegistry> {
|
||||
const projectLoadResult = options.projectRoot
|
||||
? await loadProjectAgentDefinitions(options.projectRoot)
|
||||
: { definitions: [], failedFiles: [] };
|
||||
|
||||
return createAgentRegistry({
|
||||
builtIn: options.builtIn,
|
||||
plugin: options.plugin,
|
||||
user: options.user,
|
||||
project: [...(options.project ?? []), ...projectLoadResult.definitions],
|
||||
failedFiles: [...(options.failedFiles ?? []), ...projectLoadResult.failedFiles],
|
||||
});
|
||||
}
|
||||
28
src/agent-kernel/definitions/index.ts
Normal file
28
src/agent-kernel/definitions/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
AGENT_DEFINITION_SOURCES,
|
||||
AGENT_ISOLATIONS,
|
||||
AGENT_PERMISSION_MODES,
|
||||
agentDefinitionSchema,
|
||||
isAgentDefinition,
|
||||
normalizeAgentDefinition,
|
||||
parseAgentDefinition,
|
||||
} from './agent-definition';
|
||||
export {
|
||||
PROJECT_AGENT_DEFINITIONS_DIR,
|
||||
loadProjectAgentDefinitions,
|
||||
parseAgentDefinitionMarkdown,
|
||||
} from './agent-loader';
|
||||
export { createAgentRegistry, loadAgentRegistry } from './agent-registry';
|
||||
export type {
|
||||
AgentDefinition,
|
||||
AgentDefinitionHooks,
|
||||
AgentDefinitionSource,
|
||||
AgentIsolation,
|
||||
AgentPermissionMode,
|
||||
} from './agent-definition';
|
||||
export type {
|
||||
AgentDefinitionLoadError,
|
||||
AgentDefinitionLoadErrorCode,
|
||||
AgentDefinitionLoadResult,
|
||||
} from './agent-loader';
|
||||
export type { AgentRegistry, AgentRegistryInput, LoadAgentRegistryOptions } from './agent-registry';
|
||||
@@ -0,0 +1,620 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import { ScriptedMockLLM, scriptedTurn } from '../../../llm/e2e-mock';
|
||||
import { createAgentRegistry } from '../../definitions';
|
||||
import { agentSessionRepository } from '../../session';
|
||||
import { SubagentRunner } from '../../subagents/subagent-runner';
|
||||
import { createSpawnSubagentTool } from '../../tools';
|
||||
import { MainAgentRunner } from '../main-agent-runner';
|
||||
import type { MainAgentTool } from '../types';
|
||||
|
||||
function baseAgentDefinition() {
|
||||
return {
|
||||
agentType: 'general-purpose',
|
||||
name: 'General Purpose',
|
||||
whenToUse: 'Use for delegated analysis.',
|
||||
source: 'built-in' as const,
|
||||
tools: ['search_code', 'read_file'],
|
||||
disallowedTools: [],
|
||||
skills: [],
|
||||
hooks: {},
|
||||
maxTurns: 6,
|
||||
permissionMode: 'default' as const,
|
||||
background: false,
|
||||
isolation: 'none' as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Scripted Mock LLM dynamic agent flows', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `dynamic-agent-scripted-mock-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
function makeTools(record: { submissions: unknown[] }) {
|
||||
const readFileTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'read_file',
|
||||
description: 'Read deterministic test fixture.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
execute: (args) => ({ path: (args as { path: string }).path, content: 'const value = 1;' }),
|
||||
};
|
||||
|
||||
const searchCodeTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'search_code',
|
||||
description: 'Search deterministic test fixture.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string' } },
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (args) => ({
|
||||
matches: [{ path: 'src/app.ts', line: 1, query: (args as { query: string }).query }],
|
||||
}),
|
||||
};
|
||||
|
||||
const submitReviewFindingsTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'submit_review_findings',
|
||||
description: 'Capture deterministic submission payload.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summaryMarkdown: { type: 'string' },
|
||||
findings: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
required: ['summaryMarkdown', 'findings'],
|
||||
},
|
||||
},
|
||||
execute: (args) => {
|
||||
record.submissions.push(structuredClone(args));
|
||||
return { accepted: true };
|
||||
},
|
||||
};
|
||||
|
||||
return { readFileTool, searchCodeTool, submitReviewFindingsTool };
|
||||
}
|
||||
|
||||
test('deterministically scripts main->spawn_subagent->submit_review_findings flow', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'main-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Inspect changed file',
|
||||
prompt: 'Check correctness risks.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-search-1', name: 'search_code', arguments: '{"query":"value"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({ content: 'Subagent summary: potential correctness issue found.' }),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({ summaryMarkdown: 'Found one issue.', findings: [] }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Review finalized.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
agentType: 'review-main-agent',
|
||||
userMessage: 'Start dynamic review.',
|
||||
maxTurns: 8,
|
||||
maxToolCalls: 8,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('Review finalized.');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'read_file',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(scriptedModel.toolCallSequence('subagent')).toEqual(['search_code', 'read_file']);
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'read_file',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].status).toBe('completed');
|
||||
expect(
|
||||
tree?.invocations[0].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['search_code', 'read_file']);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Found one issue.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('supports deterministic no-subagent completion flow', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'main-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({ summaryMarkdown: 'No issues found.', findings: [] }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Done without subagent.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Review directly.',
|
||||
maxTurns: 6,
|
||||
maxToolCalls: 6,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual(['read_file', 'submit_review_findings']);
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.status).toBe('completed');
|
||||
expect(tree?.finalResult).toEqual({
|
||||
status: 'completed',
|
||||
turns: 3,
|
||||
toolCalls: 2,
|
||||
finalText: 'Done without subagent.',
|
||||
});
|
||||
expect(tree?.invocations).toHaveLength(0);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'read_file',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'No issues found.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('supports multiple subagent spawns in one main run with distinct child sessions', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Run child one',
|
||||
prompt: 'Inspect alpha path.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-search-1', name: 'search_code', arguments: '{"query":"alpha"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'subagent', turn: scriptedTurn({ content: 'Child one summary.' }) },
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-2',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Run child two',
|
||||
prompt: 'Inspect beta path.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'subagent', turn: scriptedTurn({ content: 'Child two summary.' }) },
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Two children completed.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main completed multi-child flow.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
agentType: 'review-main-agent',
|
||||
userMessage: 'Run two delegated checks.',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(scriptedModel.toolCallSequence('subagent')).toEqual(['search_code', 'read_file']);
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(2);
|
||||
expect(tree?.invocations[0].status).toBe('completed');
|
||||
expect(tree?.invocations[1].status).toBe('completed');
|
||||
expect(tree?.invocations[0].childSessionId).not.toBe(tree?.invocations[1].childSessionId);
|
||||
expect(
|
||||
tree?.invocations[0].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['search_code']);
|
||||
expect(
|
||||
tree?.invocations[1].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['read_file']);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Two children completed.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('propagates structured subagent failure and still allows main completion', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Investigate quickly',
|
||||
prompt: 'Run child checks.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Subagent failed; no findings.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main handled child failure.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Run subagent and continue on failure.',
|
||||
maxTurns: 6,
|
||||
maxToolCalls: 6,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
const secondMainRequest = scriptedModel.calls.filter((call) => call.session === 'main')[1];
|
||||
const lastMessage = secondMainRequest.request.messages.at(-1);
|
||||
expect(lastMessage?.role).toBe('tool');
|
||||
expect(lastMessage?.content).toContain('No scripted mock turn queued for session');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].status).toBe('failed');
|
||||
expect(tree?.invocations[0].result).toMatchObject({
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'Error',
|
||||
message: "No scripted mock turn queued for session 'subagent'",
|
||||
},
|
||||
});
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Subagent failed; no findings.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('filters disallowed child tools and persists deterministic failed tool call path', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Restricted run',
|
||||
prompt: 'Try forbidden search first.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-denied-1', name: 'search_code', arguments: '{"query":"restricted"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({ content: 'Child observed denied tool and completed.' }),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Permission filtered as expected.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main completed restricted flow.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({
|
||||
builtIn: [
|
||||
{
|
||||
...baseAgentDefinition(),
|
||||
tools: ['search_code', 'read_file'],
|
||||
disallowedTools: ['search_code'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Run with restricted subagent tools.',
|
||||
maxTurns: 8,
|
||||
maxToolCalls: 8,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
const secondSubagentRequest = scriptedModel.calls.filter(
|
||||
(call) => call.session === 'subagent'
|
||||
)[1];
|
||||
expect(secondSubagentRequest.request.messages.at(-1)?.role).toBe('tool');
|
||||
expect(secondSubagentRequest.request.messages.at(-1)?.content).toContain('ToolNotFoundError');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['search_code', 'read_file'],
|
||||
disallowedToolNames: ['search_code'],
|
||||
deniedToolNames: ['search_code'],
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'search_code',
|
||||
status: 'failed',
|
||||
arguments: { query: 'restricted' },
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'search_code' is not registered",
|
||||
},
|
||||
});
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Permission filtered as expected.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
});
|
||||
358
src/agent-kernel/loop/__tests__/main-agent-runner.test.ts
Normal file
358
src/agent-kernel/loop/__tests__/main-agent-runner.test.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../../llm/types';
|
||||
import { agentSessionRepository } from '../../session/session-repository';
|
||||
import { MainAgentRunner } from '../main-agent-runner';
|
||||
import type { MainAgentModelClient, MainAgentTool } from '../types';
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient implements MainAgentModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
const lookupTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'lookup',
|
||||
description: 'Look up a deterministic value.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (argumentsValue) => ({ echoed: (argumentsValue as { query: string }).query }),
|
||||
};
|
||||
|
||||
describe('MainAgentRunner', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `main-agent-runner-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('runs tool call, appends tool result, then returns final answer', async () => {
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
content: null,
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'lookup', arguments: '{"query":"alpha"}' }],
|
||||
}),
|
||||
response({ content: 'final answer' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
agentType: 'main',
|
||||
model: 'mock-model',
|
||||
userMessage: 'answer with a tool',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 4,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('final answer');
|
||||
expect(result.turns).toBe(2);
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-1',
|
||||
content: JSON.stringify({ ok: true, value: { echoed: 'alpha' } }),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(tree?.toolCalls[0].result).toEqual({ echoed: 'alpha' });
|
||||
expect(tree?.finalResult).toEqual({
|
||||
status: 'completed',
|
||||
turns: 2,
|
||||
toolCalls: 1,
|
||||
finalText: 'final answer',
|
||||
});
|
||||
});
|
||||
|
||||
test('completes on final assistant answer with no tool calls', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([response({ content: 'plain final' })]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'answer directly',
|
||||
maxTurns: 2,
|
||||
maxToolCalls: 2,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCalls).toBe(0);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('stops runaway model at max turns', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'lookup', arguments: '{"query":"one"}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-2', name: 'lookup', arguments: '{"query":"two"}' }],
|
||||
}),
|
||||
]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'keep calling tools',
|
||||
maxTurns: 2,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_turns_reached');
|
||||
expect(result.turns).toBe(2);
|
||||
expect(result.toolCalls).toBe(2);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('stops before exceeding max tool calls', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{ id: 'call-1', name: 'lookup', arguments: '{"query":"one"}' },
|
||||
{ id: 'call-2', name: 'lookup', arguments: '{"query":"two"}' },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'too many tools',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 1,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_tool_calls_reached');
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.toolCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('records tool execution errors as structured tool results and continues', async () => {
|
||||
const failingTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'fail_lookup',
|
||||
description: 'Always fails.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => {
|
||||
throw new Error('lookup failed');
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'fail_lookup', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'recovered' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [failingTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'recover from tool error',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 2,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls[0].status).toBe('failed');
|
||||
expect(tree?.toolCalls[0].error).toEqual({ name: 'Error', message: 'lookup failed' });
|
||||
expect(modelClient.requests[1].messages.at(-1)?.content).toBe(
|
||||
JSON.stringify({ ok: false, error: { name: 'Error', message: 'lookup failed' } })
|
||||
);
|
||||
});
|
||||
|
||||
test('stops on maxEmptyResponses', async () => {
|
||||
const modelClient = new FakeModelClient([
|
||||
response({ content: '' }),
|
||||
response({ content: '' }),
|
||||
response({ content: 'should not reach' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test empty responses',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxEmptyResponses: 2,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_empty_responses');
|
||||
expect(result.turns).toBe(2);
|
||||
});
|
||||
|
||||
test('stops on maxConsecutiveToolFailures', async () => {
|
||||
const failTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'fail_tool',
|
||||
description: 'Always fails.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c1', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c2', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c3', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'should not reach' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [failTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test tool failures',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxConsecutiveToolFailures: 3,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_consecutive_tool_failures');
|
||||
});
|
||||
|
||||
test('refuses subagent spawn beyond maxSubagents and allows summary', async () => {
|
||||
const subagentTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'spawn_subagent',
|
||||
description: 'Spawn a subagent.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => ({ status: 'completed' }),
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c1', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c2', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c3', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'review complete with 2 subagents' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [subagentTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test subagent limit',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxSubagents: 2,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('review complete with 2 subagents');
|
||||
});
|
||||
});
|
||||
2
src/agent-kernel/loop/index.ts
Normal file
2
src/agent-kernel/loop/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './main-agent-runner';
|
||||
export * from './types';
|
||||
346
src/agent-kernel/loop/main-agent-runner.ts
Normal file
346
src/agent-kernel/loop/main-agent-runner.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import type { LLMMessage, LLMToolCall } from '../../llm/types';
|
||||
import { agentSessionRepository } from '../session/session-repository';
|
||||
import type {
|
||||
MainAgentRunInput,
|
||||
MainAgentRunResult,
|
||||
MainAgentRunnerOptions,
|
||||
MainAgentTerminalStatus,
|
||||
MainAgentTool,
|
||||
MainAgentTranscriptRepository,
|
||||
ToolExecutionResult,
|
||||
} from './types';
|
||||
|
||||
function parseToolArguments(toolCall: LLMToolCall): ToolExecutionResult {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(toolCall.arguments || '{}') };
|
||||
} catch (error) {
|
||||
const parsedError = error instanceof Error ? error : new Error(String(error));
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
name: parsedError.name,
|
||||
message: parsedError.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyToolResult(result: ToolExecutionResult): string {
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): ToolExecutionResult['error'] {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Error',
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
export class MainAgentRunner {
|
||||
private readonly modelClient: MainAgentRunnerOptions['modelClient'];
|
||||
private readonly transcriptRepository: MainAgentTranscriptRepository;
|
||||
private readonly toolsByName: Map<string, MainAgentTool>;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: MainAgentRunnerOptions) {
|
||||
this.modelClient = options.modelClient;
|
||||
this.transcriptRepository = options.transcriptRepository;
|
||||
this.toolsByName = new Map((options.tools ?? []).map((tool) => [tool.definition.name, tool]));
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async run(input: MainAgentRunInput): Promise<MainAgentRunResult> {
|
||||
const startedAt = this.now();
|
||||
const sessionId =
|
||||
input.sessionId ??
|
||||
this.transcriptRepository.createSession({
|
||||
agentType: input.session?.agentType ?? input.agentType ?? 'main',
|
||||
model: input.session?.model ?? input.model,
|
||||
parentSessionId: input.session?.parentSessionId,
|
||||
parentInvocationId: input.session?.parentInvocationId,
|
||||
status: input.session?.status,
|
||||
metadata: input.session?.metadata,
|
||||
}).id;
|
||||
|
||||
const messages: LLMMessage[] = [];
|
||||
if (input.systemPrompt) {
|
||||
messages.push({ role: 'system', content: input.systemPrompt });
|
||||
}
|
||||
|
||||
const userMessage: LLMMessage = { role: 'user', content: input.userMessage };
|
||||
messages.push(userMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: { text: input.userMessage },
|
||||
});
|
||||
|
||||
let turns = 0;
|
||||
let toolCalls = 0;
|
||||
let subagentCount = 0;
|
||||
let emptyResponseCount = 0;
|
||||
let consecutiveToolFailures = 0;
|
||||
const maxSubagents = input.maxSubagents ?? Number.POSITIVE_INFINITY;
|
||||
const maxEmptyResponses = input.maxEmptyResponses ?? 3;
|
||||
const maxConsecutiveToolFailures = input.maxConsecutiveToolFailures ?? 5;
|
||||
|
||||
while (true) {
|
||||
const budgetStatus = this.getBudgetStatus(
|
||||
input,
|
||||
startedAt,
|
||||
turns,
|
||||
emptyResponseCount,
|
||||
consecutiveToolFailures,
|
||||
maxEmptyResponses,
|
||||
maxConsecutiveToolFailures
|
||||
);
|
||||
if (budgetStatus) {
|
||||
return this.finish(sessionId, budgetStatus, turns, toolCalls, messages);
|
||||
}
|
||||
|
||||
const response = await this.modelClient.chat({
|
||||
messages,
|
||||
model: input.model,
|
||||
temperature: input.temperature,
|
||||
maxTokens: input.maxTokens,
|
||||
responseFormat: input.responseFormat,
|
||||
providerOptions: input.providerOptions,
|
||||
tools: [...this.toolsByName.values()].map((tool) => tool.definition),
|
||||
});
|
||||
|
||||
turns += 1;
|
||||
|
||||
if (!response.content?.trim() && response.toolCalls.length === 0) {
|
||||
emptyResponseCount += 1;
|
||||
messages.push({ role: 'assistant', content: '' });
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: { text: '' },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
emptyResponseCount = 0;
|
||||
|
||||
const assistantMessage: LLMMessage = {
|
||||
role: 'assistant',
|
||||
content: response.content ?? '',
|
||||
toolCalls: response.toolCalls,
|
||||
};
|
||||
messages.push(assistantMessage);
|
||||
|
||||
const assistantRecord = this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: {
|
||||
text: response.content ?? '',
|
||||
toolCalls: response.toolCalls,
|
||||
finishReason: response.finishReason,
|
||||
usage: response.usage,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.toolCalls.length === 0) {
|
||||
return this.finish(
|
||||
sessionId,
|
||||
'completed',
|
||||
turns,
|
||||
toolCalls,
|
||||
messages,
|
||||
response.content ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
for (const toolCall of response.toolCalls) {
|
||||
if (this.isTimedOut(input, startedAt)) {
|
||||
return this.finish(sessionId, 'timeout_reached', turns, toolCalls, messages);
|
||||
}
|
||||
if (toolCalls >= input.maxToolCalls) {
|
||||
return this.finish(sessionId, 'max_tool_calls_reached', turns, toolCalls, messages);
|
||||
}
|
||||
|
||||
if (toolCall.name === 'spawn_subagent') {
|
||||
if (subagentCount >= maxSubagents) {
|
||||
const refusalMessage: LLMMessage = {
|
||||
role: 'tool',
|
||||
toolCallId: toolCall.id,
|
||||
content: JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'BudgetExceeded',
|
||||
message: `Subagent limit reached (${maxSubagents}). Please summarize your findings instead.`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
messages.push(refusalMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'tool',
|
||||
content: {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: {
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'BudgetExceeded',
|
||||
message: `Subagent limit reached (${maxSubagents})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
subagentCount += 1;
|
||||
}
|
||||
|
||||
const result = await this.executeTool(toolCall, sessionId, input.model, turns);
|
||||
toolCalls += 1;
|
||||
if (!result.ok) {
|
||||
consecutiveToolFailures += 1;
|
||||
} else {
|
||||
consecutiveToolFailures = 0;
|
||||
}
|
||||
|
||||
this.transcriptRepository.appendToolCall({
|
||||
sessionId,
|
||||
messageId: assistantRecord.id,
|
||||
toolName: toolCall.name,
|
||||
status: result.ok ? 'completed' : 'failed',
|
||||
arguments: parseToolArguments(toolCall).value ?? {},
|
||||
result: result.ok ? result.value : undefined,
|
||||
error: result.ok ? undefined : result.error,
|
||||
});
|
||||
|
||||
const toolMessage: LLMMessage = {
|
||||
role: 'tool',
|
||||
toolCallId: toolCall.id,
|
||||
content: stringifyToolResult(result),
|
||||
};
|
||||
messages.push(toolMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'tool',
|
||||
content: {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.ok && consecutiveToolFailures >= maxConsecutiveToolFailures) {
|
||||
return this.finish(
|
||||
sessionId,
|
||||
'max_consecutive_tool_failures',
|
||||
turns,
|
||||
toolCalls,
|
||||
messages
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getBudgetStatus(
|
||||
input: MainAgentRunInput,
|
||||
startedAt: number,
|
||||
turns: number,
|
||||
emptyResponseCount: number,
|
||||
consecutiveToolFailures: number,
|
||||
maxEmptyResponses: number,
|
||||
maxConsecutiveToolFailures: number
|
||||
): MainAgentTerminalStatus | undefined {
|
||||
if (this.isTimedOut(input, startedAt)) return 'timeout_reached';
|
||||
if (turns >= input.maxTurns) return 'max_turns_reached';
|
||||
if (emptyResponseCount >= maxEmptyResponses) return 'max_empty_responses';
|
||||
if (consecutiveToolFailures >= maxConsecutiveToolFailures)
|
||||
return 'max_consecutive_tool_failures';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isTimedOut(input: MainAgentRunInput, startedAt: number): boolean {
|
||||
return this.now() - startedAt >= input.timeoutMs;
|
||||
}
|
||||
|
||||
private async executeTool(
|
||||
toolCall: LLMToolCall,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
turn: number
|
||||
): Promise<ToolExecutionResult> {
|
||||
const parsedArguments = parseToolArguments(toolCall);
|
||||
if (!parsedArguments.ok) return parsedArguments;
|
||||
|
||||
const tool = this.toolsByName.get(toolCall.name);
|
||||
if (!tool) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: `Tool '${toolCall.name}' is not registered`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await tool.execute(parsedArguments.value, {
|
||||
sessionId,
|
||||
model,
|
||||
toolCall,
|
||||
turn,
|
||||
});
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: normalizeError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private finish(
|
||||
sessionId: string,
|
||||
status: MainAgentTerminalStatus,
|
||||
turns: number,
|
||||
toolCalls: number,
|
||||
messages: LLMMessage[],
|
||||
finalText?: string
|
||||
): MainAgentRunResult {
|
||||
this.transcriptRepository.completeSession({
|
||||
sessionId,
|
||||
status: status === 'completed' ? 'completed' : 'failed',
|
||||
finalResult: {
|
||||
status,
|
||||
turns,
|
||||
toolCalls,
|
||||
finalText,
|
||||
},
|
||||
error: status === 'completed' ? undefined : { status },
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
sessionId,
|
||||
turns,
|
||||
toolCalls,
|
||||
finalText,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const mainAgentRunner = new MainAgentRunner({
|
||||
modelClient: {
|
||||
chat: () => {
|
||||
throw new Error('MainAgentRunner requires an injected model client');
|
||||
},
|
||||
},
|
||||
transcriptRepository: agentSessionRepository,
|
||||
});
|
||||
118
src/agent-kernel/loop/types.ts
Normal file
118
src/agent-kernel/loop/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
LLMChatRequest,
|
||||
LLMChatResponse,
|
||||
LLMMessage,
|
||||
LLMToolCall,
|
||||
LLMToolDefinition,
|
||||
} from '../../llm/types';
|
||||
import type {
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentToolCallRecord,
|
||||
CreateAgentSessionInput,
|
||||
} from '../session/types';
|
||||
|
||||
export type MainAgentTerminalStatus =
|
||||
| 'completed'
|
||||
| 'max_turns_reached'
|
||||
| 'max_tool_calls_reached'
|
||||
| 'max_subagents_reached'
|
||||
| 'timeout_reached'
|
||||
| 'max_empty_responses'
|
||||
| 'max_consecutive_tool_failures';
|
||||
|
||||
export interface MainAgentModelClient {
|
||||
chat(request: LLMChatRequest): Promise<LLMChatResponse>;
|
||||
}
|
||||
|
||||
export interface MainAgentToolContext {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
toolCall: LLMToolCall;
|
||||
turn: number;
|
||||
}
|
||||
|
||||
export type ToolPermissionScope =
|
||||
| 'read'
|
||||
| 'write'
|
||||
| 'command'
|
||||
| 'network'
|
||||
| 'git_write'
|
||||
| 'cross_session';
|
||||
|
||||
export type ToolPermissionBehavior = 'allow' | 'deny';
|
||||
|
||||
export interface MainAgentTool {
|
||||
definition: LLMToolDefinition;
|
||||
permissionScope?: ToolPermissionScope;
|
||||
execute(argumentsValue: unknown, context: MainAgentToolContext): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface MainAgentTranscriptRepository {
|
||||
createSession(input: CreateAgentSessionInput): AgentSessionRecord;
|
||||
appendMessage(input: {
|
||||
sessionId: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): AgentMessageRecord;
|
||||
appendToolCall(input: {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
toolName: string;
|
||||
status?: 'running' | 'completed' | 'failed';
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}): AgentToolCallRecord;
|
||||
completeSession(input: {
|
||||
sessionId: string;
|
||||
status: 'completed' | 'failed' | 'cancelled';
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
}): AgentSessionRecord;
|
||||
}
|
||||
|
||||
export interface MainAgentRunnerOptions {
|
||||
modelClient: MainAgentModelClient;
|
||||
transcriptRepository: MainAgentTranscriptRepository;
|
||||
tools?: MainAgentTool[];
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface MainAgentRunInput {
|
||||
session?: Omit<CreateAgentSessionInput, 'model'> & { model?: string };
|
||||
sessionId?: string;
|
||||
agentType?: string;
|
||||
model: string;
|
||||
systemPrompt?: string;
|
||||
userMessage: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
responseFormat?: 'text' | 'json';
|
||||
providerOptions?: Record<string, unknown>;
|
||||
maxTurns: number;
|
||||
maxToolCalls: number;
|
||||
maxSubagents?: number;
|
||||
timeoutMs: number;
|
||||
maxEmptyResponses?: number;
|
||||
maxConsecutiveToolFailures?: number;
|
||||
}
|
||||
|
||||
export interface MainAgentRunResult {
|
||||
status: MainAgentTerminalStatus;
|
||||
sessionId: string;
|
||||
turns: number;
|
||||
toolCalls: number;
|
||||
finalText?: string;
|
||||
messages: LLMMessage[];
|
||||
}
|
||||
|
||||
export interface ToolExecutionResult {
|
||||
ok: boolean;
|
||||
value?: unknown;
|
||||
error?: {
|
||||
name: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
45
src/agent-kernel/model/__tests__/model-resolver.test.ts
Normal file
45
src/agent-kernel/model/__tests__/model-resolver.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveAgentModel } from '../model-resolver';
|
||||
|
||||
describe('resolveAgentModel', () => {
|
||||
test('uses spawn override before every configured fallback', () => {
|
||||
const model = resolveAgentModel({
|
||||
spawnOverride: 'spawn-model',
|
||||
agentDefinition: { model: 'definition-model' },
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('spawn-model');
|
||||
});
|
||||
|
||||
test('falls back to AgentDefinition.model when spawn override is missing', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: { model: 'definition-model' },
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('definition-model');
|
||||
});
|
||||
|
||||
test('falls back to defaultSubagentModel when AgentDefinition.model is missing', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: {},
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('subagent-default-model');
|
||||
});
|
||||
|
||||
test('falls back to mainAgentModel when no subagent-specific model exists', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: {},
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('main-model');
|
||||
});
|
||||
});
|
||||
2
src/agent-kernel/model/index.ts
Normal file
2
src/agent-kernel/model/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { resolveAgentModel } from './model-resolver';
|
||||
export type { AgentModelResolutionInput } from './model-resolver';
|
||||
17
src/agent-kernel/model/model-resolver.ts
Normal file
17
src/agent-kernel/model/model-resolver.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AgentDefinition } from '../definitions';
|
||||
|
||||
export interface AgentModelResolutionInput {
|
||||
spawnOverride?: string;
|
||||
agentDefinition: Pick<AgentDefinition, 'model'>;
|
||||
defaultSubagentModel?: string;
|
||||
mainAgentModel: string;
|
||||
}
|
||||
|
||||
export function resolveAgentModel(input: AgentModelResolutionInput): string {
|
||||
return (
|
||||
input.spawnOverride ??
|
||||
input.agentDefinition.model ??
|
||||
input.defaultSubagentModel ??
|
||||
input.mainAgentModel
|
||||
);
|
||||
}
|
||||
224
src/agent-kernel/session/__tests__/session-repository.test.ts
Normal file
224
src/agent-kernel/session/__tests__/session-repository.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, getDatabase, initDatabase } from '../../../db/database';
|
||||
import { agentSessionRepository } from '../session-repository';
|
||||
|
||||
describe('agentSessionRepository', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `agent-session-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('migration creates transcript tables and can run idempotently', () => {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name IN (
|
||||
'agent_sessions', 'agent_messages', 'agent_tool_calls', 'agent_invocations'
|
||||
)
|
||||
ORDER BY name`
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual([
|
||||
'agent_invocations',
|
||||
'agent_messages',
|
||||
'agent_sessions',
|
||||
'agent_tool_calls',
|
||||
]);
|
||||
|
||||
closeDatabase();
|
||||
initDatabase();
|
||||
|
||||
const migrationRow = getDatabase()
|
||||
.query('SELECT COUNT(*) AS count FROM _migrations WHERE version = 5')
|
||||
.get() as { count: number };
|
||||
expect(migrationRow.count).toBe(1);
|
||||
});
|
||||
|
||||
test('queries parent-child transcript tree in insertion order', () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'gpt-main',
|
||||
metadata: { requestId: 'req-1' },
|
||||
});
|
||||
const secondMessage = agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'assistant',
|
||||
content: { text: 'second' },
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'user',
|
||||
content: { text: 'first but inserted second' },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: parent.id,
|
||||
messageId: secondMessage.id,
|
||||
toolName: 'search_code',
|
||||
arguments: { query: 'alpha' },
|
||||
result: { matches: 1 },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: parent.id,
|
||||
toolName: 'read_file',
|
||||
arguments: { path: 'src/index.ts' },
|
||||
result: { content: 'ok' },
|
||||
});
|
||||
|
||||
const firstInvocation = agentSessionRepository.createInvocation({
|
||||
parentSessionId: parent.id,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
input: { goal: 'security' },
|
||||
});
|
||||
const secondInvocation = agentSessionRepository.createInvocation({
|
||||
parentSessionId: parent.id,
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
input: { goal: 'quality' },
|
||||
});
|
||||
const child = agentSessionRepository.createSession({
|
||||
parentSessionId: parent.id,
|
||||
parentInvocationId: firstInvocation.id,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: child.id,
|
||||
role: 'assistant',
|
||||
content: { text: 'child transcript' },
|
||||
});
|
||||
agentSessionRepository.completeInvocation({
|
||||
invocationId: firstInvocation.id,
|
||||
status: 'completed',
|
||||
result: { summary: 'done' },
|
||||
childSessionId: child.id,
|
||||
});
|
||||
agentSessionRepository.completeInvocation({
|
||||
invocationId: secondInvocation.id,
|
||||
status: 'failed',
|
||||
error: { message: 'boom' },
|
||||
});
|
||||
agentSessionRepository.completeSession({
|
||||
sessionId: parent.id,
|
||||
status: 'completed',
|
||||
finalResult: { summary: 'parent done' },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.agentType).toBe('main');
|
||||
expect(tree?.messages.map((message) => message.content)).toEqual([
|
||||
{ text: 'second' },
|
||||
{ text: 'first but inserted second' },
|
||||
]);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'search_code',
|
||||
'read_file',
|
||||
]);
|
||||
expect(tree?.invocations.map((invocation) => invocation.agentType)).toEqual([
|
||||
'security-reviewer',
|
||||
'quality-reviewer',
|
||||
]);
|
||||
expect(tree?.invocations[0].childSession?.messages[0].content).toEqual({
|
||||
text: 'child transcript',
|
||||
});
|
||||
expect(tree?.invocations[1].error).toEqual({ message: 'boom' });
|
||||
|
||||
const completedTranscript = agentSessionRepository.getInvocationTranscript(firstInvocation.id);
|
||||
expect(completedTranscript?.invocation.id).toBe(firstInvocation.id);
|
||||
expect(completedTranscript?.childSession?.id).toBe(child.id);
|
||||
expect(completedTranscript?.childSession?.messages[0].content).toEqual({
|
||||
text: 'child transcript',
|
||||
});
|
||||
|
||||
const failedTranscript = agentSessionRepository.getInvocationTranscript(secondInvocation.id);
|
||||
expect(failedTranscript?.invocation.id).toBe(secondInvocation.id);
|
||||
expect(failedTranscript?.childSession).toBeUndefined();
|
||||
expect(agentSessionRepository.getInvocationTranscript('missing-invocation')).toBeNull();
|
||||
});
|
||||
|
||||
test('redacts sensitive JSON fields before storage', () => {
|
||||
const session = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'gpt-main',
|
||||
metadata: {
|
||||
apiKey: 'sk-live',
|
||||
nested: { authorization: 'Bearer token', safe: 'visible' },
|
||||
},
|
||||
});
|
||||
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: session.id,
|
||||
role: 'user',
|
||||
content: { password: 'p4ss', text: 'keep me' },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: session.id,
|
||||
toolName: 'call_provider',
|
||||
arguments: { token: 'tok_123', payload: { secret: 'hidden', value: 1 } },
|
||||
result: { ok: true, refreshToken: 'refresh_123' },
|
||||
});
|
||||
agentSessionRepository.completeSession({
|
||||
sessionId: session.id,
|
||||
status: 'failed',
|
||||
error: { message: 'bad', credentials: { api_key: 'secret-key' } },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(session.id);
|
||||
expect(tree?.metadata).toEqual({
|
||||
apiKey: '[REDACTED]',
|
||||
nested: { authorization: '[REDACTED]', safe: 'visible' },
|
||||
});
|
||||
expect(tree?.messages[0].content).toEqual({ password: '[REDACTED]', text: 'keep me' });
|
||||
expect(tree?.toolCalls[0].arguments).toEqual({
|
||||
token: '[REDACTED]',
|
||||
payload: { secret: '[REDACTED]', value: 1 },
|
||||
});
|
||||
expect(tree?.toolCalls[0].result).toEqual({ ok: true, refreshToken: '[REDACTED]' });
|
||||
expect(tree?.error).toEqual({
|
||||
message: 'bad',
|
||||
credentials: '[REDACTED]',
|
||||
});
|
||||
});
|
||||
|
||||
test('getSessionTreeByRunId finds the correct session tree by reviewRunId', () => {
|
||||
const runId = 'test-run-123';
|
||||
const session = agentSessionRepository.createSession({
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
metadata: { reviewRunId: runId },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTreeByRunId(runId);
|
||||
expect(tree).not.toBeNull();
|
||||
expect(tree?.id).toBe(session.id);
|
||||
expect(tree?.metadata.reviewRunId).toBe(runId);
|
||||
|
||||
const missingTree = agentSessionRepository.getSessionTreeByRunId('missing-run');
|
||||
expect(missingTree).toBeNull();
|
||||
});
|
||||
});
|
||||
18
src/agent-kernel/session/index.ts
Normal file
18
src/agent-kernel/session/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { agentSessionRepository, AgentSessionRepository } from './session-repository';
|
||||
export { redactSensitiveFields } from './redaction';
|
||||
export type {
|
||||
AgentInvocationRecord,
|
||||
AgentInvocationTranscript,
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentSessionStatus,
|
||||
AgentSessionTree,
|
||||
AgentToolCallRecord,
|
||||
AgentToolCallStatus,
|
||||
AppendAgentMessageInput,
|
||||
AppendAgentToolCallInput,
|
||||
CompleteAgentInvocationInput,
|
||||
CompleteAgentSessionInput,
|
||||
CreateAgentInvocationInput,
|
||||
CreateAgentSessionInput,
|
||||
} from './types';
|
||||
36
src/agent-kernel/session/redaction.ts
Normal file
36
src/agent-kernel/session/redaction.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const REDACTED_VALUE = '[REDACTED]';
|
||||
|
||||
const SENSITIVE_KEY_PARTS = [
|
||||
'apikey',
|
||||
'api_key',
|
||||
'authorization',
|
||||
'auth_token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'token',
|
||||
'password',
|
||||
'passwd',
|
||||
'secret',
|
||||
'credential',
|
||||
];
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const normalized = key.replace(/[-\s]/g, '_').toLowerCase();
|
||||
return SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
|
||||
}
|
||||
|
||||
export function redactSensitiveFields<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactSensitiveFields(item)) as T;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
redacted[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactSensitiveFields(childValue);
|
||||
}
|
||||
return redacted as T;
|
||||
}
|
||||
376
src/agent-kernel/session/session-repository.ts
Normal file
376
src/agent-kernel/session/session-repository.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '../../db/database';
|
||||
import { redactSensitiveFields } from './redaction';
|
||||
import type {
|
||||
AgentInvocationRecord,
|
||||
AgentInvocationTranscript,
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentSessionStatus,
|
||||
AgentSessionTree,
|
||||
AgentToolCallRecord,
|
||||
AgentToolCallStatus,
|
||||
AppendAgentMessageInput,
|
||||
AppendAgentToolCallInput,
|
||||
CompleteAgentInvocationInput,
|
||||
CompleteAgentSessionInput,
|
||||
CreateAgentInvocationInput,
|
||||
CreateAgentSessionInput,
|
||||
} from './types';
|
||||
|
||||
interface AgentSessionRow {
|
||||
id: string;
|
||||
parent_session_id: string | null;
|
||||
parent_invocation_id: string | null;
|
||||
agent_type: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
metadata_json: string;
|
||||
final_result_json: string | null;
|
||||
error_json: string | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AgentMessageRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content_json: string;
|
||||
metadata_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AgentToolCallRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
message_id: string | null;
|
||||
sequence: number;
|
||||
tool_name: string;
|
||||
status: AgentToolCallStatus;
|
||||
arguments_json: string;
|
||||
result_json: string | null;
|
||||
error_json: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface AgentInvocationRow {
|
||||
id: string;
|
||||
parent_session_id: string;
|
||||
child_session_id: string | null;
|
||||
sequence: number;
|
||||
agent_type: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
input_json: string;
|
||||
result_json: string | null;
|
||||
error_json: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(redactSensitiveFields(value));
|
||||
}
|
||||
|
||||
function parseJson(value: string | null): unknown | undefined {
|
||||
return value === null ? undefined : JSON.parse(value);
|
||||
}
|
||||
|
||||
function nextSequence(tableName: string, ownerColumn: string, ownerId: string): number {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM ${tableName} WHERE ${ownerColumn} = ?`
|
||||
)
|
||||
.get(ownerId) as { next_sequence: number };
|
||||
return row.next_sequence;
|
||||
}
|
||||
|
||||
function toSessionRecord(row: AgentSessionRow): AgentSessionRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
parentSessionId: row.parent_session_id ?? undefined,
|
||||
parentInvocationId: row.parent_invocation_id ?? undefined,
|
||||
agentType: row.agent_type,
|
||||
model: row.model,
|
||||
status: row.status,
|
||||
metadata: JSON.parse(row.metadata_json) as Record<string, unknown>,
|
||||
finalResult: parseJson(row.final_result_json),
|
||||
error: parseJson(row.error_json),
|
||||
startedAt: row.started_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toMessageRecord(row: AgentMessageRow): AgentMessageRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
sequence: row.sequence,
|
||||
role: row.role,
|
||||
content: JSON.parse(row.content_json),
|
||||
metadata: JSON.parse(row.metadata_json) as Record<string, unknown>,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toToolCallRecord(row: AgentToolCallRow): AgentToolCallRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
messageId: row.message_id ?? undefined,
|
||||
sequence: row.sequence,
|
||||
toolName: row.tool_name,
|
||||
status: row.status,
|
||||
arguments: JSON.parse(row.arguments_json),
|
||||
result: parseJson(row.result_json),
|
||||
error: parseJson(row.error_json),
|
||||
createdAt: row.created_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toInvocationRecord(row: AgentInvocationRow): AgentInvocationRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
parentSessionId: row.parent_session_id,
|
||||
childSessionId: row.child_session_id ?? undefined,
|
||||
sequence: row.sequence,
|
||||
agentType: row.agent_type,
|
||||
model: row.model,
|
||||
status: row.status,
|
||||
input: JSON.parse(row.input_json),
|
||||
result: parseJson(row.result_json),
|
||||
error: parseJson(row.error_json),
|
||||
createdAt: row.created_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class AgentSessionRepository {
|
||||
createSession(input: CreateAgentSessionInput): AgentSessionRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
db.query(
|
||||
`INSERT INTO agent_sessions (
|
||||
id, parent_session_id, parent_invocation_id, agent_type, model, status, metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.parentSessionId ?? null,
|
||||
input.parentInvocationId ?? null,
|
||||
input.agentType,
|
||||
input.model,
|
||||
input.status ?? 'running',
|
||||
stringifyJson(input.metadata ?? {})
|
||||
);
|
||||
|
||||
const session = this.getSession(id);
|
||||
if (!session) throw new Error('Failed to load created agent session');
|
||||
return session;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): AgentSessionRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_sessions WHERE id = ?')
|
||||
.get(sessionId) as AgentSessionRow | null;
|
||||
return row ? toSessionRecord(row) : null;
|
||||
}
|
||||
|
||||
appendMessage(input: AppendAgentMessageInput): AgentMessageRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const sequence = nextSequence('agent_messages', 'session_id', input.sessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_messages (id, session_id, sequence, role, content_json, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.sessionId,
|
||||
sequence,
|
||||
input.role,
|
||||
stringifyJson(input.content),
|
||||
stringifyJson(input.metadata ?? {})
|
||||
);
|
||||
return this.getMessage(id) as AgentMessageRecord;
|
||||
}
|
||||
|
||||
appendToolCall(input: AppendAgentToolCallInput): AgentToolCallRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const status = input.status ?? 'completed';
|
||||
const sequence = nextSequence('agent_tool_calls', 'session_id', input.sessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_tool_calls (
|
||||
id, session_id, message_id, sequence, tool_name, status, arguments_json, result_json, error_json, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.sessionId,
|
||||
input.messageId ?? null,
|
||||
sequence,
|
||||
input.toolName,
|
||||
status,
|
||||
stringifyJson(input.arguments ?? {}),
|
||||
input.result === undefined ? null : stringifyJson(input.result),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
status === 'running' ? null : new Date().toISOString()
|
||||
);
|
||||
return this.getToolCall(id) as AgentToolCallRecord;
|
||||
}
|
||||
|
||||
createInvocation(input: CreateAgentInvocationInput): AgentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const sequence = nextSequence('agent_invocations', 'parent_session_id', input.parentSessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_invocations (
|
||||
id, parent_session_id, child_session_id, sequence, agent_type, model, status, input_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.parentSessionId,
|
||||
input.childSessionId ?? null,
|
||||
sequence,
|
||||
input.agentType,
|
||||
input.model,
|
||||
input.status ?? 'running',
|
||||
stringifyJson(input.input ?? {})
|
||||
);
|
||||
return this.getInvocation(id) as AgentInvocationRecord;
|
||||
}
|
||||
|
||||
completeSession(input: CompleteAgentSessionInput): AgentSessionRecord {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`UPDATE agent_sessions
|
||||
SET status = ?, final_result_json = ?, error_json = ?, completed_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.status,
|
||||
input.finalResult === undefined ? null : stringifyJson(input.finalResult),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
input.sessionId
|
||||
);
|
||||
return this.getSession(input.sessionId) as AgentSessionRecord;
|
||||
}
|
||||
|
||||
completeInvocation(input: CompleteAgentInvocationInput): AgentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`UPDATE agent_invocations
|
||||
SET status = ?, child_session_id = COALESCE(?, child_session_id), result_json = ?, error_json = ?, completed_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.status,
|
||||
input.childSessionId ?? null,
|
||||
input.result === undefined ? null : stringifyJson(input.result),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
input.invocationId
|
||||
);
|
||||
return this.getInvocation(input.invocationId) as AgentInvocationRecord;
|
||||
}
|
||||
|
||||
getSessionTree(rootSessionId: string): AgentSessionTree | null {
|
||||
const session = this.getSession(rootSessionId);
|
||||
if (!session) return null;
|
||||
|
||||
const invocations = this.listInvocations(rootSessionId).map((invocation) => ({
|
||||
...invocation,
|
||||
childSession: invocation.childSessionId
|
||||
? (this.getSessionTree(invocation.childSessionId) ?? undefined)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
...session,
|
||||
messages: this.listMessages(rootSessionId),
|
||||
toolCalls: this.listToolCalls(rootSessionId),
|
||||
invocations,
|
||||
};
|
||||
}
|
||||
|
||||
getSessionTreeByRunId(runId: string): AgentSessionTree | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id FROM agent_sessions
|
||||
WHERE parent_session_id IS NULL
|
||||
AND json_extract(metadata_json, '$.reviewRunId') = ?`
|
||||
)
|
||||
.get(runId) as { id: string } | null;
|
||||
|
||||
if (!row) return null;
|
||||
return this.getSessionTree(row.id);
|
||||
}
|
||||
|
||||
listMessages(sessionId: string): AgentMessageRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_messages WHERE session_id = ? ORDER BY sequence ASC')
|
||||
.all(sessionId) as AgentMessageRow[];
|
||||
return rows.map(toMessageRecord);
|
||||
}
|
||||
|
||||
listToolCalls(sessionId: string): AgentToolCallRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_tool_calls WHERE session_id = ? ORDER BY sequence ASC')
|
||||
.all(sessionId) as AgentToolCallRow[];
|
||||
return rows.map(toToolCallRecord);
|
||||
}
|
||||
|
||||
listInvocations(parentSessionId: string): AgentInvocationRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_invocations WHERE parent_session_id = ? ORDER BY sequence ASC')
|
||||
.all(parentSessionId) as AgentInvocationRow[];
|
||||
return rows.map(toInvocationRecord);
|
||||
}
|
||||
|
||||
getInvocationTranscript(invocationId: string): AgentInvocationTranscript | null {
|
||||
const invocation = this.getInvocation(invocationId);
|
||||
if (!invocation) return null;
|
||||
|
||||
return {
|
||||
invocation,
|
||||
childSession: invocation.childSessionId
|
||||
? (this.getSessionTree(invocation.childSessionId) ?? undefined)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private getMessage(messageId: string): AgentMessageRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_messages WHERE id = ?')
|
||||
.get(messageId) as AgentMessageRow | null;
|
||||
return row ? toMessageRecord(row) : null;
|
||||
}
|
||||
|
||||
private getToolCall(toolCallId: string): AgentToolCallRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_tool_calls WHERE id = ?')
|
||||
.get(toolCallId) as AgentToolCallRow | null;
|
||||
return row ? toToolCallRecord(row) : null;
|
||||
}
|
||||
|
||||
private getInvocation(invocationId: string): AgentInvocationRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_invocations WHERE id = ?')
|
||||
.get(invocationId) as AgentInvocationRow | null;
|
||||
return row ? toInvocationRecord(row) : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentSessionRepository = new AgentSessionRepository();
|
||||
122
src/agent-kernel/session/types.ts
Normal file
122
src/agent-kernel/session/types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
export type AgentSessionStatus = 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
export type AgentToolCallStatus = 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface CreateAgentSessionInput {
|
||||
id?: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status?: AgentSessionStatus;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentSessionRecord {
|
||||
id: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
metadata: Record<string, unknown>;
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AppendAgentMessageInput {
|
||||
id?: string;
|
||||
sessionId: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AppendAgentToolCallInput {
|
||||
id?: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
toolName: string;
|
||||
status?: AgentToolCallStatus;
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentToolCallRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
sequence: number;
|
||||
toolName: string;
|
||||
status: AgentToolCallStatus;
|
||||
arguments: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentInvocationInput {
|
||||
id?: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status?: AgentSessionStatus;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentInvocationRecord {
|
||||
id: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
sequence: number;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
input: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface CompleteAgentSessionInput {
|
||||
sessionId: string;
|
||||
status: Exclude<AgentSessionStatus, 'running'>;
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface CompleteAgentInvocationInput {
|
||||
invocationId: string;
|
||||
status: Exclude<AgentSessionStatus, 'running'>;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
childSessionId?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionTree extends AgentSessionRecord {
|
||||
messages: AgentMessageRecord[];
|
||||
toolCalls: AgentToolCallRecord[];
|
||||
invocations: Array<AgentInvocationRecord & { childSession?: AgentSessionTree }>;
|
||||
}
|
||||
|
||||
export interface AgentInvocationTranscript {
|
||||
invocation: AgentInvocationRecord;
|
||||
childSession?: AgentSessionTree;
|
||||
}
|
||||
408
src/agent-kernel/subagents/__tests__/subagent-runner.test.ts
Normal file
408
src/agent-kernel/subagents/__tests__/subagent-runner.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../../llm/types';
|
||||
import type { AgentDefinition } from '../../definitions';
|
||||
import type { MainAgentModelClient, MainAgentTool, MainAgentToolContext } from '../../loop';
|
||||
import { agentSessionRepository } from '../../session';
|
||||
import type { SpawnSubagentExecutionInput } from '../../tools';
|
||||
import { SubagentRunner } from '../subagent-runner';
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient implements MainAgentModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
const lookupTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'lookup',
|
||||
description: 'Look up a deterministic value.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (argumentsValue) => ({ echoed: (argumentsValue as { query: string }).query }),
|
||||
};
|
||||
|
||||
const parentOnlyTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'parent_only',
|
||||
description: 'A parent-only tool that must not leak into subagents.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => ({ leaked: true }),
|
||||
};
|
||||
|
||||
function agentDefinition(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
|
||||
return {
|
||||
agentType: 'general-purpose',
|
||||
name: 'General Purpose',
|
||||
whenToUse: 'Use for general delegated work.',
|
||||
source: 'built-in',
|
||||
tools: [],
|
||||
disallowedTools: [],
|
||||
skills: [],
|
||||
hooks: {},
|
||||
maxTurns: 4,
|
||||
permissionMode: 'default',
|
||||
background: false,
|
||||
isolation: 'none',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function parentContext(sessionId: string): MainAgentToolContext {
|
||||
return {
|
||||
sessionId,
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: {
|
||||
id: 'call-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: '{}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function executionInput(
|
||||
sessionId: string,
|
||||
overrides: Partial<SpawnSubagentExecutionInput> = {}
|
||||
): SpawnSubagentExecutionInput {
|
||||
const definition = overrides.agentDefinition ?? agentDefinition();
|
||||
return {
|
||||
agentDefinition: definition,
|
||||
agentType: definition.agentType,
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Use lookup, then summarize.',
|
||||
isolation: 'none',
|
||||
parent: parentContext(sessionId),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SubagentRunner', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `subagent-runner-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('runs an isolated child loop and links invocation to the child session', async () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'main-model',
|
||||
metadata: { subagentDepth: 0 },
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'user',
|
||||
content: { text: 'parent prompt only' },
|
||||
});
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-lookup-1', name: 'lookup', arguments: '{"query":"alpha"}' }],
|
||||
}),
|
||||
response({ content: 'child concise summary' }),
|
||||
]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: ['lookup'] }) })
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
summary: 'child concise summary',
|
||||
messagesCount: 4,
|
||||
toolCallCount: 1,
|
||||
artifacts: { invocationId: expect.any(String) },
|
||||
});
|
||||
expect(result).not.toHaveProperty('messages');
|
||||
expect(result).not.toHaveProperty('toolCalls');
|
||||
expect(result).not.toHaveProperty('sessionId');
|
||||
expect(result).not.toHaveProperty('totalTokens');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.messages).toHaveLength(1);
|
||||
expect(tree?.messages[0].content).toEqual({ text: 'parent prompt only' });
|
||||
expect(tree?.toolCalls).toHaveLength(0);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0]).toMatchObject({
|
||||
parentSessionId: parent.id,
|
||||
childSessionId: tree?.invocations[0].childSessionId,
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(result.artifacts?.invocationId).toBe(tree?.invocations[0].id);
|
||||
expect(tree?.invocations[0].result).toEqual(result);
|
||||
const invocationTranscript = agentSessionRepository.getInvocationTranscript(
|
||||
tree?.invocations[0].id ?? 'missing'
|
||||
);
|
||||
expect(invocationTranscript?.invocation.result).toEqual(result);
|
||||
expect(invocationTranscript?.childSession?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(invocationTranscript?.childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
result: { echoed: 'alpha' },
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.parentSessionId).toBe(parent.id);
|
||||
expect(tree?.invocations[0].childSession?.parentInvocationId).toBe(tree?.invocations[0].id);
|
||||
expect(tree?.invocations[0].childSession?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
result: { echoed: 'alpha' },
|
||||
});
|
||||
expect(tree?.invocations[0].input).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: [],
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not leak parent tools into the child model tool definitions', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const modelClient = new FakeModelClient([response({ content: 'no tool needed' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool, parentOnlyTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: ['lookup'] }) })
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(modelClient.requests[0].tools?.map((tool) => tool.name)).toEqual(['lookup']);
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].input).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: ['parent_only'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('persists denied child tool calls as failed unregistered tool calls', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
let lookupExecutions = 0;
|
||||
const countedLookupTool: MainAgentTool = {
|
||||
...lookupTool,
|
||||
execute: () => {
|
||||
lookupExecutions += 1;
|
||||
return { shouldNotRun: true };
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-denied-lookup', name: 'lookup', arguments: '{"query":"blocked"}' }],
|
||||
}),
|
||||
response({ content: 'saw permission error and stopped' }),
|
||||
]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [countedLookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: [] }) })
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCallCount).toBe(1);
|
||||
expect(lookupExecutions).toBe(0);
|
||||
expect(modelClient.requests[0].tools).toEqual([]);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-denied-lookup',
|
||||
content: JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'lookup' is not registered",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
status: 'failed',
|
||||
arguments: { query: 'blocked' },
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'lookup' is not registered",
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: [],
|
||||
deniedToolNames: ['lookup'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('passes model prompt budgets and optional system prompt to MainAgentRunner', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const modelClient = new FakeModelClient([response({ content: 'system-aware result' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
defaultMaxToolCalls: 3,
|
||||
defaultTimeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, {
|
||||
agentDefinition: agentDefinition({
|
||||
agentType: 'code-auditor',
|
||||
model: 'definition-model',
|
||||
maxTurns: 2,
|
||||
getSystemPrompt: () => 'subagent system prompt',
|
||||
}),
|
||||
agentType: 'code-auditor',
|
||||
model: 'override-model',
|
||||
prompt: 'Audit deterministically.',
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(modelClient.requests[0]).toMatchObject({
|
||||
model: 'override-model',
|
||||
messages: [
|
||||
{ role: 'system', content: 'subagent system prompt' },
|
||||
{ role: 'user', content: 'Audit deterministically.' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('completes invocation with structured failure when child loop throws', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const runner = new SubagentRunner({
|
||||
modelClient: new FakeModelClient([]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
});
|
||||
|
||||
const result = await runner.execute(executionInput(parent.id));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'failed',
|
||||
summary: 'No fake model response queued',
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
error: { code: 'Error', message: 'No fake model response queued' },
|
||||
});
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].status).toBe('failed');
|
||||
expect(tree?.invocations[0].error).toEqual(result.error);
|
||||
expect(tree?.invocations[0].childSession?.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('blocks execution and returns structured error when recursion depth exceeds limit', async () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
metadata: { subagentDepth: 1 },
|
||||
});
|
||||
const modelClient = new FakeModelClient([response({ content: 'must not be used' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
maxDepth: 1,
|
||||
});
|
||||
|
||||
const result = await runner.execute(executionInput(parent.id));
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failed',
|
||||
summary: 'Subagent recursion depth limit exceeded (1).',
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: expect.any(String) },
|
||||
error: {
|
||||
code: 'recursion_depth_exceeded',
|
||||
message: 'Subagent recursion depth 2 exceeds max depth 1.',
|
||||
},
|
||||
});
|
||||
expect(modelClient.requests).toHaveLength(0);
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
childSessionId: undefined,
|
||||
result,
|
||||
error: result.error,
|
||||
});
|
||||
expect(tree?.invocations[0].childSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
6
src/agent-kernel/subagents/index.ts
Normal file
6
src/agent-kernel/subagents/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { SubagentRunner } from './subagent-runner';
|
||||
export type { SubagentResult, SubagentResultStatus } from './subagent-result';
|
||||
export type {
|
||||
SubagentRunnerOptions,
|
||||
SubagentRunnerTranscriptRepository,
|
||||
} from './subagent-runner';
|
||||
14
src/agent-kernel/subagents/subagent-result.ts
Normal file
14
src/agent-kernel/subagents/subagent-result.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type SubagentResultStatus = 'completed' | 'failed';
|
||||
|
||||
export interface SubagentResult {
|
||||
status: SubagentResultStatus;
|
||||
summary: string;
|
||||
messagesCount: number;
|
||||
toolCallCount: number;
|
||||
totalTokens?: number;
|
||||
artifacts?: Record<string, unknown>;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
243
src/agent-kernel/subagents/subagent-runner.ts
Normal file
243
src/agent-kernel/subagents/subagent-runner.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { MainAgentRunner } from '../loop';
|
||||
import type {
|
||||
MainAgentModelClient,
|
||||
MainAgentTerminalStatus,
|
||||
MainAgentTool,
|
||||
MainAgentTranscriptRepository,
|
||||
} from '../loop';
|
||||
import type {
|
||||
AgentInvocationRecord,
|
||||
AgentMessageRecord,
|
||||
CompleteAgentInvocationInput,
|
||||
CreateAgentInvocationInput,
|
||||
} from '../session';
|
||||
import type { AgentSessionRecord } from '../session';
|
||||
import { resolveAgentTools } from '../tools';
|
||||
import type { SpawnSubagentExecutionInput, SpawnSubagentExecutor } from '../tools';
|
||||
import type { SubagentResult } from './subagent-result';
|
||||
|
||||
export interface SubagentRunnerTranscriptRepository extends MainAgentTranscriptRepository {
|
||||
createInvocation(input: CreateAgentInvocationInput): AgentInvocationRecord;
|
||||
completeInvocation(input: CompleteAgentInvocationInput): AgentInvocationRecord;
|
||||
getSession?(sessionId: string): AgentSessionRecord | null;
|
||||
listMessages?(sessionId: string): AgentMessageRecord[];
|
||||
}
|
||||
|
||||
export interface SubagentRunnerOptions {
|
||||
modelClient: MainAgentModelClient;
|
||||
transcriptRepository: SubagentRunnerTranscriptRepository;
|
||||
tools?: MainAgentTool[];
|
||||
defaultMaxTurns?: number;
|
||||
defaultMaxToolCalls?: number;
|
||||
defaultTimeoutMs?: number;
|
||||
maxDepth?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function isCompletedStatus(status: MainAgentTerminalStatus): boolean {
|
||||
return status === 'completed';
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): { code: string; message: string } {
|
||||
if (error instanceof Error) {
|
||||
return { code: error.name, message: error.message };
|
||||
}
|
||||
return { code: 'Error', message: String(error) };
|
||||
}
|
||||
|
||||
function readDepth(session: AgentSessionRecord | null | undefined): number {
|
||||
const value = session?.metadata.subagentDepth;
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
function readTotalTokens(messages: AgentMessageRecord[]): number | undefined {
|
||||
let totalTokens = 0;
|
||||
let foundUsage = false;
|
||||
|
||||
for (const message of messages) {
|
||||
const content = message.content;
|
||||
if (typeof content !== 'object' || content === null || !('usage' in content)) continue;
|
||||
|
||||
const usage = (content as { usage?: unknown }).usage;
|
||||
if (typeof usage !== 'object' || usage === null || !('totalTokens' in usage)) continue;
|
||||
|
||||
const value = (usage as { totalTokens?: unknown }).totalTokens;
|
||||
if (typeof value !== 'number') continue;
|
||||
|
||||
totalTokens += value;
|
||||
foundUsage = true;
|
||||
}
|
||||
|
||||
return foundUsage ? totalTokens : undefined;
|
||||
}
|
||||
|
||||
export class SubagentRunner implements SpawnSubagentExecutor {
|
||||
private readonly modelClient: MainAgentModelClient;
|
||||
private readonly transcriptRepository: SubagentRunnerTranscriptRepository;
|
||||
private readonly tools: MainAgentTool[];
|
||||
private readonly defaultMaxTurns: number;
|
||||
private readonly defaultMaxToolCalls: number;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private readonly maxDepth: number;
|
||||
private readonly now?: () => number;
|
||||
|
||||
constructor(options: SubagentRunnerOptions) {
|
||||
this.modelClient = options.modelClient;
|
||||
this.transcriptRepository = options.transcriptRepository;
|
||||
this.tools = options.tools ?? [];
|
||||
this.defaultMaxTurns = options.defaultMaxTurns ?? 4;
|
||||
this.defaultMaxToolCalls = options.defaultMaxToolCalls ?? 8;
|
||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 60_000;
|
||||
this.maxDepth = options.maxDepth ?? 3;
|
||||
this.now = options.now;
|
||||
}
|
||||
|
||||
async execute(input: SpawnSubagentExecutionInput): Promise<SubagentResult> {
|
||||
const toolPermissions = resolveAgentTools({
|
||||
availableTools: this.tools,
|
||||
allowedToolNames: input.agentDefinition.tools,
|
||||
disallowedToolNames: input.agentDefinition.disallowedTools,
|
||||
allowListSpecified: true,
|
||||
});
|
||||
|
||||
const invocation = this.transcriptRepository.createInvocation({
|
||||
parentSessionId: input.parent.sessionId,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
input: {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
isolation: input.isolation,
|
||||
cwd: input.cwd,
|
||||
parentToolCallId: input.parent.toolCall.id,
|
||||
toolPermissions: {
|
||||
allowedToolNames: toolPermissions.allowedToolNames,
|
||||
disallowedToolNames: toolPermissions.disallowedToolNames,
|
||||
deniedToolNames: toolPermissions.deniedToolNames,
|
||||
unknownAllowedToolNames: toolPermissions.unknownAllowedToolNames,
|
||||
unknownDisallowedToolNames: toolPermissions.unknownDisallowedToolNames,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parentSession = this.transcriptRepository.getSession?.(input.parent.sessionId);
|
||||
const childDepth = readDepth(parentSession) + 1;
|
||||
|
||||
if (childDepth > this.maxDepth) {
|
||||
const result: SubagentResult = {
|
||||
status: 'failed',
|
||||
summary: `Subagent recursion depth limit exceeded (${this.maxDepth}).`,
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: invocation.id },
|
||||
error: {
|
||||
code: 'recursion_depth_exceeded',
|
||||
message: `Subagent recursion depth ${childDepth} exceeds max depth ${this.maxDepth}.`,
|
||||
},
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: 'failed',
|
||||
result,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const childSession = this.transcriptRepository.createSession({
|
||||
parentSessionId: input.parent.sessionId,
|
||||
parentInvocationId: invocation.id,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
metadata: {
|
||||
subagentDepth: childDepth,
|
||||
description: input.description,
|
||||
parentToolCallId: input.parent.toolCall.id,
|
||||
toolPermissions: {
|
||||
allowedToolNames: toolPermissions.allowedToolNames,
|
||||
disallowedToolNames: toolPermissions.disallowedToolNames,
|
||||
deniedToolNames: toolPermissions.deniedToolNames,
|
||||
unknownAllowedToolNames: toolPermissions.unknownAllowedToolNames,
|
||||
unknownDisallowedToolNames: toolPermissions.unknownDisallowedToolNames,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: this.modelClient,
|
||||
transcriptRepository: this.transcriptRepository,
|
||||
tools: toolPermissions.tools,
|
||||
now: this.now,
|
||||
});
|
||||
|
||||
try {
|
||||
const runResult = await runner.run({
|
||||
sessionId: childSession.id,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
systemPrompt: input.agentDefinition.getSystemPrompt?.(),
|
||||
userMessage: input.prompt,
|
||||
maxTurns: input.agentDefinition.maxTurns ?? this.defaultMaxTurns,
|
||||
maxToolCalls: this.defaultMaxToolCalls,
|
||||
timeoutMs: this.defaultTimeoutMs,
|
||||
});
|
||||
const totalTokens = this.transcriptRepository.listMessages
|
||||
? readTotalTokens(this.transcriptRepository.listMessages(childSession.id))
|
||||
: undefined;
|
||||
|
||||
const result: SubagentResult = {
|
||||
status: isCompletedStatus(runResult.status) ? 'completed' : 'failed',
|
||||
summary: runResult.finalText ?? runResult.status,
|
||||
messagesCount: runResult.messages.length,
|
||||
toolCallCount: runResult.toolCalls,
|
||||
...(totalTokens === undefined ? {} : { totalTokens }),
|
||||
artifacts: { invocationId: invocation.id },
|
||||
...(isCompletedStatus(runResult.status)
|
||||
? {}
|
||||
: {
|
||||
error: {
|
||||
code: runResult.status,
|
||||
message: `Subagent stopped with status ${runResult.status}.`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: result.status,
|
||||
result,
|
||||
error: result.error,
|
||||
childSessionId: childSession.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error);
|
||||
const result: SubagentResult = {
|
||||
status: 'failed',
|
||||
summary: normalized.message,
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: invocation.id },
|
||||
error: normalized,
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeSession({
|
||||
sessionId: childSession.id,
|
||||
status: 'failed',
|
||||
error: normalized,
|
||||
});
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: 'failed',
|
||||
result,
|
||||
error: normalized,
|
||||
childSessionId: childSession.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
352
src/agent-kernel/tools/__tests__/spawn-subagent-tool.test.ts
Normal file
352
src/agent-kernel/tools/__tests__/spawn-subagent-tool.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../../llm/types';
|
||||
import { createAgentRegistry } from '../../definitions';
|
||||
import { MainAgentRunner } from '../../loop';
|
||||
import type { MainAgentModelClient } from '../../loop';
|
||||
import { agentSessionRepository } from '../../session/session-repository';
|
||||
import { createSpawnSubagentTool } from '../spawn-subagent-tool';
|
||||
import type { SpawnSubagentExecutionInput, SpawnSubagentExecutor } from '../spawn-subagent-tool';
|
||||
|
||||
function agent(agentType: string, name: string, model?: string) {
|
||||
return {
|
||||
agentType,
|
||||
name,
|
||||
whenToUse: `Use ${name}.`,
|
||||
source: 'built-in' as const,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExecutor(result: unknown = { summary: 'subagent done', value: 42 }) {
|
||||
const calls: SpawnSubagentExecutionInput[] = [];
|
||||
const executor: SpawnSubagentExecutor = {
|
||||
execute: (input) => {
|
||||
calls.push(input);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
return { executor, calls };
|
||||
}
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient implements MainAgentModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
describe('createSpawnSubagentTool', () => {
|
||||
test('defaults to general-purpose when subagent_type is omitted', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'definition-model')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Summarize', prompt: 'Summarize the change.' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'definition-model',
|
||||
description: 'Summarize',
|
||||
result: { summary: 'subagent done', value: 42 },
|
||||
summary: 'subagent done',
|
||||
});
|
||||
expect(calls[0]).toMatchObject({
|
||||
agentType: 'general-purpose',
|
||||
model: 'definition-model',
|
||||
description: 'Summarize',
|
||||
prompt: 'Summarize the change.',
|
||||
isolation: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
test('spawns an explicit active subagent type', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [
|
||||
agent('general-purpose', 'General Purpose'),
|
||||
agent('code-reviewer', 'Code Reviewer'),
|
||||
],
|
||||
});
|
||||
const { executor, calls } = makeExecutor({ summary: 'reviewed' });
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: 'Review code',
|
||||
prompt: 'Review this diff.',
|
||||
subagent_type: 'code-reviewer',
|
||||
isolation: 'workspace',
|
||||
cwd: '/tmp/workspace',
|
||||
},
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'completed',
|
||||
agentType: 'code-reviewer',
|
||||
model: 'default-subagent-model',
|
||||
description: 'Review code',
|
||||
summary: 'reviewed',
|
||||
});
|
||||
expect(calls[0]).toMatchObject({
|
||||
agentType: 'code-reviewer',
|
||||
isolation: 'workspace',
|
||||
cwd: '/tmp/workspace',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns a structured error for unknown subagent types', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [
|
||||
agent('general-purpose', 'General Purpose'),
|
||||
agent('code-reviewer', 'Code Reviewer'),
|
||||
],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Unknown', prompt: 'Run missing agent.', subagent_type: 'missing-agent' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
code: 'unknown_subagent_type',
|
||||
message: "Subagent type 'missing-agent' is not active.",
|
||||
requestedType: 'missing-agent',
|
||||
availableTypes: ['code-reviewer', 'general-purpose'],
|
||||
});
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('uses model override before definition and fallback models', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'definition-model')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Override', prompt: 'Use override.', model: 'spawn-model' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'completed', model: 'spawn-model' });
|
||||
expect(calls[0].model).toBe('spawn-model');
|
||||
});
|
||||
|
||||
test('returns a structured unsupported result for background spawns', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Background', prompt: 'Run later.', run_in_background: true },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
code: 'background_execution_unsupported',
|
||||
message:
|
||||
'spawn_subagent background execution is not supported until the isolated SubagentRunner is implemented.',
|
||||
requestedType: 'general-purpose',
|
||||
availableTypes: ['general-purpose'],
|
||||
});
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns a structured validation error for missing required arguments', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose')],
|
||||
});
|
||||
const { executor } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Missing prompt' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'error',
|
||||
code: 'invalid_arguments',
|
||||
message: 'spawn_subagent requires non-empty description and prompt arguments.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawn_subagent MainAgentRunner integration', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `spawn-subagent-tool-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('executes through MainAgentRunner and persists the parent tool call', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'subagent-model')],
|
||||
});
|
||||
const { executor } = makeExecutor({ summary: 'finished by fake executor', value: 'ok' });
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Inspect this issue deterministically.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
response({ content: 'parent final' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
agentType: 'main',
|
||||
model: 'main-model',
|
||||
userMessage: 'delegate investigation',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 4,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(modelClient.requests[0].tools?.map((definition) => definition.name)).toContain(
|
||||
'spawn_subagent'
|
||||
);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-spawn-1',
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
value: {
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
result: { summary: 'finished by fake executor', value: 'ok' },
|
||||
summary: 'finished by fake executor',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls).toHaveLength(1);
|
||||
expect(tree?.toolCalls[0].toolName).toBe('spawn_subagent');
|
||||
expect(tree?.toolCalls[0].arguments).toEqual({
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Inspect this issue deterministically.',
|
||||
});
|
||||
expect(tree?.toolCalls[0].result).toEqual({
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
result: { summary: 'finished by fake executor', value: 'ok' },
|
||||
summary: 'finished by fake executor',
|
||||
});
|
||||
});
|
||||
});
|
||||
101
src/agent-kernel/tools/__tests__/tool-permissions.test.ts
Normal file
101
src/agent-kernel/tools/__tests__/tool-permissions.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { MainAgentTool } from '../../loop';
|
||||
import type { ToolPermissionScope } from '../../loop/types';
|
||||
import {
|
||||
DEFAULT_SCOPE_POLICY,
|
||||
evaluateToolPermission,
|
||||
resolveAgentTools,
|
||||
} from '../tool-permissions';
|
||||
|
||||
function tool(name: string, scope?: ToolPermissionScope): MainAgentTool {
|
||||
return {
|
||||
definition: {
|
||||
name,
|
||||
description: `${name} tool`,
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
permissionScope: scope,
|
||||
execute: () => ({ name }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('evaluateToolPermission', () => {
|
||||
test('allows read scope', () => {
|
||||
expect(evaluateToolPermission(tool('read_file', 'read')).behavior).toBe('allow');
|
||||
});
|
||||
|
||||
test('denies write scope', () => {
|
||||
expect(evaluateToolPermission(tool('write_file', 'write')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('denies command scope', () => {
|
||||
expect(evaluateToolPermission(tool('run_bash', 'command')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('denies network scope', () => {
|
||||
expect(evaluateToolPermission(tool('http_request', 'network')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('defaults to read scope when unspecified', () => {
|
||||
expect(evaluateToolPermission(tool('search_code')).behavior).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAgentTools', () => {
|
||||
const readTool = tool('read_file', 'read');
|
||||
const writeTool = tool('write_file', 'write');
|
||||
const searchTool = tool('search_code', 'read');
|
||||
|
||||
test('includes allowed tool names regardless of scope', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [writeTool],
|
||||
allowedToolNames: ['write_file'],
|
||||
disallowedToolNames: [],
|
||||
});
|
||||
expect(resolved.tools).toHaveLength(1);
|
||||
expect(resolved.tools[0].definition.name).toBe('write_file');
|
||||
});
|
||||
|
||||
test('excludes disallowed tool names regardless of scope', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool],
|
||||
allowedToolNames: ['read_file'],
|
||||
disallowedToolNames: ['read_file'],
|
||||
});
|
||||
expect(resolved.tools).toHaveLength(0);
|
||||
expect(resolved.deniedToolNames).toContain('read_file');
|
||||
});
|
||||
|
||||
test('filters by scope policy when not in allowed/disallowed lists', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool, writeTool, searchTool],
|
||||
allowedToolNames: [],
|
||||
disallowedToolNames: [],
|
||||
});
|
||||
const names = resolved.tools.map((t) => t.definition.name);
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('search_code');
|
||||
expect(names).not.toContain('write_file');
|
||||
});
|
||||
|
||||
test('reports unknown allowed/disallowed names', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool],
|
||||
allowedToolNames: ['missing_tool'],
|
||||
disallowedToolNames: ['ghost_tool'],
|
||||
});
|
||||
expect(resolved.unknownAllowedToolNames).toContain('missing_tool');
|
||||
expect(resolved.unknownDisallowedToolNames).toContain('ghost_tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_SCOPE_POLICY', () => {
|
||||
test('only allows read scope', () => {
|
||||
expect(DEFAULT_SCOPE_POLICY.read).toBe('allow');
|
||||
expect(DEFAULT_SCOPE_POLICY.write).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.command).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.network).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.git_write).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.cross_session).toBe('deny');
|
||||
});
|
||||
});
|
||||
12
src/agent-kernel/tools/index.ts
Normal file
12
src/agent-kernel/tools/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { createSpawnSubagentTool } from './spawn-subagent-tool';
|
||||
export { resolveAgentTools } from './tool-permissions';
|
||||
export type {
|
||||
SpawnSubagentExecutionInput,
|
||||
SpawnSubagentExecutor,
|
||||
SpawnSubagentInput,
|
||||
SpawnSubagentToolOptions,
|
||||
} from './spawn-subagent-tool';
|
||||
export type {
|
||||
ResolvedAgentTools,
|
||||
ResolveAgentToolsInput,
|
||||
} from './tool-permissions';
|
||||
195
src/agent-kernel/tools/spawn-subagent-tool.ts
Normal file
195
src/agent-kernel/tools/spawn-subagent-tool.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { AgentDefinition, AgentIsolation, AgentRegistry } from '../definitions';
|
||||
import type { MainAgentTool, MainAgentToolContext } from '../loop';
|
||||
import { resolveAgentModel } from '../model';
|
||||
|
||||
export interface SpawnSubagentInput {
|
||||
description: string;
|
||||
prompt: string;
|
||||
subagent_type?: string;
|
||||
model?: string;
|
||||
run_in_background?: boolean;
|
||||
isolation?: AgentIsolation;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentExecutionInput {
|
||||
agentDefinition: AgentDefinition;
|
||||
agentType: string;
|
||||
model: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
isolation?: AgentIsolation;
|
||||
cwd?: string;
|
||||
parent: MainAgentToolContext;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentExecutor {
|
||||
execute(input: SpawnSubagentExecutionInput): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentToolOptions {
|
||||
agentRegistry: AgentRegistry;
|
||||
executor: SpawnSubagentExecutor;
|
||||
defaultSubagentModel?: string;
|
||||
}
|
||||
|
||||
type SpawnSubagentToolResult =
|
||||
| {
|
||||
status: 'completed';
|
||||
agentType: string;
|
||||
model: string;
|
||||
description: string;
|
||||
result: unknown;
|
||||
summary?: unknown;
|
||||
}
|
||||
| {
|
||||
status: 'error';
|
||||
code: string;
|
||||
message: string;
|
||||
requestedType?: string;
|
||||
availableTypes?: string[];
|
||||
issues?: string[];
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function parseInput(
|
||||
argumentsValue: unknown
|
||||
): { ok: true; value: SpawnSubagentInput } | { ok: false; issues: string[] } {
|
||||
if (!isRecord(argumentsValue)) {
|
||||
return { ok: false, issues: ['arguments must be an object'] };
|
||||
}
|
||||
|
||||
const issues: string[] = [];
|
||||
const description = optionalString(argumentsValue.description);
|
||||
const prompt = optionalString(argumentsValue.prompt);
|
||||
|
||||
if (!description) issues.push('description is required');
|
||||
if (!prompt) issues.push('prompt is required');
|
||||
|
||||
if (issues.length > 0) return { ok: false, issues };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
description: description as string,
|
||||
prompt: prompt as string,
|
||||
subagent_type: optionalString(argumentsValue.subagent_type),
|
||||
model: optionalString(argumentsValue.model),
|
||||
run_in_background:
|
||||
typeof argumentsValue.run_in_background === 'boolean'
|
||||
? argumentsValue.run_in_background
|
||||
: undefined,
|
||||
isolation: optionalString(argumentsValue.isolation) as AgentIsolation | undefined,
|
||||
cwd: optionalString(argumentsValue.cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function availableTypes(registry: AgentRegistry): string[] {
|
||||
return registry.activeAgents.map((agent) => agent.agentType).sort();
|
||||
}
|
||||
|
||||
function resolveAgentType(
|
||||
input: SpawnSubagentInput,
|
||||
registry: AgentRegistry
|
||||
): AgentDefinition | undefined {
|
||||
const requestedType = input.subagent_type ?? 'general-purpose';
|
||||
return registry.getActiveAgent(requestedType);
|
||||
}
|
||||
|
||||
function extractSummary(result: unknown): unknown {
|
||||
if (isRecord(result) && 'summary' in result) return result.summary;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createSpawnSubagentTool(options: SpawnSubagentToolOptions): MainAgentTool {
|
||||
return {
|
||||
definition: {
|
||||
name: 'spawn_subagent',
|
||||
description:
|
||||
'Spawn a registered subagent with an explicit prompt and return its structured result.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: { type: 'string' },
|
||||
prompt: { type: 'string' },
|
||||
subagent_type: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
run_in_background: { type: 'boolean' },
|
||||
isolation: { type: 'string', enum: ['none', 'workspace', 'process'] },
|
||||
cwd: { type: 'string' },
|
||||
},
|
||||
required: ['description', 'prompt'],
|
||||
},
|
||||
},
|
||||
async execute(argumentsValue, context): Promise<SpawnSubagentToolResult> {
|
||||
const parsed = parseInput(argumentsValue);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'invalid_arguments',
|
||||
message: 'spawn_subagent requires non-empty description and prompt arguments.',
|
||||
issues: parsed.issues,
|
||||
};
|
||||
}
|
||||
|
||||
const input = parsed.value;
|
||||
const requestedType = input.subagent_type ?? 'general-purpose';
|
||||
const agentDefinition = resolveAgentType(input, options.agentRegistry);
|
||||
if (!agentDefinition) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'unknown_subagent_type',
|
||||
message: `Subagent type '${requestedType}' is not active.`,
|
||||
requestedType,
|
||||
availableTypes: availableTypes(options.agentRegistry),
|
||||
};
|
||||
}
|
||||
|
||||
const model = resolveAgentModel({
|
||||
spawnOverride: input.model,
|
||||
agentDefinition,
|
||||
defaultSubagentModel: options.defaultSubagentModel,
|
||||
mainAgentModel: context.model,
|
||||
});
|
||||
|
||||
if (input.run_in_background) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'background_execution_unsupported',
|
||||
message:
|
||||
'spawn_subagent background execution is not supported until the isolated SubagentRunner is implemented.',
|
||||
requestedType: agentDefinition.agentType,
|
||||
availableTypes: availableTypes(options.agentRegistry),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await options.executor.execute({
|
||||
agentDefinition,
|
||||
agentType: agentDefinition.agentType,
|
||||
model,
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
isolation: input.isolation ?? agentDefinition.isolation,
|
||||
cwd: input.cwd,
|
||||
parent: context,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
agentType: agentDefinition.agentType,
|
||||
model,
|
||||
description: input.description,
|
||||
result,
|
||||
summary: extractSummary(result),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
78
src/agent-kernel/tools/tool-permissions.ts
Normal file
78
src/agent-kernel/tools/tool-permissions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { MainAgentTool } from '../loop';
|
||||
import type { ToolPermissionBehavior, ToolPermissionScope } from '../loop/types';
|
||||
|
||||
export interface ResolveAgentToolsInput {
|
||||
availableTools: MainAgentTool[];
|
||||
allowedToolNames: string[];
|
||||
disallowedToolNames: string[];
|
||||
allowListSpecified?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedAgentTools {
|
||||
tools: MainAgentTool[];
|
||||
allowedToolNames: string[];
|
||||
disallowedToolNames: string[];
|
||||
deniedToolNames: string[];
|
||||
unknownAllowedToolNames: string[];
|
||||
unknownDisallowedToolNames: string[];
|
||||
}
|
||||
|
||||
export interface ToolPermissionDecision {
|
||||
behavior: ToolPermissionBehavior;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE_POLICY: Record<ToolPermissionScope, ToolPermissionBehavior> = {
|
||||
read: 'allow',
|
||||
write: 'deny',
|
||||
command: 'deny',
|
||||
network: 'deny',
|
||||
git_write: 'deny',
|
||||
cross_session: 'deny',
|
||||
};
|
||||
|
||||
function uniqueNames(names: string[]): string[] {
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
export function evaluateToolPermission(tool: MainAgentTool): ToolPermissionDecision {
|
||||
const scope = tool.permissionScope ?? 'read';
|
||||
const behavior = DEFAULT_SCOPE_POLICY[scope];
|
||||
return {
|
||||
behavior,
|
||||
reason: `Tool '${tool.definition.name}' ${behavior === 'allow' ? 'allowed' : 'denied'} for scope '${scope}'`,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentTools(input: ResolveAgentToolsInput): ResolvedAgentTools {
|
||||
const availableToolNames = uniqueNames(input.availableTools.map((tool) => tool.definition.name));
|
||||
const availableToolNamesSet = new Set(availableToolNames);
|
||||
const allowedToolNames = uniqueNames(input.allowedToolNames);
|
||||
const disallowedToolNames = uniqueNames(input.disallowedToolNames);
|
||||
const allowedToolNamesSet = new Set(allowedToolNames);
|
||||
const disallowedToolNamesSet = new Set(disallowedToolNames);
|
||||
|
||||
const tools = input.availableTools.filter((tool) => {
|
||||
const toolName = tool.definition.name;
|
||||
if (disallowedToolNamesSet.has(toolName)) return false;
|
||||
if (allowedToolNamesSet.size > 0) return allowedToolNamesSet.has(toolName);
|
||||
if (input.allowListSpecified) return false;
|
||||
return evaluateToolPermission(tool).behavior === 'allow';
|
||||
});
|
||||
const permittedToolNamesSet = new Set(tools.map((tool) => tool.definition.name));
|
||||
|
||||
return {
|
||||
tools,
|
||||
allowedToolNames,
|
||||
disallowedToolNames,
|
||||
deniedToolNames: availableToolNames.filter((toolName) => !permittedToolNamesSet.has(toolName)),
|
||||
unknownAllowedToolNames: allowedToolNames.filter(
|
||||
(toolName) => !availableToolNamesSet.has(toolName)
|
||||
),
|
||||
unknownDisallowedToolNames: disallowedToolNames.filter(
|
||||
(toolName) => !availableToolNamesSet.has(toolName)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export { DEFAULT_SCOPE_POLICY };
|
||||
@@ -86,7 +86,6 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(cfg.notification.feishu.webhookSecret).toBeUndefined();
|
||||
expect(cfg.notification.wecom.webhookUrl).toBeUndefined();
|
||||
expect(cfg.admin.giteaAdminToken).toBeUndefined();
|
||||
expect(cfg.review.qdrantUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns review size thresholds and token budget defaults', () => {
|
||||
@@ -99,6 +98,12 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(cfg.review.tokenBudgetMedium).toBe(45000);
|
||||
expect(cfg.review.tokenBudgetLarge).toBe(120000);
|
||||
});
|
||||
|
||||
test('returns runtime agent model defaults', () => {
|
||||
const cfg = configManager.getCurrent();
|
||||
expect(cfg.review.agentMainModel).toBe('gpt-4.1');
|
||||
expect(cfg.review.agentDefaultSubagentModel).toBe('gpt-4.1-mini');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. setOverrides() / getSource() ─────────────────────────────────────
|
||||
@@ -195,16 +200,6 @@ describe('ConfigManager (DB backend)', () => {
|
||||
// ─── 5. Type conversions ─────────────────────────────────────────────────
|
||||
|
||||
describe('type conversions in getCurrent()', () => {
|
||||
test('boolean field "true" → true', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'true' });
|
||||
expect(configManager.getCurrent().review.enableHumanGate).toBe(true);
|
||||
});
|
||||
|
||||
test('boolean field "false" → false', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'false' });
|
||||
expect(configManager.getCurrent().review.enableHumanGate).toBe(false);
|
||||
});
|
||||
|
||||
test('number field is parsed correctly', async () => {
|
||||
await configManager.setOverrides({ REVIEW_MAX_PARALLEL_RUNS: '4' });
|
||||
expect(configManager.getCurrent().review.maxParallelRuns).toBe(4);
|
||||
@@ -220,6 +215,17 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(configManager.getCurrent().review.tokenBudgetSmall).toBe(22222);
|
||||
});
|
||||
|
||||
test('agent model fields are read from overrides', async () => {
|
||||
await configManager.setOverrides({
|
||||
AGENT_MAIN_MODEL: 'main-override-model',
|
||||
AGENT_DEFAULT_SUBAGENT_MODEL: 'subagent-override-model',
|
||||
});
|
||||
|
||||
const cfg = configManager.getCurrent();
|
||||
expect(cfg.review.agentMainModel).toBe('main-override-model');
|
||||
expect(cfg.review.agentDefaultSubagentModel).toBe('subagent-override-model');
|
||||
});
|
||||
|
||||
test('comma-separated REVIEW_ALLOWED_COMMANDS parsed to array', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ALLOWED_COMMANDS: 'git, rg, cat' });
|
||||
expect(configManager.getCurrent().review.allowedCommands).toEqual(['git', 'rg', 'cat']);
|
||||
|
||||
31
src/config/__tests__/config-schema-agent-model.test.ts
Normal file
31
src/config/__tests__/config-schema-agent-model.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { CONFIG_FIELDS } from '../config-schema';
|
||||
|
||||
function findField(envKey: string) {
|
||||
const field = CONFIG_FIELDS.find((item) => item.envKey === envKey);
|
||||
expect(field).toBeDefined();
|
||||
return field!;
|
||||
}
|
||||
|
||||
describe('config-schema agent model fields', () => {
|
||||
test('AGENT_MAIN_MODEL exists with expected metadata and default', () => {
|
||||
const field = findField('AGENT_MAIN_MODEL');
|
||||
|
||||
expect(field.envKey).toBe('AGENT_MAIN_MODEL');
|
||||
expect(field.group).toBe('review');
|
||||
expect(field.type).toBe('string');
|
||||
expect(field.sensitive).toBe(false);
|
||||
expect(field.defaultValue).toBe('gpt-4.1');
|
||||
});
|
||||
|
||||
test('AGENT_DEFAULT_SUBAGENT_MODEL exists with expected metadata and default', () => {
|
||||
const field = findField('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
|
||||
expect(field.envKey).toBe('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
expect(field.group).toBe('review');
|
||||
expect(field.type).toBe('string');
|
||||
expect(field.sensitive).toBe(false);
|
||||
expect(field.defaultValue).toBe('gpt-4.1-mini');
|
||||
});
|
||||
});
|
||||
@@ -38,14 +38,13 @@ export interface AppConfig {
|
||||
maxParallelRuns: number;
|
||||
maxFilesPerRun: number;
|
||||
maxFileContentChars: number;
|
||||
autoPublishMinConfidence: number;
|
||||
enableHumanGate: boolean;
|
||||
allowedCommands: string[];
|
||||
commandTimeoutMs: number;
|
||||
llmMaxConcurrentCalls: number;
|
||||
llmRetryMaxAttempts: number;
|
||||
llmRetryBaseDelayMs: number;
|
||||
enableTriage: boolean;
|
||||
agentMainModel: string;
|
||||
agentDefaultSubagentModel: string;
|
||||
smallMaxFiles: number;
|
||||
smallMaxChangedLines: number;
|
||||
mediumMaxFiles: number;
|
||||
@@ -58,13 +57,6 @@ export interface AppConfig {
|
||||
codexModel: string;
|
||||
codexTimeoutMs: number;
|
||||
codexReviewPrompt: string | undefined;
|
||||
qdrantUrl: string | undefined;
|
||||
enableMemory: boolean;
|
||||
fewShotExamplesCount: number;
|
||||
enableReflection: boolean;
|
||||
maxReflectionRounds: number;
|
||||
enableDebate: boolean;
|
||||
debateThreshold: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,8 +161,6 @@ class ConfigManager {
|
||||
maxParallelRuns: toNumber('REVIEW_MAX_PARALLEL_RUNS', 2),
|
||||
maxFilesPerRun: toNumber('REVIEW_MAX_FILES_PER_RUN', 200),
|
||||
maxFileContentChars: toNumber('REVIEW_MAX_FILE_CONTENT_CHARS', 40000),
|
||||
autoPublishMinConfidence: toNumber('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE', 0.8),
|
||||
enableHumanGate: toBoolean('REVIEW_ENABLE_HUMAN_GATE', true),
|
||||
allowedCommands: toStringArray('REVIEW_ALLOWED_COMMANDS', [
|
||||
'git',
|
||||
'rg',
|
||||
@@ -178,11 +168,12 @@ class ConfigManager {
|
||||
'sed',
|
||||
'wc',
|
||||
]),
|
||||
commandTimeoutMs: toNumber('REVIEW_COMMAND_TIMEOUT_MS', 10000),
|
||||
commandTimeoutMs: toNumber('REVIEW_COMMAND_TIMEOUT_MS', 120000),
|
||||
llmMaxConcurrentCalls: toNumber('LLM_MAX_CONCURRENT_CALLS', 4),
|
||||
llmRetryMaxAttempts: toNumber('LLM_RETRY_MAX_ATTEMPTS', 3),
|
||||
llmRetryBaseDelayMs: toNumber('LLM_RETRY_BASE_DELAY_MS', 1000),
|
||||
enableTriage: toBoolean('ENABLE_TRIAGE', true),
|
||||
agentMainModel: values.AGENT_MAIN_MODEL ?? 'gpt-4.1',
|
||||
agentDefaultSubagentModel: values.AGENT_DEFAULT_SUBAGENT_MODEL ?? 'gpt-4.1-mini',
|
||||
smallMaxFiles: toNumber('REVIEW_SMALL_MAX_FILES', 3),
|
||||
smallMaxChangedLines: toNumber('REVIEW_SMALL_MAX_CHANGED_LINES', 80),
|
||||
mediumMaxFiles: toNumber('REVIEW_MEDIUM_MAX_FILES', 10),
|
||||
@@ -195,13 +186,6 @@ class ConfigManager {
|
||||
codexModel: values.CODEX_MODEL ?? 'o3',
|
||||
codexTimeoutMs: toNumber('CODEX_TIMEOUT_MS', 300000),
|
||||
codexReviewPrompt: values.CODEX_REVIEW_PROMPT,
|
||||
qdrantUrl: values.QDRANT_URL,
|
||||
enableMemory: toBoolean('ENABLE_MEMORY', false),
|
||||
fewShotExamplesCount: toNumber('FEW_SHOT_EXAMPLES_COUNT', 10),
|
||||
enableReflection: toBoolean('ENABLE_REFLECTION', false),
|
||||
maxReflectionRounds: toNumber('MAX_REFLECTION_ROUNDS', 2),
|
||||
enableDebate: toBoolean('ENABLE_DEBATE', false),
|
||||
debateThreshold: values.DEBATE_THRESHOLD ?? 'high',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory';
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review';
|
||||
|
||||
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||
|
||||
@@ -60,12 +60,6 @@ export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
description: 'Agent 审查模式、并发与沙箱设置',
|
||||
icon: 'file-check',
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆与学习',
|
||||
description: '向量记忆、反思与辩论系统',
|
||||
icon: 'brain',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -188,7 +182,7 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
group: 'review',
|
||||
label: '审查引擎',
|
||||
description: '代码审查模式:agent(任务化分级编排)或 codex(Codex CLI)',
|
||||
description: '代码审查模式:agent(内置 Agent 审查)或 codex(Codex CLI)',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['agent', 'codex'],
|
||||
@@ -236,26 +230,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
max: 1000000,
|
||||
defaultValue: 40000,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
group: 'review',
|
||||
label: '自动发布置信度',
|
||||
description: '自动发布评论所需的最小置信度(0~1)',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 0,
|
||||
max: 1,
|
||||
defaultValue: 0.8,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ENABLE_HUMAN_GATE',
|
||||
group: 'review',
|
||||
label: '人工审批',
|
||||
description: '是否启用人工审批队列(低置信度评论需人工确认后发布)',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ALLOWED_COMMANDS',
|
||||
group: 'review',
|
||||
@@ -272,9 +246,9 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
description: '单条本地命令的执行超时时间(毫秒)',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1000,
|
||||
min: 120000,
|
||||
max: 300000,
|
||||
defaultValue: 10000,
|
||||
defaultValue: 120000,
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
@@ -310,13 +284,22 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
defaultValue: 1000,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_TRIAGE',
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
group: 'review',
|
||||
label: '启用变更分流',
|
||||
description: '是否启用 Triage 分流(用 Planner 模型先评估变更复杂度,再按需派发 Specialist)',
|
||||
type: 'boolean',
|
||||
label: 'Agent 主模型',
|
||||
description: 'Agent runtime 在没有更具体模型配置时使用的主模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
defaultValue: 'gpt-4.1',
|
||||
},
|
||||
{
|
||||
envKey: 'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
group: 'review',
|
||||
label: 'Subagent 默认模型',
|
||||
description: 'Subagent 未声明模型且 spawn 未覆盖时使用的默认模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: 'gpt-4.1-mini',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_SMALL_MAX_FILES',
|
||||
@@ -442,75 +425,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
},
|
||||
|
||||
// ── 记忆与学习 ──────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'QDRANT_URL',
|
||||
group: 'memory',
|
||||
label: 'Qdrant 地址',
|
||||
description: 'Qdrant 向量数据库的连接 URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_MEMORY',
|
||||
group: 'memory',
|
||||
label: '启用记忆',
|
||||
description: '是否启用向量记忆系统(需配置 Qdrant)',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEW_SHOT_EXAMPLES_COUNT',
|
||||
group: 'memory',
|
||||
label: 'Few-shot 示例数',
|
||||
description: '检索的 few-shot 示例数量',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 0,
|
||||
max: 20,
|
||||
defaultValue: 10,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_REFLECTION',
|
||||
group: 'memory',
|
||||
label: '启用反思',
|
||||
description: '是否启用审查结果自我反思机制',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'MAX_REFLECTION_ROUNDS',
|
||||
group: 'memory',
|
||||
label: '最大反思轮数',
|
||||
description: '反思迭代的最大轮数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1,
|
||||
max: 5,
|
||||
defaultValue: 2,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_DEBATE',
|
||||
group: 'memory',
|
||||
label: '启用辩论',
|
||||
description: '是否启用多视角辩论机制',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'DEBATE_THRESHOLD',
|
||||
group: 'memory',
|
||||
label: '辩论阈值',
|
||||
description: '触发辩论的严重程度阈值',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['high', 'medium'],
|
||||
defaultValue: 'high',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
77
src/controllers/__tests__/admin-review-runs.test.ts
Normal file
77
src/controllers/__tests__/admin-review-runs.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import { agentSessionRepository } from '../../agent-kernel/session';
|
||||
import { reviewEngine } from '../../review/engine';
|
||||
import { adminController } from '../admin';
|
||||
|
||||
function createTestApp(): Hono {
|
||||
const app = new Hono();
|
||||
app.route('/admin/api', adminController.protectedRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('admin review runs route', () => {
|
||||
const originalGetRunDetails = reviewEngine.getRunDetails;
|
||||
const originalGetSessionTreeByRunId = agentSessionRepository.getSessionTreeByRunId;
|
||||
|
||||
afterEach(() => {
|
||||
reviewEngine.getRunDetails = originalGetRunDetails;
|
||||
agentSessionRepository.getSessionTreeByRunId = originalGetSessionTreeByRunId;
|
||||
});
|
||||
|
||||
test('GET /admin/api/review/runs/:runId returns run details with sessionTree', async () => {
|
||||
const mockRunDetails = {
|
||||
run: {
|
||||
id: 'run-123',
|
||||
status: 'succeeded',
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
};
|
||||
|
||||
const mockSessionTree = {
|
||||
id: 'session-123',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
};
|
||||
|
||||
reviewEngine.getRunDetails = async (runId) => {
|
||||
if (runId === 'run-123') {
|
||||
return mockRunDetails as any;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
agentSessionRepository.getSessionTreeByRunId = (runId) => {
|
||||
if (runId === 'run-123') {
|
||||
return mockSessionTree as any;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const app = createTestApp();
|
||||
const response = await app.request('http://localhost/admin/api/review/runs/run-123');
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.run.id).toBe('run-123');
|
||||
expect(payload.sessionTree.id).toBe('session-123');
|
||||
expect(payload.sessionTree.agentType).toBe('review-main-agent');
|
||||
});
|
||||
|
||||
test('GET /admin/api/review/runs/:runId returns 404 if run not found', async () => {
|
||||
reviewEngine.getRunDetails = async () => null;
|
||||
|
||||
const app = createTestApp();
|
||||
const response = await app.request('http://localhost/admin/api/review/runs/missing-run');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
201
src/controllers/__tests__/agents.test.ts
Normal file
201
src/controllers/__tests__/agents.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Hono } from 'hono';
|
||||
import { jwt, sign } from 'hono/jwt';
|
||||
import config from '../../config';
|
||||
import { initMasterKey } from '../../crypto/secrets';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { agentsRouter } from '../agents';
|
||||
|
||||
function createProtectedTestApp(): Hono {
|
||||
const app = new Hono();
|
||||
app.use('/admin/api/*', (c, next) => {
|
||||
const middleware = jwt({ secret: config.admin.jwtSecret, alg: 'HS256' });
|
||||
return middleware(c, next);
|
||||
});
|
||||
app.route('/admin/api/agents', agentsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function createAdminToken(): Promise<string> {
|
||||
return sign(
|
||||
{
|
||||
sub: 'admin',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
config.admin.jwtSecret
|
||||
);
|
||||
}
|
||||
|
||||
async function jsonRequest(
|
||||
app: Hono,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
token?: string
|
||||
): Promise<{ status: number; data: any }> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const init: RequestInit = { method, headers };
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await app.request(`http://localhost/admin/api/agents${path}`, init);
|
||||
const text = await res.text();
|
||||
try {
|
||||
return { status: res.status, data: JSON.parse(text) };
|
||||
} catch {
|
||||
return { status: res.status, data: { _raw: text } };
|
||||
}
|
||||
}
|
||||
|
||||
describe('agents controller', () => {
|
||||
let dbPath: string;
|
||||
let app: Hono;
|
||||
let tempProjectRoot: string;
|
||||
let savedCwd: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
const savedEncryptionKey = process.env.ENCRYPTION_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDbDir = join(tmpdir(), `agents-ctrl-db-${randomUUID()}`);
|
||||
mkdirSync(tmpDbDir, { recursive: true });
|
||||
dbPath = join(tmpDbDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
|
||||
'hex'
|
||||
);
|
||||
|
||||
tempProjectRoot = join(tmpdir(), `agents-project-${randomUUID()}`);
|
||||
mkdirSync(join(tempProjectRoot, '.gitea-assistant', 'agents'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tempProjectRoot, '.gitea-assistant', 'agents', 'alpha.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: alpha-reviewer',
|
||||
'name: Alpha Reviewer',
|
||||
'whenToUse: Use alpha reviewer for repository checks.',
|
||||
'tools: [read_file, search_code]',
|
||||
'model: gpt-4.1',
|
||||
'maxTurns: 3',
|
||||
'---',
|
||||
'You are alpha reviewer.',
|
||||
].join('\n')
|
||||
);
|
||||
writeFileSync(
|
||||
join(tempProjectRoot, '.gitea-assistant', 'agents', 'broken.md'),
|
||||
['---', 'agentType: broken', 'name: Broken Agent', '---', ' '].join('\n')
|
||||
);
|
||||
|
||||
savedCwd = process.cwd();
|
||||
process.chdir(tempProjectRoot);
|
||||
|
||||
initMasterKey();
|
||||
initDatabase();
|
||||
app = createProtectedTestApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(savedCwd);
|
||||
closeDatabase();
|
||||
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (savedEncryptionKey === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'ENCRYPTION_KEY');
|
||||
} else {
|
||||
process.env.ENCRYPTION_KEY = savedEncryptionKey;
|
||||
}
|
||||
|
||||
try {
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
} catch {}
|
||||
try {
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
} catch {}
|
||||
try {
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
} catch {}
|
||||
try {
|
||||
if (existsSync(tempProjectRoot)) rmSync(tempProjectRoot, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test('GET /definitions returns active/all definitions and load errors', async () => {
|
||||
const token = await createAdminToken();
|
||||
const { status, data } = await jsonRequest(app, 'GET', '/definitions', undefined, token);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(Array.isArray(data.activeDefinitions)).toBe(true);
|
||||
expect(Array.isArray(data.allDefinitions)).toBe(true);
|
||||
expect(Array.isArray(data.loadErrors)).toBe(true);
|
||||
|
||||
const alpha = data.activeDefinitions.find((item: any) => item.agentType === 'alpha-reviewer');
|
||||
expect(alpha).toBeDefined();
|
||||
expect(alpha.source).toBe('project');
|
||||
expect(alpha.tools).toEqual(['read_file', 'search_code']);
|
||||
expect(alpha.model).toBe('gpt-4.1');
|
||||
expect(alpha.maxTurns).toBe(3);
|
||||
|
||||
const broken = data.loadErrors.find((item: any) => item.filePath.endsWith('broken.md'));
|
||||
expect(broken).toBeDefined();
|
||||
expect(broken.code).toBe('empty_body');
|
||||
});
|
||||
|
||||
test('GET /model-config returns runtime model defaults', async () => {
|
||||
const token = await createAdminToken();
|
||||
const { status, data } = await jsonRequest(app, 'GET', '/model-config', undefined, token);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data).toHaveProperty('agentMainModel');
|
||||
expect(data).toHaveProperty('agentDefaultSubagentModel');
|
||||
expect(data).toHaveProperty('source');
|
||||
});
|
||||
|
||||
test('PUT /model-config updates runtime model defaults', async () => {
|
||||
const token = await createAdminToken();
|
||||
const updateRes = await jsonRequest(
|
||||
app,
|
||||
'PUT',
|
||||
'/model-config',
|
||||
{
|
||||
agentMainModel: 'gpt-4.1-updated',
|
||||
agentDefaultSubagentModel: 'gpt-4.1-mini-updated',
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect(updateRes.data.agentMainModel).toBe('gpt-4.1-updated');
|
||||
expect(updateRes.data.agentDefaultSubagentModel).toBe('gpt-4.1-mini-updated');
|
||||
expect(updateRes.data.source.agentMainModel).toBe('db');
|
||||
expect(updateRes.data.source.agentDefaultSubagentModel).toBe('db');
|
||||
|
||||
const readBack = await jsonRequest(app, 'GET', '/model-config', undefined, token);
|
||||
expect(readBack.status).toBe(200);
|
||||
expect(readBack.data.agentMainModel).toBe('gpt-4.1-updated');
|
||||
expect(readBack.data.agentDefaultSubagentModel).toBe('gpt-4.1-mini-updated');
|
||||
});
|
||||
|
||||
test('returns 401 when missing authorization token', async () => {
|
||||
const defsRes = await jsonRequest(app, 'GET', '/definitions');
|
||||
expect(defsRes.status).toBe(401);
|
||||
|
||||
const getModelRes = await jsonRequest(app, 'GET', '/model-config');
|
||||
expect(getModelRes.status).toBe(401);
|
||||
|
||||
const putModelRes = await jsonRequest(app, 'PUT', '/model-config', {
|
||||
agentMainModel: 'nope',
|
||||
});
|
||||
expect(putModelRes.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { join } from 'node:path';
|
||||
import { Hono } from 'hono';
|
||||
import { initMasterKey } from '../../crypto/secrets';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { modelRoleRepo } from '../../db/repositories/model-role-repo';
|
||||
import { providerRepo } from '../../db/repositories/provider-repo';
|
||||
import { secretRepo } from '../../db/repositories/secret-repo';
|
||||
import { llmConfigRouter } from '../llm-config';
|
||||
@@ -150,19 +149,6 @@ describe('llm-config controller', () => {
|
||||
expect(data.hasKey).toBe(true);
|
||||
});
|
||||
|
||||
test('auto-binds all roles when first provider is created', async () => {
|
||||
await jsonRequest(app, 'POST', '/providers', {
|
||||
name: 'First Provider',
|
||||
type: 'gemini',
|
||||
defaultModel: 'gemini-pro',
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
const { data: roles } = await jsonRequest(app, 'GET', '/roles');
|
||||
const assignedRoles = roles.filter((r: any) => r.providerId !== null);
|
||||
expect(assignedRoles).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('rejects missing required fields', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'POST', '/providers', {
|
||||
name: 'Missing Type',
|
||||
@@ -239,7 +225,7 @@ describe('llm-config controller', () => {
|
||||
});
|
||||
|
||||
describe('DELETE /providers/:id', () => {
|
||||
test('deletes provider without role assignments', async () => {
|
||||
test('deletes provider', async () => {
|
||||
const created = providerRepo.create({
|
||||
name: 'ToDelete',
|
||||
type: 'anthropic',
|
||||
@@ -249,7 +235,6 @@ describe('llm-config controller', () => {
|
||||
const { status, data } = await jsonRequest(app, 'DELETE', `/providers/${created.id}`);
|
||||
expect(status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.removedRoleAssignments).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent provider', async () => {
|
||||
@@ -320,75 +305,19 @@ describe('llm-config controller', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Role Assignments ────────────────────────────────────────────
|
||||
|
||||
describe('GET /roles', () => {
|
||||
test('returns all MODEL_ROLES with null assignments when unassigned', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'GET', '/roles');
|
||||
expect(status).toBe(200);
|
||||
expect(data).toHaveLength(4);
|
||||
expect(data[0]).toHaveProperty('role');
|
||||
expect(data[0]).toHaveProperty('providerId');
|
||||
describe('removed legacy model binding API', () => {
|
||||
test('returns 404 for old role list endpoint', async () => {
|
||||
const legacyRolePath = ['/', 'roles'].join('');
|
||||
const { status } = await jsonRequest(app, 'GET', legacyRolePath);
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
test('returns assigned role info when set', async () => {
|
||||
const provider = providerRepo.create({
|
||||
name: 'RoleTest',
|
||||
type: 'openai_compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
});
|
||||
modelRoleRepo.set('planner', provider.id, 'gpt-4o');
|
||||
|
||||
const { data } = await jsonRequest(app, 'GET', '/roles');
|
||||
const planner = data.find((r: any) => r.role === 'planner');
|
||||
expect(planner.providerId).toBe(provider.id);
|
||||
expect(planner.providerName).toBe('RoleTest');
|
||||
expect(planner.model).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /roles/:role', () => {
|
||||
test('assigns a role to a provider+model', async () => {
|
||||
const provider = providerRepo.create({
|
||||
name: 'AssignTarget',
|
||||
type: 'anthropic',
|
||||
defaultModel: 'claude-3',
|
||||
});
|
||||
|
||||
const { status, data } = await jsonRequest(app, 'PUT', '/roles/planner', {
|
||||
providerId: provider.id,
|
||||
model: 'claude-3-5-sonnet',
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.role).toBe('planner');
|
||||
expect(data.providerId).toBe(provider.id);
|
||||
expect(data.model).toBe('claude-3-5-sonnet');
|
||||
});
|
||||
|
||||
test('rejects invalid role name', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'PUT', '/roles/invalid_role', {
|
||||
test('returns 404 for old role update endpoint', async () => {
|
||||
const legacyRolePath = ['/', 'roles', '/', 'old-role'].join('');
|
||||
const { status } = await jsonRequest(app, 'PUT', legacyRolePath, {
|
||||
providerId: 'some-id',
|
||||
model: 'model',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(data.message).toContain('Invalid role');
|
||||
});
|
||||
|
||||
test('rejects missing providerId or model', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'PUT', '/roles/planner', {
|
||||
providerId: 'some-id',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(data.message).toContain('providerId and model are required');
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent provider', async () => {
|
||||
const { status } = await jsonRequest(app, 'PUT', '/roles/planner', {
|
||||
providerId: 'non-existent',
|
||||
model: 'model',
|
||||
});
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { sign } from 'hono/jwt';
|
||||
import { agentSessionRepository } from '../agent-kernel/session';
|
||||
import config from '../config';
|
||||
import { repositoryReviewPromptRepo } from '../db/repositories/repository-review-prompt-repo';
|
||||
import { reviewEngine } from '../review/engine';
|
||||
@@ -189,7 +190,11 @@ protectedRoutes.get('/review/runs/:runId', async (c) => {
|
||||
if (!result) {
|
||||
return c.json({ message: 'Run not found' }, 404);
|
||||
}
|
||||
return c.json(result);
|
||||
const sessionTree = agentSessionRepository.getSessionTreeByRunId(runId);
|
||||
return c.json({
|
||||
...result,
|
||||
sessionTree,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('获取审查任务详情失败:', error);
|
||||
return c.json({ message: 'Failed to fetch review run details', error: error.message }, 500);
|
||||
|
||||
129
src/controllers/agents.ts
Normal file
129
src/controllers/agents.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Hono } from 'hono';
|
||||
import {
|
||||
type AgentDefinition,
|
||||
type AgentDefinitionLoadError,
|
||||
loadAgentRegistry,
|
||||
} from '../agent-kernel/definitions';
|
||||
import { configManager } from '../config/config-manager';
|
||||
|
||||
export const agentsRouter = new Hono();
|
||||
|
||||
interface SerializableAgentDefinition {
|
||||
agentType: string;
|
||||
name: string;
|
||||
whenToUse: string;
|
||||
source: AgentDefinition['source'];
|
||||
tools: string[];
|
||||
disallowedTools: string[];
|
||||
skills: string[];
|
||||
model?: string;
|
||||
maxTurns: number;
|
||||
permissionMode: AgentDefinition['permissionMode'];
|
||||
background: boolean;
|
||||
isolation: AgentDefinition['isolation'];
|
||||
}
|
||||
|
||||
interface SerializableLoadError {
|
||||
source: AgentDefinitionLoadError['source'];
|
||||
filePath: string;
|
||||
code: AgentDefinitionLoadError['code'];
|
||||
message: string;
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
function toSerializableDefinition(definition: AgentDefinition): SerializableAgentDefinition {
|
||||
return {
|
||||
agentType: definition.agentType,
|
||||
name: definition.name,
|
||||
whenToUse: definition.whenToUse,
|
||||
source: definition.source,
|
||||
tools: definition.tools,
|
||||
disallowedTools: definition.disallowedTools,
|
||||
skills: definition.skills,
|
||||
model: definition.model,
|
||||
maxTurns: definition.maxTurns,
|
||||
permissionMode: definition.permissionMode,
|
||||
background: definition.background,
|
||||
isolation: definition.isolation,
|
||||
};
|
||||
}
|
||||
|
||||
function toSerializableLoadError(error: AgentDefinitionLoadError): SerializableLoadError {
|
||||
return {
|
||||
source: error.source,
|
||||
filePath: error.filePath,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
issues: error.issues,
|
||||
};
|
||||
}
|
||||
|
||||
agentsRouter.get('/definitions', async (c) => {
|
||||
const registry = await loadAgentRegistry({ projectRoot: process.cwd() });
|
||||
|
||||
return c.json({
|
||||
activeDefinitions: registry.activeAgents.map(toSerializableDefinition),
|
||||
allDefinitions: registry.allAgents.map(toSerializableDefinition),
|
||||
loadErrors: registry.failedFiles.map(toSerializableLoadError),
|
||||
});
|
||||
});
|
||||
|
||||
agentsRouter.get('/model-config', (c) => {
|
||||
const current = configManager.getCurrent();
|
||||
|
||||
return c.json({
|
||||
agentMainModel: current.review.agentMainModel,
|
||||
agentDefaultSubagentModel: current.review.agentDefaultSubagentModel,
|
||||
source: {
|
||||
agentMainModel: configManager.getSource('AGENT_MAIN_MODEL'),
|
||||
agentDefaultSubagentModel: configManager.getSource('AGENT_DEFAULT_SUBAGENT_MODEL'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
agentsRouter.put('/model-config', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
agentMainModel?: unknown;
|
||||
agentDefaultSubagentModel?: unknown;
|
||||
}>();
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
if (body.agentMainModel !== undefined) {
|
||||
if (typeof body.agentMainModel !== 'string' || !body.agentMainModel.trim()) {
|
||||
return c.json({ message: 'agentMainModel must be a non-empty string' }, 400);
|
||||
}
|
||||
updates.AGENT_MAIN_MODEL = body.agentMainModel.trim();
|
||||
}
|
||||
|
||||
if (body.agentDefaultSubagentModel !== undefined) {
|
||||
if (
|
||||
typeof body.agentDefaultSubagentModel !== 'string' ||
|
||||
!body.agentDefaultSubagentModel.trim()
|
||||
) {
|
||||
return c.json({ message: 'agentDefaultSubagentModel must be a non-empty string' }, 400);
|
||||
}
|
||||
updates.AGENT_DEFAULT_SUBAGENT_MODEL = body.agentDefaultSubagentModel.trim();
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return c.json(
|
||||
{
|
||||
message: 'At least one of agentMainModel or agentDefaultSubagentModel is required',
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
await configManager.setOverrides(updates);
|
||||
const current = configManager.getCurrent();
|
||||
|
||||
return c.json({
|
||||
agentMainModel: current.review.agentMainModel,
|
||||
agentDefaultSubagentModel: current.review.agentDefaultSubagentModel,
|
||||
source: {
|
||||
agentMainModel: configManager.getSource('AGENT_MAIN_MODEL'),
|
||||
agentDefaultSubagentModel: configManager.getSource('AGENT_DEFAULT_SUBAGENT_MODEL'),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,6 @@ const INTEGER_FIELDS = new Set([
|
||||
'REVIEW_MAX_FILES_PER_RUN',
|
||||
'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
'FEW_SHOT_EXAMPLES_COUNT',
|
||||
'MAX_REFLECTION_ROUNDS',
|
||||
]);
|
||||
|
||||
/** Fast lookup from envKey → field metadata. */
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import config from '../config';
|
||||
import { LearningSystem } from '../review/learning/learning-system';
|
||||
import { VectorMemoryStore } from '../review/memory/vector-store';
|
||||
import { FileReviewStore } from '../review/store/file-review-store';
|
||||
import { giteaService } from '../services/gitea';
|
||||
|
||||
const feedbackRouter = new Hono();
|
||||
|
||||
// 全局实例
|
||||
let memoryStore: VectorMemoryStore | null = null;
|
||||
let learningSystem: LearningSystem | null = null;
|
||||
let reviewStore: FileReviewStore | null = null;
|
||||
|
||||
// 初始化反馈系统(记忆系统可选)
|
||||
export function initializeFeedbackSystem(store: FileReviewStore): void {
|
||||
// 保存store实例以供handlers重用,避免多实例状态不同步
|
||||
reviewStore = store;
|
||||
|
||||
// 记忆系统为可选功能
|
||||
if (config.review.qdrantUrl && config.review.enableMemory) {
|
||||
memoryStore = new VectorMemoryStore(config.review.qdrantUrl);
|
||||
learningSystem = new LearningSystem(memoryStore, reviewStore);
|
||||
|
||||
memoryStore.initialize().catch((err) => {
|
||||
console.error('Failed to initialize memory store:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 提交人工反馈
|
||||
feedbackRouter.post(
|
||||
'/finding/:findingId',
|
||||
zValidator(
|
||||
'json',
|
||||
z.object({
|
||||
approved: z.boolean().describe('是否批准该finding'),
|
||||
reason: z.string().optional().describe('反馈原因'),
|
||||
reviewer: z.string().optional().describe('审查者'),
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { findingId } = c.req.param();
|
||||
const { approved, reason } = c.req.valid('json');
|
||||
|
||||
if (!reviewStore) {
|
||||
return c.json({ error: 'Feedback system not initialized' }, 503);
|
||||
}
|
||||
|
||||
// 重用已初始化的store实例,避免多实例状态不同步
|
||||
const finding = await reviewStore.getFinding(findingId);
|
||||
|
||||
if (!finding) {
|
||||
return c.json({ error: 'Finding not found' }, 404);
|
||||
}
|
||||
|
||||
// 获取run信息以获取owner和repo
|
||||
const runDetails = await reviewStore.getRunDetails(finding.runId);
|
||||
if (!runDetails) {
|
||||
return c.json({ error: 'Run not found' }, 404);
|
||||
}
|
||||
|
||||
const { owner, repo } = runDetails.run;
|
||||
|
||||
// 原子幂等性保护:先标记finding为published(原子check-and-set)
|
||||
// 只有第一个请求会得到true,后续并发/重试请求会得到false
|
||||
// 这解决了read-check-write竞态:两个并发请求不会都发布评论
|
||||
const wasUnpublished = await reviewStore.markFindingPublished(
|
||||
finding.runId,
|
||||
finding.fingerprint
|
||||
);
|
||||
|
||||
if (!wasUnpublished) {
|
||||
// finding已被标记为published,但需验证是否真的发布成功
|
||||
// 场景:并发请求A正在发布时请求B到达,或请求A发布失败回滚后请求B重试
|
||||
// 检查是否存在已发布的comment记录来确认真实状态
|
||||
// 关键:必须通过fingerprint匹配,而非仅path+line,以区分同一位置的不同findings
|
||||
const publishedComment = runDetails.comments.find(
|
||||
(c) => c.status === 'published' && c.fingerprint === finding.fingerprint
|
||||
);
|
||||
|
||||
if (publishedComment) {
|
||||
// 确认已成功发布到Gitea(存在published comment record),返回幂等成功
|
||||
return c.json({
|
||||
success: true,
|
||||
message: '该finding已处理过',
|
||||
alreadyProcessed: true,
|
||||
learningApplied: false,
|
||||
published: true,
|
||||
});
|
||||
}
|
||||
// published标记存在但无published comment记录
|
||||
// 可能原因:1) 并发请求正在发布中 2) 之前发布失败并回滚
|
||||
// 不能声称成功,返回错误让用户稍后重试
|
||||
return c.json(
|
||||
{
|
||||
error: 'Finding approval in progress or previously failed. Please retry in a moment.',
|
||||
inProgress: true,
|
||||
},
|
||||
409
|
||||
); // 409 Conflict
|
||||
}
|
||||
|
||||
// 以下代码只会被第一个请求执行(wasUnpublished=true)
|
||||
|
||||
let learningApplied = false;
|
||||
|
||||
// 如果记忆系统启用,尝试执行学习和向量存储(可选功能,失败不阻止审批流程)
|
||||
if (memoryStore && learningSystem) {
|
||||
try {
|
||||
await memoryStore.storeFeedback(findingId, approved, reason || '', owner, repo);
|
||||
|
||||
if (approved) {
|
||||
await learningSystem.learnFromApproval(finding, owner, repo);
|
||||
} else {
|
||||
await learningSystem.learnFromFalsePositive(
|
||||
finding,
|
||||
reason || '人工标记为误报',
|
||||
owner,
|
||||
repo
|
||||
);
|
||||
}
|
||||
|
||||
learningApplied = true;
|
||||
} catch (memoryError) {
|
||||
// 记忆系统故障不应阻止人工审批操作
|
||||
console.error('Memory system operation failed (non-fatal):', memoryError);
|
||||
learningApplied = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果批准,发布到Gitea(人工审批通过的问题应该通知开发者)
|
||||
if (approved) {
|
||||
const comment = `## 🔍 AI代码审查问题(人工确认)
|
||||
|
||||
**${finding.title}**
|
||||
|
||||
严重程度: ${finding.severity}
|
||||
置信度: ${(finding.confidence * 100).toFixed(0)}%
|
||||
|
||||
${finding.detail}
|
||||
|
||||
${finding.evidence ? `**证据:**\n\`\`\`\n${finding.evidence}\n\`\`\`` : ''}
|
||||
|
||||
${finding.suggestion ? `**建议:**\n${finding.suggestion}` : ''}
|
||||
|
||||
---
|
||||
_此问题已通过人工审批确认_`;
|
||||
|
||||
// 关键:区分Gitea发布失败和本地store失败,避免重复发布
|
||||
// 1. 先发布到Gitea,失败则回滚published标记
|
||||
// 2. 再写本地record,失败不回滚(因为Gitea已成功,重试不应重复发布)
|
||||
try {
|
||||
if (runDetails.run.eventType === 'pull_request' && runDetails.run.prNumber) {
|
||||
await giteaService.addPullRequestComment(owner, repo, runDetails.run.prNumber, comment);
|
||||
} else if (runDetails.run.commitSha) {
|
||||
await giteaService.addCommitComment(owner, repo, runDetails.run.commitSha, comment);
|
||||
}
|
||||
} catch (giteaError) {
|
||||
// Gitea API失败:回滚published状态,允许用户重试发布
|
||||
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
|
||||
throw giteaError;
|
||||
}
|
||||
|
||||
// Gitea发布成功,写入本地record
|
||||
// 关键权衡:如果record写入失败,必须回滚published标记以保持可恢复性
|
||||
// 代价:立即重试可能导致重复Gitea评论(罕见边缘情况,优于永久卡死)
|
||||
try {
|
||||
await reviewStore.addCommentRecord({
|
||||
runId: finding.runId,
|
||||
status: 'published',
|
||||
body: comment,
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
fingerprint: finding.fingerprint,
|
||||
});
|
||||
} catch (storeError) {
|
||||
// 本地store失败:回滚published标记,允许用户重试
|
||||
// 如果用户立即重试,可能导致重复Gitea评论(可接受的权衡以避免永久卡死)
|
||||
console.error(
|
||||
'Failed to persist comment record after successful Gitea publish, rolling back:',
|
||||
storeError
|
||||
);
|
||||
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
|
||||
throw new Error(
|
||||
'Comment published to Gitea but failed to save locally. State rolled back, you may retry. Note: immediate retry may create duplicate comments.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 拒绝(标记为误报):创建comment record以标记处理完成
|
||||
// 不发布到Gitea,但需要记录以使重试请求能识别已处理
|
||||
// 如果写入失败,回滚published标记以允许重试
|
||||
try {
|
||||
await reviewStore.addCommentRecord({
|
||||
runId: finding.runId,
|
||||
status: 'published',
|
||||
body: `REJECTED: ${finding.title} - ${reason || '人工标记为误报'}`,
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
fingerprint: finding.fingerprint,
|
||||
});
|
||||
} catch (storeError) {
|
||||
// 拒绝record写入失败:回滚published标记,允许用户重试
|
||||
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
|
||||
throw storeError;
|
||||
}
|
||||
}
|
||||
|
||||
// finding已在开头原子标记为published,处理成功则保持published状态
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: approved ? '已标记为有效问题并发布到Gitea' : '已标记为误报',
|
||||
learningApplied,
|
||||
published: approved,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to process feedback:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to process feedback',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 获取待审批的findings
|
||||
feedbackRouter.get('/pending', async (c) => {
|
||||
if (!reviewStore) {
|
||||
return c.json({ error: 'Feedback system not initialized' }, 503);
|
||||
}
|
||||
|
||||
const limit = Number(c.req.query('limit') || '50');
|
||||
|
||||
try {
|
||||
const pendingFindings = await reviewStore.getPendingFindings(limit);
|
||||
|
||||
return c.json({
|
||||
findings: pendingFindings,
|
||||
total: pendingFindings.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pending findings:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to fetch pending findings',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 获取finding详情
|
||||
feedbackRouter.get('/finding/:findingId', async (c) => {
|
||||
if (!reviewStore) {
|
||||
return c.json({ error: 'Feedback system not initialized' }, 503);
|
||||
}
|
||||
|
||||
const { findingId } = c.req.param();
|
||||
|
||||
try {
|
||||
const finding = await reviewStore.getFinding(findingId);
|
||||
|
||||
if (!finding) {
|
||||
return c.json({ error: 'Finding not found' }, 404);
|
||||
}
|
||||
|
||||
// 获取run详情以提供更多上下文
|
||||
const runDetails = await reviewStore.getRunDetails(finding.runId);
|
||||
|
||||
return c.json({
|
||||
finding,
|
||||
run: runDetails?.run,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch finding:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to fetch finding',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export { feedbackRouter };
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { type ModelRole, modelRoleRepo } from '../db/repositories/model-role-repo';
|
||||
import {
|
||||
type CreateProviderInput,
|
||||
type UpdateProviderInput,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
import { secretRepo } from '../db/repositories/secret-repo';
|
||||
import { settingsRepo } from '../db/repositories/settings-repo';
|
||||
import { llmGateway } from '../llm/gateway';
|
||||
import { MODEL_ROLES } from '../llm/types';
|
||||
import { tokenCounter } from '../review/context/token-counter';
|
||||
|
||||
export const llmConfigRouter = new Hono();
|
||||
@@ -92,14 +90,6 @@ llmConfigRouter.post('/providers', async (c) => {
|
||||
secretRepo.set(created.id, body.apiKey);
|
||||
}
|
||||
|
||||
const allProviders = providerRepo.list();
|
||||
if (allProviders.length === 1) {
|
||||
const modelRolesToBind: ModelRole[] = ['planner', 'specialist', 'judge', 'embedding'];
|
||||
for (const role of modelRolesToBind) {
|
||||
modelRoleRepo.set(role, created.id, body.defaultModel);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
id: created.id,
|
||||
@@ -153,12 +143,11 @@ llmConfigRouter.put('/providers/:id', async (c) => {
|
||||
|
||||
llmConfigRouter.delete('/providers/:id', (c) => {
|
||||
const id = c.req.param('id');
|
||||
const roles = modelRoleRepo.getRolesByProvider(id);
|
||||
const deleted = providerRepo.delete(id);
|
||||
if (!deleted) return c.json({ message: 'Provider not found' }, 404);
|
||||
|
||||
llmGateway.invalidateProvider(id);
|
||||
return c.json({ success: true, removedRoleAssignments: roles });
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ── API Key Management ──────────────────────────────────────────────────
|
||||
@@ -186,52 +175,6 @@ llmConfigRouter.delete('/providers/:id/key', (c) => {
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Role Assignments ────────────────────────────────────────────────────
|
||||
|
||||
llmConfigRouter.get('/roles', (c) => {
|
||||
const assignments = modelRoleRepo.list();
|
||||
|
||||
const allRoles = MODEL_ROLES.map((role) => {
|
||||
const assignment = assignments.find((a) => a.role === role);
|
||||
return assignment
|
||||
? {
|
||||
role: assignment.role,
|
||||
providerId: assignment.provider_id,
|
||||
providerName: assignment.provider_name,
|
||||
providerType: assignment.provider_type,
|
||||
model: assignment.model,
|
||||
}
|
||||
: { role, providerId: null, providerName: null, providerType: null, model: null };
|
||||
});
|
||||
|
||||
return c.json(allRoles);
|
||||
});
|
||||
|
||||
llmConfigRouter.put('/roles/:role', async (c) => {
|
||||
const role = c.req.param('role') as ModelRole;
|
||||
if (!MODEL_ROLES.includes(role)) {
|
||||
return c.json({ message: `Invalid role. Must be one of: ${MODEL_ROLES.join(', ')}` }, 400);
|
||||
}
|
||||
|
||||
const { providerId, model } = await c.req.json<{ providerId: string; model: string }>();
|
||||
if (!providerId || !model) {
|
||||
return c.json({ message: 'providerId and model are required' }, 400);
|
||||
}
|
||||
|
||||
const provider = providerRepo.getById(providerId);
|
||||
if (!provider) return c.json({ message: 'Provider not found' }, 404);
|
||||
|
||||
modelRoleRepo.set(role, providerId, model);
|
||||
|
||||
return c.json({
|
||||
role,
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
providerType: provider.type,
|
||||
model,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Connection Test ─────────────────────────────────────────────────────
|
||||
|
||||
llmConfigRouter.post('/providers/:id/test', async (c) => {
|
||||
|
||||
@@ -106,7 +106,7 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
|
||||
// 从事件中提取必要信息
|
||||
const { pull_request: pullRequest, repository: repo } = body;
|
||||
|
||||
if (!pullRequest || !repo) {
|
||||
if (!pullRequest || !repo || !repo.owner?.login || !repo.name) {
|
||||
return c.json({ error: '无效的Webhook数据' }, 400);
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
|
||||
*/
|
||||
async function handlePullRequestClosed(c: Context, body: any): Promise<Response> {
|
||||
const { pull_request: pullRequest, repository: repo } = body;
|
||||
if (!pullRequest || !repo) {
|
||||
if (!pullRequest || !repo || !repo.owner?.login || !repo.name) {
|
||||
return c.json({ status: 'ignored', message: 'PR close 无效数据' }, 200);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ function createLegacySchema(dbPath: string): void {
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe('migration 002 remove legacy review mode', () => {
|
||||
describe('legacy review and model role cleanup migrations', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('migration 002 remove legacy review mode', () => {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test('normalizes REVIEW_ENGINE and drops legacy model-role rows', () => {
|
||||
test('normalizes REVIEW_ENGINE and drops the old model-role assignment table', () => {
|
||||
initDatabase();
|
||||
const db = getDatabase();
|
||||
|
||||
@@ -118,15 +118,9 @@ describe('migration 002 remove legacy review mode', () => {
|
||||
.get('REVIEW_ENGINE') as { value: string } | null;
|
||||
expect(engineRow?.value).toBe('agent');
|
||||
|
||||
const roles = db
|
||||
.query('SELECT role FROM model_role_assignments ORDER BY role ASC')
|
||||
.all() as Array<{ role: string }>;
|
||||
expect(roles.map((row) => row.role)).toEqual(['planner']);
|
||||
|
||||
expect(() => {
|
||||
db.query(
|
||||
'INSERT INTO model_role_assignments (role, provider_id, model) VALUES (?, ?, ?)'
|
||||
).run('legacy', 'provider-1', 'gpt-4o');
|
||||
}).toThrow();
|
||||
const roleTable = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get('model_role_assignments') as { name: string } | null;
|
||||
expect(roleTable).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../database';
|
||||
import { modelRoleRepo } from '../repositories/model-role-repo';
|
||||
import type { ModelRole } from '../repositories/model-role-repo';
|
||||
import { providerRepo } from '../repositories/provider-repo';
|
||||
import type { CreateProviderInput } from '../repositories/provider-repo';
|
||||
|
||||
describe('model-role-repo', () => {
|
||||
let dbPath: string;
|
||||
let providerId: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
const providerInput: CreateProviderInput = {
|
||||
name: 'Test Provider',
|
||||
type: 'openai_compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
|
||||
const created = providerRepo.create(providerInput);
|
||||
providerId = created.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
try {
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
try {
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
try {
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Set (upsert) ─────────────────────────────────────────────────
|
||||
|
||||
describe('set()', () => {
|
||||
test('creates a new role assignment', () => {
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
|
||||
const assignment = modelRoleRepo.getByRole('planner');
|
||||
expect(assignment).not.toBeNull();
|
||||
expect(assignment!.role).toBe('planner');
|
||||
expect(assignment!.provider_id).toBe(providerId);
|
||||
expect(assignment!.model).toBe('gpt-4o-mini');
|
||||
});
|
||||
|
||||
test('upserts: updates existing role assignment', () => {
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o');
|
||||
|
||||
const assignment = modelRoleRepo.getByRole('planner');
|
||||
expect(assignment!.model).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
test('can assign different roles', () => {
|
||||
const roles: ModelRole[] = ['planner', 'specialist', 'judge', 'embedding'];
|
||||
for (const role of roles) {
|
||||
modelRoleRepo.set(role, providerId, `model-for-${role}`);
|
||||
}
|
||||
|
||||
for (const role of roles) {
|
||||
const a = modelRoleRepo.getByRole(role);
|
||||
expect(a!.model).toBe(`model-for-${role}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GetByRole ────────────────────────────────────────────────────
|
||||
|
||||
describe('getByRole()', () => {
|
||||
test('returns null when no assignment exists', () => {
|
||||
expect(modelRoleRepo.getByRole('planner')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns the correct assignment', () => {
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o');
|
||||
const a = modelRoleRepo.getByRole('planner');
|
||||
expect(a!.provider_id).toBe(providerId);
|
||||
expect(a!.model).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('list()', () => {
|
||||
test('returns empty array when no assignments exist', () => {
|
||||
expect(modelRoleRepo.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns all assignments with provider info (JOIN)', () => {
|
||||
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o');
|
||||
|
||||
const all = modelRoleRepo.list();
|
||||
expect(all).toHaveLength(2);
|
||||
|
||||
expect(all[0].provider_name).toBe('Test Provider');
|
||||
expect(all[0].provider_type).toBe('openai_compatible');
|
||||
});
|
||||
|
||||
test('results are ordered by role', () => {
|
||||
modelRoleRepo.set('specialist', providerId, 'model-a');
|
||||
modelRoleRepo.set('embedding', providerId, 'model-b');
|
||||
modelRoleRepo.set('planner', providerId, 'model-c');
|
||||
|
||||
const all = modelRoleRepo.list();
|
||||
const roles = all.map((a) => a.role);
|
||||
expect(roles).toEqual([...roles].sort());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delete ───────────────────────────────────────────────────────
|
||||
|
||||
describe('delete()', () => {
|
||||
test('deletes existing assignment, returns true', () => {
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
expect(modelRoleRepo.delete('planner')).toBe(true);
|
||||
expect(modelRoleRepo.getByRole('planner')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns false for non-existent role', () => {
|
||||
expect(modelRoleRepo.delete('planner')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GetRolesByProvider ───────────────────────────────────────────
|
||||
|
||||
describe('getRolesByProvider()', () => {
|
||||
test('returns empty array when no roles assigned', () => {
|
||||
expect(modelRoleRepo.getRolesByProvider(providerId)).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns all roles assigned to a provider', () => {
|
||||
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o');
|
||||
modelRoleRepo.set('judge', providerId, 'gpt-4o');
|
||||
|
||||
const roles = modelRoleRepo.getRolesByProvider(providerId);
|
||||
expect(roles).toHaveLength(3);
|
||||
expect(roles).toContain('specialist');
|
||||
expect(roles).toContain('planner');
|
||||
expect(roles).toContain('judge');
|
||||
});
|
||||
|
||||
test('does not return roles assigned to other providers', () => {
|
||||
const p2 = providerRepo.create({
|
||||
...providerInput,
|
||||
name: 'Other Provider',
|
||||
type: 'anthropic',
|
||||
});
|
||||
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
|
||||
modelRoleRepo.set('planner', p2.id, 'claude-3-5-sonnet');
|
||||
|
||||
const roles1 = modelRoleRepo.getRolesByProvider(providerId);
|
||||
expect(roles1).toEqual(['specialist']);
|
||||
|
||||
const roles2 = modelRoleRepo.getRolesByProvider(p2.id);
|
||||
expect(roles2).toEqual(['planner']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CASCADE on provider delete ───────────────────────────────────
|
||||
|
||||
describe('foreign key constraint', () => {
|
||||
test('cannot delete provider while role assignments exist (no CASCADE)', () => {
|
||||
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o');
|
||||
|
||||
// FK constraint prevents delete — must remove assignments first
|
||||
expect(() => providerRepo.delete(providerId)).toThrow();
|
||||
|
||||
// Clean up assignments first, then delete succeeds
|
||||
modelRoleRepo.delete('specialist');
|
||||
modelRoleRepo.delete('planner');
|
||||
expect(providerRepo.delete(providerId)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,9 @@ import { dirname, resolve } from 'node:path';
|
||||
import { migration001Init } from './migrations/001_init';
|
||||
import { migration002RemoveLegacyReviewMode } from './migrations/002_remove_legacy_review_mode';
|
||||
import { migration003RepositoryReviewPrompts } from './migrations/003_repository_review_prompts';
|
||||
import { migration004RemoveEmbeddingRole } from './migrations/004_remove_embedding_role';
|
||||
import { migration005AgentTranscripts } from './migrations/005_agent_transcripts';
|
||||
import { migration006DropLegacyAssignments } from './migrations/006_drop_legacy_assignments';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -31,6 +34,9 @@ const MIGRATIONS: Migration[] = [
|
||||
migration001Init,
|
||||
migration002RemoveLegacyReviewMode,
|
||||
migration003RepositoryReviewPrompts,
|
||||
migration004RemoveEmbeddingRole,
|
||||
migration005AgentTranscripts,
|
||||
migration006DropLegacyAssignments,
|
||||
];
|
||||
|
||||
const REPOSITORY_REVIEW_PROMPTS_TABLE = 'repository_review_prompts';
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* Creates tables:
|
||||
* - llm_providers: Provider instance configuration
|
||||
* - llm_secrets: Encrypted API key storage
|
||||
* - model_role_assignments: Business role → provider+model mapping
|
||||
* - system_settings: Generic KV settings store
|
||||
*/
|
||||
|
||||
@@ -48,22 +47,6 @@ export const migration001Init: Migration = {
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Table 3: model_role_assignments ─────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE model_role_assignments (
|
||||
role TEXT PRIMARY KEY CHECK (role IN (
|
||||
'planner',
|
||||
'specialist',
|
||||
'judge',
|
||||
'embedding'
|
||||
)),
|
||||
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
|
||||
model TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Table 4: system_settings ────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,34 +1,12 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import type { Migration } from '../database';
|
||||
|
||||
const ALLOWED_ROLES = "'planner','specialist','judge','embedding'";
|
||||
|
||||
export const migration002RemoveLegacyReviewMode: Migration = {
|
||||
version: 2,
|
||||
name: 'remove_legacy_review_mode',
|
||||
|
||||
up(db: Database): void {
|
||||
up(db): void {
|
||||
db.exec(
|
||||
"UPDATE system_settings SET value = 'agent' WHERE key = 'REVIEW_ENGINE' AND value NOT IN ('agent','codex')"
|
||||
);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE model_role_assignments_new (
|
||||
role TEXT PRIMARY KEY CHECK (role IN (${ALLOWED_ROLES})),
|
||||
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
|
||||
model TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO model_role_assignments_new (role, provider_id, model, updated_at)
|
||||
SELECT role, provider_id, model, updated_at
|
||||
FROM model_role_assignments
|
||||
WHERE role IN (${ALLOWED_ROLES})
|
||||
`);
|
||||
|
||||
db.exec('DROP TABLE model_role_assignments');
|
||||
db.exec('ALTER TABLE model_role_assignments_new RENAME TO model_role_assignments');
|
||||
},
|
||||
};
|
||||
|
||||
8
src/db/migrations/004_remove_embedding_role.ts
Normal file
8
src/db/migrations/004_remove_embedding_role.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Migration } from '../database';
|
||||
|
||||
export const migration004RemoveEmbeddingRole: Migration = {
|
||||
version: 4,
|
||||
name: 'remove_embedding_role',
|
||||
|
||||
up(): void {},
|
||||
};
|
||||
88
src/db/migrations/005_agent_transcripts.ts
Normal file
88
src/db/migrations/005_agent_transcripts.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import type { Migration } from '../database';
|
||||
|
||||
export const migration005AgentTranscripts: Migration = {
|
||||
version: 5,
|
||||
name: 'add_agent_transcripts',
|
||||
|
||||
up(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
parent_invocation_id TEXT,
|
||||
agent_type TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'cancelled')),
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
final_result_json TEXT,
|
||||
error_json TEXT,
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
sequence INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(session_id, sequence)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_tool_calls (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
message_id TEXT REFERENCES agent_messages(id) ON DELETE SET NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed')),
|
||||
arguments_json TEXT NOT NULL DEFAULT '{}',
|
||||
result_json TEXT,
|
||||
error_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
UNIQUE(session_id, sequence)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_invocations (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
child_session_id TEXT REFERENCES agent_sessions(id) ON DELETE SET NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
agent_type TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'cancelled')),
|
||||
input_json TEXT NOT NULL DEFAULT '{}',
|
||||
result_json TEXT,
|
||||
error_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
UNIQUE(parent_session_id, sequence)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_agent_sessions_parent ON agent_sessions(parent_session_id, created_at)'
|
||||
);
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_agent_messages_session_sequence ON agent_messages(session_id, sequence)'
|
||||
);
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_agent_tool_calls_session_sequence ON agent_tool_calls(session_id, sequence)'
|
||||
);
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_agent_invocations_parent_sequence ON agent_invocations(parent_session_id, sequence)'
|
||||
);
|
||||
},
|
||||
};
|
||||
11
src/db/migrations/006_drop_legacy_assignments.ts
Normal file
11
src/db/migrations/006_drop_legacy_assignments.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import type { Migration } from '../database';
|
||||
|
||||
export const migration006DropLegacyAssignments: Migration = {
|
||||
version: 6,
|
||||
name: 'drop_model_role_assignments',
|
||||
|
||||
up(db: Database): void {
|
||||
db.exec('DROP TABLE IF EXISTS model_role_assignments');
|
||||
},
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Repository for model_role_assignments table.
|
||||
* Maps business roles (planner, specialist, judge, embedding)
|
||||
* to specific provider + model combinations.
|
||||
*/
|
||||
|
||||
import { getDatabase } from '../database';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
|
||||
|
||||
export interface RoleAssignmentRow {
|
||||
role: ModelRole;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Enriched role assignment with provider metadata (for API responses). */
|
||||
export interface RoleAssignmentWithProvider extends RoleAssignmentRow {
|
||||
provider_name: string;
|
||||
provider_type: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const modelRoleRepo = {
|
||||
/**
|
||||
* List all role assignments with provider info.
|
||||
*/
|
||||
list(): RoleAssignmentWithProvider[] {
|
||||
const db = getDatabase();
|
||||
return db
|
||||
.query(
|
||||
`SELECT
|
||||
r.role,
|
||||
r.provider_id,
|
||||
r.model,
|
||||
r.updated_at,
|
||||
p.name AS provider_name,
|
||||
p.type AS provider_type
|
||||
FROM model_role_assignments r
|
||||
JOIN llm_providers p ON r.provider_id = p.id
|
||||
ORDER BY r.role`
|
||||
)
|
||||
.all() as RoleAssignmentWithProvider[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the assignment for a specific role.
|
||||
*/
|
||||
getByRole(role: ModelRole): RoleAssignmentRow | null {
|
||||
const db = getDatabase();
|
||||
return (
|
||||
(db
|
||||
.query('SELECT * FROM model_role_assignments WHERE role = ?')
|
||||
.get(role) as RoleAssignmentRow) || null
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set (upsert) a role → provider+model mapping.
|
||||
*/
|
||||
set(role: ModelRole, providerId: string, model: string): void {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`INSERT INTO model_role_assignments (role, provider_id, model, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(role) DO UPDATE SET
|
||||
provider_id = excluded.provider_id,
|
||||
model = excluded.model,
|
||||
updated_at = datetime('now')`
|
||||
).run(role, providerId, model);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a role assignment.
|
||||
*/
|
||||
delete(role: ModelRole): boolean {
|
||||
const db = getDatabase();
|
||||
const result = db.query('DELETE FROM model_role_assignments WHERE role = ?').run(role);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all roles assigned to a specific provider (used when disabling/deleting a provider).
|
||||
*/
|
||||
getRolesByProvider(providerId: string): ModelRole[] {
|
||||
const db = getDatabase();
|
||||
return db
|
||||
.query('SELECT role FROM model_role_assignments WHERE provider_id = ?')
|
||||
.all(providerId)
|
||||
.map((row: any) => row.role as ModelRole);
|
||||
},
|
||||
};
|
||||
@@ -130,7 +130,7 @@ export const providerRepo = {
|
||||
|
||||
/**
|
||||
* Delete a provider by ID. Returns true if deleted.
|
||||
* CASCADE will also delete the associated secret and role assignments.
|
||||
* CASCADE will also delete the associated secret.
|
||||
*/
|
||||
delete(id: string): boolean {
|
||||
const db = getDatabase();
|
||||
|
||||
14
src/index.ts
14
src/index.ts
@@ -3,8 +3,8 @@ import { serveStatic } from 'hono/bun';
|
||||
import { jwt } from 'hono/jwt';
|
||||
import config, { configManager } from './config';
|
||||
import { adminController } from './controllers/admin';
|
||||
import { agentsRouter } from './controllers/agents';
|
||||
import { configRouter } from './controllers/config';
|
||||
import { feedbackRouter, initializeFeedbackSystem } from './controllers/feedback';
|
||||
import { llmConfigRouter } from './controllers/llm-config';
|
||||
import { handleGiteaWebhook } from './controllers/review';
|
||||
import { initMasterKey } from './crypto/secrets';
|
||||
@@ -60,9 +60,9 @@ adminProtected.use('/*', (c, next) => {
|
||||
return jwtMiddleware(c, next);
|
||||
});
|
||||
adminProtected.route('/', adminController.protectedRoutes);
|
||||
adminProtected.route('/feedback', feedbackRouter);
|
||||
adminProtected.route('/config', configRouter);
|
||||
adminProtected.route('/llm', llmConfigRouter);
|
||||
adminProtected.route('/agents', agentsRouter);
|
||||
app.route('/admin/api', adminProtected);
|
||||
|
||||
// --- 前端静态文件服务 ---
|
||||
@@ -88,16 +88,6 @@ codexEngine.start().catch((error) => {
|
||||
// 启动清理调度器(定期清理过期 mirror/workspace 目录)
|
||||
cleanupScheduler.start();
|
||||
|
||||
// 初始化反馈系统(总是初始化,记忆系统可选)
|
||||
const reviewStore = reviewEngine.getStore();
|
||||
initializeFeedbackSystem(reviewStore);
|
||||
|
||||
if (config.review.enableMemory) {
|
||||
console.log('✅ 反馈系统已初始化(含向量记忆)');
|
||||
} else {
|
||||
console.log('✅ 反馈系统已初始化(不含向量记忆)');
|
||||
}
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initMasterKey } from '../../crypto/secrets';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { modelRoleRepo } from '../../db/repositories/model-role-repo';
|
||||
import { providerRepo } from '../../db/repositories/provider-repo';
|
||||
import type { CreateProviderInput } from '../../db/repositories/provider-repo';
|
||||
import { secretRepo } from '../../db/repositories/secret-repo';
|
||||
@@ -74,72 +73,6 @@ describe('LLMGateway', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── chatForRole: Error Cases ──────────────────────────────────────
|
||||
|
||||
describe('chatForRole() — error handling', () => {
|
||||
test('throws LLMNoProviderError when role is not assigned', async () => {
|
||||
try {
|
||||
await gateway.chatForRole('planner', {
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false); // Should not reach
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMNoProviderError');
|
||||
expect(e.role).toBe('planner');
|
||||
}
|
||||
});
|
||||
|
||||
test('throws LLMError when provider is disabled', async () => {
|
||||
providerRepo.update(providerId, { isEnabled: false });
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
|
||||
try {
|
||||
await gateway.chatForRole('planner', {
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMError');
|
||||
expect(e.message).toContain('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
test('throws LLMAuthError when no API key configured', async () => {
|
||||
secretRepo.delete(providerId);
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
|
||||
try {
|
||||
await gateway.chatForRole('planner', {
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMAuthError');
|
||||
expect(e.message).toContain('No API key');
|
||||
}
|
||||
});
|
||||
|
||||
test('throws LLMError when provider not found after role assignment manually deleted', async () => {
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
// Must remove assignments before deleting provider (no CASCADE on model_role_assignments)
|
||||
modelRoleRepo.delete('planner');
|
||||
secretRepo.delete(providerId);
|
||||
providerRepo.delete(providerId);
|
||||
|
||||
// Re-create assignment pointing to non-existent provider
|
||||
// (simulating stale data)
|
||||
try {
|
||||
// No assignment exists now, so this throws LLMNoProviderError
|
||||
await gateway.chatForRole('planner', {
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMNoProviderError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── chatDirect: Error Cases ──────────────────────────────────────
|
||||
|
||||
describe('chatDirect() — error handling', () => {
|
||||
@@ -155,18 +88,34 @@ describe('LLMGateway', () => {
|
||||
expect(e.message).toContain('not found');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── embedForRole: Error Cases ────────────────────────────────────
|
||||
test('throws LLMError when provider is disabled', async () => {
|
||||
providerRepo.update(providerId, { isEnabled: false });
|
||||
|
||||
describe('embedForRole() — error handling', () => {
|
||||
test('throws LLMNoProviderError when embedding role not assigned', async () => {
|
||||
try {
|
||||
await gateway.embedForRole(['text']);
|
||||
await gateway.chatDirect(providerId, {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMNoProviderError');
|
||||
expect(e.role).toBe('embedding');
|
||||
expect(e.name).toBe('LLMError');
|
||||
expect(e.message).toContain('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
test('throws LLMAuthError when no API key configured', async () => {
|
||||
secretRepo.delete(providerId);
|
||||
|
||||
try {
|
||||
await gateway.chatDirect(providerId, {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMAuthError');
|
||||
expect(e.message).toContain('No API key');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
112
src/llm/e2e-mock.ts
Normal file
112
src/llm/e2e-mock.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { MainAgentModelClient } from '../agent-kernel/loop';
|
||||
import type { LLMChatRequest, LLMChatResponse, LLMToolCall } from './types';
|
||||
|
||||
export interface MockLLMScriptTurn {
|
||||
content?: string | null;
|
||||
toolCalls?: LLMToolCall[];
|
||||
finishReason?: LLMChatResponse['finishReason'];
|
||||
usage?: LLMChatResponse['usage'];
|
||||
}
|
||||
|
||||
export interface MockLLMScriptStep {
|
||||
session: string;
|
||||
turn: MockLLMScriptTurn;
|
||||
}
|
||||
|
||||
export interface MockLLMCallRecord {
|
||||
session: string;
|
||||
request: LLMChatRequest;
|
||||
response: LLMChatResponse;
|
||||
}
|
||||
|
||||
export interface ScriptedMockLLMOptions {
|
||||
steps: MockLLMScriptStep[];
|
||||
resolveSession?: (request: LLMChatRequest) => string;
|
||||
}
|
||||
|
||||
const DEFAULT_USAGE: LLMChatResponse['usage'] = {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
};
|
||||
|
||||
function cloneTurn(turn: MockLLMScriptTurn): MockLLMScriptTurn {
|
||||
return {
|
||||
content: turn.content ?? null,
|
||||
toolCalls: structuredClone(turn.toolCalls ?? []),
|
||||
finishReason: turn.finishReason,
|
||||
usage: turn.usage ? structuredClone(turn.usage) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function scriptedTurn(turn: MockLLMScriptTurn): MockLLMScriptTurn {
|
||||
return cloneTurn(turn);
|
||||
}
|
||||
|
||||
export class ScriptedMockLLM implements MainAgentModelClient {
|
||||
private readonly resolveSession: (request: LLMChatRequest) => string;
|
||||
private readonly queues: Map<string, MockLLMScriptTurn[]>;
|
||||
readonly calls: MockLLMCallRecord[] = [];
|
||||
|
||||
constructor(options: ScriptedMockLLMOptions) {
|
||||
this.resolveSession =
|
||||
options.resolveSession ??
|
||||
((request) => {
|
||||
const userMessage = [...request.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'user');
|
||||
const marker = userMessage?.content.match(/^\[session:([^\]]+)\]/);
|
||||
return marker?.[1] ?? 'main';
|
||||
});
|
||||
|
||||
this.queues = new Map();
|
||||
for (const step of options.steps) {
|
||||
const queue = this.queues.get(step.session) ?? [];
|
||||
queue.push(cloneTurn(step.turn));
|
||||
this.queues.set(step.session, queue);
|
||||
}
|
||||
}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
const session = this.resolveSession(request);
|
||||
const queue = this.queues.get(session) ?? [];
|
||||
const turn = queue.shift();
|
||||
this.queues.set(session, queue);
|
||||
|
||||
if (!turn) {
|
||||
throw new Error(`No scripted mock turn queued for session '${session}'`);
|
||||
}
|
||||
|
||||
const response: LLMChatResponse = {
|
||||
content: turn.content ?? null,
|
||||
toolCalls: turn.toolCalls ?? [],
|
||||
finishReason:
|
||||
turn.finishReason ?? ((turn.toolCalls?.length ?? 0) > 0 ? 'tool_calls' : 'stop'),
|
||||
usage: turn.usage ?? DEFAULT_USAGE,
|
||||
};
|
||||
|
||||
this.calls.push({
|
||||
session,
|
||||
request: structuredClone(request),
|
||||
response: structuredClone(response),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
assertExhausted(): void {
|
||||
for (const [session, queue] of this.queues.entries()) {
|
||||
if (queue.length > 0) {
|
||||
throw new Error(
|
||||
`Scripted mock still has ${queue.length} pending turn(s) for session '${session}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolCallSequence(session?: string): string[] {
|
||||
return this.calls
|
||||
.filter((record) => (session ? record.session === session : true))
|
||||
.flatMap((record) => record.response.toolCalls.map((toolCall) => toolCall.name));
|
||||
}
|
||||
}
|
||||
@@ -79,14 +79,3 @@ export class LLMConnectionError extends LLMError {
|
||||
this.name = 'LLMConnectionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** No provider is configured for the requested role. */
|
||||
export class LLMNoProviderError extends LLMError {
|
||||
readonly role: string;
|
||||
|
||||
constructor(role: string) {
|
||||
super(`No provider configured for role '${role}'`, 'gateway');
|
||||
this.name = 'LLMNoProviderError';
|
||||
this.role = role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
* LLM Gateway — the sole entry point for all business-layer LLM calls.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Look up model_role_assignments → provider_id + model for a given role
|
||||
* 2. Load (or cache) LLMProvider instances with decrypted API keys
|
||||
* 3. Route chat() calls to the correct adapter
|
||||
* 4. Invalidate cache when provider config changes via UI
|
||||
* 5. Concurrency control + retry-with-backoff for resilience
|
||||
* 1. Load (or cache) LLMProvider instances with decrypted API keys
|
||||
* 2. Route chat() calls to the correct adapter
|
||||
* 3. Invalidate cache when provider config changes via UI
|
||||
* 4. Concurrency control + retry-with-backoff for resilience
|
||||
*/
|
||||
|
||||
import { type ModelRole, modelRoleRepo } from '../db/repositories/model-role-repo';
|
||||
import { providerRepo } from '../db/repositories/provider-repo';
|
||||
import { secretRepo } from '../db/repositories/secret-repo';
|
||||
import { LLMAuthError, LLMError, LLMNoProviderError } from './errors';
|
||||
import { LLMAuthError, LLMError } from './errors';
|
||||
import { createAnthropicProvider } from './providers/anthropic';
|
||||
import type { LLMProvider } from './providers/base';
|
||||
import { createGeminiProvider } from './providers/gemini';
|
||||
@@ -53,28 +51,6 @@ export class LLMGateway {
|
||||
this.retryOptions = retryOptions ?? this.retryOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call LLM by business role. The role determines which provider + model to use.
|
||||
* The `model` field in the request is ignored — it's resolved from the DB assignment.
|
||||
*/
|
||||
async chatForRole(
|
||||
role: ModelRole,
|
||||
request: Omit<LLMChatRequest, 'model'>
|
||||
): Promise<LLMChatResponse> {
|
||||
const assignment = modelRoleRepo.getByRole(role);
|
||||
if (!assignment) throw new LLMNoProviderError(role);
|
||||
|
||||
return withResilience(
|
||||
this.semaphore,
|
||||
() => {
|
||||
const provider = this.getOrCreateProvider(assignment.provider_id);
|
||||
return provider.chat({ ...request, model: assignment.model });
|
||||
},
|
||||
this.retryOptions,
|
||||
role
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct call to a specific provider (used for connection testing).
|
||||
*/
|
||||
@@ -90,30 +66,6 @@ export class LLMGateway {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedding via the provider assigned to the 'embedding' role.
|
||||
*/
|
||||
async embedForRole(texts: string[]): Promise<number[][]> {
|
||||
const assignment = modelRoleRepo.getByRole('embedding');
|
||||
if (!assignment) throw new LLMNoProviderError('embedding');
|
||||
|
||||
return withResilience(
|
||||
this.semaphore,
|
||||
() => {
|
||||
const provider = this.getOrCreateProvider(assignment.provider_id);
|
||||
if (!provider.embed) {
|
||||
throw new LLMError(
|
||||
`Provider '${provider.type}' does not support embeddings`,
|
||||
provider.type
|
||||
);
|
||||
}
|
||||
return provider.embed(texts);
|
||||
},
|
||||
this.retryOptions,
|
||||
'embedding'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached provider instance (call when config/key changes via UI).
|
||||
*/
|
||||
|
||||
117
src/llm/runtime-e2e-mock.ts
Normal file
117
src/llm/runtime-e2e-mock.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { MainAgentModelClient } from '../agent-kernel/loop';
|
||||
import type { LLMChatRequest, LLMChatResponse, LLMToolCall } from './types';
|
||||
|
||||
const DEFAULT_USAGE = {
|
||||
promptTokens: 8,
|
||||
completionTokens: 8,
|
||||
totalTokens: 16,
|
||||
} as const;
|
||||
|
||||
function hasTool(request: LLMChatRequest, name: string): boolean {
|
||||
return (request.tools ?? []).some((tool) => tool.name === name);
|
||||
}
|
||||
|
||||
function toolResultNames(request: LLMChatRequest): string[] {
|
||||
const toolNameById = new Map<string, string>();
|
||||
const completed: string[] = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'assistant') {
|
||||
for (const call of message.toolCalls ?? []) {
|
||||
toolNameById.set(call.id, call.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role !== 'tool' || !message.toolCallId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(message.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolName = toolNameById.get(message.toolCallId);
|
||||
if (toolName) {
|
||||
completed.push(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
return completed;
|
||||
}
|
||||
|
||||
function toolCall(id: string, name: string, args: Record<string, unknown>): LLMToolCall {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
arguments: JSON.stringify(args),
|
||||
};
|
||||
}
|
||||
|
||||
function response(content: string | null, toolCalls: LLMToolCall[] = []): LLMChatResponse {
|
||||
return {
|
||||
content,
|
||||
toolCalls,
|
||||
finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
||||
usage: DEFAULT_USAGE,
|
||||
};
|
||||
}
|
||||
|
||||
export class RuntimeE2EMockLLM implements MainAgentModelClient {
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
const isMain = hasTool(request, 'spawn_subagent') && hasTool(request, 'submit_review_findings');
|
||||
const names = toolResultNames(request);
|
||||
|
||||
if (isMain) {
|
||||
if (!names.includes('read_file')) {
|
||||
return response(null, [
|
||||
toolCall('main-read-file', 'read_file', { path: 'src/user-handler.ts' }),
|
||||
]);
|
||||
}
|
||||
if (!names.includes('spawn_subagent')) {
|
||||
return response(null, [
|
||||
toolCall('main-spawn-subagent', 'spawn_subagent', {
|
||||
description: '检查高风险模式并提供证据',
|
||||
prompt: '请先搜索再读取目标文件,确认是否存在高风险问题并给出简短结论。',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
if (!names.includes('submit_review_findings')) {
|
||||
return response(null, [
|
||||
toolCall('main-submit-findings', 'submit_review_findings', {
|
||||
summaryMarkdown: '发现高风险安全问题,建议阻断合并并修复。',
|
||||
findings: [
|
||||
{
|
||||
fingerprint: 'security:src/user-handler.ts:107:avoid-eval',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.95,
|
||||
path: 'src/user-handler.ts',
|
||||
line: 107,
|
||||
title: '不安全的动态代码执行',
|
||||
detail: '直接对外部输入执行 eval 可能导致远程代码执行。',
|
||||
evidence: 'const config = eval(input.config);',
|
||||
suggestion: '移除 eval,改用白名单解析器或结构化配置。',
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return response('E2E mock review completed.');
|
||||
}
|
||||
|
||||
if (!names.includes('search_code')) {
|
||||
return response(null, [
|
||||
toolCall('sub-search-code', 'search_code', { query: 'eval(', maxResults: 5 }),
|
||||
]);
|
||||
}
|
||||
if (!names.includes('read_file')) {
|
||||
return response(null, [
|
||||
toolCall('sub-read-file', 'read_file', { path: 'src/user-handler.ts' }),
|
||||
]);
|
||||
}
|
||||
return response('子代理确认发现高风险 eval 用法。');
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,6 @@
|
||||
* Provider adapters translate to/from these types.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model Role
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Business role that maps to a specific provider + model via DB config. */
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
|
||||
|
||||
/** All valid model roles. */
|
||||
export const MODEL_ROLES: readonly ModelRole[] = [
|
||||
'planner',
|
||||
'specialist',
|
||||
'judge',
|
||||
'embedding',
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
172
src/review-agent/__tests__/deterministic-publish-adapter.test.ts
Normal file
172
src/review-agent/__tests__/deterministic-publish-adapter.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { applyDeterministicPublishAdapter } from '../deterministic-publish-adapter';
|
||||
import type { ReviewAgentFinding } from '../tools';
|
||||
|
||||
function makeFinding(overrides: Partial<ReviewAgentFinding> = {}): ReviewAgentFinding {
|
||||
return {
|
||||
fingerprint: '',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'src/app.ts',
|
||||
line: 42,
|
||||
title: 'SQL injection',
|
||||
detail: 'Unsanitized input',
|
||||
evidence: 'db.query(input)',
|
||||
suggestion: 'Use parameterized queries',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectedFingerprint(category: string, path: string, line: number, title: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${category}:${path}:${line}:${title}`)
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
describe('SHA256 fingerprint generation', () => {
|
||||
it('produces consistent 24-char hex fingerprint', () => {
|
||||
const fp = expectedFingerprint('security', 'src/app.ts', 42, 'SQL injection');
|
||||
expect(fp).toHaveLength(24);
|
||||
expect(fp).toMatch(/^[0-9a-f]{24}$/);
|
||||
});
|
||||
|
||||
it('produces different fingerprints for different inputs', () => {
|
||||
const fp1 = expectedFingerprint('security', 'src/app.ts', 42, 'SQL injection');
|
||||
const fp2 = expectedFingerprint('correctness', 'src/app.ts', 42, 'SQL injection');
|
||||
expect(fp1).not.toBe(fp2);
|
||||
});
|
||||
|
||||
it('produces same fingerprint for same inputs', () => {
|
||||
const fp1 = expectedFingerprint('security', 'src/app.ts', 42, 'SQL injection');
|
||||
const fp2 = expectedFingerprint('security', 'src/app.ts', 42, 'SQL injection');
|
||||
expect(fp1).toBe(fp2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDeterministicPublishAdapter deduplication', () => {
|
||||
it('dedupes findings with identical fingerprints keeping higher rank', async () => {
|
||||
const finding1 = makeFinding({ severity: 'low', confidence: 0.5, fingerprint: 'dup-fp' });
|
||||
const finding2 = makeFinding({ severity: 'high', confidence: 0.9, fingerprint: 'dup-fp' });
|
||||
|
||||
const store = {
|
||||
getRunDetails: async () => ({ findings: [], comments: [] }),
|
||||
addFindings: async () => {},
|
||||
addCommentRecord: async () => {},
|
||||
} as any;
|
||||
|
||||
const result = await applyDeterministicPublishAdapter({
|
||||
store,
|
||||
runId: 'test-run',
|
||||
submission: { summaryMarkdown: 'test', findings: [finding1, finding2] },
|
||||
});
|
||||
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.findings[0].severity).toBe('high');
|
||||
});
|
||||
|
||||
it('dedupes findings with same path/line/title (similarity key)', async () => {
|
||||
const finding1 = makeFinding({
|
||||
path: 'a.ts',
|
||||
line: 10,
|
||||
title: 'Bug',
|
||||
severity: 'medium',
|
||||
fingerprint: 'fp1',
|
||||
});
|
||||
const finding2 = makeFinding({
|
||||
path: 'a.ts',
|
||||
line: 10,
|
||||
title: 'Bug',
|
||||
severity: 'high',
|
||||
fingerprint: 'fp2',
|
||||
});
|
||||
|
||||
const store = {
|
||||
getRunDetails: async () => ({ findings: [], comments: [] }),
|
||||
addFindings: async () => {},
|
||||
addCommentRecord: async () => {},
|
||||
} as any;
|
||||
|
||||
const result = await applyDeterministicPublishAdapter({
|
||||
store,
|
||||
runId: 'test-run',
|
||||
submission: { summaryMarkdown: 'test', findings: [finding1, finding2] },
|
||||
});
|
||||
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.findings[0].severity).toBe('high');
|
||||
});
|
||||
|
||||
it('fingerprint migration: legacy colon-format matches new JSON tuple format', () => {
|
||||
const category = 'security';
|
||||
const path = 'src/auth.ts';
|
||||
const line = 42;
|
||||
const title = 'SQL injection';
|
||||
const legacy = createHash('sha256')
|
||||
.update(`${category}:${path}:${line}:${title}`)
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
const modern = createHash('sha256')
|
||||
.update(JSON.stringify([category, path, line, title]))
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
expect(legacy).not.toBe(modern);
|
||||
});
|
||||
|
||||
it('preserves published=true when migrating from legacy to modern fingerprint', async () => {
|
||||
const legacy = createHash('sha256')
|
||||
.update('security:src/auth.ts:42:SQL injection')
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
|
||||
const store = {
|
||||
getRunDetails: async () => ({
|
||||
findings: [
|
||||
{
|
||||
id: 'old-1',
|
||||
runId: 'run-migrate',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
path: 'src/auth.ts',
|
||||
line: 42,
|
||||
title: 'SQL injection',
|
||||
detail: 'Use parameterized queries.',
|
||||
evidence: '',
|
||||
suggestion: '',
|
||||
confidence: 0.9,
|
||||
fingerprint: legacy,
|
||||
published: true,
|
||||
},
|
||||
],
|
||||
comments: [],
|
||||
}),
|
||||
addFindings: async () => {},
|
||||
addCommentRecord: async () => {},
|
||||
} as any;
|
||||
|
||||
const result = await applyDeterministicPublishAdapter({
|
||||
store,
|
||||
runId: 'run-migrate',
|
||||
submission: {
|
||||
summaryMarkdown: 'Found SQL injection.',
|
||||
findings: [
|
||||
{
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
path: 'src/auth.ts',
|
||||
line: 42,
|
||||
title: 'SQL injection',
|
||||
detail: 'Use parameterized queries.',
|
||||
evidence: '',
|
||||
suggestion: '',
|
||||
confidence: 0.9,
|
||||
fingerprint: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.findings[0].published).toBe(true);
|
||||
});
|
||||
});
|
||||
721
src/review-agent/__tests__/review-entrypoint.test.ts
Normal file
721
src/review-agent/__tests__/review-entrypoint.test.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, rmSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { agentSessionRepository } from '../../agent-kernel/session';
|
||||
import { closeDatabase, getDatabase, initDatabase } from '../../db/database';
|
||||
import { ScriptedMockLLM, scriptedTurn } from '../../llm/e2e-mock';
|
||||
import { RuntimeE2EMockLLM } from '../../llm/runtime-e2e-mock';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../llm/types';
|
||||
import { FileReviewStore } from '../../review/store/file-review-store';
|
||||
import type { ReviewContext, ReviewRun } from '../../review/types';
|
||||
import { ReviewAgentEntrypoint } from '../review-entrypoint';
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
function makeRun(id: string): ReviewRun {
|
||||
return {
|
||||
id,
|
||||
idempotencyKey: 'octo/demo#7:base...head',
|
||||
eventType: 'pull_request',
|
||||
status: 'in_progress',
|
||||
owner: 'octo',
|
||||
repo: 'demo',
|
||||
cloneUrl: 'https://example.test/octo/demo.git',
|
||||
prNumber: 7,
|
||||
baseSha: 'base-sha',
|
||||
headSha: 'head-sha',
|
||||
commitSha: 'head-sha',
|
||||
attempts: 0,
|
||||
maxAttempts: 2,
|
||||
createdAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeReviewContext(workDir: string): ReviewContext {
|
||||
return {
|
||||
workspacePath: join(workDir, 'workspace'),
|
||||
mirrorPath: join(workDir, 'mirror.git'),
|
||||
diff: 'diff --git a/src/app.ts b/src/app.ts\n+throw new Error("boom")',
|
||||
changedFiles: [{ path: 'src/app.ts', status: 'M', additions: 1, deletions: 0 }],
|
||||
parsedDiff: [
|
||||
{
|
||||
path: 'src/app.ts',
|
||||
changes: [{ lineNumber: 12, content: '+throw new Error("boom")', type: 'add' }],
|
||||
},
|
||||
],
|
||||
fileContents: { 'src/app.ts': 'throw new Error("boom")' },
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntimeE2EContext(workDir: string): ReviewContext {
|
||||
return {
|
||||
workspacePath: join(workDir, 'workspace-runtime-e2e'),
|
||||
mirrorPath: join(workDir, 'mirror-runtime-e2e.git'),
|
||||
diff: 'diff --git a/src/user-handler.ts b/src/user-handler.ts\n+const config = eval(input.config);',
|
||||
changedFiles: [{ path: 'src/user-handler.ts', status: 'M', additions: 1, deletions: 0 }],
|
||||
parsedDiff: [
|
||||
{
|
||||
path: 'src/user-handler.ts',
|
||||
changes: [{ lineNumber: 107, content: '+const config = eval(input.config);', type: 'add' }],
|
||||
},
|
||||
],
|
||||
fileContents: {
|
||||
'src/user-handler.ts': [
|
||||
'export function handleUserRequest(input: any) {',
|
||||
' const userId = input.userId;',
|
||||
" const query = `SELECT * FROM users WHERE id = '${userId}'`;",
|
||||
' const config = eval(input.config);',
|
||||
' return { query, config };',
|
||||
'}',
|
||||
].join('\n'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReviewAgentEntrypoint', () => {
|
||||
let dbPath: string;
|
||||
let workDir: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = join(tmpdir(), `review-agent-entrypoint-${randomUUID()}`);
|
||||
mkdirSync(workDir, { recursive: true });
|
||||
dbPath = join(workDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
if (existsSync(workDir)) rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('starts exactly one main agent session and stores submitted findings', async () => {
|
||||
const run = makeRun(randomUUID());
|
||||
const store = new FileReviewStore(workDir);
|
||||
await store.init();
|
||||
const persisted = await store.createOrReuseRun({
|
||||
eventType: 'pull_request',
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
owner: run.owner,
|
||||
repo: run.repo,
|
||||
cloneUrl: run.cloneUrl,
|
||||
prNumber: run.prNumber!,
|
||||
baseSha: run.baseSha!,
|
||||
headSha: run.headSha!,
|
||||
});
|
||||
const reviewRun = persisted.run;
|
||||
const context = makeReviewContext(workDir);
|
||||
let prepared = 0;
|
||||
let cleaned = 0;
|
||||
let savedMirrorPath: string | undefined;
|
||||
let savedPrNumber: number | undefined;
|
||||
let savedBaseSha: string | undefined;
|
||||
let savedHeadSha: string | undefined;
|
||||
const localRepoManager = {
|
||||
prepareWorkspace: async () => {
|
||||
prepared += 1;
|
||||
return { mirrorPath: context.mirrorPath, workspacePath: context.workspacePath };
|
||||
},
|
||||
cleanupWorkspace: async () => {
|
||||
cleaned += 1;
|
||||
},
|
||||
resolveReviewedRef: async () => null,
|
||||
saveReviewedRef: async (
|
||||
mirrorPath: string,
|
||||
prNumber: number,
|
||||
baseSha: string,
|
||||
headSha: string
|
||||
) => {
|
||||
savedMirrorPath = mirrorPath;
|
||||
savedPrNumber = prNumber;
|
||||
savedBaseSha = baseSha;
|
||||
savedHeadSha = headSha;
|
||||
},
|
||||
};
|
||||
const diffExtractor = {
|
||||
buildContext: async () => context,
|
||||
};
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'read-1',
|
||||
name: 'read_file',
|
||||
arguments: JSON.stringify({ path: 'src/app.ts' }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Found one issue.',
|
||||
findings: [
|
||||
{
|
||||
fingerprint: 'fp-main-1',
|
||||
category: 'correctness',
|
||||
severity: 'high',
|
||||
confidence: 0.91,
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
title: 'Unhandled throw',
|
||||
detail: 'The changed line throws during normal execution.',
|
||||
evidence: '+throw new Error("boom")',
|
||||
suggestion: 'Return an error value instead.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'submitted' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const entrypoint = new ReviewAgentEntrypoint({
|
||||
store,
|
||||
localRepoManager: localRepoManager as never,
|
||||
diffExtractor: diffExtractor as never,
|
||||
modelClient: scriptedModel,
|
||||
model: 'fake-main-model',
|
||||
});
|
||||
|
||||
const result = await entrypoint.execute(reviewRun);
|
||||
|
||||
expect(result.status).toBe('submitted');
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.summaryMarkdown).toBe('Found one issue.');
|
||||
expect(prepared).toBe(1);
|
||||
expect(cleaned).toBe(1);
|
||||
expect(scriptedModel.calls).toHaveLength(3);
|
||||
expect(scriptedModel.calls[0].request.tools?.map((tool) => tool.name)).toEqual([
|
||||
'list_changed_files',
|
||||
'get_diff',
|
||||
'get_file_patch',
|
||||
'read_file',
|
||||
'search_code',
|
||||
'find_references',
|
||||
'submit_review_findings',
|
||||
'spawn_subagent',
|
||||
]);
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual(['read_file', 'submit_review_findings']);
|
||||
scriptedModel.assertExhausted();
|
||||
|
||||
const session = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(session?.agentType).toBe('review-main-agent');
|
||||
expect(session?.metadata).toMatchObject({
|
||||
reviewRunId: reviewRun.id,
|
||||
sessionScope: 'pr:octo/demo#7',
|
||||
owner: 'octo',
|
||||
repo: 'demo',
|
||||
prNumber: 7,
|
||||
eventType: 'pull_request',
|
||||
});
|
||||
expect(session?.toolCalls).toHaveLength(2);
|
||||
expect(session?.toolCalls.map((tc) => tc.toolName)).toEqual([
|
||||
'read_file',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(session?.invocations).toHaveLength(0);
|
||||
|
||||
const sessionCount = getDatabase()
|
||||
.query('SELECT COUNT(*) AS count FROM agent_sessions')
|
||||
.get() as { count: number };
|
||||
expect(sessionCount.count).toBe(1);
|
||||
const details = await store.getRunDetails(reviewRun.id);
|
||||
expect(details?.findings).toHaveLength(1);
|
||||
expect(details?.findings[0]).toMatchObject({
|
||||
runId: reviewRun.id,
|
||||
fingerprint: 'fp-main-1',
|
||||
published: false,
|
||||
});
|
||||
expect(details?.comments).toHaveLength(2);
|
||||
expect(details?.comments[0]).toMatchObject({
|
||||
runId: reviewRun.id,
|
||||
body: '## AI Agent代码审查结果\n\nFound one issue.\n\n发现 1 个问题(high 1 / medium 0 / low 0)',
|
||||
status: 'pending',
|
||||
});
|
||||
expect(details?.comments[1]).toMatchObject({
|
||||
runId: reviewRun.id,
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
fingerprint: 'fp-main-1',
|
||||
status: 'pending',
|
||||
});
|
||||
expect(savedMirrorPath).toBe(context.mirrorPath);
|
||||
expect(savedPrNumber).toBe(7);
|
||||
expect(savedBaseSha).toBe('base-sha');
|
||||
expect(savedHeadSha).toBe('head-sha');
|
||||
});
|
||||
|
||||
test('runtime e2e mock advances through main/subagent flow and persists outputs', async () => {
|
||||
const run = makeRun(randomUUID());
|
||||
const store = new FileReviewStore(workDir);
|
||||
await store.init();
|
||||
const persisted = await store.createOrReuseRun({
|
||||
eventType: 'pull_request',
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
owner: run.owner,
|
||||
repo: run.repo,
|
||||
cloneUrl: run.cloneUrl,
|
||||
prNumber: run.prNumber!,
|
||||
baseSha: run.baseSha!,
|
||||
headSha: run.headSha!,
|
||||
});
|
||||
const reviewRun = persisted.run;
|
||||
const context = makeRuntimeE2EContext(workDir);
|
||||
|
||||
const localRepoManager = {
|
||||
prepareWorkspace: async () => ({
|
||||
mirrorPath: context.mirrorPath,
|
||||
workspacePath: context.workspacePath,
|
||||
}),
|
||||
cleanupWorkspace: async () => {},
|
||||
resolveReviewedRef: async () => null,
|
||||
saveReviewedRef: async () => {},
|
||||
};
|
||||
const diffExtractor = {
|
||||
buildContext: async () => context,
|
||||
};
|
||||
|
||||
const entrypoint = new ReviewAgentEntrypoint({
|
||||
store,
|
||||
localRepoManager: localRepoManager as never,
|
||||
diffExtractor: diffExtractor as never,
|
||||
modelClient: new RuntimeE2EMockLLM(),
|
||||
model: 'runtime-e2e-main-model',
|
||||
});
|
||||
|
||||
const result = await entrypoint.execute(reviewRun);
|
||||
|
||||
expect(result.status).toBe('submitted');
|
||||
const session = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(session?.agentType).toBe('review-main-agent');
|
||||
expect(session?.toolCalls.map((tc) => tc.toolName)).toEqual([
|
||||
'read_file',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(session?.invocations).toHaveLength(1);
|
||||
expect(session?.invocations[0].status).toBe('completed');
|
||||
expect(session?.invocations[0].childSession?.toolCalls.map((tc) => tc.toolName)).toEqual([
|
||||
'search_code',
|
||||
'read_file',
|
||||
]);
|
||||
|
||||
const details = await store.getRunDetails(reviewRun.id);
|
||||
expect(details?.findings.length).toBeGreaterThan(0);
|
||||
expect(details?.comments.length).toBeGreaterThan(0);
|
||||
expect(details?.findings[0]).toMatchObject({
|
||||
path: 'src/user-handler.ts',
|
||||
severity: 'high',
|
||||
published: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('dedupes similar findings and keeps comment intents idempotent across retries', async () => {
|
||||
const run = makeRun(randomUUID());
|
||||
const store = new FileReviewStore(workDir);
|
||||
await store.init();
|
||||
const persisted = await store.createOrReuseRun({
|
||||
eventType: 'pull_request',
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
owner: run.owner,
|
||||
repo: run.repo,
|
||||
cloneUrl: run.cloneUrl,
|
||||
prNumber: run.prNumber!,
|
||||
baseSha: run.baseSha!,
|
||||
headSha: run.headSha!,
|
||||
});
|
||||
const reviewRun = persisted.run;
|
||||
const context = makeReviewContext(workDir);
|
||||
|
||||
const submissionArgs = {
|
||||
summaryMarkdown: 'Potential runtime risks found.',
|
||||
findings: [
|
||||
{
|
||||
fingerprint: 'fp-dup-1',
|
||||
category: 'reliability',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
title: 'Unhandled throw in request path',
|
||||
detail: 'Throw escapes normal request handling.',
|
||||
evidence: '+throw new Error("boom")',
|
||||
suggestion: 'Return typed failure.',
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-dup-2',
|
||||
category: 'reliability',
|
||||
severity: 'medium',
|
||||
confidence: 0.8,
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
title: 'Unhandled throw in request path',
|
||||
detail: 'Same root cause with lower severity.',
|
||||
evidence: '+throw new Error("boom")',
|
||||
suggestion: 'Use result object.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fakeModel = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify(submissionArgs),
|
||||
},
|
||||
],
|
||||
}),
|
||||
response({ content: 'submitted-1' }),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'submit-2',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify(submissionArgs),
|
||||
},
|
||||
],
|
||||
}),
|
||||
response({ content: 'submitted-2' }),
|
||||
]);
|
||||
|
||||
const localRepoManager = {
|
||||
prepareWorkspace: async () => ({
|
||||
mirrorPath: context.mirrorPath,
|
||||
workspacePath: context.workspacePath,
|
||||
}),
|
||||
cleanupWorkspace: async () => undefined,
|
||||
resolveReviewedRef: async () => null,
|
||||
saveReviewedRef: async () => undefined,
|
||||
};
|
||||
const diffExtractor = { buildContext: async () => context };
|
||||
|
||||
const entrypoint = new ReviewAgentEntrypoint({
|
||||
store,
|
||||
localRepoManager: localRepoManager as never,
|
||||
diffExtractor: diffExtractor as never,
|
||||
modelClient: fakeModel,
|
||||
model: 'fake-main-model',
|
||||
});
|
||||
|
||||
await entrypoint.execute(reviewRun);
|
||||
await entrypoint.execute(reviewRun);
|
||||
|
||||
const details = await store.getRunDetails(reviewRun.id);
|
||||
expect(details?.findings).toHaveLength(1);
|
||||
expect(details?.findings[0]).toMatchObject({
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
title: 'Unhandled throw in request path',
|
||||
severity: 'high',
|
||||
});
|
||||
expect(details?.comments).toHaveLength(2);
|
||||
expect(details?.comments.filter((comment) => !comment.path)).toHaveLength(1);
|
||||
expect(details?.comments.filter((comment) => comment.path)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('publishes line-intents for high/medium only and keeps low severity in summary', async () => {
|
||||
const run = makeRun(randomUUID());
|
||||
const store = new FileReviewStore(workDir);
|
||||
await store.init();
|
||||
const persisted = await store.createOrReuseRun({
|
||||
eventType: 'pull_request',
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
owner: run.owner,
|
||||
repo: run.repo,
|
||||
cloneUrl: run.cloneUrl,
|
||||
prNumber: run.prNumber!,
|
||||
baseSha: run.baseSha!,
|
||||
headSha: run.headSha!,
|
||||
});
|
||||
const reviewRun = persisted.run;
|
||||
const context = makeReviewContext(workDir);
|
||||
|
||||
const fakeModel = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'submit-severity',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Three candidates reported.',
|
||||
findings: [
|
||||
{
|
||||
fingerprint: 'fp-high',
|
||||
category: 'correctness',
|
||||
severity: 'high',
|
||||
confidence: 0.95,
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
title: 'High issue',
|
||||
detail: 'High severity detail',
|
||||
evidence: 'e1',
|
||||
suggestion: 's1',
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-medium',
|
||||
category: 'reliability',
|
||||
severity: 'medium',
|
||||
confidence: 0.88,
|
||||
path: 'src/app.ts',
|
||||
line: 13,
|
||||
title: 'Medium issue',
|
||||
detail: 'Medium severity detail',
|
||||
evidence: 'e2',
|
||||
suggestion: 's2',
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-low',
|
||||
category: 'maintainability',
|
||||
severity: 'low',
|
||||
confidence: 0.8,
|
||||
path: 'src/app.ts',
|
||||
line: 14,
|
||||
title: 'Low issue',
|
||||
detail: 'Low severity detail',
|
||||
evidence: 'e3',
|
||||
suggestion: 's3',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
response({ content: 'submitted' }),
|
||||
]);
|
||||
|
||||
const localRepoManager = {
|
||||
prepareWorkspace: async () => ({
|
||||
mirrorPath: context.mirrorPath,
|
||||
workspacePath: context.workspacePath,
|
||||
}),
|
||||
cleanupWorkspace: async () => undefined,
|
||||
resolveReviewedRef: async () => null,
|
||||
saveReviewedRef: async () => undefined,
|
||||
};
|
||||
const diffExtractor = { buildContext: async () => context };
|
||||
|
||||
const entrypoint = new ReviewAgentEntrypoint({
|
||||
store,
|
||||
localRepoManager: localRepoManager as never,
|
||||
diffExtractor: diffExtractor as never,
|
||||
modelClient: fakeModel,
|
||||
model: 'fake-main-model',
|
||||
});
|
||||
|
||||
await entrypoint.execute(reviewRun);
|
||||
|
||||
const details = await store.getRunDetails(reviewRun.id);
|
||||
expect(details?.findings).toHaveLength(3);
|
||||
const summaryRecords = details?.comments.filter((comment) => !comment.path) ?? [];
|
||||
const lineRecords = details?.comments.filter((comment) => !!comment.path) ?? [];
|
||||
expect(summaryRecords).toHaveLength(1);
|
||||
expect(lineRecords).toHaveLength(2);
|
||||
expect(lineRecords.map((record) => record.fingerprint).sort()).toEqual([
|
||||
'fp-high',
|
||||
'fp-medium',
|
||||
]);
|
||||
expect(lineRecords.some((record) => record.fingerprint === 'fp-low')).toBe(false);
|
||||
expect(summaryRecords[0].body).toContain('high 1 / medium 1 / low 1');
|
||||
});
|
||||
|
||||
test('retry does not duplicate pending summary or existing line comment records', async () => {
|
||||
const run = makeRun(randomUUID());
|
||||
const store = new FileReviewStore(workDir);
|
||||
await store.init();
|
||||
const persisted = await store.createOrReuseRun({
|
||||
eventType: 'pull_request',
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
owner: run.owner,
|
||||
repo: run.repo,
|
||||
cloneUrl: run.cloneUrl,
|
||||
prNumber: run.prNumber!,
|
||||
baseSha: run.baseSha!,
|
||||
headSha: run.headSha!,
|
||||
});
|
||||
const reviewRun = persisted.run;
|
||||
const context = makeReviewContext(workDir);
|
||||
|
||||
const summaryBody =
|
||||
'## AI Agent代码审查结果\n\nSeeded summary body\n\n发现 2 个问题(high 1 / medium 1 / low 0)';
|
||||
const lineBodyHigh =
|
||||
'**[HIGH][correctness]** Seeded high issue\n\nHigh detail\n\n建议: High suggestion';
|
||||
|
||||
await store.addCommentRecord({ runId: reviewRun.id, body: summaryBody, status: 'pending' });
|
||||
await store.addCommentRecord({
|
||||
runId: reviewRun.id,
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
body: lineBodyHigh,
|
||||
status: 'published',
|
||||
fingerprint: 'fp-seeded-high',
|
||||
});
|
||||
|
||||
const fakeModel = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'submit-retry-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Seeded summary body',
|
||||
findings: [
|
||||
{
|
||||
fingerprint: 'fp-seeded-high',
|
||||
category: 'correctness',
|
||||
severity: 'high',
|
||||
confidence: 0.95,
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
title: 'Seeded high issue',
|
||||
detail: 'High detail',
|
||||
evidence: 'e1',
|
||||
suggestion: 'High suggestion',
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-new-medium',
|
||||
category: 'reliability',
|
||||
severity: 'medium',
|
||||
confidence: 0.83,
|
||||
path: 'src/app.ts',
|
||||
line: 20,
|
||||
title: 'New medium issue',
|
||||
detail: 'Medium detail',
|
||||
evidence: 'e2',
|
||||
suggestion: 'Medium suggestion',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
response({ content: 'submitted-1' }),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'submit-retry-2',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Seeded summary body',
|
||||
findings: [
|
||||
{
|
||||
fingerprint: 'fp-seeded-high',
|
||||
category: 'correctness',
|
||||
severity: 'high',
|
||||
confidence: 0.95,
|
||||
path: 'src/app.ts',
|
||||
line: 12,
|
||||
title: 'Seeded high issue',
|
||||
detail: 'High detail',
|
||||
evidence: 'e1',
|
||||
suggestion: 'High suggestion',
|
||||
},
|
||||
{
|
||||
fingerprint: 'fp-new-medium',
|
||||
category: 'reliability',
|
||||
severity: 'medium',
|
||||
confidence: 0.83,
|
||||
path: 'src/app.ts',
|
||||
line: 20,
|
||||
title: 'New medium issue',
|
||||
detail: 'Medium detail',
|
||||
evidence: 'e2',
|
||||
suggestion: 'Medium suggestion',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
response({ content: 'submitted-2' }),
|
||||
]);
|
||||
|
||||
const localRepoManager = {
|
||||
prepareWorkspace: async () => ({
|
||||
mirrorPath: context.mirrorPath,
|
||||
workspacePath: context.workspacePath,
|
||||
}),
|
||||
cleanupWorkspace: async () => undefined,
|
||||
resolveReviewedRef: async () => null,
|
||||
saveReviewedRef: async () => undefined,
|
||||
};
|
||||
const diffExtractor = { buildContext: async () => context };
|
||||
|
||||
const entrypoint = new ReviewAgentEntrypoint({
|
||||
store,
|
||||
localRepoManager: localRepoManager as never,
|
||||
diffExtractor: diffExtractor as never,
|
||||
modelClient: fakeModel,
|
||||
model: 'fake-main-model',
|
||||
});
|
||||
|
||||
await entrypoint.execute(reviewRun);
|
||||
await entrypoint.execute(reviewRun);
|
||||
|
||||
const details = await store.getRunDetails(reviewRun.id);
|
||||
const summaryRecords = details?.comments.filter((comment) => !comment.path) ?? [];
|
||||
const lineRecords = details?.comments.filter((comment) => !!comment.path) ?? [];
|
||||
expect(summaryRecords).toHaveLength(1);
|
||||
expect(lineRecords).toHaveLength(2);
|
||||
expect(lineRecords.filter((comment) => comment.fingerprint === 'fp-seeded-high')).toHaveLength(
|
||||
1
|
||||
);
|
||||
expect(lineRecords.filter((comment) => comment.fingerprint === 'fp-new-medium')).toHaveLength(
|
||||
1
|
||||
);
|
||||
});
|
||||
});
|
||||
194
src/review-agent/deterministic-publish-adapter.ts
Normal file
194
src/review-agent/deterministic-publish-adapter.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { applyPublishPolicy } from '../review/policy/publish-policy';
|
||||
import type { FileReviewStore } from '../review/store/file-review-store';
|
||||
import type { Finding, ReviewCommentRecord } from '../review/types';
|
||||
import type { ReviewAgentFinding, SubmittedReviewFindings } from './tools';
|
||||
|
||||
interface PublishIntent {
|
||||
path?: string;
|
||||
line?: number;
|
||||
body: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface DeterministicPublishResult {
|
||||
findings: Finding[];
|
||||
summaryBody: string;
|
||||
lineIntents: PublishIntent[];
|
||||
}
|
||||
|
||||
function severityWeight(severity: Finding['severity']): number {
|
||||
if (severity === 'high') return 3;
|
||||
if (severity === 'medium') return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function rankFinding(finding: ReviewAgentFinding): number {
|
||||
return severityWeight(finding.severity) * 1000 + Math.round(finding.confidence * 100);
|
||||
}
|
||||
|
||||
function buildFingerprint(category: string, path: string, line: number, title: string): string {
|
||||
return createHash('sha256')
|
||||
.update(JSON.stringify([category, path, line, title]))
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
function buildLegacyFingerprint(
|
||||
category: string,
|
||||
path: string,
|
||||
line: number,
|
||||
title: string
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(`${category}:${path}:${line}:${title}`)
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
function normalizeTitleRoot(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function similarityKey(finding: ReviewAgentFinding): string {
|
||||
return `${finding.path}:${finding.line}:${normalizeTitleRoot(finding.title)}`;
|
||||
}
|
||||
|
||||
function dedupeFindings(candidates: ReviewAgentFinding[]): ReviewAgentFinding[] {
|
||||
const ensured = candidates.map((f) =>
|
||||
f.fingerprint ? f : { ...f, fingerprint: buildFingerprint(f.category, f.path, f.line, f.title) }
|
||||
);
|
||||
const byFingerprint = new Map<string, ReviewAgentFinding>();
|
||||
for (const finding of ensured) {
|
||||
const existing = byFingerprint.get(finding.fingerprint);
|
||||
if (!existing || rankFinding(finding) > rankFinding(existing)) {
|
||||
byFingerprint.set(finding.fingerprint, finding);
|
||||
}
|
||||
}
|
||||
|
||||
const bySimilarity = new Map<string, ReviewAgentFinding>();
|
||||
for (const finding of byFingerprint.values()) {
|
||||
const key = similarityKey(finding);
|
||||
const existing = bySimilarity.get(key);
|
||||
if (!existing || rankFinding(finding) > rankFinding(existing)) {
|
||||
bySimilarity.set(key, finding);
|
||||
}
|
||||
}
|
||||
|
||||
return [...bySimilarity.values()].sort((a, b) => rankFinding(b) - rankFinding(a));
|
||||
}
|
||||
|
||||
function findingToLineComment(finding: ReviewAgentFinding): string {
|
||||
return `**[${finding.severity.toUpperCase()}][${finding.category}]** ${finding.title}\n\n${finding.detail}\n\n建议: ${finding.suggestion}`;
|
||||
}
|
||||
|
||||
function countBySeverity(
|
||||
findings: ReviewAgentFinding[]
|
||||
): Record<'high' | 'medium' | 'low', number> {
|
||||
const counts = { high: 0, medium: 0, low: 0 };
|
||||
for (const finding of findings) counts[finding.severity] += 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
function buildSummaryBody(summaryMarkdown: string, findings: ReviewAgentFinding[]): string {
|
||||
const trimmed = summaryMarkdown.trim();
|
||||
const counts = countBySeverity(findings);
|
||||
const stats = `发现 ${findings.length} 个问题(high ${counts.high} / medium ${counts.medium} / low ${counts.low})`;
|
||||
|
||||
if (!trimmed) {
|
||||
const fallback = findings.length
|
||||
? findings
|
||||
.slice(0, 5)
|
||||
.map((f) => `- [${f.severity}] ${f.path}:${f.line} ${f.title}`)
|
||||
.join('\n')
|
||||
: '- 未发现需要处理的问题。';
|
||||
return `## AI Agent代码审查结果\n\n${stats}\n\n${fallback}`;
|
||||
}
|
||||
|
||||
return `## AI Agent代码审查结果\n\n${trimmed}\n\n${stats}`;
|
||||
}
|
||||
|
||||
function intentKey(intent: PublishIntent): string {
|
||||
return `${intent.path ?? ''}:${intent.line ?? 0}:${intent.fingerprint ?? ''}:${intent.body}`;
|
||||
}
|
||||
|
||||
function existingIntentKeys(comments: ReviewCommentRecord[]): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const comment of comments) {
|
||||
keys.add(
|
||||
intentKey({
|
||||
path: comment.path,
|
||||
line: comment.line,
|
||||
fingerprint: comment.fingerprint,
|
||||
body: comment.body,
|
||||
})
|
||||
);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export async function applyDeterministicPublishAdapter(params: {
|
||||
store: FileReviewStore;
|
||||
runId: string;
|
||||
submission: SubmittedReviewFindings;
|
||||
}): Promise<DeterministicPublishResult> {
|
||||
const normalized = dedupeFindings(params.submission.findings);
|
||||
const details = await params.store.getRunDetails(params.runId);
|
||||
const existingPublished = new Map<string, boolean>();
|
||||
function rememberPublished(key: string, published: boolean): void {
|
||||
existingPublished.set(key, (existingPublished.get(key) ?? false) || published);
|
||||
}
|
||||
for (const finding of details?.findings ?? []) {
|
||||
const modern = buildFingerprint(finding.category, finding.path, finding.line, finding.title);
|
||||
const legacy = buildLegacyFingerprint(
|
||||
finding.category,
|
||||
finding.path,
|
||||
finding.line,
|
||||
finding.title
|
||||
);
|
||||
rememberPublished(finding.fingerprint, finding.published);
|
||||
rememberPublished(modern, finding.published);
|
||||
rememberPublished(legacy, finding.published);
|
||||
}
|
||||
|
||||
const findings: Finding[] = normalized.map((finding) => ({
|
||||
...finding,
|
||||
id: randomUUID(),
|
||||
runId: params.runId,
|
||||
published: existingPublished.get(finding.fingerprint) ?? false,
|
||||
}));
|
||||
await params.store.addFindings(params.runId, findings);
|
||||
|
||||
const summaryBody = buildSummaryBody(params.submission.summaryMarkdown, normalized);
|
||||
const lineEligible = applyPublishPolicy(normalized).publishable;
|
||||
const lineIntents: PublishIntent[] = lineEligible.map((finding) => ({
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
body: findingToLineComment(finding),
|
||||
fingerprint: finding.fingerprint,
|
||||
}));
|
||||
|
||||
const intents: PublishIntent[] = [{ body: summaryBody }, ...lineIntents];
|
||||
const existingKeys = existingIntentKeys(details?.comments ?? []);
|
||||
for (const intent of intents) {
|
||||
if (existingKeys.has(intentKey(intent))) continue;
|
||||
await params.store.addCommentRecord({
|
||||
runId: params.runId,
|
||||
path: intent.path,
|
||||
line: intent.line,
|
||||
body: intent.body,
|
||||
status: 'pending',
|
||||
fingerprint: intent.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
findings,
|
||||
summaryBody,
|
||||
lineIntents,
|
||||
};
|
||||
}
|
||||
4
src/review-agent/index.ts
Normal file
4
src/review-agent/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './review-entrypoint';
|
||||
export { applyDeterministicPublishAdapter } from './deterministic-publish-adapter';
|
||||
export { createReviewTaskTools, normalizeSubmission } from './tools';
|
||||
export type { CreateReviewTaskToolsOptions, ReviewToolState } from './tools';
|
||||
425
src/review-agent/review-entrypoint.ts
Normal file
425
src/review-agent/review-entrypoint.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { type AgentDefinition, createAgentRegistry } from '../agent-kernel/definitions';
|
||||
import {
|
||||
type MainAgentModelClient,
|
||||
MainAgentRunner,
|
||||
type MainAgentTool,
|
||||
} from '../agent-kernel/loop';
|
||||
import { type AgentSessionRepository, agentSessionRepository } from '../agent-kernel/session';
|
||||
import { SubagentRunner } from '../agent-kernel/subagents/subagent-runner';
|
||||
import { createSpawnSubagentTool } from '../agent-kernel/tools';
|
||||
import config from '../config';
|
||||
import { DiffExtractor } from '../review/context/diff-extractor';
|
||||
import type { LocalRepoPaths } from '../review/context/local-repo-manager';
|
||||
import { LocalRepoManager } from '../review/context/local-repo-manager';
|
||||
import { tokenCounter } from '../review/context/token-counter';
|
||||
import { FileReviewStore } from '../review/store/file-review-store';
|
||||
import type { Finding, ReviewContext, ReviewRun } from '../review/types';
|
||||
import { logger } from '../utils/logger';
|
||||
import { applyDeterministicPublishAdapter } from './deterministic-publish-adapter';
|
||||
import { buildRepairPrompt, parseFindingResponse } from './schema';
|
||||
import {
|
||||
type ReviewToolState,
|
||||
type SubmittedReviewFindings,
|
||||
createReviewTaskTools,
|
||||
normalizeSubmission,
|
||||
parseSubmissionFromText,
|
||||
} from './tools';
|
||||
|
||||
export interface ReviewAgentRepositoryContext {
|
||||
owner: string;
|
||||
repo: string;
|
||||
cloneUrl: string;
|
||||
headCloneUrl?: string;
|
||||
}
|
||||
|
||||
export interface ReviewAgentPullRequestContext {
|
||||
prNumber?: number;
|
||||
relatedPrNumber?: number;
|
||||
baseSha?: string;
|
||||
headSha?: string;
|
||||
commitSha?: string;
|
||||
commitMessage?: string;
|
||||
}
|
||||
|
||||
export interface ReviewAgentRunContext {
|
||||
reviewRunId: string;
|
||||
sessionScope: string;
|
||||
eventType: ReviewRun['eventType'];
|
||||
repository: ReviewAgentRepositoryContext;
|
||||
pullRequest: ReviewAgentPullRequestContext;
|
||||
workspace?: Pick<ReviewContext, 'workspacePath' | 'mirrorPath'>;
|
||||
diffSummary?: {
|
||||
changedFiles: ReviewContext['changedFiles'];
|
||||
diff?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReviewAgentEntrypointResult {
|
||||
status: 'submitted' | 'completed_without_submission';
|
||||
sessionId: string;
|
||||
reviewRunId: string;
|
||||
sessionScope: string;
|
||||
summaryMarkdown: string;
|
||||
findings: Finding[];
|
||||
finalText?: string;
|
||||
}
|
||||
|
||||
interface ReviewAgentEntrypointOptions {
|
||||
store: FileReviewStore;
|
||||
localRepoManager: LocalRepoManager;
|
||||
diffExtractor: DiffExtractor;
|
||||
modelClient: MainAgentModelClient;
|
||||
transcriptRepository?: AgentSessionRepository;
|
||||
model?: string;
|
||||
runnerFactory?: (tools: MainAgentTool[]) => MainAgentRunner;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function buildSessionScope(run: ReviewRun): string {
|
||||
if (run.prNumber) {
|
||||
return `pr:${run.owner}/${run.repo}#${run.prNumber}`;
|
||||
}
|
||||
if (run.relatedPrNumber) {
|
||||
return `pr:${run.owner}/${run.repo}#${run.relatedPrNumber}`;
|
||||
}
|
||||
return `commit:${run.owner}/${run.repo}@${run.commitSha ?? run.headSha ?? run.id}`;
|
||||
}
|
||||
|
||||
function tryParseFinalSubmission(text?: string): SubmittedReviewFindings | null {
|
||||
if (!text) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const result = normalizeSubmission(parsed);
|
||||
if (result.findings.length > 0 || result.summaryMarkdown) return result;
|
||||
}
|
||||
} catch {}
|
||||
return parseSubmissionFromText(text);
|
||||
}
|
||||
|
||||
function buildReviewPrompt(context: ReviewAgentRunContext, model?: string): string {
|
||||
const changedFiles = context.diffSummary?.changedFiles ?? [];
|
||||
const fileSummary = changedFiles
|
||||
.map((file) => `- ${file.status} ${file.path} (+${file.additions}/-${file.deletions})`)
|
||||
.join('\n');
|
||||
const rawDiff = context.diffSummary?.diff?.trim() || '(diff omitted)';
|
||||
|
||||
const usableBudget = model ? tokenCounter.getUsableBudget(model) : 124_000;
|
||||
const reservedForPrompt = 4000;
|
||||
const diffBudget = Math.max(1000, usableBudget - reservedForPrompt);
|
||||
const diff =
|
||||
tokenCounter.count(rawDiff) > diffBudget ? tokenCounter.clip(rawDiff, diffBudget) : rawDiff;
|
||||
|
||||
return [
|
||||
'You are the main code review agent for Gitea AI Assistant.',
|
||||
'Review only the supplied change context and report actionable correctness, security, reliability, or maintainability findings.',
|
||||
'When finished, call submit_review_findings exactly once with summaryMarkdown and findings. If no issues are found, submit an empty findings array.',
|
||||
'',
|
||||
`Review run: ${context.reviewRunId}`,
|
||||
`Session scope: ${context.sessionScope}`,
|
||||
`Repository: ${context.repository.owner}/${context.repository.repo}`,
|
||||
`Event type: ${context.eventType}`,
|
||||
`PR: ${context.pullRequest.prNumber ?? context.pullRequest.relatedPrNumber ?? 'n/a'}`,
|
||||
`Base SHA: ${context.pullRequest.baseSha ?? 'n/a'}`,
|
||||
`Head SHA: ${context.pullRequest.headSha ?? context.pullRequest.commitSha ?? 'n/a'}`,
|
||||
'',
|
||||
'Changed files:',
|
||||
fileSummary || '- none',
|
||||
'',
|
||||
'Diff:',
|
||||
diff,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function defaultSubagentDefinition(): AgentDefinition {
|
||||
return {
|
||||
agentType: 'general-purpose',
|
||||
name: 'General Purpose Review Subagent',
|
||||
whenToUse: 'Use for delegated focused code checks from the review main agent.',
|
||||
source: 'built-in',
|
||||
tools: ['search_code', 'read_file', 'find_references', 'get_file_patch'],
|
||||
disallowedTools: [],
|
||||
skills: [],
|
||||
hooks: {},
|
||||
maxTurns: 4,
|
||||
permissionMode: 'default',
|
||||
background: false,
|
||||
isolation: 'none',
|
||||
getSystemPrompt: () =>
|
||||
'You are a focused code-review subagent. Read only provided context, use tools deterministically, and return concise risk-focused findings summary.',
|
||||
};
|
||||
}
|
||||
|
||||
export class ReviewAgentEntrypoint {
|
||||
private readonly store: FileReviewStore;
|
||||
private readonly localRepoManager: LocalRepoManager;
|
||||
private readonly diffExtractor: DiffExtractor;
|
||||
private readonly transcriptRepository: AgentSessionRepository;
|
||||
private readonly modelClient: MainAgentModelClient;
|
||||
private readonly model: string;
|
||||
private readonly runnerFactory: (tools: MainAgentTool[]) => MainAgentRunner;
|
||||
|
||||
constructor(options: ReviewAgentEntrypointOptions) {
|
||||
this.store = options.store;
|
||||
this.localRepoManager = options.localRepoManager;
|
||||
this.diffExtractor = options.diffExtractor;
|
||||
this.modelClient = options.modelClient;
|
||||
this.transcriptRepository = options.transcriptRepository ?? agentSessionRepository;
|
||||
this.model = options.model ?? config.review.agentMainModel;
|
||||
this.runnerFactory =
|
||||
options.runnerFactory ??
|
||||
((tools) =>
|
||||
new MainAgentRunner({
|
||||
modelClient: this.modelClient,
|
||||
transcriptRepository: this.transcriptRepository,
|
||||
tools,
|
||||
}));
|
||||
}
|
||||
|
||||
async execute(run: ReviewRun): Promise<ReviewAgentEntrypointResult> {
|
||||
const targetSha = run.headSha || run.commitSha;
|
||||
if (!targetSha) throw new Error('缺少 target sha,无法启动主 Agent 审查');
|
||||
|
||||
const workspaceStart = Date.now();
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'prepare_workspace',
|
||||
status: 'started',
|
||||
startedAt: new Date(workspaceStart).toISOString(),
|
||||
});
|
||||
|
||||
let repoPaths: LocalRepoPaths | null = null;
|
||||
try {
|
||||
repoPaths = await this.localRepoManager.prepareWorkspace(
|
||||
run.owner,
|
||||
run.repo,
|
||||
run.cloneUrl,
|
||||
targetSha,
|
||||
run.id,
|
||||
run.headCloneUrl
|
||||
);
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'prepare_workspace',
|
||||
status: 'succeeded',
|
||||
startedAt: new Date(workspaceStart).toISOString(),
|
||||
finishedAt: nowIso(),
|
||||
latencyMs: Date.now() - workspaceStart,
|
||||
});
|
||||
|
||||
const lastReviewedHead = await this.resolveLastReviewedHead(
|
||||
run,
|
||||
repoPaths.mirrorPath,
|
||||
targetSha
|
||||
);
|
||||
const contextStart = Date.now();
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'build_context',
|
||||
status: 'started',
|
||||
startedAt: new Date(contextStart).toISOString(),
|
||||
});
|
||||
const reviewContext = await this.diffExtractor.buildContext(
|
||||
run,
|
||||
repoPaths.mirrorPath,
|
||||
repoPaths.workspacePath,
|
||||
lastReviewedHead
|
||||
);
|
||||
await this.store.addStep({
|
||||
runId: run.id,
|
||||
stepName: 'build_context',
|
||||
status: 'succeeded',
|
||||
startedAt: new Date(contextStart).toISOString(),
|
||||
finishedAt: nowIso(),
|
||||
latencyMs: Date.now() - contextStart,
|
||||
});
|
||||
|
||||
if (!reviewContext.diff.trim()) {
|
||||
await this.store.addCommentRecord({
|
||||
runId: run.id,
|
||||
body: '本次变更无可审查差异内容,已跳过自动行级评论。',
|
||||
status: 'pending',
|
||||
});
|
||||
await this.store.markRunIgnored(run.id, '无可审查差异');
|
||||
return {
|
||||
status: 'completed_without_submission',
|
||||
sessionId: '',
|
||||
reviewRunId: run.id,
|
||||
sessionScope: buildSessionScope(run),
|
||||
summaryMarkdown: '',
|
||||
findings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const result = await this.runMainAgent(run, reviewContext);
|
||||
if (run.eventType === 'pull_request' && run.prNumber && run.baseSha) {
|
||||
await this.localRepoManager.saveReviewedRef(
|
||||
repoPaths.mirrorPath,
|
||||
run.prNumber,
|
||||
run.baseSha,
|
||||
targetSha
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
if (repoPaths) {
|
||||
await this.localRepoManager.cleanupWorkspace(repoPaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveLastReviewedHead(
|
||||
run: ReviewRun,
|
||||
mirrorPath: string,
|
||||
targetSha: string
|
||||
): Promise<string | undefined> {
|
||||
if (run.eventType !== 'pull_request' || !run.prNumber) return undefined;
|
||||
const snapshot = await this.localRepoManager.resolveReviewedRef(mirrorPath, run.prNumber);
|
||||
if (!snapshot || snapshot.baseSha !== run.baseSha || snapshot.headSha === targetSha)
|
||||
return undefined;
|
||||
return snapshot.headSha;
|
||||
}
|
||||
|
||||
private async repairFindingsWithLLM(
|
||||
rawText: string,
|
||||
messages: Array<{ role: string; content: string; toolCalls?: unknown }>,
|
||||
maxAttempts = 2
|
||||
): Promise<string> {
|
||||
let current = rawText;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const outcome = parseFindingResponse(current);
|
||||
if (outcome.ok) return current;
|
||||
const repairPrompt = buildRepairPrompt(outcome.error);
|
||||
messages.push({ role: 'assistant', content: current });
|
||||
messages.push({ role: 'user', content: repairPrompt });
|
||||
const response = await this.modelClient.chat({
|
||||
messages: messages as import('../llm/types').LLMMessage[],
|
||||
model: this.model,
|
||||
temperature: 0,
|
||||
responseFormat: 'json',
|
||||
});
|
||||
current = response.content ?? '{"findings":[]}';
|
||||
logger.info('LLM finding repair attempt', {
|
||||
attempt: attempt + 1,
|
||||
error: outcome.error.slice(0, 100),
|
||||
});
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private async runMainAgent(
|
||||
run: ReviewRun,
|
||||
reviewContext: ReviewContext
|
||||
): Promise<ReviewAgentEntrypointResult> {
|
||||
const sessionScope = buildSessionScope(run);
|
||||
const reviewToolState: ReviewToolState = { submittedReview: null };
|
||||
const reviewTools = createReviewTaskTools({ reviewContext, state: reviewToolState });
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: this.modelClient,
|
||||
transcriptRepository: this.transcriptRepository,
|
||||
tools: reviewTools,
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [defaultSubagentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: config.review.agentDefaultSubagentModel,
|
||||
});
|
||||
const runner = this.runnerFactory([...reviewTools, spawnSubagentTool]);
|
||||
|
||||
const runContext: ReviewAgentRunContext = {
|
||||
reviewRunId: run.id,
|
||||
sessionScope,
|
||||
eventType: run.eventType,
|
||||
repository: {
|
||||
owner: run.owner,
|
||||
repo: run.repo,
|
||||
cloneUrl: run.cloneUrl,
|
||||
headCloneUrl: run.headCloneUrl,
|
||||
},
|
||||
pullRequest: {
|
||||
prNumber: run.prNumber,
|
||||
relatedPrNumber: run.relatedPrNumber,
|
||||
baseSha: run.baseSha,
|
||||
headSha: run.headSha,
|
||||
commitSha: run.commitSha,
|
||||
commitMessage: run.commitMessage,
|
||||
},
|
||||
workspace: {
|
||||
workspacePath: reviewContext.workspacePath,
|
||||
mirrorPath: reviewContext.mirrorPath,
|
||||
},
|
||||
diffSummary: {
|
||||
changedFiles: reviewContext.changedFiles,
|
||||
diff: reviewContext.diff,
|
||||
},
|
||||
};
|
||||
const agentResult = await runner.run({
|
||||
agentType: 'review-main-agent',
|
||||
model: this.model,
|
||||
userMessage: buildReviewPrompt(runContext, this.model),
|
||||
maxTurns: 8,
|
||||
maxToolCalls: 4,
|
||||
maxSubagents: 3,
|
||||
timeoutMs: config.review.commandTimeoutMs,
|
||||
maxEmptyResponses: 2,
|
||||
maxConsecutiveToolFailures: 3,
|
||||
session: {
|
||||
agentType: 'review-main-agent',
|
||||
metadata: {
|
||||
reviewRunId: run.id,
|
||||
sessionScope,
|
||||
owner: run.owner,
|
||||
repo: run.repo,
|
||||
prNumber: run.prNumber,
|
||||
relatedPrNumber: run.relatedPrNumber,
|
||||
eventType: run.eventType,
|
||||
baseSha: run.baseSha,
|
||||
headSha: run.headSha,
|
||||
commitSha: run.commitSha,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const submitted =
|
||||
reviewToolState.submittedReview ?? tryParseFinalSubmission(agentResult.finalText);
|
||||
let submission: SubmittedReviewFindings;
|
||||
|
||||
if (submitted) {
|
||||
submission = submitted;
|
||||
} else if (agentResult.finalText) {
|
||||
const repaired = await this.repairFindingsWithLLM(
|
||||
agentResult.finalText,
|
||||
agentResult.messages
|
||||
);
|
||||
const repairedSubmission = tryParseFinalSubmission(repaired);
|
||||
submission = repairedSubmission ?? { summaryMarkdown: agentResult.finalText, findings: [] };
|
||||
} else {
|
||||
submission = { summaryMarkdown: '', findings: [] };
|
||||
}
|
||||
const adapted = await applyDeterministicPublishAdapter({
|
||||
store: this.store,
|
||||
runId: run.id,
|
||||
submission,
|
||||
});
|
||||
|
||||
logger.info('主 Agent 审查入口完成', {
|
||||
runId: run.id,
|
||||
sessionId: agentResult.sessionId,
|
||||
sessionScope,
|
||||
findings: adapted.findings.length,
|
||||
});
|
||||
|
||||
return {
|
||||
status: submitted ? 'submitted' : 'completed_without_submission',
|
||||
sessionId: agentResult.sessionId,
|
||||
reviewRunId: run.id,
|
||||
sessionScope,
|
||||
summaryMarkdown: submission.summaryMarkdown,
|
||||
findings: adapted.findings,
|
||||
finalText: agentResult.finalText,
|
||||
};
|
||||
}
|
||||
}
|
||||
81
src/review-agent/schema/__tests__/finding-schema.test.ts
Normal file
81
src/review-agent/schema/__tests__/finding-schema.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildRepairPrompt, findingResponseSchema, parseFindingResponse } from '../index';
|
||||
|
||||
describe('findingResponseSchema', () => {
|
||||
it('parses valid findings', () => {
|
||||
const raw = JSON.stringify({
|
||||
findings: [
|
||||
{
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'src/app.ts',
|
||||
line: 42,
|
||||
title: 'SQL injection',
|
||||
detail: 'Unsanitized input in query',
|
||||
evidence: 'db.query(req.params.id)',
|
||||
suggestion: 'Use parameterized queries',
|
||||
category: 'security',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = parseFindingResponse(raw);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.findings[0].severity).toBe('high');
|
||||
expect(result.findings[0].category).toBe('security');
|
||||
}
|
||||
});
|
||||
|
||||
it('applies defaults for optional fields', () => {
|
||||
const raw = JSON.stringify({
|
||||
findings: [
|
||||
{
|
||||
severity: 'low',
|
||||
path: 'src/util.ts',
|
||||
line: 10,
|
||||
title: 'Missing error handling',
|
||||
detail: 'No try-catch around async call',
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = parseFindingResponse(raw);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const finding = result.findings[0];
|
||||
expect(finding.confidence).toBe(0.8);
|
||||
expect(finding.evidence).toBe('');
|
||||
expect(finding.suggestion).toBe('');
|
||||
expect(finding.category).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid severity', () => {
|
||||
const raw = JSON.stringify({
|
||||
findings: [{ severity: 'critical', path: 'a.ts', line: 1, title: 'x', detail: 'y' }],
|
||||
});
|
||||
const result = parseFindingResponse(raw);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid JSON', () => {
|
||||
const result = parseFindingResponse('not json');
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.raw).toBe('not json');
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to empty findings array', () => {
|
||||
const result = findingResponseSchema.parse({});
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRepairPrompt', () => {
|
||||
it('includes the parse error', () => {
|
||||
const prompt = buildRepairPrompt('missing field "path"');
|
||||
expect(prompt).toContain('missing field "path"');
|
||||
expect(prompt).toContain('JSON');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user