mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-06-13 07:26:48 +00:00
Compare commits
27 Commits
v1.1.0
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d442e193dd | ||
|
|
7d6794f368 | ||
|
|
6c83e12bf5 | ||
|
|
bc1dfb6dde | ||
|
|
8d6d167b33 | ||
|
|
1e7c80ca9f | ||
|
|
b92765ce7f | ||
|
|
daae32ce07 | ||
|
|
ab984ff415 | ||
|
|
d49a16db6e | ||
|
|
3a97d673f6 | ||
|
|
b6e6ee0927 | ||
|
|
22b603258a | ||
|
|
1885004874 | ||
|
|
d5deb75231 | ||
|
|
c313764b61 | ||
|
|
63f419228e | ||
|
|
f84c0ab777 | ||
|
|
7792a78c00 | ||
|
|
7aec1e452a | ||
|
|
8f9910a3fd | ||
|
|
2392808b82 | ||
|
|
9567501369 | ||
|
|
9964614b5e | ||
|
|
e40daddf0d | ||
|
|
b10b8dd7d5 | ||
|
|
5aeff7585b |
14
.env.example
14
.env.example
@@ -1,8 +1,14 @@
|
||||
# 应用配置
|
||||
PORT=3000
|
||||
# DATABASE_PATH=./data/assistant.db # 可选,默认为 ./data/assistant.db
|
||||
ENCRYPTION_KEY= # 必填,运行 openssl rand -hex 32 生成
|
||||
PORT=5174
|
||||
# 可选,默认为 ./data/assistant.db
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# 可选,默认 info,可选值:debug/info/warn/error
|
||||
# 开发环境建议:LOG_LEVEL=info
|
||||
# 生产环境建议:LOG_LEVEL=error
|
||||
# LOG_LEVEL=info
|
||||
# 必填,运行 openssl rand -hex 32 生成
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# 所有其他配置(Gitea连接、飞书通知、Webhook密钥、管理员密码、审查引擎、记忆系统等)
|
||||
# 均通过 Web 管理后台进行配置。
|
||||
# 启动服务后访问 http://localhost:3000 进行配置。
|
||||
# 启动服务后访问 http://localhost:5174 进行配置。
|
||||
|
||||
49
.github/workflows/release.yml
vendored
49
.github/workflows/release.yml
vendored
@@ -40,35 +40,66 @@ jobs:
|
||||
run: bun test
|
||||
|
||||
- name: Run semantic-release
|
||||
run: bunx semantic-release
|
||||
id: semantic
|
||||
uses: codfish/semantic-release-action@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
HUSKY: 0
|
||||
HUSKY_SKIP_HOOKS: 1
|
||||
|
||||
# Docker build and push
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: package-version
|
||||
- name: Derive Docker tags from semantic-release
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
id: docker-tags
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Detected version: $VERSION"
|
||||
VERSION="${{ steps.semantic.outputs.release-version }}"
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "semantic-release did not provide release-version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$VERSION" == *"-"* ]]; then
|
||||
TAGS="ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${VERSION}"
|
||||
else
|
||||
MAJOR="${VERSION%%.*}"
|
||||
REST="${VERSION#*.}"
|
||||
MINOR="${REST%%.*}"
|
||||
|
||||
TAGS=$(printf '%s\n' \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:latest" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${MAJOR}" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${MAJOR}.${MINOR}" \
|
||||
"ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${VERSION}")
|
||||
fi
|
||||
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo "$TAGS"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Release version: ${VERSION}"
|
||||
echo "Docker tags to push:"
|
||||
echo "$TAGS"
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: steps.semantic.outputs.new-release-published == 'true'
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:latest
|
||||
ghcr.io/${{ github.repository_owner }}/gitea-ai-assistant:${{ steps.package-version.outputs.version }}
|
||||
tags: ${{ steps.docker-tags.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@ public/
|
||||
|
||||
# Lock files (frontend has its own)
|
||||
frontend/package-lock.json
|
||||
.omo/
|
||||
.opencode/
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -1,3 +1,46 @@
|
||||
## [1.3.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.0...v1.3.1) (2026-03-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **db:** self-heal missing repository prompt schema ([b6e6ee0](https://github.com/jeffusion/gitea-ai-assistant/commit/b6e6ee0927eb757b86ee426bf8eed84ae633621a))
|
||||
* **logs:** gate repository list debug logs behind REPO_LIST_DEBUG_LOGS env flag ([3a97d67](https://github.com/jeffusion/gitea-ai-assistant/commit/3a97d673f671752a2e7f676fb0074b413c2e40cc))
|
||||
* **repo:** add structured diagnostics for repository list failures ([22b6032](https://github.com/jeffusion/gitea-ai-assistant/commit/22b603258ac32e70653aa1a05032a91e8ad23f89))
|
||||
|
||||
# [1.3.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.1...v1.3.0) (2026-03-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **repo:** add project-level review prompt with UI redesign ([d5deb75](https://github.com/jeffusion/gitea-ai-assistant/commit/d5deb752317508aa47470a20fec4d11a5d2b66b7))
|
||||
|
||||
## [1.2.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.2.0...v1.2.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** source Docker tags from semantic-release version ([f84c0ab](https://github.com/jeffusion/gitea-ai-assistant/commit/f84c0ab7770b73032b331c82cf8f87f1e8b281ff))
|
||||
|
||||
# [1.2.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.1...v1.2.0) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **lint:** apply biome cleanup for notification modules ([7aec1e4](https://github.com/jeffusion/gitea-ai-assistant/commit/7aec1e452a04d3dbf935837e9b8e96107466c487))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** add dedicated notification management menu and test panel ([9964614](https://github.com/jeffusion/gitea-ai-assistant/commit/9964614b5ebb7972e2b35f3fc673f626372f6552))
|
||||
* **notification:** replace feishu-only flow with pluggable providers ([e40dadd](https://github.com/jeffusion/gitea-ai-assistant/commit/e40daddf0dd168c19251cdb84a3b6b136814f553))
|
||||
|
||||
## [1.1.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.1.0...v1.1.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **build:** guard husky prepare for production installs ([5aeff75](https://github.com/jeffusion/gitea-ai-assistant/commit/5aeff7585b465fa9479c538b67b99978d12455b1))
|
||||
|
||||
# [1.1.0](https://github.com/Jeffusion/gitea-ai-assistant/compare/v1.0.0...v1.1.0) (2026-03-24)
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ RUN ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") && \
|
||||
# ---- Stage 4: Production ----
|
||||
FROM oven/bun:1-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates ripgrep && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates ripgrep curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -57,6 +57,6 @@ COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
# Codex CLI binary (statically linked musl build)
|
||||
COPY --from=codex-downloader /usr/local/bin/codex /usr/local/bin/codex
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
261
README.md
261
README.md
@@ -2,50 +2,48 @@
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
AI-powered code review assistant for Gitea. Automatically reviews Pull Requests and commits using pluggable LLM providers (OpenAI Compatible, OpenAI Responses API, Anthropic, Google Gemini), providing intelligent code quality analysis with both summary comments and line-level feedback.
|
||||
AI-powered code review assistant for Gitea. It receives webhooks, runs AI review workflows, and posts summary + line-level feedback back to Gitea.
|
||||
|
||||
**[中文文档](./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
|
||||
|
||||
- 🤖 **AI Code Review** - Automatic review of PRs and commits using pluggable LLM providers
|
||||
- 📝 **Line-Level Comments** - Precise feedback on specific code changes
|
||||
- 🔄 **Task-Based Review Engines** - Agent staged review (skip/light/full) plus optional Codex CLI execution mode
|
||||
- 🔔 **Feishu Notifications** - Integrated notification system for PR events
|
||||
- 🎛️ **Admin Dashboard** - Web UI for managing repository webhooks and LLM provider configuration
|
||||
- 🔐 **Secure Webhooks** - HMAC-SHA256 signature verification
|
||||
- 🤖 **Automated PR + commit review** via webhook events (`pull_request`, `status`)
|
||||
- 🧠 **Two review engines**: `agent` (native Agent pipeline) and `codex` (Codex CLI pipeline)
|
||||
- 🧵 **Pluggable LLM providers**: OpenAI Compatible, OpenAI Responses API, Anthropic, Gemini
|
||||
- 📍 **Actionable output**: summary comments and line-level findings
|
||||
- 🎛️ **Web Admin UI** for runtime configuration (providers, models, webhook, review policy)
|
||||
- 🔔 **Notifications**: Feishu + WeCom (企业微信)
|
||||
- 🔐 **Security-first defaults**: webhook signature verification + encrypted API key storage
|
||||
|
||||
## Architecture
|
||||
## Product screenshot
|
||||
|
||||
> Dashboard screenshot is generated from local dev service.
|
||||
|
||||

|
||||
|
||||
More screenshots (one per admin menu): [EN](./docs/screenshots.md) | [中文](./docs/screenshots.zh-CN.md)
|
||||
|
||||
## Architecture (high-level)
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Gitea Server │────▶│ Gitea Assistant │────▶│ LLM Gateway │
|
||||
│ (Webhooks) │ │ (Hono + Bun) │ │ (Multi-Provider)│
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ├─ OpenAI Compatible
|
||||
┌──────────────────┐ ├─ OpenAI Responses API
|
||||
│ Admin Dashboard │ ├─ Anthropic
|
||||
│ (React SPA) │ └─ Google Gemini
|
||||
└──────────────────┘
|
||||
Gitea Webhook -> Gitea AI Assistant (Hono + Bun) -> LLM Gateway (multi-provider)
|
||||
|
|
||||
+-> Admin Dashboard (React)
|
||||
```
|
||||
|
||||
### Review Engines
|
||||
For component-level design, see [Architecture docs](./docs/README.md#architecture--design).
|
||||
|
||||
| Engine | Description | Use Case |
|
||||
|--------|-------------|----------|
|
||||
| `agent` | Task-based staged review (`skip` / `light` / `full`) with scoped specialist routing and optional reflection/debate escalation | Deep reviews with token-aware execution |
|
||||
| `codex` | Codex CLI review execution with independent configuration | External Codex-driven review pipeline |
|
||||
## Quick start (minimal)
|
||||
|
||||
## Quick Start
|
||||
### 1) Prerequisites
|
||||
|
||||
### Prerequisites
|
||||
- Bun >= 1.2.5
|
||||
- Reachable Gitea instance
|
||||
- At least one LLM provider credential
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.2.5
|
||||
- Gitea instance with API access
|
||||
- At least one LLM provider API key (OpenAI, Anthropic, Google Gemini, or any OpenAI-compatible endpoint)
|
||||
|
||||
### Installation
|
||||
### 2) Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
@@ -53,193 +51,54 @@ cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a `.env` file with only infrastructure-level settings:
|
||||
If lifecycle scripts are disabled in your environment, run:
|
||||
|
||||
```bash
|
||||
# Server port
|
||||
PORT=3000
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
# REQUIRED: encryption key for API key storage (generate with: openssl rand -hex 32)
|
||||
ENCRYPTION_KEY=
|
||||
### 3) Minimal `.env`
|
||||
|
||||
# Optional: custom database path (default shown)
|
||||
```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
|
||||
```
|
||||
|
||||
> **All other configuration** (Gitea connection, webhook secret, admin password, review engine, Feishu, memory settings, etc.) is managed through the **Admin Dashboard Web UI** at `http://your-server:3000`. On first boot, all settings are seeded with secure defaults automatically.
|
||||
> `ENCRYPTION_KEY` is mandatory. The app refuses to start without it.
|
||||
|
||||
See [Configuration Reference](#configuration-reference) for all options.
|
||||
|
||||
### Running
|
||||
### 4) Run
|
||||
|
||||
```bash
|
||||
bun run dev # Development mode
|
||||
bun run start # Production mode
|
||||
bun run dev
|
||||
# or
|
||||
bun run start
|
||||
```
|
||||
|
||||
### Setting Up Webhooks
|
||||
### 5) Configure in Admin UI
|
||||
|
||||
**Option 1: Admin Dashboard (Recommended)**
|
||||
Open `http://your-server:5174`, login with default `password` (first boot only), then change it immediately.
|
||||
|
||||
1. Access `http://your-server:3000`
|
||||
2. Log in with the admin password (default: `password` — change it in the dashboard)
|
||||
3. Click "Enable" on repositories to auto-configure webhooks
|
||||
- Configure Gitea API + tokens
|
||||
- Configure webhook secret
|
||||
- Configure LLM providers/models
|
||||
- Configure review engine and policy
|
||||
|
||||
**Option 2: Manual Configuration**
|
||||
### 6) Add webhook in Gitea
|
||||
|
||||
In Gitea repository settings, add a webhook:
|
||||
- **URL**: `http://your-server:3000/webhook/gitea`
|
||||
- **Content Type**: `application/json`
|
||||
- **Secret**: Same as the Webhook Secret configured in the dashboard
|
||||
- **Events**: "Pull Request" and "Status"
|
||||
## Configuration Reference
|
||||
- URL: `http://your-server:5174/webhook/gitea`
|
||||
- Content-Type: `application/json`
|
||||
- Secret: same as dashboard webhook secret
|
||||
- Events: Pull Request + Status
|
||||
|
||||
### Environment Variables (Minimal)
|
||||
## Progressive disclosure: detailed docs
|
||||
|
||||
Only infrastructure-level settings that must be known before the database is initialized:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | Server port | `5174` |
|
||||
| `DATABASE_PATH` | SQLite database file path | `./data/assistant.db` |
|
||||
| `ENCRYPTION_KEY` | **Required.** AES-256-GCM encryption key for API key storage (64 hex chars). Generate with `openssl rand -hex 32` | — |
|
||||
|
||||
### Web UI Configuration (Admin Dashboard)
|
||||
|
||||
All runtime configuration is managed through the **Admin Dashboard** at `http://your-server:PORT`. Changes take effect immediately without restart.
|
||||
|
||||
On first boot with an empty database, all settings are seeded with secure defaults:
|
||||
- `JWT_SECRET` and `WEBHOOK_SECRET` are auto-generated (64-char hex via `crypto.randomBytes`)
|
||||
- `ADMIN_PASSWORD` defaults to `password` — **change this immediately**
|
||||
|
||||
#### Gitea
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Gitea API URL | Gitea API endpoint (e.g. `https://gitea.example.com/api/v1`) |
|
||||
| Access Token | Token for code review (read + comment permissions) |
|
||||
| Admin Token | Token for webhook management (optional) |
|
||||
|
||||
#### Security
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Webhook Secret | HMAC-SHA256 webhook signature secret | Auto-generated |
|
||||
| Admin Password | Dashboard login password | `password` |
|
||||
| JWT Secret | JWT signing secret | Auto-generated |
|
||||
|
||||
#### LLM Provider Configuration
|
||||
|
||||
LLM providers and models are configured exclusively through the **Admin Dashboard** Web UI:
|
||||
|
||||
1. Navigate to **LLM 配置** (LLM Configuration)
|
||||
2. Add your LLM providers (OpenAI Compatible, OpenAI Responses API, Anthropic, Google Gemini)
|
||||
3. Assign models to review roles (planner, specialist, judge, embedding)
|
||||
|
||||
> API keys are stored encrypted (AES-256-GCM) in the local SQLite database.
|
||||
|
||||
#### Feishu Integration
|
||||
|
||||
| Setting | Description |
|
||||
|---------|-------------|
|
||||
| Feishu Webhook URL | Feishu bot webhook URL |
|
||||
| Feishu Webhook Secret | Feishu webhook secret (optional) |
|
||||
|
||||
#### Agent Review Engine
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Review Engine | Engine mode (`agent` or `codex`) | `agent` |
|
||||
| Enable Triage | Enable planner triage for task routing | `true` |
|
||||
| Small Max Files | Upper file-count bound for `small` review size | `3` |
|
||||
| Small Max Changed Lines | Upper changed-lines bound for `small` review size | `80` |
|
||||
| Medium Max Files | Upper file-count bound for `medium` review size | `10` |
|
||||
| Medium Max Changed Lines | Upper changed-lines bound for `medium` review size | `400` |
|
||||
| Token Budget Small | Token budget cap for `small` staged tasks | `12000` |
|
||||
| Token Budget Medium | Token budget cap for `medium` staged tasks | `45000` |
|
||||
| Token Budget Large | Token budget cap for `large` staged tasks | `120000` |
|
||||
| Review Work Directory | Working directory for repo clones | `/tmp/gitea-assistant` |
|
||||
| Max Parallel Runs | Max concurrent review tasks | `2` |
|
||||
| Max Files per Run | Max files analyzed per review | `200` |
|
||||
| Auto-publish Min Confidence | Min confidence score for auto-publish | `0.8` |
|
||||
| Enable Human Gate | Require human approval before publishing | `true` |
|
||||
|
||||
Agent review execution model (current):
|
||||
|
||||
- `skip`: docs/assets/rename-only style changes can bypass specialist review.
|
||||
- `light`: low-risk code changes run minimal scoped specialist checks.
|
||||
- `full`: sensitive or larger changes run full specialist tasks, with optional reflection/debate escalation.
|
||||
- Triage outputs task scopes (`paths`, `riskTags`, `mode`, `tokenBudget`) and orchestrator dispatches specialists by task scope instead of broad fan-out.
|
||||
|
||||
#### Memory & Learning (Experimental)
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Qdrant URL | Qdrant vector database URL | - |
|
||||
| Enable Memory | Enable memory system | `false` |
|
||||
| Enable Reflection | Enable self-critique | `false` |
|
||||
| Enable Debate | Enable multi-agent debate | `false` |
|
||||
## Deployment
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data -e PORT=3000 gitea-assistant
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
Kubernetes manifests are located in the `k8s/` directory.
|
||||
|
||||
**1. Create the encryption secret**
|
||||
|
||||
```bash
|
||||
# Generate a key and create the secret
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
|
||||
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
# Save this key! You'll need it if you ever redeploy.
|
||||
echo "Your ENCRYPTION_KEY: $ENCRYPTION_KEY"
|
||||
```
|
||||
|
||||
**2. Deploy**
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
Or apply individually:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
**4. Verify**
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
|
||||
**5. Expose the Service (optional)**
|
||||
|
||||
By default, services use `ClusterIP`. To expose externally, use an Ingress or change the Service type:
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
|
||||
```
|
||||
- [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
|
||||
|
||||
|
||||
31
bun.lock
31
bun.lock
@@ -8,12 +8,12 @@
|
||||
"@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",
|
||||
"lodash-es": "^4.17.21",
|
||||
"openai": "^4.87.3",
|
||||
"pino": "^10.3.1",
|
||||
"tokenlens": "^1.3.1",
|
||||
"zod": "^3.25.1",
|
||||
"zod-to-json-schema": "^3.25.1",
|
||||
@@ -90,6 +90,8 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
|
||||
|
||||
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||
|
||||
"@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="],
|
||||
@@ -118,9 +120,7 @@
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
"@qdrant/js-client-rest": ["@qdrant/js-client-rest@1.17.0", "", { "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "undici": "^6.23.0" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A=="],
|
||||
|
||||
"@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
@@ -190,6 +190,8 @@
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
|
||||
|
||||
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
@@ -532,6 +534,8 @@
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="],
|
||||
@@ -584,18 +588,28 @@
|
||||
|
||||
"pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="],
|
||||
|
||||
"pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="],
|
||||
|
||||
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
|
||||
|
||||
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
|
||||
|
||||
"pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
|
||||
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
|
||||
|
||||
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"read-package-up": ["read-package-up@11.0.0", "", { "dependencies": { "find-up-simple": "^1.0.0", "read-pkg": "^9.0.0", "type-fest": "^4.6.0" } }, "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ=="],
|
||||
@@ -604,6 +618,8 @@
|
||||
|
||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
||||
|
||||
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
|
||||
|
||||
"registry-auth-token": ["registry-auth-token@5.1.1", "", { "dependencies": { "@pnpm/npm-conf": "^3.0.2" } }, "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
@@ -618,6 +634,8 @@
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||
|
||||
"semantic-release": ["semantic-release@24.2.9", "", { "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", "@semantic-release/github": "^11.0.0", "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.0.0-beta.1", "aggregate-error": "^5.0.0", "cosmiconfig": "^9.0.0", "debug": "^4.0.0", "env-ci": "^11.0.0", "execa": "^9.0.0", "figures": "^6.0.0", "find-versions": "^6.0.0", "get-stream": "^6.0.0", "git-log-parser": "^1.2.0", "hook-std": "^4.0.0", "hosted-git-info": "^8.0.0", "import-from-esm": "^2.0.0", "lodash-es": "^4.17.21", "marked": "^15.0.0", "marked-terminal": "^7.3.0", "micromatch": "^4.0.2", "p-each-series": "^3.0.0", "p-reduce": "^3.0.0", "read-package-up": "^11.0.0", "resolve-from": "^5.0.0", "semver": "^7.3.2", "semver-diff": "^5.0.0", "signale": "^1.2.1", "yargs": "^17.5.1" }, "bin": { "semantic-release": "bin/semantic-release.js" } }, "sha512-phCkJ6pjDi9ANdhuF5ElS10GGdAKY6R1Pvt9lT3SFhOwM4T7QZE7MLpBDbNruUx/Q3gFD92/UOFringGipRqZA=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -638,6 +656,8 @@
|
||||
|
||||
"skin-tone": ["skin-tone@2.0.0", "", { "dependencies": { "unicode-emoji-modifier-base": "^1.0.0" } }, "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA=="],
|
||||
|
||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"spawn-error-forwarder": ["spawn-error-forwarder@1.0.0", "", {}, "sha512-gRjMgK5uFjbCvdibeGJuy3I5OYz6VLoVdsOJdA6wV0WlfQVLFueoqMxwwYD9RODdgb6oUIvlRlsyFSiQkMKu0g=="],
|
||||
@@ -684,6 +704,8 @@
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||
|
||||
"through2": ["through2@2.0.5", "", { "dependencies": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" } }, "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ=="],
|
||||
|
||||
"time-span": ["time-span@5.1.0", "", { "dependencies": { "convert-hrtime": "^5.0.0" } }, "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA=="],
|
||||
@@ -710,7 +732,6 @@
|
||||
|
||||
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
|
||||
|
||||
"undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
@@ -1164,6 +1185,8 @@
|
||||
|
||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="],
|
||||
|
||||
"pino-abstract-transport/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"read-pkg/parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="],
|
||||
|
||||
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
||||
|
||||
@@ -46,19 +46,14 @@ services:
|
||||
- NODE_ENV=production
|
||||
- GITEA_API_URL=http://gitea:3000/api/v1
|
||||
- GITEA_ACCESS_TOKEN=${E2E_GITEA_TOKEN:-placeholder}
|
||||
- PORT=3000
|
||||
- E2E_MOCK_LLM=1
|
||||
- PORT=5174
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
ports:
|
||||
- "3334:3000"
|
||||
- "3334:5174"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
ports:
|
||||
- "3334:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
@@ -8,18 +8,17 @@ services:
|
||||
|
||||
container_name: gitea-assistant
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "5174:5174"
|
||||
volumes:
|
||||
- assistant_data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
LOG_LEVEL: error
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -36,30 +35,6 @@ services:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: qdrant
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
volumes:
|
||||
- qdrant_data:/qdrant/storage
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:6333/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
|
||||
volumes:
|
||||
qdrant_data:
|
||||
driver: local
|
||||
assistant_data:
|
||||
driver: local
|
||||
|
||||
20
docs/README.md
Normal file
20
docs/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Documentation
|
||||
|
||||
This project keeps the root `README.md` concise and moves implementation/deployment details here.
|
||||
|
||||
## Start here
|
||||
|
||||
- [Getting started](./getting-started.md)
|
||||
- [Configuration reference](./configuration.md)
|
||||
- [Review engines](./review-engines.md)
|
||||
- [Deployment](./deployment.md)
|
||||
- [Screenshot gallery](./screenshots.md)
|
||||
|
||||
## Architecture & design
|
||||
|
||||
- [Notification service refactoring](./design/notification-service-refactoring.md)
|
||||
- [UI theme language](./design/ui-theme-language.md)
|
||||
|
||||
## Language
|
||||
|
||||
- 中文: [README.zh-CN.md](./README.zh-CN.md)
|
||||
@@ -1,246 +1,24 @@
|
||||
# Gitea AI Assistant
|
||||
# 文档中心
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
根目录 `README.md` 保持简洁;本目录提供详细说明与设计文档。
|
||||
|
||||
基于 AI 的 Gitea 代码审查助手。自动审查 Pull Request 和提交,支持多种 LLM 提供商(OpenAI 兼容、OpenAI Responses API、Anthropic、Google Gemini),提供智能代码质量分析,支持总体评论和行级反馈。
|
||||
## 快速导航
|
||||
|
||||
**[English Documentation](../README.md)**
|
||||
- [快速开始](./getting-started.zh-CN.md)
|
||||
- [配置参考](./configuration.zh-CN.md)
|
||||
- [审查引擎](./review-engines.zh-CN.md)
|
||||
- [部署指南](./deployment.zh-CN.md)
|
||||
- [截图集](./screenshots.zh-CN.md)
|
||||
|
||||
## 功能特点
|
||||
## 架构与设计
|
||||
|
||||
- 🤖 **AI 代码审查** - 使用可插拔的 LLM 提供商自动审查 PR 和提交
|
||||
- 📝 **行级评论** - 针对具体代码变更的精确反馈
|
||||
- 🔄 **任务化审查引擎** - Agent 分级审查(skip/light/full)+ 可选 Codex CLI 审查模式
|
||||
- 🔔 **飞书通知** - PR 事件通知集成
|
||||
- 🎛️ **管理后台** - 用于管理仓库 Webhook 和 LLM 提供商配置的 Web 界面
|
||||
- 🔐 **安全验证** - HMAC-SHA256 签名验证
|
||||
- [通知服务重构设计](./design/notification-service-refactoring.md)
|
||||
- [UI 主题语言设计](./design/ui-theme-language.md)
|
||||
|
||||
## 架构设计
|
||||
## 产品截图
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Gitea 服务器 │────▶│ Gitea Assistant │────▶│ LLM 网关 │
|
||||
│ (Webhooks) │ │ (Hono + Bun) │ │ (多提供商) │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ├─ OpenAI 兼容
|
||||
┌──────────────────┐ ├─ OpenAI Responses API
|
||||
│ 管理后台 │ ├─ Anthropic
|
||||
│ (React SPA) │ └─ Google Gemini
|
||||
└──────────────────┘
|
||||
```
|
||||

|
||||
|
||||
### 审查引擎对比
|
||||
## 语言切换
|
||||
|
||||
| 引擎 | 描述 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `agent` | 任务化分级审查(`skip` / `light` / `full`),按路径范围派发 specialist,并按需升级到反思/辩论 | 在控制 token 成本的前提下做深度审查 |
|
||||
| `codex` | 通过 Codex CLI 执行审查,独立配置 | 对接外部 Codex 审查流程 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.2.5
|
||||
- 可访问的 Gitea 实例
|
||||
- 至少一个 LLM 提供商的 API 密钥(OpenAI、Anthropic、Google Gemini 或任何 OpenAI 兼容端点)
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
### 配置说明
|
||||
|
||||
创建 `.env` 文件,仅填写基础设施级别的配置:
|
||||
|
||||
```bash
|
||||
# 服务端口
|
||||
PORT=3000
|
||||
|
||||
# 必填:API Key 加密存储密钥(运行 openssl rand -hex 32 生成)
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# 可选:自定义数据库路径(以下为默认值)
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
```
|
||||
|
||||
> **所有其他配置**(Gitea 连接、Webhook 密钥、管理员密码、审查引擎、飞书、记忆系统等)均通过 **Web 管理后台** 在 `http://your-server:3000` 进行配置。首次启动时,所有设置会自动以安全的默认值进行初始化。
|
||||
|
||||
完整配置项请参阅 [配置参考](#配置参考)。
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev # 开发模式
|
||||
bun run start # 生产模式
|
||||
```
|
||||
|
||||
### 配置 Webhook
|
||||
|
||||
**方式一:管理后台(推荐)**
|
||||
|
||||
1. 在浏览器中访问 `http://your-server:3000`
|
||||
2. 使用管理员密码登录(默认:`password`,请在后台及时修改)
|
||||
3. 点击仓库对应的「启用」按钮自动配置 Webhook
|
||||
|
||||
**方式二:手动配置**
|
||||
|
||||
在 Gitea 仓库设置中添加 Webhook:
|
||||
- **URL**: `http://your-server:3000/webhook/gitea`
|
||||
- **内容类型**: `application/json`
|
||||
- **密钥**: 与管理后台中配置的 Webhook 密钥相同
|
||||
- **触发事件**: 「Pull Request」和「Status」
|
||||
## 配置参考
|
||||
|
||||
### 环境变量(最小化)
|
||||
|
||||
仅包含数据库初始化前必须已知的基础设施级别配置:
|
||||
|
||||
| 变量 | 描述 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `PORT` | 服务端口 | `5174` |
|
||||
| `DATABASE_PATH` | SQLite 数据库文件路径 | `./data/assistant.db` |
|
||||
| `ENCRYPTION_KEY` | **必填。** AES-256-GCM 加密密钥,用于加密存储 API Key(64 位十六进制字符串)。运行 `openssl rand -hex 32` 生成 | — |
|
||||
|
||||
### Web 界面配置(管理后台)
|
||||
|
||||
所有运行时配置均通过 **管理后台** `http://your-server:PORT` 进行管理,修改后立即生效,无需重启。
|
||||
|
||||
首次以空数据库启动时,所有设置会自动以安全默认值初始化:
|
||||
- `JWT_SECRET` 和 `WEBHOOK_SECRET` 自动生成(通过 `crypto.randomBytes` 生成 64 位十六进制字符串)
|
||||
- `ADMIN_PASSWORD` 默认为 `password`,**请立即修改**
|
||||
|
||||
#### Gitea
|
||||
|
||||
| 配置项 | 描述 |
|
||||
|--------|------|
|
||||
| Gitea API 地址 | Gitea API 端点(如 `https://gitea.example.com/api/v1`) |
|
||||
| 访问令牌 | 代码审查令牌(需读取和评论权限) |
|
||||
| 管理员令牌 | Webhook 管理令牌(可选) |
|
||||
|
||||
#### 安全
|
||||
|
||||
| 配置项 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| Webhook 密钥 | HMAC-SHA256 Webhook 签名密钥 | 自动生成 |
|
||||
| 管理员密码 | 后台登录密码 | `password` |
|
||||
| JWT 密钥 | JWT 签名密钥 | 自动生成 |
|
||||
|
||||
#### LLM 提供商配置
|
||||
|
||||
LLM 提供商和模型通过**管理后台** Web 界面进行配置:
|
||||
|
||||
1. 导航到 **LLM 配置** 页面
|
||||
2. 添加 LLM 提供商(OpenAI 兼容、OpenAI Responses API、Anthropic、Google Gemini)
|
||||
3. 为审查角色分配模型(planner、specialist、judge、embedding)
|
||||
|
||||
> API 密钥使用 AES-256-GCM 加密存储在本地 SQLite 数据库中。
|
||||
|
||||
#### 飞书集成
|
||||
|
||||
| 配置项 | 描述 |
|
||||
|--------|------|
|
||||
| 飞书 Webhook 地址 | 飞书机器人 Webhook URL |
|
||||
| 飞书 Webhook 密钥 | 飞书 Webhook 密钥(可选) |
|
||||
|
||||
#### Agent 审查引擎
|
||||
|
||||
| 配置项 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| 审查引擎 | 引擎模式(`agent` 或 `codex`) | `agent` |
|
||||
| 启用分流(Enable Triage) | 启用 planner 分流并输出任务化审查计划 | `true` |
|
||||
| Small 文件上限 | 判定 `small` 规模审查的文件数上限 | `3` |
|
||||
| Small 变更行上限 | 判定 `small` 规模审查的变更行数上限 | `80` |
|
||||
| Medium 文件上限 | 判定 `medium` 规模审查的文件数上限 | `10` |
|
||||
| Medium 变更行上限 | 判定 `medium` 规模审查的变更行数上限 | `400` |
|
||||
| Small Token 预算 | `small` 任务的 token 预算上限 | `12000` |
|
||||
| Medium Token 预算 | `medium` 任务的 token 预算上限 | `45000` |
|
||||
| Large Token 预算 | `large` 任务的 token 预算上限 | `120000` |
|
||||
| 工作目录 | 仓库克隆工作目录 | `/tmp/gitea-assistant` |
|
||||
| 最大并发数 | 最大并发审查任务数 | `2` |
|
||||
| 最大文件数 | 单次审查最大文件数 | `200` |
|
||||
| 自动发布置信度 | 自动发布最小置信度 | `0.8` |
|
||||
| 启用人工审批 | 发布前要求人工确认 | `true` |
|
||||
|
||||
当前 Agent 审查执行模型:
|
||||
|
||||
- `skip`:文档/资源/纯重命名等低风险改动可直接跳过 specialist。
|
||||
- `light`:低风险代码改动执行最小化、按路径范围限定的 specialist 审查。
|
||||
- `full`:敏感路径或中大型改动执行完整任务审查,并可按配置升级到 reflection/debate。
|
||||
- Triage 输出任务(`paths`、`riskTags`、`mode`、`tokenBudget`),Orchestrator 按任务范围派发,不再默认全量扇出。
|
||||
|
||||
#### 记忆与学习(实验性)
|
||||
|
||||
| 配置项 | 描述 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| Qdrant 地址 | Qdrant 向量数据库地址 | - |
|
||||
| 启用记忆 | 启用记忆系统 | `false` |
|
||||
| 启用反思 | 启用自我批评 | `false` |
|
||||
| 启用辩论 | 启用多代理辩论 | `false` |
|
||||
## 部署指南
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data -e PORT=3000 gitea-assistant
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
Kubernetes 部署清单位于 `k8s/` 目录。
|
||||
|
||||
**1. 创建加密密钥**
|
||||
|
||||
```bash
|
||||
# 生成密钥并创建 Secret
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
|
||||
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
# 请保存此密钥!重新部署时需要使用。
|
||||
echo "你的 ENCRYPTION_KEY: $ENCRYPTION_KEY"
|
||||
```
|
||||
|
||||
**2. 部署**
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
或逐个应用:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
**4. 验证**
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
|
||||
**5. 暴露服务(可选)**
|
||||
|
||||
默认使用 `ClusterIP` 类型。如需外部访问,可使用 Ingress 或修改 Service 类型:
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
|
||||
```
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT 许可证
|
||||
- English: [README.md](./README.md)
|
||||
|
||||
0
docs/assets/.gitkeep
Normal file
0
docs/assets/.gitkeep
Normal file
BIN
docs/assets/page-config.png
Normal file
BIN
docs/assets/page-config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
BIN
docs/assets/page-notifications.png
Normal file
BIN
docs/assets/page-notifications.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
BIN
docs/assets/page-repos.png
Normal file
BIN
docs/assets/page-repos.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
docs/assets/page-review-config.png
Normal file
BIN
docs/assets/page-review-config.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
85
docs/configuration.md
Normal file
85
docs/configuration.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Configuration Reference
|
||||
|
||||
## Configuration model
|
||||
|
||||
This project uses a DB-first runtime configuration model:
|
||||
|
||||
- `.env` contains only infrastructure-level bootstrap values.
|
||||
- Runtime settings (Gitea, providers, secrets, review policy, notifications) are managed in Admin UI and stored in SQLite.
|
||||
|
||||
## Environment variables (minimal)
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | Yes | AES-256-GCM master key (64 hex chars) for API key encryption | - |
|
||||
| `PORT` | No | Service port | `5174` |
|
||||
| `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 key:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## First boot defaults
|
||||
|
||||
When database is empty:
|
||||
|
||||
- `JWT_SECRET` auto-generated
|
||||
- `WEBHOOK_SECRET` auto-generated
|
||||
- `ADMIN_PASSWORD` defaults to `password`
|
||||
|
||||
Change `ADMIN_PASSWORD` immediately after first login.
|
||||
|
||||
## Runtime groups in Admin UI
|
||||
|
||||
## 1) Gitea
|
||||
|
||||
- API URL
|
||||
- Access token
|
||||
- Admin token (optional)
|
||||
|
||||
## 2) Security
|
||||
|
||||
- Webhook secret (HMAC-SHA256 verification)
|
||||
- Admin password
|
||||
- JWT secret
|
||||
|
||||
## 3) LLM
|
||||
|
||||
- Providers: OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
|
||||
- Agent runtime models:
|
||||
- `AGENT_MAIN_MODEL`: The main model name used by the agent runtime when no specific model is configured. Default is `gpt-4.1`.
|
||||
- `AGENT_DEFAULT_SUBAGENT_MODEL`: The default model name used by subagents when no specific model is declared in their definition or overridden during spawn. Default is `gpt-4.1-mini`.
|
||||
|
||||
## 4) Notification
|
||||
|
||||
- Feishu webhook and optional secret
|
||||
- WeCom (企业微信) webhook
|
||||
|
||||
## 5) Review
|
||||
|
||||
- Engine mode: `agent` or `codex`
|
||||
- Triage size classification and routing hints
|
||||
- Size thresholds (`small`/`medium`/`large`)
|
||||
- Execution modes (`skip`/`light`/`full`)
|
||||
- Token budgets and concurrency limits
|
||||
|
||||
> Size and mode are different layers:
|
||||
>
|
||||
> - `small/medium/large`: change-size classification
|
||||
> - `skip/light/full`: review execution depth
|
||||
|
||||
## Agent Definitions
|
||||
|
||||
Project agent definitions are stored as Markdown files with frontmatter in the repository:
|
||||
- Path: `.gitea-assistant/agents/*.md`
|
||||
|
||||
These files define the system prompts, metadata, and execution parameters for each agent.
|
||||
|
||||
## Tool Permissions
|
||||
|
||||
Tool permissions are controlled directly within each agent's definition file:
|
||||
- `tools`: An allow-list of tool names that the agent is permitted to call. An empty list grants no tools.
|
||||
- `disallowedTools`: A deny-list of tool names that the agent is explicitly forbidden from calling. This takes precedence over the allow-list.
|
||||
85
docs/configuration.zh-CN.md
Normal file
85
docs/configuration.zh-CN.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 配置参考
|
||||
|
||||
## 配置模型
|
||||
|
||||
项目采用 DB-first 运行时配置模型:
|
||||
|
||||
- `.env` 仅用于基础设施级引导参数
|
||||
- 运行时配置(Gitea、Provider、密钥、审查策略、通知)由管理后台维护并持久化到 SQLite
|
||||
|
||||
## 环境变量(最小集)
|
||||
|
||||
| 变量 | 必填 | 说明 | 默认值 |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥(AES-256-GCM,64 位十六进制) | - |
|
||||
| `PORT` | 否 | 服务端口 | `5174` |
|
||||
| `DATABASE_PATH` | 否 | SQLite 路径 | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | 否 | 后端日志级别(`debug`/`info`/`warn`/`error`)。默认 `info`;生产环境建议 `error`。 | `info` |
|
||||
|
||||
生成密钥:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## 首次启动默认值
|
||||
|
||||
当数据库为空时:
|
||||
|
||||
- `JWT_SECRET` 自动生成
|
||||
- `WEBHOOK_SECRET` 自动生成
|
||||
- `ADMIN_PASSWORD` 默认 `password`
|
||||
|
||||
首次登录后请立即修改管理员密码。
|
||||
|
||||
## 管理后台配置分组
|
||||
|
||||
## 1) Gitea
|
||||
|
||||
- API URL
|
||||
- Access Token
|
||||
- Admin Token(可选)
|
||||
|
||||
## 2) 安全
|
||||
|
||||
- Webhook Secret(HMAC-SHA256 验签)
|
||||
- Admin Password
|
||||
- JWT Secret
|
||||
|
||||
## 3) LLM
|
||||
|
||||
- Provider:OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
|
||||
- Agent 运行时模型:
|
||||
- `AGENT_MAIN_MODEL`:在没有更具体模型配置时,Agent 运行时使用的主模型名称。默认值为 `gpt-4.1`。
|
||||
- `AGENT_DEFAULT_SUBAGENT_MODEL`:当子代理(Subagent)未声明模型且 spawn 未覆盖时,使用的默认模型名称。默认值为 `gpt-4.1-mini`。
|
||||
|
||||
## 4) 通知
|
||||
|
||||
- Feishu Webhook 与可选签名密钥
|
||||
- WeCom(企业微信)Webhook
|
||||
|
||||
## 5) 审查
|
||||
|
||||
- 引擎模式:`agent` / `codex`
|
||||
- Triage 规模分类与路由提示
|
||||
- 规模阈值(`small`/`medium`/`large`)
|
||||
- 执行模式(`skip`/`light`/`full`)
|
||||
- Token 预算与并发限制
|
||||
|
||||
> 规模与模式是两个层次:
|
||||
>
|
||||
> - `small/medium/large`:变更规模分类
|
||||
> - `skip/light/full`:审查执行深度
|
||||
|
||||
## Agent 定义
|
||||
|
||||
项目的 Agent 定义以带有 Frontmatter 的 Markdown 文件形式存储在仓库中:
|
||||
- 路径:`.gitea-assistant/agents/*.md`
|
||||
|
||||
这些文件定义了每个 Agent 的系统提示词、元数据和执行参数。
|
||||
|
||||
## 工具权限
|
||||
|
||||
工具权限直接在每个 Agent 的定义文件中进行控制:
|
||||
- `tools`:允许该 Agent 调用的工具名称白名单。如果列表为空,则不授予任何工具权限。
|
||||
- `disallowedTools`:显式禁止该 Agent 调用的工具名称黑名单。黑名单的优先级高于白名单。
|
||||
58
docs/deployment.md
Normal file
58
docs/deployment.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Deployment
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error gitea-assistant
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` includes `gitea-assistant`.
|
||||
|
||||
Production default in compose sets `LOG_LEVEL=error`.
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes manifests are in `k8s/`.
|
||||
The default ConfigMap sets `LOG_LEVEL=error` for production.
|
||||
|
||||
### 1) Create namespace and encryption secret
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
|
||||
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
### 2) Deploy
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
Or apply individually:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
### 3) Verify
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
|
||||
### 4) Expose service (optional)
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
|
||||
```
|
||||
58
docs/deployment.zh-CN.md
Normal file
58
docs/deployment.zh-CN.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 部署指南
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t gitea-assistant .
|
||||
docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error gitea-assistant
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` 默认包含 `gitea-assistant`。
|
||||
|
||||
Compose 生产默认日志级别已设置为 `LOG_LEVEL=error`。
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes 清单位于 `k8s/` 目录。
|
||||
默认 ConfigMap 已将生产日志级别设置为 `LOG_LEVEL=error`。
|
||||
|
||||
### 1) 创建命名空间与加密密钥
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
kubectl -n gitea-assistant create secret generic gitea-assistant-secret \
|
||||
--from-literal=ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
### 2) 部署
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
或逐个应用:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
### 3) 验证
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
|
||||
### 4) 对外暴露(可选)
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant patch svc gitea-assistant -p '{"spec":{"type":"NodePort"}}'
|
||||
```
|
||||
617
docs/design/notification-service-refactoring.md
Normal file
617
docs/design/notification-service-refactoring.md
Normal 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
|
||||
**状态**: 已实施(持续验证中)
|
||||
@@ -132,14 +132,13 @@ CREATE TABLE llm_secrets (
|
||||
-- ============================================================
|
||||
-- 表3: model_role_assignments — 场景 → 模型映射
|
||||
-- ============================================================
|
||||
-- 每个业务场景(如 planner/specialist/judge/embedding)绑定到
|
||||
-- 每个业务场景(如 planner/specialist/judge)绑定到
|
||||
-- 一个 provider + 具体 model,支持不同场景用不同 provider。
|
||||
CREATE TABLE model_role_assignments (
|
||||
role TEXT PRIMARY KEY CHECK (role IN (
|
||||
'planner', -- Agent 审查 planner
|
||||
'specialist', -- Agent 审查 specialist
|
||||
'judge', -- Agent 审查 judge
|
||||
'embedding' -- 向量嵌入(Qdrant)
|
||||
'planner',
|
||||
'specialist',
|
||||
'judge'
|
||||
)),
|
||||
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
|
||||
model TEXT NOT NULL, -- 该场景使用的具体模型 ID
|
||||
@@ -183,7 +182,7 @@ CREATE INDEX idx_providers_enabled ON llm_providers(is_enabled);
|
||||
// ── src/llm/types.ts ────────────────────────────────────────
|
||||
|
||||
/** 模型角色枚举 */
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge';
|
||||
|
||||
/** 统一消息格式(内部表达,不暴露 provider 差异) */
|
||||
export interface LLMMessage {
|
||||
@@ -345,7 +344,7 @@ export class LLMGateway {
|
||||
|
||||
/**
|
||||
* 按业务角色调用 LLM
|
||||
* @param role 业务角色(planner/specialist/judge/embedding)
|
||||
* @param role 业务角色(planner/specialist/judge)
|
||||
* @param request 请求(不含 model,由角色映射决定)
|
||||
*/
|
||||
async chatForRole(
|
||||
@@ -361,14 +360,6 @@ export class LLMGateway {
|
||||
request: LLMChatRequest
|
||||
): Promise<LLMChatResponse>;
|
||||
|
||||
/**
|
||||
* 获取指定 provider 的 embedding 接口
|
||||
*/
|
||||
async embedForRole(
|
||||
role: 'embedding',
|
||||
texts: string[]
|
||||
): Promise<number[][]>;
|
||||
|
||||
/** 配置变更时清除单个 provider 缓存 */
|
||||
invalidateProvider(providerId: string): void;
|
||||
|
||||
@@ -692,7 +683,6 @@ Settings 页面
|
||||
│ │ Planner(Triage) │ [公司 OpenAI 代理 ▾] │ [gpt-4o-mini ] │
|
||||
│ │ Specialist(任务执行) │ [Anthropic Claude ▾] │ [claude-sonnet-4 ] │
|
||||
│ │ Judge(汇总裁决) │ [公司 OpenAI 代理 ▾] │ [gpt-4o ] │
|
||||
│ │ Embedding(记忆检索) │ [公司 OpenAI 代理 ▾] │ [text-embedding-3 ] │
|
||||
│ └──────────────────────────────────────────────────────────────┘
|
||||
│ [保存角色分配]
|
||||
│
|
||||
@@ -736,13 +726,10 @@ const MODEL_SUGGESTIONS: Record<string, string[]> = {
|
||||
| 2 | `src/controllers/review.ts` | 旧版 webhook 存在回退分支 | 删除回退分支,仅保留 `agent` / `codex` 入队逻辑 | 审查主入口 |
|
||||
| 3 | `src/review/orchestrator.ts:45,61-63` | `private readonly openai: OpenAI` + `this.openai = new OpenAI(...)` | 构造函数改接收 `LLMGateway`;传给各 agent(任务化分级编排:skip/light/full) | Agent 编排 |
|
||||
| 4 | `src/review/agents/specialist-agent.ts:93` | `protected readonly openai: OpenAI` | → `protected readonly gateway: LLMGateway`;`reviewWithOptions()` 与 ReAct 调用改为 `this.gateway.chatForRole('specialist', ...)` | 核心 agent |
|
||||
| 5 | `src/review/agents/critic-agent.ts:23` | `private openai: OpenAI` | 同上 | 评审 agent |
|
||||
| 6 | `src/review/agents/reflexion-agent.ts:24` | `constructor(openai: OpenAI, ...)` | 构造传 gateway | 反思 agent |
|
||||
| 7 | `src/review/agents/debate-orchestrator.ts:17` | `private openai: OpenAI` | 同上 | 辩论 agent |
|
||||
| 8 | `src/review/tools/registry.ts:22` | `toOpenAIFunctions()` | → `toToolDefinitions(): LLMToolDefinition[]`(返回内部格式),由 adapter 的 `tool-converter.ts` 负责转换 | 工具注册 |
|
||||
| 9 | `src/config/config-schema.ts:120-138` | `OPENAI_BASE_URL/API_KEY/MODEL` 字段定义 | 删除这些字段;`group: 'openai'` → 整个 group 移除 | 配置 schema |
|
||||
| 10 | `src/config/config-manager.ts:44-46` | `OPENAI_*` Zod schema 条目 | 删除 | 配置验证 |
|
||||
| 11 | `src/config/config-manager.ts:271-273` | `openai: { baseUrl, apiKey, model }` 映射 | 删除整个 `openai` 块 | 配置输出 |
|
||||
| 5 | `src/review/tools/registry.ts:22` | `toOpenAIFunctions()` | → `toToolDefinitions(): LLMToolDefinition[]`(返回内部格式),由 adapter 的 `tool-converter.ts` 负责转换 | 工具注册 |
|
||||
| 6 | `src/config/config-schema.ts:120-138` | `OPENAI_BASE_URL/API_KEY/MODEL` 字段定义 | 删除这些字段;`group: 'openai'` → 整个 group 移除 | 配置 schema |
|
||||
| 7 | `src/config/config-manager.ts:44-46` | `OPENAI_*` Zod schema 条目 | 删除 | 配置验证 |
|
||||
| 8 | `src/config/config-manager.ts:271-273` | `openai: { baseUrl, apiKey, model }` 映射 | 删除整个 `openai` 块 | 配置输出 |
|
||||
|
||||
### 8.2 前端代码改造
|
||||
|
||||
@@ -795,7 +782,6 @@ Day 6.5: 旧代码清理完毕,文档更新,Ready for review
|
||||
|---|---|---|
|
||||
| **Anthropic 无原生 JSON mode** | `response_format: json_object` 不可用,JSON 解析可能失败 | Adapter 内 prompt 注入 JSON 指令 + `JSON.parse()` 容错(正则提取 \`\`\`json\`\`\` 块 → 重试 parse) |
|
||||
| **Gemini function calling 格式差异大** | `functionDeclarations` 包装层级不同;`functionResponse` 嵌套在 `parts` 中 | `tool-converter.ts` 单独处理;finish reason 映射表全覆盖测试 |
|
||||
| **Embedding 维度变化导致 Qdrant 不兼容** | `src/review/memory/vector-store.ts` 硬编码 1536 维 | `model_role_assignments.role='embedding'` 变更时,UI 提示用户需重建 collection;或自动检测维度创建新 collection |
|
||||
| **ENCRYPTION_KEY 丢失** | 所有加密的 API Key 不可恢复 | 启动时检测密钥版本不匹配 → 报错并要求重新设置所有 API Key(trade-off:安全性 > 便利性) |
|
||||
| **SQLite 并发写** | 多请求同时写入可能 SQLITE_BUSY | `bun:sqlite` 开启 WAL mode;写操作走单连接序列化;读可并行 |
|
||||
| **Provider SDK 版本冲突** | `openai`、`@anthropic-ai/sdk`、`@google/generative-ai` 三个 SDK 共存 | 各 adapter 独立 import,无交叉依赖;`package.json` 锁定主版本 |
|
||||
@@ -823,7 +809,7 @@ Day 6.5: 旧代码清理完毕,文档更新,Ready for review
|
||||
# .env.example(仅保留启动参数)
|
||||
|
||||
# 应用启动参数(不可通过 UI 设置)
|
||||
PORT=3000
|
||||
PORT=5174
|
||||
WEBHOOK_SECRET=your_webhook_secret
|
||||
DATABASE_PATH=./data/assistant.db # SQLite 文件路径
|
||||
|
||||
@@ -832,5 +818,4 @@ DATABASE_PATH=./data/assistant.db # SQLite 文件路径
|
||||
# - Gitea 配置(API URL / Token)
|
||||
# - 飞书配置(Webhook URL / Secret)
|
||||
# - Review 引擎配置
|
||||
# - 记忆系统配置
|
||||
```
|
||||
|
||||
69
docs/getting-started.md
Normal file
69
docs/getting-started.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Getting Started
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Bun >= 1.2.5
|
||||
- A reachable Gitea instance
|
||||
- At least one LLM provider credential
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
## Minimal environment
|
||||
|
||||
Create `.env`:
|
||||
|
||||
```bash
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # required, generate with: openssl rand -hex 32
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # local dev default; use LOG_LEVEL=error in production
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` is required. Application startup fails when it is missing.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
# or
|
||||
bun run start
|
||||
```
|
||||
|
||||
## First login
|
||||
|
||||
- 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 repository list, click enable to auto-provision webhook.
|
||||
|
||||
### Option B: Manual
|
||||
|
||||
In Gitea repository settings:
|
||||
|
||||
- URL: `http://your-server:5174/webhook/gitea`
|
||||
- Content Type: `application/json`
|
||||
- Secret: same value as dashboard webhook secret
|
||||
- Events: Pull Request + Status
|
||||
|
||||
## Health endpoint
|
||||
|
||||
Use `/api/health` to check service status.
|
||||
69
docs/getting-started.zh-CN.md
Normal file
69
docs/getting-started.zh-CN.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 快速开始
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Bun >= 1.2.5
|
||||
- 可访问的 Gitea 实例
|
||||
- 至少一个 LLM 提供商凭证
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装 `frontend` 依赖。
|
||||
|
||||
如果你的环境禁用了生命周期脚本:
|
||||
|
||||
```bash
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
## 最小环境变量
|
||||
|
||||
创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # 必填,使用 openssl rand -hex 32 生成
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # 本地开发默认;生产环境请使用 LOG_LEVEL=error
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` 为必填项,缺失时服务会拒绝启动。
|
||||
|
||||
## 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
# 或
|
||||
bun run start
|
||||
```
|
||||
|
||||
## 首次登录
|
||||
|
||||
- 访问 `http://your-server:5174`
|
||||
- 首次启动默认管理员密码为 `password`
|
||||
- 登录后请立即修改管理员密码
|
||||
|
||||
## Webhook 配置
|
||||
|
||||
### 方式 A:管理后台(推荐)
|
||||
|
||||
在仓库列表点击启用按钮,由系统自动配置 webhook。
|
||||
|
||||
### 方式 B:手动配置
|
||||
|
||||
在 Gitea 仓库设置中配置:
|
||||
|
||||
- URL:`http://your-server:5174/webhook/gitea`
|
||||
- Content Type:`application/json`
|
||||
- Secret:与管理后台中的 Webhook Secret 保持一致
|
||||
- 事件:Pull Request + Status
|
||||
|
||||
## 健康检查
|
||||
|
||||
可通过 `/api/health` 查看服务状态。
|
||||
52
docs/review-engines.md
Normal file
52
docs/review-engines.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Review Engines
|
||||
|
||||
## Overview
|
||||
|
||||
The system supports two engines:
|
||||
|
||||
- `agent`: native Agent review pipeline
|
||||
- `codex`: Codex CLI-backed review pipeline
|
||||
|
||||
Engine is selected by `REVIEW_ENGINE` runtime configuration.
|
||||
|
||||
## Agent engine
|
||||
|
||||
The Agent engine runs code reviews using a dynamic agent framework. It prepares the workspace and review context, then starts a main agent to perform the review.
|
||||
|
||||
### Review behavior
|
||||
|
||||
- **Main Agent**: The entrypoint agent that coordinates the review process. It uses the tools provided to analyze the code changes.
|
||||
- **Dynamic Subagents**: The main agent can dynamically spawn subagents to perform specific tasks, such as searching code or reading files, if needed.
|
||||
- **Deterministic Publishing**: Review findings and comments are collected and processed outside the agent loop. The system normalizes, deduplicates, and filters findings deterministically before posting them back to Gitea.
|
||||
|
||||
### Review modes
|
||||
|
||||
- `skip`: Low-risk changes may bypass the agent review entirely.
|
||||
- `light`: Minimal checks for low-risk code changes.
|
||||
- `full`: Full review for risky or larger changes.
|
||||
|
||||
### Size policy
|
||||
|
||||
`small`/`medium`/`large` thresholds are used to classify the change size, which determines the execution mode and token budgets.
|
||||
|
||||
## Codex engine
|
||||
|
||||
Codex engine runs review through Codex CLI with independent runtime settings:
|
||||
|
||||
- `CODEX_API_URL`
|
||||
- `CODEX_API_KEY`
|
||||
- `CODEX_MODEL`
|
||||
- `CODEX_TIMEOUT_MS`
|
||||
- `CODEX_REVIEW_PROMPT`
|
||||
|
||||
## Event support
|
||||
|
||||
Both engines process:
|
||||
|
||||
- Pull request webhook events
|
||||
- Commit status webhook events
|
||||
|
||||
## Output
|
||||
|
||||
- PR/commit summary comment
|
||||
- Line-level findings with confidence and severity
|
||||
52
docs/review-engines.zh-CN.md
Normal file
52
docs/review-engines.zh-CN.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 审查引擎
|
||||
|
||||
## 概览
|
||||
|
||||
系统支持两种审查引擎:
|
||||
|
||||
- `agent`:内置 Agent 审查流水线
|
||||
- `codex`:基于 Codex CLI 的审查流水线
|
||||
|
||||
通过运行时配置 `REVIEW_ENGINE` 选择引擎。
|
||||
|
||||
## Agent 引擎
|
||||
|
||||
Agent 引擎使用动态 Agent 框架执行代码审查。它会准备工作区与审查上下文,然后启动主 Agent 执行审查任务。
|
||||
|
||||
### 审查行为
|
||||
|
||||
- **主 Agent**:协调审查流程的入口 Agent。它使用提供的工具来分析代码变更。
|
||||
- **动态子 Agent**:主 Agent 可以根据需要动态生成子 Agent,以执行特定任务(例如搜索代码或读取文件)。
|
||||
- **确定性发布**:审查发现的问题与评论会在 Agent 循环之外进行收集和处理。系统会在将结果发布回 Gitea 之前,对发现的问题进行确定性的规范化、去重和过滤。
|
||||
|
||||
### 审查模式
|
||||
|
||||
- `skip`:低风险改动可完全跳过 Agent 审查。
|
||||
- `light`:对低风险代码执行最小化检查。
|
||||
- `full`:对高风险或大规模改动执行完整审查。
|
||||
|
||||
### 规模策略
|
||||
|
||||
`small` / `medium` / `large` 阈值用于对变更规模进行分类,从而决定执行模式与 Token 预算。
|
||||
|
||||
## Codex 引擎
|
||||
|
||||
Codex 引擎通过 Codex CLI 执行审查,支持独立配置:
|
||||
|
||||
- `CODEX_API_URL`
|
||||
- `CODEX_API_KEY`
|
||||
- `CODEX_MODEL`
|
||||
- `CODEX_TIMEOUT_MS`
|
||||
- `CODEX_REVIEW_PROMPT`
|
||||
|
||||
## 事件支持
|
||||
|
||||
两种引擎都支持:
|
||||
|
||||
- Pull Request webhook 事件
|
||||
- Commit Status webhook 事件
|
||||
|
||||
## 输出
|
||||
|
||||
- PR/提交总结评论
|
||||
- 行级问题(含置信度与严重性)
|
||||
23
docs/screenshots.md
Normal file
23
docs/screenshots.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Screenshot Gallery
|
||||
|
||||
All screenshots are captured from local development service.
|
||||
|
||||
## Repository management (`/repos`)
|
||||
|
||||

|
||||
|
||||
## System configuration (`/config`)
|
||||
|
||||

|
||||
|
||||
## Notification management (`/notifications`)
|
||||
|
||||

|
||||
|
||||
## Review configuration (`/review-config`)
|
||||
|
||||

|
||||
|
||||
## Language
|
||||
|
||||
- 中文: [screenshots.zh-CN.md](./screenshots.zh-CN.md)
|
||||
23
docs/screenshots.zh-CN.md
Normal file
23
docs/screenshots.zh-CN.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# 截图集
|
||||
|
||||
以下截图来自本地开发环境。
|
||||
|
||||
## 仓库管理(`/repos`)
|
||||
|
||||

|
||||
|
||||
## 系统配置(`/config`)
|
||||
|
||||

|
||||
|
||||
## 通知管理(`/notifications`)
|
||||
|
||||

|
||||
|
||||
## 审查配置(`/review-config`)
|
||||
|
||||

|
||||
|
||||
## 语言切换
|
||||
|
||||
- English: [screenshots.md](./screenshots.md)
|
||||
@@ -11,6 +11,6 @@ RUN bun install --no-frozen-lockfile
|
||||
COPY src ./src
|
||||
COPY tsconfig.json .
|
||||
|
||||
EXPOSE 3000
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
258
e2e/README.md
Normal file
258
e2e/README.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# E2E 真实 PR 审查测试指南
|
||||
|
||||
本指南记录使用 Docker 运行 Gitea + Assistant 进行真实 PR 代码审查的完整流程,包括踩坑点和修复步骤。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Docker & Docker Compose
|
||||
- `openssl`(签名计算)
|
||||
- `python3`(JSON 解析)
|
||||
- 本项目源码
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
Gitea (port 3333) ←→ Assistant (port 3334)
|
||||
↑ ↑
|
||||
webhook clone + comment
|
||||
(PR 事件) (git + API)
|
||||
```
|
||||
|
||||
- **Gitea**:代码托管,运行在 `gitea:3000`(宿主机 `localhost:3333`)
|
||||
- **Assistant**:AI 审查服务,运行在 `assistant:5174`(宿主机 `localhost:3334`)
|
||||
- 两者通过 Docker 内部网络 `gitea:3000` 通信(非宿主机地址)
|
||||
|
||||
## 一键启动(自动化方式)
|
||||
|
||||
```bash
|
||||
# 1. 启动容器
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
|
||||
# 2. 等待 Gitea healthy 后创建用户(Gitea 不允许 root 执行 admin 命令)
|
||||
docker exec e2e-gitea su git -c \
|
||||
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
|
||||
--email admin@e2e-test.local --admin --must-change-password=false"
|
||||
|
||||
# 3. 运行 seed 脚本(创建仓库、推送代码、配置 webhook、创建 PR)
|
||||
bash ./e2e/seed.sh
|
||||
|
||||
# 4. 用 seed 输出的 token 重启 assistant(使 GITEA_ACCESS_TOKEN 生效)
|
||||
# seed.sh 会在最后打印实际 token 值
|
||||
E2E_GITEA_TOKEN=<seed输出的token> docker compose -f docker-compose.e2e.yml up -d assistant
|
||||
|
||||
# 5. 通过 Admin API 更新运行时配置
|
||||
LOGIN_RESP=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
|
||||
-H "Content-Type: application/json" -d '{"password": "password"}')
|
||||
JWT=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{
|
||||
"GITEA_API_URL": "http://gitea:3000/api/v1",
|
||||
"GITEA_ACCESS_TOKEN": "<seed输出的token>",
|
||||
"WEBHOOK_SECRET": "e2e-test-secret"
|
||||
}'
|
||||
|
||||
# 6. 运行 E2E 测试
|
||||
bash ./e2e/test.sh
|
||||
```
|
||||
|
||||
## 分步详解与踩坑点
|
||||
|
||||
### 1. 启动容器
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
```
|
||||
|
||||
**踩坑**:`ENCRYPTION_KEY` 和 `WEBHOOK_SECRET` 必须在 `docker-compose.e2e.yml` 中配置,否则 assistant 启动失败(`ENCRYPTION_KEY is required`)。已添加默认值:
|
||||
|
||||
```yaml
|
||||
assistant:
|
||||
environment:
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef...(64位hex)}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
```
|
||||
|
||||
### 2. 创建 Gitea 用户
|
||||
|
||||
```bash
|
||||
docker exec e2e-gitea su git -c \
|
||||
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
|
||||
--email admin@e2e-test.local --admin --must-change-password=false"
|
||||
```
|
||||
|
||||
**踩坑**:`seed.sh` 中直接 `docker exec e2e-gitea gitea admin user create ...` 会报错 `Gitea is not supposed to be run as root`。必须用 `su git -c` 切换到 git 用户执行。如果用户已存在会输出错误但可忽略。
|
||||
|
||||
### 3. Seed 初始化
|
||||
|
||||
```bash
|
||||
bash ./e2e/seed.sh
|
||||
```
|
||||
|
||||
Seed 脚本会执行:
|
||||
1. 等待 Gitea 就绪
|
||||
2. 创建管理员用户(如已存在则跳过)
|
||||
3. 生成 API Token
|
||||
4. 创建测试仓库并推送含已知 bug 的代码(`src/user-handler.ts` 包含 eval/SQL 注入/硬编码密钥)
|
||||
5. 配置 Assistant 设置(需要 assistant 已启动)
|
||||
6. 配置 Gitea Webhook(指向 `http://assistant:5174/webhook/gitea`)
|
||||
7. 创建 PR #1(`feature/add-user-handler` → `main`)
|
||||
|
||||
**踩坑**:seed.sh 第 5 步"配置 Assistant 设置"可能失败(assistant 未启动或 JWT 获取失败),这不影响后续流程——可以手动通过 API 配置。
|
||||
|
||||
### 4. 更新 Assistant 运行时配置
|
||||
|
||||
```bash
|
||||
# 获取 JWT
|
||||
JWT=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
|
||||
-H "Content-Type: application/json" -d '{"password": "password"}' | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
# 更新三个关键配置
|
||||
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{
|
||||
"GITEA_API_URL": "http://gitea:3000/api/v1",
|
||||
"GITEA_ACCESS_TOKEN": "<token>",
|
||||
"WEBHOOK_SECRET": "e2e-test-secret"
|
||||
}'
|
||||
```
|
||||
|
||||
**关键**:
|
||||
- `GITEA_API_URL` 必须是 `http://gitea:3000/api/v1`(Docker 内部地址),不是 `localhost` 或宿主机地址
|
||||
- `GITEA_ACCESS_TOKEN` 是 seed.sh 生成的 token,assistant 用它 clone 仓库和发布评论
|
||||
- `WEBHOOK_SECRET` 必须与 Gitea webhook 的 secret 一致,否则签名验证失败
|
||||
|
||||
### 5. 触发 PR 审查
|
||||
|
||||
PR 创建时 Gitea 会自动触发 webhook。如果需要手动触发:
|
||||
|
||||
```bash
|
||||
# 获取 PR 信息
|
||||
PR_RESP=$(curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1" \
|
||||
-H "Authorization: token <token>")
|
||||
|
||||
HEAD_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||
BASE_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['base']['sha'])")
|
||||
|
||||
# 构造 webhook payload(需包含 head/base SHA)
|
||||
cat > /tmp/webhook_payload.json << EOF
|
||||
{
|
||||
"action": "opened",
|
||||
"number": 1,
|
||||
"pull_request": {
|
||||
"number": 1,
|
||||
"title": "feat: add user handler",
|
||||
"head": { "ref": "feature/add-user-handler", "sha": "$HEAD_SHA",
|
||||
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } },
|
||||
"base": { "ref": "main", "sha": "$BASE_SHA",
|
||||
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } }
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "e2e-admin/e2e-test-repo",
|
||||
"name": "e2e-test-repo",
|
||||
"owner": { "login": "e2e-admin" },
|
||||
"clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git"
|
||||
},
|
||||
"sender": { "login": "e2e-admin" }
|
||||
}
|
||||
EOF
|
||||
|
||||
# 计算 HMAC 签名(注意:必须基于文件内容计算,避免 shell 变量传递时改变内容)
|
||||
SIG=$(cat /tmp/webhook_payload.json | openssl dgst -sha256 -hmac "e2e-test-secret" | awk '{print $NF}')
|
||||
|
||||
# 发送 webhook(⚠️ 必须用 --data-binary 而非 -d,否则换行符被剥离导致签名不匹配)
|
||||
curl -s -X POST "http://localhost:3334/webhook/gitea" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Gitea-Event: pull_request" \
|
||||
-H "X-Gitea-Signature: ${SIG}" \
|
||||
--data-binary @/tmp/webhook_payload.json
|
||||
```
|
||||
|
||||
### 6. 验证审查结果
|
||||
|
||||
```bash
|
||||
# 等待审查完成
|
||||
sleep 10
|
||||
|
||||
# 检查 assistant 日志
|
||||
docker logs e2e-assistant 2>&1 | grep -E "审查|publish|评论|finding|ERROR" | tail -20
|
||||
|
||||
# 通过 API 查看 run 详情
|
||||
curl -sf "http://localhost:3334/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer $JWT" | python3 -m json.tool | head -30
|
||||
|
||||
# 检查 Gitea PR 评论(summary)
|
||||
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/issues/1/comments" \
|
||||
-H "Authorization: token <token>" | python3 -c "
|
||||
import sys,json
|
||||
for c in json.load(sys.stdin):
|
||||
print(c['body'][:200])
|
||||
"
|
||||
|
||||
# 检查 Gitea PR Reviews(行级评论)
|
||||
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1/reviews" \
|
||||
-H "Authorization: token <token>"
|
||||
```
|
||||
|
||||
## 验证检查清单
|
||||
|
||||
| # | 检查项 | 验证方式 |
|
||||
|---|--------|----------|
|
||||
| 1 | Gitea 容器 healthy | `docker ps` 或 `curl localhost:3333/api/v1/version` |
|
||||
| 2 | Assistant 容器 healthy | `curl localhost:3334/api/health` |
|
||||
| 3 | Webhook 签名验证通过 | assistant 日志无"签名验证失败" |
|
||||
| 4 | Git clone mirror 成功 | assistant 日志无"could not read Username" |
|
||||
| 5 | Agent 审查执行完成 | run status = `succeeded` |
|
||||
| 6 | Subagent 被触发 | sessionTree.invocations 非空 |
|
||||
| 7 | Findings 数量 > 0 | run details 中 findings 非空 |
|
||||
| 8 | Summary 评论发布到 Gitea | PR issue comments 包含"AI Agent代码审查结果" |
|
||||
| 9 | 行级评论发布到 Gitea | PR reviews 包含 COMMENT 类型 |
|
||||
| 10 | Finding published=true | DB 中 finding.published = true |
|
||||
|
||||
## Mock LLM vs 真实 LLM
|
||||
|
||||
| 特性 | E2E_MOCK_LLM=1 | 真实 LLM |
|
||||
|------|----------------|----------|
|
||||
| 模型 | `RuntimeE2EMockLLM`(脚本驱动) | OpenAI/Anthropic/其他 |
|
||||
| Subagent | 必然调用(固定脚本) | 动态决策(根据 diff 复杂度) |
|
||||
| Findings | 固定 1 条(eval 安全问题) | 根据实际代码动态发现 |
|
||||
| 速度 | <1s | 10-60s |
|
||||
| 用途 | 集成链路验证 | 审查质量验证 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `ENCRYPTION_KEY is required`
|
||||
**原因**:`docker-compose.e2e.yml` 缺少 `ENCRYPTION_KEY` 环境变量。
|
||||
**修复**:已在 compose 文件中添加默认值。
|
||||
|
||||
### `Webhook签名验证失败`
|
||||
**原因**:请求的 HMAC 签名与 assistant 配置的 `WEBHOOK_SECRET` 不匹配。
|
||||
**修复**:确保 webhook payload 的签名计算使用与 Admin API 配置的 `WEBHOOK_SECRET` 相同的密钥。
|
||||
|
||||
### `could not read Username for 'http://gitea:3000'`
|
||||
**原因**:`GITEA_ACCESS_TOKEN` 未正确配置(默认值 `placeholder`)或 DB 配置中的值不正确。
|
||||
**修复**:通过 Admin API 更新 `GITEA_ACCESS_TOKEN` 为 seed.sh 生成的实际 token。
|
||||
|
||||
### `Gitea is not supposed to be run as root`
|
||||
**原因**:Gitea 容器以 root 运行,但 `gitea admin` 命令不允许 root 执行。
|
||||
**修复**:使用 `docker exec e2e-gitea su git -c "gitea admin user create ..."` 格式。
|
||||
|
||||
### Gitea API URL 指向 localhost
|
||||
**原因**:assistant DB 中 `GITEA_API_URL` 默认值为 `http://localhost:5174/api/v1`(自身地址)。
|
||||
**修复**:通过 Admin API 更新为 `http://gitea:3000/api/v1`(Docker 内部地址)。
|
||||
|
||||
### 评论未发布到 Gitea
|
||||
**原因**:Agent 引擎的 `publishPendingComments` 链路缺失(已修复)。
|
||||
**修复**:确保使用包含 `publishPendingComments` 逻辑的版本。
|
||||
|
||||
## 清理
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml down -v
|
||||
```
|
||||
|
||||
`-v` 会删除 Gitea 数据卷,下次启动需要重新 seed。
|
||||
22
e2e/seed.sh
22
e2e/seed.sh
@@ -26,12 +26,12 @@ for i in $(seq 1 30); do
|
||||
done
|
||||
|
||||
echo "=== [2/6] 创建管理员用户 ==="
|
||||
docker exec e2e-gitea gitea admin user create \
|
||||
--username "${ADMIN_USER}" \
|
||||
--password "${ADMIN_PASS}" \
|
||||
--email "${ADMIN_EMAIL}" \
|
||||
--admin \
|
||||
--must-change-password=false 2>/dev/null || echo " 用户已存在,跳过"
|
||||
docker exec e2e-gitea su git -c "gitea admin user create \
|
||||
--username '${ADMIN_USER}' \
|
||||
--password '${ADMIN_PASS}' \
|
||||
--email '${ADMIN_EMAIL}' \
|
||||
--admin \
|
||||
--must-change-password=false" 2>/dev/null || echo " 用户已存在,跳过"
|
||||
|
||||
echo "=== [3/6] 生成 API Token ==="
|
||||
TOKEN_RESPONSE=$(curl -sf -X POST "${GITEA_URL}/api/v1/users/${ADMIN_USER}/tokens" \
|
||||
@@ -129,7 +129,7 @@ for i in $(seq 1 20); do
|
||||
done
|
||||
|
||||
# Login to get JWT
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/login" \
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"password\": \"${ADMIN_DEFAULT_PASS}\"}" 2>/dev/null || true)
|
||||
ADMIN_JWT=$(echo "${LOGIN_RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true)
|
||||
@@ -138,7 +138,7 @@ if [ -z "${ADMIN_JWT}" ]; then
|
||||
echo " WARNING: 无法获取管理员 JWT,跳过 assistant 配置"
|
||||
else
|
||||
echo " JWT 获取成功,配置 assistant 设置..."
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/config" \
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" \
|
||||
-d "{
|
||||
@@ -146,10 +146,8 @@ else
|
||||
\"GITEA_API_URL\": \"http://gitea:3000/api/v1\",
|
||||
\"REVIEW_ENGINE\": \"agent\",
|
||||
\"REVIEW_WORKDIR\": \"/tmp/e2e-review\",
|
||||
\"REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE\": \"0.5\",
|
||||
\"REVIEW_ENABLE_HUMAN_GATE\": \"false\",
|
||||
\"REVIEW_ALLOWED_COMMANDS\": \"git,rg,cat,sed,wc\",
|
||||
\"REVIEW_COMMAND_TIMEOUT_MS\": \"30000\"
|
||||
\"REVIEW_COMMAND_TIMEOUT_MS\": \"120000\"
|
||||
}" > /dev/null 2>&1 && echo " Assistant 配置完成" || echo " WARNING: assistant 配置失败"
|
||||
fi
|
||||
|
||||
@@ -162,7 +160,7 @@ curl -sf -X POST "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/hooks" \
|
||||
\"active\": true,
|
||||
\"events\": [\"pull_request\"],
|
||||
\"config\": {
|
||||
\"url\": \"http://assistant:3000/webhook/gitea\",
|
||||
\"url\": \"http://assistant:5174/webhook/gitea\",
|
||||
\"content_type\": \"json\",
|
||||
\"secret\": \"${WEBHOOK_SECRET}\"
|
||||
}
|
||||
|
||||
179
e2e/test.sh
179
e2e/test.sh
@@ -2,7 +2,6 @@
|
||||
set -euo pipefail
|
||||
|
||||
# E2E Test Script
|
||||
# 验证 AI 代码审查是否在 PR 上产生了评论
|
||||
#
|
||||
# 前置条件:
|
||||
# 1. docker compose -f docker-compose.e2e.yml up -d
|
||||
@@ -17,10 +16,12 @@ fi
|
||||
|
||||
source "${ENV_FILE}"
|
||||
|
||||
MAX_WAIT=180 # 最多等待 3 分钟
|
||||
MAX_WAIT=240
|
||||
POLL_INTERVAL=5
|
||||
PASS=0
|
||||
FAIL=0
|
||||
RUN_ID=""
|
||||
LATEST_DETAIL='{}'
|
||||
|
||||
echo "=== E2E 测试开始 ==="
|
||||
echo " Gitea: ${GITEA_URL}"
|
||||
@@ -38,6 +39,12 @@ else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if [ "${E2E_MOCK_LLM:-}" = "1" ]; then
|
||||
echo " E2E_MOCK_LLM=1 (shell env)"
|
||||
else
|
||||
echo " E2E_MOCK_LLM 由 assistant 容器环境决定(docker-compose.e2e.yml 已配置)"
|
||||
fi
|
||||
|
||||
# ─── 测试 2: Gitea API 可用 ───
|
||||
echo "[TEST 2] Gitea API 可用性"
|
||||
VERSION=$(curl -sf "${GITEA_URL}/api/v1/version" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown")
|
||||
@@ -63,69 +70,121 @@ else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ─── 测试 4: 等待 AI 审查评论出现 ───
|
||||
echo "[TEST 4] AI 审查评论(最多等待 ${MAX_WAIT}s)"
|
||||
COMMENT_FOUND=false
|
||||
WAITED=0
|
||||
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
AI_COMMENTS=$(echo "${COMMENTS}" | python3 -c "
|
||||
import sys, json
|
||||
comments = json.load(sys.stdin)
|
||||
ai = [c for c in comments if 'AI' in c.get('body', '') or 'Agent' in c.get('body', '')]
|
||||
print(len(ai))
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${AI_COMMENTS}" -gt "0" ]; then
|
||||
COMMENT_FOUND=true
|
||||
echo " ✅ PASS: 发现 ${AI_COMMENTS} 条 AI 审查评论 (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
|
||||
echo " ⏳ 等待中... (${WAITED}/${MAX_WAIT}s, 已有评论: $(echo "${COMMENTS}" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0))"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
if [ "${COMMENT_FOUND}" = false ]; then
|
||||
echo " ❌ FAIL: ${MAX_WAIT}s 内未发现 AI 审查评论"
|
||||
FAIL=$((FAIL + 1))
|
||||
|
||||
echo " --- 调试信息 ---"
|
||||
echo " PR 所有评论:"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
|
||||
echo " Assistant review runs:"
|
||||
curl -sf "${ASSISTANT_URL}/admin/api/review/runs" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
fi
|
||||
|
||||
# ─── 测试 5: Review Run 状态检查 ───
|
||||
echo "[TEST 5] Review Run 状态"
|
||||
echo "[TEST 4] Admin 登录"
|
||||
ADMIN_JWT=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password":"password"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "${ADMIN_JWT}" ]; then
|
||||
echo " ✅ PASS: Admin JWT 获取成功"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Admin JWT 获取失败"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 5] 等待 review run 产出并成功(最多等待 ${MAX_WAIT}s)"
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "[]")
|
||||
RUN_COUNT=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('data',d if isinstance(d,list) else [])))" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${RUN_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: 发现 ${RUN_COUNT} 个 review run(s)"
|
||||
PASS=$((PASS + 1))
|
||||
WAITED=0
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "{}")
|
||||
RUN_ID=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0]['id'] if runs else '')" 2>/dev/null || echo "")
|
||||
RUN_STATUS=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0].get('status','') if runs else '')" 2>/dev/null || echo "")
|
||||
if [ -n "${RUN_ID}" ] && [ "${RUN_STATUS}" = "succeeded" ]; then
|
||||
echo " ✅ PASS: run=${RUN_ID} status=succeeded (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
echo " ⏳ 等待 run... (${WAITED}/${MAX_WAIT}s, run=${RUN_ID:-none}, status=${RUN_STATUS:-none})"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
echo "${RUNS}" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
runs = data.get('data', data if isinstance(data, list) else data.get('runs', []))
|
||||
for r in runs[:3]:
|
||||
print(f\" - {r.get('id','?')[:8]}... status={r.get('status','?')} attempts={r.get('attempts','?')}\")
|
||||
" 2>/dev/null || true
|
||||
if [ -z "${RUN_ID}" ]; then
|
||||
echo " ❌ FAIL: 未发现 review run"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if [ -n "${RUN_ID}" ]; then
|
||||
LATEST_DETAIL=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs/${RUN_ID}" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo '{}')
|
||||
fi
|
||||
|
||||
echo "[TEST 6] 会话树包含主/子 Agent 与工具调用"
|
||||
TREE_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
t=d.get("sessionTree") or {}
|
||||
main_type=t.get("agentType")
|
||||
main_tools=[x.get("toolName") for x in t.get("toolCalls",[])]
|
||||
inv=t.get("invocations",[])
|
||||
has_spawn="spawn_subagent" in main_tools
|
||||
child_ok=False
|
||||
if inv:
|
||||
child=inv[0].get("childSession") or {}
|
||||
child_tools=[x.get("toolName") for x in child.get("toolCalls",[])]
|
||||
child_ok=("search_code" in child_tools and "read_file" in child_tools)
|
||||
print("ok" if (main_type=="review-main-agent" and has_spawn and len(inv)>0 and child_ok) else "bad")
|
||||
' 2>/dev/null || echo "bad")
|
||||
|
||||
if [ "${TREE_ASSERT}" = "ok" ]; then
|
||||
echo " ✅ PASS: 主会话与子代理调用链存在(含 search_code/read_file)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: 无 review runs"
|
||||
echo " ❌ FAIL: sessionTree 未满足动态子代理断言"
|
||||
echo "${LATEST_DETAIL}" | python3 -m json.tool 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 7] run details 包含 findings 与评论记录"
|
||||
DETAIL_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
findings=d.get("findings",[])
|
||||
comments=d.get("comments",[])
|
||||
ok=(len(findings) > 0 and len(comments) > 0)
|
||||
print("ok" if ok else "bad")
|
||||
' 2>/dev/null || echo "bad")
|
||||
|
||||
if [ "${DETAIL_ASSERT}" = "ok" ]; then
|
||||
echo " ✅ PASS: run details 存在 findings/comments"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: run details 缺少 findings 或 comments"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 8] Gitea 评论产物(summary + line comments)"
|
||||
ISSUE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
LINE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
SUMMARY_COUNT=$(echo "${ISSUE_COMMENTS}" | python3 -c '
|
||||
import json,sys
|
||||
arr=json.load(sys.stdin)
|
||||
cnt=0
|
||||
for c in arr:
|
||||
body=c.get("body") or ""
|
||||
if "审查" in body or "review" in body.lower() or "AI" in body:
|
||||
cnt += 1
|
||||
print(cnt)
|
||||
' 2>/dev/null || echo "0")
|
||||
LINE_COUNT=$(echo "${LINE_COMMENTS}" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${SUMMARY_COUNT}" -gt "0" ] && [ "${LINE_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT}"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Gitea 评论产物不足(summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT})"
|
||||
echo " --- issue comments ---"
|
||||
echo "${ISSUE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
|
||||
echo " --- line comments ---"
|
||||
echo "${LINE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
@@ -138,10 +197,10 @@ echo " 失败: ${FAIL}/${TOTAL}"
|
||||
|
||||
if [ ${FAIL} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ 部分测试失败。如果 AI 评论测试失败,请确保:"
|
||||
echo " 1. OPENAI_API_KEY 已正确配置"
|
||||
echo " 2. assistant 容器的 GITEA_ACCESS_TOKEN 已设置为 seed 生成的 token"
|
||||
echo " 3. Webhook 已正确触发(检查 Gitea webhook 日志)"
|
||||
echo "⚠️ 部分测试失败。请检查:"
|
||||
echo " 1. docker compose e2e 容器均 healthy"
|
||||
echo " 2. assistant 容器环境含 E2E_MOCK_LLM=1 与正确 GITEA_ACCESS_TOKEN"
|
||||
echo " 3. webhook 已触发且 run details 可见 sessionTree/findings/comments"
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -216,10 +218,14 @@
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
|
||||
|
||||
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
|
||||
@@ -228,6 +234,8 @@
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
|
||||
|
||||
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"ui:visual:update": "playwright test -c playwright.config.ts --update-snapshots"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
|
||||
@@ -4,7 +4,9 @@ import { LoginPage } from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
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';
|
||||
@@ -51,7 +53,9 @@ function AppContent() {
|
||||
<Route index element={<Navigate to="/repos" replace />} />
|
||||
<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>
|
||||
</Routes>
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ConfigGroupCardProps {
|
||||
onFieldChange: (envKey: string, value: any) => void;
|
||||
onReset: (keys: string[]) => void;
|
||||
isResetting: boolean;
|
||||
headerActions?: React.ReactNode;
|
||||
/** Optional custom renderer for individual fields. Return `undefined` to use default ConfigFieldInput. */
|
||||
renderField?: (field: ConfigFieldDto, value: any, onChange: (val: any) => void) => React.ReactNode | undefined;
|
||||
}
|
||||
@@ -35,6 +36,7 @@ export function ConfigGroupCard({
|
||||
onFieldChange,
|
||||
onReset,
|
||||
isResetting,
|
||||
headerActions,
|
||||
renderField,
|
||||
}: ConfigGroupCardProps) {
|
||||
const hasOverride = group.fields.some((f) => f.source === 'db');
|
||||
@@ -69,17 +71,22 @@ export function ConfigGroupCard({
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
重置组配置
|
||||
</Button>
|
||||
{(headerActions || hasOverride) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{headerActions}
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting}
|
||||
className="theme-interactive-elevate border-danger/30 text-danger hover:bg-danger/10 hover:text-danger hover:border-danger/50 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
重置组配置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content divide-y divide-border/50">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Save, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/** Groups shown on the system config page (excludes review & memory — moved to ReviewConfigPage). */
|
||||
const SYSTEM_GROUPS = new Set(['gitea', 'feishu', 'security']);
|
||||
const SYSTEM_GROUPS = new Set(['gitea', 'security']);
|
||||
|
||||
export function ConfigManager() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
379
frontend/src/components/NotificationConfigPage.tsx
Normal file
379
frontend/src/components/NotificationConfigPage.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchConfig,
|
||||
fetchNotificationTestHistory,
|
||||
updateConfig,
|
||||
resetConfig,
|
||||
testNotification,
|
||||
type NotificationTestProvider,
|
||||
} from '@/services/configService';
|
||||
import type {
|
||||
ConfigResponse,
|
||||
ConfigGroupDto,
|
||||
ConfigFieldDto,
|
||||
NotificationTestRecordDto,
|
||||
} from '@/services/configService';
|
||||
import { ConfigGroupCard } from './ConfigGroupCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Save, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const NOTIFICATION_GROUPS = new Set(['notification']);
|
||||
|
||||
type ProviderCardMeta = {
|
||||
key: NotificationTestProvider;
|
||||
fieldPrefix: 'FEISHU_' | 'WECOM_';
|
||||
label: string;
|
||||
description: string;
|
||||
enableKey: 'FEISHU_ENABLED' | 'WECOM_ENABLED';
|
||||
webhookKey: 'FEISHU_WEBHOOK_URL' | 'WECOM_WEBHOOK_URL';
|
||||
};
|
||||
|
||||
const PROVIDER_CARDS: ProviderCardMeta[] = [
|
||||
{
|
||||
key: 'feishu',
|
||||
fieldPrefix: 'FEISHU_',
|
||||
label: '飞书通知',
|
||||
description: '配置飞书机器人 Webhook 与签名密钥。',
|
||||
enableKey: 'FEISHU_ENABLED',
|
||||
webhookKey: 'FEISHU_WEBHOOK_URL',
|
||||
},
|
||||
{
|
||||
key: 'wecom',
|
||||
fieldPrefix: 'WECOM_',
|
||||
label: '企业微信通知',
|
||||
description: '配置企业微信群机器人 Webhook。',
|
||||
enableKey: 'WECOM_ENABLED',
|
||||
webhookKey: 'WECOM_WEBHOOK_URL',
|
||||
},
|
||||
];
|
||||
|
||||
export function NotificationConfigPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [localConfig, setLocalConfig] = useState<Record<string, any>>({});
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchConfig,
|
||||
});
|
||||
|
||||
const {
|
||||
data: testHistory,
|
||||
isLoading: isHistoryLoading,
|
||||
} = useQuery<NotificationTestRecordDto[], Error>({
|
||||
queryKey: ['notification-test-history'],
|
||||
queryFn: fetchNotificationTestHistory,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups
|
||||
.filter((g) => NOTIFICATION_GROUPS.has(g.key))
|
||||
.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
initialState[field.envKey] = '••••••••';
|
||||
} else if (field.type === 'boolean') {
|
||||
initialState[field.envKey] = field.value === 'true' || field.value === true;
|
||||
} else {
|
||||
initialState[field.envKey] = field.value ?? '';
|
||||
}
|
||||
});
|
||||
});
|
||||
setLocalConfig(initialState);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
|
||||
onSuccess: () => {
|
||||
toast.success('通知配置已成功保存');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`保存失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const feishuTestMutation = useMutation({
|
||||
mutationFn: () => testNotification('feishu'),
|
||||
onSuccess: () => {
|
||||
toast.success('飞书测试通知已发送');
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`飞书测试发送失败: ${err.message}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
});
|
||||
|
||||
const wecomTestMutation = useMutation({
|
||||
mutationFn: () => testNotification('wecom'),
|
||||
onSuccess: () => {
|
||||
toast.success('企业微信测试通知已发送');
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`企业微信测试发送失败: ${err.message}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['notification-test-history'] });
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (keys: string[]) => resetConfig(keys),
|
||||
onSuccess: () => {
|
||||
toast.success('通知配置已重置');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(`重置失败: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFieldChange = (envKey: string, value: any) => {
|
||||
setLocalConfig((prev) => ({
|
||||
...prev,
|
||||
[envKey]: value,
|
||||
}));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
|
||||
for (const [key, val] of Object.entries(localConfig)) {
|
||||
if (typeof val === 'boolean') {
|
||||
payload[key] = val ? 'true' : 'false';
|
||||
} else {
|
||||
payload[key] = val === undefined || val === null ? '' : String(val);
|
||||
}
|
||||
}
|
||||
|
||||
saveMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleResetGroup = (keys: string[]) => {
|
||||
if (confirm('确定要重置这些通知配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(keys);
|
||||
}
|
||||
};
|
||||
|
||||
const notificationGroup = useMemo(
|
||||
() => data?.groups.find((g) => NOTIFICATION_GROUPS.has(g.key)),
|
||||
[data]
|
||||
);
|
||||
|
||||
const providerGroups = useMemo<ConfigGroupDto[]>(() => {
|
||||
if (!notificationGroup) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return PROVIDER_CARDS.map((provider) => {
|
||||
const fields = notificationGroup.fields.filter((field: ConfigFieldDto) =>
|
||||
field.envKey.startsWith(provider.fieldPrefix)
|
||||
);
|
||||
|
||||
return {
|
||||
...notificationGroup,
|
||||
key: `notification-${provider.key}`,
|
||||
label: provider.label,
|
||||
description: provider.description,
|
||||
fields,
|
||||
};
|
||||
}).filter((group) => group.fields.length > 0);
|
||||
}, [notificationGroup]);
|
||||
|
||||
const hasOverrides = useMemo(
|
||||
() =>
|
||||
providerGroups.some((g) =>
|
||||
g.fields.some((f) => f.source === 'db')
|
||||
),
|
||||
[providerGroups]
|
||||
);
|
||||
|
||||
const handleResetAll = () => {
|
||||
if (providerGroups.length === 0) return;
|
||||
const allOverrideKeys = providerGroups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
|
||||
if (allOverrideKeys.length === 0) return;
|
||||
if (confirm('确定要重置所有通知配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(allOverrideKeys);
|
||||
}
|
||||
};
|
||||
|
||||
const canSendProviderTest = (provider: ProviderCardMeta): boolean => {
|
||||
const enabled = localConfig[provider.enableKey] === true;
|
||||
const webhook = localConfig[provider.webhookKey];
|
||||
return enabled && typeof webhook === 'string' && webhook.trim().length > 0;
|
||||
};
|
||||
|
||||
const getProviderMutation = (providerKey: NotificationTestProvider) => {
|
||||
return providerKey === 'feishu' ? feishuTestMutation : wecomTestMutation;
|
||||
};
|
||||
|
||||
const getProviderLabel = (provider: string): string => {
|
||||
if (provider === 'feishu') return '飞书';
|
||||
if (provider === 'wecom') return '企业微信';
|
||||
return provider;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Skeleton className="h-10 w-48 bg-muted/60" />
|
||||
<Skeleton className="h-10 w-24 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[280px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="theme-error-panel flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<div className="font-medium tracking-wide">加载通知配置失败: {error.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame">
|
||||
<div className="sticky top-0 z-10 theme-sticky-bar py-3 px-4 md:px-6 lg:px-8">
|
||||
<div className="theme-page-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResetAll}
|
||||
disabled={!hasOverrides || resetMutation.isPending}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
全部重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saveMutation.isPending}
|
||||
className="theme-interactive-elevate min-w-[130px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/50 border-t-transparent" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存配置
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-page-content">
|
||||
{providerGroups.map((group) => {
|
||||
const provider = PROVIDER_CARDS.find((item) => group.key === `notification-${item.key}`);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mutation = getProviderMutation(provider.key);
|
||||
const canTest = canSendProviderTest(provider);
|
||||
const canTestNow = canTest && !hasChanges && !saveMutation.isPending;
|
||||
const testTitle = hasChanges
|
||||
? '请先保存配置后再测试'
|
||||
: canTest
|
||||
? '发送测试通知'
|
||||
: '请先启用并配置Webhook地址';
|
||||
|
||||
return (
|
||||
<ConfigGroupCard
|
||||
key={group.key}
|
||||
group={group}
|
||||
localConfig={localConfig}
|
||||
onFieldChange={handleFieldChange}
|
||||
onReset={handleResetGroup}
|
||||
isResetting={resetMutation.isPending}
|
||||
headerActions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending || !canTestNow}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
title={testTitle}
|
||||
>
|
||||
{mutation.isPending ? '测试中...' : '测试发送'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="rounded-xl border border-border/60 bg-card p-4 md:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold tracking-wide text-foreground">最近测试记录</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['notification-test-history'] })}
|
||||
className="theme-interactive-elevate border-border text-muted-foreground hover:text-foreground hover:bg-accent/40 transition-colors"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isHistoryLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full bg-muted/60" />
|
||||
<Skeleton className="h-10 w-full bg-muted/60" />
|
||||
</div>
|
||||
) : (testHistory?.length ?? 0) === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
暂无测试记录,点击上方“测试发送”按钮可生成记录。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{testHistory?.slice(0, 10).map((record) => (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="border-border text-foreground">
|
||||
{getProviderLabel(record.provider)}
|
||||
</Badge>
|
||||
<Badge
|
||||
className={
|
||||
record.status === 'success'
|
||||
? 'bg-success/15 text-success border-success/30'
|
||||
: 'bg-danger/15 text-danger border-danger/30'
|
||||
}
|
||||
>
|
||||
{record.status === 'success' ? '成功' : '失败'}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">{record.message}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(record.timestamp).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/src/components/RepositoryConfigCell.tsx
Normal file
141
frontend/src/components/RepositoryConfigCell.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Settings, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import api from '@/lib/api';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
interface RepositoryConfigCellProps {
|
||||
repo: Repository;
|
||||
}
|
||||
|
||||
export function RepositoryConfigCell({ repo }: RepositoryConfigCellProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [isPromptDialogOpen, setIsPromptDialogOpen] = useState(false);
|
||||
const [draftPrompt, setDraftPrompt] = useState(repo.project_review_prompt ?? '');
|
||||
|
||||
const promptMutation = useMutation({
|
||||
mutationFn: async (prompt: string) => {
|
||||
const { data } = await api.put(`/repositories/${repo.name}/project-prompt`, {
|
||||
project_review_prompt: prompt,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories'] });
|
||||
setIsPromptDialogOpen(false);
|
||||
toast.success(`已更新 ${repo.name} 的项目级提示词`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`更新失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSavePrompt = () => {
|
||||
promptMutation.mutate(draftPrompt.trim());
|
||||
};
|
||||
|
||||
const handleOpenDialog = () => {
|
||||
setDraftPrompt(repo.project_review_prompt ?? '');
|
||||
setIsPromptDialogOpen(true);
|
||||
};
|
||||
|
||||
const hasPrompt = !!repo.project_review_prompt?.trim();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={hasPrompt ? "outline" : "ghost"}
|
||||
size="sm"
|
||||
className={`h-8 gap-1.5 text-xs ${
|
||||
hasPrompt
|
||||
? "border-primary/50 text-primary hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={handleOpenDialog}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{hasPrompt ? '已配置' : '配置'}</span>
|
||||
{hasPrompt && <span className="ml-1 h-1.5 w-1.5 rounded-full bg-primary" />}
|
||||
</Button>
|
||||
|
||||
<Dialog open={isPromptDialogOpen} onOpenChange={setIsPromptDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>配置项目级提示词</DialogTitle>
|
||||
<DialogDescription>
|
||||
为仓库 <code className="rounded bg-muted px-1 py-0.5 text-xs">{repo.name}</code> 设置审查提示词
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{hasPrompt && (
|
||||
<div className="rounded-lg bg-muted/50 border border-border/50 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">当前配置</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground leading-relaxed whitespace-pre-wrap break-all max-h-[80px] overflow-y-auto">
|
||||
{repo.project_review_prompt}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">提示词内容</label>
|
||||
<Textarea
|
||||
value={draftPrompt}
|
||||
onChange={(e) => setDraftPrompt(e.target.value)}
|
||||
placeholder="输入项目级审查提示词,例如:重点关注 API 安全性、空值处理和错误边界..."
|
||||
className="min-h-[120px] resize-none text-sm leading-relaxed focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
disabled={promptMutation.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
此提示词将在代码审查时与全局提示词合并,传递给 AI 模型。
|
||||
{hasPrompt && ' 留空保存将清除当前配置。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsPromptDialogOpen(false)}
|
||||
disabled={promptMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSavePrompt}
|
||||
disabled={
|
||||
promptMutation.isPending ||
|
||||
draftPrompt.trim() === (repo.project_review_prompt ?? '').trim()
|
||||
}
|
||||
>
|
||||
{promptMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
保存中
|
||||
</>
|
||||
) : (
|
||||
'保存'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,17 +17,17 @@ function DataTableSkeleton() {
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/60 border-b border-border/50">
|
||||
<TableRow className="border-border/50">
|
||||
<TableHead className="w-[40%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[30%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[30%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-muted" /></TableHead>
|
||||
<TableHead className="w-[50%]"><Skeleton className="h-5 w-24 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[25%]"><Skeleton className="h-5 w-16 bg-muted" /></TableHead>
|
||||
<TableHead className="w-[25%] text-right"><Skeleton className="h-5 w-16 ml-auto bg-muted" /></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i} className="border-border/50">
|
||||
<TableCell><Skeleton className="h-5 w-3/4 bg-muted/70" /></TableCell>
|
||||
<TableCell><Skeleton className="h-6 w-20 bg-muted/70 rounded-full" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-8 w-16 ml-auto bg-muted/70" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-20 bg-muted/70" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-8 w-24 ml-auto bg-muted/70" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -2,42 +2,34 @@
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import type { Repository } from "@/services/repositoryService"
|
||||
import { WebhookToggleButton } from "@/components/WebhookToggleButton"
|
||||
import { RepositoryConfigCell } from "@/components/RepositoryConfigCell"
|
||||
import { WebhookToggleCell } from "@/components/WebhookToggleCell"
|
||||
|
||||
export const columns: ColumnDef<Repository>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "仓库名称",
|
||||
cell: ({ row }) => <div className="font-medium text-foreground text-sm">{row.getValue("name")}</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium text-foreground text-sm">
|
||||
{row.getValue("name")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "webhook_status",
|
||||
header: "Webhook 状态",
|
||||
header: "Webhook",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("webhook_status") as Repository["webhook_status"]
|
||||
const isActive = status === 'active'
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold border ${isActive ? 'bg-success/10 text-success border-success/30' : 'bg-transparent text-muted-foreground border-border theme-border-soft'}`}>
|
||||
{isActive && <span className="w-1.5 h-1.5 rounded-full bg-success animate-pulse theme-glow-success"></span>}
|
||||
{isActive ? '已启用' : '未启用'}
|
||||
</div>
|
||||
)
|
||||
const repo = row.original
|
||||
return <WebhookToggleCell repo={repo} />
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="text-right text-muted-foreground">操作</div>,
|
||||
cell: ({ row }) => {
|
||||
const repo = row.original
|
||||
return (
|
||||
<div className="text-right">
|
||||
<WebhookToggleButton
|
||||
repoName={repo.name}
|
||||
status={repo.webhook_status}
|
||||
hookId={repo.hook_id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
header: () => <div className="text-right text-muted-foreground text-xs">提示词</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<RepositoryConfigCell repo={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -32,14 +32,11 @@ const AGENT_SHARED_FIELDS = new Set([
|
||||
|
||||
/** Fields specific to agent mode only. */
|
||||
const AGENT_ONLY_FIELDS = new Set([
|
||||
'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
'REVIEW_ENABLE_HUMAN_GATE',
|
||||
'REVIEW_ALLOWED_COMMANDS',
|
||||
'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
'LLM_MAX_CONCURRENT_CALLS',
|
||||
'LLM_RETRY_MAX_ATTEMPTS',
|
||||
'LLM_RETRY_BASE_DELAY_MS',
|
||||
'ENABLE_TRIAGE',
|
||||
]);
|
||||
|
||||
/** Fields specific to codex mode only. */
|
||||
@@ -102,16 +99,13 @@ export function ReviewConfigPage() {
|
||||
return 'agent';
|
||||
}, [localConfig]);
|
||||
|
||||
// Derived: review group and memory group from fetched data
|
||||
const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]);
|
||||
const memoryGroup = useMemo(() => data?.groups.find((g) => g.key === 'memory'), [data]);
|
||||
|
||||
// Initialize local config from ALL groups (so save works for review + memory fields)
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups
|
||||
.filter((g) => g.key === 'review' || g.key === 'memory')
|
||||
.filter((g) => g.key === 'review')
|
||||
.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
@@ -158,7 +152,10 @@ export function ReviewConfigPage() {
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(localConfig)) {
|
||||
const fieldsToSave = new Set([ENGINE_FIELD, ...visibleReviewFields.map((field) => field.envKey)]);
|
||||
|
||||
for (const key of fieldsToSave) {
|
||||
const val = localConfig[key];
|
||||
if (typeof val === 'boolean') {
|
||||
payload[key] = val ? 'true' : 'false';
|
||||
} else {
|
||||
@@ -175,7 +172,7 @@ export function ReviewConfigPage() {
|
||||
};
|
||||
|
||||
const handleResetAll = () => {
|
||||
const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const allOverrideKeys = groups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'db')
|
||||
@@ -193,9 +190,9 @@ export function ReviewConfigPage() {
|
||||
);
|
||||
|
||||
const hasOverrides = useMemo(() => {
|
||||
const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
return groups.some((g) => g.fields.some((f) => f.source === 'db'));
|
||||
}, [reviewGroup, memoryGroup]);
|
||||
}, [reviewGroup]);
|
||||
|
||||
// -- Render states --
|
||||
|
||||
@@ -229,7 +226,7 @@ export function ReviewConfigPage() {
|
||||
description:
|
||||
engine === 'codex'
|
||||
? 'Codex CLI 审查引擎配置'
|
||||
: '多代理编排审查引擎配置',
|
||||
: 'Agent 审查引擎配置',
|
||||
fields: visibleReviewFields,
|
||||
}
|
||||
: null;
|
||||
@@ -358,17 +355,6 @@ export function ReviewConfigPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Memory group — agent mode only */}
|
||||
{engine === 'agent' && memoryGroup && (
|
||||
<ConfigGroupCard
|
||||
group={memoryGroup}
|
||||
localConfig={localConfig}
|
||||
onFieldChange={handleFieldChange}
|
||||
onReset={handleResetGroup}
|
||||
isResetting={resetMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{engine !== 'codex' && (
|
||||
<>
|
||||
<ProviderList />
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface WebhookToggleButtonProps {
|
||||
repoName: string;
|
||||
status: 'active' | 'inactive';
|
||||
hookId: number | null;
|
||||
}
|
||||
|
||||
const createWebhook = (repoName: string) => api.post(`/repositories/${repoName}/webhook`);
|
||||
const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) => api.delete(`/repositories/${repoName}/webhook/${hookId}`);
|
||||
|
||||
export function WebhookToggleButton({ repoName, status, hookId }: WebhookToggleButtonProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: status === 'active'
|
||||
? () => deleteWebhook({ repoName, hookId: hookId! })
|
||||
: () => createWebhook(repoName),
|
||||
onSuccess: () => {
|
||||
// 操作成功后,使仓库列表的查询失效,React Query会自动重新获取最新数据
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories'] });
|
||||
toast.success(`Webhook for ${repoName} has been ${status === 'active' ? 'disabled' : 'enabled'}.`);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("操作失败:", error);
|
||||
toast.error(`Operation failed: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={status === 'active' ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className={
|
||||
status === 'active'
|
||||
? "border-danger/50 bg-transparent text-danger hover:bg-danger/10 hover:text-danger transition-colors"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 tech-glow"
|
||||
}
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
<span className="font-mono text-xs">处理中...</span>
|
||||
</>
|
||||
) : status === 'active' ? (
|
||||
<span className="font-mono text-xs">停用</span>
|
||||
) : (
|
||||
<span className="font-mono text-xs">启用</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/WebhookToggleCell.tsx
Normal file
57
frontend/src/components/WebhookToggleCell.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import api from '@/lib/api';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
interface WebhookToggleCellProps {
|
||||
repo: Repository;
|
||||
}
|
||||
|
||||
const createWebhook = (repoName: string) =>
|
||||
api.post(`/repositories/${repoName}/webhook`);
|
||||
const deleteWebhook = ({ repoName, hookId }: { repoName: string; hookId: number }) =>
|
||||
api.delete(`/repositories/${repoName}/webhook/${hookId}`);
|
||||
|
||||
export function WebhookToggleCell({ repo }: WebhookToggleCellProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isActive = repo.webhook_status === 'active';
|
||||
|
||||
const webhookMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (isActive && repo.hook_id) {
|
||||
return deleteWebhook({ repoName: repo.name, hookId: repo.hook_id });
|
||||
}
|
||||
return createWebhook(repo.name);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['repositories'] });
|
||||
const action = isActive ? '已禁用' : '已启用';
|
||||
toast.success(`${repo.name} 的 Webhook ${action}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`操作失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{webhookMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => webhookMutation.mutate()}
|
||||
disabled={webhookMutation.isPending}
|
||||
aria-label={isActive ? '禁用 Webhook' : '启用 Webhook'}
|
||||
/>
|
||||
)}
|
||||
<span className={`text-xs ${isActive ? 'text-success' : 'text-muted-foreground'}`}>
|
||||
{isActive ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RepositoryConfigCell } from '../RepositoryConfigCell';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
put: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
default: apiMocks,
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repository> = {}): Repository {
|
||||
return {
|
||||
name: 'demo-owner/demo-repo',
|
||||
webhook_status: 'inactive',
|
||||
hook_id: null,
|
||||
project_review_prompt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RepositoryConfigCell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('opens prompt dialog and saves project prompt', async () => {
|
||||
apiMocks.put.mockResolvedValueOnce({
|
||||
data: {
|
||||
success: true,
|
||||
project_review_prompt: 'focus null safety',
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<RepositoryConfigCell repo={makeRepo()} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /配置/i }));
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText('配置项目级提示词')).toBeInTheDocument();
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
await user.type(textarea, ' focus null safety ');
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.put).toHaveBeenCalledWith(
|
||||
'/repositories/demo-owner/demo-repo/project-prompt',
|
||||
{ project_review_prompt: 'focus null safety' }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
185
frontend/src/components/__tests__/ReviewConfigPage.test.tsx
Normal file
185
frontend/src/components/__tests__/ReviewConfigPage.test.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ReviewConfigPage } from '../ReviewConfigPage';
|
||||
import { fetchConfig, updateConfig, resetConfig, type ConfigResponse } from '@/services/configService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/configService', () => ({
|
||||
fetchConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
resetConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../llm/ProviderList', () => ({
|
||||
ProviderList: () => <div>ProviderListMock</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../llm/RoleAssignment', () => ({
|
||||
RoleAssignment: () => <div>RoleAssignmentMock</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../llm/ModelCombobox', () => ({
|
||||
ModelCombobox: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<input aria-label="Codex model" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeConfigResponse(): ConfigResponse {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
key: 'review',
|
||||
label: '审查引擎',
|
||||
description: 'Agent 审查模式、并发与沙箱设置',
|
||||
icon: 'file-check',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
label: '审查引擎',
|
||||
description: '代码审查模式',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['agent', 'codex'],
|
||||
value: 'agent',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'GLOBAL_PROMPT',
|
||||
label: '全局提示词',
|
||||
description: '附加到所有 LLM 调用',
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
value: '',
|
||||
hasValue: false,
|
||||
source: 'default',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_WORKDIR',
|
||||
label: '工作目录',
|
||||
description: 'Agent 模式下本地仓库目录',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: '/tmp/gitea-assistant',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MAX_PARALLEL_RUNS',
|
||||
label: '最大并发数',
|
||||
description: '单机同时执行的审查任务上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '2',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ALLOWED_COMMANDS',
|
||||
label: '允许命令',
|
||||
description: '本地审查沙箱命令白名单',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'git,rg,cat,sed,wc',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
label: '命令超时(ms)',
|
||||
description: '单条本地命令的执行超时时间',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 120000,
|
||||
max: 300000,
|
||||
value: '120000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
label: 'LLM 最大并发调用',
|
||||
description: '同时在飞的 LLM API 调用上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '4',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_TOKEN_BUDGET_LARGE',
|
||||
label: 'Large 令牌预算',
|
||||
description: 'large 规模审查任务的 token 预算上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '120000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'CODEX_MODEL',
|
||||
label: 'Codex 模型',
|
||||
description: 'Codex CLI 使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'o3',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReviewConfigPage', () => {
|
||||
it('shows only current Agent config surface and saves only visible fields', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
|
||||
vi.mocked(updateConfig).mockResolvedValue(undefined);
|
||||
vi.mocked(resetConfig).mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<ReviewConfigPage />);
|
||||
|
||||
expect(await screen.findByText('Agent 审查设置')).toBeInTheDocument();
|
||||
expect(screen.getByText('REVIEW_COMMAND_TIMEOUT_MS')).toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_TOKEN_BUDGET_LARGE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_ENABLE_HUMAN_GATE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('ENABLE_TRIAGE')).not.toBeInTheDocument();
|
||||
|
||||
const workdirInput = screen.getByDisplayValue('/tmp/gitea-assistant');
|
||||
await user.clear(workdirInput);
|
||||
await user.type(workdirInput, '/tmp/new-review-workdir');
|
||||
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
||||
|
||||
await waitFor(() => expect(updateConfig).toHaveBeenCalledTimes(1));
|
||||
const payload = vi.mocked(updateConfig).mock.calls[0][0];
|
||||
expect(payload.REVIEW_WORKDIR).toBe('/tmp/new-review-workdir');
|
||||
expect(payload.REVIEW_ENGINE).toBe('agent');
|
||||
expect(payload.REVIEW_COMMAND_TIMEOUT_MS).toBe('120000');
|
||||
expect(payload).not.toHaveProperty('REVIEW_TOKEN_BUDGET_LARGE');
|
||||
expect(payload).not.toHaveProperty('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE');
|
||||
expect(payload).not.toHaveProperty('REVIEW_ENABLE_HUMAN_GATE');
|
||||
expect(payload).not.toHaveProperty('ENABLE_TRIAGE');
|
||||
});
|
||||
});
|
||||
84
frontend/src/components/__tests__/WebhookToggleCell.test.tsx
Normal file
84
frontend/src/components/__tests__/WebhookToggleCell.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { WebhookToggleCell } from '../WebhookToggleCell';
|
||||
import type { Repository } from '@/services/repositoryService';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
default: apiMocks,
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Partial<Repository> = {}): Repository {
|
||||
return {
|
||||
name: 'demo-owner/demo-repo',
|
||||
webhook_status: 'inactive',
|
||||
hook_id: null,
|
||||
project_review_prompt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WebhookToggleCell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('toggles webhook via switch to enable', async () => {
|
||||
apiMocks.post.mockResolvedValueOnce({ data: { success: true } });
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<WebhookToggleCell repo={makeRepo()} />);
|
||||
|
||||
const switchEl = screen.getByRole('switch');
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByText('未启用')).toBeInTheDocument();
|
||||
|
||||
await user.click(switchEl);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.post).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook');
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles webhook via switch to disable', async () => {
|
||||
apiMocks.delete.mockResolvedValueOnce({ data: { success: true } });
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<WebhookToggleCell repo={makeRepo({ webhook_status: 'active', hook_id: 123 })} />);
|
||||
|
||||
const switchEl = screen.getByRole('switch');
|
||||
expect(switchEl).toHaveAttribute('aria-checked', 'true');
|
||||
expect(screen.getByText('已启用')).toBeInTheDocument();
|
||||
|
||||
await user.click(switchEl);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.delete).toHaveBeenCalledWith('/repositories/demo-owner/demo-repo/webhook/123');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { Edit2, Trash2, Play, Plus, Activity } from 'lucide-react';
|
||||
import {
|
||||
fetchProviders, updateProvider, deleteProvider, testProvider, fetchRoles
|
||||
} from '@/services/llmProviderService';
|
||||
import { fetchProviders, updateProvider, deleteProvider, testProvider } from '@/services/llmProviderService';
|
||||
import type { ProviderDto, TestResult } from '@/services/llmProviderService';
|
||||
import { ProviderDialog } from './ProviderDialog';
|
||||
import { TestResultDialog } from './TestResultDialog';
|
||||
@@ -43,11 +41,6 @@ export function ProviderList() {
|
||||
queryFn: fetchProviders,
|
||||
});
|
||||
|
||||
const { data: roles = [] } = useQuery({
|
||||
queryKey: ['llm-roles'],
|
||||
queryFn: fetchRoles,
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({ id, isEnabled }: { id: string; isEnabled: boolean }) => {
|
||||
return updateProvider(id, { isEnabled });
|
||||
@@ -74,7 +67,6 @@ export function ProviderList() {
|
||||
onSuccess: () => {
|
||||
toast.success('已删除提供商');
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
@@ -87,16 +79,8 @@ export function ProviderList() {
|
||||
};
|
||||
|
||||
const handleDelete = (provider: ProviderDto) => {
|
||||
const boundRoles = roles.filter(r => r.providerId === provider.id);
|
||||
if (boundRoles.length > 0) {
|
||||
const roleNames = boundRoles.map(r => r.role).join(', ');
|
||||
if (!window.confirm(`警告:该提供商已绑定到以下角色 (${roleNames})。\n删除后这些角色将失去提供商配置!\n确定要删除吗?`)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!window.confirm(`确定要删除提供商 "${provider.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确定要删除提供商 "${provider.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(provider.id);
|
||||
};
|
||||
|
||||
@@ -1,213 +1,229 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { fetchConfig, updateConfig } from '@/services/configService';
|
||||
import type { ConfigResponse, ConfigFieldDto } from '@/services/configService';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertCircle, Save, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Save, ShieldCheck } from 'lucide-react';
|
||||
import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService';
|
||||
import { ModelCombobox } from './ModelCombobox';
|
||||
|
||||
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
|
||||
planner: { label: '规划器 Planner', desc: '多阶段审查的第一步,负责分析上下文并分配任务' },
|
||||
specialist: { label: '专家 Specialist', desc: '执行深度代码审查的主力模型,专注于发现具体问题' },
|
||||
judge: { label: '评审 Judge', desc: '对专家的建议进行审核、合并和过滤,确保评论质量' },
|
||||
embedding: { label: '嵌入 Embedding', desc: '用于向量化代码和注释,支持语义搜索 (Qdrant)' },
|
||||
};
|
||||
|
||||
const ROLES = ['planner', 'specialist', 'judge', 'embedding'];
|
||||
|
||||
interface RoleState {
|
||||
providerId: string | null;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export function RoleAssignment() {
|
||||
const queryClient = useQueryClient();
|
||||
const [roleStates, setRoleStates] = useState<Record<string, RoleState>>({});
|
||||
const [localValues, setLocalValues] = useState<Record<string, string>>({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
const { data: providers = [] } = useQuery({
|
||||
queryKey: ['llm-providers'],
|
||||
queryFn: fetchProviders,
|
||||
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchConfig,
|
||||
});
|
||||
|
||||
const { data: roles = [], isLoading } = useQuery({
|
||||
queryKey: ['llm-roles'],
|
||||
queryFn: fetchRoles,
|
||||
});
|
||||
const REQUIRED_KEYS = [
|
||||
'AGENT_MAIN_MODEL',
|
||||
'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
'LLM_MAX_CONCURRENT_CALLS',
|
||||
'LLM_RETRY_MAX_ATTEMPTS',
|
||||
'LLM_RETRY_BASE_DELAY_MS',
|
||||
];
|
||||
|
||||
const fieldsMap = useMemo(() => {
|
||||
if (!data) return new Map<string, ConfigFieldDto>();
|
||||
const map = new Map<string, ConfigFieldDto>();
|
||||
data.groups.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
map.set(field.envKey, field);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (roles.length > 0) {
|
||||
const initial: Record<string, RoleState> = {};
|
||||
roles.forEach(role => {
|
||||
initial[role.role] = {
|
||||
providerId: role.providerId,
|
||||
model: role.model || '',
|
||||
};
|
||||
});
|
||||
// Fill missing roles
|
||||
ROLES.forEach(r => {
|
||||
if (!initial[r]) {
|
||||
initial[r] = { providerId: null, model: '' };
|
||||
if (data) {
|
||||
const initialValues: Record<string, string> = {};
|
||||
REQUIRED_KEYS.forEach((key) => {
|
||||
const field = fieldsMap.get(key);
|
||||
if (field) {
|
||||
initialValues[key] = String(field.value ?? field.defaultValue ?? '');
|
||||
} else {
|
||||
initialValues[key] = '';
|
||||
}
|
||||
});
|
||||
setRoleStates(initial);
|
||||
} else if (!isLoading) {
|
||||
const initial: Record<string, RoleState> = {};
|
||||
ROLES.forEach(r => {
|
||||
initial[r] = { providerId: null, model: '' };
|
||||
});
|
||||
setRoleStates(initial);
|
||||
setLocalValues(initialValues);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [roles, isLoading]);
|
||||
}, [data, fieldsMap]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async ({ role, providerId, model }: { role: string; providerId: string | null; model: string | null }) => {
|
||||
return setRole(role, providerId, model);
|
||||
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
|
||||
onSuccess: () => {
|
||||
toast.success('智能体模型设置已保存');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setIsDirty(false);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${ROLE_LABELS[data.role]?.label || data.role} 角色配置已保存`);
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
|
||||
onError: (err: Error) => {
|
||||
toast.error(`保存失败: ${err.message}`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
toast.error(`保存失败: ${err?.response?.data?.error || err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const handleProviderChange = (role: string, providerId: string) => {
|
||||
const provider = providers.find(p => p.id === providerId);
|
||||
setRoleStates(prev => ({
|
||||
...prev,
|
||||
[role]: {
|
||||
providerId,
|
||||
model: provider?.defaultModel || ''
|
||||
}
|
||||
}));
|
||||
const handleFieldChange = (key: string, value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, [key]: value }));
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleModelChange = (role: string, model: string) => {
|
||||
setRoleStates(prev => ({
|
||||
...prev,
|
||||
[role]: { ...prev[role], model }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = (role: string) => {
|
||||
const state = roleStates[role];
|
||||
if (!state.providerId) {
|
||||
return toast.error('请选择提供商');
|
||||
}
|
||||
if (!state.model) {
|
||||
return toast.error('请输入模型名称');
|
||||
}
|
||||
saveMutation.mutate({
|
||||
role,
|
||||
providerId: state.providerId,
|
||||
model: state.model,
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
REQUIRED_KEYS.forEach((key) => {
|
||||
payload[key] = localValues[key] ?? '';
|
||||
});
|
||||
saveMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const enabledProviders = providers.filter(p => p.isEnabled && p.hasKey);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
加载配置中...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content">
|
||||
<div className="theme-error-panel flex items-center gap-3 text-danger">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<div className="font-medium tracking-wide">加载配置失败: {error.message}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const missingKeys = REQUIRED_KEYS.filter((key) => !fieldsMap.has(key));
|
||||
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header flex flex-row items-center justify-between pb-4 space-y-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center border border-warning/20 group-hover:bg-warning/20 transition-all duration-300">
|
||||
<ShieldCheck className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
角色分配
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
为 AI 审查系统的不同角色指定提供商和模型
|
||||
</CardDescription>
|
||||
<CardHeader className="theme-card-header pb-4 flex flex-row items-start justify-between space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
管理智能体运行时的主模型、子模型以及 LLM 调用弹性设置。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
className="theme-interactive-elevate min-w-[100px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存设置
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content space-y-6">
|
||||
{missingKeys.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-warning/10 border border-warning/20 text-warning text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold">部分配置项在系统中不可用:</span>
|
||||
<span className="font-mono text-xs">{missingKeys.join(', ')}</span>。这些设置将无法编辑或保存。
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
<CardContent className="theme-card-content">
|
||||
{isLoading ? (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载角色配置...
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{ROLES.map(role => {
|
||||
const state = roleStates[role] || { providerId: null, model: '' };
|
||||
const isDirty = roles.find(r => r.role === role)?.providerId !== state.providerId ||
|
||||
(roles.find(r => r.role === role)?.model || '') !== state.model;
|
||||
|
||||
return (
|
||||
<div key={role} className="flex flex-col md:flex-row items-start md:items-center gap-4 py-5 px-1 hover:bg-accent/40 transition-colors rounded-lg">
|
||||
<div className="w-full md:w-1/3 space-y-1.5">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{ROLE_LABELS[role]?.label || role}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{ROLE_LABELS[role]?.desc}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{REQUIRED_KEYS.map((key) => {
|
||||
const field = fieldsMap.get(key);
|
||||
const isAvailable = !!field;
|
||||
const label = field?.label || key;
|
||||
const description = field?.description || '系统未提供该配置项的描述。';
|
||||
const type = field?.type === 'number' ? 'number' : 'text';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex flex-col gap-2 p-4 rounded-lg border transition-colors ${
|
||||
isAvailable
|
||||
? 'border-border hover:bg-accent/20'
|
||||
: 'border-dashed border-muted bg-muted/10 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={key} className="text-base font-semibold text-foreground cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
{!isAvailable && (
|
||||
<Badge variant="outline" className="border-danger/30 text-danger bg-danger/5">
|
||||
不可用
|
||||
</Badge>
|
||||
)}
|
||||
{isAvailable && field.source === 'db' && (
|
||||
<Badge className="bg-primary/20 text-primary border-primary/30 tech-glow">
|
||||
已配置
|
||||
</Badge>
|
||||
)}
|
||||
{isAvailable && field.source === 'default' && (
|
||||
<Badge variant="outline" className="border-border text-muted-foreground">
|
||||
默认值
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</span>
|
||||
<div className="pt-1">
|
||||
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-2/3 flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">提供商</Label>
|
||||
<Select
|
||||
value={state.providerId || ''}
|
||||
onValueChange={(v) => handleProviderChange(role, v)}
|
||||
>
|
||||
<SelectTrigger className="bg-muted/50 border-border text-foreground">
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-popover border-border text-foreground">
|
||||
{enabledProviders.map(p => (
|
||||
<SelectItem key={p.id} value={p.id} description={p.type} className="focus:bg-accent focus:text-primary">
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{enabledProviders.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-danger text-center border-t border-border/60">
|
||||
无可用提供商。请先添加并启用。
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">使用的模型</Label>
|
||||
<ModelCombobox
|
||||
providerType={providers.find(p => p.id === state.providerId)?.type}
|
||||
value={state.model}
|
||||
onChange={(model) => handleModelChange(role, model)}
|
||||
placeholder="选择或输入模型..."
|
||||
disabled={!state.providerId}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-5 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSave(role)}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
variant={isDirty ? 'default' : 'secondary'}
|
||||
className={`transition-all ${isDirty ? 'bg-warning/15 text-warning border border-warning/30 hover:bg-warning/25' : 'bg-muted/50 text-muted-foreground border border-transparent'}`}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
{isDirty ? '保存更改' : '已保存'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 w-full max-w-xl flex flex-col gap-2">
|
||||
<Input
|
||||
id={key}
|
||||
type={type}
|
||||
value={localValues[key] ?? ''}
|
||||
onChange={(e) => handleFieldChange(key, e.target.value)}
|
||||
disabled={!isAvailable || saveMutation.isPending}
|
||||
placeholder={!isAvailable ? '配置项不可用' : `请输入 ${label}...`}
|
||||
className="bg-muted/50 border-border focus-visible:ring-primary focus-visible:border-primary transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import {
|
||||
fetchProviders,
|
||||
fetchRoles,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
testProvider,
|
||||
@@ -20,7 +19,6 @@ vi.mock('sonner', () => ({
|
||||
|
||||
vi.mock('@/services/llmProviderService', () => ({
|
||||
fetchProviders: vi.fn(),
|
||||
fetchRoles: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
@@ -62,7 +60,6 @@ describe('ProviderList', () => {
|
||||
createdAt: '2026-01-01',
|
||||
},
|
||||
]);
|
||||
vi.mocked(fetchRoles).mockResolvedValueOnce([]);
|
||||
vi.mocked(updateProvider).mockResolvedValue({} as never);
|
||||
vi.mocked(deleteProvider).mockResolvedValue(undefined);
|
||||
vi.mocked(testProvider).mockResolvedValue({ success: true });
|
||||
|
||||
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RoleAssignment } from '../RoleAssignment';
|
||||
import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService';
|
||||
import { fetchConfig, updateConfig, type ConfigResponse } from '@/services/configService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
@@ -13,21 +13,10 @@ vi.mock('sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/llmProviderService', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/services/llmProviderService')>('@/services/llmProviderService');
|
||||
return {
|
||||
...actual,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchRoles: vi.fn(),
|
||||
setRole: vi.fn(),
|
||||
fetchModelSuggestions: vi.fn().mockResolvedValue({
|
||||
openai_compatible: ['gpt-4o', 'gpt-4o-mini'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini'],
|
||||
anthropic: ['claude-sonnet-4-20250514'],
|
||||
gemini: ['gemini-2.5-pro'],
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@/services/configService', () => ({
|
||||
fetchConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
@@ -39,60 +28,163 @@ function renderWithQuery(ui: ReactNode) {
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeConfigResponse(): ConfigResponse {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM 设置',
|
||||
description: 'LLM 运行时与弹性设置',
|
||||
icon: 'brain',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
label: '主智能体模型',
|
||||
description: '主智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
label: '默认子智能体模型',
|
||||
description: '子智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o-mini',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
label: 'LLM 最大并发调用',
|
||||
description: '同时在飞的 LLM API 调用上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '4',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_RETRY_MAX_ATTEMPTS',
|
||||
label: 'LLM 最大重试次数',
|
||||
description: 'LLM 调用失败时的最大重试次数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '3',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_RETRY_BASE_DELAY_MS',
|
||||
label: 'LLM 重试基础延迟(ms)',
|
||||
description: 'LLM 调用失败重试的基础延迟时间',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '1000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('RoleAssignment', () => {
|
||||
it('renders role cards and supports provider/model editing', async () => {
|
||||
vi.mocked(fetchProviders).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'OpenAI',
|
||||
type: 'openai_responses',
|
||||
baseUrl: null,
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
isEnabled: true,
|
||||
hasKey: true,
|
||||
extraConfig: {},
|
||||
createdAt: '2026-01-01',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(fetchRoles).mockResolvedValueOnce([
|
||||
{
|
||||
role: 'planner',
|
||||
providerId: 'p1',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(setRole).mockResolvedValue({
|
||||
role: 'planner',
|
||||
providerId: 'p1',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'custom-planner-model',
|
||||
});
|
||||
it('renders agent model settings and saves edits', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
|
||||
vi.mocked(updateConfig).mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<RoleAssignment />);
|
||||
|
||||
expect(await screen.findByText('角色分配')).toBeInTheDocument();
|
||||
expect(await screen.findByText('规划器 Planner')).toBeInTheDocument();
|
||||
// Wait for the fields to load and render
|
||||
expect(await screen.findByText('主智能体模型')).toBeInTheDocument();
|
||||
expect(screen.getByText('智能体模型设置')).toBeInTheDocument();
|
||||
expect(screen.getByText('默认子智能体模型')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 最大并发调用')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 最大重试次数')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 重试基础延迟(ms)')).toBeInTheDocument();
|
||||
|
||||
// Radix Select renders placeholder in a span with pointer-events: none.
|
||||
// Click the trigger button (parent) instead of the placeholder text.
|
||||
const providerPlaceholders = screen.getAllByText('选择提供商');
|
||||
const triggerButton = providerPlaceholders[0].closest('button')!;
|
||||
await user.click(triggerButton);
|
||||
await user.click(await screen.findByRole('option', { name: /OpenAI/ }));
|
||||
const legacyLabels = ['pla' + 'nner', 'speci' + 'alist', 'ju' + 'dge', '角色' + '分配'];
|
||||
legacyLabels.forEach((label) => {
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(['/', 'llm', '/', 'roles'].join(''))).not.toBeInTheDocument();
|
||||
|
||||
const modelInputs = screen.getAllByPlaceholderText('选择或输入模型...') as HTMLInputElement[];
|
||||
await waitFor(() => {
|
||||
expect(modelInputs[0].value).toBe('gpt-4o');
|
||||
const mainModelInput = screen.getByLabelText('主智能体模型');
|
||||
const subagentModelInput = screen.getByLabelText('默认子智能体模型');
|
||||
const maxCallsInput = screen.getByLabelText('LLM 最大并发调用');
|
||||
const retryAttemptsInput = screen.getByLabelText('LLM 最大重试次数');
|
||||
const retryDelayInput = screen.getByLabelText('LLM 重试基础延迟(ms)');
|
||||
|
||||
await user.clear(mainModelInput);
|
||||
await user.type(mainModelInput, 'claude-3-5-sonnet');
|
||||
|
||||
await user.clear(subagentModelInput);
|
||||
await user.type(subagentModelInput, 'claude-3-5-haiku');
|
||||
|
||||
await user.clear(maxCallsInput);
|
||||
await user.type(maxCallsInput, '8');
|
||||
|
||||
await user.clear(retryAttemptsInput);
|
||||
await user.type(retryAttemptsInput, '5');
|
||||
|
||||
await user.clear(retryDelayInput);
|
||||
await user.type(retryDelayInput, '2000');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: '保存设置' });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(updateConfig).toHaveBeenCalledTimes(1));
|
||||
const payload = vi.mocked(updateConfig).mock.calls[0][0];
|
||||
expect(payload).toEqual({
|
||||
AGENT_MAIN_MODEL: 'claude-3-5-sonnet',
|
||||
AGENT_DEFAULT_SUBAGENT_MODEL: 'claude-3-5-haiku',
|
||||
LLM_MAX_CONCURRENT_CALLS: '8',
|
||||
LLM_RETRY_MAX_ATTEMPTS: '5',
|
||||
LLM_RETRY_BASE_DELAY_MS: '2000',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders missing-field/unavailable state when fields are missing', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue({
|
||||
groups: [
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM 设置',
|
||||
description: 'LLM 运行时与弹性设置',
|
||||
icon: 'brain',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
label: '主智能体模型',
|
||||
description: '主智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await user.clear(modelInputs[0]);
|
||||
await user.type(modelInputs[0], 'custom-planner-model');
|
||||
expect(modelInputs[0].value).toBe('custom-planner-model');
|
||||
renderWithQuery(<RoleAssignment />);
|
||||
|
||||
// Wait for the warning to load and render
|
||||
expect(await screen.findByText('部分配置项在系统中不可用:')).toBeInTheDocument();
|
||||
expect(screen.getByText('智能体模型设置')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('AGENT_DEFAULT_SUBAGENT_MODEL')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_MAX_CONCURRENT_CALLS')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_RETRY_MAX_ATTEMPTS')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_RETRY_BASE_DELAY_MS')).toBeInTheDocument();
|
||||
|
||||
const subagentInput = screen.getByLabelText('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
expect(subagentInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
122
frontend/src/components/ui/dialog.tsx
Normal file
122
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
200
frontend/src/components/ui/dropdown-menu.tsx
Normal file
200
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette } from 'lucide-react';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette, Layers } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
||||
@@ -9,7 +9,9 @@ import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
||||
const navItems = [
|
||||
{ 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() {
|
||||
@@ -31,6 +33,7 @@ export default function DashboardPage() {
|
||||
|
||||
const currentTitle = navItems.find(item => location.pathname.startsWith(item.path))?.label || 'Dashboard';
|
||||
const isConfigPage = location.pathname.startsWith('/config');
|
||||
const isNotificationPage = location.pathname.startsWith('/notifications');
|
||||
const isReviewConfigPage = location.pathname.startsWith('/review-config');
|
||||
|
||||
return (
|
||||
@@ -205,7 +208,7 @@ export default function DashboardPage() {
|
||||
<main className="flex-1 overflow-y-auto relative">
|
||||
<div className="absolute inset-0 bg-background/95 backdrop-blur-[1px] -z-10"></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.025] -z-10"></div>
|
||||
<div className={`w-full animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isReviewConfigPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
|
||||
<div className={`w-full animate-in fade-in slide-in-from-bottom-4 duration-500 ${(isConfigPage || isNotificationPage || isReviewConfigPage) ? '' : 'p-4 md:p-6 lg:p-8'}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
797
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
797
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
|
||||
import type { AgentSessionTree } from '@/services/reviewSessionService';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Bot, Cpu, Terminal, CheckCircle2, AlertCircle,
|
||||
ChevronRight, ChevronDown, Clock, FileText, Layers,
|
||||
AlertTriangle, CornerDownRight, HelpCircle, Info
|
||||
} from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper Components & Formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
case 'completed':
|
||||
return <Badge className="bg-success/20 text-success border-success/30">成功</Badge>;
|
||||
case 'failed':
|
||||
return <Badge className="bg-danger/20 text-danger border-danger/30">失败</Badge>;
|
||||
case 'running':
|
||||
case 'in_progress':
|
||||
return <Badge className="bg-primary/20 text-primary border-primary/30 animate-pulse">运行中</Badge>;
|
||||
case 'queued':
|
||||
return <Badge className="bg-warning/20 text-warning border-warning/30">排队中</Badge>;
|
||||
case 'ignored':
|
||||
return <Badge className="bg-muted text-muted-foreground border-border">已忽略</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: 'high' | 'medium' | 'low' }) {
|
||||
switch (severity) {
|
||||
case 'high':
|
||||
return <Badge className="bg-danger/20 text-danger border-danger/30 font-bold">高</Badge>;
|
||||
case 'medium':
|
||||
return <Badge className="bg-warning/20 text-warning border-warning/30 font-bold">中</Badge>;
|
||||
case 'low':
|
||||
return <Badge className="bg-info/20 text-info border-info/30 font-bold">低</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{severity}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(isoString?: string): string {
|
||||
if (!isoString) return '-';
|
||||
return new Date(isoString).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Session Tree Node Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TreeNodeProps {
|
||||
session: AgentSessionTree;
|
||||
level: number;
|
||||
onSelectSession: (session: AgentSessionTree) => void;
|
||||
selectedSessionId?: string;
|
||||
}
|
||||
|
||||
function AgentTreeNode({ session, level, onSelectSession, selectedSessionId }: TreeNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const hasChildren = session.invocations && session.invocations.some(inv => inv.childSession);
|
||||
const isSelected = selectedSessionId === session.id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{/* Node Row */}
|
||||
<div
|
||||
onClick={() => onSelectSession(session)}
|
||||
className={`flex items-center justify-between p-3 rounded-xl border transition-all duration-200 cursor-pointer mb-2 ${
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/10 theme-glow-primary'
|
||||
: 'border-border/60 bg-muted/30 hover:bg-accent/40 hover:border-border'
|
||||
}`}
|
||||
style={{ marginLeft: `${level * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
{level > 0 && <CornerDownRight className="w-4 h-4 text-muted-foreground/50" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-2 rounded-lg ${level === 0 ? 'bg-primary/10 text-primary' : 'bg-info/10 text-info'}`}>
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm text-foreground truncate">
|
||||
{level === 0 ? '主代理' : '子代理'}: {session.agentType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{session.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status={session.status} />
|
||||
{session.error && (
|
||||
<div title="代理执行出错">
|
||||
<AlertTriangle className="w-4 h-4 text-danger animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && session.invocations && (
|
||||
<div className="flex flex-col w-full">
|
||||
{session.invocations.map((inv) => {
|
||||
if (inv.childSession) {
|
||||
return (
|
||||
<AgentTreeNode
|
||||
key={inv.childSession.id}
|
||||
session={inv.childSession}
|
||||
level={level + 1}
|
||||
onSelectSession={onSelectSession}
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
);
|
||||
} else if (inv.status === 'failed') {
|
||||
// Failed subagent invocation without child session
|
||||
return (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-danger/30 bg-danger/5 mb-2"
|
||||
style={{ marginLeft: `${(level + 1) * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<CornerDownRight className="w-4 h-4 text-danger/50" />
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-danger/10 text-danger">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm text-danger truncate">
|
||||
子代理启动失败: {inv.agentType}
|
||||
</span>
|
||||
<span className="text-xs text-danger/80 font-mono truncate">
|
||||
{inv.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status="failed" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Session Detail Panel Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DetailPanelProps {
|
||||
session: AgentSessionTree;
|
||||
}
|
||||
|
||||
function AgentDetailPanel({ session }: DetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<'messages' | 'tools' | 'raw'>('messages');
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-muted/10 h-full flex flex-col">
|
||||
<CardHeader className="border-b border-border/50 pb-4 shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" />
|
||||
{session.parentSessionId ? '子代理详情' : '主代理详情'}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-mono text-xs text-muted-foreground break-all">
|
||||
ID: {session.id}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<StatusBadge status={session.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">代理类型</span>
|
||||
<span className="font-semibold text-foreground">{session.agentType}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">运行模型</span>
|
||||
<span className="font-mono font-semibold text-foreground">{session.model}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">启动时间</span>
|
||||
<span className="text-foreground">{formatDateTime(session.startedAt)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">结束时间</span>
|
||||
<span className="text-foreground">{formatDateTime(session.completedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.error && (
|
||||
<div className="mt-4 p-3 rounded-lg border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold">执行错误</div>
|
||||
<pre className="mt-1 font-mono text-xs whitespace-pre-wrap break-all">
|
||||
{typeof session.error === 'object' ? JSON.stringify(session.error, null, 2) : String(session.error)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
|
||||
<div className="border-b border-border/50 px-4 py-2 bg-muted/30 shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={activeTab === 'messages' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('messages')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 mr-1.5" />
|
||||
消息记录 ({session.messages?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'tools' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('tools')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<Terminal className="w-3.5 h-3.5 mr-1.5" />
|
||||
工具调用 ({session.toolCalls?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'raw' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('raw')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<Info className="w-3.5 h-3.5 mr-1.5" />
|
||||
元数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{activeTab === 'messages' && (
|
||||
<div className="space-y-4">
|
||||
{session.messages && session.messages.length > 0 ? (
|
||||
session.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex flex-col p-3 rounded-xl border ${
|
||||
msg.role === 'user'
|
||||
? 'border-primary/20 bg-primary/5 ml-8'
|
||||
: msg.role === 'assistant'
|
||||
? 'border-border bg-muted/40 mr-8'
|
||||
: 'border-warning/20 bg-warning/5 mx-4'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${
|
||||
msg.role === 'user' ? 'text-primary' : msg.role === 'assistant' ? 'text-foreground' : 'text-warning'
|
||||
}`}>
|
||||
{msg.role}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatDateTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap break-all font-sans leading-relaxed">
|
||||
{typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: typeof msg.content === 'object' && msg.content !== null && 'text' in msg.content
|
||||
? String(msg.content.text)
|
||||
: JSON.stringify(msg.content, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无消息记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tools' && (
|
||||
<div className="space-y-4">
|
||||
{session.toolCalls && session.toolCalls.length > 0 ? (
|
||||
session.toolCalls.map((tool) => (
|
||||
<div key={tool.id} className="border border-border/60 rounded-xl overflow-hidden bg-muted/20">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-3.5 h-3.5 text-primary" />
|
||||
<span className="font-mono text-sm font-bold text-foreground">{tool.toolName}</span>
|
||||
</div>
|
||||
<StatusBadge status={tool.status} />
|
||||
</div>
|
||||
<div className="p-3 space-y-3 text-xs font-mono">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">参数 (Arguments)</div>
|
||||
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(tool.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{tool.result !== undefined && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">结果 (Result)</div>
|
||||
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
|
||||
{typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{tool.error && (
|
||||
<div>
|
||||
<div className="text-danger mb-1">错误 (Error)</div>
|
||||
<pre className="p-2 rounded-lg bg-danger/5 border border-danger/20 text-danger overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{typeof tool.error === 'string' ? tool.error : JSON.stringify(tool.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无工具调用记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'raw' && (
|
||||
<div className="space-y-4 font-mono text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">元数据 (Metadata)</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(session.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{session.finalResult !== undefined && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">最终结果 (Final Result)</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(session.finalResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ReviewSessionsPage() {
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [selectedSession, setSelectedSession] = useState<AgentSessionTree | null>(null);
|
||||
|
||||
// Fetch runs list
|
||||
const { data: runsData, isLoading: isListLoading, isError: isListError, error: listError } = useQuery({
|
||||
queryKey: ['reviewRuns'],
|
||||
queryFn: () => fetchReviewRuns(50),
|
||||
});
|
||||
|
||||
// Fetch selected run details
|
||||
const { data: runDetails, isLoading: isDetailsLoading, isError: isDetailsError, error: detailsError } = useQuery({
|
||||
queryKey: ['reviewRunDetails', selectedRunId],
|
||||
queryFn: () => fetchReviewRunDetails(selectedRunId!),
|
||||
enabled: !!selectedRunId,
|
||||
});
|
||||
|
||||
const runs = runsData?.data ?? [];
|
||||
|
||||
// Handle run selection
|
||||
const handleSelectRun = (runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
setSelectedSession(null); // Reset selected session when switching runs
|
||||
};
|
||||
|
||||
// Automatically select first run if none selected
|
||||
if (!selectedRunId && runs.length > 0) {
|
||||
setSelectedRunId(runs[0].id);
|
||||
}
|
||||
|
||||
// Automatically select root session when run details load
|
||||
if (runDetails?.sessionTree && !selectedSession) {
|
||||
setSelectedSession(runDetails.sessionTree);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Sidebar: Runs List */}
|
||||
<aside className="w-80 border-r border-border/50 flex flex-col bg-muted/10 shrink-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-border/50 shrink-0">
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
审查任务列表
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">展示最近 50 次自动审查任务</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{isListLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-border/40 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4 bg-muted/60" />
|
||||
<Skeleton className="h-3 w-1/2 bg-muted/60" />
|
||||
</div>
|
||||
))
|
||||
) : isListError ? (
|
||||
<div className="theme-error-panel flex items-center gap-2 p-4">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<span className="text-sm font-medium">加载列表失败: {listError.message}</span>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
暂无审查任务记录
|
||||
</div>
|
||||
) : (
|
||||
runs.map((run) => {
|
||||
const isSelected = selectedRunId === run.id;
|
||||
return (
|
||||
<div
|
||||
key={run.id}
|
||||
onClick={() => handleSelectRun(run.id)}
|
||||
className={`p-3.5 rounded-xl border transition-all duration-200 cursor-pointer flex flex-col gap-2 ${
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/5 theme-glow-primary'
|
||||
: 'border-transparent hover:bg-accent/40 hover:border-border/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-bold text-sm text-foreground truncate flex-1">
|
||||
{run.owner}/{run.repo}
|
||||
</span>
|
||||
<StatusBadge status={run.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-border/60">
|
||||
{run.eventType === 'pull_request' ? `PR #${run.prNumber}` : 'Commit'}
|
||||
</Badge>
|
||||
<span className="truncate font-mono text-[10px]">
|
||||
{run.commitSha?.substring(0, 7) || run.headSha?.substring(0, 7) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground pt-1 border-t border-border/30">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(run.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<span>尝试: {run.attempts}/{run.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right Content: Run Details */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
{selectedRunId ? (
|
||||
isDetailsLoading ? (
|
||||
<div className="flex-1 p-6 space-y-6 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-1/3 bg-muted/60" />
|
||||
<Skeleton className="h-4 w-1/4 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
) : isDetailsError ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="theme-error-panel flex items-center gap-3 max-w-md">
|
||||
<AlertCircle className="w-6 h-6 text-danger shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold text-foreground">加载详情失败</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{detailsError.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !runDetails ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
未找到任务详情
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
<header className="p-6 border-b border-border/50 shrink-0 bg-muted/5">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-foreground tracking-tight">
|
||||
{runDetails.run.owner}/{runDetails.run.repo}
|
||||
</h1>
|
||||
<StatusBadge status={runDetails.run.status} />
|
||||
<Badge variant="outline" className="border-border/60">
|
||||
{runDetails.run.eventType === 'pull_request' ? `PR #${runDetails.run.prNumber}` : 'Commit'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
任务 ID: {runDetails.run.id} | Commit: {runDetails.run.commitSha || runDetails.run.headSha || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">创建时间</span>
|
||||
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.createdAt)}</span>
|
||||
</div>
|
||||
{runDetails.run.finishedAt && (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">完成时间</span>
|
||||
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.finishedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runDetails.run.error && (
|
||||
<div className="mt-4 p-3 rounded-xl border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-semibold">任务执行失败:</span> {runDetails.run.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Detail Tabs */}
|
||||
<Tabs defaultValue="observability" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-6 border-b border-border/50 bg-muted/5 shrink-0">
|
||||
<TabsList className="h-12 bg-transparent p-0 gap-6 border-b-0">
|
||||
<TabsTrigger
|
||||
value="observability"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
代理观测 (Observability)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="findings"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
审查结果 ({runDetails.findings?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="log"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
运行日志 ({runDetails.steps?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Tab Content: Observability */}
|
||||
<TabsContent value="observability" className="flex-1 overflow-hidden p-6 m-0 flex flex-col md:flex-row gap-6">
|
||||
{runDetails.sessionTree ? (
|
||||
<>
|
||||
{/* Left: Session Tree */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto pr-2">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
代理调用树 (Parent-Child Tree)
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<AgentTreeNode
|
||||
session={runDetails.sessionTree}
|
||||
level={0}
|
||||
onSelectSession={(session) => setSelectedSession(session)}
|
||||
selectedSessionId={selectedSession?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Selected Session Detail */}
|
||||
<div className="flex-1 h-full overflow-hidden">
|
||||
{selectedSession ? (
|
||||
<AgentDetailPanel session={selectedSession} />
|
||||
) : (
|
||||
<div className="h-full border border-dashed border-border/60 rounded-xl flex flex-col items-center justify-center text-muted-foreground p-6">
|
||||
<Bot className="w-12 h-12 text-muted-foreground/40 mb-3 animate-pulse" />
|
||||
<p className="text-sm font-medium">请在左侧选择一个代理节点查看详细调用轨迹</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground border border-dashed border-border/60 rounded-xl p-12">
|
||||
<HelpCircle className="w-12 h-12 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm font-medium">本次审查任务未使用 Agent 引擎,或暂无代理调用轨迹数据</p>
|
||||
<p className="text-xs text-muted-foreground/80 mt-1">请确保系统配置中已启用 Agent 审查引擎</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Content: Findings */}
|
||||
<TabsContent value="findings" className="flex-1 overflow-y-auto p-6 m-0 space-y-4">
|
||||
{runDetails.findings && runDetails.findings.length > 0 ? (
|
||||
runDetails.findings.map((finding) => (
|
||||
<Card key={finding.id} className="border-border/60 hover:border-border transition-all duration-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
<Badge variant="outline" className="bg-muted/50 border-border/60 text-xs">
|
||||
{finding.category}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{finding.path}:{finding.line}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-base font-bold text-foreground tracking-tight">
|
||||
{finding.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 flex items-center gap-1">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
置信度: {(finding.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<div className="font-semibold text-foreground mb-1">详细描述</div>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.detail}</p>
|
||||
</div>
|
||||
{finding.evidence && (
|
||||
<div>
|
||||
<div className="font-semibold text-foreground mb-1">代码证据</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{finding.evidence}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{finding.suggestion && (
|
||||
<div className="p-3.5 rounded-xl border border-success/20 bg-success/5">
|
||||
<div className="font-semibold text-success flex items-center gap-1.5 mb-1">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
修改建议
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.suggestion}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
|
||||
本次审查未发现任何问题
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Content: Run Log */}
|
||||
<TabsContent value="log" className="flex-1 overflow-y-auto p-6 m-0 space-y-6">
|
||||
{/* Steps */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
执行步骤 (Steps)
|
||||
</h3>
|
||||
<div className="border border-border/60 rounded-xl overflow-hidden bg-muted/10">
|
||||
<table className="w-full text-left border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border/50 text-muted-foreground font-semibold">
|
||||
<th className="p-3">步骤名称</th>
|
||||
<th className="p-3">状态</th>
|
||||
<th className="p-3">耗时</th>
|
||||
<th className="p-3">开始时间</th>
|
||||
<th className="p-3">结束时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{runDetails.steps && runDetails.steps.length > 0 ? (
|
||||
runDetails.steps.map((step) => (
|
||||
<tr key={step.id} className="hover:bg-accent/20 transition-colors">
|
||||
<td className="p-3 font-medium text-foreground">{step.stepName}</td>
|
||||
<td className="p-3">
|
||||
<StatusBadge status={step.status} />
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{step.latencyMs ? `${(step.latencyMs / 1000).toFixed(2)}s` : '-'}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.startedAt)}</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.finishedAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
暂无步骤记录
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
评论记录 (Comments)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{runDetails.comments && runDetails.comments.length > 0 ? (
|
||||
runDetails.comments.map((comment) => (
|
||||
<div key={comment.id} className="p-4 rounded-xl border border-border/60 bg-muted/20 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
{comment.path && (
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{comment.path}:{comment.line}
|
||||
</span>
|
||||
)}
|
||||
{comment.giteaCommentId && (
|
||||
<Badge variant="outline" className="text-[10px] border-border/60">
|
||||
Gitea ID: {comment.giteaCommentId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={comment.status} />
|
||||
<span className="text-muted-foreground">{formatDateTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap break-all leading-relaxed">
|
||||
{comment.body}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
|
||||
暂无评论记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-6">
|
||||
<Bot className="w-16 h-16 text-muted-foreground/30 mb-4 animate-pulse" />
|
||||
<h3 className="text-lg font-bold text-foreground">请选择一个审查任务</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">在左侧列表中选择一个任务以查看其详细的代理调用轨迹和审查结果</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
frontend/src/pages/__tests__/ReviewSessionsPage.test.tsx
Normal file
312
frontend/src/pages/__tests__/ReviewSessionsPage.test.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ReviewSessionsPage from '../ReviewSessionsPage';
|
||||
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
|
||||
|
||||
vi.mock('@/services/reviewSessionService', () => ({
|
||||
fetchReviewRuns: vi.fn(),
|
||||
fetchReviewRunDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('ReviewSessionsPage', () => {
|
||||
it('Scenario 1: renders main agent plus two subagents with statuses, tool counts, and model info', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-1',
|
||||
idempotencyKey: 'key-1',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'succeeded' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 42,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
sessionId: 'session-main',
|
||||
sequence: 1,
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
metadata: {},
|
||||
createdAt: '2026-05-25T00:00:05.000Z',
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
sessionId: 'session-main',
|
||||
sequence: 1,
|
||||
toolName: 'search_code',
|
||||
status: 'completed',
|
||||
arguments: {},
|
||||
createdAt: '2026-05-25T00:00:10.000Z',
|
||||
},
|
||||
],
|
||||
invocations: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
parentSessionId: 'session-main',
|
||||
childSessionId: 'session-sub-1',
|
||||
sequence: 1,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'completed',
|
||||
input: {},
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
childSession: {
|
||||
id: 'session-sub-1',
|
||||
parentSessionId: 'session-main',
|
||||
parentInvocationId: 'inv-1',
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:15.000Z',
|
||||
completedAt: '2026-05-25T00:00:30.000Z',
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
updatedAt: '2026-05-25T00:00:30.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inv-2',
|
||||
parentSessionId: 'session-main',
|
||||
childSessionId: 'session-sub-2',
|
||||
sequence: 2,
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
status: 'completed',
|
||||
input: {},
|
||||
createdAt: '2026-05-25T00:00:35.000Z',
|
||||
childSession: {
|
||||
id: 'session-sub-2',
|
||||
parentSessionId: 'session-main',
|
||||
parentInvocationId: 'inv-2',
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:35.000Z',
|
||||
completedAt: '2026-05-25T00:00:50.000Z',
|
||||
createdAt: '2026-05-25T00:00:35.000Z',
|
||||
updatedAt: '2026-05-25T00:00:50.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
// Wait for details to load and render
|
||||
const mainAgentText = await screen.findByText('主代理: review-main-agent');
|
||||
expect(mainAgentText).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByText('gpt-main').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assert subagents are rendered
|
||||
expect(screen.getByText('子代理: security-reviewer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-sub-a').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('子代理: quality-reviewer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-sub-b').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assert tool calls count is visible in the details panel tabs
|
||||
expect(screen.getByText('工具调用 (1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Scenario 2: renders failed subagent invocation and findings correctly', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-2',
|
||||
idempotencyKey: 'key-2',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'failed' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 43,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
runId: 'run-2',
|
||||
fingerprint: 'fp-1',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'src/db.ts',
|
||||
line: 10,
|
||||
title: 'SQL Injection vulnerability',
|
||||
detail: 'Direct string concatenation in query',
|
||||
evidence: 'db.query("SELECT * FROM users WHERE id = " + id)',
|
||||
suggestion: 'Use parameterized queries',
|
||||
published: false,
|
||||
},
|
||||
],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main-2',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'failed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [
|
||||
{
|
||||
id: 'inv-failed',
|
||||
parentSessionId: 'session-main-2',
|
||||
sequence: 1,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'failed',
|
||||
input: {},
|
||||
error: 'Failed to initialize subagent',
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
// Wait for details to load and render
|
||||
const failedSubagentText = await screen.findByText('子代理启动失败: security-reviewer');
|
||||
expect(failedSubagentText).toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Switch to findings tab
|
||||
const findingsTab = screen.getByText('审查结果 (1)');
|
||||
expect(findingsTab).toBeInTheDocument();
|
||||
await user.click(findingsTab);
|
||||
|
||||
// Assert finding title still renders
|
||||
const findingTitle = await screen.findByText('SQL Injection vulnerability');
|
||||
expect(findingTitle).toBeInTheDocument();
|
||||
expect(screen.getByText('Direct string concatenation in query')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Scenario 3: asserts no legacy review labels are visible', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-3',
|
||||
idempotencyKey: 'key-3',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'succeeded' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 44,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main-3',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-owner/test-repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const legacyLabels = ['tri' + 'age', 'speci' + 'alist', 'ju' + 'dge', 'pla' + 'nner'];
|
||||
legacyLabels.forEach((label) => {
|
||||
expect(screen.queryByText(label)).toBeNull();
|
||||
});
|
||||
expect(screen.queryByText('分流')).toBeNull();
|
||||
expect(screen.queryByText('专家')).toBeNull();
|
||||
expect(screen.queryByText('裁判')).toBeNull();
|
||||
expect(screen.queryByText('规划')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,21 @@ export interface ConfigResponse {
|
||||
groups: ConfigGroupDto[];
|
||||
}
|
||||
|
||||
export type NotificationTestProvider = 'feishu' | 'wecom';
|
||||
export type NotificationTestStatus = 'success' | 'error';
|
||||
|
||||
export interface NotificationTestRecordDto {
|
||||
id: string;
|
||||
provider: string;
|
||||
status: NotificationTestStatus;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface NotificationTestHistoryResponse {
|
||||
data: NotificationTestRecordDto[];
|
||||
}
|
||||
|
||||
export const fetchConfig = async (): Promise<ConfigResponse> => {
|
||||
const response = await api.get<ConfigResponse>('/config');
|
||||
return response.data;
|
||||
@@ -42,3 +57,12 @@ export const updateConfig = async (configData: Record<string, string>): Promise<
|
||||
export const resetConfig = async (keys: string[]): Promise<void> => {
|
||||
await api.post('/config/reset', { keys });
|
||||
};
|
||||
|
||||
export const testNotification = async (provider: NotificationTestProvider): Promise<void> => {
|
||||
await api.post('/config/notification/test', { provider });
|
||||
};
|
||||
|
||||
export const fetchNotificationTestHistory = async (): Promise<NotificationTestRecordDto[]> => {
|
||||
const response = await api.get<NotificationTestHistoryResponse>('/config/notification/test/history');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
@@ -15,14 +15,6 @@ export interface ProviderDto {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RoleAssignmentDto {
|
||||
role: string;
|
||||
providerId: string | null;
|
||||
providerName: string | null;
|
||||
providerType: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
@@ -75,16 +67,6 @@ export const deleteApiKey = async (id: string): Promise<void> => {
|
||||
await api.delete(`/llm/providers/${id}/key`);
|
||||
};
|
||||
|
||||
export const fetchRoles = async (): Promise<RoleAssignmentDto[]> => {
|
||||
const response = await api.get<RoleAssignmentDto[]>('/llm/roles');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const setRole = async (role: string, providerId: string | null, model: string | null): Promise<RoleAssignmentDto> => {
|
||||
const response = await api.put<RoleAssignmentDto>(`/llm/roles/${role}`, { providerId, model });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const testProvider = async (id: string): Promise<TestResult> => {
|
||||
const response = await api.post<TestResult>(`/llm/providers/${id}/test`);
|
||||
return response.data;
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface Repository {
|
||||
name: string;
|
||||
webhook_status: 'active' | 'inactive';
|
||||
hook_id: number | null;
|
||||
project_review_prompt: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedRepositories {
|
||||
@@ -19,3 +20,13 @@ export const fetchRepositories = async (page: number = 1, query: string = ""): P
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateRepositoryProjectPrompt = async (
|
||||
repoName: string,
|
||||
projectReviewPrompt: string
|
||||
): Promise<{ success: boolean; project_review_prompt: string | null }> => {
|
||||
const { data } = await api.put(`/repositories/${repoName}/project-prompt`, {
|
||||
project_review_prompt: projectReviewPrompt,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
147
frontend/src/services/reviewSessionService.ts
Normal file
147
frontend/src/services/reviewSessionService.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
export type ReviewRunStatus = 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'ignored';
|
||||
|
||||
export interface ReviewRun {
|
||||
id: string;
|
||||
idempotencyKey: string;
|
||||
eventType: 'pull_request' | 'commit_status';
|
||||
status: ReviewRunStatus;
|
||||
owner: string;
|
||||
repo: string;
|
||||
cloneUrl: string;
|
||||
headCloneUrl?: string;
|
||||
prNumber?: number;
|
||||
relatedPrNumber?: number;
|
||||
baseSha?: string;
|
||||
headSha?: string;
|
||||
commitSha?: string;
|
||||
commitMessage?: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ReviewStep {
|
||||
id: string;
|
||||
runId: string;
|
||||
stepName: string;
|
||||
agentName?: string;
|
||||
status: 'started' | 'succeeded' | 'failed';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
latencyMs?: number;
|
||||
inputRef?: string;
|
||||
outputRef?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
id: string;
|
||||
runId: string;
|
||||
fingerprint: string;
|
||||
category: 'correctness' | 'security' | 'reliability' | 'maintainability';
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
confidence: number;
|
||||
path: string;
|
||||
line: number;
|
||||
title: string;
|
||||
detail: string;
|
||||
evidence: string;
|
||||
suggestion: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewCommentRecord {
|
||||
id: string;
|
||||
runId: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
body: string;
|
||||
giteaCommentId?: number;
|
||||
status: 'pending' | 'published' | 'failed';
|
||||
createdAt: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content: any;
|
||||
metadata: Record<string, any>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AgentToolCallRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
sequence: number;
|
||||
toolName: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
arguments: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentInvocationRecord {
|
||||
id: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
sequence: number;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
input: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionTree {
|
||||
id: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
metadata: Record<string, any>;
|
||||
finalResult?: any;
|
||||
error?: any;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: AgentMessageRecord[];
|
||||
toolCalls: AgentToolCallRecord[];
|
||||
invocations: Array<AgentInvocationRecord & { childSession?: AgentSessionTree }>;
|
||||
}
|
||||
|
||||
export interface ReviewRunDetails {
|
||||
run: ReviewRun;
|
||||
steps: ReviewStep[];
|
||||
findings: Finding[];
|
||||
comments: ReviewCommentRecord[];
|
||||
sessionTree?: AgentSessionTree | null;
|
||||
}
|
||||
|
||||
export const fetchReviewRuns = async (limit: number = 50): Promise<{ data: ReviewRun[] }> => {
|
||||
const response = await api.get<{ data: ReviewRun[] }>('/review/runs', {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchReviewRunDetails = async (runId: string): Promise<ReviewRunDetails> => {
|
||||
const response = await api.get<ReviewRunDetails>(`/review/runs/${runId}`);
|
||||
return response.data;
|
||||
};
|
||||
@@ -2,8 +2,18 @@ import type { Page, Route } from '@playwright/test';
|
||||
|
||||
const repositories = {
|
||||
data: [
|
||||
{ name: 'demo-repo-1', webhook_status: 'active', hook_id: 101 },
|
||||
{ name: 'demo-repo-2', webhook_status: 'inactive', hook_id: null },
|
||||
{
|
||||
name: 'demo-repo-1',
|
||||
webhook_status: 'active',
|
||||
hook_id: 101,
|
||||
project_review_prompt: '重点检查 API 错误处理与鉴权边界。',
|
||||
},
|
||||
{
|
||||
name: 'demo-repo-2',
|
||||
webhook_status: 'inactive',
|
||||
hook_id: null,
|
||||
project_review_prompt: null,
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
page: 1,
|
||||
@@ -31,21 +41,41 @@ const configResponse = {
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'feishu',
|
||||
label: '飞书通知',
|
||||
description: '配置飞书 webhook 通知。',
|
||||
key: 'notification',
|
||||
label: '通知服务',
|
||||
description: '配置飞书与企业微信通知。',
|
||||
icon: 'bell',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'FEISHU_ENABLED',
|
||||
label: '启用飞书通知',
|
||||
description: '是否启用飞书通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
value: true,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_URL',
|
||||
label: '飞书 Webhook URL',
|
||||
description: '用于发送审查通知',
|
||||
description: '用于发送飞书通知',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
value: 'https://open.feishu.cn/mock/webhook',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'WECOM_ENABLED',
|
||||
label: '启用企业微信通知',
|
||||
description: '是否启用企业微信通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
value: false,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -165,24 +195,6 @@ const configResponse = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆设置',
|
||||
description: '控制上下文记忆与保留策略。',
|
||||
icon: 'database',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'MEMORY_ENABLED',
|
||||
label: '启用记忆',
|
||||
description: '是否启用长期记忆',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
value: true,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -213,23 +225,6 @@ const providers = [
|
||||
},
|
||||
];
|
||||
|
||||
const roles = [
|
||||
{
|
||||
role: 'planner',
|
||||
providerId: 'provider-openai',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
{
|
||||
role: 'specialist',
|
||||
providerId: 'provider-deepseek',
|
||||
providerName: 'DeepSeek',
|
||||
providerType: 'openai_compatible',
|
||||
model: 'deepseek-chat',
|
||||
},
|
||||
];
|
||||
|
||||
const modelSuggestions = {
|
||||
openai_compatible: ['deepseek-chat', 'gpt-4o-mini'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
|
||||
@@ -237,6 +232,23 @@ const modelSuggestions = {
|
||||
gemini: ['gemini-2.5-pro'],
|
||||
};
|
||||
|
||||
const notificationTestHistory = [
|
||||
{
|
||||
id: 'test-1',
|
||||
provider: 'feishu',
|
||||
status: 'success',
|
||||
message: 'feishu 测试通知已发送',
|
||||
timestamp: '2026-03-24T09:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
provider: 'wecom',
|
||||
status: 'error',
|
||||
message: 'wecom 未启用或未配置',
|
||||
timestamp: '2026-03-24T08:50:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const json = async (route: Route, body: unknown, status = 200) => {
|
||||
await route.fulfill({
|
||||
status,
|
||||
@@ -259,14 +271,27 @@ export async function installVisualApiMocks(page: Page) {
|
||||
return json(route, repositories);
|
||||
}
|
||||
|
||||
if (method === 'POST' && /\/admin\/api\/repositories\/[^/]+\/webhook$/.test(path)) {
|
||||
if (method === 'POST' && /\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/webhook$/.test(path)) {
|
||||
return json(route, { hook_id: 101, webhook_status: 'active' });
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && /\/admin\/api\/repositories\/[^/]+\/webhook\/\d+$/.test(path)) {
|
||||
if (
|
||||
method === 'DELETE' &&
|
||||
/\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/webhook\/\d+$/.test(path)
|
||||
) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'PUT' &&
|
||||
/\/admin\/api\/repositories\/[^/]+(?:\/[^/]+)?\/project-prompt$/.test(path)
|
||||
) {
|
||||
return json(route, {
|
||||
success: true,
|
||||
project_review_prompt: 'updated prompt',
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/config')) {
|
||||
return json(route, configResponse);
|
||||
}
|
||||
@@ -279,6 +304,14 @@ export async function installVisualApiMocks(page: Page) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'POST' && path.endsWith('/admin/api/config/notification/test')) {
|
||||
return json(route, { success: true, message: 'test sent' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/config/notification/test/history')) {
|
||||
return json(route, { data: notificationTestHistory });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/llm/model-suggestions')) {
|
||||
return json(route, modelSuggestions);
|
||||
}
|
||||
@@ -307,14 +340,6 @@ export async function installVisualApiMocks(page: Page) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/llm/roles')) {
|
||||
return json(route, roles);
|
||||
}
|
||||
|
||||
if (method === 'PUT' && /\/admin\/api\/llm\/roles\/[^/]+$/.test(path)) {
|
||||
return json(route, roles[0]);
|
||||
}
|
||||
|
||||
if (method === 'POST' && /\/admin\/api\/llm\/providers\/[^/]+\/test$/.test(path)) {
|
||||
return json(route, {
|
||||
success: true,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
---
|
||||
# ConfigMap: only infrastructure-level env vars that must be known before DB init
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
@@ -9,10 +7,8 @@ metadata:
|
||||
app.kubernetes.io/name: gitea-assistant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
data:
|
||||
PORT: "3000"
|
||||
# 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.
|
||||
PORT: "5174"
|
||||
LOG_LEVEL: "error"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -38,7 +34,7 @@ spec:
|
||||
image: ghcr.io/jeffusion/gitea-ai-assistant:latest
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 3000
|
||||
containerPort: 5174
|
||||
protocol: TCP
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
@@ -92,6 +88,6 @@ spec:
|
||||
app.kubernetes.io/name: gitea-assistant
|
||||
ports:
|
||||
- name: http
|
||||
port: 3000
|
||||
port: 5174
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
|
||||
@@ -6,5 +6,4 @@ namespace: gitea-assistant
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- secret.yaml
|
||||
- qdrant.yaml
|
||||
- gitea-assistant.yaml
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: qdrant
|
||||
namespace: gitea-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
containers:
|
||||
- name: qdrant
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 6333
|
||||
protocol: TCP
|
||||
- name: grpc
|
||||
containerPort: 6334
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
volumeMounts:
|
||||
- name: qdrant-storage
|
||||
mountPath: /qdrant/storage
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
volumes:
|
||||
- name: qdrant-storage
|
||||
hostPath:
|
||||
# Customize this path to match your node's storage layout
|
||||
path: /opt/gitea-assistant/qdrant
|
||||
type: DirectoryOrCreate
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: qdrant
|
||||
namespace: gitea-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: qdrant
|
||||
ports:
|
||||
- name: http
|
||||
port: 6333
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
- name: grpc
|
||||
port: 6334
|
||||
targetPort: grpc
|
||||
protocol: TCP
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitea-assistant",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0-develop",
|
||||
"description": "Gitea功能增强助手,包含AI代码审核功能",
|
||||
"engines": {
|
||||
"bun": ">=1.2.5"
|
||||
@@ -9,12 +9,12 @@
|
||||
"@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",
|
||||
"lodash-es": "^4.17.21",
|
||||
"openai": "^4.87.3",
|
||||
"pino": "^10.3.1",
|
||||
"tokenlens": "^1.3.1",
|
||||
"zod": "^3.25.1",
|
||||
"zod-to-json-schema": "^3.25.1"
|
||||
@@ -38,6 +38,8 @@
|
||||
"main": "./dist/index.js",
|
||||
"typings": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"bootstrap": "bun install && (cd frontend && bun install)",
|
||||
"postinstall": "if [ -d frontend ]; then (cd frontend && bun install); fi",
|
||||
"dev": "concurrently -k -p [{name}] -n backend,frontend -c blue,green \"bun run dev:backend\" \"bun run dev:frontend\"",
|
||||
"dev:backend": "bun run --watch src/index.ts",
|
||||
"dev:frontend": "cd frontend && bun run dev",
|
||||
@@ -49,7 +51,8 @@
|
||||
"start:prod": "bun run dist/index.js",
|
||||
"lint": "biome check src/",
|
||||
"test": "bun test",
|
||||
"prepare": "husky"
|
||||
"test:e2e": "bash ./e2e/test.sh",
|
||||
"prepare": "command -v husky >/dev/null 2>&1 && husky || true"
|
||||
},
|
||||
"keywords": [
|
||||
"code-review",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
agentDefinitionSchema,
|
||||
isAgentDefinition,
|
||||
normalizeAgentDefinition,
|
||||
} from '../agent-definition';
|
||||
|
||||
describe('agentDefinitionSchema', () => {
|
||||
test('parses a valid agent definition', () => {
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:fix-validator',
|
||||
whenToUse: 'Use for focused fix validation after a failing test run.',
|
||||
source: 'built-in',
|
||||
tools: ['readFile', 'searchCode'],
|
||||
disallowedTools: ['deleteFile'],
|
||||
skills: ['diagnostics'],
|
||||
hooks: {
|
||||
preToolUse: { enabled: true },
|
||||
},
|
||||
model: 'gpt-4.1-mini',
|
||||
maxTurns: 3,
|
||||
permissionMode: 'ask',
|
||||
background: true,
|
||||
isolation: 'workspace',
|
||||
getSystemPrompt: () => 'system prompt',
|
||||
});
|
||||
|
||||
expect(definition).toEqual({
|
||||
agentType: 'subagent',
|
||||
name: 'review:fix-validator',
|
||||
whenToUse: 'Use for focused fix validation after a failing test run.',
|
||||
source: 'built-in',
|
||||
tools: ['readFile', 'searchCode'],
|
||||
disallowedTools: ['deleteFile'],
|
||||
skills: ['diagnostics'],
|
||||
hooks: {
|
||||
preToolUse: { enabled: true },
|
||||
},
|
||||
model: 'gpt-4.1-mini',
|
||||
maxTurns: 3,
|
||||
permissionMode: 'ask',
|
||||
background: true,
|
||||
isolation: 'workspace',
|
||||
getSystemPrompt: definition.getSystemPrompt,
|
||||
});
|
||||
expect(isAgentDefinition(definition)).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes defaults for omitted runtime fields', () => {
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:intake',
|
||||
whenToUse: 'Use for initial task routing.',
|
||||
source: 'project',
|
||||
});
|
||||
|
||||
expect(definition.tools).toEqual([]);
|
||||
expect(definition.disallowedTools).toEqual([]);
|
||||
expect(definition.skills).toEqual([]);
|
||||
expect(definition.hooks).toEqual({});
|
||||
expect(definition.model).toBeUndefined();
|
||||
expect(definition.maxTurns).toBe(1);
|
||||
expect(definition.permissionMode).toBe('default');
|
||||
expect(definition.background).toBe(false);
|
||||
expect(definition.isolation).toBe('none');
|
||||
});
|
||||
|
||||
test('rejects missing required fields', () => {
|
||||
const result = agentDefinitionSchema.safeParse({
|
||||
agentType: 'subagent',
|
||||
source: 'built-in',
|
||||
model: 'gpt-4.1-mini',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('strips legacy business role fields', () => {
|
||||
const legacyKeys = ['plan' + 'ner', 'special' + 'ist', 'ju' + 'dge'];
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:modern',
|
||||
whenToUse: 'Use for modern runtime routing only.',
|
||||
source: 'user',
|
||||
model: 'gpt-4.1-mini',
|
||||
[legacyKeys[0]]: true,
|
||||
[legacyKeys[1]]: true,
|
||||
[legacyKeys[2]]: true,
|
||||
} as Record<string, unknown>);
|
||||
|
||||
for (const legacyKey of legacyKeys) {
|
||||
expect(legacyKey in definition).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
186
src/agent-kernel/definitions/__tests__/agent-registry.test.ts
Normal file
186
src/agent-kernel/definitions/__tests__/agent-registry.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
createAgentRegistry,
|
||||
loadAgentRegistry,
|
||||
loadProjectAgentDefinitions,
|
||||
parseAgentDefinitionMarkdown,
|
||||
} from '..';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function definition(source: 'built-in' | 'plugin' | 'user' | 'project', name: string) {
|
||||
return {
|
||||
agentType: 'reviewer',
|
||||
name,
|
||||
whenToUse: `Use ${name}`,
|
||||
source,
|
||||
model: `${name}-model`,
|
||||
};
|
||||
}
|
||||
|
||||
async function makeProjectRoot(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-registry-test-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
test('keeps all agents and resolves duplicates by built-in < plugin < user < project precedence', () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [definition('built-in', 'built-in-reviewer')],
|
||||
plugin: [definition('plugin', 'plugin-reviewer')],
|
||||
user: [definition('user', 'user-reviewer')],
|
||||
project: [definition('project', 'project-reviewer')],
|
||||
});
|
||||
|
||||
expect(registry.allAgents.map((agent) => agent.name)).toEqual([
|
||||
'built-in-reviewer',
|
||||
'plugin-reviewer',
|
||||
'user-reviewer',
|
||||
'project-reviewer',
|
||||
]);
|
||||
expect(registry.activeAgents).toHaveLength(1);
|
||||
expect(registry.getActiveAgent('reviewer')?.name).toBe('project-reviewer');
|
||||
expect(registry.getActiveAgent('reviewer')?.source).toBe('project');
|
||||
});
|
||||
|
||||
test('loads project definitions only from .gitea-assistant/agents/*.md', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const validDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
const ignoredDir = join(projectRoot, 'agents');
|
||||
await mkdir(validDir, { recursive: true });
|
||||
await mkdir(ignoredDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(validDir, 'reviewer.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Project Reviewer',
|
||||
'whenToUse: Use for project-specific review.',
|
||||
'tools: [readFile, searchCode]',
|
||||
'maxTurns: 2',
|
||||
'background: true',
|
||||
'---',
|
||||
'You are the project reviewer.',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(ignoredDir, 'ignored.md'),
|
||||
['---', 'agentType: ignored', 'name: Ignored', 'whenToUse: Never.', '---', 'Ignored.'].join(
|
||||
'\n'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const loaded = await loadProjectAgentDefinitions(projectRoot);
|
||||
|
||||
expect(loaded.failedFiles).toEqual([]);
|
||||
expect(loaded.definitions).toHaveLength(1);
|
||||
expect(loaded.definitions[0].agentType).toBe('reviewer');
|
||||
expect(loaded.definitions[0].source).toBe('project');
|
||||
expect(loaded.definitions[0].tools).toEqual(['readFile', 'searchCode']);
|
||||
expect(loaded.definitions[0].maxTurns).toBe(2);
|
||||
expect(loaded.definitions[0].background).toBe(true);
|
||||
expect(loaded.definitions[0].getSystemPrompt?.()).toBe('You are the project reviewer.');
|
||||
});
|
||||
|
||||
test('keeps optional model definitions valid through markdown loading', async () => {
|
||||
const parsed = parseAgentDefinitionMarkdown(
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: No Model Reviewer',
|
||||
'whenToUse: Use without model.',
|
||||
'---',
|
||||
'Prompt body.',
|
||||
].join('\n'),
|
||||
{ source: 'project', filePath: '/tmp/reviewer.md' }
|
||||
);
|
||||
|
||||
expect('code' in parsed).toBe(false);
|
||||
if ('code' in parsed) {
|
||||
throw new Error('expected valid definition');
|
||||
}
|
||||
expect(parsed.model).toBeUndefined();
|
||||
expect(parsed.getSystemPrompt?.()).toBe('Prompt body.');
|
||||
});
|
||||
|
||||
test('returns structured load errors for malformed frontmatter and empty body', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const agentsDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(agentsDir, 'bad-frontmatter.md'),
|
||||
'---\nagentType [reviewer]\n---\nPrompt.',
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'empty-body.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Empty Body',
|
||||
'whenToUse: Use never.',
|
||||
'---',
|
||||
' ',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'invalid-definition.md'),
|
||||
['---', 'agentType: reviewer', 'name: Missing Use', '---', 'Prompt.'].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const loaded = await loadProjectAgentDefinitions(projectRoot);
|
||||
|
||||
expect(loaded.definitions).toEqual([]);
|
||||
expect(loaded.failedFiles.map((error) => error.code).sort()).toEqual([
|
||||
'empty_body',
|
||||
'invalid_definition',
|
||||
'malformed_frontmatter',
|
||||
]);
|
||||
expect(loaded.failedFiles.every((error) => error.source === 'project')).toBe(true);
|
||||
expect(
|
||||
loaded.failedFiles.find((error) => error.code === 'invalid_definition')?.issues?.length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('loadAgentRegistry combines built-in, plugin, user, and loaded project definitions', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const agentsDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(agentsDir, 'reviewer.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Loaded Project',
|
||||
'whenToUse: Use loaded project.',
|
||||
'---',
|
||||
'Project prompt.',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = await loadAgentRegistry({
|
||||
projectRoot,
|
||||
builtIn: [definition('built-in', 'Built In')],
|
||||
plugin: [definition('plugin', 'Plugin')],
|
||||
user: [definition('user', 'User')],
|
||||
});
|
||||
|
||||
expect(registry.allAgents).toHaveLength(4);
|
||||
expect(registry.failedFiles).toEqual([]);
|
||||
expect(registry.getActiveAgent('reviewer')?.name).toBe('Loaded Project');
|
||||
expect(registry.getActiveAgent('reviewer')?.getSystemPrompt?.()).toBe('Project prompt.');
|
||||
});
|
||||
});
|
||||
84
src/agent-kernel/definitions/agent-definition.ts
Normal file
84
src/agent-kernel/definitions/agent-definition.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type AgentDefinitionSource = 'built-in' | 'project' | 'user' | 'plugin';
|
||||
|
||||
export const AGENT_DEFINITION_SOURCES = [
|
||||
'built-in',
|
||||
'project',
|
||||
'user',
|
||||
'plugin',
|
||||
] as const satisfies readonly AgentDefinitionSource[];
|
||||
|
||||
export type AgentPermissionMode = 'default' | 'ask' | 'deny';
|
||||
|
||||
export const AGENT_PERMISSION_MODES = [
|
||||
'default',
|
||||
'ask',
|
||||
'deny',
|
||||
] as const satisfies readonly AgentPermissionMode[];
|
||||
|
||||
export type AgentIsolation = 'none' | 'workspace' | 'process';
|
||||
|
||||
export const AGENT_ISOLATIONS = [
|
||||
'none',
|
||||
'workspace',
|
||||
'process',
|
||||
] as const satisfies readonly AgentIsolation[];
|
||||
|
||||
export interface AgentDefinitionHooks {
|
||||
sessionStart?: unknown;
|
||||
subagentStart?: unknown;
|
||||
permissionRequest?: unknown;
|
||||
preToolUse?: unknown;
|
||||
postToolUse?: unknown;
|
||||
postToolUseFailure?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const agentDefinitionHooksSchema: z.ZodType<AgentDefinitionHooks> = z
|
||||
.object({
|
||||
sessionStart: z.unknown().optional(),
|
||||
subagentStart: z.unknown().optional(),
|
||||
permissionRequest: z.unknown().optional(),
|
||||
preToolUse: z.unknown().optional(),
|
||||
postToolUse: z.unknown().optional(),
|
||||
postToolUseFailure: z.unknown().optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const agentDefinitionSchema = z
|
||||
.object({
|
||||
agentType: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
whenToUse: z.string().min(1),
|
||||
source: z.enum(AGENT_DEFINITION_SOURCES),
|
||||
tools: z.array(z.string()).default([]),
|
||||
disallowedTools: z.array(z.string()).default([]),
|
||||
skills: z.array(z.string()).default([]),
|
||||
hooks: agentDefinitionHooksSchema.default({}),
|
||||
model: z.string().min(1).optional(),
|
||||
maxTurns: z.number().int().positive().default(1),
|
||||
permissionMode: z.enum(AGENT_PERMISSION_MODES).default('default'),
|
||||
background: z.boolean().default(false),
|
||||
isolation: z.enum(AGENT_ISOLATIONS).default('none'),
|
||||
getSystemPrompt: z
|
||||
.custom<() => string>((value) => typeof value === 'function', {
|
||||
message: 'getSystemPrompt must be a function',
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export type AgentDefinition = z.infer<typeof agentDefinitionSchema>;
|
||||
|
||||
export function normalizeAgentDefinition(definition: unknown): AgentDefinition {
|
||||
return agentDefinitionSchema.parse(definition);
|
||||
}
|
||||
|
||||
export function isAgentDefinition(definition: unknown): definition is AgentDefinition {
|
||||
return agentDefinitionSchema.safeParse(definition).success;
|
||||
}
|
||||
|
||||
export function parseAgentDefinition(definition: unknown): AgentDefinition {
|
||||
return normalizeAgentDefinition(definition);
|
||||
}
|
||||
258
src/agent-kernel/definitions/agent-loader.ts
Normal file
258
src/agent-kernel/definitions/agent-loader.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ZodError } from 'zod';
|
||||
import type { AgentDefinition, AgentDefinitionSource } from './agent-definition';
|
||||
import { normalizeAgentDefinition } from './agent-definition';
|
||||
|
||||
export const PROJECT_AGENT_DEFINITIONS_DIR = '.gitea-assistant/agents';
|
||||
|
||||
export type AgentDefinitionLoadErrorCode =
|
||||
| 'missing_frontmatter'
|
||||
| 'malformed_frontmatter'
|
||||
| 'empty_body'
|
||||
| 'invalid_definition'
|
||||
| 'read_error';
|
||||
|
||||
export interface AgentDefinitionLoadError {
|
||||
source: AgentDefinitionSource;
|
||||
filePath: string;
|
||||
code: AgentDefinitionLoadErrorCode;
|
||||
message: string;
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
export interface AgentDefinitionLoadResult {
|
||||
definitions: AgentDefinition[];
|
||||
failedFiles: AgentDefinitionLoadError[];
|
||||
}
|
||||
|
||||
interface MarkdownParseOptions {
|
||||
source: AgentDefinitionSource;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
type FrontmatterRecord = Record<string, string | number | boolean | string[]>;
|
||||
|
||||
export function parseAgentDefinitionMarkdown(
|
||||
content: string,
|
||||
options: MarkdownParseOptions
|
||||
): AgentDefinition | AgentDefinitionLoadError {
|
||||
const extracted = extractFrontmatter(content, options);
|
||||
if (isLoadError(extracted)) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
const systemPrompt = extracted.body.trim();
|
||||
if (!systemPrompt) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'empty_body',
|
||||
message: 'Agent definition markdown body must contain the system prompt.',
|
||||
};
|
||||
}
|
||||
|
||||
const frontmatter = parseFrontmatter(extracted.frontmatter, options);
|
||||
if (isLoadError(frontmatter)) {
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeAgentDefinition({
|
||||
...frontmatter,
|
||||
source: options.source,
|
||||
getSystemPrompt: () => systemPrompt,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'invalid_definition',
|
||||
message: 'Agent definition frontmatter does not match AgentDefinition.',
|
||||
issues:
|
||||
error instanceof ZodError ? error.issues.map((issue) => issue.message) : [String(error)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadProjectAgentDefinitions(
|
||||
projectRoot: string
|
||||
): Promise<AgentDefinitionLoadResult> {
|
||||
const definitionsDir = join(projectRoot, PROJECT_AGENT_DEFINITIONS_DIR);
|
||||
const result: AgentDefinitionLoadResult = { definitions: [], failedFiles: [] };
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(definitionsDir);
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
definitions: [],
|
||||
failedFiles: [
|
||||
{
|
||||
source: 'project',
|
||||
filePath: definitionsDir,
|
||||
code: 'read_error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
for (const entry of entries.sort()) {
|
||||
if (!entry.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(definitionsDir, entry);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = parseAgentDefinitionMarkdown(content, { source: 'project', filePath });
|
||||
if (isLoadError(parsed)) {
|
||||
result.failedFiles.push(parsed);
|
||||
} else {
|
||||
result.definitions.push(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
result.failedFiles.push({
|
||||
source: 'project',
|
||||
filePath,
|
||||
code: 'read_error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractFrontmatter(
|
||||
content: string,
|
||||
options: MarkdownParseOptions
|
||||
): { frontmatter: string; body: string } | AgentDefinitionLoadError {
|
||||
const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
||||
if (!normalized.startsWith('---\n')) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'missing_frontmatter',
|
||||
message: 'Agent definition markdown must start with --- frontmatter.',
|
||||
};
|
||||
}
|
||||
|
||||
const closingMarker = '\n---\n';
|
||||
const closingIndex = normalized.indexOf(closingMarker, 4);
|
||||
if (closingIndex === -1) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'malformed_frontmatter',
|
||||
message: 'Agent definition markdown frontmatter must close with --- on its own line.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter: normalized.slice(4, closingIndex),
|
||||
body: normalized.slice(closingIndex + closingMarker.length),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFrontmatter(
|
||||
frontmatter: string,
|
||||
options: MarkdownParseOptions
|
||||
): FrontmatterRecord | AgentDefinitionLoadError {
|
||||
const parsed: FrontmatterRecord = {};
|
||||
const lines = frontmatter.split('\n');
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = /^(\w+):\s*(.*)$/.exec(line);
|
||||
if (!match) {
|
||||
return malformedFrontmatter(options, `Invalid frontmatter line: ${line}`);
|
||||
}
|
||||
|
||||
const key = match[1];
|
||||
const rawValue = match[2];
|
||||
if (rawValue === '') {
|
||||
const values: string[] = [];
|
||||
while (index + 1 < lines.length && /^\s+-\s+/.test(lines[index + 1])) {
|
||||
index += 1;
|
||||
values.push(unquote(lines[index].replace(/^\s+-\s+/, '').trim()));
|
||||
}
|
||||
parsed[key] = values;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = parseFrontmatterValue(rawValue.trim(), options);
|
||||
if (isLoadError(value)) {
|
||||
return value;
|
||||
}
|
||||
parsed[key] = value;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseFrontmatterValue(
|
||||
value: string,
|
||||
options: MarkdownParseOptions
|
||||
): string | number | boolean | string[] | AgentDefinitionLoadError {
|
||||
if (value.startsWith('[')) {
|
||||
if (!value.endsWith(']')) {
|
||||
return malformedFrontmatter(options, `Invalid inline array: ${value}`);
|
||||
}
|
||||
|
||||
const inner = value.slice(1, -1).trim();
|
||||
return inner ? inner.split(',').map((item) => unquote(item.trim())) : [];
|
||||
}
|
||||
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
return unquote(value);
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function malformedFrontmatter(
|
||||
options: MarkdownParseOptions,
|
||||
message: string
|
||||
): AgentDefinitionLoadError {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'malformed_frontmatter',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function isLoadError(value: unknown): value is AgentDefinitionLoadError {
|
||||
return typeof value === 'object' && value !== null && 'code' in value;
|
||||
}
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
62
src/agent-kernel/definitions/agent-registry.ts
Normal file
62
src/agent-kernel/definitions/agent-registry.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { AgentDefinition } from './agent-definition';
|
||||
import { normalizeAgentDefinition } from './agent-definition';
|
||||
import type { AgentDefinitionLoadError } from './agent-loader';
|
||||
import { loadProjectAgentDefinitions } from './agent-loader';
|
||||
|
||||
export interface AgentRegistry {
|
||||
allAgents: AgentDefinition[];
|
||||
activeAgents: AgentDefinition[];
|
||||
failedFiles: AgentDefinitionLoadError[];
|
||||
getActiveAgent(agentType: string): AgentDefinition | undefined;
|
||||
}
|
||||
|
||||
export interface AgentRegistryInput {
|
||||
builtIn?: unknown[];
|
||||
plugin?: unknown[];
|
||||
user?: unknown[];
|
||||
project?: unknown[];
|
||||
failedFiles?: AgentDefinitionLoadError[];
|
||||
}
|
||||
|
||||
export interface LoadAgentRegistryOptions extends AgentRegistryInput {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
export function createAgentRegistry(input: AgentRegistryInput = {}): AgentRegistry {
|
||||
const allAgents = [
|
||||
...(input.builtIn ?? []),
|
||||
...(input.plugin ?? []),
|
||||
...(input.user ?? []),
|
||||
...(input.project ?? []),
|
||||
].map((definition) => normalizeAgentDefinition(definition));
|
||||
const activeByType = new Map<string, AgentDefinition>();
|
||||
|
||||
for (const agent of allAgents) {
|
||||
activeByType.set(agent.agentType, agent);
|
||||
}
|
||||
|
||||
return {
|
||||
allAgents,
|
||||
activeAgents: Array.from(activeByType.values()),
|
||||
failedFiles: input.failedFiles ?? [],
|
||||
getActiveAgent(agentType: string): AgentDefinition | undefined {
|
||||
return activeByType.get(agentType);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadAgentRegistry(
|
||||
options: LoadAgentRegistryOptions = {}
|
||||
): Promise<AgentRegistry> {
|
||||
const projectLoadResult = options.projectRoot
|
||||
? await loadProjectAgentDefinitions(options.projectRoot)
|
||||
: { definitions: [], failedFiles: [] };
|
||||
|
||||
return createAgentRegistry({
|
||||
builtIn: options.builtIn,
|
||||
plugin: options.plugin,
|
||||
user: options.user,
|
||||
project: [...(options.project ?? []), ...projectLoadResult.definitions],
|
||||
failedFiles: [...(options.failedFiles ?? []), ...projectLoadResult.failedFiles],
|
||||
});
|
||||
}
|
||||
28
src/agent-kernel/definitions/index.ts
Normal file
28
src/agent-kernel/definitions/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
AGENT_DEFINITION_SOURCES,
|
||||
AGENT_ISOLATIONS,
|
||||
AGENT_PERMISSION_MODES,
|
||||
agentDefinitionSchema,
|
||||
isAgentDefinition,
|
||||
normalizeAgentDefinition,
|
||||
parseAgentDefinition,
|
||||
} from './agent-definition';
|
||||
export {
|
||||
PROJECT_AGENT_DEFINITIONS_DIR,
|
||||
loadProjectAgentDefinitions,
|
||||
parseAgentDefinitionMarkdown,
|
||||
} from './agent-loader';
|
||||
export { createAgentRegistry, loadAgentRegistry } from './agent-registry';
|
||||
export type {
|
||||
AgentDefinition,
|
||||
AgentDefinitionHooks,
|
||||
AgentDefinitionSource,
|
||||
AgentIsolation,
|
||||
AgentPermissionMode,
|
||||
} from './agent-definition';
|
||||
export type {
|
||||
AgentDefinitionLoadError,
|
||||
AgentDefinitionLoadErrorCode,
|
||||
AgentDefinitionLoadResult,
|
||||
} from './agent-loader';
|
||||
export type { AgentRegistry, AgentRegistryInput, LoadAgentRegistryOptions } from './agent-registry';
|
||||
@@ -0,0 +1,620 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import { ScriptedMockLLM, scriptedTurn } from '../../../llm/e2e-mock';
|
||||
import { createAgentRegistry } from '../../definitions';
|
||||
import { agentSessionRepository } from '../../session';
|
||||
import { SubagentRunner } from '../../subagents/subagent-runner';
|
||||
import { createSpawnSubagentTool } from '../../tools';
|
||||
import { MainAgentRunner } from '../main-agent-runner';
|
||||
import type { MainAgentTool } from '../types';
|
||||
|
||||
function baseAgentDefinition() {
|
||||
return {
|
||||
agentType: 'general-purpose',
|
||||
name: 'General Purpose',
|
||||
whenToUse: 'Use for delegated analysis.',
|
||||
source: 'built-in' as const,
|
||||
tools: ['search_code', 'read_file'],
|
||||
disallowedTools: [],
|
||||
skills: [],
|
||||
hooks: {},
|
||||
maxTurns: 6,
|
||||
permissionMode: 'default' as const,
|
||||
background: false,
|
||||
isolation: 'none' as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Scripted Mock LLM dynamic agent flows', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `dynamic-agent-scripted-mock-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
function makeTools(record: { submissions: unknown[] }) {
|
||||
const readFileTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'read_file',
|
||||
description: 'Read deterministic test fixture.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
execute: (args) => ({ path: (args as { path: string }).path, content: 'const value = 1;' }),
|
||||
};
|
||||
|
||||
const searchCodeTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'search_code',
|
||||
description: 'Search deterministic test fixture.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string' } },
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (args) => ({
|
||||
matches: [{ path: 'src/app.ts', line: 1, query: (args as { query: string }).query }],
|
||||
}),
|
||||
};
|
||||
|
||||
const submitReviewFindingsTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'submit_review_findings',
|
||||
description: 'Capture deterministic submission payload.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summaryMarkdown: { type: 'string' },
|
||||
findings: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
required: ['summaryMarkdown', 'findings'],
|
||||
},
|
||||
},
|
||||
execute: (args) => {
|
||||
record.submissions.push(structuredClone(args));
|
||||
return { accepted: true };
|
||||
},
|
||||
};
|
||||
|
||||
return { readFileTool, searchCodeTool, submitReviewFindingsTool };
|
||||
}
|
||||
|
||||
test('deterministically scripts main->spawn_subagent->submit_review_findings flow', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'main-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Inspect changed file',
|
||||
prompt: 'Check correctness risks.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-search-1', name: 'search_code', arguments: '{"query":"value"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({ content: 'Subagent summary: potential correctness issue found.' }),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({ summaryMarkdown: 'Found one issue.', findings: [] }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Review finalized.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
agentType: 'review-main-agent',
|
||||
userMessage: 'Start dynamic review.',
|
||||
maxTurns: 8,
|
||||
maxToolCalls: 8,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('Review finalized.');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'read_file',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(scriptedModel.toolCallSequence('subagent')).toEqual(['search_code', 'read_file']);
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'read_file',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].status).toBe('completed');
|
||||
expect(
|
||||
tree?.invocations[0].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['search_code', 'read_file']);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Found one issue.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('supports deterministic no-subagent completion flow', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'main-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({ summaryMarkdown: 'No issues found.', findings: [] }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Done without subagent.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Review directly.',
|
||||
maxTurns: 6,
|
||||
maxToolCalls: 6,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual(['read_file', 'submit_review_findings']);
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.status).toBe('completed');
|
||||
expect(tree?.finalResult).toEqual({
|
||||
status: 'completed',
|
||||
turns: 3,
|
||||
toolCalls: 2,
|
||||
finalText: 'Done without subagent.',
|
||||
});
|
||||
expect(tree?.invocations).toHaveLength(0);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'read_file',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'No issues found.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('supports multiple subagent spawns in one main run with distinct child sessions', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Run child one',
|
||||
prompt: 'Inspect alpha path.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-search-1', name: 'search_code', arguments: '{"query":"alpha"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'subagent', turn: scriptedTurn({ content: 'Child one summary.' }) },
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-2',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Run child two',
|
||||
prompt: 'Inspect beta path.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'subagent', turn: scriptedTurn({ content: 'Child two summary.' }) },
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Two children completed.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main completed multi-child flow.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
agentType: 'review-main-agent',
|
||||
userMessage: 'Run two delegated checks.',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(scriptedModel.toolCallSequence('subagent')).toEqual(['search_code', 'read_file']);
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(2);
|
||||
expect(tree?.invocations[0].status).toBe('completed');
|
||||
expect(tree?.invocations[1].status).toBe('completed');
|
||||
expect(tree?.invocations[0].childSessionId).not.toBe(tree?.invocations[1].childSessionId);
|
||||
expect(
|
||||
tree?.invocations[0].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['search_code']);
|
||||
expect(
|
||||
tree?.invocations[1].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['read_file']);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Two children completed.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('propagates structured subagent failure and still allows main completion', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Investigate quickly',
|
||||
prompt: 'Run child checks.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Subagent failed; no findings.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main handled child failure.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Run subagent and continue on failure.',
|
||||
maxTurns: 6,
|
||||
maxToolCalls: 6,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
const secondMainRequest = scriptedModel.calls.filter((call) => call.session === 'main')[1];
|
||||
const lastMessage = secondMainRequest.request.messages.at(-1);
|
||||
expect(lastMessage?.role).toBe('tool');
|
||||
expect(lastMessage?.content).toContain('No scripted mock turn queued for session');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].status).toBe('failed');
|
||||
expect(tree?.invocations[0].result).toMatchObject({
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'Error',
|
||||
message: "No scripted mock turn queued for session 'subagent'",
|
||||
},
|
||||
});
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Subagent failed; no findings.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('filters disallowed child tools and persists deterministic failed tool call path', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Restricted run',
|
||||
prompt: 'Try forbidden search first.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-denied-1', name: 'search_code', arguments: '{"query":"restricted"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({ content: 'Child observed denied tool and completed.' }),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Permission filtered as expected.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main completed restricted flow.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({
|
||||
builtIn: [
|
||||
{
|
||||
...baseAgentDefinition(),
|
||||
tools: ['search_code', 'read_file'],
|
||||
disallowedTools: ['search_code'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Run with restricted subagent tools.',
|
||||
maxTurns: 8,
|
||||
maxToolCalls: 8,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
const secondSubagentRequest = scriptedModel.calls.filter(
|
||||
(call) => call.session === 'subagent'
|
||||
)[1];
|
||||
expect(secondSubagentRequest.request.messages.at(-1)?.role).toBe('tool');
|
||||
expect(secondSubagentRequest.request.messages.at(-1)?.content).toContain('ToolNotFoundError');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['search_code', 'read_file'],
|
||||
disallowedToolNames: ['search_code'],
|
||||
deniedToolNames: ['search_code'],
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'search_code',
|
||||
status: 'failed',
|
||||
arguments: { query: 'restricted' },
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'search_code' is not registered",
|
||||
},
|
||||
});
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Permission filtered as expected.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
});
|
||||
358
src/agent-kernel/loop/__tests__/main-agent-runner.test.ts
Normal file
358
src/agent-kernel/loop/__tests__/main-agent-runner.test.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../../llm/types';
|
||||
import { agentSessionRepository } from '../../session/session-repository';
|
||||
import { MainAgentRunner } from '../main-agent-runner';
|
||||
import type { MainAgentModelClient, MainAgentTool } from '../types';
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient implements MainAgentModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
const lookupTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'lookup',
|
||||
description: 'Look up a deterministic value.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (argumentsValue) => ({ echoed: (argumentsValue as { query: string }).query }),
|
||||
};
|
||||
|
||||
describe('MainAgentRunner', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `main-agent-runner-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('runs tool call, appends tool result, then returns final answer', async () => {
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
content: null,
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'lookup', arguments: '{"query":"alpha"}' }],
|
||||
}),
|
||||
response({ content: 'final answer' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
agentType: 'main',
|
||||
model: 'mock-model',
|
||||
userMessage: 'answer with a tool',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 4,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('final answer');
|
||||
expect(result.turns).toBe(2);
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-1',
|
||||
content: JSON.stringify({ ok: true, value: { echoed: 'alpha' } }),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(tree?.toolCalls[0].result).toEqual({ echoed: 'alpha' });
|
||||
expect(tree?.finalResult).toEqual({
|
||||
status: 'completed',
|
||||
turns: 2,
|
||||
toolCalls: 1,
|
||||
finalText: 'final answer',
|
||||
});
|
||||
});
|
||||
|
||||
test('completes on final assistant answer with no tool calls', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([response({ content: 'plain final' })]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'answer directly',
|
||||
maxTurns: 2,
|
||||
maxToolCalls: 2,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCalls).toBe(0);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('stops runaway model at max turns', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'lookup', arguments: '{"query":"one"}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-2', name: 'lookup', arguments: '{"query":"two"}' }],
|
||||
}),
|
||||
]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'keep calling tools',
|
||||
maxTurns: 2,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_turns_reached');
|
||||
expect(result.turns).toBe(2);
|
||||
expect(result.toolCalls).toBe(2);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('stops before exceeding max tool calls', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{ id: 'call-1', name: 'lookup', arguments: '{"query":"one"}' },
|
||||
{ id: 'call-2', name: 'lookup', arguments: '{"query":"two"}' },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'too many tools',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 1,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_tool_calls_reached');
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.toolCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('records tool execution errors as structured tool results and continues', async () => {
|
||||
const failingTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'fail_lookup',
|
||||
description: 'Always fails.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => {
|
||||
throw new Error('lookup failed');
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'fail_lookup', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'recovered' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [failingTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'recover from tool error',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 2,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls[0].status).toBe('failed');
|
||||
expect(tree?.toolCalls[0].error).toEqual({ name: 'Error', message: 'lookup failed' });
|
||||
expect(modelClient.requests[1].messages.at(-1)?.content).toBe(
|
||||
JSON.stringify({ ok: false, error: { name: 'Error', message: 'lookup failed' } })
|
||||
);
|
||||
});
|
||||
|
||||
test('stops on maxEmptyResponses', async () => {
|
||||
const modelClient = new FakeModelClient([
|
||||
response({ content: '' }),
|
||||
response({ content: '' }),
|
||||
response({ content: 'should not reach' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test empty responses',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxEmptyResponses: 2,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_empty_responses');
|
||||
expect(result.turns).toBe(2);
|
||||
});
|
||||
|
||||
test('stops on maxConsecutiveToolFailures', async () => {
|
||||
const failTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'fail_tool',
|
||||
description: 'Always fails.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c1', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c2', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c3', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'should not reach' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [failTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test tool failures',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxConsecutiveToolFailures: 3,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_consecutive_tool_failures');
|
||||
});
|
||||
|
||||
test('refuses subagent spawn beyond maxSubagents and allows summary', async () => {
|
||||
const subagentTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'spawn_subagent',
|
||||
description: 'Spawn a subagent.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => ({ status: 'completed' }),
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c1', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c2', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c3', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'review complete with 2 subagents' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [subagentTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test subagent limit',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxSubagents: 2,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('review complete with 2 subagents');
|
||||
});
|
||||
});
|
||||
2
src/agent-kernel/loop/index.ts
Normal file
2
src/agent-kernel/loop/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './main-agent-runner';
|
||||
export * from './types';
|
||||
346
src/agent-kernel/loop/main-agent-runner.ts
Normal file
346
src/agent-kernel/loop/main-agent-runner.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import type { LLMMessage, LLMToolCall } from '../../llm/types';
|
||||
import { agentSessionRepository } from '../session/session-repository';
|
||||
import type {
|
||||
MainAgentRunInput,
|
||||
MainAgentRunResult,
|
||||
MainAgentRunnerOptions,
|
||||
MainAgentTerminalStatus,
|
||||
MainAgentTool,
|
||||
MainAgentTranscriptRepository,
|
||||
ToolExecutionResult,
|
||||
} from './types';
|
||||
|
||||
function parseToolArguments(toolCall: LLMToolCall): ToolExecutionResult {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(toolCall.arguments || '{}') };
|
||||
} catch (error) {
|
||||
const parsedError = error instanceof Error ? error : new Error(String(error));
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
name: parsedError.name,
|
||||
message: parsedError.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyToolResult(result: ToolExecutionResult): string {
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): ToolExecutionResult['error'] {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Error',
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
export class MainAgentRunner {
|
||||
private readonly modelClient: MainAgentRunnerOptions['modelClient'];
|
||||
private readonly transcriptRepository: MainAgentTranscriptRepository;
|
||||
private readonly toolsByName: Map<string, MainAgentTool>;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: MainAgentRunnerOptions) {
|
||||
this.modelClient = options.modelClient;
|
||||
this.transcriptRepository = options.transcriptRepository;
|
||||
this.toolsByName = new Map((options.tools ?? []).map((tool) => [tool.definition.name, tool]));
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async run(input: MainAgentRunInput): Promise<MainAgentRunResult> {
|
||||
const startedAt = this.now();
|
||||
const sessionId =
|
||||
input.sessionId ??
|
||||
this.transcriptRepository.createSession({
|
||||
agentType: input.session?.agentType ?? input.agentType ?? 'main',
|
||||
model: input.session?.model ?? input.model,
|
||||
parentSessionId: input.session?.parentSessionId,
|
||||
parentInvocationId: input.session?.parentInvocationId,
|
||||
status: input.session?.status,
|
||||
metadata: input.session?.metadata,
|
||||
}).id;
|
||||
|
||||
const messages: LLMMessage[] = [];
|
||||
if (input.systemPrompt) {
|
||||
messages.push({ role: 'system', content: input.systemPrompt });
|
||||
}
|
||||
|
||||
const userMessage: LLMMessage = { role: 'user', content: input.userMessage };
|
||||
messages.push(userMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: { text: input.userMessage },
|
||||
});
|
||||
|
||||
let turns = 0;
|
||||
let toolCalls = 0;
|
||||
let subagentCount = 0;
|
||||
let emptyResponseCount = 0;
|
||||
let consecutiveToolFailures = 0;
|
||||
const maxSubagents = input.maxSubagents ?? Number.POSITIVE_INFINITY;
|
||||
const maxEmptyResponses = input.maxEmptyResponses ?? 3;
|
||||
const maxConsecutiveToolFailures = input.maxConsecutiveToolFailures ?? 5;
|
||||
|
||||
while (true) {
|
||||
const budgetStatus = this.getBudgetStatus(
|
||||
input,
|
||||
startedAt,
|
||||
turns,
|
||||
emptyResponseCount,
|
||||
consecutiveToolFailures,
|
||||
maxEmptyResponses,
|
||||
maxConsecutiveToolFailures
|
||||
);
|
||||
if (budgetStatus) {
|
||||
return this.finish(sessionId, budgetStatus, turns, toolCalls, messages);
|
||||
}
|
||||
|
||||
const response = await this.modelClient.chat({
|
||||
messages,
|
||||
model: input.model,
|
||||
temperature: input.temperature,
|
||||
maxTokens: input.maxTokens,
|
||||
responseFormat: input.responseFormat,
|
||||
providerOptions: input.providerOptions,
|
||||
tools: [...this.toolsByName.values()].map((tool) => tool.definition),
|
||||
});
|
||||
|
||||
turns += 1;
|
||||
|
||||
if (!response.content?.trim() && response.toolCalls.length === 0) {
|
||||
emptyResponseCount += 1;
|
||||
messages.push({ role: 'assistant', content: '' });
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: { text: '' },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
emptyResponseCount = 0;
|
||||
|
||||
const assistantMessage: LLMMessage = {
|
||||
role: 'assistant',
|
||||
content: response.content ?? '',
|
||||
toolCalls: response.toolCalls,
|
||||
};
|
||||
messages.push(assistantMessage);
|
||||
|
||||
const assistantRecord = this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: {
|
||||
text: response.content ?? '',
|
||||
toolCalls: response.toolCalls,
|
||||
finishReason: response.finishReason,
|
||||
usage: response.usage,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.toolCalls.length === 0) {
|
||||
return this.finish(
|
||||
sessionId,
|
||||
'completed',
|
||||
turns,
|
||||
toolCalls,
|
||||
messages,
|
||||
response.content ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
for (const toolCall of response.toolCalls) {
|
||||
if (this.isTimedOut(input, startedAt)) {
|
||||
return this.finish(sessionId, 'timeout_reached', turns, toolCalls, messages);
|
||||
}
|
||||
if (toolCalls >= input.maxToolCalls) {
|
||||
return this.finish(sessionId, 'max_tool_calls_reached', turns, toolCalls, messages);
|
||||
}
|
||||
|
||||
if (toolCall.name === 'spawn_subagent') {
|
||||
if (subagentCount >= maxSubagents) {
|
||||
const refusalMessage: LLMMessage = {
|
||||
role: 'tool',
|
||||
toolCallId: toolCall.id,
|
||||
content: JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'BudgetExceeded',
|
||||
message: `Subagent limit reached (${maxSubagents}). Please summarize your findings instead.`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
messages.push(refusalMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'tool',
|
||||
content: {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: {
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'BudgetExceeded',
|
||||
message: `Subagent limit reached (${maxSubagents})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
subagentCount += 1;
|
||||
}
|
||||
|
||||
const result = await this.executeTool(toolCall, sessionId, input.model, turns);
|
||||
toolCalls += 1;
|
||||
if (!result.ok) {
|
||||
consecutiveToolFailures += 1;
|
||||
} else {
|
||||
consecutiveToolFailures = 0;
|
||||
}
|
||||
|
||||
this.transcriptRepository.appendToolCall({
|
||||
sessionId,
|
||||
messageId: assistantRecord.id,
|
||||
toolName: toolCall.name,
|
||||
status: result.ok ? 'completed' : 'failed',
|
||||
arguments: parseToolArguments(toolCall).value ?? {},
|
||||
result: result.ok ? result.value : undefined,
|
||||
error: result.ok ? undefined : result.error,
|
||||
});
|
||||
|
||||
const toolMessage: LLMMessage = {
|
||||
role: 'tool',
|
||||
toolCallId: toolCall.id,
|
||||
content: stringifyToolResult(result),
|
||||
};
|
||||
messages.push(toolMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'tool',
|
||||
content: {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.ok && consecutiveToolFailures >= maxConsecutiveToolFailures) {
|
||||
return this.finish(
|
||||
sessionId,
|
||||
'max_consecutive_tool_failures',
|
||||
turns,
|
||||
toolCalls,
|
||||
messages
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getBudgetStatus(
|
||||
input: MainAgentRunInput,
|
||||
startedAt: number,
|
||||
turns: number,
|
||||
emptyResponseCount: number,
|
||||
consecutiveToolFailures: number,
|
||||
maxEmptyResponses: number,
|
||||
maxConsecutiveToolFailures: number
|
||||
): MainAgentTerminalStatus | undefined {
|
||||
if (this.isTimedOut(input, startedAt)) return 'timeout_reached';
|
||||
if (turns >= input.maxTurns) return 'max_turns_reached';
|
||||
if (emptyResponseCount >= maxEmptyResponses) return 'max_empty_responses';
|
||||
if (consecutiveToolFailures >= maxConsecutiveToolFailures)
|
||||
return 'max_consecutive_tool_failures';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isTimedOut(input: MainAgentRunInput, startedAt: number): boolean {
|
||||
return this.now() - startedAt >= input.timeoutMs;
|
||||
}
|
||||
|
||||
private async executeTool(
|
||||
toolCall: LLMToolCall,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
turn: number
|
||||
): Promise<ToolExecutionResult> {
|
||||
const parsedArguments = parseToolArguments(toolCall);
|
||||
if (!parsedArguments.ok) return parsedArguments;
|
||||
|
||||
const tool = this.toolsByName.get(toolCall.name);
|
||||
if (!tool) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: `Tool '${toolCall.name}' is not registered`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await tool.execute(parsedArguments.value, {
|
||||
sessionId,
|
||||
model,
|
||||
toolCall,
|
||||
turn,
|
||||
});
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: normalizeError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private finish(
|
||||
sessionId: string,
|
||||
status: MainAgentTerminalStatus,
|
||||
turns: number,
|
||||
toolCalls: number,
|
||||
messages: LLMMessage[],
|
||||
finalText?: string
|
||||
): MainAgentRunResult {
|
||||
this.transcriptRepository.completeSession({
|
||||
sessionId,
|
||||
status: status === 'completed' ? 'completed' : 'failed',
|
||||
finalResult: {
|
||||
status,
|
||||
turns,
|
||||
toolCalls,
|
||||
finalText,
|
||||
},
|
||||
error: status === 'completed' ? undefined : { status },
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
sessionId,
|
||||
turns,
|
||||
toolCalls,
|
||||
finalText,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const mainAgentRunner = new MainAgentRunner({
|
||||
modelClient: {
|
||||
chat: () => {
|
||||
throw new Error('MainAgentRunner requires an injected model client');
|
||||
},
|
||||
},
|
||||
transcriptRepository: agentSessionRepository,
|
||||
});
|
||||
118
src/agent-kernel/loop/types.ts
Normal file
118
src/agent-kernel/loop/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
LLMChatRequest,
|
||||
LLMChatResponse,
|
||||
LLMMessage,
|
||||
LLMToolCall,
|
||||
LLMToolDefinition,
|
||||
} from '../../llm/types';
|
||||
import type {
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentToolCallRecord,
|
||||
CreateAgentSessionInput,
|
||||
} from '../session/types';
|
||||
|
||||
export type MainAgentTerminalStatus =
|
||||
| 'completed'
|
||||
| 'max_turns_reached'
|
||||
| 'max_tool_calls_reached'
|
||||
| 'max_subagents_reached'
|
||||
| 'timeout_reached'
|
||||
| 'max_empty_responses'
|
||||
| 'max_consecutive_tool_failures';
|
||||
|
||||
export interface MainAgentModelClient {
|
||||
chat(request: LLMChatRequest): Promise<LLMChatResponse>;
|
||||
}
|
||||
|
||||
export interface MainAgentToolContext {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
toolCall: LLMToolCall;
|
||||
turn: number;
|
||||
}
|
||||
|
||||
export type ToolPermissionScope =
|
||||
| 'read'
|
||||
| 'write'
|
||||
| 'command'
|
||||
| 'network'
|
||||
| 'git_write'
|
||||
| 'cross_session';
|
||||
|
||||
export type ToolPermissionBehavior = 'allow' | 'deny';
|
||||
|
||||
export interface MainAgentTool {
|
||||
definition: LLMToolDefinition;
|
||||
permissionScope?: ToolPermissionScope;
|
||||
execute(argumentsValue: unknown, context: MainAgentToolContext): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface MainAgentTranscriptRepository {
|
||||
createSession(input: CreateAgentSessionInput): AgentSessionRecord;
|
||||
appendMessage(input: {
|
||||
sessionId: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): AgentMessageRecord;
|
||||
appendToolCall(input: {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
toolName: string;
|
||||
status?: 'running' | 'completed' | 'failed';
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}): AgentToolCallRecord;
|
||||
completeSession(input: {
|
||||
sessionId: string;
|
||||
status: 'completed' | 'failed' | 'cancelled';
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
}): AgentSessionRecord;
|
||||
}
|
||||
|
||||
export interface MainAgentRunnerOptions {
|
||||
modelClient: MainAgentModelClient;
|
||||
transcriptRepository: MainAgentTranscriptRepository;
|
||||
tools?: MainAgentTool[];
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface MainAgentRunInput {
|
||||
session?: Omit<CreateAgentSessionInput, 'model'> & { model?: string };
|
||||
sessionId?: string;
|
||||
agentType?: string;
|
||||
model: string;
|
||||
systemPrompt?: string;
|
||||
userMessage: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
responseFormat?: 'text' | 'json';
|
||||
providerOptions?: Record<string, unknown>;
|
||||
maxTurns: number;
|
||||
maxToolCalls: number;
|
||||
maxSubagents?: number;
|
||||
timeoutMs: number;
|
||||
maxEmptyResponses?: number;
|
||||
maxConsecutiveToolFailures?: number;
|
||||
}
|
||||
|
||||
export interface MainAgentRunResult {
|
||||
status: MainAgentTerminalStatus;
|
||||
sessionId: string;
|
||||
turns: number;
|
||||
toolCalls: number;
|
||||
finalText?: string;
|
||||
messages: LLMMessage[];
|
||||
}
|
||||
|
||||
export interface ToolExecutionResult {
|
||||
ok: boolean;
|
||||
value?: unknown;
|
||||
error?: {
|
||||
name: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
45
src/agent-kernel/model/__tests__/model-resolver.test.ts
Normal file
45
src/agent-kernel/model/__tests__/model-resolver.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveAgentModel } from '../model-resolver';
|
||||
|
||||
describe('resolveAgentModel', () => {
|
||||
test('uses spawn override before every configured fallback', () => {
|
||||
const model = resolveAgentModel({
|
||||
spawnOverride: 'spawn-model',
|
||||
agentDefinition: { model: 'definition-model' },
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('spawn-model');
|
||||
});
|
||||
|
||||
test('falls back to AgentDefinition.model when spawn override is missing', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: { model: 'definition-model' },
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('definition-model');
|
||||
});
|
||||
|
||||
test('falls back to defaultSubagentModel when AgentDefinition.model is missing', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: {},
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('subagent-default-model');
|
||||
});
|
||||
|
||||
test('falls back to mainAgentModel when no subagent-specific model exists', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: {},
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('main-model');
|
||||
});
|
||||
});
|
||||
2
src/agent-kernel/model/index.ts
Normal file
2
src/agent-kernel/model/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { resolveAgentModel } from './model-resolver';
|
||||
export type { AgentModelResolutionInput } from './model-resolver';
|
||||
17
src/agent-kernel/model/model-resolver.ts
Normal file
17
src/agent-kernel/model/model-resolver.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AgentDefinition } from '../definitions';
|
||||
|
||||
export interface AgentModelResolutionInput {
|
||||
spawnOverride?: string;
|
||||
agentDefinition: Pick<AgentDefinition, 'model'>;
|
||||
defaultSubagentModel?: string;
|
||||
mainAgentModel: string;
|
||||
}
|
||||
|
||||
export function resolveAgentModel(input: AgentModelResolutionInput): string {
|
||||
return (
|
||||
input.spawnOverride ??
|
||||
input.agentDefinition.model ??
|
||||
input.defaultSubagentModel ??
|
||||
input.mainAgentModel
|
||||
);
|
||||
}
|
||||
224
src/agent-kernel/session/__tests__/session-repository.test.ts
Normal file
224
src/agent-kernel/session/__tests__/session-repository.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, getDatabase, initDatabase } from '../../../db/database';
|
||||
import { agentSessionRepository } from '../session-repository';
|
||||
|
||||
describe('agentSessionRepository', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `agent-session-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('migration creates transcript tables and can run idempotently', () => {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name IN (
|
||||
'agent_sessions', 'agent_messages', 'agent_tool_calls', 'agent_invocations'
|
||||
)
|
||||
ORDER BY name`
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual([
|
||||
'agent_invocations',
|
||||
'agent_messages',
|
||||
'agent_sessions',
|
||||
'agent_tool_calls',
|
||||
]);
|
||||
|
||||
closeDatabase();
|
||||
initDatabase();
|
||||
|
||||
const migrationRow = getDatabase()
|
||||
.query('SELECT COUNT(*) AS count FROM _migrations WHERE version = 5')
|
||||
.get() as { count: number };
|
||||
expect(migrationRow.count).toBe(1);
|
||||
});
|
||||
|
||||
test('queries parent-child transcript tree in insertion order', () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'gpt-main',
|
||||
metadata: { requestId: 'req-1' },
|
||||
});
|
||||
const secondMessage = agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'assistant',
|
||||
content: { text: 'second' },
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'user',
|
||||
content: { text: 'first but inserted second' },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: parent.id,
|
||||
messageId: secondMessage.id,
|
||||
toolName: 'search_code',
|
||||
arguments: { query: 'alpha' },
|
||||
result: { matches: 1 },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: parent.id,
|
||||
toolName: 'read_file',
|
||||
arguments: { path: 'src/index.ts' },
|
||||
result: { content: 'ok' },
|
||||
});
|
||||
|
||||
const firstInvocation = agentSessionRepository.createInvocation({
|
||||
parentSessionId: parent.id,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
input: { goal: 'security' },
|
||||
});
|
||||
const secondInvocation = agentSessionRepository.createInvocation({
|
||||
parentSessionId: parent.id,
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
input: { goal: 'quality' },
|
||||
});
|
||||
const child = agentSessionRepository.createSession({
|
||||
parentSessionId: parent.id,
|
||||
parentInvocationId: firstInvocation.id,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: child.id,
|
||||
role: 'assistant',
|
||||
content: { text: 'child transcript' },
|
||||
});
|
||||
agentSessionRepository.completeInvocation({
|
||||
invocationId: firstInvocation.id,
|
||||
status: 'completed',
|
||||
result: { summary: 'done' },
|
||||
childSessionId: child.id,
|
||||
});
|
||||
agentSessionRepository.completeInvocation({
|
||||
invocationId: secondInvocation.id,
|
||||
status: 'failed',
|
||||
error: { message: 'boom' },
|
||||
});
|
||||
agentSessionRepository.completeSession({
|
||||
sessionId: parent.id,
|
||||
status: 'completed',
|
||||
finalResult: { summary: 'parent done' },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.agentType).toBe('main');
|
||||
expect(tree?.messages.map((message) => message.content)).toEqual([
|
||||
{ text: 'second' },
|
||||
{ text: 'first but inserted second' },
|
||||
]);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'search_code',
|
||||
'read_file',
|
||||
]);
|
||||
expect(tree?.invocations.map((invocation) => invocation.agentType)).toEqual([
|
||||
'security-reviewer',
|
||||
'quality-reviewer',
|
||||
]);
|
||||
expect(tree?.invocations[0].childSession?.messages[0].content).toEqual({
|
||||
text: 'child transcript',
|
||||
});
|
||||
expect(tree?.invocations[1].error).toEqual({ message: 'boom' });
|
||||
|
||||
const completedTranscript = agentSessionRepository.getInvocationTranscript(firstInvocation.id);
|
||||
expect(completedTranscript?.invocation.id).toBe(firstInvocation.id);
|
||||
expect(completedTranscript?.childSession?.id).toBe(child.id);
|
||||
expect(completedTranscript?.childSession?.messages[0].content).toEqual({
|
||||
text: 'child transcript',
|
||||
});
|
||||
|
||||
const failedTranscript = agentSessionRepository.getInvocationTranscript(secondInvocation.id);
|
||||
expect(failedTranscript?.invocation.id).toBe(secondInvocation.id);
|
||||
expect(failedTranscript?.childSession).toBeUndefined();
|
||||
expect(agentSessionRepository.getInvocationTranscript('missing-invocation')).toBeNull();
|
||||
});
|
||||
|
||||
test('redacts sensitive JSON fields before storage', () => {
|
||||
const session = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'gpt-main',
|
||||
metadata: {
|
||||
apiKey: 'sk-live',
|
||||
nested: { authorization: 'Bearer token', safe: 'visible' },
|
||||
},
|
||||
});
|
||||
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: session.id,
|
||||
role: 'user',
|
||||
content: { password: 'p4ss', text: 'keep me' },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: session.id,
|
||||
toolName: 'call_provider',
|
||||
arguments: { token: 'tok_123', payload: { secret: 'hidden', value: 1 } },
|
||||
result: { ok: true, refreshToken: 'refresh_123' },
|
||||
});
|
||||
agentSessionRepository.completeSession({
|
||||
sessionId: session.id,
|
||||
status: 'failed',
|
||||
error: { message: 'bad', credentials: { api_key: 'secret-key' } },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(session.id);
|
||||
expect(tree?.metadata).toEqual({
|
||||
apiKey: '[REDACTED]',
|
||||
nested: { authorization: '[REDACTED]', safe: 'visible' },
|
||||
});
|
||||
expect(tree?.messages[0].content).toEqual({ password: '[REDACTED]', text: 'keep me' });
|
||||
expect(tree?.toolCalls[0].arguments).toEqual({
|
||||
token: '[REDACTED]',
|
||||
payload: { secret: '[REDACTED]', value: 1 },
|
||||
});
|
||||
expect(tree?.toolCalls[0].result).toEqual({ ok: true, refreshToken: '[REDACTED]' });
|
||||
expect(tree?.error).toEqual({
|
||||
message: 'bad',
|
||||
credentials: '[REDACTED]',
|
||||
});
|
||||
});
|
||||
|
||||
test('getSessionTreeByRunId finds the correct session tree by reviewRunId', () => {
|
||||
const runId = 'test-run-123';
|
||||
const session = agentSessionRepository.createSession({
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
metadata: { reviewRunId: runId },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTreeByRunId(runId);
|
||||
expect(tree).not.toBeNull();
|
||||
expect(tree?.id).toBe(session.id);
|
||||
expect(tree?.metadata.reviewRunId).toBe(runId);
|
||||
|
||||
const missingTree = agentSessionRepository.getSessionTreeByRunId('missing-run');
|
||||
expect(missingTree).toBeNull();
|
||||
});
|
||||
});
|
||||
18
src/agent-kernel/session/index.ts
Normal file
18
src/agent-kernel/session/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { agentSessionRepository, AgentSessionRepository } from './session-repository';
|
||||
export { redactSensitiveFields } from './redaction';
|
||||
export type {
|
||||
AgentInvocationRecord,
|
||||
AgentInvocationTranscript,
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentSessionStatus,
|
||||
AgentSessionTree,
|
||||
AgentToolCallRecord,
|
||||
AgentToolCallStatus,
|
||||
AppendAgentMessageInput,
|
||||
AppendAgentToolCallInput,
|
||||
CompleteAgentInvocationInput,
|
||||
CompleteAgentSessionInput,
|
||||
CreateAgentInvocationInput,
|
||||
CreateAgentSessionInput,
|
||||
} from './types';
|
||||
36
src/agent-kernel/session/redaction.ts
Normal file
36
src/agent-kernel/session/redaction.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const REDACTED_VALUE = '[REDACTED]';
|
||||
|
||||
const SENSITIVE_KEY_PARTS = [
|
||||
'apikey',
|
||||
'api_key',
|
||||
'authorization',
|
||||
'auth_token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'token',
|
||||
'password',
|
||||
'passwd',
|
||||
'secret',
|
||||
'credential',
|
||||
];
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const normalized = key.replace(/[-\s]/g, '_').toLowerCase();
|
||||
return SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
|
||||
}
|
||||
|
||||
export function redactSensitiveFields<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactSensitiveFields(item)) as T;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
redacted[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactSensitiveFields(childValue);
|
||||
}
|
||||
return redacted as T;
|
||||
}
|
||||
376
src/agent-kernel/session/session-repository.ts
Normal file
376
src/agent-kernel/session/session-repository.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '../../db/database';
|
||||
import { redactSensitiveFields } from './redaction';
|
||||
import type {
|
||||
AgentInvocationRecord,
|
||||
AgentInvocationTranscript,
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentSessionStatus,
|
||||
AgentSessionTree,
|
||||
AgentToolCallRecord,
|
||||
AgentToolCallStatus,
|
||||
AppendAgentMessageInput,
|
||||
AppendAgentToolCallInput,
|
||||
CompleteAgentInvocationInput,
|
||||
CompleteAgentSessionInput,
|
||||
CreateAgentInvocationInput,
|
||||
CreateAgentSessionInput,
|
||||
} from './types';
|
||||
|
||||
interface AgentSessionRow {
|
||||
id: string;
|
||||
parent_session_id: string | null;
|
||||
parent_invocation_id: string | null;
|
||||
agent_type: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
metadata_json: string;
|
||||
final_result_json: string | null;
|
||||
error_json: string | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AgentMessageRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content_json: string;
|
||||
metadata_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AgentToolCallRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
message_id: string | null;
|
||||
sequence: number;
|
||||
tool_name: string;
|
||||
status: AgentToolCallStatus;
|
||||
arguments_json: string;
|
||||
result_json: string | null;
|
||||
error_json: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface AgentInvocationRow {
|
||||
id: string;
|
||||
parent_session_id: string;
|
||||
child_session_id: string | null;
|
||||
sequence: number;
|
||||
agent_type: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
input_json: string;
|
||||
result_json: string | null;
|
||||
error_json: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(redactSensitiveFields(value));
|
||||
}
|
||||
|
||||
function parseJson(value: string | null): unknown | undefined {
|
||||
return value === null ? undefined : JSON.parse(value);
|
||||
}
|
||||
|
||||
function nextSequence(tableName: string, ownerColumn: string, ownerId: string): number {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM ${tableName} WHERE ${ownerColumn} = ?`
|
||||
)
|
||||
.get(ownerId) as { next_sequence: number };
|
||||
return row.next_sequence;
|
||||
}
|
||||
|
||||
function toSessionRecord(row: AgentSessionRow): AgentSessionRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
parentSessionId: row.parent_session_id ?? undefined,
|
||||
parentInvocationId: row.parent_invocation_id ?? undefined,
|
||||
agentType: row.agent_type,
|
||||
model: row.model,
|
||||
status: row.status,
|
||||
metadata: JSON.parse(row.metadata_json) as Record<string, unknown>,
|
||||
finalResult: parseJson(row.final_result_json),
|
||||
error: parseJson(row.error_json),
|
||||
startedAt: row.started_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toMessageRecord(row: AgentMessageRow): AgentMessageRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
sequence: row.sequence,
|
||||
role: row.role,
|
||||
content: JSON.parse(row.content_json),
|
||||
metadata: JSON.parse(row.metadata_json) as Record<string, unknown>,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toToolCallRecord(row: AgentToolCallRow): AgentToolCallRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
messageId: row.message_id ?? undefined,
|
||||
sequence: row.sequence,
|
||||
toolName: row.tool_name,
|
||||
status: row.status,
|
||||
arguments: JSON.parse(row.arguments_json),
|
||||
result: parseJson(row.result_json),
|
||||
error: parseJson(row.error_json),
|
||||
createdAt: row.created_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toInvocationRecord(row: AgentInvocationRow): AgentInvocationRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
parentSessionId: row.parent_session_id,
|
||||
childSessionId: row.child_session_id ?? undefined,
|
||||
sequence: row.sequence,
|
||||
agentType: row.agent_type,
|
||||
model: row.model,
|
||||
status: row.status,
|
||||
input: JSON.parse(row.input_json),
|
||||
result: parseJson(row.result_json),
|
||||
error: parseJson(row.error_json),
|
||||
createdAt: row.created_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class AgentSessionRepository {
|
||||
createSession(input: CreateAgentSessionInput): AgentSessionRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
db.query(
|
||||
`INSERT INTO agent_sessions (
|
||||
id, parent_session_id, parent_invocation_id, agent_type, model, status, metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.parentSessionId ?? null,
|
||||
input.parentInvocationId ?? null,
|
||||
input.agentType,
|
||||
input.model,
|
||||
input.status ?? 'running',
|
||||
stringifyJson(input.metadata ?? {})
|
||||
);
|
||||
|
||||
const session = this.getSession(id);
|
||||
if (!session) throw new Error('Failed to load created agent session');
|
||||
return session;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): AgentSessionRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_sessions WHERE id = ?')
|
||||
.get(sessionId) as AgentSessionRow | null;
|
||||
return row ? toSessionRecord(row) : null;
|
||||
}
|
||||
|
||||
appendMessage(input: AppendAgentMessageInput): AgentMessageRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const sequence = nextSequence('agent_messages', 'session_id', input.sessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_messages (id, session_id, sequence, role, content_json, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.sessionId,
|
||||
sequence,
|
||||
input.role,
|
||||
stringifyJson(input.content),
|
||||
stringifyJson(input.metadata ?? {})
|
||||
);
|
||||
return this.getMessage(id) as AgentMessageRecord;
|
||||
}
|
||||
|
||||
appendToolCall(input: AppendAgentToolCallInput): AgentToolCallRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const status = input.status ?? 'completed';
|
||||
const sequence = nextSequence('agent_tool_calls', 'session_id', input.sessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_tool_calls (
|
||||
id, session_id, message_id, sequence, tool_name, status, arguments_json, result_json, error_json, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.sessionId,
|
||||
input.messageId ?? null,
|
||||
sequence,
|
||||
input.toolName,
|
||||
status,
|
||||
stringifyJson(input.arguments ?? {}),
|
||||
input.result === undefined ? null : stringifyJson(input.result),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
status === 'running' ? null : new Date().toISOString()
|
||||
);
|
||||
return this.getToolCall(id) as AgentToolCallRecord;
|
||||
}
|
||||
|
||||
createInvocation(input: CreateAgentInvocationInput): AgentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const sequence = nextSequence('agent_invocations', 'parent_session_id', input.parentSessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_invocations (
|
||||
id, parent_session_id, child_session_id, sequence, agent_type, model, status, input_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.parentSessionId,
|
||||
input.childSessionId ?? null,
|
||||
sequence,
|
||||
input.agentType,
|
||||
input.model,
|
||||
input.status ?? 'running',
|
||||
stringifyJson(input.input ?? {})
|
||||
);
|
||||
return this.getInvocation(id) as AgentInvocationRecord;
|
||||
}
|
||||
|
||||
completeSession(input: CompleteAgentSessionInput): AgentSessionRecord {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`UPDATE agent_sessions
|
||||
SET status = ?, final_result_json = ?, error_json = ?, completed_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.status,
|
||||
input.finalResult === undefined ? null : stringifyJson(input.finalResult),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
input.sessionId
|
||||
);
|
||||
return this.getSession(input.sessionId) as AgentSessionRecord;
|
||||
}
|
||||
|
||||
completeInvocation(input: CompleteAgentInvocationInput): AgentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`UPDATE agent_invocations
|
||||
SET status = ?, child_session_id = COALESCE(?, child_session_id), result_json = ?, error_json = ?, completed_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.status,
|
||||
input.childSessionId ?? null,
|
||||
input.result === undefined ? null : stringifyJson(input.result),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
input.invocationId
|
||||
);
|
||||
return this.getInvocation(input.invocationId) as AgentInvocationRecord;
|
||||
}
|
||||
|
||||
getSessionTree(rootSessionId: string): AgentSessionTree | null {
|
||||
const session = this.getSession(rootSessionId);
|
||||
if (!session) return null;
|
||||
|
||||
const invocations = this.listInvocations(rootSessionId).map((invocation) => ({
|
||||
...invocation,
|
||||
childSession: invocation.childSessionId
|
||||
? (this.getSessionTree(invocation.childSessionId) ?? undefined)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
...session,
|
||||
messages: this.listMessages(rootSessionId),
|
||||
toolCalls: this.listToolCalls(rootSessionId),
|
||||
invocations,
|
||||
};
|
||||
}
|
||||
|
||||
getSessionTreeByRunId(runId: string): AgentSessionTree | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id FROM agent_sessions
|
||||
WHERE parent_session_id IS NULL
|
||||
AND json_extract(metadata_json, '$.reviewRunId') = ?`
|
||||
)
|
||||
.get(runId) as { id: string } | null;
|
||||
|
||||
if (!row) return null;
|
||||
return this.getSessionTree(row.id);
|
||||
}
|
||||
|
||||
listMessages(sessionId: string): AgentMessageRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_messages WHERE session_id = ? ORDER BY sequence ASC')
|
||||
.all(sessionId) as AgentMessageRow[];
|
||||
return rows.map(toMessageRecord);
|
||||
}
|
||||
|
||||
listToolCalls(sessionId: string): AgentToolCallRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_tool_calls WHERE session_id = ? ORDER BY sequence ASC')
|
||||
.all(sessionId) as AgentToolCallRow[];
|
||||
return rows.map(toToolCallRecord);
|
||||
}
|
||||
|
||||
listInvocations(parentSessionId: string): AgentInvocationRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_invocations WHERE parent_session_id = ? ORDER BY sequence ASC')
|
||||
.all(parentSessionId) as AgentInvocationRow[];
|
||||
return rows.map(toInvocationRecord);
|
||||
}
|
||||
|
||||
getInvocationTranscript(invocationId: string): AgentInvocationTranscript | null {
|
||||
const invocation = this.getInvocation(invocationId);
|
||||
if (!invocation) return null;
|
||||
|
||||
return {
|
||||
invocation,
|
||||
childSession: invocation.childSessionId
|
||||
? (this.getSessionTree(invocation.childSessionId) ?? undefined)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private getMessage(messageId: string): AgentMessageRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_messages WHERE id = ?')
|
||||
.get(messageId) as AgentMessageRow | null;
|
||||
return row ? toMessageRecord(row) : null;
|
||||
}
|
||||
|
||||
private getToolCall(toolCallId: string): AgentToolCallRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_tool_calls WHERE id = ?')
|
||||
.get(toolCallId) as AgentToolCallRow | null;
|
||||
return row ? toToolCallRecord(row) : null;
|
||||
}
|
||||
|
||||
private getInvocation(invocationId: string): AgentInvocationRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_invocations WHERE id = ?')
|
||||
.get(invocationId) as AgentInvocationRow | null;
|
||||
return row ? toInvocationRecord(row) : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentSessionRepository = new AgentSessionRepository();
|
||||
122
src/agent-kernel/session/types.ts
Normal file
122
src/agent-kernel/session/types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
export type AgentSessionStatus = 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
export type AgentToolCallStatus = 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface CreateAgentSessionInput {
|
||||
id?: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status?: AgentSessionStatus;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentSessionRecord {
|
||||
id: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
metadata: Record<string, unknown>;
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AppendAgentMessageInput {
|
||||
id?: string;
|
||||
sessionId: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AppendAgentToolCallInput {
|
||||
id?: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
toolName: string;
|
||||
status?: AgentToolCallStatus;
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentToolCallRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
sequence: number;
|
||||
toolName: string;
|
||||
status: AgentToolCallStatus;
|
||||
arguments: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentInvocationInput {
|
||||
id?: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status?: AgentSessionStatus;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentInvocationRecord {
|
||||
id: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
sequence: number;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
input: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface CompleteAgentSessionInput {
|
||||
sessionId: string;
|
||||
status: Exclude<AgentSessionStatus, 'running'>;
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface CompleteAgentInvocationInput {
|
||||
invocationId: string;
|
||||
status: Exclude<AgentSessionStatus, 'running'>;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
childSessionId?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionTree extends AgentSessionRecord {
|
||||
messages: AgentMessageRecord[];
|
||||
toolCalls: AgentToolCallRecord[];
|
||||
invocations: Array<AgentInvocationRecord & { childSession?: AgentSessionTree }>;
|
||||
}
|
||||
|
||||
export interface AgentInvocationTranscript {
|
||||
invocation: AgentInvocationRecord;
|
||||
childSession?: AgentSessionTree;
|
||||
}
|
||||
408
src/agent-kernel/subagents/__tests__/subagent-runner.test.ts
Normal file
408
src/agent-kernel/subagents/__tests__/subagent-runner.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../../llm/types';
|
||||
import type { AgentDefinition } from '../../definitions';
|
||||
import type { MainAgentModelClient, MainAgentTool, MainAgentToolContext } from '../../loop';
|
||||
import { agentSessionRepository } from '../../session';
|
||||
import type { SpawnSubagentExecutionInput } from '../../tools';
|
||||
import { SubagentRunner } from '../subagent-runner';
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient implements MainAgentModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
const lookupTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'lookup',
|
||||
description: 'Look up a deterministic value.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (argumentsValue) => ({ echoed: (argumentsValue as { query: string }).query }),
|
||||
};
|
||||
|
||||
const parentOnlyTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'parent_only',
|
||||
description: 'A parent-only tool that must not leak into subagents.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => ({ leaked: true }),
|
||||
};
|
||||
|
||||
function agentDefinition(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
|
||||
return {
|
||||
agentType: 'general-purpose',
|
||||
name: 'General Purpose',
|
||||
whenToUse: 'Use for general delegated work.',
|
||||
source: 'built-in',
|
||||
tools: [],
|
||||
disallowedTools: [],
|
||||
skills: [],
|
||||
hooks: {},
|
||||
maxTurns: 4,
|
||||
permissionMode: 'default',
|
||||
background: false,
|
||||
isolation: 'none',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function parentContext(sessionId: string): MainAgentToolContext {
|
||||
return {
|
||||
sessionId,
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: {
|
||||
id: 'call-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: '{}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function executionInput(
|
||||
sessionId: string,
|
||||
overrides: Partial<SpawnSubagentExecutionInput> = {}
|
||||
): SpawnSubagentExecutionInput {
|
||||
const definition = overrides.agentDefinition ?? agentDefinition();
|
||||
return {
|
||||
agentDefinition: definition,
|
||||
agentType: definition.agentType,
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Use lookup, then summarize.',
|
||||
isolation: 'none',
|
||||
parent: parentContext(sessionId),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SubagentRunner', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `subagent-runner-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('runs an isolated child loop and links invocation to the child session', async () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'main-model',
|
||||
metadata: { subagentDepth: 0 },
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'user',
|
||||
content: { text: 'parent prompt only' },
|
||||
});
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-lookup-1', name: 'lookup', arguments: '{"query":"alpha"}' }],
|
||||
}),
|
||||
response({ content: 'child concise summary' }),
|
||||
]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: ['lookup'] }) })
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
summary: 'child concise summary',
|
||||
messagesCount: 4,
|
||||
toolCallCount: 1,
|
||||
artifacts: { invocationId: expect.any(String) },
|
||||
});
|
||||
expect(result).not.toHaveProperty('messages');
|
||||
expect(result).not.toHaveProperty('toolCalls');
|
||||
expect(result).not.toHaveProperty('sessionId');
|
||||
expect(result).not.toHaveProperty('totalTokens');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.messages).toHaveLength(1);
|
||||
expect(tree?.messages[0].content).toEqual({ text: 'parent prompt only' });
|
||||
expect(tree?.toolCalls).toHaveLength(0);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0]).toMatchObject({
|
||||
parentSessionId: parent.id,
|
||||
childSessionId: tree?.invocations[0].childSessionId,
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(result.artifacts?.invocationId).toBe(tree?.invocations[0].id);
|
||||
expect(tree?.invocations[0].result).toEqual(result);
|
||||
const invocationTranscript = agentSessionRepository.getInvocationTranscript(
|
||||
tree?.invocations[0].id ?? 'missing'
|
||||
);
|
||||
expect(invocationTranscript?.invocation.result).toEqual(result);
|
||||
expect(invocationTranscript?.childSession?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(invocationTranscript?.childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
result: { echoed: 'alpha' },
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.parentSessionId).toBe(parent.id);
|
||||
expect(tree?.invocations[0].childSession?.parentInvocationId).toBe(tree?.invocations[0].id);
|
||||
expect(tree?.invocations[0].childSession?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
result: { echoed: 'alpha' },
|
||||
});
|
||||
expect(tree?.invocations[0].input).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: [],
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not leak parent tools into the child model tool definitions', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const modelClient = new FakeModelClient([response({ content: 'no tool needed' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool, parentOnlyTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: ['lookup'] }) })
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(modelClient.requests[0].tools?.map((tool) => tool.name)).toEqual(['lookup']);
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].input).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: ['parent_only'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('persists denied child tool calls as failed unregistered tool calls', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
let lookupExecutions = 0;
|
||||
const countedLookupTool: MainAgentTool = {
|
||||
...lookupTool,
|
||||
execute: () => {
|
||||
lookupExecutions += 1;
|
||||
return { shouldNotRun: true };
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-denied-lookup', name: 'lookup', arguments: '{"query":"blocked"}' }],
|
||||
}),
|
||||
response({ content: 'saw permission error and stopped' }),
|
||||
]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [countedLookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: [] }) })
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCallCount).toBe(1);
|
||||
expect(lookupExecutions).toBe(0);
|
||||
expect(modelClient.requests[0].tools).toEqual([]);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-denied-lookup',
|
||||
content: JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'lookup' is not registered",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
status: 'failed',
|
||||
arguments: { query: 'blocked' },
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'lookup' is not registered",
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: [],
|
||||
deniedToolNames: ['lookup'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('passes model prompt budgets and optional system prompt to MainAgentRunner', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const modelClient = new FakeModelClient([response({ content: 'system-aware result' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
defaultMaxToolCalls: 3,
|
||||
defaultTimeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, {
|
||||
agentDefinition: agentDefinition({
|
||||
agentType: 'code-auditor',
|
||||
model: 'definition-model',
|
||||
maxTurns: 2,
|
||||
getSystemPrompt: () => 'subagent system prompt',
|
||||
}),
|
||||
agentType: 'code-auditor',
|
||||
model: 'override-model',
|
||||
prompt: 'Audit deterministically.',
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(modelClient.requests[0]).toMatchObject({
|
||||
model: 'override-model',
|
||||
messages: [
|
||||
{ role: 'system', content: 'subagent system prompt' },
|
||||
{ role: 'user', content: 'Audit deterministically.' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('completes invocation with structured failure when child loop throws', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const runner = new SubagentRunner({
|
||||
modelClient: new FakeModelClient([]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
});
|
||||
|
||||
const result = await runner.execute(executionInput(parent.id));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'failed',
|
||||
summary: 'No fake model response queued',
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
error: { code: 'Error', message: 'No fake model response queued' },
|
||||
});
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].status).toBe('failed');
|
||||
expect(tree?.invocations[0].error).toEqual(result.error);
|
||||
expect(tree?.invocations[0].childSession?.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('blocks execution and returns structured error when recursion depth exceeds limit', async () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
metadata: { subagentDepth: 1 },
|
||||
});
|
||||
const modelClient = new FakeModelClient([response({ content: 'must not be used' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
maxDepth: 1,
|
||||
});
|
||||
|
||||
const result = await runner.execute(executionInput(parent.id));
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failed',
|
||||
summary: 'Subagent recursion depth limit exceeded (1).',
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: expect.any(String) },
|
||||
error: {
|
||||
code: 'recursion_depth_exceeded',
|
||||
message: 'Subagent recursion depth 2 exceeds max depth 1.',
|
||||
},
|
||||
});
|
||||
expect(modelClient.requests).toHaveLength(0);
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
childSessionId: undefined,
|
||||
result,
|
||||
error: result.error,
|
||||
});
|
||||
expect(tree?.invocations[0].childSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
6
src/agent-kernel/subagents/index.ts
Normal file
6
src/agent-kernel/subagents/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { SubagentRunner } from './subagent-runner';
|
||||
export type { SubagentResult, SubagentResultStatus } from './subagent-result';
|
||||
export type {
|
||||
SubagentRunnerOptions,
|
||||
SubagentRunnerTranscriptRepository,
|
||||
} from './subagent-runner';
|
||||
14
src/agent-kernel/subagents/subagent-result.ts
Normal file
14
src/agent-kernel/subagents/subagent-result.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type SubagentResultStatus = 'completed' | 'failed';
|
||||
|
||||
export interface SubagentResult {
|
||||
status: SubagentResultStatus;
|
||||
summary: string;
|
||||
messagesCount: number;
|
||||
toolCallCount: number;
|
||||
totalTokens?: number;
|
||||
artifacts?: Record<string, unknown>;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
243
src/agent-kernel/subagents/subagent-runner.ts
Normal file
243
src/agent-kernel/subagents/subagent-runner.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { MainAgentRunner } from '../loop';
|
||||
import type {
|
||||
MainAgentModelClient,
|
||||
MainAgentTerminalStatus,
|
||||
MainAgentTool,
|
||||
MainAgentTranscriptRepository,
|
||||
} from '../loop';
|
||||
import type {
|
||||
AgentInvocationRecord,
|
||||
AgentMessageRecord,
|
||||
CompleteAgentInvocationInput,
|
||||
CreateAgentInvocationInput,
|
||||
} from '../session';
|
||||
import type { AgentSessionRecord } from '../session';
|
||||
import { resolveAgentTools } from '../tools';
|
||||
import type { SpawnSubagentExecutionInput, SpawnSubagentExecutor } from '../tools';
|
||||
import type { SubagentResult } from './subagent-result';
|
||||
|
||||
export interface SubagentRunnerTranscriptRepository extends MainAgentTranscriptRepository {
|
||||
createInvocation(input: CreateAgentInvocationInput): AgentInvocationRecord;
|
||||
completeInvocation(input: CompleteAgentInvocationInput): AgentInvocationRecord;
|
||||
getSession?(sessionId: string): AgentSessionRecord | null;
|
||||
listMessages?(sessionId: string): AgentMessageRecord[];
|
||||
}
|
||||
|
||||
export interface SubagentRunnerOptions {
|
||||
modelClient: MainAgentModelClient;
|
||||
transcriptRepository: SubagentRunnerTranscriptRepository;
|
||||
tools?: MainAgentTool[];
|
||||
defaultMaxTurns?: number;
|
||||
defaultMaxToolCalls?: number;
|
||||
defaultTimeoutMs?: number;
|
||||
maxDepth?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function isCompletedStatus(status: MainAgentTerminalStatus): boolean {
|
||||
return status === 'completed';
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): { code: string; message: string } {
|
||||
if (error instanceof Error) {
|
||||
return { code: error.name, message: error.message };
|
||||
}
|
||||
return { code: 'Error', message: String(error) };
|
||||
}
|
||||
|
||||
function readDepth(session: AgentSessionRecord | null | undefined): number {
|
||||
const value = session?.metadata.subagentDepth;
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
function readTotalTokens(messages: AgentMessageRecord[]): number | undefined {
|
||||
let totalTokens = 0;
|
||||
let foundUsage = false;
|
||||
|
||||
for (const message of messages) {
|
||||
const content = message.content;
|
||||
if (typeof content !== 'object' || content === null || !('usage' in content)) continue;
|
||||
|
||||
const usage = (content as { usage?: unknown }).usage;
|
||||
if (typeof usage !== 'object' || usage === null || !('totalTokens' in usage)) continue;
|
||||
|
||||
const value = (usage as { totalTokens?: unknown }).totalTokens;
|
||||
if (typeof value !== 'number') continue;
|
||||
|
||||
totalTokens += value;
|
||||
foundUsage = true;
|
||||
}
|
||||
|
||||
return foundUsage ? totalTokens : undefined;
|
||||
}
|
||||
|
||||
export class SubagentRunner implements SpawnSubagentExecutor {
|
||||
private readonly modelClient: MainAgentModelClient;
|
||||
private readonly transcriptRepository: SubagentRunnerTranscriptRepository;
|
||||
private readonly tools: MainAgentTool[];
|
||||
private readonly defaultMaxTurns: number;
|
||||
private readonly defaultMaxToolCalls: number;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private readonly maxDepth: number;
|
||||
private readonly now?: () => number;
|
||||
|
||||
constructor(options: SubagentRunnerOptions) {
|
||||
this.modelClient = options.modelClient;
|
||||
this.transcriptRepository = options.transcriptRepository;
|
||||
this.tools = options.tools ?? [];
|
||||
this.defaultMaxTurns = options.defaultMaxTurns ?? 4;
|
||||
this.defaultMaxToolCalls = options.defaultMaxToolCalls ?? 8;
|
||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 60_000;
|
||||
this.maxDepth = options.maxDepth ?? 3;
|
||||
this.now = options.now;
|
||||
}
|
||||
|
||||
async execute(input: SpawnSubagentExecutionInput): Promise<SubagentResult> {
|
||||
const toolPermissions = resolveAgentTools({
|
||||
availableTools: this.tools,
|
||||
allowedToolNames: input.agentDefinition.tools,
|
||||
disallowedToolNames: input.agentDefinition.disallowedTools,
|
||||
allowListSpecified: true,
|
||||
});
|
||||
|
||||
const invocation = this.transcriptRepository.createInvocation({
|
||||
parentSessionId: input.parent.sessionId,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
input: {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
isolation: input.isolation,
|
||||
cwd: input.cwd,
|
||||
parentToolCallId: input.parent.toolCall.id,
|
||||
toolPermissions: {
|
||||
allowedToolNames: toolPermissions.allowedToolNames,
|
||||
disallowedToolNames: toolPermissions.disallowedToolNames,
|
||||
deniedToolNames: toolPermissions.deniedToolNames,
|
||||
unknownAllowedToolNames: toolPermissions.unknownAllowedToolNames,
|
||||
unknownDisallowedToolNames: toolPermissions.unknownDisallowedToolNames,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parentSession = this.transcriptRepository.getSession?.(input.parent.sessionId);
|
||||
const childDepth = readDepth(parentSession) + 1;
|
||||
|
||||
if (childDepth > this.maxDepth) {
|
||||
const result: SubagentResult = {
|
||||
status: 'failed',
|
||||
summary: `Subagent recursion depth limit exceeded (${this.maxDepth}).`,
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: invocation.id },
|
||||
error: {
|
||||
code: 'recursion_depth_exceeded',
|
||||
message: `Subagent recursion depth ${childDepth} exceeds max depth ${this.maxDepth}.`,
|
||||
},
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: 'failed',
|
||||
result,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const childSession = this.transcriptRepository.createSession({
|
||||
parentSessionId: input.parent.sessionId,
|
||||
parentInvocationId: invocation.id,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
metadata: {
|
||||
subagentDepth: childDepth,
|
||||
description: input.description,
|
||||
parentToolCallId: input.parent.toolCall.id,
|
||||
toolPermissions: {
|
||||
allowedToolNames: toolPermissions.allowedToolNames,
|
||||
disallowedToolNames: toolPermissions.disallowedToolNames,
|
||||
deniedToolNames: toolPermissions.deniedToolNames,
|
||||
unknownAllowedToolNames: toolPermissions.unknownAllowedToolNames,
|
||||
unknownDisallowedToolNames: toolPermissions.unknownDisallowedToolNames,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: this.modelClient,
|
||||
transcriptRepository: this.transcriptRepository,
|
||||
tools: toolPermissions.tools,
|
||||
now: this.now,
|
||||
});
|
||||
|
||||
try {
|
||||
const runResult = await runner.run({
|
||||
sessionId: childSession.id,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
systemPrompt: input.agentDefinition.getSystemPrompt?.(),
|
||||
userMessage: input.prompt,
|
||||
maxTurns: input.agentDefinition.maxTurns ?? this.defaultMaxTurns,
|
||||
maxToolCalls: this.defaultMaxToolCalls,
|
||||
timeoutMs: this.defaultTimeoutMs,
|
||||
});
|
||||
const totalTokens = this.transcriptRepository.listMessages
|
||||
? readTotalTokens(this.transcriptRepository.listMessages(childSession.id))
|
||||
: undefined;
|
||||
|
||||
const result: SubagentResult = {
|
||||
status: isCompletedStatus(runResult.status) ? 'completed' : 'failed',
|
||||
summary: runResult.finalText ?? runResult.status,
|
||||
messagesCount: runResult.messages.length,
|
||||
toolCallCount: runResult.toolCalls,
|
||||
...(totalTokens === undefined ? {} : { totalTokens }),
|
||||
artifacts: { invocationId: invocation.id },
|
||||
...(isCompletedStatus(runResult.status)
|
||||
? {}
|
||||
: {
|
||||
error: {
|
||||
code: runResult.status,
|
||||
message: `Subagent stopped with status ${runResult.status}.`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: result.status,
|
||||
result,
|
||||
error: result.error,
|
||||
childSessionId: childSession.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error);
|
||||
const result: SubagentResult = {
|
||||
status: 'failed',
|
||||
summary: normalized.message,
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: invocation.id },
|
||||
error: normalized,
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeSession({
|
||||
sessionId: childSession.id,
|
||||
status: 'failed',
|
||||
error: normalized,
|
||||
});
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: 'failed',
|
||||
result,
|
||||
error: normalized,
|
||||
childSessionId: childSession.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
352
src/agent-kernel/tools/__tests__/spawn-subagent-tool.test.ts
Normal file
352
src/agent-kernel/tools/__tests__/spawn-subagent-tool.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../../llm/types';
|
||||
import { createAgentRegistry } from '../../definitions';
|
||||
import { MainAgentRunner } from '../../loop';
|
||||
import type { MainAgentModelClient } from '../../loop';
|
||||
import { agentSessionRepository } from '../../session/session-repository';
|
||||
import { createSpawnSubagentTool } from '../spawn-subagent-tool';
|
||||
import type { SpawnSubagentExecutionInput, SpawnSubagentExecutor } from '../spawn-subagent-tool';
|
||||
|
||||
function agent(agentType: string, name: string, model?: string) {
|
||||
return {
|
||||
agentType,
|
||||
name,
|
||||
whenToUse: `Use ${name}.`,
|
||||
source: 'built-in' as const,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExecutor(result: unknown = { summary: 'subagent done', value: 42 }) {
|
||||
const calls: SpawnSubagentExecutionInput[] = [];
|
||||
const executor: SpawnSubagentExecutor = {
|
||||
execute: (input) => {
|
||||
calls.push(input);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
return { executor, calls };
|
||||
}
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient implements MainAgentModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
describe('createSpawnSubagentTool', () => {
|
||||
test('defaults to general-purpose when subagent_type is omitted', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'definition-model')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Summarize', prompt: 'Summarize the change.' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'definition-model',
|
||||
description: 'Summarize',
|
||||
result: { summary: 'subagent done', value: 42 },
|
||||
summary: 'subagent done',
|
||||
});
|
||||
expect(calls[0]).toMatchObject({
|
||||
agentType: 'general-purpose',
|
||||
model: 'definition-model',
|
||||
description: 'Summarize',
|
||||
prompt: 'Summarize the change.',
|
||||
isolation: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
test('spawns an explicit active subagent type', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [
|
||||
agent('general-purpose', 'General Purpose'),
|
||||
agent('code-reviewer', 'Code Reviewer'),
|
||||
],
|
||||
});
|
||||
const { executor, calls } = makeExecutor({ summary: 'reviewed' });
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: 'Review code',
|
||||
prompt: 'Review this diff.',
|
||||
subagent_type: 'code-reviewer',
|
||||
isolation: 'workspace',
|
||||
cwd: '/tmp/workspace',
|
||||
},
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'completed',
|
||||
agentType: 'code-reviewer',
|
||||
model: 'default-subagent-model',
|
||||
description: 'Review code',
|
||||
summary: 'reviewed',
|
||||
});
|
||||
expect(calls[0]).toMatchObject({
|
||||
agentType: 'code-reviewer',
|
||||
isolation: 'workspace',
|
||||
cwd: '/tmp/workspace',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns a structured error for unknown subagent types', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [
|
||||
agent('general-purpose', 'General Purpose'),
|
||||
agent('code-reviewer', 'Code Reviewer'),
|
||||
],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Unknown', prompt: 'Run missing agent.', subagent_type: 'missing-agent' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
code: 'unknown_subagent_type',
|
||||
message: "Subagent type 'missing-agent' is not active.",
|
||||
requestedType: 'missing-agent',
|
||||
availableTypes: ['code-reviewer', 'general-purpose'],
|
||||
});
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('uses model override before definition and fallback models', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'definition-model')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Override', prompt: 'Use override.', model: 'spawn-model' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'completed', model: 'spawn-model' });
|
||||
expect(calls[0].model).toBe('spawn-model');
|
||||
});
|
||||
|
||||
test('returns a structured unsupported result for background spawns', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Background', prompt: 'Run later.', run_in_background: true },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
code: 'background_execution_unsupported',
|
||||
message:
|
||||
'spawn_subagent background execution is not supported until the isolated SubagentRunner is implemented.',
|
||||
requestedType: 'general-purpose',
|
||||
availableTypes: ['general-purpose'],
|
||||
});
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns a structured validation error for missing required arguments', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose')],
|
||||
});
|
||||
const { executor } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Missing prompt' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'error',
|
||||
code: 'invalid_arguments',
|
||||
message: 'spawn_subagent requires non-empty description and prompt arguments.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawn_subagent MainAgentRunner integration', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `spawn-subagent-tool-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('executes through MainAgentRunner and persists the parent tool call', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'subagent-model')],
|
||||
});
|
||||
const { executor } = makeExecutor({ summary: 'finished by fake executor', value: 'ok' });
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Inspect this issue deterministically.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
response({ content: 'parent final' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
agentType: 'main',
|
||||
model: 'main-model',
|
||||
userMessage: 'delegate investigation',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 4,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(modelClient.requests[0].tools?.map((definition) => definition.name)).toContain(
|
||||
'spawn_subagent'
|
||||
);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-spawn-1',
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
value: {
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
result: { summary: 'finished by fake executor', value: 'ok' },
|
||||
summary: 'finished by fake executor',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls).toHaveLength(1);
|
||||
expect(tree?.toolCalls[0].toolName).toBe('spawn_subagent');
|
||||
expect(tree?.toolCalls[0].arguments).toEqual({
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Inspect this issue deterministically.',
|
||||
});
|
||||
expect(tree?.toolCalls[0].result).toEqual({
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
result: { summary: 'finished by fake executor', value: 'ok' },
|
||||
summary: 'finished by fake executor',
|
||||
});
|
||||
});
|
||||
});
|
||||
101
src/agent-kernel/tools/__tests__/tool-permissions.test.ts
Normal file
101
src/agent-kernel/tools/__tests__/tool-permissions.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { MainAgentTool } from '../../loop';
|
||||
import type { ToolPermissionScope } from '../../loop/types';
|
||||
import {
|
||||
DEFAULT_SCOPE_POLICY,
|
||||
evaluateToolPermission,
|
||||
resolveAgentTools,
|
||||
} from '../tool-permissions';
|
||||
|
||||
function tool(name: string, scope?: ToolPermissionScope): MainAgentTool {
|
||||
return {
|
||||
definition: {
|
||||
name,
|
||||
description: `${name} tool`,
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
permissionScope: scope,
|
||||
execute: () => ({ name }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('evaluateToolPermission', () => {
|
||||
test('allows read scope', () => {
|
||||
expect(evaluateToolPermission(tool('read_file', 'read')).behavior).toBe('allow');
|
||||
});
|
||||
|
||||
test('denies write scope', () => {
|
||||
expect(evaluateToolPermission(tool('write_file', 'write')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('denies command scope', () => {
|
||||
expect(evaluateToolPermission(tool('run_bash', 'command')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('denies network scope', () => {
|
||||
expect(evaluateToolPermission(tool('http_request', 'network')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('defaults to read scope when unspecified', () => {
|
||||
expect(evaluateToolPermission(tool('search_code')).behavior).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAgentTools', () => {
|
||||
const readTool = tool('read_file', 'read');
|
||||
const writeTool = tool('write_file', 'write');
|
||||
const searchTool = tool('search_code', 'read');
|
||||
|
||||
test('includes allowed tool names regardless of scope', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [writeTool],
|
||||
allowedToolNames: ['write_file'],
|
||||
disallowedToolNames: [],
|
||||
});
|
||||
expect(resolved.tools).toHaveLength(1);
|
||||
expect(resolved.tools[0].definition.name).toBe('write_file');
|
||||
});
|
||||
|
||||
test('excludes disallowed tool names regardless of scope', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool],
|
||||
allowedToolNames: ['read_file'],
|
||||
disallowedToolNames: ['read_file'],
|
||||
});
|
||||
expect(resolved.tools).toHaveLength(0);
|
||||
expect(resolved.deniedToolNames).toContain('read_file');
|
||||
});
|
||||
|
||||
test('filters by scope policy when not in allowed/disallowed lists', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool, writeTool, searchTool],
|
||||
allowedToolNames: [],
|
||||
disallowedToolNames: [],
|
||||
});
|
||||
const names = resolved.tools.map((t) => t.definition.name);
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('search_code');
|
||||
expect(names).not.toContain('write_file');
|
||||
});
|
||||
|
||||
test('reports unknown allowed/disallowed names', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool],
|
||||
allowedToolNames: ['missing_tool'],
|
||||
disallowedToolNames: ['ghost_tool'],
|
||||
});
|
||||
expect(resolved.unknownAllowedToolNames).toContain('missing_tool');
|
||||
expect(resolved.unknownDisallowedToolNames).toContain('ghost_tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_SCOPE_POLICY', () => {
|
||||
test('only allows read scope', () => {
|
||||
expect(DEFAULT_SCOPE_POLICY.read).toBe('allow');
|
||||
expect(DEFAULT_SCOPE_POLICY.write).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.command).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.network).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.git_write).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.cross_session).toBe('deny');
|
||||
});
|
||||
});
|
||||
12
src/agent-kernel/tools/index.ts
Normal file
12
src/agent-kernel/tools/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { createSpawnSubagentTool } from './spawn-subagent-tool';
|
||||
export { resolveAgentTools } from './tool-permissions';
|
||||
export type {
|
||||
SpawnSubagentExecutionInput,
|
||||
SpawnSubagentExecutor,
|
||||
SpawnSubagentInput,
|
||||
SpawnSubagentToolOptions,
|
||||
} from './spawn-subagent-tool';
|
||||
export type {
|
||||
ResolvedAgentTools,
|
||||
ResolveAgentToolsInput,
|
||||
} from './tool-permissions';
|
||||
195
src/agent-kernel/tools/spawn-subagent-tool.ts
Normal file
195
src/agent-kernel/tools/spawn-subagent-tool.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { AgentDefinition, AgentIsolation, AgentRegistry } from '../definitions';
|
||||
import type { MainAgentTool, MainAgentToolContext } from '../loop';
|
||||
import { resolveAgentModel } from '../model';
|
||||
|
||||
export interface SpawnSubagentInput {
|
||||
description: string;
|
||||
prompt: string;
|
||||
subagent_type?: string;
|
||||
model?: string;
|
||||
run_in_background?: boolean;
|
||||
isolation?: AgentIsolation;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentExecutionInput {
|
||||
agentDefinition: AgentDefinition;
|
||||
agentType: string;
|
||||
model: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
isolation?: AgentIsolation;
|
||||
cwd?: string;
|
||||
parent: MainAgentToolContext;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentExecutor {
|
||||
execute(input: SpawnSubagentExecutionInput): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentToolOptions {
|
||||
agentRegistry: AgentRegistry;
|
||||
executor: SpawnSubagentExecutor;
|
||||
defaultSubagentModel?: string;
|
||||
}
|
||||
|
||||
type SpawnSubagentToolResult =
|
||||
| {
|
||||
status: 'completed';
|
||||
agentType: string;
|
||||
model: string;
|
||||
description: string;
|
||||
result: unknown;
|
||||
summary?: unknown;
|
||||
}
|
||||
| {
|
||||
status: 'error';
|
||||
code: string;
|
||||
message: string;
|
||||
requestedType?: string;
|
||||
availableTypes?: string[];
|
||||
issues?: string[];
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function parseInput(
|
||||
argumentsValue: unknown
|
||||
): { ok: true; value: SpawnSubagentInput } | { ok: false; issues: string[] } {
|
||||
if (!isRecord(argumentsValue)) {
|
||||
return { ok: false, issues: ['arguments must be an object'] };
|
||||
}
|
||||
|
||||
const issues: string[] = [];
|
||||
const description = optionalString(argumentsValue.description);
|
||||
const prompt = optionalString(argumentsValue.prompt);
|
||||
|
||||
if (!description) issues.push('description is required');
|
||||
if (!prompt) issues.push('prompt is required');
|
||||
|
||||
if (issues.length > 0) return { ok: false, issues };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
description: description as string,
|
||||
prompt: prompt as string,
|
||||
subagent_type: optionalString(argumentsValue.subagent_type),
|
||||
model: optionalString(argumentsValue.model),
|
||||
run_in_background:
|
||||
typeof argumentsValue.run_in_background === 'boolean'
|
||||
? argumentsValue.run_in_background
|
||||
: undefined,
|
||||
isolation: optionalString(argumentsValue.isolation) as AgentIsolation | undefined,
|
||||
cwd: optionalString(argumentsValue.cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function availableTypes(registry: AgentRegistry): string[] {
|
||||
return registry.activeAgents.map((agent) => agent.agentType).sort();
|
||||
}
|
||||
|
||||
function resolveAgentType(
|
||||
input: SpawnSubagentInput,
|
||||
registry: AgentRegistry
|
||||
): AgentDefinition | undefined {
|
||||
const requestedType = input.subagent_type ?? 'general-purpose';
|
||||
return registry.getActiveAgent(requestedType);
|
||||
}
|
||||
|
||||
function extractSummary(result: unknown): unknown {
|
||||
if (isRecord(result) && 'summary' in result) return result.summary;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createSpawnSubagentTool(options: SpawnSubagentToolOptions): MainAgentTool {
|
||||
return {
|
||||
definition: {
|
||||
name: 'spawn_subagent',
|
||||
description:
|
||||
'Spawn a registered subagent with an explicit prompt and return its structured result.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: { type: 'string' },
|
||||
prompt: { type: 'string' },
|
||||
subagent_type: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
run_in_background: { type: 'boolean' },
|
||||
isolation: { type: 'string', enum: ['none', 'workspace', 'process'] },
|
||||
cwd: { type: 'string' },
|
||||
},
|
||||
required: ['description', 'prompt'],
|
||||
},
|
||||
},
|
||||
async execute(argumentsValue, context): Promise<SpawnSubagentToolResult> {
|
||||
const parsed = parseInput(argumentsValue);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'invalid_arguments',
|
||||
message: 'spawn_subagent requires non-empty description and prompt arguments.',
|
||||
issues: parsed.issues,
|
||||
};
|
||||
}
|
||||
|
||||
const input = parsed.value;
|
||||
const requestedType = input.subagent_type ?? 'general-purpose';
|
||||
const agentDefinition = resolveAgentType(input, options.agentRegistry);
|
||||
if (!agentDefinition) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'unknown_subagent_type',
|
||||
message: `Subagent type '${requestedType}' is not active.`,
|
||||
requestedType,
|
||||
availableTypes: availableTypes(options.agentRegistry),
|
||||
};
|
||||
}
|
||||
|
||||
const model = resolveAgentModel({
|
||||
spawnOverride: input.model,
|
||||
agentDefinition,
|
||||
defaultSubagentModel: options.defaultSubagentModel,
|
||||
mainAgentModel: context.model,
|
||||
});
|
||||
|
||||
if (input.run_in_background) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'background_execution_unsupported',
|
||||
message:
|
||||
'spawn_subagent background execution is not supported until the isolated SubagentRunner is implemented.',
|
||||
requestedType: agentDefinition.agentType,
|
||||
availableTypes: availableTypes(options.agentRegistry),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await options.executor.execute({
|
||||
agentDefinition,
|
||||
agentType: agentDefinition.agentType,
|
||||
model,
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
isolation: input.isolation ?? agentDefinition.isolation,
|
||||
cwd: input.cwd,
|
||||
parent: context,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
agentType: agentDefinition.agentType,
|
||||
model,
|
||||
description: input.description,
|
||||
result,
|
||||
summary: extractSummary(result),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
78
src/agent-kernel/tools/tool-permissions.ts
Normal file
78
src/agent-kernel/tools/tool-permissions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { MainAgentTool } from '../loop';
|
||||
import type { ToolPermissionBehavior, ToolPermissionScope } from '../loop/types';
|
||||
|
||||
export interface ResolveAgentToolsInput {
|
||||
availableTools: MainAgentTool[];
|
||||
allowedToolNames: string[];
|
||||
disallowedToolNames: string[];
|
||||
allowListSpecified?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedAgentTools {
|
||||
tools: MainAgentTool[];
|
||||
allowedToolNames: string[];
|
||||
disallowedToolNames: string[];
|
||||
deniedToolNames: string[];
|
||||
unknownAllowedToolNames: string[];
|
||||
unknownDisallowedToolNames: string[];
|
||||
}
|
||||
|
||||
export interface ToolPermissionDecision {
|
||||
behavior: ToolPermissionBehavior;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE_POLICY: Record<ToolPermissionScope, ToolPermissionBehavior> = {
|
||||
read: 'allow',
|
||||
write: 'deny',
|
||||
command: 'deny',
|
||||
network: 'deny',
|
||||
git_write: 'deny',
|
||||
cross_session: 'deny',
|
||||
};
|
||||
|
||||
function uniqueNames(names: string[]): string[] {
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
export function evaluateToolPermission(tool: MainAgentTool): ToolPermissionDecision {
|
||||
const scope = tool.permissionScope ?? 'read';
|
||||
const behavior = DEFAULT_SCOPE_POLICY[scope];
|
||||
return {
|
||||
behavior,
|
||||
reason: `Tool '${tool.definition.name}' ${behavior === 'allow' ? 'allowed' : 'denied'} for scope '${scope}'`,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentTools(input: ResolveAgentToolsInput): ResolvedAgentTools {
|
||||
const availableToolNames = uniqueNames(input.availableTools.map((tool) => tool.definition.name));
|
||||
const availableToolNamesSet = new Set(availableToolNames);
|
||||
const allowedToolNames = uniqueNames(input.allowedToolNames);
|
||||
const disallowedToolNames = uniqueNames(input.disallowedToolNames);
|
||||
const allowedToolNamesSet = new Set(allowedToolNames);
|
||||
const disallowedToolNamesSet = new Set(disallowedToolNames);
|
||||
|
||||
const tools = input.availableTools.filter((tool) => {
|
||||
const toolName = tool.definition.name;
|
||||
if (disallowedToolNamesSet.has(toolName)) return false;
|
||||
if (allowedToolNamesSet.size > 0) return allowedToolNamesSet.has(toolName);
|
||||
if (input.allowListSpecified) return false;
|
||||
return evaluateToolPermission(tool).behavior === 'allow';
|
||||
});
|
||||
const permittedToolNamesSet = new Set(tools.map((tool) => tool.definition.name));
|
||||
|
||||
return {
|
||||
tools,
|
||||
allowedToolNames,
|
||||
disallowedToolNames,
|
||||
deniedToolNames: availableToolNames.filter((toolName) => !permittedToolNamesSet.has(toolName)),
|
||||
unknownAllowedToolNames: allowedToolNames.filter(
|
||||
(toolName) => !availableToolNamesSet.has(toolName)
|
||||
),
|
||||
unknownDisallowedToolNames: disallowedToolNames.filter(
|
||||
(toolName) => !availableToolNamesSet.has(toolName)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export { DEFAULT_SCOPE_POLICY };
|
||||
@@ -82,10 +82,10 @@ describe('ConfigManager (DB backend)', () => {
|
||||
|
||||
test('optional fields with no default return undefined', () => {
|
||||
const cfg = configManager.getCurrent();
|
||||
expect(cfg.feishu.webhookUrl).toBeUndefined();
|
||||
expect(cfg.feishu.webhookSecret).toBeUndefined();
|
||||
expect(cfg.notification.feishu.webhookUrl).toBeUndefined();
|
||||
expect(cfg.notification.feishu.webhookSecret).toBeUndefined();
|
||||
expect(cfg.notification.wecom.webhookUrl).toBeUndefined();
|
||||
expect(cfg.admin.giteaAdminToken).toBeUndefined();
|
||||
expect(cfg.review.qdrantUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns review size thresholds and token budget defaults', () => {
|
||||
@@ -98,6 +98,12 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(cfg.review.tokenBudgetMedium).toBe(45000);
|
||||
expect(cfg.review.tokenBudgetLarge).toBe(120000);
|
||||
});
|
||||
|
||||
test('returns runtime agent model defaults', () => {
|
||||
const cfg = configManager.getCurrent();
|
||||
expect(cfg.review.agentMainModel).toBe('gpt-4.1');
|
||||
expect(cfg.review.agentDefaultSubagentModel).toBe('gpt-4.1-mini');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. setOverrides() / getSource() ─────────────────────────────────────
|
||||
@@ -194,16 +200,6 @@ describe('ConfigManager (DB backend)', () => {
|
||||
// ─── 5. Type conversions ─────────────────────────────────────────────────
|
||||
|
||||
describe('type conversions in getCurrent()', () => {
|
||||
test('boolean field "true" → true', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'true' });
|
||||
expect(configManager.getCurrent().review.enableHumanGate).toBe(true);
|
||||
});
|
||||
|
||||
test('boolean field "false" → false', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'false' });
|
||||
expect(configManager.getCurrent().review.enableHumanGate).toBe(false);
|
||||
});
|
||||
|
||||
test('number field is parsed correctly', async () => {
|
||||
await configManager.setOverrides({ REVIEW_MAX_PARALLEL_RUNS: '4' });
|
||||
expect(configManager.getCurrent().review.maxParallelRuns).toBe(4);
|
||||
@@ -219,6 +215,17 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(configManager.getCurrent().review.tokenBudgetSmall).toBe(22222);
|
||||
});
|
||||
|
||||
test('agent model fields are read from overrides', async () => {
|
||||
await configManager.setOverrides({
|
||||
AGENT_MAIN_MODEL: 'main-override-model',
|
||||
AGENT_DEFAULT_SUBAGENT_MODEL: 'subagent-override-model',
|
||||
});
|
||||
|
||||
const cfg = configManager.getCurrent();
|
||||
expect(cfg.review.agentMainModel).toBe('main-override-model');
|
||||
expect(cfg.review.agentDefaultSubagentModel).toBe('subagent-override-model');
|
||||
});
|
||||
|
||||
test('comma-separated REVIEW_ALLOWED_COMMANDS parsed to array', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ALLOWED_COMMANDS: 'git, rg, cat' });
|
||||
expect(configManager.getCurrent().review.allowedCommands).toEqual(['git', 'rg', 'cat']);
|
||||
|
||||
31
src/config/__tests__/config-schema-agent-model.test.ts
Normal file
31
src/config/__tests__/config-schema-agent-model.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { CONFIG_FIELDS } from '../config-schema';
|
||||
|
||||
function findField(envKey: string) {
|
||||
const field = CONFIG_FIELDS.find((item) => item.envKey === envKey);
|
||||
expect(field).toBeDefined();
|
||||
return field!;
|
||||
}
|
||||
|
||||
describe('config-schema agent model fields', () => {
|
||||
test('AGENT_MAIN_MODEL exists with expected metadata and default', () => {
|
||||
const field = findField('AGENT_MAIN_MODEL');
|
||||
|
||||
expect(field.envKey).toBe('AGENT_MAIN_MODEL');
|
||||
expect(field.group).toBe('review');
|
||||
expect(field.type).toBe('string');
|
||||
expect(field.sensitive).toBe(false);
|
||||
expect(field.defaultValue).toBe('gpt-4.1');
|
||||
});
|
||||
|
||||
test('AGENT_DEFAULT_SUBAGENT_MODEL exists with expected metadata and default', () => {
|
||||
const field = findField('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
|
||||
expect(field.envKey).toBe('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
expect(field.group).toBe('review');
|
||||
expect(field.type).toBe('string');
|
||||
expect(field.sensitive).toBe(false);
|
||||
expect(field.defaultValue).toBe('gpt-4.1-mini');
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,16 @@ export interface AppConfig {
|
||||
apiUrl: string;
|
||||
accessToken: string;
|
||||
};
|
||||
feishu: {
|
||||
webhookUrl: string | undefined;
|
||||
webhookSecret: string | undefined;
|
||||
notification: {
|
||||
feishu: {
|
||||
enabled: boolean;
|
||||
webhookUrl: string | undefined;
|
||||
webhookSecret: string | undefined;
|
||||
};
|
||||
wecom: {
|
||||
enabled: boolean;
|
||||
webhookUrl: string | undefined;
|
||||
};
|
||||
};
|
||||
app: {
|
||||
port: number;
|
||||
@@ -31,14 +38,13 @@ export interface AppConfig {
|
||||
maxParallelRuns: number;
|
||||
maxFilesPerRun: number;
|
||||
maxFileContentChars: number;
|
||||
autoPublishMinConfidence: number;
|
||||
enableHumanGate: boolean;
|
||||
allowedCommands: string[];
|
||||
commandTimeoutMs: number;
|
||||
llmMaxConcurrentCalls: number;
|
||||
llmRetryMaxAttempts: number;
|
||||
llmRetryBaseDelayMs: number;
|
||||
enableTriage: boolean;
|
||||
agentMainModel: string;
|
||||
agentDefaultSubagentModel: string;
|
||||
smallMaxFiles: number;
|
||||
smallMaxChangedLines: number;
|
||||
mediumMaxFiles: number;
|
||||
@@ -46,20 +52,11 @@ export interface AppConfig {
|
||||
tokenBudgetSmall: number;
|
||||
tokenBudgetMedium: number;
|
||||
tokenBudgetLarge: number;
|
||||
// Codex engine
|
||||
codexApiUrl: string;
|
||||
codexApiKey: string | undefined;
|
||||
codexModel: string;
|
||||
codexTimeoutMs: number;
|
||||
codexReviewPrompt: string | undefined;
|
||||
// Memory (shared)
|
||||
qdrantUrl: string | undefined;
|
||||
enableMemory: boolean;
|
||||
fewShotExamplesCount: number;
|
||||
enableReflection: boolean;
|
||||
maxReflectionRounds: number;
|
||||
enableDebate: boolean;
|
||||
debateThreshold: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,9 +134,16 @@ class ConfigManager {
|
||||
apiUrl: values.GITEA_API_URL ?? 'http://localhost:5174/api/v1',
|
||||
accessToken: values.GITEA_ACCESS_TOKEN ?? 'test_token',
|
||||
},
|
||||
feishu: {
|
||||
webhookUrl: values.FEISHU_WEBHOOK_URL,
|
||||
webhookSecret: values.FEISHU_WEBHOOK_SECRET,
|
||||
notification: {
|
||||
feishu: {
|
||||
enabled: toBoolean('FEISHU_ENABLED', true),
|
||||
webhookUrl: values.FEISHU_WEBHOOK_URL,
|
||||
webhookSecret: values.FEISHU_WEBHOOK_SECRET,
|
||||
},
|
||||
wecom: {
|
||||
enabled: toBoolean('WECOM_ENABLED', false),
|
||||
webhookUrl: values.WECOM_WEBHOOK_URL,
|
||||
},
|
||||
},
|
||||
app: {
|
||||
port,
|
||||
@@ -157,8 +161,6 @@ class ConfigManager {
|
||||
maxParallelRuns: toNumber('REVIEW_MAX_PARALLEL_RUNS', 2),
|
||||
maxFilesPerRun: toNumber('REVIEW_MAX_FILES_PER_RUN', 200),
|
||||
maxFileContentChars: toNumber('REVIEW_MAX_FILE_CONTENT_CHARS', 40000),
|
||||
autoPublishMinConfidence: toNumber('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE', 0.8),
|
||||
enableHumanGate: toBoolean('REVIEW_ENABLE_HUMAN_GATE', true),
|
||||
allowedCommands: toStringArray('REVIEW_ALLOWED_COMMANDS', [
|
||||
'git',
|
||||
'rg',
|
||||
@@ -166,11 +168,12 @@ class ConfigManager {
|
||||
'sed',
|
||||
'wc',
|
||||
]),
|
||||
commandTimeoutMs: toNumber('REVIEW_COMMAND_TIMEOUT_MS', 10000),
|
||||
commandTimeoutMs: toNumber('REVIEW_COMMAND_TIMEOUT_MS', 120000),
|
||||
llmMaxConcurrentCalls: toNumber('LLM_MAX_CONCURRENT_CALLS', 4),
|
||||
llmRetryMaxAttempts: toNumber('LLM_RETRY_MAX_ATTEMPTS', 3),
|
||||
llmRetryBaseDelayMs: toNumber('LLM_RETRY_BASE_DELAY_MS', 1000),
|
||||
enableTriage: toBoolean('ENABLE_TRIAGE', true),
|
||||
agentMainModel: values.AGENT_MAIN_MODEL ?? 'gpt-4.1',
|
||||
agentDefaultSubagentModel: values.AGENT_DEFAULT_SUBAGENT_MODEL ?? 'gpt-4.1-mini',
|
||||
smallMaxFiles: toNumber('REVIEW_SMALL_MAX_FILES', 3),
|
||||
smallMaxChangedLines: toNumber('REVIEW_SMALL_MAX_CHANGED_LINES', 80),
|
||||
mediumMaxFiles: toNumber('REVIEW_MEDIUM_MAX_FILES', 10),
|
||||
@@ -178,20 +181,11 @@ class ConfigManager {
|
||||
tokenBudgetSmall: toNumber('REVIEW_TOKEN_BUDGET_SMALL', 12000),
|
||||
tokenBudgetMedium: toNumber('REVIEW_TOKEN_BUDGET_MEDIUM', 45000),
|
||||
tokenBudgetLarge: toNumber('REVIEW_TOKEN_BUDGET_LARGE', 120000),
|
||||
// Codex engine
|
||||
codexApiUrl: values.CODEX_API_URL ?? 'https://api.openai.com/v1',
|
||||
codexApiKey: values.CODEX_API_KEY,
|
||||
codexModel: values.CODEX_MODEL ?? 'o3',
|
||||
codexTimeoutMs: toNumber('CODEX_TIMEOUT_MS', 300000),
|
||||
codexReviewPrompt: values.CODEX_REVIEW_PROMPT,
|
||||
// Memory
|
||||
qdrantUrl: values.QDRANT_URL,
|
||||
enableMemory: toBoolean('ENABLE_MEMORY', false),
|
||||
fewShotExamplesCount: toNumber('FEW_SHOT_EXAMPLES_COUNT', 10),
|
||||
enableReflection: toBoolean('ENABLE_REFLECTION', false),
|
||||
maxReflectionRounds: toNumber('MAX_REFLECTION_ROUNDS', 2),
|
||||
enableDebate: toBoolean('ENABLE_DEBATE', false),
|
||||
debateThreshold: values.DEBATE_THRESHOLD ?? 'high',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConfigGroup = 'gitea' | 'feishu' | 'security' | 'review' | 'memory';
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review';
|
||||
|
||||
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||
|
||||
@@ -43,9 +43,9 @@ export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
icon: 'link',
|
||||
},
|
||||
{
|
||||
key: 'feishu',
|
||||
label: '飞书通知',
|
||||
description: '飞书 Webhook 通知配置',
|
||||
key: 'notification',
|
||||
label: '通知服务',
|
||||
description: '飞书、企业微信等通知渠道配置',
|
||||
icon: 'bell',
|
||||
},
|
||||
{
|
||||
@@ -60,12 +60,6 @@ export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
description: 'Agent 审查模式、并发与沙箱设置',
|
||||
icon: 'file-check',
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆与学习',
|
||||
description: '向量记忆、反思与辩论系统',
|
||||
icon: 'brain',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,24 +104,50 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
sensitive: false,
|
||||
},
|
||||
|
||||
// ── 飞书 ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'FEISHU_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用飞书通知',
|
||||
description: '是否启用飞书通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_URL',
|
||||
group: 'feishu',
|
||||
label: 'Webhook 地址',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 地址',
|
||||
description: '飞书机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_SECRET',
|
||||
group: 'feishu',
|
||||
label: 'Webhook 签名密钥',
|
||||
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,
|
||||
},
|
||||
|
||||
{
|
||||
envKey: 'WEBHOOK_SECRET',
|
||||
group: 'security',
|
||||
@@ -162,7 +182,7 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
group: 'review',
|
||||
label: '审查引擎',
|
||||
description: '代码审查模式:agent(任务化分级编排)或 codex(Codex CLI)',
|
||||
description: '代码审查模式:agent(内置 Agent 审查)或 codex(Codex CLI)',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['agent', 'codex'],
|
||||
@@ -210,26 +230,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
max: 1000000,
|
||||
defaultValue: 40000,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
group: 'review',
|
||||
label: '自动发布置信度',
|
||||
description: '自动发布评论所需的最小置信度(0~1)',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 0,
|
||||
max: 1,
|
||||
defaultValue: 0.8,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ENABLE_HUMAN_GATE',
|
||||
group: 'review',
|
||||
label: '人工审批',
|
||||
description: '是否启用人工审批队列(低置信度评论需人工确认后发布)',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ALLOWED_COMMANDS',
|
||||
group: 'review',
|
||||
@@ -246,9 +246,9 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
description: '单条本地命令的执行超时时间(毫秒)',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1000,
|
||||
min: 120000,
|
||||
max: 300000,
|
||||
defaultValue: 10000,
|
||||
defaultValue: 120000,
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
@@ -284,13 +284,22 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
defaultValue: 1000,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_TRIAGE',
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
group: 'review',
|
||||
label: '启用变更分流',
|
||||
description: '是否启用 Triage 分流(用 Planner 模型先评估变更复杂度,再按需派发 Specialist)',
|
||||
type: 'boolean',
|
||||
label: 'Agent 主模型',
|
||||
description: 'Agent runtime 在没有更具体模型配置时使用的主模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
defaultValue: 'gpt-4.1',
|
||||
},
|
||||
{
|
||||
envKey: 'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
group: 'review',
|
||||
label: 'Subagent 默认模型',
|
||||
description: 'Subagent 未声明模型且 spawn 未覆盖时使用的默认模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: 'gpt-4.1-mini',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_SMALL_MAX_FILES',
|
||||
@@ -416,75 +425,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
},
|
||||
|
||||
// ── 记忆与学习 ──────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'QDRANT_URL',
|
||||
group: 'memory',
|
||||
label: 'Qdrant 地址',
|
||||
description: 'Qdrant 向量数据库的连接 URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_MEMORY',
|
||||
group: 'memory',
|
||||
label: '启用记忆',
|
||||
description: '是否启用向量记忆系统(需配置 Qdrant)',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEW_SHOT_EXAMPLES_COUNT',
|
||||
group: 'memory',
|
||||
label: 'Few-shot 示例数',
|
||||
description: '检索的 few-shot 示例数量',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 0,
|
||||
max: 20,
|
||||
defaultValue: 10,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_REFLECTION',
|
||||
group: 'memory',
|
||||
label: '启用反思',
|
||||
description: '是否启用审查结果自我反思机制',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'MAX_REFLECTION_ROUNDS',
|
||||
group: 'memory',
|
||||
label: '最大反思轮数',
|
||||
description: '反思迭代的最大轮数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1,
|
||||
max: 5,
|
||||
defaultValue: 2,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_DEBATE',
|
||||
group: 'memory',
|
||||
label: '启用辩论',
|
||||
description: '是否启用多视角辩论机制',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'DEBATE_THRESHOLD',
|
||||
group: 'memory',
|
||||
label: '辩论阈值',
|
||||
description: '触发辩论的严重程度阈值',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['high', 'medium'],
|
||||
defaultValue: 'high',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { configManager } from './config-manager';
|
||||
import type { NotificationServiceConfig } from '../services/notification/types.js';
|
||||
import { configManager } from './config-manager.js';
|
||||
|
||||
type AppConfig = import('./config-manager').AppConfig;
|
||||
type AppConfig = import('./config-manager.js').AppConfig;
|
||||
|
||||
const config = new Proxy({} as AppConfig, {
|
||||
get(_target, prop) {
|
||||
@@ -8,5 +9,29 @@ const config = new Proxy({} as AppConfig, {
|
||||
},
|
||||
});
|
||||
|
||||
export function getNotificationConfigs(): NotificationServiceConfig[] {
|
||||
const current = configManager.getCurrent();
|
||||
const configs: NotificationServiceConfig[] = [];
|
||||
|
||||
if (current.notification.feishu.enabled && current.notification.feishu.webhookUrl) {
|
||||
configs.push({
|
||||
provider: 'feishu',
|
||||
enabled: true,
|
||||
webhookUrl: current.notification.feishu.webhookUrl,
|
||||
webhookSecret: current.notification.feishu.webhookSecret,
|
||||
});
|
||||
}
|
||||
|
||||
if (current.notification.wecom.enabled && current.notification.wecom.webhookUrl) {
|
||||
configs.push({
|
||||
provider: 'wecom',
|
||||
enabled: true,
|
||||
webhookUrl: current.notification.wecom.webhookUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
export { configManager };
|
||||
export default config;
|
||||
|
||||
86
src/controllers/__tests__/admin-repositories.test.ts
Normal file
86
src/controllers/__tests__/admin-repositories.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import { repositoryReviewPromptRepo } from '../../db/repositories/repository-review-prompt-repo';
|
||||
import { giteaService } from '../../services/gitea';
|
||||
import { adminController } from '../admin';
|
||||
|
||||
type RepoRecord = { full_name: string };
|
||||
type HookRecord = { id: number; config: { url: string } };
|
||||
|
||||
function createTestApp(): Hono {
|
||||
const app = new Hono();
|
||||
app.route('/admin/api', adminController.protectedRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('admin repositories route', () => {
|
||||
const originalListAllRepositories = giteaService.listAllRepositories;
|
||||
const originalListWebhooks = giteaService.listWebhooks;
|
||||
const originalListProjectPrompts = repositoryReviewPromptRepo.listProjectPrompts;
|
||||
|
||||
beforeEach(() => {
|
||||
const repos: RepoRecord[] = [
|
||||
{ full_name: 'team/inactive-alpha' },
|
||||
{ full_name: 'team/active-beta' },
|
||||
{ full_name: 'team/inactive-gamma' },
|
||||
{ full_name: 'team/active-delta' },
|
||||
];
|
||||
|
||||
giteaService.listAllRepositories = async () => ({
|
||||
repos,
|
||||
totalCount: repos.length,
|
||||
});
|
||||
|
||||
giteaService.listWebhooks = async (_owner: string, repo: string) => {
|
||||
if (repo.startsWith('active-')) {
|
||||
return [{ id: 101, config: { url: 'http://localhost/webhook/gitea' } }] as HookRecord[];
|
||||
}
|
||||
return [] as HookRecord[];
|
||||
};
|
||||
|
||||
repositoryReviewPromptRepo.listProjectPrompts = () => ({
|
||||
'team/active-beta': 'focus security',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
giteaService.listAllRepositories = originalListAllRepositories;
|
||||
giteaService.listWebhooks = originalListWebhooks;
|
||||
repositoryReviewPromptRepo.listProjectPrompts = originalListProjectPrompts;
|
||||
});
|
||||
|
||||
test('returns active webhook repositories first', async () => {
|
||||
const app = createTestApp();
|
||||
const response = await app.request('http://localhost/admin/api/repositories?page=1');
|
||||
const payload = (await response.json()) as {
|
||||
data: Array<{
|
||||
name: string;
|
||||
webhook_status: 'active' | 'inactive';
|
||||
hook_id: number | null;
|
||||
project_review_prompt: string | null;
|
||||
}>;
|
||||
totalCount: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.totalCount).toBe(4);
|
||||
expect(payload.page).toBe(1);
|
||||
expect(payload.limit).toBe(30);
|
||||
expect(payload.data.map((repo) => repo.name)).toEqual([
|
||||
'team/active-beta',
|
||||
'team/active-delta',
|
||||
'team/inactive-alpha',
|
||||
'team/inactive-gamma',
|
||||
]);
|
||||
expect(payload.data.map((repo) => repo.webhook_status)).toEqual([
|
||||
'active',
|
||||
'active',
|
||||
'inactive',
|
||||
'inactive',
|
||||
]);
|
||||
expect(payload.data[0]?.project_review_prompt).toBe('focus security');
|
||||
expect(payload.data[1]?.project_review_prompt).toBeNull();
|
||||
});
|
||||
});
|
||||
77
src/controllers/__tests__/admin-review-runs.test.ts
Normal file
77
src/controllers/__tests__/admin-review-runs.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import { agentSessionRepository } from '../../agent-kernel/session';
|
||||
import { reviewEngine } from '../../review/engine';
|
||||
import { adminController } from '../admin';
|
||||
|
||||
function createTestApp(): Hono {
|
||||
const app = new Hono();
|
||||
app.route('/admin/api', adminController.protectedRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('admin review runs route', () => {
|
||||
const originalGetRunDetails = reviewEngine.getRunDetails;
|
||||
const originalGetSessionTreeByRunId = agentSessionRepository.getSessionTreeByRunId;
|
||||
|
||||
afterEach(() => {
|
||||
reviewEngine.getRunDetails = originalGetRunDetails;
|
||||
agentSessionRepository.getSessionTreeByRunId = originalGetSessionTreeByRunId;
|
||||
});
|
||||
|
||||
test('GET /admin/api/review/runs/:runId returns run details with sessionTree', async () => {
|
||||
const mockRunDetails = {
|
||||
run: {
|
||||
id: 'run-123',
|
||||
status: 'succeeded',
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
};
|
||||
|
||||
const mockSessionTree = {
|
||||
id: 'session-123',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
};
|
||||
|
||||
reviewEngine.getRunDetails = async (runId) => {
|
||||
if (runId === 'run-123') {
|
||||
return mockRunDetails as any;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
agentSessionRepository.getSessionTreeByRunId = (runId) => {
|
||||
if (runId === 'run-123') {
|
||||
return mockSessionTree as any;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const app = createTestApp();
|
||||
const response = await app.request('http://localhost/admin/api/review/runs/run-123');
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.run.id).toBe('run-123');
|
||||
expect(payload.sessionTree.id).toBe('session-123');
|
||||
expect(payload.sessionTree.agentType).toBe('review-main-agent');
|
||||
});
|
||||
|
||||
test('GET /admin/api/review/runs/:runId returns 404 if run not found', async () => {
|
||||
reviewEngine.getRunDetails = async () => null;
|
||||
|
||||
const app = createTestApp();
|
||||
const response = await app.request('http://localhost/admin/api/review/runs/missing-run');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user