28 Commits

Author SHA1 Message Date
jeffusion
2fac1f6942 fix(review): treat triage entrypoints as hints 2026-05-20 01:55:23 +08:00
jeffusion
45fcf2eaa1 refactor(review): remove domain specialist review path 2026-05-20 01:41:23 +08:00
jeffusion
d48eee3474 test(review): verify autonomous review behavior 2026-05-20 01:17:21 +08:00
jeffusion
c0de9238b5 refactor(review): normalize autonomous findings deterministically 2026-05-20 00:46:50 +08:00
jeffusion
aa8d4ab072 refactor(kernel): dispatch single autonomous review subagent 2026-05-20 00:35:22 +08:00
jeffusion
1831704644 refactor(review): make triage produce review hints 2026-05-20 00:11:18 +08:00
jeffusion
f0e45a5ae5 feat(review): add autonomous review agent loop 2026-05-19 23:51:40 +08:00
jeffusion
0ad83a4082 refactor(review): define autonomous review contract 2026-05-19 23:34:56 +08:00
jeffusion
eeb209dbaf chore(runtime): apply resilience config at startup
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:48:23 +08:00
jeffusion
e1d8c1b7d2 chore(review): remove triage cleanup residue
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:48:23 +08:00
jeffusion
6d62b9f87c test(review): preserve kernel resume invariants
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:48:22 +08:00
jeffusion
bcc9e7b8eb test(review): assert concise review comments
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:48:22 +08:00
jeffusion
12e1f4717b fix(review): dedupe repeated specialist findings
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:47:35 +08:00
jeffusion
6ca9edecfd fix(review): require code reading before specialist findings
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:47:35 +08:00
jeffusion
c4cbced8af fix(llm): pass tool choice to OpenAI-compatible providers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:47:35 +08:00
jeffusion
e0ab3019db refactor(admin): simplify model route assignment UI
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:46:58 +08:00
jeffusion
cd2bdf4131 feat(db): migrate model roles to planner and specialist
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:46:58 +08:00
jeffusion
b304814e42 refactor(llm): drop judge and embedding roles
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:46:58 +08:00
jeffusion
1ff629cffb refactor(review): remove reflexion agent wiring
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:46:28 +08:00
jeffusion
8ccc7452e5 refactor(review): remove legacy publishing policy path
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:46:28 +08:00
jeffusion
b2b914f919 refactor(review): remove obsolete memory and learning modules
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:46:28 +08:00
jeffusion
7b9b9e69a7 refactor(config): remove retired review settings
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-12 14:45:56 +08:00
jeffusion
46c5e09a62 fix(e2e): stabilize kernel engine startup 2026-05-07 09:48:13 +08:00
jeffusion
1a43b1f206 docs(kernel): describe built-in subagents 2026-05-07 00:13:35 +08:00
jeffusion
1b26fac951 test(e2e): automate kernel review flow 2026-05-07 00:13:19 +08:00
jeffusion
38e4c58d71 feat(admin): add kernel review session console 2026-05-07 00:12:33 +08:00
jeffusion
5b29e2d4af feat(review): replace orchestrator with kernel engine 2026-05-07 00:12:20 +08:00
jeffusion
ac40957ede feat(kernel): add extensible agent runtime 2026-05-07 00:12:10 +08:00
204 changed files with 17489 additions and 10626 deletions

View File

@@ -52,3 +52,58 @@ jobs:
path: |
frontend/playwright-report/
frontend/test-results/
e2e:
runs-on: ubuntu-22.04
needs: test
services:
gitea:
image: gitea/gitea:1.22
ports: ['3333:3000']
env:
GITEA__database__DB_TYPE: sqlite3
GITEA__server__ROOT_URL: http://localhost:3333
GITEA__security__INSTALL_LOCK: true
GITEA__webhook__ALLOWED_HOST_LIST: '*'
GITEA__webhook__SKIP_TLS_VERIFY: true
options: >-
--health-cmd "curl -f http://localhost:3000/api/v1/version"
--health-interval 5s
--health-timeout 3s
--health-retries 20
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install git
run: sudo apt-get update && sudo apt-get install -y git
- name: Create Gitea admin user
run: |
for i in $(seq 1 10); do
if docker exec $(docker ps -q --filter "ancestor=gitea/gitea:1.22") \
gitea admin user create --username e2e-admin --password 'e2ePassword123!' --email 'e2e@test.local' --admin 2>/dev/null; then
echo "User created"
break
fi
echo "Retrying... ($i)"
sleep 3
done || true
docker exec -u git $(docker ps -q --filter "ancestor=gitea/gitea:1.22") \
gitea admin user create --username e2e-admin --password 'e2ePassword123!' --email 'e2e@test.local' --admin 2>/dev/null || true
- name: Run E2E tests
run: bun run test:e2e
env:
E2E_GITEA_URL: http://localhost:3333
E2E_MOCK_LLM: 1

2
.gitignore vendored
View File

@@ -18,5 +18,3 @@ public/
# Lock files (frontend has its own)
frontend/package-lock.json
.omo/
.opencode/

View File

@@ -1,16 +1,3 @@
# [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)

View File

@@ -1,119 +0,0 @@
# Contributing
## Development setup
- **Runtime**: [Bun](https://bun.sh) >= 1.2.5
- **Backend**: Hono (TypeScript)
- **Frontend**: React + Vite + Tailwind CSS
- **Database**: SQLite (via Drizzle ORM)
```bash
bun install # install all dependencies
bun run dev # start dev server with hot reload
```
## Code quality
```bash
bun run lint # lint backend + frontend
bun test # backend unit tests
cd frontend && bun test # frontend unit tests
bun run build # build backend
cd frontend && bun run build # build frontend
E2E_MOCK_LLM=1 bun run test:e2e # E2E with mock LLM (no real provider needed)
```
Run all checks before pushing:
```bash
bun run lint && bun run build && bun test && cd frontend && bun run build && bun test
```
## Pull requests
1. Fork the repository and create a feature branch from `main`
2. Make your changes with clear, atomic commits
3. Ensure all quality checks pass (lint, build, test)
4. Open a PR against `main` with a concise description of the change and motivation
### Commit style
Use [Conventional Commits](https://www.conventionalcommits.org/):
```
type(scope): subject
feat(review): add size-based routing
fix(webhook): handle missing signature header
chore(deps): bump hono to 4.x
docs(config): update env variable table
```
Common types: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`.
## UI development conventions
The frontend follows a three-layer design token model:
1. **Primitive** — HSL base values, defined only in global CSS tokens
2. **Semantic**`background`, `foreground`, `success`, `danger`, etc.
3. **Component** — Components consume semantic tokens only. Direct primitive references are forbidden
### Theme definition
- Theme file: `frontend/src/index.css`
- Tailwind mapping: `frontend/tailwind.config.js`
- Primary palette: **Cobalt Blue**, with light/dark variants tuned for contrast
Available palette presets (`data-palette` attribute): `cobalt` (default), `zinc`, `nord`, `tokyo-night`.
### Hard rules
- **No hardcoded dark-theme classes** in business TSX: `bg-zinc-*`, `text-zinc-*`, `border-white/10`, etc.
- **No inline color values**: `rgba(...)`, `#xxxxxx`, `rgb(...)`
- **Status colors use semantic tokens**: `success` / `warning` / `danger` / `info`
- **Panels use semantic surface tokens**: `card`, `muted`, `popover`, `background`
- **Interactive glow** uses utility classes: `theme-glow-primary|success|warning|danger`
- **Non-primary hover** uses `hover:bg-accent*` or `hover:bg-muted*`. Only primary buttons may use `hover:bg-primary/90`
### Recommended class patterns
| Context | Classes |
|---|---|
| Text | `text-foreground` / `text-muted-foreground` |
| Panel | `bg-card` / `bg-muted/50` / `bg-popover` |
| Border | `border-border` / `theme-border-soft` |
| Status | `text-success` / `bg-danger/10` / `border-warning/30` |
| Hover (non-primary) | `hover:bg-accent/60` / `hover:bg-muted/60` |
| Hover (primary action) | `hover:bg-primary/90` |
| Page frame | `theme-page-frame` / `theme-page-actions` / `theme-page-content` |
| Card | `theme-card-shell` / `theme-card-header` / `theme-card-content` |
| Dialog | `theme-dialog-panel` / `theme-dialog-header` / `theme-dialog-body` / `theme-dialog-footer` |
| Error state | `theme-error-panel` |
| Sticky bar | `theme-sticky-bar` |
| Input surface | `theme-input-surface` |
| Control pill | `theme-control-pill` |
### `destructive` vs `danger`
- `destructive` — reserved for shadcn built-in destructive variant semantics
- `danger` — business status semantics (errors, failures, risk indicators)
New business components should prefer `danger` to avoid drift.
### Pre-merge checklist
- [ ] Page renders correctly in both light and dark themes
- [ ] No `zinc`/`white` hardcoded dark-theme classes
- [ ] No inline `style` color values
- [ ] All status colors use semantic tokens
- [ ] Components do not bypass the semantic layer to access primitive colors
- [ ] `bun run ui:visual` passes (light/dark visual regression)
### Visual regression
- Generate/update baseline: `bun run ui:visual:update`
- Verify baseline consistency: `bun run ui:visual`
- Full UI check: `bun run ui:regression && bun run ui:visual`
Baseline snapshots use Linux CI environment (`*-linux.png`). Cross-system snapshot updates introduce noise and should be avoided.

View File

@@ -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 curl && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates ripgrep && rm -rf /var/lib/apt/lists/*
WORKDIR /app

104
README.md
View File

@@ -2,58 +2,104 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
AI-powered code review assistant for Gitea. Receives webhooks, runs AI review workflows, and posts summary + line-level feedback back to Gitea.
AI-powered code review assistant for Gitea. It receives webhooks, runs staged AI review workflows, and posts summary + line-level feedback back to Gitea.
[English](./docs/README.md) | [中文](./docs/README.zh-CN.md)
- English docs: [./docs/README.md](./docs/README.md)
- 中文文档: [./docs/README.zh-CN.md](./docs/README.zh-CN.md)
## Features
## Why this project
- **Automated PR & commit review** via webhook events
- **Dynamic Agent engine** — main agent autonomously spawns subagents for focused analysis
- **Codex engine** — Codex CLI-backed review as an alternative pipeline
- **Pluggable LLM providers** — OpenAI Compatible, OpenAI Responses API, Anthropic, Gemini
- **Web Admin UI** — runtime configuration for providers, models, webhook, review policy
- **Notifications** — Feishu + WeCom (企业微信)
- **Security-first** — webhook signature verification + AES-256-GCM encrypted API key storage
- 🤖 **Automated PR + commit review** via webhook events (`pull_request`, `status`)
- 🧠 **Two review engines**: `agent` (staged tasks) 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)
- 🔔 **Notifications**: Feishu + WeCom (企业微信)
- 🔐 **Security-first defaults**: webhook signature verification + encrypted API key storage
![Dashboard](./docs/assets/page-repos.png)
## Product screenshot
## Quick start
> Dashboard screenshot is generated from local dev service.
![Gitea AI Assistant Dashboard - Repository Page](./docs/assets/page-repos.png)
More screenshots (one per admin menu): [EN](./docs/screenshots.md) | [中文](./docs/screenshots.zh-CN.md)
## Architecture (high-level)
```
Gitea Webhook -> Gitea AI Assistant (Hono + Bun) -> LLM Gateway (multi-provider)
|
+-> Admin Dashboard (React)
```
For component-level design, see [Architecture docs](./docs/README.md#architecture--design).
## Quick start (minimal)
### 1) Prerequisites
- Bun >= 1.2.5
- Reachable Gitea instance
- At least one LLM provider credential
### 2) Install
```bash
git clone https://github.com/jeffusion/gitea-ai-assistant.git
git clone https://github.com/user/gitea-ai-assistant.git
cd gitea-ai-assistant
bun install # installs frontend via postinstall
bun install
```
Create `.env`:
If lifecycle scripts are disabled in your environment, run:
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32)
bun run bootstrap
```
### 3) Minimal `.env`
```bash
PORT=5174
ENCRYPTION_KEY= # required, 64 hex chars (openssl rand -hex 32)
# DATABASE_PATH=./data/assistant.db
# LOG_LEVEL=info # dev default; use LOG_LEVEL=error in production
```
> `ENCRYPTION_KEY` is mandatory. The app refuses to start without it.
### 4) Run
```bash
bun run dev
# or
bun run start
```
Open `http://localhost:5174`, login with default password `password` (change it immediately), then configure Gitea, LLM providers, and webhook in the Admin UI.
### 5) Configure in Admin UI
See [Getting Started](./docs/getting-started.md) for full setup walkthrough including webhook configuration.
Open `http://your-server:5174`, login with default `password` (first boot only), then change it immediately.
## Documentation
- Configure Gitea API + tokens
- Configure webhook secret
- Configure LLM providers/models
- Configure review engine and policy
| Topic | Description |
|---|---|
| [Getting Started](./docs/getting-started.md) | Full installation and setup walkthrough |
| [Configuration](./docs/configuration.md) | Environment variables and Admin UI settings |
| [Review Engines](./docs/review-engines.md) | Agent engine, Codex engine, review modes |
| [Deployment](./docs/deployment.md) | Docker, Compose, and Kubernetes |
| [Screenshots](./docs/screenshots.md) | Admin UI gallery |
### 6) Add webhook in Gitea
## Contributing
- URL: `http://your-server:5174/webhook/gitea`
- Content-Type: `application/json`
- Secret: same as dashboard webhook secret
- Events: Pull Request + Status
See [CONTRIBUTING.md](./CONTRIBUTING.md) for development conventions and UI guidelines.
## Progressive disclosure: detailed docs
- [Documentation index](./docs/README.md)
- [Getting started details](./docs/getting-started.md)
- [Configuration reference](./docs/configuration.md)
- [Review engines](./docs/review-engines.md)
- [Deployment (Docker / Compose / Kubernetes)](./docs/deployment.md)
## License
MIT
MIT License

View File

@@ -8,6 +8,7 @@
"@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",
@@ -120,7 +121,9 @@
"@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=="],
@@ -732,6 +735,7 @@
"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=="],

View File

@@ -1,14 +1,8 @@
version: '3.8'
# E2E 测试环境Gitea + gitea-assistant
# 用法:
# docker compose -f docker-compose.e2e.yml up -d
# # 等待服务启动后运行 seed 脚本:
# ./e2e/seed.sh
# # 运行 E2E 测试:
# ./e2e/test.sh
# # 清理:
# docker compose -f docker-compose.e2e.yml down -v
# docker compose -f docker-compose.e2e.yml up -d
# ./e2e/seed.sh
# docker compose -f docker-compose.e2e.yml down -v
services:
gitea:
@@ -46,12 +40,16 @@ 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
- ENCRYPTION_KEY=5752fac0e57d00e9b7954863faef878693420e6b06bc20d710897587e802668a
- REVIEW_ENGINE=kernel
- REVIEW_WORKDIR=/tmp/e2e-review
- DATABASE_PATH=/data/assistant.db
- E2E_MOCK_LLM=1
ports:
- "3334:5174"
volumes:
- assistant-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
interval: 5s
@@ -61,3 +59,4 @@ services:
volumes:
gitea-data:
assistant-data:

View File

@@ -15,6 +15,9 @@ services:
- .env
environment:
LOG_LEVEL: error
depends_on:
qdrant:
condition: service_healthy
restart: unless-stopped
healthcheck:
@@ -35,6 +38,30 @@ 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

View File

@@ -1,20 +1,21 @@
# Documentation
Setup, configuration, and deployment guides for Gitea AI Assistant.
This project keeps the root `README.md` concise and moves implementation/deployment details here.
## Getting started
## Start here
- [Getting Started](./getting-started.md) — full installation, first login, and webhook setup walkthrough
- [Screenshots](./screenshots.md) — Admin UI gallery (one page per feature area)
- [Getting started](./getting-started.md)
- [Configuration reference](./configuration.md)
- [Review engines](./review-engines.md)
- [Deployment](./deployment.md)
- [Screenshot gallery](./screenshots.md)
## Reference
## Architecture & design
- [Configuration](./configuration.md) — environment variables, Admin UI settings, and runtime configuration model
- [Review Engines](./review-engines.md) — Agent engine, Codex engine, review modes, size policy, and agent definitions
## Deployment
- [Deployment](./deployment.md) — Docker, Docker Compose, and Kubernetes
- [Pluggable LLM providers](./design/pluggable-llm-providers.md)
- [Kernel built-in Agent architecture](./design/kernel-built-in-agents.md)
- [Notification service refactoring](./design/notification-service-refactoring.md)
- [UI theme language](./design/ui-theme-language.md)
## Language

View File

@@ -1,20 +1,25 @@
# 文档中心
Gitea AI Assistant 的安装、配置与部署指南
根目录 `README.md` 保持简洁;本目录提供详细说明与设计文档
## 快速开始
## 快速导航
- [快速开始](./getting-started.zh-CN.md) — 完整安装、首次登录与 Webhook 配置指引
- [截图集](./screenshots.zh-CN.md) — 管理后台界面一览(每个功能页面一张截图)
- [快速开始](./getting-started.zh-CN.md)
- [配置参考](./configuration.zh-CN.md)
- [审查引擎](./review-engines.zh-CN.md)
- [部署指南](./deployment.zh-CN.md)
- [截图集](./screenshots.zh-CN.md)
## 参考手册
## 架构与设计
- [配置参考](./configuration.zh-CN.md) — 环境变量、管理后台设置与运行时配置模型
- [审查引擎](./review-engines.zh-CN.md) — Agent 引擎、Codex 引擎、审查模式、规模策略与 Agent 定义
- [可插拔 LLM 提供商设计](./design/pluggable-llm-providers.md)
- [Kernel 内置 Agent 架构设计](./design/kernel-built-in-agents.md)
- [通知服务重构设计](./design/notification-service-refactoring.md)
- [UI 主题语言设计](./design/ui-theme-language.md)
## 部署
## 产品截图
- [部署指南](./deployment.zh-CN.md) — Docker、Docker Compose 与 Kubernetes
![Gitea AI Assistant 管理后台(仓库管理页)](./assets/page-repos.png)
## 语言切换

View File

@@ -2,23 +2,21 @@
## Configuration model
This project uses a **DB-first** runtime configuration model:
This project uses a DB-first runtime configuration model:
- `.env` stores only infrastructure-level bootstrap values
- Runtime settings (Gitea, providers, secrets, review policy, notifications) are managed in Admin UI and persisted to SQLite
- `.env` contains only infrastructure-level bootstrap values.
- Runtime settings (Gitea, providers, secrets, review policy, notifications) are managed in Admin UI and stored in SQLite.
This means you configure most settings through the web dashboard after first boot, not through environment variables.
## Environment variables
## Environment variables (minimal)
| Variable | Required | Description | Default |
|---|---|---|---|
| `ENCRYPTION_KEY` | Yes | AES-256-GCM master key for API key encryption (64 hex chars) | |
| `ENCRYPTION_KEY` | Yes | AES-256-GCM master key (64 hex chars) for API key encryption | - |
| `PORT` | No | Service port | `5174` |
| `DATABASE_PATH` | No | SQLite database path | `./data/assistant.db` |
| `LOG_LEVEL` | No | Backend log level: `debug` / `info` / `warn` / `error` | `info` |
| `DATABASE_PATH` | No | SQLite path | `./data/assistant.db` |
| `LOG_LEVEL` | No | Backend log level (`debug`/`info`/`warn`/`error`). Default is `info`; use `error` in production. | `info` |
Generate encryption key:
Generate key:
```bash
openssl rand -hex 32
@@ -26,57 +24,55 @@ openssl rand -hex 32
## First boot defaults
When the database is empty on first launch:
When database is empty:
- `JWT_SECRET` auto-generated
- `WEBHOOK_SECRET` auto-generated
- `ADMIN_PASSWORD` defaults to `password` (**change immediately after login**)
- `JWT_SECRET` auto-generated
- `WEBHOOK_SECRET` auto-generated
- `ADMIN_PASSWORD` defaults to `password`
## Admin UI settings
Change `ADMIN_PASSWORD` immediately after first login.
All settings below are configured through the Admin UI at `http://your-server:5174`.
## Runtime groups in Admin UI
### Gitea
## 1) Gitea
| Setting | Description |
|---|---|
| API URL | Gitea API endpoint (e.g. `http://gitea:3000/api/v1`) |
| Access Token | Token for cloning repos and posting comments |
| Admin Token | Optional; required for repository discovery |
- API URL
- Access token
- Admin token (optional)
### Security
## 2) Security
| Setting | Description |
|---|---|
| Webhook Secret | HMAC-SHA256 key for verifying incoming webhooks |
| Admin Password | Dashboard login password |
| JWT Secret | Token signing key (auto-generated on first boot) |
- Webhook secret (HMAC-SHA256 verification)
- Admin password
- JWT secret
### LLM
## 3) LLM
| Setting | Description |
|---|---|
| Providers | Add one or more providers: OpenAI Compatible / OpenAI Responses / Anthropic / Gemini |
| `AGENT_MAIN_MODEL` | Default model for the main agent runtime. Default: `gpt-4.1` |
| `AGENT_DEFAULT_SUBAGENT_MODEL` | Default model for subagents when not declared in definition or spawn. Default: `gpt-4.1-mini` |
- Providers: OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
- Role mapping: planner, specialist, judge, embedding
Model resolution order: `spawn override > AgentDefinition.model > AGENT_DEFAULT_SUBAGENT_MODEL > AGENT_MAIN_MODEL`
## 4) Notification
### Notifications
- Feishu webhook and optional secret
- WeCom (企业微信) webhook
| Setting | Description |
|---|---|
| Feishu Webhook | Feishu bot webhook URL and optional signing secret |
| WeCom Webhook | WeCom (企业微信) bot webhook URL |
## 5) Review
### Review
- Engine mode: `agent` or `codex`
- Triage switch
- Size thresholds (`small`/`medium`/`large`)
- Execution modes (`skip`/`light`/`full`)
- Token budgets and concurrency limits
| Setting | Description |
|---|---|
| Engine | `agent` or `codex` |
| Size thresholds | `small` / `medium` / `large` — classifies change size |
| Execution modes | `skip` / `light` / `full` — controls review depth |
| Token budgets | Per-mode token limits |
| Concurrency | Max parallel review runs |
> Size and mode are different layers:
>
> - `small/medium/large`: change-size classification
> - `skip/light/full`: review execution depth
> Size and mode are separate layers: `small/medium/large` classifies how big the change is; `skip/light/full` controls how deeply the engine reviews it.
## 6) Memory & learning (optional)
- `ENABLE_MEMORY` (default `false`)
- Qdrant URL
- Reflection/debate toggles
Qdrant is only required when memory is enabled.

View File

@@ -2,23 +2,21 @@
## 配置模型
项目采用 **DB-first** 运行时配置模型:
项目采用 DB-first 运行时配置模型:
- `.env`存储基础设施级引导参数
- `.env`用于基础设施级引导参数
- 运行时配置Gitea、Provider、密钥、审查策略、通知由管理后台维护并持久化到 SQLite
即大部分设置在首次启动后通过 Web 管理后台配置,而非环境变量。
## 环境变量
## 环境变量(最小集)
| 变量 | 必填 | 说明 | 默认值 |
|---|---|---|---|
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥AES-256-GCM64 位十六进制) | |
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥AES-256-GCM64 位十六进制) | - |
| `PORT` | 否 | 服务端口 | `5174` |
| `DATABASE_PATH` | 否 | SQLite 数据库路径 | `./data/assistant.db` |
| `LOG_LEVEL` | 否 | 后端日志级别`debug` / `info` / `warn` / `error` | `info` |
| `DATABASE_PATH` | 否 | SQLite 路径 | `./data/assistant.db` |
| `LOG_LEVEL` | 否 | 后端日志级别`debug`/`info`/`warn`/`error`)。默认 `info`;生产环境建议 `error` | `info` |
生成加密密钥:
生成密钥:
```bash
openssl rand -hex 32
@@ -26,57 +24,55 @@ openssl rand -hex 32
## 首次启动默认值
数据库为空时首次启动
数据库为空时:
- `JWT_SECRET` 自动生成
- `WEBHOOK_SECRET` 自动生成
- `ADMIN_PASSWORD` 默认 `password`**登录后请立即修改**
- `JWT_SECRET` 自动生成
- `WEBHOOK_SECRET` 自动生成
- `ADMIN_PASSWORD` 默认 `password`
## 管理后台设置
首次登录后请立即修改管理员密码。
以下所有设置均通过管理后台 `http://your-server:5174` 配置。
## 管理后台配置分组
### Gitea
## 1) Gitea
| 设置项 | 说明 |
|---|---|
| API URL | Gitea API 端点(如 `http://gitea:3000/api/v1` |
| Access Token | 用于克隆仓库和发布评论的令牌 |
| Admin Token | 可选;仓库发现功能需要 |
- API URL
- Access Token
- Admin Token可选
### 安全
## 2) 安全
| 设置项 | 说明 |
|---|---|
| Webhook Secret | HMAC-SHA256 签名验证密钥 |
| Admin Password | 管理后台登录密码 |
| JWT Secret | Token 签名密钥(首次启动自动生成) |
- Webhook SecretHMAC-SHA256 验签)
- Admin Password
- JWT Secret
### LLM
## 3) LLM
| 设置项 | 说明 |
|---|---|
| Provider | 添加一个或多个提供商OpenAI Compatible / OpenAI Responses / Anthropic / Gemini |
| `AGENT_MAIN_MODEL` | 主 Agent 运行时默认模型。默认值:`gpt-4.1` |
| `AGENT_DEFAULT_SUBAGENT_MODEL` | 子 Agent 未声明模型且 spawn 未覆盖时的默认模型。默认值:`gpt-4.1-mini` |
- ProviderOpenAI Compatible / OpenAI Responses / Anthropic / Gemini
- 角色模型planner、specialist、judge、embedding
模型解析顺序:`spawn 覆盖 > AgentDefinition.model > AGENT_DEFAULT_SUBAGENT_MODEL > AGENT_MAIN_MODEL`
## 4) 通知
### 通知
- Feishu Webhook 与可选签名密钥
- WeCom企业微信Webhook
| 设置项 | 说明 |
|---|---|
| Feishu Webhook | 飞书机器人 Webhook URL 及可选签名密钥 |
| WeCom Webhook | 企业微信机器人 Webhook URL |
## 5) 审查
### 审查
- 引擎模式:`agent` / `codex`
- Triage 开关
- 规模阈值(`small`/`medium`/`large`
- 执行模式(`skip`/`light`/`full`
- Token 预算与并发限制
| 设置项 | 说明 |
|---|---|
| 引擎 | `agent``codex` |
| 规模阈值 | `small` / `medium` / `large` — 变更规模分类 |
| 执行模式 | `skip` / `light` / `full` — 审查深度控制 |
| Token 预算 | 各模式 Token 限额 |
| 并发限制 | 最大并行审查数 |
> 规模与模式是两个层次:
>
> - `small/medium/large`:变更规模分类
> - `skip/light/full`:审查执行深度
> 规模与模式是两个层次:`small/medium/large` 分类变更的大小;`skip/light/full` 控制审查的深度。
## 6) 记忆与学习(可选)
- `ENABLE_MEMORY`(默认 `false`
- Qdrant URL
- Reflection / Debate 开关
仅在开启记忆能力时需要 Qdrant。

View File

@@ -13,10 +13,15 @@ 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 `gitea-assistant`.
`docker-compose.yml` includes both:
- `gitea-assistant`
- `qdrant`
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/`.
@@ -41,6 +46,7 @@ Or apply individually:
```bash
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/qdrant.yaml
kubectl apply -f k8s/gitea-assistant.yaml
```

View File

@@ -13,10 +13,15 @@ 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`
`docker-compose.yml` 默认包含
- `gitea-assistant`
- `qdrant`
Compose 生产默认日志级别已设置为 `LOG_LEVEL=error`
如果不使用记忆能力,可在自定义编排中将 Qdrant 设为可选。
## Kubernetes
Kubernetes 清单位于 `k8s/` 目录。
@@ -41,6 +46,7 @@ kubectl apply -k k8s/
```bash
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/qdrant.yaml
kubectl apply -f k8s/gitea-assistant.yaml
```

View File

@@ -0,0 +1,890 @@
# 技术设计文档Kernel 内置 Agent 架构
> **状态**: Draft
> **作者**: AI Architect
> **日期**: 2026-04-28
> **相关模块**: `src/agent-kernel/`、`src/review/kernel/`
> **适用范围**: Review Kernel 的内置 subagent 体系、运行时委派、管理后台可观测能力与生产测试门禁
---
## 目录
- [0. 文档信息](#0-文档信息)
- [1. 背景与目标](#1-背景与目标)
- [2. 设计原则与关键取舍](#2-设计原则与关键取舍)
- [3. 概要设计](#3-概要设计)
- [4. 内置 Agent 详细设计](#4-内置-agent-详细设计)
- [4.8 Agent工作机制详解](#48-agent工作机制详解)
- [5. 运行时与状态设计](#5-运行时与状态设计)
- [6. API 与管理后台可观测性](#6-api-与管理后台可观测性)
- [7. 非功能性设计](#7-非功能性设计)
- [8. 测试与上线验证](#8-测试与上线验证)
- [9. 风险、待确认与后续演进](#9-风险待确认与后续演进)
---
## 0. 文档信息
| 字段 | 内容 |
|---|---|
| 版本 | v0.1 |
| 状态 | 草案 |
| 目标读者 | 研发 / 架构 / QA / 运维 / 管理后台开发 |
| 系统类型 | AI 应用工程 / 后端 Agent Runtime / 审查系统适配层 |
| 主要代码路径 | `src/agent-kernel/``src/review/kernel/` |
| 相关配置 | `REVIEW_ENGINE=kernel` |
### Assumptions
- 当前项目已选择 **kernel-first** 作为代码审查主路径;旧固定 agent 编排不作为未来运行时主路径。
- 内置 Agent 当前以 **built-in subagent definition** 的方式注册,后续可演进到 plugin/custom subagent 加载。
- 一条 PR 对应一个 kernel sessioncommit 更新、人工反馈和后续恢复都写入同一 session。
### To Be Confirmed
- 是否需要把 built-in subagent 的定义从 TypeScript 代码进一步外置为 YAML/JSON/插件目录。
- 管理后台是否需要支持逐 subagent 的启用/禁用、版本选择与灰度策略。
---
## 1. 背景与目标
### 1.1 背景
早期审查系统采用固定流程编排triage 后按审查域派生多个 specialist再由额外阶段汇总。该方案的问题是
- 流程扩展需要修改 orchestrator/runtime 代码;
- 角色能力与执行链路耦合,难以按能力标签选择代理;
- 缺少独立 subagent identity、delegation boundary 和 invocation trace
- 管理后台难以展示“有哪些 Agent、何时被调用、产生了什么结果”
- 恢复、压缩、权限、hook 等横切能力难以统一接入。
新的 Kernel 内置 Agent 架构将 review 角色转换为注册式 built-in subagents`AgentKernelRunner` 根据 planner 输出与 session state 推进任务,并通过 `KernelAgentInvoker` 统一委派执行。
### 1.2 核心目标
| 目标 | 说明 |
|---|---|
| 注册式扩展 | 内置 Agent 以 `KernelSubagentDefinition` 注册runtime 不硬编码角色实例 |
| 能力选择 | planner 通过 tags/capabilities 选择 subagent而不是写死 agent id |
| 可恢复执行 | session checkpoint 持久化 state + pendingTasks支持 feedback 后继续执行 |
| 委派边界 | 每次 subagent 调用都有 agentId、delegation packet、invocation record、structured result |
| 上下文压缩 | 大上下文触发 compressionsummary 写入 checkpoint 并回注后续 subagent |
| 工具治理 | 工具调用走统一 orchestration、permission gating 与 hooks |
| 可观测性 | 管理 API 暴露 task/subagent/hook catalog、session timeline、subagent invocations |
### 1.3 范围与非范围
**范围内**
- Review Kernel 内置 subagents 的定义、职责、标签、运行链路;
- Kernel agent registry / invoker / runner 与 session checkpoint 的协作;
- 内置 Agent 与 tools、hooks、permission、compression 的集成方式;
- 管理后台需要消费的 catalog 与 session 投影视图;
- 生产前自动化测试门禁。
**范围外**
- 前端 UI 视觉设计细节;
-`agent` 固定编排引擎兼容;
- Codex CLI 引擎内部实现;
- 通用插件市场、远程 agent 执行后端和多租户权限模型。
---
## 2. 设计原则与关键取舍
### 2.1 核心设计原则
| 原则 | 落地方式 |
|---|---|
| 高内聚低耦合 | `src/agent-kernel/` 只提供通用 session/runner/registry/invoker/hooksreview 逻辑放在 `src/review/kernel/` |
| 开闭原则 | 新增流程能力优先增加 subagent、skill、hook 或 tool而不是修改主循环 |
| Session 为状态源 | PR/commit session 记录 event、checkpoint、subagent invocation是恢复与投影的事实来源 |
| 可观测优先 | 每次 subagent 调用持久化 invocation每个 task 写入 started/completed/failed event |
| 安全默认 | 工具执行统一经过 permission gating高风险 scope 默认 ask/deny |
| 可测试 | 断言面落在 checkpoint、events、invocations、tool result、admin projection而不是完整 LLM 文本 |
### 2.2 关键取舍
| 取舍点 | 选择 | 原因 |
|---|---|---|
| 内置 Agent 表达方式 | TypeScript built-in definitions | 当前阶段需要强类型、低迁移成本;后续可迁移到 plugin loader |
| Agent 调用入口 | `KernelAgentInvoker` 统一调用 | 统一 agentId、hook、invocation persistence、structured result |
| 流程推进方式 | planner + session state | 避免静态任务数组;支持继续执行与人审恢复 |
| Findings 处理 | 本地归一化、去重、排序与发布 | full review 只产出 findings后续由 skill/本地逻辑保证确定性 |
| 压缩策略 | planner 模型窗口 80% 触发 | 使用 tokenlens context window预留 20% 冗余 |
| 管理接口 | task/subagent/hook catalog + session detail | 让后台可解释当前能力目录与执行轨迹 |
---
## 3. 概要设计
### 3.1 总体架构
```mermaid
flowchart TB
Webhook[Gitea Webhook / Feedback] --> Engine[KernelReviewEngine]
Engine --> Session[(Kernel Session Repository)]
Engine --> Runtime[ReviewKernelRuntime]
Runtime --> Runner[AgentKernelRunner]
Runtime --> SkillRegistry[KernelTaskRegistry / Skills]
Runtime --> AgentRegistry[KernelAgentRegistry / Built-in Subagents]
Runtime --> HookRegistry[KernelHookRegistry]
Runtime --> ToolRegistry[ToolRegistry]
Runner --> Planner[State-driven Planner]
Planner --> SkillTask[Skill Task]
Planner --> SubagentTask[Subagent Task]
SkillTask --> SkillRegistry
SubagentTask --> Invoker[KernelAgentInvoker]
Invoker --> AgentContext[AsyncLocalStorage Agent Context]
Invoker --> Invocation[(Subagent Invocation Record)]
Invoker --> Builtins[Review Built-in Subagents]
Builtins --> Triage[review:triage]
Builtins --> FullReview[review:full_review]
FullReview --> ToolOrchestration[Tool Orchestration]
ToolOrchestration --> Permission[Permission Gating]
ToolOrchestration --> Hooks[Pre/Post Tool Hooks]
Runtime --> AdminAPI[Admin API Catalog / Session Projection]
```
### 3.2 模块职责
| 模块 | 文件 | 职责 |
|---|---|---|
| Kernel types | `src/agent-kernel/types.ts` | 定义 task、subagent、delegation packet、checkpoint、invocation result |
| Agent registry | `src/agent-kernel/agents/kernel-agent-registry.ts` | 注册、查询、按 tag 过滤 subagent |
| Agent invoker | `src/agent-kernel/agents/kernel-agent-invoker.ts` | 创建 agentId、触发 hook、持久化 invocation、执行 subagent |
| Agent context | `src/agent-kernel/agents/kernel-agent-context.ts` | 使用 AsyncLocalStorage 隔离子代理执行上下文 |
| Runner | `src/agent-kernel/runtime/agent-kernel-runner.ts` | 按 planner 结果推进 skill/subagent task写 checkpoint 与 task event |
| Session repo | `src/agent-kernel/session/session-repository.ts` | 持久化 session、events、checkpoint、subagent invocations |
| Review runtime | `src/review/kernel/review-kernel-runtime.ts` | 注册 skills/hooks/built-in subagents提供 execute/continueExecution |
| Built-in subagents | `src/review/kernel/review-built-in-subagents.ts` | 将 triage 与 full_review 转换为注册式 subagent definitions |
| Subagent ids | `src/review/kernel/review-subagent-ids.ts` | 统一内置 subagent id 命名 |
| Admin projection | `src/review/kernel/session-read-model.ts` | 将 session event/checkpoint/invocation 投影为后台视图 |
### 3.3 核心执行链路
```mermaid
sequenceDiagram
participant E as KernelReviewEngine
participant R as ReviewKernelRuntime
participant S as SessionRepository
participant K as AgentKernelRunner
participant I as KernelAgentInvoker
participant A as Built-in Subagent
E->>S: ensureSession(scopeKey)
E->>R: execute(run, sessionId)
R->>S: appendEvent(run_started)
R->>K: run(initialState, initialTasks=[])
loop until stopReason
K->>K: planner.plan(state)
alt skill task
K->>R: execute skill handler
else subagent task
K->>I: invoke(task, context)
I->>S: createSubagentInvocation(running)
I->>A: execute(task, agentContext)
A-->>I: KernelHandlerResult
I->>S: completeSubagentInvocation(completed)
end
K->>S: appendEvent(task_completed)
K->>S: saveCheckpoint(state, pendingTasks, stopReason)
end
R->>S: appendEvent(run_completed)
```
---
## 4. 内置 Agent 详细设计
### 4.1 内置 Agent 目录
| Subagent ID | Source | Model Role | Tags | 职责 | 触发条件 |
|---|---|---|---|---|---|
| `review:triage` | `built-in` | `planner` | `review`, `planner`, `triage` | 根据 diff、文件、风险生成自主审查提示、模式和预算 | build context 完成且尚无 triage 结果 |
| `review:full_review` | `built-in` | `specialist` | `review`, `specialist`, `full-review`, `autonomous-review` | 执行一次完整自主代码审查,模型自行选择工具和调查路径 | triage 完成且尚未完成 full review |
### 4.2 Subagent Definition 契约
每个内置 Agent 必须实现 `KernelSubagentDefinition<TState>`
```typescript
interface KernelSubagentDefinition<TState> {
kind: 'subagent';
name: string;
source: 'built-in' | 'custom' | 'plugin';
description: string;
whenToUse: string;
tags?: string[];
modelRole?: string;
resumable?: boolean;
execute(task, context): Promise<KernelHandlerResult<TState> | undefined>;
}
```
关键约束:
- `name` 必须稳定,作为 session event、invocation、admin catalog 的统一标识;
- `tags` 必须包含能力标签planner 只能按 tag/capability 选择代理;
- `whenToUse` 既用于管理后台解释,也用于 delegation packet 的 goal
- `execute` 不直接控制主循环,只返回 state/enqueue/prepend/stopReason
- 内置 Agent 不应越权直接修改 pendingTasks除非通过标准 `KernelHandlerResult`
### 4.3 Planner 选择规则
`ReviewKernelRuntime.planTasks()` 根据 checkpoint state 推导下一步:
```mermaid
flowchart TD
A[开始 plan] --> B{有 pendingTasks?}
B -- 是 --> Z[不新增任务]
B -- 否 --> C{缺 workspace?}
C -- 是 --> PW[prepare_workspace skill]
C -- 否 --> D{缺 context?}
D -- 是 --> BC[build_context skill]
D -- 否 --> E{需要压缩?}
E -- 是 --> CC[compress_context skill]
E -- 否 --> F{缺 triage?}
F -- 是 --> T[按 tag=triage 选择 review:triage]
F -- 否 --> G{full review 未完成?}
G -- 是 --> S[执行 review:full_review]
G -- 否 --> P{未 publish?}
P -- 是 --> PR[publish_review skill]
P -- 否 --> R{未保存 reviewed ref?}
R -- 是 --> SR[save_reviewed_ref skill]
R -- 否 --> DONE[completed]
```
### 4.4 Triage Agent
`review:triage` 包装 `TriageAgent`,输出自主审查提示:
- 使用 `planner` 模型角色;
- 接收 `projectPrompt``compressedContext.summary`
- 生成 `mode``reviewSize``riskTags``suspectedEntrypoints` 与预算提示;
- 提示只影响 full review 的调查起点,不拆分审查任务。
### 4.5 Autonomous Full Review Agent
`review:full_review` 包装 `AutonomousReviewAgent`
- 共享 `ToolRegistry``KernelHookRegistry`
- 根据 `ReviewTask` 控制 mode、reviewSize、riskTags、suspectedEntrypoints、maxTurns、maxToolCalls、maxElapsedMs、tokenBudget
- 支持压缩 summary 回注到 prompt
- 不预拆 correctness/security/quality 子任务,模型在一次自主循环内跨文件调查;
- 工具调用统一经过 tool orchestration、permission gating、Pre/Post tool hooks。
### 4.6 Aggregate Findings Skill
`aggregate_findings` 是 full review 后的确定性本地步骤:
- 接收 `review:full_review` 产出的 findings
- 归一化 category/severity/confidence补齐 fingerprint
- 按 fingerprint 去重,并按 severity/path/line/title 稳定排序;
- 写回 checkpoint供后续发布步骤使用。
### 4.7 Publish and Save Skills
`publish_review``save_reviewed_ref` 负责外部副作用:
- `publish_review` 生成确定性 summary并发布 PR summary 与 line comments
- `save_reviewed_ref` 在本地 mirror 保存已审查 ref用于后续增量审查
- 两个步骤分离,避免评论发布和 ref 保存互相污染,失败时依赖 checkpoint 重试。
---
## 4.8 Agent工作机制详解
本节详细说明 Kernel Agent 的运转机制、任务调度、工具调用、决策逻辑及边界划分。
### 4.8.1 核心运转架构
Kernel 采用「**事件驱动 + 状态机**」的运行模式:
```mermaid
flowchart LR
Webhook[Gitea Webhook / Feedback] --> Engine[KernelReviewEngine]
Engine --> Session[Session Repository]
Engine --> Runtime[ReviewKernelRuntime]
Runtime --> Runner[AgentKernelRunner]
Runner --> Planner[Turn Planner]
Planner --> Tasks[Tasks Queue]
Tasks --> Executor[Task Executor]
Executor --> State[State Update]
State --> Checkpoint[Checkpoint Save]
Checkpoint --> Runner
```
**关键组件职责**:
| 组件 | 文件 | 核心职责 |
|------|------|----------|
| **AgentKernelRunner** | `agent-kernel-runner.ts` | 主循环控制器任务调度、状态流转、checkpoint 管理 |
| **ReviewKernelRuntime** | `review-kernel-runtime.ts` | Review 业务运行时:封装 skills、subagents、hooks、tools |
| **KernelTurnPlanner** | `review-kernel-runtime.ts:305-361` | 基于当前 state 决定下一步执行什么任务 |
### 4.8.2 核心运转流程
**1. 启动阶段**:
```typescript
// PR webhook 触发
kernelReviewEngine.enqueuePullRequest(payload)
ensureSession(scopeKey) // 创建或复用 session
runtime.execute(run, sessionId) // 启动运行时
AgentKernelRunner.run({ // 启动主循环
sessionId,
initialState: {...},
initialTasks: []
})
```
**2. 主循环机制** (`AgentKernelRunner.run`):
```typescript
async run({ sessionId, initialState, initialTasks, continueExisting }) {
// 从 checkpoint 恢复状态(支持继续执行)
const persisted = loadCheckpoint(sessionId);
let state = persisted?.state ?? initialState;
const pendingTasks = [...(persisted?.pendingTasks ?? initialTasks)];
// 主循环:直到有 stopReason
while (!stopReason) {
// 如果没有待执行任务,让 planner 规划新任务
if (pendingTasks.length === 0) {
const planned = planner.plan({ session, state, pendingTasks });
pendingTasks.push(...planned);
}
// 取出下一个任务
const task = pendingTasks.shift();
// 执行任务
const result = await executeTask(task, context);
// 处理执行结果
if (result?.state) state = result.state; // 更新状态
if (result?.prepend) pendingTasks.unshift(...result.prepend); // 前置任务
if (result?.enqueue) pendingTasks.push(...result.enqueue); // 后置任务
if (result?.stopReason) stopReason = result.stopReason; // 停止原因
// 保存 checkpoint支持失败恢复
saveCheckpoint(sessionId, { state, pendingTasks, stopReason });
}
}
```
**3. 恢复机制** (`continueExisting`):
- 从 SQLite 加载持久化的 checkpoint
- 恢复 `state``pendingTasks`
- **显式忽略**旧 checkpoint 的 `stopReason`,允许从 feedback 后继续
- 不 replay events直接继续执行
### 4.8.3 任务调度与决策
**Planner 是决策中枢**,根据当前 state 动态决定下一步:
```typescript
private planTasks(context: KernelPlanningContext): KernelTask[] {
// 阶段1: 前置条件检查(顺序执行)
if (!context.state.workspacePath) {
return [{ kind: 'skill', name: 'prepare_workspace' }];
}
if (!context.state.context) {
return [{ kind: 'skill', name: 'build_context' }];
}
// 阶段2: 上下文压缩决策
if (shouldCompress(context)) {
return [{ kind: 'skill', name: 'compress_context' }];
}
// 阶段3: Triage 决策(生成自主审查提示)
if (!context.state.triage) {
return [{ kind: 'subagent', name: 'review:triage' }];
}
// 阶段4: 单次完整自主审查
if (!context.state.reviewCompleted) {
return [{ kind: 'subagent', name: 'review:full_review' }];
}
// 阶段5: 发布与收尾
if (!context.state.published) {
return [{ kind: 'skill', name: 'publish_review' }];
}
return []; // 完成
}
```
**决策依据**:
- **当前 State**: `triage`, `reviewCompleted`, `findings`, `published`, `reviewedRefSaved` 等字段
- **Tags/Capabilities**: 按标签选择 subagent`filterByTag('triage')`),非硬编码
- **Config 开关**: 审查引擎、工作区、命令白名单等运行配置
### 4.8.4 Skills 与 Subagents 调用机制
**Skills - 原子任务**:
```typescript
// 注册 Skills
this.skillRegistry.register(createPrepareWorkspaceSkill());
this.skillRegistry.register(createBuildContextSkill());
// Skill 定义
{
kind: 'skill',
name: 'build_context',
execute: async (task, context) => {
// 执行业务逻辑
const reviewContext = await diffExtractor.buildContext(...);
return {
state: { ...context.state, context: reviewContext }, // 更新状态
// 可选控制流
prepend: [], // 在当前任务前插入新任务
enqueue: [], // 在当前任务后追加新任务
stopReason: undefined // 或 'completed', 'failed', 'awaiting_human_feedback'
};
}
}
```
**Subagents - 委派执行**:
```typescript
// 调用路径
AgentKernelRunner KernelAgentInvoker.invoke(task, context)
invocation record
subagent.execute(task, agentContext)
invocation
```
```typescript
// Subagent 执行上下文
const agentContext: KernelAgentExecutionContext = {
...context,
agent, // subagent 定义
delegation: { // 委派包
goal: agent.whenToUse,
parentTaskName: task.name,
input: task.input,
contextSummary: state.compressedContext?.summary // 压缩摘要回注
}
};
// 执行(带 AsyncLocalStorage 隔离)
const result = await runWithKernelAgentContext(
{ agentId, parentSessionId, agentType: 'subagent', ... },
() => agent.execute(task, agentContext)
);
```
### 4.8.5 Tools 调用机制
**调用路径**(在 `review:full_review` 内部):
```mermaid
sequenceDiagram
participant FullReview as AutonomousReviewAgent
participant Loop as Autonomous Loop
participant Orchestration as ToolOrchestration
participant Permission as Permission Gating
participant Hook as PreToolUse Hook
participant Tool as Tool.execute()
participant PostHook as PostToolUse Hook
FullReview->>Loop: 决定调用 tool
Loop->>Orchestration: partitionToolCalls(tools)
Orchestration->>Permission: evaluateToolPermission(tool)
Permission-->>Orchestration: allow/ask/deny
Orchestration->>Hook: runKernelHooks(PreToolUse)
Hook-->>Orchestration: additionalContext/updatedInput
Orchestration->>Tool: tool.execute(args)
Tool-->>Orchestration: result
Orchestration->>PostHook: runKernelHooks(PostToolUse)
PostHook-->>Orchestration: -
Orchestration-->>Loop: toolResult
Loop-->>FullReview: 更新 diagnostics/findings
```
**并发控制**:
- **并发安全工具** (`isConcurrencySafe: true`): 并行执行
- **非并发安全工具**: 串行执行
- **权限拦截**: `PermissionRequest` Hook 可批准/阻断
**权限边界**:
| Scope | 默认行为 | 说明 |
|-------|----------|------|
| `read` | `allow` | 安全操作(读文件、搜索代码) |
| `write` | `ask` | 需审批(写文件) |
| `command` | `ask` | 需审批(执行命令) |
| `git_write` | `ask` | 需审批Git 操作) |
| `network` | `deny` | 禁止网络访问 |
| `cross_session` | `deny` | 禁止跨 session 操作 |
### 4.8.6 代码审查结合流程
**完整数据流**:
```
Webhook → PR/Commit
prepare_workspace → 克隆仓库、准备 mirror/workspace
build_context → 提取 diff、文件内容、构建 ReviewContext
compress_context (可选) → 大上下文自动压缩,生成 summary
review:triage → 生成自主审查提示、模式和预算
review:full_review → 单个自主代理跨文件调查,生成 findings
publish_review → 发布 summary + line comments
save_reviewed_ref → 保存审查快照(支持增量审查)
```
**状态流转**:
```mermaid
stateDiagram-v2
[*] --> prepare_workspace: 启动
prepare_workspace --> build_context: 成功
build_context --> compress_context: 上下文过大
build_context --> triage: 正常
compress_context --> triage: 完成
triage --> full_review: 提示生成完成
full_review --> publish_review: findings 聚合完成
publish_review --> save_reviewed_ref: 直接完成
save_reviewed_ref --> [*]: completed
```
### 4.8.7 边界划分
**Skills vs Subagents 边界**:
| 维度 | Skills | Subagents |
|------|--------|-----------|
| **粒度** | 原子操作(准备环境、构建上下文、发布) | 复杂推理(规划、完整审查) |
| **模型** | 通常不涉及 LLM | 必须调用 LLMplanner/specialist |
| **并发** | 顺序执行 | 通过单个 full review 代理内部自主工具调用实现调查 |
| **状态** | 修改 state 字段 | 可修改 state主要产出 hints/findings/diagnostics |
| **失败** | 阻断整个流程 | 可单独重试或降级 |
| **示例** | prepare_workspace, publish_review | review:triage, review:full_review |
**Runtime vs Runner 边界**:
| 组件 | 职责 | 不做什么 |
|------|------|----------|
| **AgentKernelRunner** | 通用调度、checkpoint、task 循环 | 不感知 Review 业务逻辑 |
| **ReviewKernelRuntime** | Review 业务封装、skills、subagents、hooks | 不直接调度任务(委托给 runner |
**Subagents 间边界**:
| Subagent | 输入 | 输出 | 边界限制 |
|----------|------|------|----------|
| **triage** | ReviewContext | review hints + budget | 只生成提示,不审查 |
| **full_review** | ReviewTask + context | findings[] + diagnostics | 一次完整自主审查,不预拆域或文件 |
**Hook 介入边界**:
```typescript
// 在关键生命周期点介入
SessionStart // session 启动时
SubagentStart // subagent 启动时
PreToolUse // 工具调用前(可修改输入、阻断)
PermissionRequest // 权限请求时(决定 allow/ask/deny
PostToolUse // 工具调用成功后
PostToolUseFailure // 工具调用失败后
```
**Session 隔离边界**:
- 每个 PR/Commit 对应独立 session
- session 间 state 不共享
- tool 默认禁止 cross_session 操作
- subagent invocation 绑定 parentSessionId
---
## 5. 运行时与状态设计
### 5.1 Session 与 Checkpoint
每条 PR/commit 审查对应一个 kernel session
| 数据 | 用途 |
|---|---|
| `KernelSessionRecord` | 记录 scopeType、scopeKey、metadata、lastRunId |
| `KernelSessionEventRecord` | append-only 事件流,记录 run/task/hook/feedback 生命周期 |
| `KernelCheckpoint<TState>` | 持久化 state、pendingTasks、stopReason |
| `KernelSubagentInvocationRecord` | 记录每次 subagent 委派调用 |
恢复语义:
- `continueExisting=true` 时从 persisted checkpoint 恢复 `state + pendingTasks`
- 显式忽略旧 checkpoint 的 stopReason允许 feedback 后继续推进;
- 当前不 replay session events 重建 stateevent 主要用于投影与审计。
### 5.2 ReviewKernelState
核心状态包括:
| 字段 | 说明 |
|---|---|
| `targetSha` | 当前审查目标 commit |
| `mirrorPath/workspacePath` | 本地仓库与工作区路径 |
| `context` | `ReviewContext`,包含 diff、changedFiles、fileContents 等 |
| `projectPrompt` | 仓库级审查 prompt |
| `compressedContext` | 自动压缩摘要及 token 元数据 |
| `triage/reviewTask/reviewCompleted` | 自主审查提示、预算与完成状态 |
| `findings` | subagents 收集到的问题 |
| `reviewDiagnostics` | full review 工具调用、停止原因、解析计数等诊断信息 |
| `published/reviewedRefSaved` | 发布与审查快照保存状态位 |
### 5.3 Subagent Invocation
每次 subagent 调用会持久化:
| 字段 | 说明 |
|---|---|
| `parent_session_id` | 父 session |
| `parent_run_id` | 当前 review run |
| `parent_task_name` | 触发该调用的 task name |
| `subagent_name` | subagent id例如 `review:triage` |
| `agent_id` | 本次调用唯一 agent identity |
| `status` | running / completed / failed |
| `input_json` | delegation packet |
| `result_json` | structured invocation result |
失败处理:
- invoker 将 invocation 标记为 `failed`
- runner 写入 `task_failed` event
- checkpoint 保存当前 state 与 `[failedTask, ...pendingTasks]`stopReason=`failed`
- 调用方可根据 checkpoint 与错误信息决定重试/人工介入。
### 5.4 上下文压缩与回注
```mermaid
sequenceDiagram
participant P as Planner
participant C as ContextCompressionService
participant S as Session Checkpoint
participant A as Subagent
P->>C: shouldCompress(context, compressedContext)
C-->>P: true when tokenEstimate >= contextWindow * 0.8
P->>C: compress(context, projectPrompt)
C-->>S: compressedContext(summary, token stats, model, timestamp)
P->>A: invoke subagent with contextSummary
A-->>A: prompt includes compressed summary
```
压缩触发阈值:
- 使用 `tokenCounter.getContextWindow(plannerModel)` 获取模型上下文窗口;
- 取 80% 作为触发阈值,预留 20% 冗余;
- 若无法获取模型配置,兜底使用默认窗口。
### 5.5 Hooks 与 Permission
内置 hooks
| Hook | Event | 作用 |
|---|---|---|
| `kernel:session-start-audit` | `SessionStart` | 写入 `hook_session_start` event |
| `kernel:subagent-start-audit` | `SubagentStart` | 写入 `hook_subagent_start` event |
| `kernel:pre-tool-audit` | `PreToolUse` | 为工具调用追加审计上下文 |
| `kernel:permission-request-audit` | `PermissionRequest` | 记录权限请求上下文 |
工具权限默认策略:
| Scope | 默认行为 |
|---|---|
| `read` | allow |
| `write` | ask |
| `command` | ask |
| `git_write` | ask |
| `network` | deny |
| `cross_session` | deny |
---
## 6. API 与管理后台可观测性
### 6.1 Admin API
| API | 说明 |
|---|---|
| `GET /admin/api/review/sessions` | 返回 session 列表与 summary |
| `GET /admin/api/review/sessions/:sessionId` | 返回 session、summary、checkpoint、plan、timeline、events、subagentInvocations、runDetails |
| `GET /admin/api/review/kernel/tasks` | 返回 skill + subagent task catalog |
| `GET /admin/api/review/kernel/subagents` | 返回 subagent catalog |
| `GET /admin/api/review/kernel/hooks` | 返回 hook catalog |
### 6.2 Subagent Catalog 响应字段
```json
{
"kind": "subagent",
"name": "review:full_review",
"source": "built-in",
"description": "执行一次完整自主代码审查",
"whenToUse": "当 triage 生成审查提示后执行完整审查",
"modelRole": "specialist",
"tags": ["review", "specialist", "full-review", "autonomous-review"],
"resumable": true
}
```
### 6.3 管理后台展示建议
管理后台应采用双层控制面:
- 上层Kernel Subagents 目录,展示 built-in/custom/plugin subagents
- 下层:模型角色路由,配置 `planner / specialist` 到 provider/model。
展示字段建议:
| 区域 | 字段 |
|---|---|
| Subagent 目录 | name、source、description、whenToUse、modelRole、tags、resumable |
| Session 详情 | summary、plan、timeline、findings、comments、subagentInvocations |
| Invocation 详情 | agentId、status、startedAt、finishedAt、summary、artifacts |
---
## 7. 非功能性设计
### 7.1 安全设计
- 工具调用统一走 permission gating避免 subagent 绕过权限策略;
- 高风险工具默认 ask/deny不允许直接执行网络、跨 session 或写操作;
- hooks 可作为后续审批、审计、通知与策略扩展点;
- LLM prompt 不作为安全边界,所有外部副作用必须由 tool/skill/adapters 承载。
### 7.2 高可用与恢复
- 每个 task 完成后保存 checkpoint降低失败后的重复工作
- subagent invocation 失败会记录 failed 状态,便于定位失败代理;
- feedback 后通过 `continueExisting` 从 checkpoint 继续;
- publish 与 save reviewed ref 分离,避免评论发布与 ref 保存互相污染;
- cleanup workspace 放在 runtime finally 中执行,降低资源泄漏风险。
### 7.3 可观测性
- session event 记录 run/task/hook/feedback 生命周期;
- subagent invocation 记录 parent-child 委派关系;
- admin projection 汇总 plan/timeline/currentStep/findingCount/pendingTaskCount
- compression 记录 sourceTokenEstimate、summaryTokenEstimate、triggerThreshold、model。
### 7.4 性能与容量
- 大 diff 先经 diff extractor/token budget 裁剪,再由 compression service 做会话级摘要;
- `review:full_review` 在单个自主循环内使用工具逐步调查,避免运行时预拆 domain 或文件;
- tool orchestration 可并发执行 read-only 工具,非并发安全工具串行;
- session/event/checkpoint 使用 SQLite适合当前单体部署未来高并发可迁移到外部数据库。
### 7.5 可维护性与扩展性
- 新增内置 Agent 应只新增 `KernelSubagentDefinition` 并打 tags
- 新增流程副作用应优先实现 skill/adapters
- 新增横切逻辑应优先实现 hook
- 新增工具必须声明 permissionScope 和 isConcurrencySafe。
---
## 8. 测试与上线验证
### 8.1 自动化测试分层
| 层级 | 测试文件 | 覆盖点 |
|---|---|---|
| Unit | `src/review/kernel/__tests__/session-read-model.test.ts` | session summary/plan/timeline 投影 |
| Unit | `src/review/tools/__tests__/tool-permissions.test.ts` | permission scope 默认策略 |
| Contract | `src/agent-kernel/hooks/__tests__/kernel-hook-runner.test.ts` | hook 聚合、approve/block、updatedInput |
| Integration | `src/controllers/__tests__/admin-review-sessions.test.ts` | admin session 与 catalog API |
| Integration | `src/controllers/__tests__/feedback-kernel-session.test.ts` | feedback approve/reject/rollback/continue |
| Runtime | `src/review/kernel/__tests__/runtime-happy-path.test.ts` | 完整 runtime happy path |
| Runtime | `src/review/kernel/__tests__/runtime-feedback-resume.test.ts` | awaiting feedback 后恢复 |
| Runtime | `src/review/kernel/__tests__/runtime-replay-invariants.test.ts` | checkpoint/resume/replay 不变量 |
| Runtime | `src/review/kernel/__tests__/runtime-concurrency-idempotency.test.ts` | 并发上限与幂等 |
| Canary | `src/review/kernel/__tests__/compression-resumability.test.ts` | 压缩恢复与生产关键 canary |
### 8.2 上线前门禁
必须通过:
```bash
bun run lint
bun run build
bun test src/review/kernel/__tests__ src/review/tools/__tests__ src/controllers/__tests__ src/agent-kernel/hooks/__tests__
bun test
```
关键验收信号:
- runtime happy path 完成stopReason=`completed`
- feedback resume 从 `awaiting_human_feedback` 恢复到 completed
- compression resume 保留 targetSha、pending boundary、invocation boundary、summary
- permission deny 不会绕过工具治理;
- duplicate enqueue/continue/feedback 不产生重复有效工作;
- admin session detail 能看到 plan/timeline/subagentInvocations。
### 8.3 灰度与回滚
- 配置默认:`REVIEW_ENGINE=kernel`
- 若需要回滚,可临时切到 `codex` 引擎,但旧固定 agent 编排不再作为主路径;
- 灰度期间重点观察 session stopReason 分布、task_failed 事件、subagent failed invocations、feedback resume 成功率。
---
## 9. 风险、待确认与后续演进
### 9.1 风险与应对
| 风险 | 影响 | 应对 |
|---|---|---|
| Built-in definitions 仍在代码中 | 扩展仍需发版 | 下一阶段引入 plugin/custom subagent loader |
| SQLite 单文件并发能力有限 | 高并发 session 下写入竞争 | 当前单体可接受;未来迁移外部 DB 或队列化写入 |
| Compression summary 可能遗漏细节 | 后续 subagent 判断偏差 | 保留 recent context + summary测试锁定关键事实不丢 |
| Hook 阻断策略过强或过弱 | 工具误阻断或越权 | permission matrix 测试 + 审计 event + 管理后台策略展示 |
### 9.2 后续演进计划
1. **Plugin-based Subagent Loading**:支持从目录或配置加载 custom/plugin subagents。
2. **Child Session Tree**:为长任务或后台 subagent 引入 child session/resume tree。
3. **Attachment Reinjection**:压缩后恢复文件附件、计划附件和技能附件。
4. **更细粒度权限模型**:支持仓库级、工具级、用户级策略配置。
5. **Subagent 版本治理**:为 built-in/custom/plugin subagents 增加 version、enabled、rollout 字段。
### 9.3 评审清单
- [ ] 内置 Agent 是否都通过 registry/invoker 调用,而不是 runtime 硬编码实例?
- [ ] planner 是否按 tag/capability 选择 subagent
- [ ] 每次 subagent 调用是否有 invocation record
- [ ] feedback 后 continue 是否从 checkpoint 恢复?
- [ ] 压缩 summary 是否持久化并回注 triage/full_review
- [ ] 工具执行是否经过 permission/hook/orchestration
- [ ] 管理后台是否能展示 catalog、timeline、invocations
- [ ] 生产测试门禁是否覆盖 happy path、失败恢复、幂等和 canary
---
## 版本记录
| 版本 | 日期 | 说明 |
|---|---|---|
| v0.1 | 2026-04-28 | 初版:记录 Kernel 内置 Agent 架构、运行链路、可观测性与测试门禁 |

View File

@@ -0,0 +1,617 @@
# 通知服务抽象化重构方案
## 1. 概述
### 1.1 背景
当前项目中的通知功能仅支持飞书(Feishu/Lark)平台代码高度耦合飞书特定的API实现。随着业务需求扩展需要支持企业微信(WeCom)等其他通知渠道。
### 1.2 目标
- 抽象通用通知服务接口,支持多平台扩展
- 支持同时配置多个通知服务(如飞书+企业微信同时推送)
- 统一通知调用入口,避免平台耦合与重复发送
- 清晰的代码结构便于后续添加新平台如Slack、钉钉等
### 1.3 非目标
- 不修改通知的业务触发逻辑
- 不改变现有的Gitea Webhook处理流程
- 不引入外部通知服务SDK依赖保持轻量
---
## 2. 现有架构分析
### 2.1 重构前实现(已下线)
```
src/
├── services/feishu.ts # 飞书服务实现156行
├── controllers/review.ts # 通知调用点
├── config/config-schema.ts # 配置定义
└── config/config-manager.ts # 配置管理
```
### 2.2 关键代码特征
- **强耦合**`review.ts` 直接调用 `feishuService.sendXXXNotification()`
- **硬编码消息格式**:飞书特定的 `msg_type: 'text'` 结构
- **签名逻辑**HMAC-SHA256(timestamp+"\n"+secret)
- **配置单一**:仅支持一组飞书配置
### 2.3 通知场景
| 场景 | 方法名 | 触发条件 |
|------|--------|----------|
| 工单创建 | `sendIssueCreatedNotification` | Issue opened + 有指派人 |
| 工单关闭 | `sendIssueClosedNotification` | Issue closed |
| 工单指派 | `sendIssueAssignedNotification` | Issue assigned |
| PR创建 | `sendPrCreatedNotification` | PR opened + 有审阅者 |
| PR指派 | `sendPrReviewerAssignedNotification` | PR review_requested |
---
## 3. 目标架构设计
### 3.1 架构模式
采用**策略模式(Strategy)** + **工厂模式(Factory)**
```
┌─────────────────────────────────────────────────────────────┐
│ Notification Service Layer │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
│ │ INotification │ │ INotification │ │ INotification│ │
│ │ Service │ │ Service │ │ Service │ │
│ │ (Interface) │ │ (Interface) │ │ (Interface) │ │
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐ │
│ │ Feishu │ │ WeCom │ │ Slack │ │
│ │Service │ │Service │ │ Service │ │
│ └───────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────┴─────────┐
│ NotificationFactory│
└─────────┬─────────┘
┌─────────┴─────────┐
│ NotificationManager│ ← 统一入口,支持多服务
└───────────────────┘
```
### 3.2 核心接口设计
#### 3.2.1 类型定义
```typescript
// types.ts
export type NotificationProvider = 'feishu' | 'wecom' | 'slack' | 'dingtalk';
export interface NotificationContext {
// PR相关
prTitle?: string;
prUrl?: string;
prNumber?: number;
// Issue相关
issueTitle?: string;
issueUrl?: string;
issueNumber?: number;
// 用户相关
actor?: string;
assignees?: string[];
reviewers?: string[];
creator?: string;
// 仓库相关
repository?: string;
owner?: string;
// 时间戳
timestamp?: Date;
}
export interface NotificationMessage {
type: 'text' | 'markdown';
title?: string;
content: string;
atUsers?: string[];
url?: string;
}
```
#### 3.2.2 服务接口
```typescript
// INotificationService
export interface INotificationService {
readonly provider: NotificationProvider;
isEnabled(): boolean;
sendMessage(message: NotificationMessage): Promise<void>;
// 场景特定方法
sendIssueCreatedNotification(context: NotificationContext): Promise<void>;
sendIssueClosedNotification(context: NotificationContext): Promise<void>;
sendIssueAssignedNotification(context: NotificationContext): Promise<void>;
sendPrCreatedNotification(context: NotificationContext): Promise<void>;
sendPrReviewerAssignedNotification(context: NotificationContext): Promise<void>;
}
```
### 3.3 平台差异对照
| 特性 | 飞书(Feishu) | 企业微信(WeCom) | Slack |
|------|--------------|-----------------|-------|
| **Webhook格式** | `open.feishu.cn/open-apis/bot/v2/hook/{key}` | `qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}` | `hooks.slack.com/services/...` |
| **签名机制** | HMAC-SHA256(timestamp+"\n"+secret) | **无** | HMAC-SHA256(timestamp+":"+secret) |
| **@用户方式** | `<at user_id="xxx">` 或文本追加 | `mentioned_list: ["userid"]` 或手机号 | `<@user_id>` |
| **消息类型字段** | `msg_type` | `msgtype` | `type` |
| **内容字段** | `content.text` | `text.content` | `text` |
| **频率限制** | 100次/分钟 | 20条/分钟 | 1次/秒 |
---
## 4. 详细实现方案
### 4.1 目录结构
```
src/
├── services/
│ ├── notification/
│ │ ├── index.ts # 导出入口
│ │ ├── types.ts # 类型定义
│ │ ├── base-notification-service.ts # 抽象基类
│ │ ├── notification-factory.ts # 工厂
│ │ ├── notification-manager.ts # 管理器
│ │ └── providers/
│ │ ├── feishu-notification-service.ts
│ │ └── wecom-notification-service.ts
│ └── notification-manager.ts # 运行时通知管理器入口
```
### 4.2 基类实现
```typescript
// base-notification-service.ts
export abstract class BaseNotificationService implements INotificationService {
abstract readonly provider: NotificationProvider;
constructor(protected config: NotificationServiceConfig) {}
isEnabled(): boolean {
return this.config.enabled && !!this.config.webhookUrl;
}
abstract sendMessage(message: NotificationMessage): Promise<void>;
// 通用模板方法
async sendIssueCreatedNotification(context: NotificationContext): Promise<void> {
const message = this.buildIssueCreatedMessage(context);
await this.sendMessage(message);
}
// 子类实现消息构建
protected abstract buildIssueCreatedMessage(context: NotificationContext): NotificationMessage;
// ... 其他方法类似
}
```
### 4.3 飞书实现要点
```typescript
// feishu-notification-service.ts
export class FeishuNotificationService extends BaseNotificationService {
readonly provider = 'feishu' as const;
async sendMessage(message: NotificationMessage): Promise<void> {
const payload: any = {
msg_type: 'text',
content: {
text: message.content,
},
};
// 添加签名
if (this.config.webhookSecret) {
const timestamp = Math.floor(Date.now() / 1000).toString();
payload.timestamp = timestamp;
payload.sign = this.generateSign(timestamp, this.config.webhookSecret);
}
const response = await fetch(this.config.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Feishu notification failed: ${response.statusText}`);
}
}
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
return {
type: 'text',
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
atUsers: context.assignees,
};
}
private generateSign(timestamp: string, secret: string): string {
const stringToSign = `${timestamp}\n${secret}`;
const hmac = crypto.createHmac('sha256', stringToSign);
return hmac.digest('base64');
}
}
```
### 4.4 企业微信实现要点
```typescript
// wecom-notification-service.ts
export class WeComNotificationService extends BaseNotificationService {
readonly provider = 'wecom' as const;
async sendMessage(message: NotificationMessage): Promise<void> {
const payload: any = {
msgtype: 'text',
text: {
content: message.content,
},
};
// 企业微信使用 mentioned_list
if (message.atUsers?.length) {
payload.text.mentioned_list = message.atUsers.map(u =>
u.toLowerCase() === 'all' ? '@all' : u
);
}
const response = await fetch(this.config.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`WeCom notification failed: ${response.statusText}`);
}
}
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
return {
type: 'text',
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
atUsers: context.assignees,
};
}
}
```
### 4.5 管理器实现
```typescript
// notification-manager.ts
export class NotificationManager {
private services: INotificationService[] = [];
constructor(configs: NotificationServiceConfig[]) {
this.services = configs
.filter(c => c.enabled && c.webhookUrl)
.map(c => NotificationFactory.createService(c));
}
// 广播到所有服务
async broadcast(
operation: (service: INotificationService) => Promise<void>
): Promise<void> {
const results = await Promise.allSettled(
this.services.map(async service => {
try {
await operation(service);
} catch (error) {
logger.error(`${service.provider} notification failed:`, error);
throw error; // 重新抛出以便Promise.allSettled捕获
}
})
);
// 记录失败统计
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
logger.warn(`${failures.length}/${this.services.length} notification services failed`);
}
}
// 便捷方法
async notifyIssueCreated(context: NotificationContext): Promise<void> {
await this.broadcast(s => s.sendIssueCreatedNotification(context));
}
async notifyIssueClosed(context: NotificationContext): Promise<void> {
await this.broadcast(s => s.sendIssueClosedNotification(context));
}
async notifyIssueAssigned(context: NotificationContext): Promise<void> {
await this.broadcast(s => s.sendIssueAssignedNotification(context));
}
async notifyPrCreated(context: NotificationContext): Promise<void> {
await this.broadcast(s => s.sendPrCreatedNotification(context));
}
async notifyPrReviewerAssigned(context: NotificationContext): Promise<void> {
await this.broadcast(s => s.sendPrReviewerAssignedNotification(context));
}
}
```
---
## 5. 配置改造
### 5.1 新增配置字段
```typescript
// config-schema.ts
export const CONFIG_FIELDS: ConfigFieldMeta[] = [
// ... 保留原有 ...
// 飞书配置(改造为可独立启用)
{
envKey: 'FEISHU_ENABLED',
group: 'notification',
label: '启用飞书通知',
description: '是否启用飞书通知',
type: 'boolean',
sensitive: false,
defaultValue: true,
},
{
envKey: 'FEISHU_WEBHOOK_URL',
group: 'notification',
label: '飞书 Webhook 地址',
description: '飞书机器人 Webhook URL',
type: 'url',
sensitive: false,
},
{
envKey: 'FEISHU_WEBHOOK_SECRET',
group: 'notification',
label: '飞书 Webhook 密钥',
description: '飞书 Webhook 签名密钥(可选)',
type: 'string',
sensitive: true,
},
// 企业微信配置(新增)
{
envKey: 'WECOM_ENABLED',
group: 'notification',
label: '启用企业微信通知',
description: '是否启用企业微信通知',
type: 'boolean',
sensitive: false,
defaultValue: false,
},
{
envKey: 'WECOM_WEBHOOK_URL',
group: 'notification',
label: '企业微信 Webhook 地址',
description: '企业微信机器人 Webhook URL',
type: 'url',
sensitive: false,
},
];
```
### 5.2 配置组调整
```typescript
// 将 'feishu' 组改为 'notification' 组
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory';
export const CONFIG_GROUPS: ConfigGroupMeta[] = [
// ...
{
key: 'notification',
label: '通知服务',
description: '飞书、企业微信等通知渠道配置',
icon: 'bell',
},
// ...
];
```
---
## 6. 调用层迁移
### 6.1 review.ts 改造
```typescript
import { getNotificationManager } from '../services/notification-manager';
// PR事件处理
async function handlePullRequestEvent(c: Context, body: any): Promise<Response> {
// ... 原有逻辑 ...
const context: NotificationContext = {
prTitle: pullRequest.title,
prUrl: pullRequest.html_url,
prNumber: pullRequest.number,
reviewers: reviewerUsernames,
repository: repo.name,
owner: repo.owner.login,
actor: body.sender?.login,
};
const notificationManager = getNotificationManager();
if (body.action === 'opened' && reviewerUsernames.length > 0) {
await notificationManager.notifyPrCreated(context);
}
if (body.action === 'review_requested' && body.requested_reviewer) {
context.assignees = [body.requested_reviewer.full_name || body.requested_reviewer.login];
await notificationManager.notifyPrReviewerAssigned(context);
}
// ... 继续原有逻辑 ...
}
// Issue事件处理
async function handleIssueEvent(c: Context, body: any): Promise<Response> {
// ...
const context: NotificationContext = {
issueTitle: issue.title,
issueUrl: issue.html_url,
issueNumber: issue.number,
creator: creatorUsername,
assignees: assigneeUsernames,
repository: repository.name,
actor: body.sender?.login,
};
if (action === 'opened' && assigneeUsernames.length > 0) {
await notificationManager.notifyIssueCreated(context);
} else if (action === 'closed') {
await notificationManager.notifyIssueClosed(context);
} else if (action === 'assigned') {
await notificationManager.notifyIssueAssigned(context);
}
}
```
---
## 7. 落地决策(已执行)
### 7.1 旧飞书服务下线
- 已删除 `src/services/feishu.ts`,不再保留兼容层。
- `src/controllers/review.ts` 中所有通知发送路径已统一到 `NotificationManager`
- 通过单一通知入口避免重复发送与配置路径分裂问题。
### 7.2 运行时配置生效策略
- 通知管理器按当前配置即时创建,不再使用长生命周期缓存。
- 后台保存通知配置后,可立即在后续 webhook 事件生效。
### 7.3 落地检查清单
- [x] 飞书与企业微信通过统一通知抽象发送
- [x] 旧飞书服务文件已下线
- [x] 控制器通知链路已去重
- [x] 前端新增独立“通知管理”菜单与页面
---
## 8. 实施计划
### 8.1 阶段划分
| 阶段 | 任务 | 文件 | 优先级 |
|------|------|------|--------|
| 1 | 核心抽象层 | `types.ts`, `base-notification-service.ts` | P0 |
| 2 | 工厂与管理器 | `notification-factory.ts`, `notification-manager.ts` | P0 |
| 3 | 飞书实现 | `providers/feishu-notification-service.ts` | P1 |
| 4 | 企业微信实现 | `providers/wecom-notification-service.ts` | P1 |
| 5 | 配置改造 | `config-schema.ts`, `config-manager.ts` | P1 |
| 6 | 调用层迁移 | `review.ts` | P1 |
| 7 | 前端通知管理页面 | `App.tsx`, `DashboardPage.tsx`, `NotificationConfigPage.tsx` | P1 |
| 8 | 测试验证 | `config-manager.test.ts` 等 | P0 |
### 8.2 风险与缓解
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 签名算法变更 | 飞书通知失效 | 保持原有签名实现,单元测试覆盖 |
| 配置迁移失败 | 服务无法启动 | 添加配置验证和默认值回退 |
| 多服务并发失败 | 部分通知丢失 | Promise.allSettled 确保独立性 |
---
## 9. 测试策略
### 9.1 单元测试
```typescript
// __tests__/notification.test.ts
describe('NotificationService', () => {
describe('FeishuNotificationService', () => {
it('should generate correct signature', () => {
// 测试签名算法
});
it('should format message correctly', () => {
// 测试消息格式转换
});
});
describe('WeComNotificationService', () => {
it('should use mentioned_list for @users', () => {
// 测试@用户格式
});
});
describe('NotificationManager', () => {
it('should broadcast to all enabled services', async () => {
// 测试广播逻辑
});
it('should not fail if one service fails', async () => {
// 测试容错
});
});
});
```
### 9.2 集成测试
- 配置真实飞书机器人测试消息发送
- 配置企业微信机器人测试消息发送
- 验证同时配置多个服务时的行为
---
## 10. 附录
### 10.1 飞书与企业微信API对比详情
#### 飞书消息格式
```json
{
"msg_type": "text",
"content": {
"text": "Hello <at user_id=\"ou_xxx\">Tom</at>"
}
}
```
#### 企业微信消息格式
```json
{
"msgtype": "text",
"text": {
"content": "Hello World",
"mentioned_list": ["wangqing", "@all"],
"mentioned_mobile_list": ["13800001111"]
}
}
```
### 10.2 扩展指南
添加新通知平台步骤:
1.`types.ts` 添加新的 `NotificationProvider` 类型
2.`providers/` 创建新的服务类,继承 `BaseNotificationService`
3.`notification-factory.ts` 添加创建逻辑
4.`config-schema.ts` 添加配置字段
5. 在 Admin Dashboard 添加UI配置项
---
**文档版本**: 1.0
**创建日期**: 2026-03-24
**作者**: Sisyphus
**状态**: 已实施(持续验证中)

View File

@@ -0,0 +1,836 @@
# 技术设计文档:可插拔 LLM Provider 架构
> **状态**: Draft
> **作者**: AI Architect
> **日期**: 2026-03-04
> **相关 Issue**: N/A
---
## 目录
- [0. 设计原则](#0-设计原则)
- [1. 目录结构](#1-目录结构新增改动部分)
- [2. 数据库表结构](#2-数据库表结构sqlite-ddl)
- [3. LLM Gateway 核心接口](#3-llm-gateway-核心-typescript-接口)
- [4. Provider Adapter 差异映射](#4-四个-provider-adapter-核心差异映射)
- [5. 后端 REST API 契约](#5-后端-rest-api-契约)
- [6. 密钥安全设计](#6-密钥安全设计)
- [7. 前端配置页设计](#7-前端配置页设计)
- [8. 现有调用点改造清单](#8-现有调用点改造清单)
- [9. 实施阶段建议](#9-实施阶段建议)
- [10. 风险与缓解](#10-风险与缓解)
---
## 0. 设计原则
| 原则 | 说明 |
|---|---|
| **UI-Only 配置** | 所有业务配置仅通过 Web 管理后台设置,不再有环境变量覆盖层(仅保留极少数启动参数如 `PORT``WEBHOOK_SECRET``DATABASE_PATH` |
| **4 Provider 并存** | `openai_compatible`(现有兼容格式)、`openai_responses`Responses API`anthropic`Messages API`gemini`generateContent API |
| **SQLite 持久化** | 使用 `bun:sqlite` 零依赖,单文件 `data/assistant.db` |
| **密钥应用层加密** | API Key 使用 AES-256-GCM 加密后存 DB主密钥通过环境变量 `ENCRYPTION_KEY` 传入hex 编码64 字符 = 32 字节),未设置则拒绝启动 |
| **不做向前兼容** | 旧 JSON 配置文件方案直接废弃,新版本仅支持数据库配置 |
### 开源参考
| 借鉴点 | 参考项目 | 具体模式 |
|---|---|---|
| 接口先行 + provider registry | [Vercel AI SDK](https://github.com/vercel/ai) | `LanguageModelV3` spec-first adapter版本化接口 |
| Provider transformation 差异映射 | [LiteLLM](https://github.com/BerriAI/litellm) | 每个 provider 独立 `transformation.py`,标准化 error/usage |
| Runtime factory + 多 provider 配置 | [LobeChat](https://github.com/lobehub/lobe-chat) | `createOpenAICompatibleRuntime` 工厂 + 动态 model list |
| 配置驱动多 endpoint | [LibreChat](https://github.com/danny-avila/LibreChat) | `librechat.yaml` Custom Endpoints 模式 |
| 能力声明/能力检测 | [Continue](https://github.com/continuedev/continue) | `supportsTools` / `supportsImages` 声明式 capability |
---
## 1. 目录结构(新增/改动部分)
```
src/
├── db/
│ ├── database.ts # bun:sqlite 初始化
│ ├── migrations/
│ │ └── 001_init.ts # 建表 DDL
│ └── repositories/
│ ├── provider-repo.ts # llm_providers CRUD
│ ├── model-role-repo.ts # model_role_assignments CRUD
│ ├── secret-repo.ts # 加密 read/write
│ └── settings-repo.ts # system_settings KV
├── llm/
│ ├── types.ts # 统一内部请求/响应类型
│ ├── capabilities.ts # 能力声明枚举
│ ├── errors.ts # LLM 层标准化错误
│ ├── gateway.ts # LLM Gateway 入口(按 provider 路由)
│ ├── tool-converter.ts # 工具定义 → 各 provider 格式转换
│ └── providers/
│ ├── base.ts # LLMProvider 抽象接口
│ ├── openai-compatible.ts # 现有兼容格式 adapter
│ ├── openai-responses.ts # OpenAI Responses API adapter
│ ├── anthropic.ts # Anthropic Messages API adapter
│ └── gemini.ts # Gemini generateContent adapter
├── crypto/
│ └── secrets.ts # AES-256-GCM 加解密 + master key 管理
├── controllers/
│ └── llm-config.ts # 新 REST API替代 config.ts 中 LLM 部分)
└── config/
├── config-manager.ts # 精简:只管非 LLM 配置gitea/feishu/app/admin/review 非模型部分)
└── config-schema.ts # 移除 openai groupLLM 配置全部走 DB
```
---
## 2. 数据库表结构SQLite DDL
### 2.1 ER 关系
```
llm_providers 1 ←──── 1 llm_secrets (每个 provider 一个加密 key)
llm_providers 1 ←──── N model_role_assignments (一个 provider 可服务多个角色)
```
### 2.2 完整 DDL
```sql
-- ============================================================
-- 表1: llm_providers — Provider 实例配置
-- ============================================================
CREATE TABLE llm_providers (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
name TEXT NOT NULL, -- 用户自定义显示名,如 "公司内部 OpenAI 代理"
type TEXT NOT NULL CHECK (type IN (
'openai_compatible', -- 现有兼容格式(自定义 baseUrl
'openai_responses', -- OpenAI 标准 Responses API
'anthropic', -- Anthropic Messages API
'gemini' -- Google Gemini generateContent
)),
base_url TEXT, -- 可选自定义 endpointopenai_compatible 必填)
default_model TEXT NOT NULL, -- 此 provider 的默认模型 ID
is_enabled INTEGER NOT NULL DEFAULT 1, -- 0=禁用, 1=启用
extra_config TEXT DEFAULT '{}', -- JSON: provider 特有配置(如 api_version, project_id 等)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ============================================================
-- 表2: llm_secrets — 加密存储的 API Key
-- ============================================================
CREATE TABLE llm_secrets (
provider_id TEXT PRIMARY KEY REFERENCES llm_providers(id) ON DELETE CASCADE,
ciphertext BLOB NOT NULL, -- AES-256-GCM 密文
iv BLOB NOT NULL, -- 12 bytes nonce
auth_tag BLOB NOT NULL, -- 16 bytes GCM tag
key_version INTEGER NOT NULL DEFAULT 1, -- 主密钥版本号(便于密钥轮换)
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ============================================================
-- 表3: model_role_assignments — 场景 → 模型映射
-- ============================================================
-- 每个业务场景(如 planner/specialist/judge/embedding绑定到
-- 一个 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
)),
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
model TEXT NOT NULL, -- 该场景使用的具体模型 ID
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ============================================================
-- 表4: system_settings — 通用 KV 设置
-- ============================================================
-- 存放非 LLM 的业务配置(由 UI 直接写入 DB
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
is_sensitive INTEGER NOT NULL DEFAULT 0, -- 1=加密存储(复用 crypto 模块)
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 索引
CREATE INDEX idx_providers_type ON llm_providers(type);
CREATE INDEX idx_providers_enabled ON llm_providers(is_enabled);
```
### 2.3 字段说明补充
| 表.字段 | 说明 |
|---|---|
| `llm_providers.type` | 决定使用哪个 adapter 实现 |
| `llm_providers.base_url` | `openai_compatible` 类型必填(用户自建代理地址);其他类型可选覆盖官方默认 endpoint |
| `llm_providers.extra_config` | JSON 字段,存放 provider 特有参数。例如 Gemini 的 `projectId`、OpenAI 的 `organization`、Anthropic 的 `anthropic-version` header 等 |
| `llm_secrets.key_version` | 用于密钥轮换:当 `ENCRYPTION_KEY` 更新后,启动时批量重加密所有 `key_version < current` 的记录 |
| `model_role_assignments.role` | 业务角色枚举,对应代码中不同调用场景 |
| `system_settings.is_sensitive` | 为 1 时 value 字段存密文(复用 `crypto/secrets.ts`GET API 返回 masked |
---
## 3. LLM Gateway 核心 TypeScript 接口
### 3.1 统一消息与请求/响应类型
```typescript
// ── src/llm/types.ts ────────────────────────────────────────
/** 模型角色枚举 */
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
/** 统一消息格式(内部表达,不暴露 provider 差异) */
export interface LLMMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
toolCallId?: string; // role=tool 时关联的 tool call ID
toolCalls?: LLMToolCall[]; // role=assistant 时返回的 tool calls
}
export interface LLMToolCall {
id: string;
name: string;
arguments: string; // JSON string
}
/** 工具定义(内部通用格式,由 tool-converter.ts 转为各 provider 格式) */
export interface LLMToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>; // JSON Schema
}
/** 统一请求 */
export interface LLMChatRequest {
messages: LLMMessage[];
model: string;
temperature?: number;
maxTokens?: number;
responseFormat?: 'text' | 'json'; // 抽象 JSON mode
tools?: LLMToolDefinition[];
/** provider 透传配置(如 Anthropic thinking、Gemini safetySettings */
providerOptions?: Record<string, unknown>;
}
/** 统一响应 */
export interface LLMChatResponse {
content: string | null;
toolCalls: LLMToolCall[];
finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'error';
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
raw?: unknown; // 保留原始响应供调试
}
```
### 3.2 能力模型
```typescript
// ── src/llm/capabilities.ts ─────────────────────────────────
export interface ProviderCapabilities {
/** 是否支持 tool/function calling */
supportsTools: boolean;
/** 是否支持原生 JSON modevs 需要 prompt 指令 + 手动解析) */
supportsJsonMode: boolean;
/** 是否支持 SSE streaming */
supportsStreaming: boolean;
/** 是否支持 embedding 接口 */
supportsEmbeddings: boolean;
/** 是否支持图片/多模态输入 */
supportsMultimodal: boolean;
/** 最大输入 token 数(用于预校验,避免无效调用) */
maxInputTokens?: number;
}
/** 各 provider 默认能力声明 */
export const DEFAULT_CAPABILITIES: Record<string, ProviderCapabilities> = {
openai_compatible: {
supportsTools: true,
supportsJsonMode: true,
supportsStreaming: true,
supportsEmbeddings: true,
supportsMultimodal: false, // 取决于具体模型
},
openai_responses: {
supportsTools: true,
supportsJsonMode: true,
supportsStreaming: true,
supportsEmbeddings: true,
supportsMultimodal: true,
},
anthropic: {
supportsTools: true,
supportsJsonMode: false, // 无原生 JSON mode需 prompt 指令
supportsStreaming: true,
supportsEmbeddings: false,
supportsMultimodal: true,
},
gemini: {
supportsTools: true,
supportsJsonMode: true, // responseMimeType: 'application/json'
supportsStreaming: true,
supportsEmbeddings: true,
supportsMultimodal: true,
},
};
```
### 3.3 Provider 抽象接口
```typescript
// ── src/llm/providers/base.ts ───────────────────────────────
import type { ProviderCapabilities } from '../capabilities';
import type { LLMChatRequest, LLMChatResponse } from '../types';
export interface LLMProvider {
/** Provider 类型标识 */
readonly type: string;
/** 能力声明 */
readonly capabilities: ProviderCapabilities;
/**
* 核心调用方法。Gateway 只调用此方法。
* 各 adapter 负责:
* 1. 将 LLMChatRequest 转为 provider 原生格式
* 2. 发 HTTP / SDK 调用
* 3. 将原生响应转为 LLMChatResponse
*/
chat(request: LLMChatRequest): Promise<LLMChatResponse>;
/** 可选:嵌入接口 */
embed?(texts: string[]): Promise<number[][]>;
}
/** Provider 工厂函数签名:从 DB 配置 + 解密后 apiKey 创建实例 */
export type ProviderFactory = (config: {
baseUrl?: string;
apiKey: string;
defaultModel: string;
extraConfig: Record<string, unknown>;
}) => LLMProvider;
```
### 3.4 Gateway 入口
```typescript
// ── src/llm/gateway.ts ──────────────────────────────────────
import type { ModelRole, LLMChatRequest, LLMChatResponse } from './types';
import type { LLMProvider } from './providers/base';
/**
* LLM Gateway — 业务层唯一入口
*
* 职责:
* 1. 根据 role 查询 model_role_assignments → provider_id + model
* 2. 从 provider 缓存获取或按需创建LLMProvider 实例
* 3. 调用 provider.chat() 并返回统一响应
* 4. 如果 provider 配置变更UI 保存时invalidate 缓存
*/
export class LLMGateway {
/** provider 实例缓存provider_id → LLMProvider */
private cache = new Map<string, LLMProvider>();
/**
* 按业务角色调用 LLM
* @param role 业务角色planner/specialist/judge/embedding
* @param request 请求(不含 model由角色映射决定
*/
async chatForRole(
role: ModelRole,
request: Omit<LLMChatRequest, 'model'>
): Promise<LLMChatResponse>;
/**
* 用指定 provider 直接调用(连通性测试用)
*/
async chatDirect(
providerId: string,
request: LLMChatRequest
): Promise<LLMChatResponse>;
/**
* 获取指定 provider 的 embedding 接口
*/
async embedForRole(
role: 'embedding',
texts: string[]
): Promise<number[][]>;
/** 配置变更时清除单个 provider 缓存 */
invalidateProvider(providerId: string): void;
/** 清除全部缓存(全局配置变更时) */
invalidateAll(): void;
}
```
---
## 4. 四个 Provider Adapter 核心差异映射
### 4.1 总览对照表
| 特性 | openai_compatible | openai_responses | anthropic | gemini |
|---|---|---|---|---|
| **SDK/HTTP** | `openai` npm (`chat.completions`) | `openai` npm (`responses.create`) | `@anthropic-ai/sdk` | `@google/generative-ai` 或 REST |
| **系统指令** | `messages[0].role='system'` | `instructions` 参数 | `system` 顶层参数 | `systemInstruction` 参数 |
| **JSON mode** | `response_format: {type:'json_object'}` | `text.format: {type:'json_object'}` | 无原生支持 → prompt 指令 + `JSON.parse` | `responseMimeType: 'application/json'` + `responseSchema` |
| **工具调用请求** | `tools[].type='function'` + `function.{name,description,parameters}` | `tools[].type='function'` + `function.{name,description,parameters}` | `tools[].name` + `input_schema` | `tools[].functionDeclarations[].{name,description,parameters}` |
| **工具结果返回** | `role: 'tool'` + `tool_call_id` | `type: 'function_call_output'` + `call_id` | `role: 'user'` + `content: [{type:'tool_result', tool_use_id}]` | `role: 'function'` + `parts: [{functionResponse}]` |
| **finish_reason** | `stop` / `tool_calls` / `length` | `stop` / `tool_calls` / ... | `end_turn` / `tool_use` / `max_tokens` | `STOP` / `FUNCTION_CALL` / `MAX_TOKENS` |
| **Token 用量** | `usage.{prompt,completion}_tokens` | `usage.{input,output}_tokens` | `usage.{input,output}_tokens` | `usageMetadata.{prompt,candidates}TokenCount` |
### 4.2 各 Adapter 核心转换逻辑
#### 4.2.1 openai_compatible现有兼容格式
```typescript
// 请求转换:几乎直通(这就是现有代码逻辑的抽象)
// - LLMMessage → OpenAI ChatCompletionMessage (直接映射)
// - responseFormat='json' → { type: 'json_object' }
// - tools → tools[].function (直接映射)
//
// 响应转换:
// - choices[0].message.content → content
// - choices[0].message.tool_calls → toolCalls
// - choices[0].finish_reason → finishReason (直接映射)
// - usage.{prompt,completion}_tokens → usage
```
#### 4.2.2 openai_responsesResponses API
```typescript
// 请求转换:
// - system message 提取为 instructions 参数
// - 非 system messages 转为 input items
// - responseFormat='json' → text: { format: { type: 'json_object' } }
// - tools → tools[].function
//
// 响应转换:
// - output items 中 type='message' → content
// - output items 中 type='function_call' → toolCalls
// - status → finishReason 映射
// - usage.{input,output}_tokens → usage
```
#### 4.2.3 anthropicMessages API
```typescript
// 请求转换:
// - system message 提取为 system 顶层参数
// - 非 system messages → messagesrole 直接映射)
// - responseFormat='json' → 无原生支持,在 system prompt 末尾追加:
// "You MUST respond with valid JSON only. No other text."
// - tools → tools[].{ name, description, input_schema }
// - tool results → role='user', content: [{ type: 'tool_result', tool_use_id, content }]
//
// 响应转换:
// - content blocks: type='text' → content
// - content blocks: type='tool_use' → toolCalls (id, name, JSON.stringify(input))
// - stop_reason: 'end_turn' → 'stop', 'tool_use' → 'tool_calls', 'max_tokens' → 'length'
// - usage.{input,output}_tokens → usage
//
// JSON mode 容错:
// - JSON.parse(content) 失败时,尝试正则提取 ```json...``` 块
```
#### 4.2.4 geminigenerateContent API
```typescript
// 请求转换:
// - system message 提取为 systemInstruction: { parts: [{ text }] }
// - messages → contents[].{ role: 'user'|'model', parts: [{ text }] }
// 注意Gemini 用 'model' 而非 'assistant'
// - responseFormat='json' → generationConfig: {
// responseMimeType: 'application/json',
// responseSchema: <如果有的话>
// }
// - tools → tools: [{ functionDeclarations: [...] }]
// - tool results → role: 'function', parts: [{ functionResponse: { name, response } }]
//
// 响应转换:
// - candidates[0].content.parts: type='text' → content
// - candidates[0].content.parts: functionCall → toolCalls
// - candidates[0].finishReason: 'STOP' → 'stop', 'FUNCTION_CALL' → 'tool_calls', 'MAX_TOKENS' → 'length'
// - usageMetadata.{promptTokenCount, candidatesTokenCount} → usage
```
### 4.3 tool-converter.ts 接口
```typescript
// ── src/llm/tool-converter.ts ───────────────────────────────
import type { LLMToolDefinition } from './types';
/**
* 将内部通用 LLMToolDefinition 转为各 provider 原生格式。
* 由各 adapter 在 chat() 中调用。
*/
/** → OpenAI / OpenAI Compatible 格式 */
export function toOpenAITools(tools: LLMToolDefinition[]): object[];
/** → Anthropic 格式 */
export function toAnthropicTools(tools: LLMToolDefinition[]): object[];
/** → Gemini functionDeclarations 格式 */
export function toGeminiTools(tools: LLMToolDefinition[]): object[];
```
---
## 5. 后端 REST API 契约
所有新 API 挂在 `/admin/api/llm/` 下,复用现有 JWT 鉴权中间件。
### 5.1 Provider 管理
| Method | Path | 说明 |
|---|---|---|
| `GET` | `/admin/api/llm/providers` | 列出所有 provider`hasKey` 布尔,不含明文 key |
| `POST` | `/admin/api/llm/providers` | 创建 provider + 设置 API Key |
| `GET` | `/admin/api/llm/providers/:id` | 获取单个详情 |
| `PUT` | `/admin/api/llm/providers/:id` | 更新name/base_url/default_model/extra_config/is_enabled |
| `DELETE` | `/admin/api/llm/providers/:id` | 删除(级联删 secret + role assignments |
### 5.2 API Key仅 set/clear不回显
| Method | Path | 说明 |
|---|---|---|
| `PUT` | `/admin/api/llm/providers/:id/key` | 设置/更新 API Key |
| `DELETE` | `/admin/api/llm/providers/:id/key` | 清除 API Key |
### 5.3 角色 → 模型映射
| Method | Path | 说明 |
|---|---|---|
| `GET` | `/admin/api/llm/roles` | 列出所有角色及当前绑定 |
| `PUT` | `/admin/api/llm/roles/:role` | 设置角色绑定 |
### 5.4 连通性测试
| Method | Path | 说明 |
|---|---|---|
| `POST` | `/admin/api/llm/providers/:id/test` | 发送简单 prompt 验证 provider 可达 |
### 5.5 通用设置(非 LLM
| Method | Path | 说明 |
|---|---|---|
| `GET` | `/admin/api/settings` | 列出所有sensitive 字段 masked |
| `PUT` | `/admin/api/settings` | 批量更新 |
### 5.6 请求/响应示例
#### 创建 Provider
```jsonc
// POST /admin/api/llm/providers
// Request:
{
"name": "Anthropic Claude",
"type": "anthropic",
"baseUrl": null,
"defaultModel": "claude-sonnet-4-20250514",
"apiKey": "sk-ant-xxxx",
"extraConfig": {}
}
// Response 201:
{
"id": "a1b2c3d4",
"name": "Anthropic Claude",
"type": "anthropic",
"baseUrl": null,
"defaultModel": "claude-sonnet-4-20250514",
"isEnabled": true,
"hasKey": true,
"extraConfig": {},
"createdAt": "2026-03-04T12:00:00Z"
}
```
#### 设置角色绑定
```jsonc
// PUT /admin/api/llm/roles/specialist
// Request:
{
"providerId": "a1b2c3d4",
"model": "claude-sonnet-4-20250514"
}
// Response 200:
{
"role": "specialist",
"providerId": "a1b2c3d4",
"providerName": "Anthropic Claude",
"providerType": "anthropic",
"model": "claude-sonnet-4-20250514"
}
```
#### 连通性测试
```jsonc
// POST /admin/api/llm/providers/a1b2c3d4/test
// Response 200:
{
"success": true,
"latencyMs": 823,
"model": "claude-sonnet-4-20250514",
"message": "Hello! I'm Claude, an AI assistant."
}
// Response 200 (失败):
{
"success": false,
"latencyMs": 5012,
"error": "401 Unauthorized: Invalid API key"
}
```
---
## 6. 密钥安全设计
### 6.1 Master Key 管理
```
启动流程:
1. 读取环境变量 ENCRYPTION_KEYhex 编码64 字符)
├── 未设置或为空 → 抛出错误,拒绝启动
├── 长度不正确 → 抛出错误,提示需要 64 个十六进制字符
└── 正确 → 解码为 32 字节 Buffer
2. 主密钥常驻内存(进程生命周期)
3. 绝对不写入日志、不暴露给 API
```
### 6.2 加密流程(写 API Key
```
输入: plaintext apiKey (string)
1. 生成 12 bytes IV: crypto.randomFillSync(new Uint8Array(12))
2. 创建 cipher: crypto.createCipheriv('aes-256-gcm', masterKey, iv)
3. 加密: ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()])
4. 获取 auth tag: authTag = cipher.getAuthTag() // 16 bytes
5. 写 DB: INSERT INTO llm_secrets (provider_id, ciphertext, iv, auth_tag, key_version)
```
### 6.3 解密流程Gateway 需要调 provider
```
输入: provider_id
1. 从 DB 读取: { ciphertext, iv, auth_tag, key_version }
2. 创建 decipher: crypto.createDecipheriv('aes-256-gcm', masterKey, iv)
3. 设置 auth tag: decipher.setAuthTag(authTag)
4. 解密: plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
5. 返回明文 API Key → 传给 provider factory
6. 解密后的 key 随 provider 实例缓存在 Gateway.cache 中
```
### 6.4 密钥轮换
```
场景: 管理员更换 ENCRYPTION_KEY
1. 启动时读取新的 ENCRYPTION_KEY 环境变量
2. 查询所有 llm_secrets WHERE key_version < current_version
3. 逐条: 用旧 key 解密 → 用新 key 重加密 → 更新 key_version
4. 如果旧 key 不可用(环境变量缺失)→ 启动报错,要求重新设置所有 API Key
```
---
## 7. 前端配置页设计
### 7.1 页面结构
```
Settings 页面
├── 🔌 LLM ProvidersTab 或独立 Card
│ │
│ ├── Provider 列表表格
│ │ ┌──────────────────────────────────────────────────────────────┐
│ │ │ 名称 │ 类型 │ 默认模型 │ Key │ 状态 │ 操作 │
│ │ ├───────────────────┼───────────────┼───────────────┼─────┼────────┼──────┤
│ │ │ 公司 OpenAI 代理 │ OpenAI Compat │ gpt-4o-mini │ ● │ [开关] │ [⋮] │
│ │ │ Anthropic Claude │ Anthropic │ claude-sonnet │ ● │ [开关] │ [⋮] │
│ │ │ Google Gemini │ Gemini │ gemini-2.5-pro│ ○ │ [开关] │ [⋮] │
│ │ └──────────────────────────────────────────────────────────────┘
│ │ + 添加 Provider 按钮
│ │
│ ├── 添加/编辑 Provider 对话框
│ │ ├── 名称 (text input)
│ │ ├── 类型 (select dropdown)
│ │ │ ├── OpenAI Compatible — 兼容 OpenAI 接口的第三方服务
│ │ │ ├── OpenAI Responses — OpenAI 官方 Responses API
│ │ │ ├── Anthropic — Anthropic Messages API
│ │ │ └── Gemini — Google Gemini API
│ │ ├── Base URL (text, 条件显示openai_compatible 必填, 其他可选)
│ │ ├── 默认模型 (text + autocomplete suggestions)
│ │ ├── API Key (password input, 已有时显示 ••••••••)
│ │ ├── ▸ 高级配置 (collapsible, JSON key-value editor)
│ │ └── [测试连接] [保存] [取消]
│ │
│ └── 🧩 角色分配与分级审查映射 区域
│ ┌──────────────────────────────────────────────────────────────┐
│ │ 角色/阶段 │ Provider 下拉 │ 模型 ID │
│ ├──────────────┼──────────────────────┼──────────────────────┤
│ │ Planner(Triage) │ [公司 OpenAI 代理 ▾] │ [gpt-4o-mini ] │
│ │ Specialist(任务执行) │ [Anthropic Claude ▾] │ [claude-sonnet-4 ] │
│ │ Judge(汇总裁决) │ [公司 OpenAI 代理 ▾] │ [gpt-4o ] │
│ │ Embedding(记忆检索) │ [公司 OpenAI 代理 ▾] │ [text-embedding-3 ] │
│ └──────────────────────────────────────────────────────────────┘
│ [保存角色分配]
├── ⚙️ 通用设置(现有 Gitea / 飞书 / App / Review 参数)
│ ├── Agent 分级审查参数small/medium 阈值、token budget、triage 开关
│ └── (复用现有 ConfigManager 组件,数据源统一为 DB)
```
### 7.2 交互规则
| 交互 | 行为 |
|---|---|
| **添加 Provider** | 弹出对话框;类型选择后动态显示/隐藏 `base_url` 字段 |
| **API Key 输入** | 已有 key 时展示 `••••••••`readonly 占位);清空内容后保存 = 删除 key输入新值 = 替换(调用 `PUT /key`);未修改 = 不发请求 |
| **测试连接** | 点击后调 `POST /providers/:id/test`;显示 spinner → 成功绿色 toast延迟+模型)/ 失败红色 toast错误信息 |
| **角色分配下拉** | 仅显示 `is_enabled=true``hasKey=true` 的 provider选择后自动填充该 provider 的 `default_model`(用户可修改) |
| **禁用 Provider** | 如果有角色绑定到此 provider → 弹确认对话框:"此 Provider 正被以下角色使用:[...],禁用后这些角色将无法调用 LLM。确定禁用" |
| **删除 Provider** | 同上,级联影响提示更强烈 |
| **模型建议** | 根据 provider type 显示常见模型建议列表(硬编码在前端,仅作参考,不限制输入) |
### 7.3 模型建议列表(前端硬编码参考)
```typescript
const MODEL_SUGGESTIONS: Record<string, string[]> = {
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'deepseek-chat', 'qwen-plus'],
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o3-mini'],
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022', 'claude-opus-4-20250514'],
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
};
```
---
## 8. 现有调用点改造清单
### 8.1 后端代码改造
| # | 文件 | 当前代码 | 改造为 | 影响范围 |
|---|---|---|---|---|
| 1 | `src/index.ts:69-71` | `const openaiClient = new OpenAI({baseURL, apiKey})` | 删除;初始化 `LLMGateway` 单例并传入业务层 | 入口 |
| 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` 块 | 配置输出 |
### 8.2 前端代码改造
| # | 文件 | 改造内容 |
|---|---|---|
| 1 | `frontend/src/services/configService.ts` | 新增 `llmProviderService.ts`Provider CRUD + Key 管理 + Role 管理 + Test |
| 2 | `frontend/src/components/ConfigManager.tsx` | 添加 "LLM Providers" Tab/Card引入新组件 |
| 3 | 新增 | `frontend/src/components/llm/ProviderList.tsx` — Provider 列表表格 |
| 4 | 新增 | `frontend/src/components/llm/ProviderDialog.tsx` — 添加/编辑对话框 |
| 5 | 新增 | `frontend/src/components/llm/RoleAssignment.tsx` — 角色分配面板 |
### 8.3 配置层改造
| 变更 | 说明 |
|---|---|
| `config-manager.ts` | 精简为只管非 LLM 配置;数据源统一为 `system_settings` 表 |
| `config-schema.ts` | 移除 `openai` group 及其字段;保留 gitea/feishu/app/admin/review非模型字段 |
| `controllers/config.ts` | LLM 相关接口迁到 `controllers/llm-config.ts`;通用配置接口改读写 DB |
| `.env.example` | 移除 `OPENAI_*``REVIEW_MODEL_*`;仅保留启动参数 |
---
## 9. 实施阶段建议
| 阶段 | 内容 | 依赖 | 估时 |
|---|---|---|---|
| **Phase 1: 基础设施** | DB 层 (`bun:sqlite` 初始化 + DDL) + crypto 模块 | 无 | 1d |
| **Phase 2: LLM 抽象层** | `src/llm/` 全部types + capabilities + errors + gateway + 4 adapters + tool-converter | Phase 1 | 2d |
| **Phase 3: 后端 API + 调用点替换** | `controllers/llm-config.ts` + 替换 11 个现有 OpenAI 调用点 + 测试 | Phase 2 | 1.5d |
| **Phase 4: 前端改造** | Provider 管理 + 角色分配 + 连接测试 UI + 通用设置切 DB | Phase 3 | 1.5d |
| **Phase 5: 清理与验收** | 删除旧代码 + 更新文档 + E2E 测试 + `.env.example` 精简 | Phase 4 | 0.5d |
**总计约 6.5 人天。**
### 关键里程碑
```
Day 1: DB + crypto 就绪,配置写入链路打通
Day 3: LLM Gateway 可用4 个 adapter 通过单元测试
Day 4.5: 后端 API 完成,所有调用点已替换,`bun test` 全绿
Day 6: 前端配置页可用,可通过 UI 添加/测试 Provider
Day 6.5: 旧代码清理完毕文档更新Ready for review
```
---
## 10. 风险与缓解
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| **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 Keytrade-off安全性 > 便利性) |
| **SQLite 并发写** | 多请求同时写入可能 SQLITE_BUSY | `bun:sqlite` 开启 WAL mode写操作走单连接序列化读可并行 |
| **Provider SDK 版本冲突** | `openai``@anthropic-ai/sdk``@google/generative-ai` 三个 SDK 共存 | 各 adapter 独立 import无交叉依赖`package.json` 锁定主版本 |
| **配置热更新** | UI 修改 provider 配置后,正在进行的审查仍用旧配置 | Gateway 缓存按 provider_id 粒度 invalidate正在执行的请求不受影响用的是已创建的实例下次请求用新实例 |
---
## 附录 A: 新增依赖
```jsonc
// package.json 新增
{
"dependencies": {
// bun:sqlite 是 Bun 内置,无需安装
"@anthropic-ai/sdk": "^0.39.0", // Anthropic adapter
"@google/generative-ai": "^0.24.0" // Gemini adapter
// "openai" 已存在: "^4.87.3" // OpenAI compatible + Responses adapter
}
}
```
## 附录 B: 环境变量精简
```bash
# .env.example仅保留启动参数
# 应用启动参数(不可通过 UI 设置)
PORT=5174
WEBHOOK_SECRET=your_webhook_secret
DATABASE_PATH=./data/assistant.db # SQLite 文件路径
# 以下配置已迁入数据库,通过 Web UI 管理:
# - LLM Provider 配置API Key / Base URL / Model
# - Gitea 配置API URL / Token
# - 飞书配置Webhook URL / Secret
# - Review 引擎配置
# - 记忆系统配置
```

View File

@@ -0,0 +1,154 @@
# UI Theme Language亮/暗双主题统一规范)
## 目标
- 保证浅色/深色主题视觉一致、可读性稳定。
- 避免组件直接写死颜色,防止后续开发样式漂移。
- 让新增页面默认遵循同一套语义化设计语言。
## 三层设计语言模型
1. **Primitive原子值**HSL 基础值,仅在全局 token 定义处出现。
2. **Semantic语义 token**`background``foreground``success``danger` 等,按语义命名。
3. **Component组件 token**:组件只能消费语义 token不允许跨层引用原子值。
## 当前项目的主题基线
主题定义文件:`frontend/src/index.css`
- 基础语义:`background``foreground``card``muted``border``ring`
- 状态语义:`success``warning``danger``info`
- 补充语义:`surface-muted``surface-elevated``surface-overlay``text-subtle``text-soft``border-soft`
Tailwind 语义映射:`frontend/tailwind.config.js`
- 已将语义 token 映射为可直接使用的 class`bg-success/10``text-danger``border-info/20`)。
### 主色方案(当前)
- 主色选择:**Cobalt Blue钴蓝**,兼顾 light/dark 的对比度与品牌辨识度。
- Light`--primary: 224 76% 52%``--primary-foreground: 0 0% 100%`
- Dark`--primary: 224 88% 68%``--primary-foreground: 224 40% 12%`
- 焦点环:`--ring``--primary` 保持同色,确保交互一致性。
- 设计理由:亮色下避免过“脏”或偏绿感;暗色下提升明度保证可见性,同时用深色前景保证主按钮文字对比。
## 可选整套主题色方案(社区开源复用)
> 要求:切换的是**整套语义 token**,不是只改 `primary`。
当前支持四套(统一冷调科技风):
1. `cobalt`(默认)
- 本项目当前默认冷色科技风(自定义)。
2. `zinc`
- 来源shadcn/ui themesMIT
- 参考:<https://github.com/shadcn-ui/ui/blob/main/apps/v4/public/r/themes.css>
3. `nord`
- 来源NordMIT
- 参考:<https://github.com/nordtheme/nord>
4. `tokyo-night`
- 来源Tokyo NightApache-2.0
- 参考:<https://github.com/folke/tokyonight.nvim>
实现方式:
- `cobalt` 作为内置基础主题,直接由 `:root` / `.dark` 提供默认 token。
- 其余方案(`zinc|nord|tokyo-night`)通过 `data-palette` 覆盖:
- `:root[data-palette='*']` 覆盖浅色 token
- `.dark[data-palette='*']` 覆盖暗色 token
- 在根节点写入 `data-palette``cobalt|zinc|nord|tokyo-night`)。
- 组件侧不改业务 class继续消费语义 token。
## 页面风格骨架(社区方案落地)
> 目标:即使切换配色,页面结构、密度、层级、动效仍保持统一。
本项目采用了三类社区成熟范式,并映射到本仓库 utility
1. **4px 节奏与密度系统shadcn/仪表盘实践)**
- 基础节奏按 4px 递进,主内容区使用 `theme-page-content`(统一宽度 + 留白节奏)。
- 卡片内部与卡片间距默认采用 `p-6 / gap-6` 级别,避免页面“块状松散或拥挤”。
2. **三层深度系统(卡片/悬浮/遮罩)**
- 统一卡片外观:`theme-card-shell` + `theme-card-header` + `theme-card-content`
- 交互抬升统一:`theme-interactive-elevate`(轻微位移 + 阴影,不做夸张动效)。
- 页面壳层统一:`theme-shell-gradient` + `theme-sticky-bar`
3. **可控动效系统Linear/Vercel 风格)**
- Hover/按钮反馈优先短时平滑动效,避免大幅动画导致“廉价感”。
- 表单输入统一 `theme-input-surface`,状态条与统计胶囊统一 `theme-control-pill`
参考来源:
- shadcn/ui themes 与组件风格实践MIT<https://github.com/shadcn-ui/ui>
- Vercel Dashboard 设计迭代思路:<https://vercel.com/changelog/dashboard-navigation-redesign-rollout>
- Nord / Tokyo Night 社区配色体系:
- <https://github.com/nordtheme/nord>
- <https://github.com/folke/tokyonight.nvim>
## 强制规则(必须遵守)
1. **禁止在业务 TSX 中使用硬编码暗色类**:如 `bg-zinc-*``text-zinc-*``border-white/10`(历史 UI 基础组件逐步迁移,不作为新增业务代码例外)。
2. **禁止在组件内写死颜色值**:如 `rgba(...)``#xxxxxx``rgb(...)`
3. **状态色统一语义化**:成功/警告/错误/信息统一用 `success|warning|danger|info`
4. **弹窗/卡片/表格优先使用语义表面色**`card``muted``popover``background`
5. **交互阴影统一工具类**`theme-glow-primary|success|warning|danger`
6. **普通 hover 反馈禁止用主色背景**:非主操作控件统一使用 `hover:bg-accent*``hover:bg-muted*`,避免亮色主题出现重色块。
## 推荐 class 使用方式
- 文字层级:`text-foreground` / `text-muted-foreground`
- 面板层级:`bg-card` / `bg-muted/50` / `bg-popover`
- 边框层级:`border-border` / `theme-border-soft`
- 状态展示:`text-success``bg-danger/10``border-warning/30`
- 普通交互 hover`hover:bg-accent/60``hover:bg-accent``hover:bg-muted/60`
- 主操作 hover仅主按钮可用 `hover:bg-primary/90`
- 顶部吸附操作栏:`theme-sticky-bar`
- 页面骨架:`theme-page-frame` / `theme-page-actions` / `theme-page-content`
- 卡片骨架:`theme-card-shell` / `theme-card-header` / `theme-card-content`
- 弹窗骨架:`theme-dialog-panel` / `theme-dialog-header` / `theme-dialog-body` / `theme-dialog-footer`
- 错误态容器:`theme-error-panel`
- 模态遮罩:`theme-surface-overlay`
## 页面级统一约束(防止布局风格漂移)
1. 页面容器优先使用 `theme-page-frame`,避免每个页面自行定义高度和底部间距。
2. 顶部操作区统一使用 `theme-sticky-bar + theme-page-actions`,避免按钮栏视觉断层。
3. 主内容区统一使用 `theme-page-content`,确保横向节奏和留白一致。
4. 标准业务卡片统一使用 `theme-card-shell/header/content`,避免同类卡片出现不同边框/背景层级。
5. 错误提示统一使用 `theme-error-panel`,保持状态反馈视觉语言一致。
## destructive 与 danger 的约定
- `destructive`:保留给 shadcn 组件内置 destructive 变体语义。
- `danger`:业务状态语义(报错、失败、风险提示)统一使用。
- 新业务组件优先使用 `danger`,避免 `destructive/danger` 混用造成漂移。
## 新功能开发检查清单
- [ ] 页面在 light/dark 下均可读(文本、边框、状态色有对比度)
- [ ]`zinc/white` 等暗色硬编码 class
- [ ] 无内联 `style` 颜色值
- [ ] 状态色全部使用语义 token
- [ ] 组件未绕过语义层直接访问原子颜色
- [ ] `bun run ui:visual` 通过light/dark 关键页面视觉回归)
## 视觉基线截图回归Playwright
- 生成/更新基线:`bun run ui:visual:update`
- 校验基线一致性:`bun run ui:visual`
- 轻量 UI 全链路:`bun run ui:regression && bun run ui:visual`
约定:
1. PR 默认运行 `ui:visual`,出现 diff 必须人工确认是“预期视觉变更”。
2. 只有在确认设计变更成立时,才执行 `ui:visual:update` 更新基线并提交快照。
3. 不允许在未更新设计规范的情况下大量更新视觉基线,避免把漂移“固化为正确”。
4. 基线快照以 Linux CI 环境为准(当前为 `*-linux.png`),避免跨系统更新导致快照噪声。
## 迁移策略
当新增模块时,按以下顺序处理:
1. 先补充语义 token如确有新语义而不是新颜色
2. 在 Tailwind 映射语义 token。
3. 在组件中只消费语义 class。
4. 最后做 light/dark 视觉回归。

View File

@@ -2,92 +2,68 @@
## Prerequisites
- [Bun](https://bun.sh) >= 1.2.5
- Bun >= 1.2.5
- A reachable Gitea instance
- At least one LLM provider credential (OpenAI, Anthropic, Gemini, or compatible)
- At least one LLM provider credential
## Install
```bash
git clone https://github.com/jeffusion/gitea-ai-assistant.git
git clone https://github.com/user/gitea-ai-assistant.git
cd gitea-ai-assistant
bun install
```
`bun install` at repository root installs frontend dependencies via `postinstall`. If lifecycle scripts are disabled:
`bun install` at repository root installs frontend dependencies via `postinstall`.
If lifecycle scripts are disabled:
```bash
bun run bootstrap
```
## Configure environment
## Minimal environment
Create `.env` in the project root:
Create `.env`:
```bash
ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
# PORT=5174
PORT=5174
ENCRYPTION_KEY= # required, generate with: openssl rand -hex 32
# DATABASE_PATH=./data/assistant.db
# LOG_LEVEL=info
# LOG_LEVEL=info # local dev default; use LOG_LEVEL=error in production
```
`ENCRYPTION_KEY` is required — the application refuses to start without it. It is the AES-256-GCM master key for encrypting API keys stored in the database.
See [Configuration](./configuration.md) for all environment variables and runtime settings.
> `ENCRYPTION_KEY` is required. Application startup fails when it is missing.
## Run
```bash
bun run dev # development with hot reload
bun run start # production mode
bun run dev
# or
bun run start
```
Open `http://localhost:5174` to access the Admin UI.
## First login
- Default admin password is `password` on first boot.
- **Change it immediately** after login (Security section in Admin UI).
## Configure in Admin UI
The Admin UI manages all runtime settings stored in SQLite. You only need `.env` for infrastructure bootstrap values.
![Dashboard](./assets/page-repos.png)
Key settings to configure:
1. **Gitea** — API URL, access token
2. **LLM Providers** — add at least one provider (OpenAI Compatible, Anthropic, Gemini, etc.) with API key and default model
3. **Webhook Secret** — used for HMAC-SHA256 signature verification
See [Screenshots](./screenshots.md) for a full UI gallery.
- Open `http://your-server:5174`
- Default admin password is `password` on first boot
- Change admin password immediately after login
## Webhook setup
### Option A: Admin UI (recommended)
In the repository list page, click the enable button. The system auto-provisions the webhook in Gitea.
In repository list, click enable to auto-provision webhook.
### Option B: Manual
In Gitea repository settings → Webhooks → Add webhook:
In Gitea repository settings:
| Field | Value |
|---|---|
| URL | `http://your-server:5174/webhook/gitea` |
| Content Type | `application/json` |
| Secret | Same value as configured in Admin UI |
| Events | Pull Request + Status |
- URL: `http://your-server:5174/webhook/gitea`
- Content Type: `application/json`
- Secret: same value as dashboard webhook secret
- Events: Pull Request + Status
## Health check
## Health endpoint
```
GET /api/health
```
## Next steps
- [Configuration reference](./configuration.md) — all settings and runtime model
- [Review engines](./review-engines.md) — Agent engine, Codex engine, review modes
- [Deployment](./deployment.md) — Docker, Compose, Kubernetes
Use `/api/health` to check service status.

View File

@@ -2,92 +2,68 @@
## 环境要求
- [Bun](https://bun.sh) >= 1.2.5
- Bun >= 1.2.5
- 可访问的 Gitea 实例
- 至少一个 LLM 提供商凭证OpenAI、Anthropic、Gemini 或兼容接口)
- 至少一个 LLM 提供商凭证
## 安装
```bash
git clone https://github.com/jeffusion/gitea-ai-assistant.git
git clone https://github.com/user/gitea-ai-assistant.git
cd gitea-ai-assistant
bun install
```
在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装前端依赖。如果环境禁用了生命周期脚本:
在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装 `frontend` 依赖。
如果你的环境禁用了生命周期脚本:
```bash
bun run bootstrap
```
## 配置环境变量
## 最小环境变量
在项目根目录创建 `.env` 文件:
创建 `.env` 文件:
```bash
ENCRYPTION_KEY=<使用 openssl rand -hex 32 生成>
# PORT=5174
PORT=5174
ENCRYPTION_KEY= # 必填,使用 openssl rand -hex 32 生成
# DATABASE_PATH=./data/assistant.db
# LOG_LEVEL=info
# LOG_LEVEL=info # 本地开发默认;生产环境请使用 LOG_LEVEL=error
```
`ENCRYPTION_KEY` 为必填项——缺失时服务会拒绝启动。它是用于加密数据库中 API Key 的 AES-256-GCM 主密钥。
所有环境变量和运行时设置参见 [配置参考](./configuration.zh-CN.md)。
> `ENCRYPTION_KEY` 为必填项缺失时服务会拒绝启动。
## 启动服务
```bash
bun run dev # 开发模式(热重载)
bun run start # 生产模式
bun run dev
# 或
bun run start
```
访问 `http://localhost:5174` 进入管理后台。
## 首次登录
- 访问 `http://your-server:5174`
- 首次启动默认管理员密码为 `password`
- **登录后请立即修改密码**(管理后台「安全」分区)
## 管理后台配置
管理后台管理所有持久化到 SQLite 的运行时配置。`.env` 仅用于基础设施级引导参数。
![管理后台](./assets/page-repos.png)
需要配置的关键项:
1. **Gitea** — API URL、Access Token
2. **LLM Provider** — 添加至少一个提供商OpenAI Compatible、Anthropic、Gemini 等)并配置 API Key 和默认模型
3. **Webhook Secret** — 用于 HMAC-SHA256 签名验证
完整界面截图参见 [截图集](./screenshots.zh-CN.md)。
- 登录后请立即修改管理员密码
## Webhook 配置
### 方式 A管理后台推荐
在仓库列表点击启用按钮,系统自动在 Gitea 中配置 Webhook。
在仓库列表点击启用按钮,系统自动配置 webhook。
### 方式 B手动配置
在 Gitea 仓库设置 → Webhooks → 添加 Webhook
在 Gitea 仓库设置中配置
| 字段 | 值 |
|---|---|
| URL | `http://your-server:5174/webhook/gitea` |
| Content Type | `application/json` |
| Secret | 与管理后台中配置的 Webhook Secret 保持一致 |
| 事件 | Pull Request + Status |
- URL`http://your-server:5174/webhook/gitea`
- Content Type`application/json`
- Secret与管理后台中的 Webhook Secret 保持一致
- 事件Pull Request + Status
## 健康检查
```
GET /api/health
```
## 下一步
- [配置参考](./configuration.zh-CN.md) — 完整设置项与运行时模型
- [审查引擎](./review-engines.zh-CN.md) — Agent 引擎、Codex 引擎与审查模式
- [部署指南](./deployment.zh-CN.md) — Docker、Compose、Kubernetes
可通过 `/api/health` 查看服务状态。

View File

@@ -1,81 +1,37 @@
# Review Engines
The system supports two review engines, selected by `REVIEW_ENGINE` in Admin UI.
## Overview
The system supports two engines:
- `agent`: native staged review pipeline
- `codex`: Codex CLI-backed review pipeline
Engine is selected by `REVIEW_ENGINE` runtime configuration.
## Agent engine
The Agent engine uses a dynamic agent framework. It prepares the workspace and review context, then starts a main agent to perform the review.
### How it works
1. **Main Agent** — the entrypoint agent that coordinates the review. It uses available tools to analyze code changes.
2. **Dynamic Subagents** — the main agent can autonomously spawn subagents for focused tasks (e.g. searching code, reading files). Subagents are created at runtime through tool calls, not hardcoded in the workflow.
3. **Deterministic Publishing** — findings and comments are collected and processed outside the agent loop. The system normalizes, deduplicates, and filters findings deterministically before posting to Gitea.
Agent engine classifies changes and dispatches specialist tasks.
### Review modes
| Mode | Behavior |
|---|---|
| `skip` | Low-risk changes bypass review entirely |
| `light` | Minimal checks for low-risk code changes |
| `full` | Complete review for risky or large changes |
- `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
### Size policy
Change size determines execution mode and token budgets:
| Size | Typical threshold |
|---|---|
| `small` | Few lines changed |
| `medium` | Moderate change set |
| `large` | Significant refactoring or many files |
> Size and mode are separate layers: `small/medium/large` classifies how big the change is; `skip/light/full` controls how deeply the engine reviews it.
`small`/`medium`/`large` thresholds are used by triage to choose mode and token budgets.
## Codex engine
The Codex engine runs review through Codex CLI with independent runtime settings:
Codex engine runs review through Codex CLI with independent runtime settings:
| Setting | Description |
|---|---|
| `CODEX_API_URL` | Codex API endpoint |
| `CODEX_API_KEY` | Codex API key |
| `CODEX_MODEL` | Model to use |
| `CODEX_TIMEOUT_MS` | Request timeout |
| `CODEX_REVIEW_PROMPT` | Custom review prompt |
## Agent definitions
Agent definitions are Markdown files with YAML frontmatter stored in the reviewed repository:
```
.gitea-assistant/agents/*.md
```
Each file defines:
- **System prompt** — instructions for the agent
- **Model** — which LLM model to use (optional; falls back to runtime defaults)
- **Max turns** — limit for the agent loop
- **Tools** — which tools the agent can access
### Model resolution
When the main agent spawns a subagent, the model is resolved in this order:
1. `spawn` override (explicit in the tool call)
2. `AgentDefinition.model` (declared in the agent definition file)
3. `AGENT_DEFAULT_SUBAGENT_MODEL` (runtime config)
4. `AGENT_MAIN_MODEL` (runtime config)
## Tool permissions
Tool permissions are controlled within each agent's definition file:
| Field | Type | Description |
|---|---|---|
| `tools` | Allow list | Tool names the agent is permitted to call. Empty list grants no tools. |
| `disallowedTools` | Deny list | Tool names the agent is explicitly forbidden from calling. Takes precedence over `tools`. |
- `CODEX_API_URL`
- `CODEX_API_KEY`
- `CODEX_MODEL`
- `CODEX_TIMEOUT_MS`
- `CODEX_REVIEW_PROMPT`
## Event support
@@ -86,5 +42,5 @@ Both engines process:
## Output
- PR/commit summary comment (posted as an issue comment)
- Line-level findings with confidence and severity (posted as review comments)
- PR/commit summary comment
- Line-level findings with confidence and severity

View File

@@ -1,81 +1,37 @@
# 审查引擎
系统支持两种审查引擎,通过管理后台的 `REVIEW_ENGINE` 配置选择。
## 概览
系统支持两种审查引擎:
- `agent`:原生任务化分级审查
- `codex`:基于 Codex CLI 的审查流水线
通过运行时配置 `REVIEW_ENGINE` 选择引擎。
## Agent 引擎
Agent 引擎使用动态 Agent 框架执行代码审查。它会准备工作区与审查上下文,然后启动主 Agent 执行审查任务。
### 工作原理
1. **主 Agent** — 协调审查流程的入口 Agent使用可用工具分析代码变更。
2. **动态子 Agent** — 主 Agent 可在运行时自主生成子 Agent执行聚焦任务如搜索代码、读取文件。子 Agent 通过工具调用动态创建,而非硬编码在工作流中。
3. **确定性发布** — 审查发现与评论在 Agent 循环之外收集和处理。系统在发布到 Gitea 之前,对发现进行确定性的规范化、去重和过滤。
Agent 引擎会先做变更分流,再按领域派发 specialist 任务。
### 审查模式
| 模式 | 行为 |
|---|---|
| `skip` | 低风险改动完全跳过审查 |
| `light` | 对低风险代码执行最小化检查 |
| `full` | 对高风险或大规模改动执行完整审查 |
- `skip`:低风险改动可跳过 specialist
- `light`:对低风险代码执行最小化专项检查
- `full`:对高风险或大规模改动执行完整审查
### 规模策略
变更规模决定执行模式与 Token 预算
| 规模 | 典型阈值 |
|---|---|
| `small` | 少量行变更 |
| `medium` | 中等变更集 |
| `large` | 大规模重构或多文件变更 |
> 规模与模式是两个层次:`small/medium/large` 分类变更的大小;`skip/light/full` 控制审查的深度。
`small` / `medium` / `large` 阈值用于 triage 阶段决策模式与 token 预算
## Codex 引擎
Codex 引擎通过 Codex CLI 执行审查,支持独立配置:
| 设置项 | 说明 |
|---|---|
| `CODEX_API_URL` | Codex API 端点 |
| `CODEX_API_KEY` | Codex API 密钥 |
| `CODEX_MODEL` | 使用的模型 |
| `CODEX_TIMEOUT_MS` | 请求超时时间 |
| `CODEX_REVIEW_PROMPT` | 自定义审查提示词 |
## Agent 定义
Agent 定义以带 YAML Frontmatter 的 Markdown 文件形式存储在被审查的仓库中:
```
.gitea-assistant/agents/*.md
```
每个文件定义:
- **系统提示词** — Agent 的指令
- **模型** — 使用的 LLM 模型(可选;未指定时使用运行时默认值)
- **最大轮数** — Agent 循环上限
- **工具** — Agent 可使用的工具
### 模型解析
主 Agent 生成子 Agent 时,模型按以下顺序解析:
1. `spawn` 覆盖(工具调用中显式指定)
2. `AgentDefinition.model`Agent 定义文件中声明)
3. `AGENT_DEFAULT_SUBAGENT_MODEL`(运行时配置)
4. `AGENT_MAIN_MODEL`(运行时配置)
## 工具权限
工具权限在每个 Agent 的定义文件中控制:
| 字段 | 类型 | 说明 |
|---|---|---|
| `tools` | 白名单 | 允许该 Agent 调用的工具名称。空列表表示不授予任何工具权限 |
| `disallowedTools` | 黑名单 | 显式禁止该 Agent 调用的工具名称。优先级高于白名单 |
- `CODEX_API_URL`
- `CODEX_API_KEY`
- `CODEX_MODEL`
- `CODEX_TIMEOUT_MS`
- `CODEX_REVIEW_PROMPT`
## 事件支持
@@ -86,5 +42,5 @@ Agent 定义以带 YAML Frontmatter 的 Markdown 文件形式存储在被审查
## 输出
- PR/提交总结评论(作为 Issue 评论发布)
- 行级问题(含置信度与严重性,作为审查评论发布
- PR/提交总结评论
- 行级问题(含置信度与严重性)

View File

@@ -11,6 +11,8 @@ RUN bun install --no-frozen-lockfile
COPY src ./src
COPY tsconfig.json .
COPY frontend/dist ./public
EXPOSE 5174
CMD ["bun", "run", "start"]

View File

@@ -1,258 +0,0 @@
# 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 生成的 tokenassistant 用它 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。

View File

@@ -0,0 +1,169 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import {
E2ETestHarness,
type Finding,
type Scenario,
type SessionDetail,
} from './e2e-test-harness';
function assertFindingsMatchScenario(findings: Finding[], scenario: Scenario): void {
expect(findings.length).toBeGreaterThanOrEqual(scenario.minFindings);
if (scenario.maxFindings !== undefined) {
expect(findings.length).toBeLessThanOrEqual(scenario.maxFindings);
}
const highSeverityCount = findings.filter((finding) => finding.severity === 'high').length;
expect(highSeverityCount).toBeGreaterThanOrEqual(scenario.minHighSeverity);
const fingerprints = findings
.map((finding) => finding.fingerprint)
.filter((value): value is string => Boolean(value));
expect(new Set(fingerprints).size).toBe(fingerprints.length);
}
function expectPipelineStepsCompleted(detail: SessionDetail): void {
const statusesByKey = new Map(detail.plan.map((step) => [step.key, step.status]));
expect(statusesByKey.get('prepare_workspace')).toBe('completed');
expect(statusesByKey.get('build_context')).toBe('completed');
expect(statusesByKey.get('review:triage')).toBe('completed');
expect(statusesByKey.get('review:full_review')).toBe('completed');
expect(statusesByKey.get('aggregate_findings')).toBe('completed');
expect(statusesByKey.get('publish_review')).toBe('completed');
expect(statusesByKey.get('save_reviewed_ref')).toBe('completed');
}
function expectAutonomousFullReviewPipeline(detail: SessionDetail): void {
const fullReviewInvocations = detail.subagentInvocations.filter(
(invocation) => invocation.subagentName === 'review:full_review'
);
expect(fullReviewInvocations).toHaveLength(1);
expect(fullReviewInvocations[0].status).toBe('completed');
expect(detail.checkpoint?.state?.reviewCompleted).toBe(true);
expect(detail.checkpoint?.state?.published).toBe(true);
expect(detail.checkpoint?.state?.reviewedRefSaved).toBe(true);
expect(detail.checkpoint?.state?.reviewDiagnostics?.toolCallNames).toEqual([
'search_code',
'read_file',
'read_file',
]);
expect(detail.checkpoint?.state?.reviewDiagnostics?.stopReason).toBe('modelFinalized');
const findings = detail.checkpoint?.state?.findings ?? [];
expect(findings.length).toBeGreaterThan(0);
expect(findings[0].detail).toContain('auth/user model');
expect(findings[0].evidence).toContain('src/auth.ts');
const publishedComments = detail.runDetails?.comments?.filter(
(comment) => comment.status === 'published'
);
expect(publishedComments?.length).toBeGreaterThan(0);
expect(publishedComments?.some((comment) => !comment.path)).toBe(true);
expect(publishedComments?.some((comment) => comment.path === 'src/user-handler.ts')).toBe(true);
}
describe('E2E Review Flow', () => {
const harness = new E2ETestHarness();
beforeAll(async () => {
await harness.start();
await harness.seedGitea();
}, 90_000);
afterAll(async () => {
await harness.stop();
});
test('核心链路验证: webhook → clone → triage → full_review → aggregate → publish → save ref → Gitea has comments', async () => {
const { owner, repo, prNumber } = await harness.seedPR('simple-bug-pr');
const webhookResponse = await harness.triggerWebhook(owner, repo, prNumber);
expect(webhookResponse.status).toBe('accepted');
const result = await harness.waitForReview(owner, repo, prNumber, 120);
expect(result.completed).toBe(true);
expect(result.sessionState).toBe('completed');
expectPipelineStepsCompleted(result.detail);
expect(result.detail.checkpoint?.state?.published).toBe(true);
expectAutonomousFullReviewPipeline(result.detail);
const comments = await harness.getGiteaComments(owner, repo, prNumber);
expect(comments.length).toBeGreaterThan(0);
}, 150_000);
test('状态正确性: session status transitions and checkpoint consistency', async () => {
const { owner, repo, prNumber } = await harness.seedPR('security-pr');
await harness.triggerWebhook(owner, repo, prNumber);
const snapshot = await harness.waitForSessionSnapshot(owner, repo, prNumber, 30);
expect(['queued', 'planning', 'executing', 'completed']).toContain(
snapshot.detail.summary.status
);
const result = await harness.waitForReview(owner, repo, prNumber, 120);
expect(['queued', 'planning', 'executing', 'completed']).toContain(result.observedStates[0]);
expect(result.sessionState).toBe('completed');
expect(result.detail.checkpoint?.stopReason).toBe('completed');
expect(result.detail.checkpoint?.pendingTasks ?? []).toHaveLength(0);
expect(result.detail.summary.findingCount).toBe(harness.extractFindings(result.detail).length);
}, 150_000);
test('Findings 质量: fixtures trigger expected triage modes, autonomous full review, and finding counts', async () => {
const fixtureNames = ['simple-bug-pr', 'minimal-change-pr'];
for (const fixtureName of fixtureNames) {
const { owner, repo, prNumber, scenario } = await harness.seedPR(fixtureName);
await harness.triggerWebhook(owner, repo, prNumber);
const result = await harness.waitForReview(owner, repo, prNumber, 120);
expect(result.sessionState).toBe('completed');
const triageMode = harness.extractTriageMode(result.detail);
if (triageMode !== undefined) {
expect(triageMode).toBe(scenario.expectedTriageMode);
}
expectPipelineStepsCompleted(result.detail);
expect(result.detail.subagentInvocations).toEqual(
expect.arrayContaining([
expect.objectContaining({ subagentName: 'review:full_review', status: 'completed' }),
])
);
assertFindingsMatchScenario(harness.extractFindings(result.detail), scenario);
}
}, 360_000);
test('幂等性: duplicate webhook does not create duplicate comments', async () => {
const { owner, repo, prNumber } = await harness.seedPR('duplicate-webhook-pr');
await harness.triggerWebhook(owner, repo, prNumber);
const firstResult = await harness.waitForReview(owner, repo, prNumber, 120);
expect(firstResult.sessionState).toBe('completed');
const firstComments = await harness.getGiteaComments(owner, repo, prNumber);
expect(firstComments.length).toBeGreaterThan(0);
const duplicateWebhookResponse = await harness.triggerWebhook(owner, repo, prNumber);
expect(['accepted', 'deduplicated']).toContain(duplicateWebhookResponse.status);
const secondResult = await harness.waitForReview(owner, repo, prNumber, 60);
expect(secondResult.sessionId).toBe(firstResult.sessionId);
const secondComments = await harness.getGiteaComments(owner, repo, prNumber);
expect(secondComments.length).toBe(firstComments.length);
expect(new Set(secondComments.map((comment) => comment.body)).size).toBe(
new Set(firstComments.map((comment) => comment.body)).size
);
}, 180_000);
test('错误恢复: clone failure marks session failed, not stuck', async () => {
const { owner, repo, prNumber } = await harness.seedPR('clean-refactor-pr');
await harness.triggerWebhook(owner, repo, prNumber, {
repositoryPatch: {
clone_url: `http://invalid-host-99999.local/${owner}/${repo}-missing.git`,
},
});
const result = await harness.waitForReview(owner, repo, prNumber, 120);
expect(['completed', 'failed']).toContain(result.sessionState);
}, 150_000);
});

View File

@@ -0,0 +1,748 @@
import { createHmac } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
const ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
const WEBHOOK_SECRET = 'e2e-test-webhook-secret';
const TERMINAL_STATES = new Set(['completed', 'failed', 'ignored', 'cancelled', 'error']);
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
interface GiteaUser {
login: string;
full_name?: string;
}
interface GiteaRepo {
id: number;
name: string;
full_name: string;
clone_url: string;
html_url: string;
ssh_url?: string;
owner: GiteaUser;
}
interface GiteaPullRequest {
id: number;
number: number;
title: string;
html_url: string;
head: {
ref: string;
sha: string;
repo?: GiteaRepo;
};
base: {
ref: string;
sha: string;
repo?: GiteaRepo;
};
requested_reviewers?: GiteaUser[];
user?: GiteaUser;
}
interface Scenario {
name: string;
description: string;
expectedTriageMode: string;
expectedDomains: string[];
minFindings: number;
maxFindings?: number;
minHighSeverity: number;
testIdempotency?: boolean;
}
interface AdminLoginResponse {
token: string;
}
interface SessionSummary {
sessionId: string;
owner?: string;
repo?: string;
prNumber?: number;
status: string;
findingCount: number;
}
interface SessionListEntry {
session: {
id: string;
metadata?: Record<string, JsonValue>;
};
summary: SessionSummary;
}
interface SessionListResponse {
data: SessionListEntry[];
}
interface Finding {
severity?: string;
confidence?: number;
path?: string;
line?: number;
title?: string;
detail?: string;
evidence?: string;
category?: string;
domain?: string;
fingerprint?: string;
}
interface SessionDetail {
session: {
id: string;
metadata?: Record<string, JsonValue>;
};
summary: SessionSummary;
checkpoint: {
stopReason?: string;
pendingTasks?: Array<{ name: string }>;
state?: {
targetSha?: string;
triage?: {
mode?: string;
domains?: string[];
};
triageMode?: string;
findings?: Finding[];
published?: boolean;
reviewedRefSaved?: boolean;
reviewCompleted?: boolean;
reviewedRef?: string;
reviewDiagnostics?: {
toolCallNames?: string[];
toolCallCount?: number;
parsedFindingCount?: number;
stopReason?: string;
};
};
} | null;
plan: Array<{ key: string; status: string; label: string }>;
events: Array<{ eventType: string; payload: Record<string, JsonValue> }>;
runDetails: {
findings?: Finding[];
comments?: Array<{
status?: string;
path?: string;
line?: number;
body?: string;
fingerprint?: string;
}>;
} | null;
subagentInvocations: Array<{
subagentName: string;
status: string;
result?: Record<string, JsonValue>;
}>;
}
interface GiteaTokenResponse {
sha1?: string;
token?: string;
}
interface CommentLike {
id: number;
body: string;
path?: string;
line?: number;
}
interface SeedResult {
owner: string;
repo: string;
prNumber: number;
scenario: Scenario;
}
interface ReviewWaitResult {
completed: boolean;
sessionState: string;
sessionId: string;
detail: SessionDetail;
observedStates: string[];
}
interface TriggerWebhookOptions {
repositoryPatch?: Partial<GiteaRepo>;
action?: string;
}
export class E2ETestHarness {
readonly giteaUrl = (process.env.E2E_GITEA_URL ?? 'http://localhost:3333').replace(/\/$/, '');
readonly adminUser = process.env.E2E_GITEA_ADMIN_USER ?? 'e2e-admin';
readonly adminPass = process.env.E2E_GITEA_ADMIN_PASS ?? 'e2ePassword123!';
private assistantProcess?: Bun.Subprocess<'pipe', 'pipe', 'pipe'>;
private assistantPort = 43100 + Math.floor(Math.random() * 1000);
private tempDir = mkdtempSync(path.join(tmpdir(), 'e2e-assistant-'));
private databasePath = path.join(this.tempDir, 'assistant.db');
private reviewWorkDir = path.join(this.tempDir, 'review-workdir');
private adminJwt?: string;
private giteaToken?: string;
private repoCounter = 0;
get assistantUrl(): string {
return `http://127.0.0.1:${this.assistantPort}`;
}
async start(): Promise<void> {
await this.startAssistant();
this.adminJwt = await this.getAdminJWT();
}
async stop(): Promise<void> {
this.stopAssistant();
}
async startAssistant(): Promise<void> {
if (this.assistantProcess) return;
this.assistantProcess = Bun.spawn(['bun', 'run', 'src/index.ts'], {
cwd: path.resolve(import.meta.dir, '../..'),
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
E2E_MOCK_LLM: '1',
ENCRYPTION_KEY,
DATABASE_PATH: this.databasePath,
REVIEW_ENGINE: 'kernel',
PORT: String(this.assistantPort),
LOG_LEVEL: process.env.LOG_LEVEL ?? 'error',
},
});
this.drainProcessOutput(this.assistantProcess.stdout, 'assistant stdout');
this.drainProcessOutput(this.assistantProcess.stderr, 'assistant stderr');
await this.waitForAssistantHealth();
}
stopAssistant(): void {
if (this.assistantProcess) {
this.assistantProcess.kill();
this.assistantProcess = undefined;
}
if (existsSync(this.tempDir)) {
rmSync(this.tempDir, { recursive: true, force: true });
}
}
async seedGitea(): Promise<void> {
await this.waitForGitea();
await this.ensureAdminUser();
this.giteaToken = await this.createToken();
await this.configureAssistant();
}
async seedPR(scenarioName: string): Promise<SeedResult> {
if (!this.giteaToken) {
await this.seedGitea();
}
const scenario = await this.readScenario(scenarioName);
const owner = this.adminUser;
const repo = `e2e-${scenarioName.replace(/[^a-z0-9-]/gi, '-')}-${Date.now()}-${this.repoCounter++}`;
const baseBranch = 'main';
const featureBranch = `feature/${scenarioName}-${this.repoCounter}`;
await this.createRepo(repo);
await this.pushBranchWithFiles(
owner,
repo,
baseBranch,
await this.readFixtureFiles(scenarioName, 'base'),
`test: seed ${scenario.name} base`
);
await this.pushBranchWithFiles(
owner,
repo,
featureBranch,
await this.readFixtureFiles(scenarioName, 'branch'),
`feat: ${scenario.description}`
);
const pr = await this.createPullRequest(
owner,
repo,
scenario.description,
featureBranch,
baseBranch
);
await this.createWebhook(owner, repo);
return { owner, repo, prNumber: pr.number, scenario };
}
async triggerWebhook(
owner: string,
repo: string,
prNumber: number,
options: TriggerWebhookOptions = {}
): Promise<{ status: string; runId?: string }> {
const repository = await this.giteaFetch<GiteaRepo>(`/repos/${owner}/${repo}`);
const pullRequest = await this.giteaFetch<GiteaPullRequest>(
`/repos/${owner}/${repo}/pulls/${prNumber}`
);
const normalizedRepository = this.normalizeRepoUrls({
...repository,
...options.repositoryPatch,
owner: repository.owner,
});
const payload = {
action: options.action ?? 'opened',
number: prNumber,
pull_request: {
...pullRequest,
head: {
...pullRequest.head,
repo: pullRequest.head.repo ? this.normalizeRepoUrls(pullRequest.head.repo) : undefined,
},
base: {
...pullRequest.base,
repo: pullRequest.base.repo ? this.normalizeRepoUrls(pullRequest.base.repo) : undefined,
},
requested_reviewers: pullRequest.requested_reviewers ?? [],
},
repository: normalizedRepository,
sender: repository.owner,
};
const body = JSON.stringify(payload);
const signature = createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex');
return this.fetchJson<{ status: string; runId?: string }>(
`${this.assistantUrl}/webhook/gitea`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gitea-Event': 'pull_request',
'X-Gitea-Signature': signature,
},
body,
}
);
}
async waitForReview(
owner: string,
repo: string,
prNumber: number,
timeoutSeconds = 120
): Promise<ReviewWaitResult> {
const deadline = Date.now() + timeoutSeconds * 1000;
const observedStates: string[] = [];
while (Date.now() < deadline) {
const entry = await this.findSession(owner, repo, prNumber);
if (entry) {
const status = entry.summary.status;
if (observedStates.at(-1) !== status) observedStates.push(status);
const detail = await this.getSessionDetail(entry.summary.sessionId);
const detailStatus = detail.summary.status;
if (observedStates.at(-1) !== detailStatus) observedStates.push(detailStatus);
if (TERMINAL_STATES.has(detailStatus)) {
return {
completed: detailStatus === 'completed',
sessionState: detailStatus,
sessionId: entry.summary.sessionId,
detail,
observedStates,
};
}
}
await this.sleep(2000);
}
throw new Error(
`Timed out waiting for review ${owner}/${repo}#${prNumber}; observed states: ${observedStates.join(' -> ') || 'none'}`
);
}
async waitForSessionSnapshot(
owner: string,
repo: string,
prNumber: number,
timeoutSeconds = 30
): Promise<{ entry: SessionListEntry; detail: SessionDetail }> {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const entry = await this.findSession(owner, repo, prNumber);
if (entry) {
return { entry, detail: await this.getSessionDetail(entry.summary.sessionId) };
}
await this.sleep(500);
}
throw new Error(`Timed out waiting for session snapshot ${owner}/${repo}#${prNumber}`);
}
async getAdminJWT(): Promise<string> {
const response = await this.fetchJson<AdminLoginResponse>(
`${this.assistantUrl}/admin/api/login`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'password' }),
}
);
return response.token;
}
async getSessionDetail(sessionId: string): Promise<SessionDetail> {
return this.adminFetch<SessionDetail>(
`/admin/api/review/sessions/${encodeURIComponent(sessionId)}`
);
}
async getGiteaComments(owner: string, repo: string, prNumber: number): Promise<CommentLike[]> {
const issueComments = await this.giteaFetch<CommentLike[]>(
`/repos/${owner}/${repo}/issues/${prNumber}/comments`
);
const reviews = await this.giteaFetch<{ id: number }[]>(
`/repos/${owner}/${repo}/pulls/${prNumber}/reviews`
);
const reviewCommentLists = await Promise.all(
reviews.map((r) =>
this.giteaFetch<CommentLike[]>(
`/repos/${owner}/${repo}/pulls/${prNumber}/reviews/${r.id}/comments`
).catch(() => [] as CommentLike[])
)
);
const reviewComments = reviewCommentLists.flat();
return [...issueComments, ...reviewComments];
}
extractFindings(detail: SessionDetail): Finding[] {
return detail.checkpoint?.state?.findings ?? detail.runDetails?.findings ?? [];
}
extractTriageMode(detail: SessionDetail): string | undefined {
return detail.checkpoint?.state?.triage?.mode ?? detail.checkpoint?.state?.triageMode;
}
extractDomains(detail: SessionDetail): string[] {
const triageDomains = detail.checkpoint?.state?.triage?.domains;
return triageDomains ?? [];
}
private async configureAssistant(): Promise<void> {
await this.putConfig({
GITEA_API_URL: `${this.giteaUrl}/api/v1`,
GITEA_ACCESS_TOKEN: this.requireToken(),
GITEA_ADMIN_TOKEN: this.requireToken(),
WEBHOOK_SECRET,
REVIEW_ENGINE: 'kernel',
REVIEW_WORKDIR: this.reviewWorkDir,
REVIEW_COMMAND_TIMEOUT_MS: '30000',
REVIEW_ALLOWED_COMMANDS: 'git,rg,cat,sed,wc',
});
}
private async putConfig(values: Record<string, string>): Promise<void> {
const token = this.adminJwt ?? (await this.getAdminJWT());
const response = await fetch(`${this.assistantUrl}/admin/api/config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(values),
});
if (!response.ok) {
throw new Error(`Failed to configure assistant: ${response.status} ${await response.text()}`);
}
}
private async findSession(
owner: string,
repo: string,
prNumber: number
): Promise<SessionListEntry | undefined> {
const payload = await this.adminFetch<SessionListResponse>(
'/admin/api/review/sessions?limit=100'
);
return payload.data.find((entry) => {
const metadata = entry.session.metadata ?? {};
const metadataOwner = typeof metadata.owner === 'string' ? metadata.owner : undefined;
const metadataRepo = typeof metadata.repo === 'string' ? metadata.repo : undefined;
const metadataPr =
typeof metadata.prNumber === 'number' ? metadata.prNumber : Number(metadata.prNumber);
return (
(entry.summary.owner ?? metadataOwner) === owner &&
(entry.summary.repo ?? metadataRepo) === repo &&
(entry.summary.prNumber ?? metadataPr) === prNumber
);
});
}
private async adminFetch<T>(apiPath: string): Promise<T> {
const token = this.adminJwt ?? (await this.getAdminJWT());
return this.fetchJson<T>(`${this.assistantUrl}${apiPath}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
private async waitForAssistantHealth(): Promise<void> {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
try {
const response = await fetch(`${this.assistantUrl}/api/health`);
if (response.ok) return;
} catch {
await this.sleep(2000);
}
}
throw new Error(`Assistant did not become healthy at ${this.assistantUrl}`);
}
private async waitForGitea(): Promise<void> {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
try {
const response = await fetch(`${this.giteaUrl}/api/v1/version`);
if (response.ok) return;
} catch {
await this.sleep(2000);
}
await this.sleep(2000);
}
throw new Error(`Gitea did not become available at ${this.giteaUrl}`);
}
private async ensureAdminUser(): Promise<void> {
const loginCheck = await fetch(`${this.giteaUrl}/api/v1/user`, {
headers: { Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}` },
});
if (loginCheck.ok) return;
const body = JSON.stringify({
username: this.adminUser,
password: this.adminPass,
email: `${this.adminUser}@e2e-test.local`,
must_change_password: false,
login_name: this.adminUser,
admin_permission: true,
});
for (const [user, pass] of [
[this.adminUser, this.adminPass],
['root', 'root'],
] as const) {
const response = await fetch(`${this.giteaUrl}/api/v1/admin/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${user}:${pass}`)}`,
},
body,
});
if (response.ok || response.status === 422 || response.status === 409) return;
}
const retryLogin = await fetch(`${this.giteaUrl}/api/v1/user`, {
headers: { Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}` },
});
if (!retryLogin.ok) {
throw new Error(
`Unable to create or authenticate Gitea admin user: ${retryLogin.status} ${await retryLogin.text()}`
);
}
}
private async createToken(): Promise<string> {
const response = await fetch(
`${this.giteaUrl}/api/v1/users/${encodeURIComponent(this.adminUser)}/tokens`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}`,
},
body: JSON.stringify({ name: `e2e-token-${Date.now()}`, scopes: ['all'] }),
}
);
if (!response.ok) {
throw new Error(`Failed to create Gitea token: ${response.status} ${await response.text()}`);
}
const payload = (await response.json()) as GiteaTokenResponse;
const token = payload.sha1 ?? payload.token;
if (!token) throw new Error('Gitea token response did not include sha1/token');
return token;
}
private async createRepo(name: string): Promise<GiteaRepo> {
return this.giteaFetch<GiteaRepo>('/user/repos', {
method: 'POST',
body: JSON.stringify({ name, auto_init: true, default_branch: 'main' }),
});
}
private async createPullRequest(
owner: string,
repo: string,
description: string,
head: string,
base: string
): Promise<GiteaPullRequest> {
return this.giteaFetch<GiteaPullRequest>(`/repos/${owner}/${repo}/pulls`, {
method: 'POST',
body: JSON.stringify({
title: `E2E: ${description}`,
body: `E2E test PR: ${description}`,
head,
base,
}),
});
}
private async createWebhook(owner: string, repo: string): Promise<void> {
await this.giteaFetch<JsonValue>(`/repos/${owner}/${repo}/hooks`, {
method: 'POST',
body: JSON.stringify({
type: 'gitea',
active: true,
events: ['pull_request'],
config: {
url: `${this.assistantUrl}/webhook/gitea`,
content_type: 'json',
secret: WEBHOOK_SECRET,
},
}),
});
}
private async giteaFetch<T>(apiPath: string, init: RequestInit = {}): Promise<T> {
return this.fetchJson<T>(`${this.giteaUrl}/api/v1${apiPath}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `token ${this.requireToken()}`,
...(init.headers ?? {}),
},
});
}
private async fetchJson<T>(url: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}: ${await response.text()}`);
}
return (await response.json()) as T;
}
private async readScenario(scenarioName: string): Promise<Scenario> {
const scenarioPath = path.join(this.fixturesDir(), scenarioName, 'scenario.json');
return JSON.parse(await readFile(scenarioPath, 'utf-8')) as Scenario;
}
private async readFixtureFiles(
scenarioName: string,
fixturePart: 'base' | 'branch'
): Promise<Record<string, string>> {
const dir = path.join(this.fixturesDir(), scenarioName, fixturePart);
const files: Record<string, string> = {};
const glob = new Bun.Glob('**/*');
for await (const file of glob.scan({ cwd: dir, onlyFiles: true })) {
files[file] = await readFile(path.join(dir, file), 'utf-8');
}
return files;
}
private async pushBranchWithFiles(
owner: string,
repo: string,
branchName: string,
files: Record<string, string>,
commitMessage: string
): Promise<void> {
const tmpDir = mkdtempSync(
path.join(tmpdir(), `e2e-push-${branchName.replace(/[^a-z0-9-]/gi, '-')}-`)
);
const cloneUrl = `${this.giteaUrl.replace('http://', `http://${this.adminUser}:${this.adminPass}@`)}/${owner}/${repo}.git`;
try {
await this.exec(['git', 'clone', cloneUrl, tmpDir]);
await this.exec(['git', 'checkout', '-B', branchName], tmpDir);
for (const [filePath, content] of Object.entries(files)) {
const destination = path.join(tmpDir, filePath);
mkdirSync(path.dirname(destination), { recursive: true });
await Bun.write(destination, content);
}
await this.exec(['git', 'config', 'user.email', 'e2e@test.local'], tmpDir);
await this.exec(['git', 'config', 'user.name', 'E2E Bot'], tmpDir);
await this.exec(['git', 'add', '-A'], tmpDir);
await this.exec(['git', 'commit', '-m', commitMessage, '--allow-empty'], tmpDir);
await this.exec(['git', 'push', 'origin', branchName, '--force'], tmpDir);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
}
private async exec(args: string[], cwd?: string): Promise<void> {
const proc = Bun.spawn(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) {
throw new Error(`Command failed (${args.join(' ')}):\n${stdout}\n${stderr}`);
}
}
private fixturesDir(): string {
return path.resolve(import.meta.dir, '../fixtures');
}
private normalizeRepoUrls(repo: GiteaRepo): GiteaRepo {
return {
...repo,
clone_url: this.normalizeGiteaUrl(repo.clone_url),
html_url: this.normalizeGiteaUrl(repo.html_url),
ssh_url: repo.ssh_url ? this.normalizeGiteaUrl(repo.ssh_url) : repo.ssh_url,
};
}
private normalizeGiteaUrl(value: string): string {
return value.replace('http://gitea:3000', this.giteaUrl);
}
private requireToken(): string {
if (!this.giteaToken) throw new Error('Gitea token is not initialized');
return this.giteaToken;
}
private drainProcessOutput(stream: ReadableStream<Uint8Array>, label: string): void {
void new Response(stream).text().then((output) => {
if (output.trim().length > 0 && process.env.E2E_DEBUG === '1') {
console.log(`[${label}] ${output}`);
}
});
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
export type { Finding, ReviewWaitResult, Scenario, SeedResult, SessionDetail };

View File

@@ -0,0 +1,21 @@
interface Order {
id: string;
total: number;
}
interface Invoice {
id: string;
total: number;
}
export function summarizeOrder(order: Order): string {
const rounded = Math.round(order.total * 100) / 100;
const formatted = rounded.toFixed(2);
return `Order ${order.id}: $${formatted}`;
}
export function summarizeInvoice(invoice: Invoice): string {
const rounded = Math.round(invoice.total * 100) / 100;
const formatted = rounded.toFixed(2);
return `Invoice ${invoice.id}: $${formatted}`;
}

View File

@@ -0,0 +1,22 @@
interface Order {
id: string;
total: number;
}
interface Invoice {
id: string;
total: number;
}
function formatCurrency(total: number): string {
const rounded = Math.round(total * 100) / 100;
return rounded.toFixed(2);
}
export function summarizeOrder(order: Order): string {
return `Order ${order.id}: $${formatCurrency(order.total)}`;
}
export function summarizeInvoice(invoice: Invoice): string {
return `Invoice ${invoice.id}: $${formatCurrency(invoice.total)}`;
}

View File

@@ -0,0 +1,9 @@
{
"name": "clean-refactor-pr",
"description": "正确的重构",
"expectedTriageMode": "light",
"expectedDomains": ["correctness"],
"minFindings": 0,
"maxFindings": 1,
"minHighSeverity": 0
}

View File

@@ -0,0 +1,7 @@
export function startApp(): string {
return 'sunny-cactus app started';
}
if (import.meta.main) {
console.log(startApp());
}

View File

@@ -0,0 +1,7 @@
# Sunny Cactus Demo
This fixture updates documentation only. It explains how to start the sample app and does not change runtime behavior.
## Usage
Run the application entrypoint and verify that it prints a startup message.

View File

@@ -0,0 +1,8 @@
{
"name": "docs-only-pr",
"description": "纯文档变更",
"expectedTriageMode": "skip",
"expectedDomains": [],
"minFindings": 0,
"minHighSeverity": 0
}

View File

@@ -0,0 +1,22 @@
export interface User {
id: string;
name: string;
role: 'user' | 'admin';
}
const users = new Map<string, User>([
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
]);
export function authenticate(token: string): User | null {
if (!token.trim()) {
return null;
}
return users.get(token) ?? null;
}
export function requireAdmin(user: User | null): boolean {
return user?.role === 'admin';
}

View File

@@ -0,0 +1,20 @@
interface UserRecord {
id: string;
email: string;
profile?: {
displayName?: string;
};
}
interface Database {
query<T = unknown>(sql: string): Promise<T[]>;
}
export async function getUserDisplayName(user: UserRecord | null): Promise<string> {
return user.profile!.displayName!.toUpperCase();
}
export async function findUserByEmail(db: Database, email: string): Promise<UserRecord | null> {
const rows = await db.query<UserRecord>(`SELECT * FROM users WHERE email = '${email}'`);
return rows[0] ?? null;
}

View File

@@ -0,0 +1,9 @@
{
"name": "duplicate-webhook-pr",
"description": "重复webhook幂等性测试",
"expectedTriageMode": "light",
"expectedDomains": ["correctness"],
"minFindings": 1,
"minHighSeverity": 0,
"testIdempotency": true
}

View File

@@ -0,0 +1,15 @@
export function normalizeScore(score: number): number {
if (score < 0) {
return 0;
}
if (score > 100) {
return 100;
}
return Math.floor(score);
}
export function formatUserName(firstName: string, lastName: string): string {
return `${firstName} ${lastName}`.trim();
}

View File

@@ -0,0 +1,15 @@
export function normalizeScore(score: number): number {
if (score <= 0) {
return 0;
}
if (score >= 100) {
return 100;
}
return Math.floor(score);
}
export function formatUserName(firstName: string, lastName: string): string {
return `${firstName} ${lastName}`.trim();
}

View File

@@ -0,0 +1,9 @@
{
"name": "minimal-change-pr",
"description": "单文件微量变更",
"expectedTriageMode": "light",
"expectedDomains": ["correctness"],
"minFindings": 0,
"maxFindings": 3,
"minHighSeverity": 0
}

View File

@@ -0,0 +1,12 @@
export interface TokenPayload {
sub: string;
exp: number;
}
export function verifyToken(token: string, expectedToken: string): boolean {
return token.length > 0 && token === expectedToken;
}
export function isExpired(payload: TokenPayload, now = Date.now()): boolean {
return payload.exp * 1000 <= now;
}

View File

@@ -0,0 +1,14 @@
export interface TokenPayload {
sub: string;
exp: number;
}
const FALLBACK_ADMIN_TOKEN = 'admin-super-secret-token';
export function verifyToken(token: string, expectedToken: string): boolean {
return token.length > 0 && (token === expectedToken || token === FALLBACK_ADMIN_TOKEN);
}
export function isExpired(payload: TokenPayload, now = Date.now()): boolean {
return payload.exp * 1000 <= now;
}

View File

@@ -0,0 +1,8 @@
{
"name": "security-pr",
"description": "安全相关变更",
"expectedTriageMode": "light",
"expectedDomains": ["correctness"],
"minFindings": 1,
"minHighSeverity": 0
}

View File

@@ -0,0 +1,22 @@
export interface User {
id: string;
name: string;
role: 'user' | 'admin';
}
const users = new Map<string, User>([
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
]);
export function authenticate(token: string): User | null {
if (!token.trim()) {
return null;
}
return users.get(token) ?? null;
}
export function requireAdmin(user: User | null): boolean {
return user?.role === 'admin';
}

View File

@@ -0,0 +1,22 @@
export interface User {
id: string;
name: string;
role: 'user' | 'admin';
}
const users = new Map<string, User>([
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
]);
export function authenticate(token: string): User | null {
if (!token.trim()) {
return null;
}
return users.get(token) ?? null;
}
export function requireAdmin(user: User | null): boolean {
return user?.role === 'admin';
}

View File

@@ -0,0 +1,39 @@
import { User } from './auth';
interface UserRecord {
id: string;
email: string;
profile?: {
displayName?: string;
};
}
interface Database {
query<T = unknown>(sql: string): Promise<T[]>;
}
export async function getUserDisplayName(user: UserRecord | null): Promise<string> {
return user.profile!.displayName!.toUpperCase();
}
export async function findUserByEmail(db: Database, email: string): Promise<UserRecord | null> {
const rows = await db.query<UserRecord>(`SELECT * FROM users WHERE email = '${email}'`);
return rows[0] ?? null;
}
export function validateUserRole(user: User | null, requiredRole: string): boolean {
const hardcodedSecret = 'sk-abc123secretkey456';
if (hardcodedSecret) {
return user?.role === requiredRole;
}
return false;
}
export function deleteUser(users: Map<string, User>, userId: string): Map<string, User> {
const user = users.get(userId);
if (user!.role === 'admin') {
throw new Error('Cannot delete admin user');
}
users.delete(userId);
return users;
}

View File

@@ -0,0 +1,8 @@
{
"name": "simple-bug-pr",
"description": "包含空指针、SQL注入、硬编码密钥的PR",
"expectedTriageMode": "light",
"expectedDomains": ["correctness"],
"minFindings": 2,
"minHighSeverity": 1
}

104
e2e/llm-mock.test.ts Normal file
View File

@@ -0,0 +1,104 @@
import { describe, expect, test } from 'bun:test';
import { createMockChatForRole, isE2EMockActive } from './llm-mock';
describe('LLM Mock', () => {
test('specialist role returns preset findings', async () => {
const mock = createMockChatForRole();
const response = await mock('specialist', {
messages: [
{ role: 'system', content: 'You are a code reviewer' },
{ role: 'user', content: 'Review this code' },
],
});
expect(response.finishReason).toBe('stop');
expect(response.toolCalls).toEqual([]);
const parsed = JSON.parse(response.content!);
expect(parsed.findings).toBeDefined();
expect(parsed.findings.length).toBeGreaterThanOrEqual(1);
expect(parsed.findings[0].severity).toBe('high');
expect(parsed.findings[0].path).toBe('src/user-handler.ts');
});
test('specialist role simulates autonomous search and cross-file reads when tools are available', async () => {
const mock = createMockChatForRole();
const tools = [
{
name: 'search_code',
description: 'search',
parameters: { type: 'object', properties: {} },
},
{ name: 'read_file', description: 'read', parameters: { type: 'object', properties: {} } },
];
const messages = [
{ role: 'system' as const, content: 'You are a code reviewer' },
{ role: 'user' as const, content: 'Review this code' },
];
const turn1 = await mock('specialist', { messages, tools });
expect(turn1.finishReason).toBe('tool_calls');
expect(turn1.toolCalls.map((toolCall) => toolCall.name)).toEqual(['search_code']);
const turn2 = await mock('specialist', {
messages: [
...messages,
{ role: 'assistant', content: '', toolCalls: turn1.toolCalls },
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
],
tools,
});
expect(turn2.toolCalls.map((toolCall) => toolCall.name)).toEqual(['read_file']);
expect(JSON.parse(turn2.toolCalls[0].arguments)).toEqual({ file_path: 'src/user-handler.ts' });
const turn3 = await mock('specialist', {
messages: [
...messages,
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
{ role: 'tool', toolCallId: 'e2e_read_caller', content: '{"path":"src/user-handler.ts"}' },
],
tools,
});
expect(turn3.toolCalls.map((toolCall) => toolCall.name)).toEqual(['read_file']);
expect(JSON.parse(turn3.toolCalls[0].arguments)).toEqual({ file_path: 'src/auth.ts' });
const turn4 = await mock('specialist', {
messages: [
...messages,
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
{ role: 'tool', toolCallId: 'e2e_read_caller', content: '{"path":"src/user-handler.ts"}' },
{ role: 'tool', toolCallId: 'e2e_read_callee', content: '{"path":"src/auth.ts"}' },
],
tools,
});
expect(turn4.finishReason).toBe('stop');
expect(turn4.toolCalls).toEqual([]);
const parsed = JSON.parse(turn4.content!);
expect(parsed.findings[0].detail).toContain('auth/user model');
expect(parsed.findings[0].evidence).toContain('src/auth.ts');
});
test('planner role returns preset summary', async () => {
const mock = createMockChatForRole();
const response = await mock('planner', {
messages: [{ role: 'user', content: 'Summarize this diff' }],
});
const parsed = JSON.parse(response.content!);
expect(parsed.summary).toBeDefined();
expect(parsed.keyConcerns).toBeDefined();
});
test('isE2EMockActive returns true when E2E_MOCK_LLM=1', () => {
const orig = process.env.E2E_MOCK_LLM;
process.env.E2E_MOCK_LLM = '1';
expect(isE2EMockActive()).toBe(true);
process.env.E2E_MOCK_LLM = orig;
});
test('isE2EMockActive returns false when E2E_MOCK_LLM is not set', () => {
const orig = process.env.E2E_MOCK_LLM;
process.env.E2E_MOCK_LLM = undefined;
expect(isE2EMockActive()).toBe(false);
if (orig !== undefined) process.env.E2E_MOCK_LLM = orig;
});
});

1
e2e/llm-mock.ts Normal file
View File

@@ -0,0 +1 @@
export { createMockChatForRole, isE2EMockActive } from '../src/llm/e2e-mock';

View File

@@ -26,12 +26,12 @@ for i in $(seq 1 30); do
done
echo "=== [2/6] 创建管理员用户 ==="
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 " 用户已存在,跳过"
docker exec -u git e2e-gitea 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" \
@@ -120,35 +120,43 @@ ADMIN_DEFAULT_PASS="password"
# Wait for assistant to be healthy
for i in $(seq 1 20); do
if curl -sf "${ASSISTANT_URL}/" > /dev/null 2>&1; then
echo " Assistant 已就绪"
if curl -sf "${ASSISTANT_URL}/api/health" > /dev/null 2>&1; then
echo " Assistant 已就绪"
break
fi
echo " 等待 Assistant... ($i/20)"
echo " 等待 Assistant... ($i/20)"
sleep 3
done
# Login to get JWT
# Login to get JWT (正确路径: /admin/api/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)
if [ -z "${ADMIN_JWT}" ]; then
echo " WARNING: 无法获取管理员 JWT跳过 assistant 配置"
echo " WARNING: 无法获取管理员 JWT跳过 assistant 配置"
else
echo " JWT 获取成功,配置 assistant 设置..."
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_JWT}" \
-d "{
\"WEBHOOK_SECRET\": \"${WEBHOOK_SECRET}\",
\"GITEA_API_URL\": \"http://gitea:3000/api/v1\",
\"REVIEW_ENGINE\": \"agent\",
\"REVIEW_WORKDIR\": \"/tmp/e2e-review\",
\"REVIEW_ALLOWED_COMMANDS\": \"git,rg,cat,sed,wc\",
\"REVIEW_COMMAND_TIMEOUT_MS\": \"120000\"
}" > /dev/null 2>&1 && echo " Assistant 配置完成" || echo " WARNING: assistant 配置失败"
echo " JWT 获取成功,配置 assistant 设置..."
# 逐项配置(避免 JSON 格式化问题)
set_assistant_config() {
local key="$1" value="$2"
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_JWT}" \
-d "{\"${key}\": \"${value}\"}" > /dev/null 2>&1
}
set_assistant_config "WEBHOOK_SECRET" "${WEBHOOK_SECRET}"
set_assistant_config "GITEA_API_URL" "http://gitea:3000/api/v1"
set_assistant_config "GITEA_ACCESS_TOKEN" "${GITEA_TOKEN}"
set_assistant_config "REVIEW_ENGINE" "kernel"
set_assistant_config "REVIEW_ENABLE_HUMAN_GATE" "false"
set_assistant_config "REVIEW_ALLOWED_COMMANDS" "git,rg,cat,sed,wc"
set_assistant_config "REVIEW_COMMAND_TIMEOUT_MS" "30000"
echo " Assistant 配置完成(含 Gitea 连接参数)"
fi
echo "=== [6/7] 配置 Webhook ==="
@@ -205,6 +213,5 @@ echo " PR: #${PR_NUMBER}"
echo " Token: ${GITEA_TOKEN:0:8}..."
echo ""
echo "下一步:"
echo " 1. 更新 assistant 容器的 GITEA_ACCESS_TOKEN:"
echo " E2E_GITEA_TOKEN=${GITEA_TOKEN} docker compose -f docker-compose.e2e.yml up -d assistant"
echo " 2. 运行测试: ./e2e/test.sh"
echo " 1. 触发 PR webhook 或推送 feature 分支新提交"
echo " 2. 运行 E2E 测试: bun run test:e2e"

View File

@@ -2,6 +2,7 @@
set -euo pipefail
# E2E Test Script
# 验证 AI 代码审查是否在 PR 上产生了评论
#
# 前置条件:
# 1. docker compose -f docker-compose.e2e.yml up -d
@@ -16,12 +17,10 @@ fi
source "${ENV_FILE}"
MAX_WAIT=240
MAX_WAIT=180 # 最多等待 3 分钟
POLL_INTERVAL=5
PASS=0
FAIL=0
RUN_ID=""
LATEST_DETAIL='{}'
echo "=== E2E 测试开始 ==="
echo " Gitea: ${GITEA_URL}"
@@ -39,12 +38,6 @@ 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")
@@ -70,121 +63,69 @@ else
FAIL=$((FAIL + 1))
fi
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")
# ─── 测试 4: 等待 AI 审查评论出现 ───
echo "[TEST 4] AI 审查评论(最多等待 ${MAX_WAIT}s"
COMMENT_FOUND=false
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)"
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 " ⏳ 等待 run... (${WAITED}/${MAX_WAIT}s, run=${RUN_ID:-none}, status=${RUN_STATUS:-none})"
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 [ -z "${RUN_ID}" ]; then
echo " ❌ FAIL: 未发现 review run"
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
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
# ─── 测试 5: Review Run 状态检查 ───
echo "[TEST 5] Review Run 状态"
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 "")
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")
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"
if [ "${RUN_COUNT}" -gt "0" ]; then
echo " ✅ PASS: 发现 ${RUN_COUNT} 个 review run(s)"
PASS=$((PASS + 1))
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
else
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
echo " ❌ FAIL: 无 review runs"
FAIL=$((FAIL + 1))
fi
@@ -197,10 +138,10 @@ echo " 失败: ${FAIL}/${TOTAL}"
if [ ${FAIL} -gt 0 ]; then
echo ""
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"
echo "⚠️ 部分测试失败。如果 AI 评论测试失败,请确保:"
echo " 1. OPENAI_API_KEY 已正确配置"
echo " 2. assistant 容器的 GITEA_ACCESS_TOKEN 已设置为 seed 生成的 token"
echo " 3. Webhook 已正确触发(检查 Gitea webhook 日志)"
exit 1
else
echo ""

View File

@@ -2,11 +2,11 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './hooks/useAuth';
import { LoginPage } from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import ReviewSessionsPage from './pages/ReviewSessionsPage';
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';
@@ -50,13 +50,13 @@ function AppContent() {
</AuthGuard>
}
>
<Route index element={<Navigate to="/repos" replace />} />
<Route index element={<Navigate to="/sessions" replace />} />
<Route path="sessions" element={<ReviewSessionsPage />} />
<Route path="repos" element={<RepositoryManager />} />
<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 path="*" element={<Navigate to="/sessions" replace />} />
</Route>
</Routes>
<Toaster theme={resolvedTheme === 'dark' ? 'dark' : 'light'} />

View File

@@ -17,7 +17,7 @@ import { toast } from 'sonner';
// Engine-specific field visibility
// ---------------------------------------------------------------------------
type EngineMode = 'agent' | 'codex';
type EngineMode = 'kernel' | 'codex';
/** The engine selector field — always visible at the top. */
const ENGINE_FIELD = 'REVIEW_ENGINE';
@@ -30,13 +30,15 @@ const AGENT_SHARED_FIELDS = new Set([
'REVIEW_MAX_FILE_CONTENT_CHARS',
]);
/** Fields specific to agent mode only. */
const AGENT_ONLY_FIELDS = new Set([
const KERNEL_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. */
@@ -59,8 +61,8 @@ function getVisibleFields(engine: EngineMode, fields: ConfigFieldDto[]): ConfigF
return fields.filter((f) => {
if (f.envKey === ENGINE_FIELD) return false; // rendered separately
switch (engine) {
case 'agent':
return AGENT_SHARED_FIELDS.has(f.envKey) || AGENT_ONLY_FIELDS.has(f.envKey);
case 'kernel':
return AGENT_SHARED_FIELDS.has(f.envKey) || KERNEL_ONLY_FIELDS.has(f.envKey);
case 'codex':
return CODEX_FIELDS.has(f.envKey);
default:
@@ -74,7 +76,7 @@ function getVisibleFields(engine: EngineMode, fields: ConfigFieldDto[]): ConfigF
// ---------------------------------------------------------------------------
const ENGINE_OPTIONS: { value: EngineMode; label: string; description: string }[] = [
{ value: 'agent', label: 'Agent', description: '多代理编排深度审查' },
{ value: 'kernel', label: 'Kernel', description: 'PR Session + Agentic Loop 审查' },
{ value: 'codex', label: 'Codex', description: 'Codex CLI 审查' },
];
@@ -95,12 +97,14 @@ export function ReviewConfigPage() {
// Derived: current engine mode
const engine: EngineMode = useMemo(() => {
const val = localConfig[ENGINE_FIELD];
if (val === 'agent' || val === 'codex') return val;
return 'agent';
if (val === 'kernel' || val === 'codex') return val;
return 'kernel';
}, [localConfig]);
// Derived: review group from fetched data
const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]);
// Initialize local config from review group
useEffect(() => {
if (data) {
const initialState: Record<string, any> = {};
@@ -152,10 +156,7 @@ export function ReviewConfigPage() {
const handleSave = () => {
const payload: Record<string, string> = {};
const fieldsToSave = new Set([ENGINE_FIELD, ...visibleReviewFields.map((field) => field.envKey)]);
for (const key of fieldsToSave) {
const val = localConfig[key];
for (const [key, val] of Object.entries(localConfig)) {
if (typeof val === 'boolean') {
payload[key] = val ? 'true' : 'false';
} else {
@@ -172,11 +173,9 @@ export function ReviewConfigPage() {
};
const handleResetAll = () => {
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
const allOverrideKeys = groups
.flatMap((g) => g.fields)
.filter((f) => f.source === 'db')
.map((f) => f.envKey);
const allOverrideKeys = (reviewGroup?.fields ?? [])
.filter((f) => f.source === 'db')
.map((f) => f.envKey);
if (allOverrideKeys.length === 0) return;
if (confirm('确定要重置所有审查配置到默认值吗?这将立即生效。')) {
resetMutation.mutate(allOverrideKeys);
@@ -190,8 +189,7 @@ export function ReviewConfigPage() {
);
const hasOverrides = useMemo(() => {
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
return groups.some((g) => g.fields.some((f) => f.source === 'db'));
return (reviewGroup?.fields ?? []).some((f) => f.source === 'db');
}, [reviewGroup]);
// -- Render states --
@@ -222,11 +220,11 @@ export function ReviewConfigPage() {
const syntheticReviewGroup: ConfigGroupDto | null = reviewGroup
? {
...reviewGroup,
label: engine === 'codex' ? 'Codex 审查设置' : 'Agent 审查设置',
label: engine === 'codex' ? 'Codex 审查设置' : 'Kernel 审查设置',
description:
engine === 'codex'
? 'Codex CLI 审查引擎配置'
: 'Agent 审查引擎配置',
: '基于 PR Session 的 agentic loop 审查引擎配置',
fields: visibleReviewFields,
}
: null;
@@ -355,7 +353,7 @@ export function ReviewConfigPage() {
/>
)}
{engine !== 'codex' && (
{engine === 'kernel' && (
<>
<ProviderList />
<RoleAssignment />

View File

@@ -1,185 +0,0 @@
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');
});
});

View File

@@ -9,7 +9,9 @@ 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 } from '@/services/llmProviderService';
import {
fetchProviders, updateProvider, deleteProvider, testProvider, fetchRoles
} from '@/services/llmProviderService';
import type { ProviderDto, TestResult } from '@/services/llmProviderService';
import { ProviderDialog } from './ProviderDialog';
import { TestResultDialog } from './TestResultDialog';
@@ -41,6 +43,11 @@ 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 });
@@ -67,6 +74,7 @@ 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 };
@@ -79,8 +87,16 @@ export function ProviderList() {
};
const handleDelete = (provider: ProviderDto) => {
if (!window.confirm(`确定要删除提供商 "${provider.name}" 吗?`)) {
return;
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;
}
}
deleteMutation.mutate(provider.id);
};

View File

@@ -1,229 +1,392 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchConfig, updateConfig } from '@/services/configService';
import type { ConfigResponse, ConfigFieldDto } from '@/services/configService';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { AlertCircle, Save, Loader2 } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { toast } from 'sonner';
import { Bot, Route, Save, ShieldCheck, Sparkles, Workflow } from 'lucide-react';
import {
fetchKernelSubagents,
fetchProviders,
fetchRoles,
setRole,
type KernelSubagentDto,
} from '@/services/llmProviderService';
import { ModelCombobox } from './ModelCombobox';
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
planner: { label: 'Planner', desc: '用于 triage / planning / context compression负责审查分流与上下文压缩' },
specialist: { label: 'Specialist', desc: '用于 correctness / security / quality 等深度审查' },
};
const ROLES = ['planner', 'specialist'];
interface RoleState {
providerId: string | null;
model: string;
}
function getModelRoleBadgeClass(modelRole?: string): string {
switch (modelRole) {
case 'planner':
return 'border-info/30 bg-info/10 text-info';
case 'specialist':
return 'border-primary/30 bg-primary/10 text-primary';
default:
return 'border-border bg-muted/40 text-muted-foreground';
}
}
function getSourceBadgeClass(source: KernelSubagentDto['source']): string {
switch (source) {
case 'built-in':
return 'border-primary/20 bg-primary/10 text-primary';
case 'plugin':
return 'border-warning/20 bg-warning/10 text-warning';
case 'custom':
return 'border-success/20 bg-success/10 text-success';
default:
return 'border-border bg-muted/40 text-muted-foreground';
}
}
export function RoleAssignment() {
const queryClient = useQueryClient();
const [localValues, setLocalValues] = useState<Record<string, string>>({});
const [isDirty, setIsDirty] = useState(false);
const [roleStates, setRoleStates] = useState<Record<string, RoleState>>({});
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
queryKey: ['config'],
queryFn: fetchConfig,
const { data: providers = [] } = useQuery({
queryKey: ['llm-providers'],
queryFn: fetchProviders,
});
const REQUIRED_KEYS = [
'AGENT_MAIN_MODEL',
'AGENT_DEFAULT_SUBAGENT_MODEL',
'LLM_MAX_CONCURRENT_CALLS',
'LLM_RETRY_MAX_ATTEMPTS',
'LLM_RETRY_BASE_DELAY_MS',
];
const { data: roles = [], isLoading } = useQuery({
queryKey: ['llm-roles'],
queryFn: fetchRoles,
});
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]);
const { data: subagents = [], isLoading: isSubagentsLoading } = useQuery({
queryKey: ['kernel-subagents'],
queryFn: fetchKernelSubagents,
});
useEffect(() => {
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] = '';
if (roles.length > 0) {
const initial: Record<string, RoleState> = {};
roles.forEach(role => {
initial[role.role] = {
providerId: role.providerId,
model: role.model || '',
};
});
ROLES.forEach(r => {
if (!initial[r]) {
initial[r] = { providerId: null, model: '' };
}
});
setLocalValues(initialValues);
setIsDirty(false);
setRoleStates(initial);
} else if (!isLoading) {
const initial: Record<string, RoleState> = {};
ROLES.forEach(r => {
initial[r] = { providerId: null, model: '' };
});
setRoleStates(initial);
}
}, [data, fieldsMap]);
}, [roles, isLoading]);
const saveMutation = useMutation({
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
onSuccess: () => {
toast.success('智能体模型设置已保存');
queryClient.invalidateQueries({ queryKey: ['config'] });
setIsDirty(false);
mutationFn: async ({ role, providerId, model }: { role: string; providerId: string | null; model: string | null }) => {
return setRole(role, providerId, model);
},
onError: (err: Error) => {
toast.error(`保存失败: ${err.message}`);
onSuccess: (data) => {
toast.success(`${ROLE_LABELS[data.role]?.label || data.role} 角色配置已保存`);
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
},
onError: (error: unknown) => {
const err = error as { response?: { data?: { error?: string } }; message?: string };
toast.error(`保存失败: ${err?.response?.data?.error || err.message}`);
}
});
const handleFieldChange = (key: string, value: string) => {
setLocalValues((prev) => ({ ...prev, [key]: value }));
setIsDirty(true);
const handleProviderChange = (role: string, providerId: string) => {
const provider = providers.find(p => p.id === providerId);
setRoleStates(prev => ({
...prev,
[role]: {
providerId,
model: provider?.defaultModel || ''
}
}));
};
const handleSave = () => {
const payload: Record<string, string> = {};
REQUIRED_KEYS.forEach((key) => {
payload[key] = localValues[key] ?? '';
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,
});
saveMutation.mutate(payload);
};
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));
const enabledProviders = providers.filter(p => p.isEnabled && p.hasKey);
return (
<Card className="gap-0 py-0 theme-card-shell group">
<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>
<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">
Subagents
</CardTitle>
<CardDescription className="text-muted-foreground">
subagent Planner / Specialist
</CardDescription>
</div>
</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>
<CardContent className="theme-card-content space-y-8">
{/* ── Subagents 目录 ──────────────────────────────────────────── */}
<section className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
<Sparkles className="h-4 w-4" />
</div>
<h3 className="text-base font-semibold text-foreground">Subagents </h3>
</div>
)}
<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';
<Alert className="border-primary/20 bg-primary/5">
<Bot className="h-4 w-4 text-primary" />
<AlertTitle> kernel </AlertTitle>
<AlertDescription>
kernel session state planner subagent subagent
</AlertDescription>
</Alert>
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>
)}
{isSubagentsLoading ? (
<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" />
subagent ...
</div>
) : (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-border/70 bg-card/70">
<CardContent className="p-5">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Subagents</div>
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">{subagents.length}</div>
</CardContent>
</Card>
<Card className="border-border/70 bg-card/70">
<CardContent className="p-5">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Built-in</div>
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">
{subagents.filter((item) => item.source === 'built-in').length}
</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>
</CardContent>
</Card>
<Card className="border-border/70 bg-card/70">
<CardContent className="p-5">
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground"></div>
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">
{new Set(subagents.map((item) => item.modelRole).filter(Boolean)).size}
</div>
</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>
</CardContent>
</Card>
</div>
);
})}
</div>
<Card className="border-border/70 bg-card/70">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-5">Subagent</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="pr-5 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subagents.map((subagent) => (
<TableRow key={subagent.name}>
<TableCell className="pl-5 align-top">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground">{subagent.name}</span>
<Badge className={getSourceBadgeClass(subagent.source)}>{subagent.source}</Badge>
</div>
<div className="text-sm text-muted-foreground">{subagent.description}</div>
</div>
</TableCell>
<TableCell className="align-top text-sm text-muted-foreground whitespace-normal">
{subagent.whenToUse}
</TableCell>
<TableCell className="align-top">
<Badge className={getModelRoleBadgeClass(subagent.modelRole)}>
<Route className="h-3 w-3" />
{subagent.modelRole ?? '未绑定'}
</Badge>
</TableCell>
<TableCell className="align-top">
<div className="flex flex-wrap gap-1.5 max-w-[260px]">
{subagent.tags.map((tag) => (
<Badge key={tag} variant="outline" className="bg-muted/30">{tag}</Badge>
))}
</div>
</TableCell>
<TableCell className="pr-5 align-top text-right">
<Badge className={subagent.resumable ? 'border-success/20 bg-success/10 text-success' : 'border-border bg-muted/40 text-muted-foreground'}>
{subagent.resumable ? '可恢复' : '一次性'}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)}
</section>
<Separator />
{/* ── 模型角色路由 ─────────────────────────────────────────────── */}
<section className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-warning/25 bg-warning/10 text-warning">
<Workflow className="h-4 w-4" />
</div>
<h3 className="text-base font-semibold text-foreground"></h3>
</div>
<Alert className="border-warning/20 bg-warning/5">
<ShieldCheck className="h-4 w-4 text-warning" />
<AlertTitle></AlertTitle>
<AlertDescription>
Planner / Specialist provider/model LLM subagent kernel
</AlertDescription>
</Alert>
{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;
const consumers = subagents.filter((item) => item.modelRole === role);
return (
<div key={role} className="py-5 px-1">
<div className="flex flex-col gap-4 rounded-lg border border-border/60 bg-card/40 p-4 hover:bg-accent/20 transition-colors">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Label className="text-base font-semibold text-foreground">
{ROLE_LABELS[role]?.label || role}
</Label>
<Badge variant="outline" className="bg-muted/30">
{consumers.length} subagent
</Badge>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{ROLE_LABELS[role]?.desc}
</p>
{consumers.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{consumers.map((item) => (
<Badge key={item.name} className="border-primary/15 bg-primary/5 text-primary">
{item.name}
</Badge>
))}
</div>
)}
</div>
<Separator />
<div className="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>
</div>
</div>
);
})}
</div>
)}
</section>
</CardContent>
</Card>
);

View File

@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest';
import { ProviderList } from '../ProviderList';
import {
fetchProviders,
fetchRoles,
updateProvider,
deleteProvider,
testProvider,
@@ -19,6 +20,7 @@ vi.mock('sonner', () => ({
vi.mock('@/services/llmProviderService', () => ({
fetchProviders: vi.fn(),
fetchRoles: vi.fn(),
updateProvider: vi.fn(),
deleteProvider: vi.fn(),
testProvider: vi.fn(),
@@ -60,6 +62,7 @@ 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 });

View File

@@ -4,7 +4,12 @@ import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { RoleAssignment } from '../RoleAssignment';
import { fetchConfig, updateConfig, type ConfigResponse } from '@/services/configService';
import {
fetchKernelSubagents,
fetchProviders,
fetchRoles,
setRole,
} from '@/services/llmProviderService';
vi.mock('sonner', () => ({
toast: {
@@ -13,10 +18,22 @@ vi.mock('sonner', () => ({
},
}));
vi.mock('@/services/configService', () => ({
fetchConfig: vi.fn(),
updateConfig: vi.fn(),
}));
vi.mock('@/services/llmProviderService', async () => {
const actual = await vi.importActual<typeof import('@/services/llmProviderService')>('@/services/llmProviderService');
return {
...actual,
fetchProviders: vi.fn(),
fetchKernelSubagents: 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'],
}),
};
});
function renderWithQuery(ui: ReactNode) {
const queryClient = new QueryClient({
@@ -28,163 +45,84 @@ 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 agent model settings and saves edits', async () => {
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
vi.mocked(updateConfig).mockResolvedValue(undefined);
it('renders subagent directory and model role routing', 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(fetchKernelSubagents).mockResolvedValueOnce([
{
kind: 'subagent',
name: 'review:triage',
source: 'built-in',
description: '根据变更范围决定 review 域与审查模式',
whenToUse: '当需要规划任务时',
modelRole: 'planner',
tags: ['review', 'planner', 'triage'],
resumable: true,
},
{
kind: 'subagent',
name: 'review:full_review',
source: 'built-in',
description: '执行一次完整自主代码审查',
whenToUse: '当 triage 生成审查提示后执行完整审查',
modelRole: 'specialist',
tags: ['review', 'specialist', 'full-review', 'autonomous-review'],
resumable: true,
},
]);
vi.mocked(setRole).mockResolvedValue({
role: 'planner',
providerId: 'p1',
providerName: 'OpenAI',
providerType: 'openai_responses',
model: 'custom-planner-model',
});
const user = userEvent.setup();
renderWithQuery(<RoleAssignment />);
// 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();
expect(await screen.findByText('Subagents 与模型路由')).toBeInTheDocument();
expect((await screen.findAllByText('review:triage')).length).toBeGreaterThan(0);
expect(screen.getByText('模型角色路由')).toBeInTheDocument();
expect(screen.getByText('Planner')).toBeInTheDocument();
expect(screen.getByText('Specialist')).toBeInTheDocument();
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 providerPlaceholders = screen.getAllByText('选择提供商');
const triggerButton = providerPlaceholders[0].closest('button')!;
await user.click(triggerButton);
await user.click(await screen.findByRole('option', { name: /OpenAI/ }));
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',
},
],
},
],
const modelInputs = screen.getAllByPlaceholderText('选择或输入模型...') as HTMLInputElement[];
await waitFor(() => {
expect(modelInputs[0].value).toBe('gpt-4o');
});
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();
await user.clear(modelInputs[0]);
await user.type(modelInputs[0], 'custom-planner-model');
expect(modelInputs[0].value).toBe('custom-planner-model');
});
});

View File

@@ -1,17 +1,17 @@
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, Layers } from 'lucide-react';
import { LogOut, Bot, FolderGit2, Sliders, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette, Waypoints } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
const navItems = [
{ path: '/sessions', label: '审查会话', icon: Waypoints },
{ path: '/repos', label: '仓库管理', icon: FolderGit2 },
{ 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() {

File diff suppressed because it is too large Load Diff

View File

@@ -1,312 +0,0 @@
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();
});
});

View File

@@ -15,6 +15,25 @@ export interface ProviderDto {
updatedAt?: string;
}
export interface RoleAssignmentDto {
role: string;
providerId: string | null;
providerName: string | null;
providerType: string | null;
model: string | null;
}
export interface KernelSubagentDto {
kind: 'subagent';
name: string;
source: 'built-in' | 'custom' | 'plugin';
description: string;
whenToUse: string;
modelRole?: string;
tags: string[];
resumable?: boolean;
}
export interface TestResult {
success: boolean;
latencyMs?: number;
@@ -67,6 +86,21 @@ 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 fetchKernelSubagents = async (): Promise<KernelSubagentDto[]> => {
const response = await api.get<{ data: KernelSubagentDto[] }>('/review/kernel/subagents');
return response.data.data;
};
export const testProvider = async (id: string): Promise<TestResult> => {
const response = await api.post<TestResult>(`/llm/providers/${id}/test`);
return response.data;

View File

@@ -1,147 +1,122 @@
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 ReviewSessionSummaryRecordDto {
session: {
id: string;
scopeType: 'pull_request' | 'commit';
scopeKey: string;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
lastRunId?: string;
};
summary: {
sessionId: string;
scopeKey: string;
scopeType: 'pull_request' | 'commit';
owner?: string;
repo?: string;
prNumber?: number;
headSha?: string;
status:
| 'queued'
| 'planning'
| 'executing'
| 'awaiting_human_feedback'
| 'completed'
| 'failed'
| 'ignored';
currentStep?: string;
findingCount: number;
pendingTaskCount: number;
updatedAt: 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 ReviewPlanStepDto {
key: string;
label: string;
description: string;
status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'skipped';
progressText?: string;
}
export interface Finding {
export interface ReviewTimelineEntryDto {
id: string;
runId: string;
fingerprint: string;
category: 'correctness' | 'security' | 'reliability' | 'maintainability';
severity: 'high' | 'medium' | 'low';
confidence: number;
path: string;
line: number;
timestamp: string;
title: string;
detail: string;
evidence: string;
suggestion: string;
published: boolean;
tone: 'neutral' | 'success' | 'warning' | 'danger';
}
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 ReviewSessionDetailDto {
session: ReviewSessionSummaryRecordDto['session'];
summary: ReviewSessionSummaryRecordDto['summary'];
checkpoint: {
state: Record<string, unknown>;
pendingTasks: Array<{ kind: 'skill' | 'subagent'; name: string; input?: Record<string, unknown> }>;
stopReason?: string;
} | null;
plan: ReviewPlanStepDto[];
timeline: ReviewTimelineEntryDto[];
events: Array<{
id: string;
sessionId: string;
eventType: string;
payload: Record<string, unknown>;
createdAt: string;
}>;
runDetails: {
run: {
id: string;
eventType: string;
status: string;
owner: string;
repo: string;
prNumber?: number;
commitSha?: string;
headSha?: string;
baseSha?: string;
createdAt: string;
updatedAt: string;
};
findings: Array<{
id: string;
title: string;
detail: string;
evidence: string;
suggestion: string;
severity: 'high' | 'medium' | 'low';
category: string;
path: string;
line: number;
confidence: number;
published: boolean;
fingerprint: string;
}>;
comments: Array<{
id: string;
status: string;
body: string;
path?: string;
line?: number;
createdAt: string;
}>;
} | null;
}
export interface AgentMessageRecord {
id: string;
sessionId: string;
sequence: number;
role: string;
content: any;
metadata: Record<string, any>;
createdAt: string;
export interface ReviewSessionListResponse {
data: ReviewSessionSummaryRecordDto[];
}
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 fetchReviewSessions = async (): Promise<ReviewSessionSummaryRecordDto[]> => {
const response = await api.get<ReviewSessionListResponse>('/review/sessions');
return response.data.data;
};
export const fetchReviewRunDetails = async (runId: string): Promise<ReviewRunDetails> => {
const response = await api.get<ReviewRunDetails>(`/review/runs/${runId}`);
export const fetchReviewSessionDetail = async (
sessionId: string
): Promise<ReviewSessionDetailDto> => {
const response = await api.get<ReviewSessionDetailDto>(`/review/sessions/${sessionId}`);
return response.data;
};

View File

@@ -107,9 +107,9 @@ const configResponse = {
label: '审查引擎',
description: '当前使用的审查引擎',
type: 'enum',
enumValues: ['agent', 'codex'],
enumValues: ['kernel', 'codex'],
sensitive: false,
value: 'agent',
value: 'kernel',
hasValue: true,
source: 'db',
},
@@ -195,6 +195,24 @@ const configResponse = {
},
],
},
{
key: 'memory',
label: '记忆设置',
description: '控制上下文记忆与保留策略。',
icon: 'database',
fields: [
{
envKey: 'MEMORY_ENABLED',
label: '启用记忆',
description: '是否启用长期记忆',
type: 'boolean',
sensitive: false,
value: true,
hasValue: true,
source: 'db',
},
],
},
],
};
@@ -225,6 +243,23 @@ 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'],
@@ -340,6 +375,14 @@ 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,

View File

@@ -1,3 +1,5 @@
---
# ConfigMap: only infrastructure-level env vars that must be known before DB init
apiVersion: v1
kind: ConfigMap
metadata:
@@ -9,6 +11,9 @@ 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

View File

@@ -6,4 +6,5 @@ namespace: gitea-assistant
resources:
- namespace.yaml
- secret.yaml
- qdrant.yaml
- gitea-assistant.yaml

84
k8s/qdrant.yaml Normal file
View File

@@ -0,0 +1,84 @@
---
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

View File

@@ -9,6 +9,7 @@
"@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",
@@ -51,7 +52,7 @@
"start:prod": "bun run dist/index.js",
"lint": "biome check src/",
"test": "bun test",
"test:e2e": "bash ./e2e/test.sh",
"test:e2e": "E2E_MOCK_LLM=1 bun test ./e2e/__tests__/e2e-review.test.ts",
"prepare": "command -v husky >/dev/null 2>&1 && husky || true"
},
"keywords": [

View File

@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { closeDatabase, initDatabase } from '../../db/database';
import { KernelAgentInvoker } from '../agents/kernel-agent-invoker';
import { KernelAgentRegistry } from '../agents/kernel-agent-registry';
import { KernelTaskRegistry } from '../registry/kernel-task-registry';
import { AgentKernelRunner } from '../runtime/agent-kernel-runner';
import { kernelSessionRepository } from '../session/session-repository';
interface DummyState {
counter: number;
}
describe('AgentKernelRunner', () => {
let tempDir: string;
let savedDbPath: string | undefined;
beforeEach(async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-runner-db-'));
savedDbPath = process.env.DATABASE_PATH;
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
initDatabase();
});
afterEach(async () => {
closeDatabase();
if (savedDbPath === undefined) {
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
await rm(tempDir, { recursive: true, force: true });
});
test('runs queued skills and subagents and persists checkpoint', async () => {
const session = kernelSessionRepository.ensureSession({
scopeType: 'pull_request',
scopeKey: 'acme/repo#7',
metadata: { owner: 'acme', repo: 'repo', prNumber: 7 },
runId: 'run-7',
});
const skillRegistry = new KernelTaskRegistry<DummyState>();
const subagentRegistry = new KernelAgentRegistry<DummyState>();
skillRegistry.register({
kind: 'skill',
name: 'step_one',
description: 'Initial skill for runner test',
execute: async () => ({
state: { counter: 1 },
enqueue: [{ kind: 'subagent', name: 'step_two' }],
}),
});
subagentRegistry.register({
kind: 'subagent',
name: 'step_two',
source: 'built-in',
whenToUse: 'Increment the test counter',
description: 'Test subagent used by runner tests',
execute: async (_task, context) => ({
state: { counter: context.state.counter + 1 },
}),
});
const runner = new AgentKernelRunner(skillRegistry, new KernelAgentInvoker(subagentRegistry), {
plan: () => [],
});
const checkpoint = await runner.run({
sessionId: session.id,
runId: 'run-7',
initialState: { counter: 0 },
initialTasks: [{ kind: 'skill', name: 'step_one' }],
});
const events = kernelSessionRepository.listEvents(session.id);
expect(checkpoint.state.counter).toBe(2);
expect(checkpoint.pendingTasks).toHaveLength(0);
expect(checkpoint.stopReason).toBe('completed');
expect(events.map((event) => event.eventType).sort()).toEqual([
'task_completed',
'task_completed',
'task_started',
'task_started',
]);
});
test('continueExisting ignores persisted stop reason and resumes planned work', async () => {
const session = kernelSessionRepository.ensureSession({
scopeType: 'pull_request',
scopeKey: 'acme/repo#8',
metadata: { owner: 'acme', repo: 'repo', prNumber: 8 },
runId: 'run-8',
});
kernelSessionRepository.saveCheckpoint(session.id, {
state: { counter: 1 },
pendingTasks: [],
stopReason: 'awaiting_human_feedback',
});
const skillRegistry = new KernelTaskRegistry<DummyState>();
const subagentRegistry = new KernelAgentRegistry<DummyState>();
skillRegistry.register({
kind: 'skill',
name: 'resume_step',
description: 'Resume skill for runner test',
execute: async (_task, context) => ({
state: { counter: context.state.counter + 1 },
}),
});
const runner = new AgentKernelRunner(skillRegistry, new KernelAgentInvoker(subagentRegistry), {
plan: (context) =>
context.state.counter < 2 ? [{ kind: 'skill', name: 'resume_step' }] : [],
});
const checkpoint = await runner.run({
sessionId: session.id,
runId: 'run-8',
initialState: { counter: 0 },
initialTasks: [],
continueExisting: true,
});
expect(checkpoint.state.counter).toBe(2);
expect(checkpoint.stopReason).toBe('completed');
});
});

View File

@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { closeDatabase, initDatabase } from '../../db/database';
import { getKernelAgentContext } from '../agents/kernel-agent-context';
import { KernelAgentInvoker } from '../agents/kernel-agent-invoker';
import { KernelAgentRegistry } from '../agents/kernel-agent-registry';
import { kernelSessionRepository } from '../session/session-repository';
interface DummyState {
value: number;
}
describe('KernelAgentInvoker', () => {
let tempDir: string;
let savedDbPath: string | undefined;
beforeEach(async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-agent-invoker-db-'));
savedDbPath = process.env.DATABASE_PATH;
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
initDatabase();
});
afterEach(async () => {
closeDatabase();
if (savedDbPath === undefined) {
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
await rm(tempDir, { recursive: true, force: true });
});
test('invokes subagent with isolated agent context and structured result', async () => {
const session = kernelSessionRepository.ensureSession({
scopeType: 'pull_request',
scopeKey: 'acme/repo#88',
metadata: { owner: 'acme', repo: 'repo', prNumber: 88 },
runId: 'run-88',
});
const registry = new KernelAgentRegistry<DummyState>();
registry.register({
kind: 'subagent',
name: 'test:subagent',
source: 'built-in',
description: 'Test subagent',
whenToUse: 'Used by invoker test',
tags: ['test'],
execute: async (_task, context) => {
const agentContext = getKernelAgentContext();
expect(agentContext?.agentType).toBe('subagent');
expect(agentContext?.subagentName).toBe('test:subagent');
expect(context.delegation.parentSessionId).toBe(session.id);
return {
state: { value: context.state.value + 1 },
summary: 'subagent completed',
artifacts: { nextValue: context.state.value + 1 },
};
},
});
const invoker = new KernelAgentInvoker(registry);
const output = await invoker.invoke(
{ kind: 'subagent', name: 'test:subagent', input: { focus: 'test' } },
{
session,
runId: 'run-88',
state: { value: 1 },
}
);
expect(output.result?.state).toEqual({ value: 2 });
expect(output.invocation.status).toBe('completed');
expect(output.invocation.result?.summary).toBe('subagent completed');
expect(output.invocation.result?.artifacts).toEqual({ nextValue: 2 });
});
});

View File

@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { closeDatabase, initDatabase } from '../../db/database';
import { kernelSessionRepository } from '../session/session-repository';
describe('KernelSessionRepository', () => {
let tempDir: string;
let savedDbPath: string | undefined;
beforeEach(async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-session-db-'));
savedDbPath = process.env.DATABASE_PATH;
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
initDatabase();
});
afterEach(async () => {
closeDatabase();
if (savedDbPath === undefined) {
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
await rm(tempDir, { recursive: true, force: true });
});
test('ensureSession reuses the same scope key and updates metadata', () => {
const first = kernelSessionRepository.ensureSession({
scopeType: 'pull_request',
scopeKey: 'acme/repo#42',
metadata: { owner: 'acme', repo: 'repo', prNumber: 42 },
runId: 'run-1',
});
const second = kernelSessionRepository.ensureSession({
scopeType: 'pull_request',
scopeKey: 'acme/repo#42',
metadata: { owner: 'acme', repo: 'repo', prNumber: 42, updated: true },
runId: 'run-2',
});
expect(second.id).toBe(first.id);
expect(second.lastRunId).toBe('run-2');
expect(second.metadata).toEqual({ owner: 'acme', repo: 'repo', prNumber: 42, updated: true });
});
test('appendEvent and saveCheckpoint persist session runtime state', () => {
const session = kernelSessionRepository.ensureSession({
scopeType: 'pull_request',
scopeKey: 'acme/repo#99',
metadata: { owner: 'acme', repo: 'repo', prNumber: 99 },
runId: 'run-99',
});
kernelSessionRepository.appendEvent(session.id, 'review_enqueued', { runId: 'run-99' });
kernelSessionRepository.appendEvent(session.id, 'task_started', { name: 'prepare_workspace' });
kernelSessionRepository.saveCheckpoint(session.id, {
state: { prepared: true, findings: 3 },
pendingTasks: [{ kind: 'skill', name: 'publish_review' }],
stopReason: 'waiting',
});
const events = kernelSessionRepository.listEvents(session.id);
const checkpoint = kernelSessionRepository.loadCheckpoint<{
prepared: boolean;
findings: number;
}>(session.id);
expect(events).toHaveLength(2);
expect(events.map((event) => event.eventType).sort()).toEqual([
'review_enqueued',
'task_started',
]);
expect(checkpoint).not.toBeNull();
expect(checkpoint?.state).toEqual({ prepared: true, findings: 3 });
expect(checkpoint?.pendingTasks).toEqual([{ kind: 'skill', name: 'publish_review' }]);
expect(checkpoint?.stopReason).toBe('waiting');
});
test('can query sessions by scope key and list sessions', () => {
const first = kernelSessionRepository.ensureSession({
scopeType: 'pull_request',
scopeKey: 'acme/repo#1',
metadata: { owner: 'acme', repo: 'repo', prNumber: 1 },
runId: 'run-1',
});
const second = kernelSessionRepository.ensureSession({
scopeType: 'pull_request',
scopeKey: 'acme/repo#2',
metadata: { owner: 'acme', repo: 'repo', prNumber: 2 },
runId: 'run-2',
});
expect(kernelSessionRepository.getSessionByScopeKey('acme/repo#1')?.id).toBe(first.id);
expect(kernelSessionRepository.listSessions(10).map((session) => session.id)).toEqual(
expect.arrayContaining([first.id, second.id])
);
});
});

View File

@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { closeDatabase, initDatabase } from '../../db/database';
import { kernelSessionRepository } from '../session/session-repository';
describe('KernelSessionRepository subagent invocations', () => {
let tempDir: string;
let savedDbPath: string | undefined;
beforeEach(async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-subagent-db-'));
savedDbPath = process.env.DATABASE_PATH;
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
initDatabase();
});
afterEach(async () => {
closeDatabase();
if (savedDbPath === undefined) {
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
} else {
process.env.DATABASE_PATH = savedDbPath;
}
await rm(tempDir, { recursive: true, force: true });
});
test('persists and lists subagent invocations', () => {
const session = kernelSessionRepository.ensureSession({
scopeType: 'pull_request',
scopeKey: 'acme/repo#101',
metadata: { owner: 'acme', repo: 'repo', prNumber: 101 },
runId: 'run-101',
});
const invocation = kernelSessionRepository.createSubagentInvocation({
parentSessionId: session.id,
parentRunId: 'run-101',
parentTaskName: 'custom:security-audit',
subagentName: 'custom:security-audit',
agentId: 'agent-123',
packet: {
goal: 'Review security issues',
parentTaskName: 'custom:security-audit',
input: { domain: 'security' },
parentSessionId: session.id,
parentRunId: 'run-101',
contextSummary: 'summary',
},
});
kernelSessionRepository.completeSubagentInvocation(invocation.id, 'completed', {
agentId: 'agent-123',
agentType: 'custom:security-audit',
summary: 'security review done',
totalDurationMs: 10,
totalToolUseCount: 0,
totalTokens: 0,
artifacts: { findings: 2 },
});
const invocations = kernelSessionRepository.listSubagentInvocations(session.id);
expect(invocations).toHaveLength(1);
expect(invocations[0]?.subagentName).toBe('custom:security-audit');
expect(invocations[0]?.result?.summary).toBe('security review done');
});
});

View File

@@ -0,0 +1,15 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { KernelSubagentContextRecord } from '../types';
const kernelAgentContextStorage = new AsyncLocalStorage<KernelSubagentContextRecord>();
export function getKernelAgentContext(): KernelSubagentContextRecord | undefined {
return kernelAgentContextStorage.getStore();
}
export function runWithKernelAgentContext<T>(
context: KernelSubagentContextRecord,
fn: () => Promise<T>
): Promise<T> {
return kernelAgentContextStorage.run(context, fn);
}

View File

@@ -0,0 +1,140 @@
import { randomUUID } from 'node:crypto';
import type { KernelHookRegistry } from '../hooks/kernel-hook-registry';
import { runKernelHooks } from '../hooks/kernel-hook-runner';
import { kernelSessionRepository } from '../session/session-repository';
import type {
KernelAgentExecutionContext,
KernelDelegationPacket,
KernelExecutionContext,
KernelHandlerResult,
KernelSubagentDefinition,
KernelTask,
} from '../types';
import { runWithKernelAgentContext } from './kernel-agent-context';
import { KernelAgentRegistry } from './kernel-agent-registry';
import { finalizeKernelSubagentResult } from './kernel-subagent-result';
export interface KernelSubagentInvocationOutput<TState> {
result?: KernelHandlerResult<TState>;
invocation: ReturnType<typeof kernelSessionRepository.listSubagentInvocations>[number];
}
export class KernelAgentInvoker<TState> {
constructor(
private readonly registry: KernelAgentRegistry<TState>,
private readonly hookRegistry?: KernelHookRegistry
) {}
get(name: string): KernelSubagentDefinition<TState> | undefined {
return this.registry.get(name);
}
getAll(): KernelSubagentDefinition<TState>[] {
return this.registry.getAll();
}
filterByTag(tag: string): KernelSubagentDefinition<TState>[] {
return this.registry.filterByTag(tag);
}
async invoke(
task: KernelTask,
context: KernelExecutionContext<TState>
): Promise<KernelSubagentInvocationOutput<TState>> {
const agent = this.registry.get(task.name);
if (!agent) {
throw new Error(`Kernel subagent definition not found: ${task.name}`);
}
const agentId = randomUUID();
const delegation: KernelDelegationPacket = {
goal: agent.whenToUse,
parentTaskName: task.name,
input: task.input ?? {},
parentSessionId: context.session.id,
parentRunId: context.runId,
contextSummary:
typeof (context.state as { compressedContext?: { summary?: string } }).compressedContext
?.summary === 'string'
? (context.state as { compressedContext?: { summary?: string } }).compressedContext
?.summary
: undefined,
};
const invocation = kernelSessionRepository.createSubagentInvocation({
parentSessionId: context.session.id,
parentRunId: context.runId,
parentTaskName: task.name,
subagentName: agent.name,
agentId,
packet: delegation,
});
const agentContext: KernelAgentExecutionContext<TState> = {
...context,
agent,
delegation,
};
if (this.hookRegistry) {
const hookResult = await runKernelHooks({
registry: this.hookRegistry,
input: {
event: 'SubagentStart',
sessionId: context.session.id,
runId: context.runId,
subagentName: agent.name,
agentId,
packet: delegation,
},
});
if (hookResult.blockingReason) {
throw new Error(hookResult.blockingReason);
}
}
const startTime = Date.now();
try {
const result = await runWithKernelAgentContext(
{
agentId,
parentSessionId: context.session.id,
agentType: 'subagent',
subagentName: agent.name,
source: agent.source,
invocationKind: 'spawn',
},
() => agent.execute(task, agentContext)
);
const finalized = finalizeKernelSubagentResult({
agentId,
agentType: agent.name,
startTime,
result,
});
return {
result,
invocation: kernelSessionRepository.completeSubagentInvocation(
invocation.id,
'completed',
finalized
),
};
} catch (error) {
const finalized = finalizeKernelSubagentResult({
agentId,
agentType: agent.name,
startTime,
result: {
summary: error instanceof Error ? error.message : String(error),
artifacts: { error: error instanceof Error ? error.message : String(error) },
},
});
kernelSessionRepository.completeSubagentInvocation(invocation.id, 'failed', finalized);
throw error;
}
}
}

View File

@@ -0,0 +1,21 @@
import type { KernelSubagentDefinition } from '../types';
export class KernelAgentRegistry<TState> {
private readonly agents = new Map<string, KernelSubagentDefinition<TState>>();
register(agent: KernelSubagentDefinition<TState>): void {
this.agents.set(agent.name, agent);
}
get(agentType: string): KernelSubagentDefinition<TState> | undefined {
return this.agents.get(agentType);
}
getAll(): KernelSubagentDefinition<TState>[] {
return [...this.agents.values()];
}
filterByTag(tag: string): KernelSubagentDefinition<TState>[] {
return this.getAll().filter((agent) => agent.tags?.includes(tag));
}
}

View File

@@ -0,0 +1,21 @@
import type { KernelHandlerResult, KernelSubagentInvocationResult } from '../types';
export function finalizeKernelSubagentResult<TState>(params: {
agentId: string;
agentType: string;
startTime: number;
result?: KernelHandlerResult<TState>;
}): KernelSubagentInvocationResult {
const { agentId, agentType, startTime, result } = params;
const totalDurationMs = Date.now() - startTime;
return {
agentId,
agentType,
summary: result?.summary ?? `${agentType} completed`,
totalDurationMs,
totalToolUseCount: 0,
totalTokens: 0,
artifacts: result?.artifacts,
};
}

View File

@@ -1,97 +0,0 @@
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);
}
});
});

View File

@@ -1,186 +0,0 @@
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.');
});
});

View File

@@ -1,84 +0,0 @@
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);
}

View File

@@ -1,258 +0,0 @@
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;
}

View File

@@ -1,62 +0,0 @@
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],
});
}

View File

@@ -1,28 +0,0 @@
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';

View File

@@ -0,0 +1,219 @@
import { describe, expect, test } from 'bun:test';
import { KernelHookRegistry } from '../kernel-hook-registry';
import { runKernelHooks } from '../kernel-hook-runner';
import type { KernelHookDefinition, KernelHookInput } from '../kernel-hook-types';
const baseContext = {
workspacePath: '/tmp/workspace',
mirrorPath: '/tmp/mirror',
runId: 'run-1',
};
function makeRegistry(hooks: KernelHookDefinition[]): KernelHookRegistry {
const registry = new KernelHookRegistry();
for (const hook of hooks) {
registry.register(hook);
}
return registry;
}
function makeHook(
name: string,
event: KernelHookInput['event'],
execute: KernelHookDefinition['execute']
): KernelHookDefinition {
return {
name,
event,
description: `Test hook ${name}`,
execute,
};
}
describe('runKernelHooks', () => {
test.each([
[
'SessionStart',
{
event: 'SessionStart',
sessionId: 'session-1',
runId: 'run-1',
scopeKey: 'repo#1',
},
],
[
'SubagentStart',
{
event: 'SubagentStart',
sessionId: 'session-1',
runId: 'run-1',
subagentName: 'test:subagent',
agentId: 'agent-1',
packet: {
input: { focus: 'test' },
goal: 'test goal',
parentTaskName: 'test:task',
parentSessionId: 'session-1',
parentRunId: 'run-1',
},
},
],
[
'PermissionRequest',
{
event: 'PermissionRequest',
toolName: 'write_file',
toolCallId: 'call-1',
input: { value: 'raw' },
context: baseContext,
suggestedBehavior: 'ask',
reason: 'needs approval',
},
],
[
'PreToolUse',
{
event: 'PreToolUse',
toolName: 'write_file',
toolCallId: 'call-1',
input: { value: 'raw' },
context: baseContext,
},
],
[
'PostToolUse',
{
event: 'PostToolUse',
toolName: 'write_file',
toolCallId: 'call-1',
input: { value: 'raw' },
output: { ok: true },
context: baseContext,
},
],
[
'PostToolUseFailure',
{
event: 'PostToolUseFailure',
toolName: 'write_file',
toolCallId: 'call-1',
input: { value: 'raw' },
error: 'boom',
context: baseContext,
},
],
] as const)('dispatches %s to matching hooks', async (_label, input) => {
const executed: string[] = [];
const registry = makeRegistry([
makeHook('first', input.event, async () => {
executed.push('first');
return { additionalContext: 'ctx-1' };
}),
]);
const result = await runKernelHooks({ registry, input });
expect(executed).toEqual(['first']);
expect(result.results).toHaveLength(1);
expect(result.additionalContexts).toEqual(['ctx-1']);
});
test('aggregates additionalContext values and lets later updatedInput override earlier values', async () => {
const registry = makeRegistry([
makeHook('first', 'PreToolUse', async () => ({
additionalContext: 'ctx-1',
updatedInput: { value: 'first' },
})),
makeHook('second', 'PreToolUse', async () => ({
additionalContext: 'ctx-2',
updatedInput: { value: 'second' },
})),
]);
const result = await runKernelHooks({
registry,
input: {
event: 'PreToolUse',
toolName: 'write_file',
toolCallId: 'call-1',
input: { value: 'raw' },
context: baseContext,
},
});
expect(result.additionalContexts).toEqual(['ctx-1', 'ctx-2']);
expect(result.updatedInput).toEqual({ value: 'second' });
expect(result.results).toHaveLength(2);
});
test('propagates blockingReason when a hook returns decision block', async () => {
const registry = makeRegistry([
makeHook('before', 'PermissionRequest', async () => ({
additionalContext: 'ctx-before',
updatedInput: { value: 'before' },
})),
makeHook('blocker', 'PermissionRequest', async () => ({
decision: 'block',
reason: 'blocked by policy',
additionalContext: 'ctx-blocker',
updatedInput: { value: 'blocked' },
})),
makeHook('after', 'PermissionRequest', async () => ({
additionalContext: 'ctx-after',
updatedInput: { value: 'after' },
})),
]);
const result = await runKernelHooks({
registry,
input: {
event: 'PermissionRequest',
toolName: 'write_file',
toolCallId: 'call-1',
input: { value: 'raw' },
context: baseContext,
suggestedBehavior: 'ask',
reason: 'needs approval',
},
});
expect(result.additionalContexts).toEqual(['ctx-before', 'ctx-blocker']);
expect(result.updatedInput).toEqual({ value: 'blocked' });
expect(result.blockingReason).toBe('blocked by policy');
expect(result.results).toHaveLength(2);
});
test('preserves approve decisions for PermissionRequest without introducing a blocking reason', async () => {
const registry = makeRegistry([
makeHook('approver', 'PermissionRequest', async () => ({
decision: 'approve',
reason: 'approved by reviewer',
additionalContext: 'ctx-approve',
updatedInput: { value: 'approved' },
})),
]);
const result = await runKernelHooks({
registry,
input: {
event: 'PermissionRequest',
toolName: 'write_file',
toolCallId: 'call-1',
input: { value: 'raw' },
context: baseContext,
suggestedBehavior: 'ask',
reason: 'needs approval',
},
});
expect(result.additionalContexts).toEqual(['ctx-approve']);
expect(result.updatedInput).toEqual({ value: 'approved' });
expect(result.blockingReason).toBeUndefined();
expect(result.results).toEqual([
expect.objectContaining({
decision: 'approve',
reason: 'approved by reviewer',
}),
]);
});
});

View File

@@ -0,0 +1,19 @@
import type { KernelHookDefinition, KernelHookEventName } from './kernel-hook-types';
export class KernelHookRegistry {
private readonly hooks = new Map<KernelHookEventName, KernelHookDefinition[]>();
register(hook: KernelHookDefinition): void {
const existing = this.hooks.get(hook.event) ?? [];
existing.push(hook);
this.hooks.set(hook.event, existing);
}
get(event: KernelHookEventName): KernelHookDefinition[] {
return this.hooks.get(event) ?? [];
}
getAll(): KernelHookDefinition[] {
return [...this.hooks.values()].flat();
}
}

View File

@@ -0,0 +1,47 @@
import { logger } from '../../utils/logger';
import { KernelHookRegistry } from './kernel-hook-registry';
import type { KernelHookInput, KernelLifecycleResult } from './kernel-hook-types';
export async function runKernelHooks(params: {
registry: KernelHookRegistry;
input: KernelHookInput;
}): Promise<KernelLifecycleResult> {
const hooks = params.registry.get(params.input.event);
const results = [] as KernelLifecycleResult['results'];
const additionalContexts: string[] = [];
let updatedInput: Record<string, unknown> | undefined;
let blockingReason: string | undefined;
for (const hook of hooks) {
try {
const result = await hook.execute(params.input);
if (!result) {
continue;
}
results.push(result);
if (result.additionalContext) {
additionalContexts.push(result.additionalContext);
}
if (result.updatedInput) {
updatedInput = result.updatedInput;
}
if (result.continue === false || result.decision === 'block') {
blockingReason = result.reason ?? `Execution blocked by hook ${hook.name}`;
break;
}
} catch (error) {
logger.error('Kernel hook 执行失败', {
hookName: hook.name,
event: params.input.event,
error: error instanceof Error ? error.message : String(error),
});
}
}
return {
results,
additionalContexts,
updatedInput,
blockingReason,
};
}

View File

@@ -0,0 +1,99 @@
import type { ToolExecutionContext } from '../../review/tools/types';
import type { KernelDelegationPacket, KernelSubagentInvocationResult } from '../types';
export type KernelHookEventName =
| 'SessionStart'
| 'SubagentStart'
| 'PermissionRequest'
| 'PreToolUse'
| 'PostToolUse'
| 'PostToolUseFailure';
export interface SessionStartHookInput {
event: 'SessionStart';
sessionId: string;
runId: string;
scopeKey: string;
}
export interface SubagentStartHookInput {
event: 'SubagentStart';
sessionId: string;
runId: string;
subagentName: string;
agentId: string;
packet: KernelDelegationPacket;
}
export interface PreToolUseHookInput {
event: 'PreToolUse';
toolName: string;
toolCallId: string;
input: Record<string, unknown>;
context: ToolExecutionContext;
}
export interface PermissionRequestHookInput {
event: 'PermissionRequest';
toolName: string;
toolCallId: string;
input: Record<string, unknown>;
context: ToolExecutionContext;
suggestedBehavior: 'ask' | 'deny';
reason: string;
}
export interface PostToolUseHookInput {
event: 'PostToolUse';
toolName: string;
toolCallId: string;
input: Record<string, unknown>;
output: unknown;
context: ToolExecutionContext;
}
export interface PostToolUseFailureHookInput {
event: 'PostToolUseFailure';
toolName: string;
toolCallId: string;
input: Record<string, unknown>;
error: string;
context: ToolExecutionContext;
}
export type KernelHookInput =
| SessionStartHookInput
| SubagentStartHookInput
| PermissionRequestHookInput
| PreToolUseHookInput
| PostToolUseHookInput
| PostToolUseFailureHookInput;
export interface KernelHookResult {
continue?: boolean;
additionalContext?: string;
updatedInput?: Record<string, unknown>;
decision?: 'approve' | 'block';
reason?: string;
metadata?: Record<string, unknown>;
}
export interface KernelHookDefinition {
name: string;
event: KernelHookEventName;
description: string;
execute(input: KernelHookInput): Promise<KernelHookResult | undefined>;
}
export interface KernelLifecycleResult {
results: KernelHookResult[];
additionalContexts: string[];
updatedInput?: Record<string, unknown>;
blockingReason?: string;
}
export interface KernelSubagentCompletionEnvelope {
invocationId: string;
subagentName: string;
result: KernelSubagentInvocationResult;
}

View File

@@ -1,620 +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 '../../../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();
});
});

View File

@@ -1,358 +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 '../../../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');
});
});

View File

@@ -1,2 +0,0 @@
export * from './main-agent-runner';
export * from './types';

View File

@@ -1,346 +0,0 @@
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,
});

View File

@@ -1,118 +0,0 @@
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;
};
}

View File

@@ -1,45 +0,0 @@
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');
});
});

View File

@@ -1,2 +0,0 @@
export { resolveAgentModel } from './model-resolver';
export type { AgentModelResolutionInput } from './model-resolver';

View File

@@ -1,17 +0,0 @@
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
);
}

View File

@@ -0,0 +1,17 @@
import type { KernelTaskHandler } from '../types';
export class KernelTaskRegistry<TState> {
private readonly handlers = new Map<string, KernelTaskHandler<TState>>();
register(handler: KernelTaskHandler<TState>): void {
this.handlers.set(handler.name, handler);
}
get(name: string): KernelTaskHandler<TState> | undefined {
return this.handlers.get(name);
}
getAll(): KernelTaskHandler<TState>[] {
return [...this.handlers.values()];
}
}

View File

@@ -0,0 +1,138 @@
import { KernelAgentInvoker } from '../agents/kernel-agent-invoker';
import { KernelTaskRegistry } from '../registry/kernel-task-registry';
import { kernelSessionRepository } from '../session/session-repository';
import type {
KernelCheckpoint,
KernelExecutionContext,
KernelTask,
KernelTurnPlanner,
} from '../types';
export class AgentKernelRunner<TState> {
constructor(
private readonly skillRegistry: KernelTaskRegistry<TState>,
private readonly subagentInvoker: KernelAgentInvoker<TState>,
private readonly planner: KernelTurnPlanner<TState>
) {}
async run(params: {
sessionId: string;
runId: string;
initialState: TState;
initialTasks: KernelTask[];
continueExisting?: boolean;
}): Promise<KernelCheckpoint<TState>> {
const session = kernelSessionRepository.getSessionById(params.sessionId);
if (!session) {
throw new Error(`Kernel session not found: ${params.sessionId}`);
}
const persisted = kernelSessionRepository.loadCheckpoint<TState>(params.sessionId);
let state = persisted?.state ?? params.initialState;
const pendingTasks = [...(persisted?.pendingTasks ?? params.initialTasks)];
let stopReason: string | undefined;
while (!stopReason) {
if (pendingTasks.length === 0) {
const plannedTasks = this.planner.plan({
session,
runId: params.runId,
state,
pendingTasks: [...pendingTasks],
});
if (plannedTasks.length === 0) {
stopReason = 'completed';
break;
}
pendingTasks.push(...plannedTasks);
}
const task = pendingTasks.shift() as KernelTask;
if (task.kind === 'subagent' && !this.subagentInvoker.get(task.name)) {
throw new Error(`Kernel subagent handler not found: ${task.name}`);
}
if (task.kind === 'skill' && !this.skillRegistry.get(task.name)) {
throw new Error(`Kernel skill handler not found: ${task.name}`);
}
kernelSessionRepository.appendEvent(params.sessionId, 'task_started', {
kind: task.kind,
name: task.name,
input: task.input ?? {},
runId: params.runId,
});
const context: KernelExecutionContext<TState> = {
session,
runId: params.runId,
state,
};
let result;
let invocation;
try {
if (task.kind === 'skill') {
result = await this.skillRegistry.get(task.name)?.execute(task, context);
} else {
const invocationOutput = await this.subagentInvoker.invoke(task, context);
result = invocationOutput.result;
invocation = invocationOutput.invocation;
}
} catch (error) {
kernelSessionRepository.appendEvent(params.sessionId, 'task_failed', {
kind: task.kind,
name: task.name,
runId: params.runId,
invocationId: invocation?.id,
agentId: invocation?.agentId,
error: error instanceof Error ? error.message : String(error),
});
kernelSessionRepository.saveCheckpoint(params.sessionId, {
state,
pendingTasks: [task, ...pendingTasks],
stopReason: 'failed',
});
throw error;
}
if (result?.state !== undefined) {
state = result.state;
}
if (result?.prepend?.length) {
pendingTasks.unshift(...result.prepend);
}
if (result?.enqueue?.length) {
pendingTasks.push(...result.enqueue);
}
if (result?.stopReason) {
stopReason = result.stopReason;
}
kernelSessionRepository.appendEvent(params.sessionId, 'task_completed', {
kind: task.kind,
name: task.name,
runId: params.runId,
invocationId: invocation?.id,
agentId: invocation?.agentId,
summary: invocation?.result?.summary ?? result?.summary,
artifacts: invocation?.result?.artifacts ?? result?.artifacts,
stopReason: result?.stopReason,
});
kernelSessionRepository.saveCheckpoint(params.sessionId, {
state,
pendingTasks,
stopReason,
});
}
const checkpoint = {
state,
pendingTasks,
stopReason: stopReason ?? 'completed',
};
kernelSessionRepository.saveCheckpoint(params.sessionId, checkpoint);
return checkpoint;
}
}

View File

@@ -1,224 +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, 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();
});
});

View File

@@ -1,18 +0,0 @@
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';

View File

@@ -1,36 +0,0 @@
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;
}

View File

@@ -1,376 +1,335 @@
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';
KernelCheckpoint,
KernelDelegationPacket,
KernelSessionEventRecord,
KernelSessionRecord,
KernelSubagentInvocationRecord,
KernelSubagentInvocationResult,
} from '../types';
interface AgentSessionRow {
interface SessionRow {
id: string;
parent_session_id: string | null;
parent_invocation_id: string | null;
agent_type: string;
model: string;
status: AgentSessionStatus;
scope_type: 'pull_request' | 'commit';
scope_key: string;
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;
last_run_id?: string;
}
interface AgentMessageRow {
interface EventRow {
id: string;
session_id: string;
sequence: number;
role: string;
content_json: string;
metadata_json: string;
event_type: string;
payload_json: string;
created_at: string;
}
interface AgentToolCallRow {
id: string;
interface CheckpointRow {
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;
state_json: string;
pending_tasks_json: string;
stop_reason?: string;
updated_at: string;
state_version: number;
}
interface AgentInvocationRow {
interface SubagentInvocationRow {
id: string;
parent_session_id: string;
child_session_id: string | null;
sequence: number;
agent_type: string;
model: string;
status: AgentSessionStatus;
parent_run_id: string;
parent_task_name: string;
subagent_name: string;
agent_id: string;
status: 'running' | 'completed' | 'failed';
input_json: string;
result_json: string | null;
error_json: string | null;
created_at: string;
completed_at: string | null;
result_json?: string;
started_at: string;
finished_at?: string;
}
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 {
function toSessionRecord(row: SessionRow): KernelSessionRecord {
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,
scopeType: row.scope_type,
scopeKey: row.scope_key,
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,
lastRunId: row.last_run_id,
};
}
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 {
export class KernelSessionRepository {
ensureSession(input: {
scopeType: 'pull_request' | 'commit';
scopeKey: string;
metadata: Record<string, unknown>;
runId?: string;
}): KernelSessionRecord {
const db = getDatabase();
const id = input.id ?? randomUUID();
const existing = db
.query(
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
FROM agent_kernel_sessions
WHERE scope_key = ?`
)
.get(input.scopeKey) as SessionRow | null;
if (existing) {
db.query(
`UPDATE agent_kernel_sessions
SET metadata_json = ?, updated_at = datetime('now'), last_run_id = ?
WHERE id = ?`
).run(
JSON.stringify(input.metadata),
input.runId ?? existing.last_run_id ?? null,
existing.id
);
return this.getSessionById(existing.id) as KernelSessionRecord;
}
const 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 ?? {})
);
`INSERT INTO agent_kernel_sessions (
id, scope_type, scope_key, metadata_json, last_run_id
) VALUES (?, ?, ?, ?, ?)`
).run(id, input.scopeType, input.scopeKey, JSON.stringify(input.metadata), input.runId ?? null);
const session = this.getSession(id);
if (!session) throw new Error('Failed to load created agent session');
return session;
return this.getSessionById(id) as KernelSessionRecord;
}
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 {
getSessionById(sessionId: string): KernelSessionRecord | 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') = ?`
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
FROM agent_kernel_sessions
WHERE id = ?`
)
.get(runId) as { id: string } | null;
.get(sessionId) as SessionRow | null;
if (!row) return null;
return this.getSessionTree(row.id);
return row ? toSessionRecord(row) : null;
}
listMessages(sessionId: string): AgentMessageRecord[] {
getSessionByScopeKey(scopeKey: string): KernelSessionRecord | null {
const db = getDatabase();
const row = db
.query(
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
FROM agent_kernel_sessions
WHERE scope_key = ?`
)
.get(scopeKey) as SessionRow | null;
return row ? toSessionRecord(row) : null;
}
listSessions(limit = 50): KernelSessionRecord[] {
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);
.query(
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
FROM agent_kernel_sessions
ORDER BY updated_at DESC, created_at DESC
LIMIT ?`
)
.all(limit) as SessionRow[];
return rows.map(toSessionRecord);
}
listToolCalls(sessionId: string): AgentToolCallRecord[] {
appendEvent(
sessionId: string,
eventType: string,
payload: Record<string, unknown>
): KernelSessionEventRecord {
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);
}
const id = randomUUID();
db.query(
`INSERT INTO agent_kernel_session_events (id, session_id, event_type, payload_json)
VALUES (?, ?, ?, ?)`
).run(id, sessionId, eventType, JSON.stringify(payload));
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;
const row = db
.query(
`SELECT id, session_id, event_type, payload_json, created_at
FROM agent_kernel_session_events
WHERE id = ?`
)
.get(id) as EventRow;
return {
invocation,
childSession: invocation.childSessionId
? (this.getSessionTree(invocation.childSessionId) ?? undefined)
: undefined,
id: row.id,
sessionId: row.session_id,
eventType: row.event_type,
payload: JSON.parse(row.payload_json) as Record<string, unknown>,
createdAt: row.created_at,
};
}
private getMessage(messageId: string): AgentMessageRecord | null {
listEvents(sessionId: string): KernelSessionEventRecord[] {
const db = getDatabase();
const row = db
.query('SELECT * FROM agent_messages WHERE id = ?')
.get(messageId) as AgentMessageRow | null;
return row ? toMessageRecord(row) : null;
const rows = db
.query(
`SELECT id, session_id, event_type, payload_json, created_at
FROM agent_kernel_session_events
WHERE session_id = ?
ORDER BY created_at ASC, id ASC`
)
.all(sessionId) as EventRow[];
return rows.map((row) => ({
id: row.id,
sessionId: row.session_id,
eventType: row.event_type,
payload: JSON.parse(row.payload_json) as Record<string, unknown>,
createdAt: row.created_at,
}));
}
private getToolCall(toolCallId: string): AgentToolCallRecord | null {
saveCheckpoint<TState>(
sessionId: string,
checkpoint: KernelCheckpoint<TState>,
stateVersion = 1
): void {
const db = getDatabase();
const row = db
.query('SELECT * FROM agent_tool_calls WHERE id = ?')
.get(toolCallId) as AgentToolCallRow | null;
return row ? toToolCallRecord(row) : null;
db.query(
`INSERT INTO agent_kernel_session_checkpoints (
session_id, state_json, pending_tasks_json, stop_reason, state_version, updated_at
) VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(session_id) DO UPDATE SET
state_json = excluded.state_json,
pending_tasks_json = excluded.pending_tasks_json,
stop_reason = excluded.stop_reason,
state_version = excluded.state_version,
updated_at = datetime('now')`
).run(
sessionId,
JSON.stringify(checkpoint.state),
JSON.stringify(checkpoint.pendingTasks),
checkpoint.stopReason ?? null,
stateVersion
);
}
private getInvocation(invocationId: string): AgentInvocationRecord | null {
loadCheckpoint<TState>(sessionId: string): KernelCheckpoint<TState> | null {
const db = getDatabase();
const row = db
.query('SELECT * FROM agent_invocations WHERE id = ?')
.get(invocationId) as AgentInvocationRow | null;
return row ? toInvocationRecord(row) : null;
.query(
`SELECT session_id, state_json, pending_tasks_json, stop_reason, updated_at, state_version
FROM agent_kernel_session_checkpoints
WHERE session_id = ?`
)
.get(sessionId) as CheckpointRow | null;
if (!row) {
return null;
}
return {
state: JSON.parse(row.state_json) as TState,
pendingTasks: JSON.parse(row.pending_tasks_json) as KernelCheckpoint<TState>['pendingTasks'],
stopReason: row.stop_reason,
};
}
deleteCheckpoint(sessionId: string): void {
const db = getDatabase();
db.query('DELETE FROM agent_kernel_session_checkpoints WHERE session_id = ?').run(sessionId);
}
createSubagentInvocation(input: {
parentSessionId: string;
parentRunId: string;
parentTaskName: string;
subagentName: string;
agentId: string;
packet: KernelDelegationPacket;
}): KernelSubagentInvocationRecord {
const db = getDatabase();
const id = randomUUID();
db.query(
`INSERT INTO agent_kernel_subagent_invocations (
id, parent_session_id, parent_run_id, parent_task_name, subagent_name, agent_id, status, input_json
) VALUES (?, ?, ?, ?, ?, ?, 'running', ?)`
).run(
id,
input.parentSessionId,
input.parentRunId,
input.parentTaskName,
input.subagentName,
input.agentId,
JSON.stringify(input.packet)
);
return this.getSubagentInvocationById(id) as KernelSubagentInvocationRecord;
}
completeSubagentInvocation(
invocationId: string,
status: 'completed' | 'failed',
result: KernelSubagentInvocationResult
): KernelSubagentInvocationRecord {
const db = getDatabase();
db.query(
`UPDATE agent_kernel_subagent_invocations
SET status = ?, result_json = ?, finished_at = datetime('now')
WHERE id = ?`
).run(status, JSON.stringify(result), invocationId);
return this.getSubagentInvocationById(invocationId) as KernelSubagentInvocationRecord;
}
listSubagentInvocations(parentSessionId: string): KernelSubagentInvocationRecord[] {
const db = getDatabase();
const rows = db
.query(
`SELECT id, parent_session_id, parent_run_id, parent_task_name, subagent_name, agent_id,
status, input_json, result_json, started_at, finished_at
FROM agent_kernel_subagent_invocations
WHERE parent_session_id = ?
ORDER BY started_at ASC, id ASC`
)
.all(parentSessionId) as SubagentInvocationRow[];
return rows.map((row) => this.toSubagentInvocationRecord(row));
}
private getSubagentInvocationById(invocationId: string): KernelSubagentInvocationRecord | null {
const db = getDatabase();
const row = db
.query(
`SELECT id, parent_session_id, parent_run_id, parent_task_name, subagent_name, agent_id,
status, input_json, result_json, started_at, finished_at
FROM agent_kernel_subagent_invocations
WHERE id = ?`
)
.get(invocationId) as SubagentInvocationRow | null;
return row ? this.toSubagentInvocationRecord(row) : null;
}
private toSubagentInvocationRecord(row: SubagentInvocationRow): KernelSubagentInvocationRecord {
return {
id: row.id,
parentSessionId: row.parent_session_id,
parentRunId: row.parent_run_id,
parentTaskName: row.parent_task_name,
subagentName: row.subagent_name,
agentId: row.agent_id,
status: row.status,
input: JSON.parse(row.input_json) as KernelDelegationPacket,
result: row.result_json
? (JSON.parse(row.result_json) as KernelSubagentInvocationResult)
: undefined,
startedAt: row.started_at,
finishedAt: row.finished_at,
};
}
}
export const agentSessionRepository = new AgentSessionRepository();
export const kernelSessionRepository = new KernelSessionRepository();

View File

@@ -1,122 +0,0 @@
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;
}

Some files were not shown because too many files have changed in this diff Show More