mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-06-15 07:26:44 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d0ad6bf20 | ||
|
|
a9c70ab292 | ||
|
|
635eb7a88f | ||
|
|
2ee9f570c4 | ||
|
|
27f4ac6a18 | ||
|
|
44d52cddc5 | ||
|
|
1e38a0e5e0 | ||
|
|
8d6d167b33 | ||
|
|
1e7c80ca9f | ||
|
|
b92765ce7f | ||
|
|
daae32ce07 | ||
|
|
ab984ff415 | ||
|
|
d49a16db6e | ||
|
|
3a97d673f6 | ||
|
|
b6e6ee0927 | ||
|
|
22b603258a | ||
|
|
1885004874 | ||
|
|
d5deb75231 | ||
|
|
c313764b61 | ||
|
|
63f419228e | ||
|
|
f84c0ab777 |
@@ -2,6 +2,10 @@
|
||||
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=
|
||||
|
||||
|
||||
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/
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,3 +1,39 @@
|
||||
# [1.4.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.1...v1.4.0) (2026-05-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent-kernel:** address Oracle review round 2 findings ([27f4ac6](https://github.com/jeffusion/gitea-ai-assistant/commit/27f4ac6a18ec3510575f9234486e2fd5fc72de3c))
|
||||
* **review-agent:** complete fingerprint migration — dual-index all three keys ([2ee9f57](https://github.com/jeffusion/gitea-ai-assistant/commit/2ee9f570c4e43f6fa2901e6a1ff10d79826b7d60))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **agent-kernel:** cherry-pick high-value components from PR [#15](https://github.com/jeffusion/gitea-ai-assistant/issues/15) ([44d52cd](https://github.com/jeffusion/gitea-ai-assistant/commit/44d52cddc5e5301b9ec4ae8ca11ba926dd709cd3)), closes [hi#value](https://github.com/hi/issues/value)
|
||||
|
||||
## [1.3.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.0...v1.3.1) (2026-03-26)
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
|
||||
119
CONTRIBUTING.md
Normal file
119
CONTRIBUTING.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Contributing
|
||||
|
||||
## Development setup
|
||||
|
||||
- **Runtime**: [Bun](https://bun.sh) >= 1.2.5
|
||||
- **Backend**: Hono (TypeScript)
|
||||
- **Frontend**: React + Vite + Tailwind CSS
|
||||
- **Database**: SQLite (via Drizzle ORM)
|
||||
|
||||
```bash
|
||||
bun install # install all dependencies
|
||||
bun run dev # start dev server with hot reload
|
||||
```
|
||||
|
||||
## Code quality
|
||||
|
||||
```bash
|
||||
bun run lint # lint backend + frontend
|
||||
bun test # backend unit tests
|
||||
cd frontend && bun test # frontend unit tests
|
||||
bun run build # build backend
|
||||
cd frontend && bun run build # build frontend
|
||||
E2E_MOCK_LLM=1 bun run test:e2e # E2E with mock LLM (no real provider needed)
|
||||
```
|
||||
|
||||
Run all checks before pushing:
|
||||
|
||||
```bash
|
||||
bun run lint && bun run build && bun test && cd frontend && bun run build && bun test
|
||||
```
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Fork the repository and create a feature branch from `main`
|
||||
2. Make your changes with clear, atomic commits
|
||||
3. Ensure all quality checks pass (lint, build, test)
|
||||
4. Open a PR against `main` with a concise description of the change and motivation
|
||||
|
||||
### Commit style
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
type(scope): subject
|
||||
|
||||
feat(review): add size-based routing
|
||||
fix(webhook): handle missing signature header
|
||||
chore(deps): bump hono to 4.x
|
||||
docs(config): update env variable table
|
||||
```
|
||||
|
||||
Common types: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`.
|
||||
|
||||
## UI development conventions
|
||||
|
||||
The frontend follows a three-layer design token model:
|
||||
|
||||
1. **Primitive** — HSL base values, defined only in global CSS tokens
|
||||
2. **Semantic** — `background`, `foreground`, `success`, `danger`, etc.
|
||||
3. **Component** — Components consume semantic tokens only. Direct primitive references are forbidden
|
||||
|
||||
### Theme definition
|
||||
|
||||
- Theme file: `frontend/src/index.css`
|
||||
- Tailwind mapping: `frontend/tailwind.config.js`
|
||||
- Primary palette: **Cobalt Blue**, with light/dark variants tuned for contrast
|
||||
|
||||
Available palette presets (`data-palette` attribute): `cobalt` (default), `zinc`, `nord`, `tokyo-night`.
|
||||
|
||||
### Hard rules
|
||||
|
||||
- **No hardcoded dark-theme classes** in business TSX: `bg-zinc-*`, `text-zinc-*`, `border-white/10`, etc.
|
||||
- **No inline color values**: `rgba(...)`, `#xxxxxx`, `rgb(...)`
|
||||
- **Status colors use semantic tokens**: `success` / `warning` / `danger` / `info`
|
||||
- **Panels use semantic surface tokens**: `card`, `muted`, `popover`, `background`
|
||||
- **Interactive glow** uses utility classes: `theme-glow-primary|success|warning|danger`
|
||||
- **Non-primary hover** uses `hover:bg-accent*` or `hover:bg-muted*`. Only primary buttons may use `hover:bg-primary/90`
|
||||
|
||||
### Recommended class patterns
|
||||
|
||||
| Context | Classes |
|
||||
|---|---|
|
||||
| Text | `text-foreground` / `text-muted-foreground` |
|
||||
| Panel | `bg-card` / `bg-muted/50` / `bg-popover` |
|
||||
| Border | `border-border` / `theme-border-soft` |
|
||||
| Status | `text-success` / `bg-danger/10` / `border-warning/30` |
|
||||
| Hover (non-primary) | `hover:bg-accent/60` / `hover:bg-muted/60` |
|
||||
| Hover (primary action) | `hover:bg-primary/90` |
|
||||
| Page frame | `theme-page-frame` / `theme-page-actions` / `theme-page-content` |
|
||||
| Card | `theme-card-shell` / `theme-card-header` / `theme-card-content` |
|
||||
| Dialog | `theme-dialog-panel` / `theme-dialog-header` / `theme-dialog-body` / `theme-dialog-footer` |
|
||||
| Error state | `theme-error-panel` |
|
||||
| Sticky bar | `theme-sticky-bar` |
|
||||
| Input surface | `theme-input-surface` |
|
||||
| Control pill | `theme-control-pill` |
|
||||
|
||||
### `destructive` vs `danger`
|
||||
|
||||
- `destructive` — reserved for shadcn built-in destructive variant semantics
|
||||
- `danger` — business status semantics (errors, failures, risk indicators)
|
||||
|
||||
New business components should prefer `danger` to avoid drift.
|
||||
|
||||
### Pre-merge checklist
|
||||
|
||||
- [ ] Page renders correctly in both light and dark themes
|
||||
- [ ] No `zinc`/`white` hardcoded dark-theme classes
|
||||
- [ ] No inline `style` color values
|
||||
- [ ] All status colors use semantic tokens
|
||||
- [ ] Components do not bypass the semantic layer to access primitive colors
|
||||
- [ ] `bun run ui:visual` passes (light/dark visual regression)
|
||||
|
||||
### Visual regression
|
||||
|
||||
- Generate/update baseline: `bun run ui:visual:update`
|
||||
- Verify baseline consistency: `bun run ui:visual`
|
||||
- Full UI check: `bun run ui:regression && bun run ui:visual`
|
||||
|
||||
Baseline snapshots use Linux CI environment (`*-linux.png`). Cross-system snapshot updates introduce noise and should be avoided.
|
||||
@@ -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
|
||||
|
||||
|
||||
246
README.md
246
README.md
@@ -2,248 +2,58 @@
|
||||
|
||||
[](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. Receives webhooks, runs AI review workflows, and posts summary + line-level feedback back to Gitea.
|
||||
|
||||
**[中文文档](./docs/README.zh-CN.md)**
|
||||
[English](./docs/README.md) | [中文](./docs/README.zh-CN.md)
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **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
|
||||
- **Dynamic Agent engine** — main agent autonomously spawns subagents for focused analysis
|
||||
- **Codex engine** — Codex CLI-backed review as an alternative pipeline
|
||||
- **Pluggable LLM providers** — OpenAI Compatible, OpenAI Responses API, Anthropic, Gemini
|
||||
- **Web Admin UI** — runtime configuration for providers, models, webhook, review policy
|
||||
- **Notifications** — Feishu + WeCom (企业微信)
|
||||
- **Security-first** — webhook signature verification + AES-256-GCM encrypted API key storage
|
||||
|
||||
## Architecture
|
||||

|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Gitea Server │────▶│ Gitea Assistant │────▶│ LLM Gateway │
|
||||
│ (Webhooks) │ │ (Hono + Bun) │ │ (Multi-Provider)│
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ├─ OpenAI Compatible
|
||||
┌──────────────────┐ ├─ OpenAI Responses API
|
||||
│ Admin Dashboard │ ├─ Anthropic
|
||||
│ (React SPA) │ └─ Google Gemini
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### Review Engines
|
||||
|
||||
| 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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [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
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
git clone https://github.com/jeffusion/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
bun install # installs frontend via postinstall
|
||||
```
|
||||
|
||||
> `bun install` at repository root now triggers frontend dependency installation automatically via `postinstall`.
|
||||
> If your environment skips lifecycle scripts, run `bun run bootstrap` once.
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a `.env` file with only infrastructure-level settings:
|
||||
Create `.env`:
|
||||
|
||||
```bash
|
||||
# Server port
|
||||
PORT=5174
|
||||
|
||||
# REQUIRED: encryption key for API key storage (generate with: openssl rand -hex 32)
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# Optional: custom database path (default shown)
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
```
|
||||
|
||||
> **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:5174`. On first boot, all settings are seeded with secure defaults automatically.
|
||||
|
||||
See [Configuration Reference](#configuration-reference) for all options.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
bun run dev # Development mode
|
||||
bun run start # Production mode
|
||||
```
|
||||
|
||||
### Setting Up Webhooks
|
||||
|
||||
**Option 1: Admin Dashboard (Recommended)**
|
||||
|
||||
1. Access `http://your-server:5174`
|
||||
2. Log in with the admin password (default: `password` — change it in the dashboard)
|
||||
3. Click "Enable" on repositories to auto-configure webhooks
|
||||
|
||||
**Option 2: Manual Configuration**
|
||||
|
||||
In Gitea repository settings, add a webhook:
|
||||
- **URL**: `http://your-server:5174/webhook/gitea`
|
||||
- **Content Type**: `application/json`
|
||||
- **Secret**: Same as the Webhook Secret configured in the dashboard
|
||||
- **Events**: "Pull Request" and "Status"
|
||||
## Configuration Reference
|
||||
|
||||
### Environment Variables (Minimal)
|
||||
|
||||
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 5174:5174 -v ./data:/app/data -e PORT=5174 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/
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Or apply individually:
|
||||
Open `http://localhost:5174`, login with default password `password` (change it immediately), then configure Gitea, LLM providers, and webhook in the Admin UI.
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
See [Getting Started](./docs/getting-started.md) for full setup walkthrough including webhook configuration.
|
||||
|
||||
**4. Verify**
|
||||
## Documentation
|
||||
|
||||
```bash
|
||||
kubectl -n gitea-assistant get pods
|
||||
kubectl -n gitea-assistant get svc
|
||||
```
|
||||
| Topic | Description |
|
||||
|---|---|
|
||||
| [Getting Started](./docs/getting-started.md) | Full installation and setup walkthrough |
|
||||
| [Configuration](./docs/configuration.md) | Environment variables and Admin UI settings |
|
||||
| [Review Engines](./docs/review-engines.md) | Agent engine, Codex engine, review modes |
|
||||
| [Deployment](./docs/deployment.md) | Docker, Compose, and Kubernetes |
|
||||
| [Screenshots](./docs/screenshots.md) | Admin UI gallery |
|
||||
|
||||
**5. Expose the Service (optional)**
|
||||
## Contributing
|
||||
|
||||
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"}}'
|
||||
```
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for development conventions and UI guidelines.
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
MIT
|
||||
|
||||
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,7 +46,10 @@ services:
|
||||
- NODE_ENV=production
|
||||
- GITEA_API_URL=http://gitea:3000/api/v1
|
||||
- GITEA_ACCESS_TOKEN=${E2E_GITEA_TOKEN:-placeholder}
|
||||
- E2E_MOCK_LLM=1
|
||||
- PORT=5174
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
ports:
|
||||
- "3334:5174"
|
||||
healthcheck:
|
||||
|
||||
@@ -13,9 +13,8 @@ services:
|
||||
- assistant_data:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
LOG_LEVEL: error
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
@@ -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
|
||||
|
||||
21
docs/README.md
Normal file
21
docs/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Documentation
|
||||
|
||||
Setup, configuration, and deployment guides for Gitea AI Assistant.
|
||||
|
||||
## Getting started
|
||||
|
||||
- [Getting Started](./getting-started.md) — full installation, first login, and webhook setup walkthrough
|
||||
- [Screenshots](./screenshots.md) — Admin UI gallery (one page per feature area)
|
||||
|
||||
## Reference
|
||||
|
||||
- [Configuration](./configuration.md) — environment variables, Admin UI settings, and runtime configuration model
|
||||
- [Review Engines](./review-engines.md) — Agent engine, Codex engine, review modes, size policy, and agent definitions
|
||||
|
||||
## Deployment
|
||||
|
||||
- [Deployment](./deployment.md) — Docker, Docker Compose, and Kubernetes
|
||||
|
||||
## Language
|
||||
|
||||
- 中文: [README.zh-CN.md](./README.zh-CN.md)
|
||||
@@ -1,249 +1,21 @@
|
||||
# Gitea AI Assistant
|
||||
# 文档中心
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
基于 AI 的 Gitea 代码审查助手。自动审查 Pull Request 和提交,支持多种 LLM 提供商(OpenAI 兼容、OpenAI Responses API、Anthropic、Google Gemini),提供智能代码质量分析,支持总体评论和行级反馈。
|
||||
|
||||
**[English Documentation](../README.md)**
|
||||
|
||||
## 功能特点
|
||||
|
||||
- 🤖 **AI 代码审查** - 使用可插拔的 LLM 提供商自动审查 PR 和提交
|
||||
- 📝 **行级评论** - 针对具体代码变更的精确反馈
|
||||
- 🔄 **任务化审查引擎** - Agent 分级审查(skip/light/full)+ 可选 Codex CLI 审查模式
|
||||
- 🔔 **飞书通知** - PR 事件通知集成
|
||||
- 🎛️ **管理后台** - 用于管理仓库 Webhook 和 LLM 提供商配置的 Web 界面
|
||||
- 🔐 **安全验证** - HMAC-SHA256 签名验证
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ 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 审查流程 |
|
||||
Gitea AI Assistant 的安装、配置与部署指南。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
- [快速开始](./getting-started.zh-CN.md) — 完整安装、首次登录与 Webhook 配置指引
|
||||
- [截图集](./screenshots.zh-CN.md) — 管理后台界面一览(每个功能页面一张截图)
|
||||
|
||||
- [Bun](https://bun.sh/) >= 1.2.5
|
||||
- 可访问的 Gitea 实例
|
||||
- 至少一个 LLM 提供商的 API 密钥(OpenAI、Anthropic、Google Gemini 或任何 OpenAI 兼容端点)
|
||||
## 参考手册
|
||||
|
||||
### 安装步骤
|
||||
- [配置参考](./configuration.zh-CN.md) — 环境变量、管理后台设置与运行时配置模型
|
||||
- [审查引擎](./review-engines.zh-CN.md) — Agent 引擎、Codex 引擎、审查模式、规模策略与 Agent 定义
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
## 部署
|
||||
|
||||
> 在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装 `frontend` 依赖。
|
||||
> 若你的环境跳过生命周期脚本,请额外执行一次 `bun run bootstrap`。
|
||||
- [部署指南](./deployment.zh-CN.md) — Docker、Docker Compose 与 Kubernetes
|
||||
|
||||
### 配置说明
|
||||
## 语言切换
|
||||
|
||||
创建 `.env` 文件,仅填写基础设施级别的配置:
|
||||
|
||||
```bash
|
||||
# 服务端口
|
||||
PORT=5174
|
||||
|
||||
# 必填:API Key 加密存储密钥(运行 openssl rand -hex 32 生成)
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# 可选:自定义数据库路径(以下为默认值)
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
```
|
||||
|
||||
> **所有其他配置**(Gitea 连接、Webhook 密钥、管理员密码、审查引擎、飞书、记忆系统等)均通过 **Web 管理后台** 在 `http://your-server:5174` 进行配置。首次启动时,所有设置会自动以安全的默认值进行初始化。
|
||||
|
||||
完整配置项请参阅 [配置参考](#配置参考)。
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev # 开发模式
|
||||
bun run start # 生产模式
|
||||
```
|
||||
|
||||
### 配置 Webhook
|
||||
|
||||
**方式一:管理后台(推荐)**
|
||||
|
||||
1. 在浏览器中访问 `http://your-server:5174`
|
||||
2. 使用管理员密码登录(默认:`password`,请在后台及时修改)
|
||||
3. 点击仓库对应的「启用」按钮自动配置 Webhook
|
||||
|
||||
**方式二:手动配置**
|
||||
|
||||
在 Gitea 仓库设置中添加 Webhook:
|
||||
- **URL**: `http://your-server:5174/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 5174:5174 -v ./data:/app/data -e PORT=5174 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 |
82
docs/configuration.md
Normal file
82
docs/configuration.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Configuration Reference
|
||||
|
||||
## Configuration model
|
||||
|
||||
This project uses a **DB-first** runtime configuration model:
|
||||
|
||||
- `.env` stores only infrastructure-level bootstrap values
|
||||
- Runtime settings (Gitea, providers, secrets, review policy, notifications) are managed in Admin UI and persisted to SQLite
|
||||
|
||||
This means you configure most settings through the web dashboard after first boot, not through environment variables.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | Yes | AES-256-GCM master key for API key encryption (64 hex chars) | — |
|
||||
| `PORT` | No | Service port | `5174` |
|
||||
| `DATABASE_PATH` | No | SQLite database path | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | No | Backend log level: `debug` / `info` / `warn` / `error` | `info` |
|
||||
|
||||
Generate encryption key:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## First boot defaults
|
||||
|
||||
When the database is empty on first launch:
|
||||
|
||||
- `JWT_SECRET` — auto-generated
|
||||
- `WEBHOOK_SECRET` — auto-generated
|
||||
- `ADMIN_PASSWORD` — defaults to `password` (**change immediately after login**)
|
||||
|
||||
## Admin UI settings
|
||||
|
||||
All settings below are configured through the Admin UI at `http://your-server:5174`.
|
||||
|
||||
### Gitea
|
||||
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| API URL | Gitea API endpoint (e.g. `http://gitea:3000/api/v1`) |
|
||||
| Access Token | Token for cloning repos and posting comments |
|
||||
| Admin Token | Optional; required for repository discovery |
|
||||
|
||||
### Security
|
||||
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| Webhook Secret | HMAC-SHA256 key for verifying incoming webhooks |
|
||||
| Admin Password | Dashboard login password |
|
||||
| JWT Secret | Token signing key (auto-generated on first boot) |
|
||||
|
||||
### LLM
|
||||
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| Providers | Add one or more providers: OpenAI Compatible / OpenAI Responses / Anthropic / Gemini |
|
||||
| `AGENT_MAIN_MODEL` | Default model for the main agent runtime. Default: `gpt-4.1` |
|
||||
| `AGENT_DEFAULT_SUBAGENT_MODEL` | Default model for subagents when not declared in definition or spawn. Default: `gpt-4.1-mini` |
|
||||
|
||||
Model resolution order: `spawn override > AgentDefinition.model > AGENT_DEFAULT_SUBAGENT_MODEL > AGENT_MAIN_MODEL`
|
||||
|
||||
### Notifications
|
||||
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| Feishu Webhook | Feishu bot webhook URL and optional signing secret |
|
||||
| WeCom Webhook | WeCom (企业微信) bot webhook URL |
|
||||
|
||||
### Review
|
||||
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| Engine | `agent` or `codex` |
|
||||
| Size thresholds | `small` / `medium` / `large` — classifies change size |
|
||||
| Execution modes | `skip` / `light` / `full` — controls review depth |
|
||||
| Token budgets | Per-mode token limits |
|
||||
| Concurrency | Max parallel review runs |
|
||||
|
||||
> Size and mode are separate layers: `small/medium/large` classifies how big the change is; `skip/light/full` controls how deeply the engine reviews it.
|
||||
82
docs/configuration.zh-CN.md
Normal file
82
docs/configuration.zh-CN.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 配置参考
|
||||
|
||||
## 配置模型
|
||||
|
||||
项目采用 **DB-first** 运行时配置模型:
|
||||
|
||||
- `.env` 仅存储基础设施级引导参数
|
||||
- 运行时配置(Gitea、Provider、密钥、审查策略、通知)由管理后台维护并持久化到 SQLite
|
||||
|
||||
即大部分设置在首次启动后通过 Web 管理后台配置,而非环境变量。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 必填 | 说明 | 默认值 |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥(AES-256-GCM,64 位十六进制) | — |
|
||||
| `PORT` | 否 | 服务端口 | `5174` |
|
||||
| `DATABASE_PATH` | 否 | SQLite 数据库路径 | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | 否 | 后端日志级别:`debug` / `info` / `warn` / `error` | `info` |
|
||||
|
||||
生成加密密钥:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
## 首次启动默认值
|
||||
|
||||
数据库为空时首次启动:
|
||||
|
||||
- `JWT_SECRET` — 自动生成
|
||||
- `WEBHOOK_SECRET` — 自动生成
|
||||
- `ADMIN_PASSWORD` — 默认 `password`(**登录后请立即修改**)
|
||||
|
||||
## 管理后台设置
|
||||
|
||||
以下所有设置均通过管理后台 `http://your-server:5174` 配置。
|
||||
|
||||
### Gitea
|
||||
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| API URL | Gitea API 端点(如 `http://gitea:3000/api/v1`) |
|
||||
| Access Token | 用于克隆仓库和发布评论的令牌 |
|
||||
| Admin Token | 可选;仓库发现功能需要 |
|
||||
|
||||
### 安全
|
||||
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| Webhook Secret | HMAC-SHA256 签名验证密钥 |
|
||||
| Admin Password | 管理后台登录密码 |
|
||||
| JWT Secret | Token 签名密钥(首次启动自动生成) |
|
||||
|
||||
### LLM
|
||||
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| Provider | 添加一个或多个提供商:OpenAI Compatible / OpenAI Responses / Anthropic / Gemini |
|
||||
| `AGENT_MAIN_MODEL` | 主 Agent 运行时默认模型。默认值:`gpt-4.1` |
|
||||
| `AGENT_DEFAULT_SUBAGENT_MODEL` | 子 Agent 未声明模型且 spawn 未覆盖时的默认模型。默认值:`gpt-4.1-mini` |
|
||||
|
||||
模型解析顺序:`spawn 覆盖 > AgentDefinition.model > AGENT_DEFAULT_SUBAGENT_MODEL > AGENT_MAIN_MODEL`
|
||||
|
||||
### 通知
|
||||
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| Feishu Webhook | 飞书机器人 Webhook URL 及可选签名密钥 |
|
||||
| WeCom Webhook | 企业微信机器人 Webhook URL |
|
||||
|
||||
### 审查
|
||||
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| 引擎 | `agent` 或 `codex` |
|
||||
| 规模阈值 | `small` / `medium` / `large` — 变更规模分类 |
|
||||
| 执行模式 | `skip` / `light` / `full` — 审查深度控制 |
|
||||
| Token 预算 | 各模式 Token 限额 |
|
||||
| 并发限制 | 最大并行审查数 |
|
||||
|
||||
> 规模与模式是两个层次:`small/medium/large` 分类变更的大小;`skip/light/full` 控制审查的深度。
|
||||
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"}}'
|
||||
```
|
||||
@@ -1,617 +0,0 @@
|
||||
# 通知服务抽象化重构方案
|
||||
|
||||
## 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
|
||||
**状态**: 已实施(持续验证中)
|
||||
@@ -1,836 +0,0 @@
|
||||
# 技术设计文档:可插拔 LLM Provider 架构
|
||||
|
||||
> **状态**: Draft
|
||||
> **作者**: AI Architect
|
||||
> **日期**: 2026-03-04
|
||||
> **相关 Issue**: N/A
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [0. 设计原则](#0-设计原则)
|
||||
- [1. 目录结构](#1-目录结构新增改动部分)
|
||||
- [2. 数据库表结构](#2-数据库表结构sqlite-ddl)
|
||||
- [3. LLM Gateway 核心接口](#3-llm-gateway-核心-typescript-接口)
|
||||
- [4. Provider Adapter 差异映射](#4-四个-provider-adapter-核心差异映射)
|
||||
- [5. 后端 REST API 契约](#5-后端-rest-api-契约)
|
||||
- [6. 密钥安全设计](#6-密钥安全设计)
|
||||
- [7. 前端配置页设计](#7-前端配置页设计)
|
||||
- [8. 现有调用点改造清单](#8-现有调用点改造清单)
|
||||
- [9. 实施阶段建议](#9-实施阶段建议)
|
||||
- [10. 风险与缓解](#10-风险与缓解)
|
||||
|
||||
---
|
||||
|
||||
## 0. 设计原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|---|---|
|
||||
| **UI-Only 配置** | 所有业务配置仅通过 Web 管理后台设置,不再有环境变量覆盖层(仅保留极少数启动参数如 `PORT`、`WEBHOOK_SECRET`、`DATABASE_PATH`) |
|
||||
| **4 Provider 并存** | `openai_compatible`(现有兼容格式)、`openai_responses`(Responses API)、`anthropic`(Messages API)、`gemini`(generateContent API) |
|
||||
| **SQLite 持久化** | 使用 `bun:sqlite` 零依赖,单文件 `data/assistant.db` |
|
||||
| **密钥应用层加密** | API Key 使用 AES-256-GCM 加密后存 DB;主密钥通过环境变量 `ENCRYPTION_KEY` 传入(hex 编码,64 字符 = 32 字节),未设置则拒绝启动 |
|
||||
| **不做向前兼容** | 旧 JSON 配置文件方案直接废弃,新版本仅支持数据库配置 |
|
||||
|
||||
### 开源参考
|
||||
|
||||
| 借鉴点 | 参考项目 | 具体模式 |
|
||||
|---|---|---|
|
||||
| 接口先行 + provider registry | [Vercel AI SDK](https://github.com/vercel/ai) | `LanguageModelV3` spec-first adapter,版本化接口 |
|
||||
| Provider transformation 差异映射 | [LiteLLM](https://github.com/BerriAI/litellm) | 每个 provider 独立 `transformation.py`,标准化 error/usage |
|
||||
| Runtime factory + 多 provider 配置 | [LobeChat](https://github.com/lobehub/lobe-chat) | `createOpenAICompatibleRuntime` 工厂 + 动态 model list |
|
||||
| 配置驱动多 endpoint | [LibreChat](https://github.com/danny-avila/LibreChat) | `librechat.yaml` Custom Endpoints 模式 |
|
||||
| 能力声明/能力检测 | [Continue](https://github.com/continuedev/continue) | `supportsTools` / `supportsImages` 声明式 capability |
|
||||
|
||||
---
|
||||
|
||||
## 1. 目录结构(新增/改动部分)
|
||||
|
||||
```
|
||||
src/
|
||||
├── db/
|
||||
│ ├── database.ts # bun:sqlite 初始化
|
||||
│ ├── migrations/
|
||||
│ │ └── 001_init.ts # 建表 DDL
|
||||
│ └── repositories/
|
||||
│ ├── provider-repo.ts # llm_providers CRUD
|
||||
│ ├── model-role-repo.ts # model_role_assignments CRUD
|
||||
│ ├── secret-repo.ts # 加密 read/write
|
||||
│ └── settings-repo.ts # system_settings KV
|
||||
│
|
||||
├── llm/
|
||||
│ ├── types.ts # 统一内部请求/响应类型
|
||||
│ ├── capabilities.ts # 能力声明枚举
|
||||
│ ├── errors.ts # LLM 层标准化错误
|
||||
│ ├── gateway.ts # LLM Gateway 入口(按 provider 路由)
|
||||
│ ├── tool-converter.ts # 工具定义 → 各 provider 格式转换
|
||||
│ └── providers/
|
||||
│ ├── base.ts # LLMProvider 抽象接口
|
||||
│ ├── openai-compatible.ts # 现有兼容格式 adapter
|
||||
│ ├── openai-responses.ts # OpenAI Responses API adapter
|
||||
│ ├── anthropic.ts # Anthropic Messages API adapter
|
||||
│ └── gemini.ts # Gemini generateContent adapter
|
||||
│
|
||||
├── crypto/
|
||||
│ └── secrets.ts # AES-256-GCM 加解密 + master key 管理
|
||||
│
|
||||
├── controllers/
|
||||
│ └── llm-config.ts # 新 REST API(替代 config.ts 中 LLM 部分)
|
||||
│
|
||||
└── config/
|
||||
├── config-manager.ts # 精简:只管非 LLM 配置(gitea/feishu/app/admin/review 非模型部分)
|
||||
└── config-schema.ts # 移除 openai group,LLM 配置全部走 DB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库表结构(SQLite DDL)
|
||||
|
||||
### 2.1 ER 关系
|
||||
|
||||
```
|
||||
llm_providers 1 ←──── 1 llm_secrets (每个 provider 一个加密 key)
|
||||
llm_providers 1 ←──── N model_role_assignments (一个 provider 可服务多个角色)
|
||||
```
|
||||
|
||||
### 2.2 完整 DDL
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- 表1: llm_providers — Provider 实例配置
|
||||
-- ============================================================
|
||||
CREATE TABLE llm_providers (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
||||
name TEXT NOT NULL, -- 用户自定义显示名,如 "公司内部 OpenAI 代理"
|
||||
type TEXT NOT NULL CHECK (type IN (
|
||||
'openai_compatible', -- 现有兼容格式(自定义 baseUrl)
|
||||
'openai_responses', -- OpenAI 标准 Responses API
|
||||
'anthropic', -- Anthropic Messages API
|
||||
'gemini' -- Google Gemini generateContent
|
||||
)),
|
||||
base_url TEXT, -- 可选自定义 endpoint(openai_compatible 必填)
|
||||
default_model TEXT NOT NULL, -- 此 provider 的默认模型 ID
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1, -- 0=禁用, 1=启用
|
||||
extra_config TEXT DEFAULT '{}', -- JSON: provider 特有配置(如 api_version, project_id 等)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 表2: llm_secrets — 加密存储的 API Key
|
||||
-- ============================================================
|
||||
CREATE TABLE llm_secrets (
|
||||
provider_id TEXT PRIMARY KEY REFERENCES llm_providers(id) ON DELETE CASCADE,
|
||||
ciphertext BLOB NOT NULL, -- AES-256-GCM 密文
|
||||
iv BLOB NOT NULL, -- 12 bytes nonce
|
||||
auth_tag BLOB NOT NULL, -- 16 bytes GCM tag
|
||||
key_version INTEGER NOT NULL DEFAULT 1, -- 主密钥版本号(便于密钥轮换)
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 表3: model_role_assignments — 场景 → 模型映射
|
||||
-- ============================================================
|
||||
-- 每个业务场景(如 planner/specialist/judge/embedding)绑定到
|
||||
-- 一个 provider + 具体 model,支持不同场景用不同 provider。
|
||||
CREATE TABLE model_role_assignments (
|
||||
role TEXT PRIMARY KEY CHECK (role IN (
|
||||
'planner', -- Agent 审查 planner
|
||||
'specialist', -- Agent 审查 specialist
|
||||
'judge', -- Agent 审查 judge
|
||||
'embedding' -- 向量嵌入(Qdrant)
|
||||
)),
|
||||
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
|
||||
model TEXT NOT NULL, -- 该场景使用的具体模型 ID
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 表4: system_settings — 通用 KV 设置
|
||||
-- ============================================================
|
||||
-- 存放非 LLM 的业务配置(由 UI 直接写入 DB)
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
is_sensitive INTEGER NOT NULL DEFAULT 0, -- 1=加密存储(复用 crypto 模块)
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_providers_type ON llm_providers(type);
|
||||
CREATE INDEX idx_providers_enabled ON llm_providers(is_enabled);
|
||||
```
|
||||
|
||||
### 2.3 字段说明补充
|
||||
|
||||
| 表.字段 | 说明 |
|
||||
|---|---|
|
||||
| `llm_providers.type` | 决定使用哪个 adapter 实现 |
|
||||
| `llm_providers.base_url` | `openai_compatible` 类型必填(用户自建代理地址);其他类型可选覆盖官方默认 endpoint |
|
||||
| `llm_providers.extra_config` | JSON 字段,存放 provider 特有参数。例如 Gemini 的 `projectId`、OpenAI 的 `organization`、Anthropic 的 `anthropic-version` header 等 |
|
||||
| `llm_secrets.key_version` | 用于密钥轮换:当 `ENCRYPTION_KEY` 更新后,启动时批量重加密所有 `key_version < current` 的记录 |
|
||||
| `model_role_assignments.role` | 业务角色枚举,对应代码中不同调用场景 |
|
||||
| `system_settings.is_sensitive` | 为 1 时 value 字段存密文(复用 `crypto/secrets.ts`),GET API 返回 masked |
|
||||
|
||||
---
|
||||
|
||||
## 3. LLM Gateway 核心 TypeScript 接口
|
||||
|
||||
### 3.1 统一消息与请求/响应类型
|
||||
|
||||
```typescript
|
||||
// ── src/llm/types.ts ────────────────────────────────────────
|
||||
|
||||
/** 模型角色枚举 */
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
|
||||
|
||||
/** 统一消息格式(内部表达,不暴露 provider 差异) */
|
||||
export interface LLMMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
toolCallId?: string; // role=tool 时关联的 tool call ID
|
||||
toolCalls?: LLMToolCall[]; // role=assistant 时返回的 tool calls
|
||||
}
|
||||
|
||||
export interface LLMToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string; // JSON string
|
||||
}
|
||||
|
||||
/** 工具定义(内部通用格式,由 tool-converter.ts 转为各 provider 格式) */
|
||||
export interface LLMToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>; // JSON Schema
|
||||
}
|
||||
|
||||
/** 统一请求 */
|
||||
export interface LLMChatRequest {
|
||||
messages: LLMMessage[];
|
||||
model: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
responseFormat?: 'text' | 'json'; // 抽象 JSON mode
|
||||
tools?: LLMToolDefinition[];
|
||||
/** provider 透传配置(如 Anthropic thinking、Gemini safetySettings) */
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 统一响应 */
|
||||
export interface LLMChatResponse {
|
||||
content: string | null;
|
||||
toolCalls: LLMToolCall[];
|
||||
finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'error';
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
raw?: unknown; // 保留原始响应供调试
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 能力模型
|
||||
|
||||
```typescript
|
||||
// ── src/llm/capabilities.ts ─────────────────────────────────
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
/** 是否支持 tool/function calling */
|
||||
supportsTools: boolean;
|
||||
/** 是否支持原生 JSON mode(vs 需要 prompt 指令 + 手动解析) */
|
||||
supportsJsonMode: boolean;
|
||||
/** 是否支持 SSE streaming */
|
||||
supportsStreaming: boolean;
|
||||
/** 是否支持 embedding 接口 */
|
||||
supportsEmbeddings: boolean;
|
||||
/** 是否支持图片/多模态输入 */
|
||||
supportsMultimodal: boolean;
|
||||
/** 最大输入 token 数(用于预校验,避免无效调用) */
|
||||
maxInputTokens?: number;
|
||||
}
|
||||
|
||||
/** 各 provider 默认能力声明 */
|
||||
export const DEFAULT_CAPABILITIES: Record<string, ProviderCapabilities> = {
|
||||
openai_compatible: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: true,
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: true,
|
||||
supportsMultimodal: false, // 取决于具体模型
|
||||
},
|
||||
openai_responses: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: true,
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: true,
|
||||
supportsMultimodal: true,
|
||||
},
|
||||
anthropic: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: false, // 无原生 JSON mode,需 prompt 指令
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: false,
|
||||
supportsMultimodal: true,
|
||||
},
|
||||
gemini: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: true, // responseMimeType: 'application/json'
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: true,
|
||||
supportsMultimodal: true,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3 Provider 抽象接口
|
||||
|
||||
```typescript
|
||||
// ── src/llm/providers/base.ts ───────────────────────────────
|
||||
|
||||
import type { ProviderCapabilities } from '../capabilities';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../types';
|
||||
|
||||
export interface LLMProvider {
|
||||
/** Provider 类型标识 */
|
||||
readonly type: string;
|
||||
|
||||
/** 能力声明 */
|
||||
readonly capabilities: ProviderCapabilities;
|
||||
|
||||
/**
|
||||
* 核心调用方法。Gateway 只调用此方法。
|
||||
* 各 adapter 负责:
|
||||
* 1. 将 LLMChatRequest 转为 provider 原生格式
|
||||
* 2. 发 HTTP / SDK 调用
|
||||
* 3. 将原生响应转为 LLMChatResponse
|
||||
*/
|
||||
chat(request: LLMChatRequest): Promise<LLMChatResponse>;
|
||||
|
||||
/** 可选:嵌入接口 */
|
||||
embed?(texts: string[]): Promise<number[][]>;
|
||||
}
|
||||
|
||||
/** Provider 工厂函数签名:从 DB 配置 + 解密后 apiKey 创建实例 */
|
||||
export type ProviderFactory = (config: {
|
||||
baseUrl?: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
extraConfig: Record<string, unknown>;
|
||||
}) => LLMProvider;
|
||||
```
|
||||
|
||||
### 3.4 Gateway 入口
|
||||
|
||||
```typescript
|
||||
// ── src/llm/gateway.ts ──────────────────────────────────────
|
||||
|
||||
import type { ModelRole, LLMChatRequest, LLMChatResponse } from './types';
|
||||
import type { LLMProvider } from './providers/base';
|
||||
|
||||
/**
|
||||
* LLM Gateway — 业务层唯一入口
|
||||
*
|
||||
* 职责:
|
||||
* 1. 根据 role 查询 model_role_assignments → provider_id + model
|
||||
* 2. 从 provider 缓存获取(或按需创建)LLMProvider 实例
|
||||
* 3. 调用 provider.chat() 并返回统一响应
|
||||
* 4. 如果 provider 配置变更(UI 保存时),invalidate 缓存
|
||||
*/
|
||||
export class LLMGateway {
|
||||
/** provider 实例缓存(provider_id → LLMProvider) */
|
||||
private cache = new Map<string, LLMProvider>();
|
||||
|
||||
/**
|
||||
* 按业务角色调用 LLM
|
||||
* @param role 业务角色(planner/specialist/judge/embedding)
|
||||
* @param request 请求(不含 model,由角色映射决定)
|
||||
*/
|
||||
async chatForRole(
|
||||
role: ModelRole,
|
||||
request: Omit<LLMChatRequest, 'model'>
|
||||
): Promise<LLMChatResponse>;
|
||||
|
||||
/**
|
||||
* 用指定 provider 直接调用(连通性测试用)
|
||||
*/
|
||||
async chatDirect(
|
||||
providerId: string,
|
||||
request: LLMChatRequest
|
||||
): Promise<LLMChatResponse>;
|
||||
|
||||
/**
|
||||
* 获取指定 provider 的 embedding 接口
|
||||
*/
|
||||
async embedForRole(
|
||||
role: 'embedding',
|
||||
texts: string[]
|
||||
): Promise<number[][]>;
|
||||
|
||||
/** 配置变更时清除单个 provider 缓存 */
|
||||
invalidateProvider(providerId: string): void;
|
||||
|
||||
/** 清除全部缓存(全局配置变更时) */
|
||||
invalidateAll(): void;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 四个 Provider Adapter 核心差异映射
|
||||
|
||||
### 4.1 总览对照表
|
||||
|
||||
| 特性 | openai_compatible | openai_responses | anthropic | gemini |
|
||||
|---|---|---|---|---|
|
||||
| **SDK/HTTP** | `openai` npm (`chat.completions`) | `openai` npm (`responses.create`) | `@anthropic-ai/sdk` | `@google/generative-ai` 或 REST |
|
||||
| **系统指令** | `messages[0].role='system'` | `instructions` 参数 | `system` 顶层参数 | `systemInstruction` 参数 |
|
||||
| **JSON mode** | `response_format: {type:'json_object'}` | `text.format: {type:'json_object'}` | 无原生支持 → prompt 指令 + `JSON.parse` | `responseMimeType: 'application/json'` + `responseSchema` |
|
||||
| **工具调用请求** | `tools[].type='function'` + `function.{name,description,parameters}` | `tools[].type='function'` + `function.{name,description,parameters}` | `tools[].name` + `input_schema` | `tools[].functionDeclarations[].{name,description,parameters}` |
|
||||
| **工具结果返回** | `role: 'tool'` + `tool_call_id` | `type: 'function_call_output'` + `call_id` | `role: 'user'` + `content: [{type:'tool_result', tool_use_id}]` | `role: 'function'` + `parts: [{functionResponse}]` |
|
||||
| **finish_reason** | `stop` / `tool_calls` / `length` | `stop` / `tool_calls` / ... | `end_turn` / `tool_use` / `max_tokens` | `STOP` / `FUNCTION_CALL` / `MAX_TOKENS` |
|
||||
| **Token 用量** | `usage.{prompt,completion}_tokens` | `usage.{input,output}_tokens` | `usage.{input,output}_tokens` | `usageMetadata.{prompt,candidates}TokenCount` |
|
||||
|
||||
### 4.2 各 Adapter 核心转换逻辑
|
||||
|
||||
#### 4.2.1 openai_compatible(现有兼容格式)
|
||||
|
||||
```typescript
|
||||
// 请求转换:几乎直通(这就是现有代码逻辑的抽象)
|
||||
// - LLMMessage → OpenAI ChatCompletionMessage (直接映射)
|
||||
// - responseFormat='json' → { type: 'json_object' }
|
||||
// - tools → tools[].function (直接映射)
|
||||
//
|
||||
// 响应转换:
|
||||
// - choices[0].message.content → content
|
||||
// - choices[0].message.tool_calls → toolCalls
|
||||
// - choices[0].finish_reason → finishReason (直接映射)
|
||||
// - usage.{prompt,completion}_tokens → usage
|
||||
```
|
||||
|
||||
#### 4.2.2 openai_responses(Responses API)
|
||||
|
||||
```typescript
|
||||
// 请求转换:
|
||||
// - system message 提取为 instructions 参数
|
||||
// - 非 system messages 转为 input items
|
||||
// - responseFormat='json' → text: { format: { type: 'json_object' } }
|
||||
// - tools → tools[].function
|
||||
//
|
||||
// 响应转换:
|
||||
// - output items 中 type='message' → content
|
||||
// - output items 中 type='function_call' → toolCalls
|
||||
// - status → finishReason 映射
|
||||
// - usage.{input,output}_tokens → usage
|
||||
```
|
||||
|
||||
#### 4.2.3 anthropic(Messages API)
|
||||
|
||||
```typescript
|
||||
// 请求转换:
|
||||
// - system message 提取为 system 顶层参数
|
||||
// - 非 system messages → messages(role 直接映射)
|
||||
// - responseFormat='json' → 无原生支持,在 system prompt 末尾追加:
|
||||
// "You MUST respond with valid JSON only. No other text."
|
||||
// - tools → tools[].{ name, description, input_schema }
|
||||
// - tool results → role='user', content: [{ type: 'tool_result', tool_use_id, content }]
|
||||
//
|
||||
// 响应转换:
|
||||
// - content blocks: type='text' → content
|
||||
// - content blocks: type='tool_use' → toolCalls (id, name, JSON.stringify(input))
|
||||
// - stop_reason: 'end_turn' → 'stop', 'tool_use' → 'tool_calls', 'max_tokens' → 'length'
|
||||
// - usage.{input,output}_tokens → usage
|
||||
//
|
||||
// JSON mode 容错:
|
||||
// - JSON.parse(content) 失败时,尝试正则提取 ```json...``` 块
|
||||
```
|
||||
|
||||
#### 4.2.4 gemini(generateContent API)
|
||||
|
||||
```typescript
|
||||
// 请求转换:
|
||||
// - system message 提取为 systemInstruction: { parts: [{ text }] }
|
||||
// - messages → contents[].{ role: 'user'|'model', parts: [{ text }] }
|
||||
// 注意:Gemini 用 'model' 而非 'assistant'
|
||||
// - responseFormat='json' → generationConfig: {
|
||||
// responseMimeType: 'application/json',
|
||||
// responseSchema: <如果有的话>
|
||||
// }
|
||||
// - tools → tools: [{ functionDeclarations: [...] }]
|
||||
// - tool results → role: 'function', parts: [{ functionResponse: { name, response } }]
|
||||
//
|
||||
// 响应转换:
|
||||
// - candidates[0].content.parts: type='text' → content
|
||||
// - candidates[0].content.parts: functionCall → toolCalls
|
||||
// - candidates[0].finishReason: 'STOP' → 'stop', 'FUNCTION_CALL' → 'tool_calls', 'MAX_TOKENS' → 'length'
|
||||
// - usageMetadata.{promptTokenCount, candidatesTokenCount} → usage
|
||||
```
|
||||
|
||||
### 4.3 tool-converter.ts 接口
|
||||
|
||||
```typescript
|
||||
// ── src/llm/tool-converter.ts ───────────────────────────────
|
||||
|
||||
import type { LLMToolDefinition } from './types';
|
||||
|
||||
/**
|
||||
* 将内部通用 LLMToolDefinition 转为各 provider 原生格式。
|
||||
* 由各 adapter 在 chat() 中调用。
|
||||
*/
|
||||
|
||||
/** → OpenAI / OpenAI Compatible 格式 */
|
||||
export function toOpenAITools(tools: LLMToolDefinition[]): object[];
|
||||
|
||||
/** → Anthropic 格式 */
|
||||
export function toAnthropicTools(tools: LLMToolDefinition[]): object[];
|
||||
|
||||
/** → Gemini functionDeclarations 格式 */
|
||||
export function toGeminiTools(tools: LLMToolDefinition[]): object[];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端 REST API 契约
|
||||
|
||||
所有新 API 挂在 `/admin/api/llm/` 下,复用现有 JWT 鉴权中间件。
|
||||
|
||||
### 5.1 Provider 管理
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/admin/api/llm/providers` | 列出所有 provider(含 `hasKey` 布尔,不含明文 key) |
|
||||
| `POST` | `/admin/api/llm/providers` | 创建 provider + 设置 API Key |
|
||||
| `GET` | `/admin/api/llm/providers/:id` | 获取单个详情 |
|
||||
| `PUT` | `/admin/api/llm/providers/:id` | 更新(name/base_url/default_model/extra_config/is_enabled) |
|
||||
| `DELETE` | `/admin/api/llm/providers/:id` | 删除(级联删 secret + role assignments) |
|
||||
|
||||
### 5.2 API Key(仅 set/clear,不回显)
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `PUT` | `/admin/api/llm/providers/:id/key` | 设置/更新 API Key |
|
||||
| `DELETE` | `/admin/api/llm/providers/:id/key` | 清除 API Key |
|
||||
|
||||
### 5.3 角色 → 模型映射
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/admin/api/llm/roles` | 列出所有角色及当前绑定 |
|
||||
| `PUT` | `/admin/api/llm/roles/:role` | 设置角色绑定 |
|
||||
|
||||
### 5.4 连通性测试
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `POST` | `/admin/api/llm/providers/:id/test` | 发送简单 prompt 验证 provider 可达 |
|
||||
|
||||
### 5.5 通用设置(非 LLM)
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/admin/api/settings` | 列出所有(sensitive 字段 masked) |
|
||||
| `PUT` | `/admin/api/settings` | 批量更新 |
|
||||
|
||||
### 5.6 请求/响应示例
|
||||
|
||||
#### 创建 Provider
|
||||
|
||||
```jsonc
|
||||
// POST /admin/api/llm/providers
|
||||
// Request:
|
||||
{
|
||||
"name": "Anthropic Claude",
|
||||
"type": "anthropic",
|
||||
"baseUrl": null,
|
||||
"defaultModel": "claude-sonnet-4-20250514",
|
||||
"apiKey": "sk-ant-xxxx",
|
||||
"extraConfig": {}
|
||||
}
|
||||
|
||||
// Response 201:
|
||||
{
|
||||
"id": "a1b2c3d4",
|
||||
"name": "Anthropic Claude",
|
||||
"type": "anthropic",
|
||||
"baseUrl": null,
|
||||
"defaultModel": "claude-sonnet-4-20250514",
|
||||
"isEnabled": true,
|
||||
"hasKey": true,
|
||||
"extraConfig": {},
|
||||
"createdAt": "2026-03-04T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 设置角色绑定
|
||||
|
||||
```jsonc
|
||||
// PUT /admin/api/llm/roles/specialist
|
||||
// Request:
|
||||
{
|
||||
"providerId": "a1b2c3d4",
|
||||
"model": "claude-sonnet-4-20250514"
|
||||
}
|
||||
|
||||
// Response 200:
|
||||
{
|
||||
"role": "specialist",
|
||||
"providerId": "a1b2c3d4",
|
||||
"providerName": "Anthropic Claude",
|
||||
"providerType": "anthropic",
|
||||
"model": "claude-sonnet-4-20250514"
|
||||
}
|
||||
```
|
||||
|
||||
#### 连通性测试
|
||||
|
||||
```jsonc
|
||||
// POST /admin/api/llm/providers/a1b2c3d4/test
|
||||
// Response 200:
|
||||
{
|
||||
"success": true,
|
||||
"latencyMs": 823,
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"message": "Hello! I'm Claude, an AI assistant."
|
||||
}
|
||||
|
||||
// Response 200 (失败):
|
||||
{
|
||||
"success": false,
|
||||
"latencyMs": 5012,
|
||||
"error": "401 Unauthorized: Invalid API key"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 密钥安全设计
|
||||
|
||||
### 6.1 Master Key 管理
|
||||
|
||||
```
|
||||
启动流程:
|
||||
1. 读取环境变量 ENCRYPTION_KEY(hex 编码,64 字符)
|
||||
├── 未设置或为空 → 抛出错误,拒绝启动
|
||||
├── 长度不正确 → 抛出错误,提示需要 64 个十六进制字符
|
||||
└── 正确 → 解码为 32 字节 Buffer
|
||||
2. 主密钥常驻内存(进程生命周期)
|
||||
3. 绝对不写入日志、不暴露给 API
|
||||
```
|
||||
|
||||
### 6.2 加密流程(写 API Key)
|
||||
|
||||
```
|
||||
输入: plaintext apiKey (string)
|
||||
1. 生成 12 bytes IV: crypto.randomFillSync(new Uint8Array(12))
|
||||
2. 创建 cipher: crypto.createCipheriv('aes-256-gcm', masterKey, iv)
|
||||
3. 加密: ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()])
|
||||
4. 获取 auth tag: authTag = cipher.getAuthTag() // 16 bytes
|
||||
5. 写 DB: INSERT INTO llm_secrets (provider_id, ciphertext, iv, auth_tag, key_version)
|
||||
```
|
||||
|
||||
### 6.3 解密流程(Gateway 需要调 provider)
|
||||
|
||||
```
|
||||
输入: provider_id
|
||||
1. 从 DB 读取: { ciphertext, iv, auth_tag, key_version }
|
||||
2. 创建 decipher: crypto.createDecipheriv('aes-256-gcm', masterKey, iv)
|
||||
3. 设置 auth tag: decipher.setAuthTag(authTag)
|
||||
4. 解密: plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
5. 返回明文 API Key → 传给 provider factory
|
||||
6. 解密后的 key 随 provider 实例缓存在 Gateway.cache 中
|
||||
```
|
||||
|
||||
### 6.4 密钥轮换
|
||||
|
||||
```
|
||||
场景: 管理员更换 ENCRYPTION_KEY
|
||||
1. 启动时读取新的 ENCRYPTION_KEY 环境变量
|
||||
2. 查询所有 llm_secrets WHERE key_version < current_version
|
||||
3. 逐条: 用旧 key 解密 → 用新 key 重加密 → 更新 key_version
|
||||
4. 如果旧 key 不可用(环境变量缺失)→ 启动报错,要求重新设置所有 API Key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端配置页设计
|
||||
|
||||
### 7.1 页面结构
|
||||
|
||||
```
|
||||
Settings 页面
|
||||
├── 🔌 LLM Providers(Tab 或独立 Card)
|
||||
│ │
|
||||
│ ├── Provider 列表表格
|
||||
│ │ ┌──────────────────────────────────────────────────────────────┐
|
||||
│ │ │ 名称 │ 类型 │ 默认模型 │ Key │ 状态 │ 操作 │
|
||||
│ │ ├───────────────────┼───────────────┼───────────────┼─────┼────────┼──────┤
|
||||
│ │ │ 公司 OpenAI 代理 │ OpenAI Compat │ gpt-4o-mini │ ● │ [开关] │ [⋮] │
|
||||
│ │ │ Anthropic Claude │ Anthropic │ claude-sonnet │ ● │ [开关] │ [⋮] │
|
||||
│ │ │ Google Gemini │ Gemini │ gemini-2.5-pro│ ○ │ [开关] │ [⋮] │
|
||||
│ │ └──────────────────────────────────────────────────────────────┘
|
||||
│ │ + 添加 Provider 按钮
|
||||
│ │
|
||||
│ ├── 添加/编辑 Provider 对话框
|
||||
│ │ ├── 名称 (text input)
|
||||
│ │ ├── 类型 (select dropdown)
|
||||
│ │ │ ├── OpenAI Compatible — 兼容 OpenAI 接口的第三方服务
|
||||
│ │ │ ├── OpenAI Responses — OpenAI 官方 Responses API
|
||||
│ │ │ ├── Anthropic — Anthropic Messages API
|
||||
│ │ │ └── Gemini — Google Gemini API
|
||||
│ │ ├── Base URL (text, 条件显示:openai_compatible 必填, 其他可选)
|
||||
│ │ ├── 默认模型 (text + autocomplete suggestions)
|
||||
│ │ ├── API Key (password input, 已有时显示 ••••••••)
|
||||
│ │ ├── ▸ 高级配置 (collapsible, JSON key-value editor)
|
||||
│ │ └── [测试连接] [保存] [取消]
|
||||
│ │
|
||||
│ └── 🧩 角色分配与分级审查映射 区域
|
||||
│ ┌──────────────────────────────────────────────────────────────┐
|
||||
│ │ 角色/阶段 │ Provider 下拉 │ 模型 ID │
|
||||
│ ├──────────────┼──────────────────────┼──────────────────────┤
|
||||
│ │ Planner(Triage) │ [公司 OpenAI 代理 ▾] │ [gpt-4o-mini ] │
|
||||
│ │ Specialist(任务执行) │ [Anthropic Claude ▾] │ [claude-sonnet-4 ] │
|
||||
│ │ Judge(汇总裁决) │ [公司 OpenAI 代理 ▾] │ [gpt-4o ] │
|
||||
│ │ Embedding(记忆检索) │ [公司 OpenAI 代理 ▾] │ [text-embedding-3 ] │
|
||||
│ └──────────────────────────────────────────────────────────────┘
|
||||
│ [保存角色分配]
|
||||
│
|
||||
├── ⚙️ 通用设置(现有 Gitea / 飞书 / App / Review 参数)
|
||||
│ ├── Agent 分级审查参数:small/medium 阈值、token budget、triage 开关
|
||||
│ └── (复用现有 ConfigManager 组件,数据源统一为 DB)
|
||||
```
|
||||
|
||||
### 7.2 交互规则
|
||||
|
||||
| 交互 | 行为 |
|
||||
|---|---|
|
||||
| **添加 Provider** | 弹出对话框;类型选择后动态显示/隐藏 `base_url` 字段 |
|
||||
| **API Key 输入** | 已有 key 时展示 `••••••••`(readonly 占位);清空内容后保存 = 删除 key;输入新值 = 替换(调用 `PUT /key`);未修改 = 不发请求 |
|
||||
| **测试连接** | 点击后调 `POST /providers/:id/test`;显示 spinner → 成功绿色 toast(延迟+模型)/ 失败红色 toast(错误信息) |
|
||||
| **角色分配下拉** | 仅显示 `is_enabled=true` 且 `hasKey=true` 的 provider;选择后自动填充该 provider 的 `default_model`(用户可修改) |
|
||||
| **禁用 Provider** | 如果有角色绑定到此 provider → 弹确认对话框:"此 Provider 正被以下角色使用:[...],禁用后这些角色将无法调用 LLM。确定禁用?" |
|
||||
| **删除 Provider** | 同上,级联影响提示更强烈 |
|
||||
| **模型建议** | 根据 provider type 显示常见模型建议列表(硬编码在前端,仅作参考,不限制输入) |
|
||||
|
||||
### 7.3 模型建议列表(前端硬编码参考)
|
||||
|
||||
```typescript
|
||||
const MODEL_SUGGESTIONS: Record<string, string[]> = {
|
||||
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'deepseek-chat', 'qwen-plus'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o3-mini'],
|
||||
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022', 'claude-opus-4-20250514'],
|
||||
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 现有调用点改造清单
|
||||
|
||||
### 8.1 后端代码改造
|
||||
|
||||
| # | 文件 | 当前代码 | 改造为 | 影响范围 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `src/index.ts:69-71` | `const openaiClient = new OpenAI({baseURL, apiKey})` | 删除;初始化 `LLMGateway` 单例并传入业务层 | 入口 |
|
||||
| 2 | `src/controllers/review.ts` | 旧版 webhook 存在回退分支 | 删除回退分支,仅保留 `agent` / `codex` 入队逻辑 | 审查主入口 |
|
||||
| 3 | `src/review/orchestrator.ts:45,61-63` | `private readonly openai: OpenAI` + `this.openai = new OpenAI(...)` | 构造函数改接收 `LLMGateway`;传给各 agent(任务化分级编排:skip/light/full) | Agent 编排 |
|
||||
| 4 | `src/review/agents/specialist-agent.ts:93` | `protected readonly openai: OpenAI` | → `protected readonly gateway: LLMGateway`;`reviewWithOptions()` 与 ReAct 调用改为 `this.gateway.chatForRole('specialist', ...)` | 核心 agent |
|
||||
| 5 | `src/review/agents/critic-agent.ts:23` | `private openai: OpenAI` | 同上 | 评审 agent |
|
||||
| 6 | `src/review/agents/reflexion-agent.ts:24` | `constructor(openai: OpenAI, ...)` | 构造传 gateway | 反思 agent |
|
||||
| 7 | `src/review/agents/debate-orchestrator.ts:17` | `private openai: OpenAI` | 同上 | 辩论 agent |
|
||||
| 8 | `src/review/tools/registry.ts:22` | `toOpenAIFunctions()` | → `toToolDefinitions(): LLMToolDefinition[]`(返回内部格式),由 adapter 的 `tool-converter.ts` 负责转换 | 工具注册 |
|
||||
| 9 | `src/config/config-schema.ts:120-138` | `OPENAI_BASE_URL/API_KEY/MODEL` 字段定义 | 删除这些字段;`group: 'openai'` → 整个 group 移除 | 配置 schema |
|
||||
| 10 | `src/config/config-manager.ts:44-46` | `OPENAI_*` Zod schema 条目 | 删除 | 配置验证 |
|
||||
| 11 | `src/config/config-manager.ts:271-273` | `openai: { baseUrl, apiKey, model }` 映射 | 删除整个 `openai` 块 | 配置输出 |
|
||||
|
||||
### 8.2 前端代码改造
|
||||
|
||||
| # | 文件 | 改造内容 |
|
||||
|---|---|---|
|
||||
| 1 | `frontend/src/services/configService.ts` | 新增 `llmProviderService.ts`(Provider CRUD + Key 管理 + Role 管理 + Test) |
|
||||
| 2 | `frontend/src/components/ConfigManager.tsx` | 添加 "LLM Providers" Tab/Card,引入新组件 |
|
||||
| 3 | 新增 | `frontend/src/components/llm/ProviderList.tsx` — Provider 列表表格 |
|
||||
| 4 | 新增 | `frontend/src/components/llm/ProviderDialog.tsx` — 添加/编辑对话框 |
|
||||
| 5 | 新增 | `frontend/src/components/llm/RoleAssignment.tsx` — 角色分配面板 |
|
||||
|
||||
### 8.3 配置层改造
|
||||
|
||||
| 变更 | 说明 |
|
||||
|---|---|
|
||||
| `config-manager.ts` | 精简为只管非 LLM 配置;数据源统一为 `system_settings` 表 |
|
||||
| `config-schema.ts` | 移除 `openai` group 及其字段;保留 gitea/feishu/app/admin/review(非模型)字段 |
|
||||
| `controllers/config.ts` | LLM 相关接口迁到 `controllers/llm-config.ts`;通用配置接口改读写 DB |
|
||||
| `.env.example` | 移除 `OPENAI_*` 和 `REVIEW_MODEL_*`;仅保留启动参数 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 实施阶段建议
|
||||
|
||||
| 阶段 | 内容 | 依赖 | 估时 |
|
||||
|---|---|---|---|
|
||||
| **Phase 1: 基础设施** | DB 层 (`bun:sqlite` 初始化 + DDL) + crypto 模块 | 无 | 1d |
|
||||
| **Phase 2: LLM 抽象层** | `src/llm/` 全部(types + capabilities + errors + gateway + 4 adapters + tool-converter) | Phase 1 | 2d |
|
||||
| **Phase 3: 后端 API + 调用点替换** | `controllers/llm-config.ts` + 替换 11 个现有 OpenAI 调用点 + 测试 | Phase 2 | 1.5d |
|
||||
| **Phase 4: 前端改造** | Provider 管理 + 角色分配 + 连接测试 UI + 通用设置切 DB | Phase 3 | 1.5d |
|
||||
| **Phase 5: 清理与验收** | 删除旧代码 + 更新文档 + E2E 测试 + `.env.example` 精简 | Phase 4 | 0.5d |
|
||||
|
||||
**总计约 6.5 人天。**
|
||||
|
||||
### 关键里程碑
|
||||
|
||||
```
|
||||
Day 1: DB + crypto 就绪,配置写入链路打通
|
||||
Day 3: LLM Gateway 可用,4 个 adapter 通过单元测试
|
||||
Day 4.5: 后端 API 完成,所有调用点已替换,`bun test` 全绿
|
||||
Day 6: 前端配置页可用,可通过 UI 添加/测试 Provider
|
||||
Day 6.5: 旧代码清理完毕,文档更新,Ready for review
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|---|---|---|
|
||||
| **Anthropic 无原生 JSON mode** | `response_format: json_object` 不可用,JSON 解析可能失败 | Adapter 内 prompt 注入 JSON 指令 + `JSON.parse()` 容错(正则提取 \`\`\`json\`\`\` 块 → 重试 parse) |
|
||||
| **Gemini function calling 格式差异大** | `functionDeclarations` 包装层级不同;`functionResponse` 嵌套在 `parts` 中 | `tool-converter.ts` 单独处理;finish reason 映射表全覆盖测试 |
|
||||
| **Embedding 维度变化导致 Qdrant 不兼容** | `src/review/memory/vector-store.ts` 硬编码 1536 维 | `model_role_assignments.role='embedding'` 变更时,UI 提示用户需重建 collection;或自动检测维度创建新 collection |
|
||||
| **ENCRYPTION_KEY 丢失** | 所有加密的 API Key 不可恢复 | 启动时检测密钥版本不匹配 → 报错并要求重新设置所有 API Key(trade-off:安全性 > 便利性) |
|
||||
| **SQLite 并发写** | 多请求同时写入可能 SQLITE_BUSY | `bun:sqlite` 开启 WAL mode;写操作走单连接序列化;读可并行 |
|
||||
| **Provider SDK 版本冲突** | `openai`、`@anthropic-ai/sdk`、`@google/generative-ai` 三个 SDK 共存 | 各 adapter 独立 import,无交叉依赖;`package.json` 锁定主版本 |
|
||||
| **配置热更新** | UI 修改 provider 配置后,正在进行的审查仍用旧配置 | Gateway 缓存按 provider_id 粒度 invalidate;正在执行的请求不受影响(用的是已创建的实例),下次请求用新实例 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A: 新增依赖
|
||||
|
||||
```jsonc
|
||||
// package.json 新增
|
||||
{
|
||||
"dependencies": {
|
||||
// bun:sqlite 是 Bun 内置,无需安装
|
||||
"@anthropic-ai/sdk": "^0.39.0", // Anthropic adapter
|
||||
"@google/generative-ai": "^0.24.0" // Gemini adapter
|
||||
// "openai" 已存在: "^4.87.3" // OpenAI compatible + Responses adapter
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 B: 环境变量精简
|
||||
|
||||
```bash
|
||||
# .env.example(仅保留启动参数)
|
||||
|
||||
# 应用启动参数(不可通过 UI 设置)
|
||||
PORT=5174
|
||||
WEBHOOK_SECRET=your_webhook_secret
|
||||
DATABASE_PATH=./data/assistant.db # SQLite 文件路径
|
||||
|
||||
# 以下配置已迁入数据库,通过 Web UI 管理:
|
||||
# - LLM Provider 配置(API Key / Base URL / Model)
|
||||
# - Gitea 配置(API URL / Token)
|
||||
# - 飞书配置(Webhook URL / Secret)
|
||||
# - Review 引擎配置
|
||||
# - 记忆系统配置
|
||||
```
|
||||
@@ -1,154 +0,0 @@
|
||||
# UI Theme Language(亮/暗双主题统一规范)
|
||||
|
||||
## 目标
|
||||
|
||||
- 保证浅色/深色主题视觉一致、可读性稳定。
|
||||
- 避免组件直接写死颜色,防止后续开发样式漂移。
|
||||
- 让新增页面默认遵循同一套语义化设计语言。
|
||||
|
||||
## 三层设计语言模型
|
||||
|
||||
1. **Primitive(原子值)**:HSL 基础值,仅在全局 token 定义处出现。
|
||||
2. **Semantic(语义 token)**:`background`、`foreground`、`success`、`danger` 等,按语义命名。
|
||||
3. **Component(组件 token)**:组件只能消费语义 token,不允许跨层引用原子值。
|
||||
|
||||
## 当前项目的主题基线
|
||||
|
||||
主题定义文件:`frontend/src/index.css`
|
||||
|
||||
- 基础语义:`background`、`foreground`、`card`、`muted`、`border`、`ring`
|
||||
- 状态语义:`success`、`warning`、`danger`、`info`
|
||||
- 补充语义:`surface-muted`、`surface-elevated`、`surface-overlay`、`text-subtle`、`text-soft`、`border-soft`
|
||||
|
||||
Tailwind 语义映射:`frontend/tailwind.config.js`
|
||||
|
||||
- 已将语义 token 映射为可直接使用的 class(如 `bg-success/10`、`text-danger`、`border-info/20`)。
|
||||
|
||||
### 主色方案(当前)
|
||||
|
||||
- 主色选择:**Cobalt Blue(钴蓝)**,兼顾 light/dark 的对比度与品牌辨识度。
|
||||
- Light:`--primary: 224 76% 52%`,`--primary-foreground: 0 0% 100%`
|
||||
- Dark:`--primary: 224 88% 68%`,`--primary-foreground: 224 40% 12%`
|
||||
- 焦点环:`--ring` 与 `--primary` 保持同色,确保交互一致性。
|
||||
- 设计理由:亮色下避免过“脏”或偏绿感;暗色下提升明度保证可见性,同时用深色前景保证主按钮文字对比。
|
||||
|
||||
## 可选整套主题色方案(社区开源复用)
|
||||
|
||||
> 要求:切换的是**整套语义 token**,不是只改 `primary`。
|
||||
|
||||
当前支持四套(统一冷调科技风):
|
||||
|
||||
1. `cobalt`(默认)
|
||||
- 本项目当前默认冷色科技风(自定义)。
|
||||
2. `zinc`
|
||||
- 来源:shadcn/ui themes(MIT)
|
||||
- 参考:<https://github.com/shadcn-ui/ui/blob/main/apps/v4/public/r/themes.css>
|
||||
3. `nord`
|
||||
- 来源:Nord(MIT)
|
||||
- 参考:<https://github.com/nordtheme/nord>
|
||||
4. `tokyo-night`
|
||||
- 来源:Tokyo Night(Apache-2.0)
|
||||
- 参考:<https://github.com/folke/tokyonight.nvim>
|
||||
|
||||
实现方式:
|
||||
|
||||
- `cobalt` 作为内置基础主题,直接由 `:root` / `.dark` 提供默认 token。
|
||||
- 其余方案(`zinc|nord|tokyo-night`)通过 `data-palette` 覆盖:
|
||||
- `:root[data-palette='*']` 覆盖浅色 token
|
||||
- `.dark[data-palette='*']` 覆盖暗色 token
|
||||
- 在根节点写入 `data-palette`(`cobalt|zinc|nord|tokyo-night`)。
|
||||
- 组件侧不改业务 class,继续消费语义 token。
|
||||
|
||||
## 页面风格骨架(社区方案落地)
|
||||
|
||||
> 目标:即使切换配色,页面结构、密度、层级、动效仍保持统一。
|
||||
|
||||
本项目采用了三类社区成熟范式,并映射到本仓库 utility:
|
||||
|
||||
1. **4px 节奏与密度系统(shadcn/仪表盘实践)**
|
||||
- 基础节奏按 4px 递进,主内容区使用 `theme-page-content`(统一宽度 + 留白节奏)。
|
||||
- 卡片内部与卡片间距默认采用 `p-6 / gap-6` 级别,避免页面“块状松散或拥挤”。
|
||||
2. **三层深度系统(卡片/悬浮/遮罩)**
|
||||
- 统一卡片外观:`theme-card-shell` + `theme-card-header` + `theme-card-content`。
|
||||
- 交互抬升统一:`theme-interactive-elevate`(轻微位移 + 阴影,不做夸张动效)。
|
||||
- 页面壳层统一:`theme-shell-gradient` + `theme-sticky-bar`。
|
||||
3. **可控动效系统(Linear/Vercel 风格)**
|
||||
- Hover/按钮反馈优先短时平滑动效,避免大幅动画导致“廉价感”。
|
||||
- 表单输入统一 `theme-input-surface`,状态条与统计胶囊统一 `theme-control-pill`。
|
||||
|
||||
参考来源:
|
||||
|
||||
- shadcn/ui themes 与组件风格实践(MIT):<https://github.com/shadcn-ui/ui>
|
||||
- Vercel Dashboard 设计迭代思路:<https://vercel.com/changelog/dashboard-navigation-redesign-rollout>
|
||||
- Nord / Tokyo Night 社区配色体系:
|
||||
- <https://github.com/nordtheme/nord>
|
||||
- <https://github.com/folke/tokyonight.nvim>
|
||||
|
||||
## 强制规则(必须遵守)
|
||||
|
||||
1. **禁止在业务 TSX 中使用硬编码暗色类**:如 `bg-zinc-*`、`text-zinc-*`、`border-white/10`(历史 UI 基础组件逐步迁移,不作为新增业务代码例外)。
|
||||
2. **禁止在组件内写死颜色值**:如 `rgba(...)`、`#xxxxxx`、`rgb(...)`。
|
||||
3. **状态色统一语义化**:成功/警告/错误/信息统一用 `success|warning|danger|info`。
|
||||
4. **弹窗/卡片/表格优先使用语义表面色**:`card`、`muted`、`popover`、`background`。
|
||||
5. **交互阴影统一工具类**:`theme-glow-primary|success|warning|danger`。
|
||||
6. **普通 hover 反馈禁止用主色背景**:非主操作控件统一使用 `hover:bg-accent*` 或 `hover:bg-muted*`,避免亮色主题出现重色块。
|
||||
|
||||
## 推荐 class 使用方式
|
||||
|
||||
- 文字层级:`text-foreground` / `text-muted-foreground`
|
||||
- 面板层级:`bg-card` / `bg-muted/50` / `bg-popover`
|
||||
- 边框层级:`border-border` / `theme-border-soft`
|
||||
- 状态展示:`text-success`、`bg-danger/10`、`border-warning/30`
|
||||
- 普通交互 hover:`hover:bg-accent/60`、`hover:bg-accent`、`hover:bg-muted/60`
|
||||
- 主操作 hover:仅主按钮可用 `hover:bg-primary/90`
|
||||
- 顶部吸附操作栏:`theme-sticky-bar`
|
||||
- 页面骨架:`theme-page-frame` / `theme-page-actions` / `theme-page-content`
|
||||
- 卡片骨架:`theme-card-shell` / `theme-card-header` / `theme-card-content`
|
||||
- 弹窗骨架:`theme-dialog-panel` / `theme-dialog-header` / `theme-dialog-body` / `theme-dialog-footer`
|
||||
- 错误态容器:`theme-error-panel`
|
||||
- 模态遮罩:`theme-surface-overlay`
|
||||
|
||||
## 页面级统一约束(防止布局风格漂移)
|
||||
|
||||
1. 页面容器优先使用 `theme-page-frame`,避免每个页面自行定义高度和底部间距。
|
||||
2. 顶部操作区统一使用 `theme-sticky-bar + theme-page-actions`,避免按钮栏视觉断层。
|
||||
3. 主内容区统一使用 `theme-page-content`,确保横向节奏和留白一致。
|
||||
4. 标准业务卡片统一使用 `theme-card-shell/header/content`,避免同类卡片出现不同边框/背景层级。
|
||||
5. 错误提示统一使用 `theme-error-panel`,保持状态反馈视觉语言一致。
|
||||
|
||||
## destructive 与 danger 的约定
|
||||
|
||||
- `destructive`:保留给 shadcn 组件内置 destructive 变体语义。
|
||||
- `danger`:业务状态语义(报错、失败、风险提示)统一使用。
|
||||
- 新业务组件优先使用 `danger`,避免 `destructive/danger` 混用造成漂移。
|
||||
|
||||
## 新功能开发检查清单
|
||||
|
||||
- [ ] 页面在 light/dark 下均可读(文本、边框、状态色有对比度)
|
||||
- [ ] 无 `zinc/white` 等暗色硬编码 class
|
||||
- [ ] 无内联 `style` 颜色值
|
||||
- [ ] 状态色全部使用语义 token
|
||||
- [ ] 组件未绕过语义层直接访问原子颜色
|
||||
- [ ] `bun run ui:visual` 通过(light/dark 关键页面视觉回归)
|
||||
|
||||
## 视觉基线截图回归(Playwright)
|
||||
|
||||
- 生成/更新基线:`bun run ui:visual:update`
|
||||
- 校验基线一致性:`bun run ui:visual`
|
||||
- 轻量 UI 全链路:`bun run ui:regression && bun run ui:visual`
|
||||
|
||||
约定:
|
||||
|
||||
1. PR 默认运行 `ui:visual`,出现 diff 必须人工确认是“预期视觉变更”。
|
||||
2. 只有在确认设计变更成立时,才执行 `ui:visual:update` 更新基线并提交快照。
|
||||
3. 不允许在未更新设计规范的情况下大量更新视觉基线,避免把漂移“固化为正确”。
|
||||
4. 基线快照以 Linux CI 环境为准(当前为 `*-linux.png`),避免跨系统更新导致快照噪声。
|
||||
|
||||
## 迁移策略
|
||||
|
||||
当新增模块时,按以下顺序处理:
|
||||
|
||||
1. 先补充语义 token(如确有新语义,而不是新颜色)。
|
||||
2. 在 Tailwind 映射语义 token。
|
||||
3. 在组件中只消费语义 class。
|
||||
4. 最后做 light/dark 视觉回归。
|
||||
93
docs/getting-started.md
Normal file
93
docs/getting-started.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Getting Started
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh) >= 1.2.5
|
||||
- A reachable Gitea instance
|
||||
- At least one LLM provider credential (OpenAI, Anthropic, Gemini, or compatible)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jeffusion/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
|
||||
```
|
||||
|
||||
## Configure environment
|
||||
|
||||
Create `.env` in the project root:
|
||||
|
||||
```bash
|
||||
ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
|
||||
# PORT=5174
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info
|
||||
```
|
||||
|
||||
`ENCRYPTION_KEY` is required — the application refuses to start without it. It is the AES-256-GCM master key for encrypting API keys stored in the database.
|
||||
|
||||
See [Configuration](./configuration.md) for all environment variables and runtime settings.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bun run dev # development with hot reload
|
||||
bun run start # production mode
|
||||
```
|
||||
|
||||
Open `http://localhost:5174` to access the Admin UI.
|
||||
|
||||
## First login
|
||||
|
||||
- Default admin password is `password` on first boot.
|
||||
- **Change it immediately** after login (Security section in Admin UI).
|
||||
|
||||
## Configure in Admin UI
|
||||
|
||||
The Admin UI manages all runtime settings stored in SQLite. You only need `.env` for infrastructure bootstrap values.
|
||||
|
||||

|
||||
|
||||
Key settings to configure:
|
||||
|
||||
1. **Gitea** — API URL, access token
|
||||
2. **LLM Providers** — add at least one provider (OpenAI Compatible, Anthropic, Gemini, etc.) with API key and default model
|
||||
3. **Webhook Secret** — used for HMAC-SHA256 signature verification
|
||||
|
||||
See [Screenshots](./screenshots.md) for a full UI gallery.
|
||||
|
||||
## Webhook setup
|
||||
|
||||
### Option A: Admin UI (recommended)
|
||||
|
||||
In the repository list page, click the enable button. The system auto-provisions the webhook in Gitea.
|
||||
|
||||
### Option B: Manual
|
||||
|
||||
In Gitea repository settings → Webhooks → Add webhook:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| URL | `http://your-server:5174/webhook/gitea` |
|
||||
| Content Type | `application/json` |
|
||||
| Secret | Same value as configured in Admin UI |
|
||||
| Events | Pull Request + Status |
|
||||
|
||||
## Health check
|
||||
|
||||
```
|
||||
GET /api/health
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Configuration reference](./configuration.md) — all settings and runtime model
|
||||
- [Review engines](./review-engines.md) — Agent engine, Codex engine, review modes
|
||||
- [Deployment](./deployment.md) — Docker, Compose, Kubernetes
|
||||
93
docs/getting-started.zh-CN.md
Normal file
93
docs/getting-started.zh-CN.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 快速开始
|
||||
|
||||
## 环境要求
|
||||
|
||||
- [Bun](https://bun.sh) >= 1.2.5
|
||||
- 可访问的 Gitea 实例
|
||||
- 至少一个 LLM 提供商凭证(OpenAI、Anthropic、Gemini 或兼容接口)
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jeffusion/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装前端依赖。如果环境禁用了生命周期脚本:
|
||||
|
||||
```bash
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
## 配置环境变量
|
||||
|
||||
在项目根目录创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
ENCRYPTION_KEY=<使用 openssl rand -hex 32 生成>
|
||||
# PORT=5174
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info
|
||||
```
|
||||
|
||||
`ENCRYPTION_KEY` 为必填项——缺失时服务会拒绝启动。它是用于加密数据库中 API Key 的 AES-256-GCM 主密钥。
|
||||
|
||||
所有环境变量和运行时设置参见 [配置参考](./configuration.zh-CN.md)。
|
||||
|
||||
## 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev # 开发模式(热重载)
|
||||
bun run start # 生产模式
|
||||
```
|
||||
|
||||
访问 `http://localhost:5174` 进入管理后台。
|
||||
|
||||
## 首次登录
|
||||
|
||||
- 首次启动默认管理员密码为 `password`
|
||||
- **登录后请立即修改密码**(管理后台「安全」分区)
|
||||
|
||||
## 管理后台配置
|
||||
|
||||
管理后台管理所有持久化到 SQLite 的运行时配置。`.env` 仅用于基础设施级引导参数。
|
||||
|
||||

|
||||
|
||||
需要配置的关键项:
|
||||
|
||||
1. **Gitea** — API URL、Access Token
|
||||
2. **LLM Provider** — 添加至少一个提供商(OpenAI Compatible、Anthropic、Gemini 等)并配置 API Key 和默认模型
|
||||
3. **Webhook Secret** — 用于 HMAC-SHA256 签名验证
|
||||
|
||||
完整界面截图参见 [截图集](./screenshots.zh-CN.md)。
|
||||
|
||||
## Webhook 配置
|
||||
|
||||
### 方式 A:管理后台(推荐)
|
||||
|
||||
在仓库列表页点击启用按钮,系统自动在 Gitea 中配置 Webhook。
|
||||
|
||||
### 方式 B:手动配置
|
||||
|
||||
在 Gitea 仓库设置 → Webhooks → 添加 Webhook:
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| URL | `http://your-server:5174/webhook/gitea` |
|
||||
| Content Type | `application/json` |
|
||||
| Secret | 与管理后台中配置的 Webhook Secret 保持一致 |
|
||||
| 事件 | Pull Request + Status |
|
||||
|
||||
## 健康检查
|
||||
|
||||
```
|
||||
GET /api/health
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- [配置参考](./configuration.zh-CN.md) — 完整设置项与运行时模型
|
||||
- [审查引擎](./review-engines.zh-CN.md) — Agent 引擎、Codex 引擎与审查模式
|
||||
- [部署指南](./deployment.zh-CN.md) — Docker、Compose、Kubernetes
|
||||
90
docs/review-engines.md
Normal file
90
docs/review-engines.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Review Engines
|
||||
|
||||
The system supports two review engines, selected by `REVIEW_ENGINE` in Admin UI.
|
||||
|
||||
## Agent engine
|
||||
|
||||
The Agent engine uses a dynamic agent framework. It prepares the workspace and review context, then starts a main agent to perform the review.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Main Agent** — the entrypoint agent that coordinates the review. It uses available tools to analyze code changes.
|
||||
2. **Dynamic Subagents** — the main agent can autonomously spawn subagents for focused tasks (e.g. searching code, reading files). Subagents are created at runtime through tool calls, not hardcoded in the workflow.
|
||||
3. **Deterministic Publishing** — findings and comments are collected and processed outside the agent loop. The system normalizes, deduplicates, and filters findings deterministically before posting to Gitea.
|
||||
|
||||
### Review modes
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| `skip` | Low-risk changes bypass review entirely |
|
||||
| `light` | Minimal checks for low-risk code changes |
|
||||
| `full` | Complete review for risky or large changes |
|
||||
|
||||
### Size policy
|
||||
|
||||
Change size determines execution mode and token budgets:
|
||||
|
||||
| Size | Typical threshold |
|
||||
|---|---|
|
||||
| `small` | Few lines changed |
|
||||
| `medium` | Moderate change set |
|
||||
| `large` | Significant refactoring or many files |
|
||||
|
||||
> Size and mode are separate layers: `small/medium/large` classifies how big the change is; `skip/light/full` controls how deeply the engine reviews it.
|
||||
|
||||
## Codex engine
|
||||
|
||||
The Codex engine runs review through Codex CLI with independent runtime settings:
|
||||
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| `CODEX_API_URL` | Codex API endpoint |
|
||||
| `CODEX_API_KEY` | Codex API key |
|
||||
| `CODEX_MODEL` | Model to use |
|
||||
| `CODEX_TIMEOUT_MS` | Request timeout |
|
||||
| `CODEX_REVIEW_PROMPT` | Custom review prompt |
|
||||
|
||||
## Agent definitions
|
||||
|
||||
Agent definitions are Markdown files with YAML frontmatter stored in the reviewed repository:
|
||||
|
||||
```
|
||||
.gitea-assistant/agents/*.md
|
||||
```
|
||||
|
||||
Each file defines:
|
||||
|
||||
- **System prompt** — instructions for the agent
|
||||
- **Model** — which LLM model to use (optional; falls back to runtime defaults)
|
||||
- **Max turns** — limit for the agent loop
|
||||
- **Tools** — which tools the agent can access
|
||||
|
||||
### Model resolution
|
||||
|
||||
When the main agent spawns a subagent, the model is resolved in this order:
|
||||
|
||||
1. `spawn` override (explicit in the tool call)
|
||||
2. `AgentDefinition.model` (declared in the agent definition file)
|
||||
3. `AGENT_DEFAULT_SUBAGENT_MODEL` (runtime config)
|
||||
4. `AGENT_MAIN_MODEL` (runtime config)
|
||||
|
||||
## Tool permissions
|
||||
|
||||
Tool permissions are controlled within each agent's definition file:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `tools` | Allow list | Tool names the agent is permitted to call. Empty list grants no tools. |
|
||||
| `disallowedTools` | Deny list | Tool names the agent is explicitly forbidden from calling. Takes precedence over `tools`. |
|
||||
|
||||
## Event support
|
||||
|
||||
Both engines process:
|
||||
|
||||
- Pull request webhook events
|
||||
- Commit status webhook events
|
||||
|
||||
## Output
|
||||
|
||||
- PR/commit summary comment (posted as an issue comment)
|
||||
- Line-level findings with confidence and severity (posted as review comments)
|
||||
90
docs/review-engines.zh-CN.md
Normal file
90
docs/review-engines.zh-CN.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# 审查引擎
|
||||
|
||||
系统支持两种审查引擎,通过管理后台的 `REVIEW_ENGINE` 配置选择。
|
||||
|
||||
## Agent 引擎
|
||||
|
||||
Agent 引擎使用动态 Agent 框架执行代码审查。它会准备工作区与审查上下文,然后启动主 Agent 执行审查任务。
|
||||
|
||||
### 工作原理
|
||||
|
||||
1. **主 Agent** — 协调审查流程的入口 Agent,使用可用工具分析代码变更。
|
||||
2. **动态子 Agent** — 主 Agent 可在运行时自主生成子 Agent,执行聚焦任务(如搜索代码、读取文件)。子 Agent 通过工具调用动态创建,而非硬编码在工作流中。
|
||||
3. **确定性发布** — 审查发现与评论在 Agent 循环之外收集和处理。系统在发布到 Gitea 之前,对发现进行确定性的规范化、去重和过滤。
|
||||
|
||||
### 审查模式
|
||||
|
||||
| 模式 | 行为 |
|
||||
|---|---|
|
||||
| `skip` | 低风险改动完全跳过审查 |
|
||||
| `light` | 对低风险代码执行最小化检查 |
|
||||
| `full` | 对高风险或大规模改动执行完整审查 |
|
||||
|
||||
### 规模策略
|
||||
|
||||
变更规模决定执行模式与 Token 预算:
|
||||
|
||||
| 规模 | 典型阈值 |
|
||||
|---|---|
|
||||
| `small` | 少量行变更 |
|
||||
| `medium` | 中等变更集 |
|
||||
| `large` | 大规模重构或多文件变更 |
|
||||
|
||||
> 规模与模式是两个层次:`small/medium/large` 分类变更的大小;`skip/light/full` 控制审查的深度。
|
||||
|
||||
## Codex 引擎
|
||||
|
||||
Codex 引擎通过 Codex CLI 执行审查,支持独立配置:
|
||||
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| `CODEX_API_URL` | Codex API 端点 |
|
||||
| `CODEX_API_KEY` | Codex API 密钥 |
|
||||
| `CODEX_MODEL` | 使用的模型 |
|
||||
| `CODEX_TIMEOUT_MS` | 请求超时时间 |
|
||||
| `CODEX_REVIEW_PROMPT` | 自定义审查提示词 |
|
||||
|
||||
## Agent 定义
|
||||
|
||||
Agent 定义以带 YAML Frontmatter 的 Markdown 文件形式存储在被审查的仓库中:
|
||||
|
||||
```
|
||||
.gitea-assistant/agents/*.md
|
||||
```
|
||||
|
||||
每个文件定义:
|
||||
|
||||
- **系统提示词** — Agent 的指令
|
||||
- **模型** — 使用的 LLM 模型(可选;未指定时使用运行时默认值)
|
||||
- **最大轮数** — Agent 循环上限
|
||||
- **工具** — Agent 可使用的工具
|
||||
|
||||
### 模型解析
|
||||
|
||||
主 Agent 生成子 Agent 时,模型按以下顺序解析:
|
||||
|
||||
1. `spawn` 覆盖(工具调用中显式指定)
|
||||
2. `AgentDefinition.model`(Agent 定义文件中声明)
|
||||
3. `AGENT_DEFAULT_SUBAGENT_MODEL`(运行时配置)
|
||||
4. `AGENT_MAIN_MODEL`(运行时配置)
|
||||
|
||||
## 工具权限
|
||||
|
||||
工具权限在每个 Agent 的定义文件中控制:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `tools` | 白名单 | 允许该 Agent 调用的工具名称。空列表表示不授予任何工具权限 |
|
||||
| `disallowedTools` | 黑名单 | 显式禁止该 Agent 调用的工具名称。优先级高于白名单 |
|
||||
|
||||
## 事件支持
|
||||
|
||||
两种引擎都支持:
|
||||
|
||||
- Pull Request webhook 事件
|
||||
- Commit Status webhook 事件
|
||||
|
||||
## 输出
|
||||
|
||||
- PR/提交总结评论(作为 Issue 评论发布)
|
||||
- 行级问题(含置信度与严重性,作为审查评论发布)
|
||||
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)
|
||||
258
e2e/README.md
Normal file
258
e2e/README.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# E2E 真实 PR 审查测试指南
|
||||
|
||||
本指南记录使用 Docker 运行 Gitea + Assistant 进行真实 PR 代码审查的完整流程,包括踩坑点和修复步骤。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Docker & Docker Compose
|
||||
- `openssl`(签名计算)
|
||||
- `python3`(JSON 解析)
|
||||
- 本项目源码
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
Gitea (port 3333) ←→ Assistant (port 3334)
|
||||
↑ ↑
|
||||
webhook clone + comment
|
||||
(PR 事件) (git + API)
|
||||
```
|
||||
|
||||
- **Gitea**:代码托管,运行在 `gitea:3000`(宿主机 `localhost:3333`)
|
||||
- **Assistant**:AI 审查服务,运行在 `assistant:5174`(宿主机 `localhost:3334`)
|
||||
- 两者通过 Docker 内部网络 `gitea:3000` 通信(非宿主机地址)
|
||||
|
||||
## 一键启动(自动化方式)
|
||||
|
||||
```bash
|
||||
# 1. 启动容器
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
|
||||
# 2. 等待 Gitea healthy 后创建用户(Gitea 不允许 root 执行 admin 命令)
|
||||
docker exec e2e-gitea su git -c \
|
||||
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
|
||||
--email admin@e2e-test.local --admin --must-change-password=false"
|
||||
|
||||
# 3. 运行 seed 脚本(创建仓库、推送代码、配置 webhook、创建 PR)
|
||||
bash ./e2e/seed.sh
|
||||
|
||||
# 4. 用 seed 输出的 token 重启 assistant(使 GITEA_ACCESS_TOKEN 生效)
|
||||
# seed.sh 会在最后打印实际 token 值
|
||||
E2E_GITEA_TOKEN=<seed输出的token> docker compose -f docker-compose.e2e.yml up -d assistant
|
||||
|
||||
# 5. 通过 Admin API 更新运行时配置
|
||||
LOGIN_RESP=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
|
||||
-H "Content-Type: application/json" -d '{"password": "password"}')
|
||||
JWT=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{
|
||||
"GITEA_API_URL": "http://gitea:3000/api/v1",
|
||||
"GITEA_ACCESS_TOKEN": "<seed输出的token>",
|
||||
"WEBHOOK_SECRET": "e2e-test-secret"
|
||||
}'
|
||||
|
||||
# 6. 运行 E2E 测试
|
||||
bash ./e2e/test.sh
|
||||
```
|
||||
|
||||
## 分步详解与踩坑点
|
||||
|
||||
### 1. 启动容器
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
```
|
||||
|
||||
**踩坑**:`ENCRYPTION_KEY` 和 `WEBHOOK_SECRET` 必须在 `docker-compose.e2e.yml` 中配置,否则 assistant 启动失败(`ENCRYPTION_KEY is required`)。已添加默认值:
|
||||
|
||||
```yaml
|
||||
assistant:
|
||||
environment:
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef...(64位hex)}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
```
|
||||
|
||||
### 2. 创建 Gitea 用户
|
||||
|
||||
```bash
|
||||
docker exec e2e-gitea su git -c \
|
||||
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
|
||||
--email admin@e2e-test.local --admin --must-change-password=false"
|
||||
```
|
||||
|
||||
**踩坑**:`seed.sh` 中直接 `docker exec e2e-gitea gitea admin user create ...` 会报错 `Gitea is not supposed to be run as root`。必须用 `su git -c` 切换到 git 用户执行。如果用户已存在会输出错误但可忽略。
|
||||
|
||||
### 3. Seed 初始化
|
||||
|
||||
```bash
|
||||
bash ./e2e/seed.sh
|
||||
```
|
||||
|
||||
Seed 脚本会执行:
|
||||
1. 等待 Gitea 就绪
|
||||
2. 创建管理员用户(如已存在则跳过)
|
||||
3. 生成 API Token
|
||||
4. 创建测试仓库并推送含已知 bug 的代码(`src/user-handler.ts` 包含 eval/SQL 注入/硬编码密钥)
|
||||
5. 配置 Assistant 设置(需要 assistant 已启动)
|
||||
6. 配置 Gitea Webhook(指向 `http://assistant:5174/webhook/gitea`)
|
||||
7. 创建 PR #1(`feature/add-user-handler` → `main`)
|
||||
|
||||
**踩坑**:seed.sh 第 5 步"配置 Assistant 设置"可能失败(assistant 未启动或 JWT 获取失败),这不影响后续流程——可以手动通过 API 配置。
|
||||
|
||||
### 4. 更新 Assistant 运行时配置
|
||||
|
||||
```bash
|
||||
# 获取 JWT
|
||||
JWT=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
|
||||
-H "Content-Type: application/json" -d '{"password": "password"}' | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
# 更新三个关键配置
|
||||
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{
|
||||
"GITEA_API_URL": "http://gitea:3000/api/v1",
|
||||
"GITEA_ACCESS_TOKEN": "<token>",
|
||||
"WEBHOOK_SECRET": "e2e-test-secret"
|
||||
}'
|
||||
```
|
||||
|
||||
**关键**:
|
||||
- `GITEA_API_URL` 必须是 `http://gitea:3000/api/v1`(Docker 内部地址),不是 `localhost` 或宿主机地址
|
||||
- `GITEA_ACCESS_TOKEN` 是 seed.sh 生成的 token,assistant 用它 clone 仓库和发布评论
|
||||
- `WEBHOOK_SECRET` 必须与 Gitea webhook 的 secret 一致,否则签名验证失败
|
||||
|
||||
### 5. 触发 PR 审查
|
||||
|
||||
PR 创建时 Gitea 会自动触发 webhook。如果需要手动触发:
|
||||
|
||||
```bash
|
||||
# 获取 PR 信息
|
||||
PR_RESP=$(curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1" \
|
||||
-H "Authorization: token <token>")
|
||||
|
||||
HEAD_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||
BASE_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['base']['sha'])")
|
||||
|
||||
# 构造 webhook payload(需包含 head/base SHA)
|
||||
cat > /tmp/webhook_payload.json << EOF
|
||||
{
|
||||
"action": "opened",
|
||||
"number": 1,
|
||||
"pull_request": {
|
||||
"number": 1,
|
||||
"title": "feat: add user handler",
|
||||
"head": { "ref": "feature/add-user-handler", "sha": "$HEAD_SHA",
|
||||
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } },
|
||||
"base": { "ref": "main", "sha": "$BASE_SHA",
|
||||
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } }
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "e2e-admin/e2e-test-repo",
|
||||
"name": "e2e-test-repo",
|
||||
"owner": { "login": "e2e-admin" },
|
||||
"clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git"
|
||||
},
|
||||
"sender": { "login": "e2e-admin" }
|
||||
}
|
||||
EOF
|
||||
|
||||
# 计算 HMAC 签名(注意:必须基于文件内容计算,避免 shell 变量传递时改变内容)
|
||||
SIG=$(cat /tmp/webhook_payload.json | openssl dgst -sha256 -hmac "e2e-test-secret" | awk '{print $NF}')
|
||||
|
||||
# 发送 webhook(⚠️ 必须用 --data-binary 而非 -d,否则换行符被剥离导致签名不匹配)
|
||||
curl -s -X POST "http://localhost:3334/webhook/gitea" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Gitea-Event: pull_request" \
|
||||
-H "X-Gitea-Signature: ${SIG}" \
|
||||
--data-binary @/tmp/webhook_payload.json
|
||||
```
|
||||
|
||||
### 6. 验证审查结果
|
||||
|
||||
```bash
|
||||
# 等待审查完成
|
||||
sleep 10
|
||||
|
||||
# 检查 assistant 日志
|
||||
docker logs e2e-assistant 2>&1 | grep -E "审查|publish|评论|finding|ERROR" | tail -20
|
||||
|
||||
# 通过 API 查看 run 详情
|
||||
curl -sf "http://localhost:3334/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer $JWT" | python3 -m json.tool | head -30
|
||||
|
||||
# 检查 Gitea PR 评论(summary)
|
||||
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/issues/1/comments" \
|
||||
-H "Authorization: token <token>" | python3 -c "
|
||||
import sys,json
|
||||
for c in json.load(sys.stdin):
|
||||
print(c['body'][:200])
|
||||
"
|
||||
|
||||
# 检查 Gitea PR Reviews(行级评论)
|
||||
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1/reviews" \
|
||||
-H "Authorization: token <token>"
|
||||
```
|
||||
|
||||
## 验证检查清单
|
||||
|
||||
| # | 检查项 | 验证方式 |
|
||||
|---|--------|----------|
|
||||
| 1 | Gitea 容器 healthy | `docker ps` 或 `curl localhost:3333/api/v1/version` |
|
||||
| 2 | Assistant 容器 healthy | `curl localhost:3334/api/health` |
|
||||
| 3 | Webhook 签名验证通过 | assistant 日志无"签名验证失败" |
|
||||
| 4 | Git clone mirror 成功 | assistant 日志无"could not read Username" |
|
||||
| 5 | Agent 审查执行完成 | run status = `succeeded` |
|
||||
| 6 | Subagent 被触发 | sessionTree.invocations 非空 |
|
||||
| 7 | Findings 数量 > 0 | run details 中 findings 非空 |
|
||||
| 8 | Summary 评论发布到 Gitea | PR issue comments 包含"AI Agent代码审查结果" |
|
||||
| 9 | 行级评论发布到 Gitea | PR reviews 包含 COMMENT 类型 |
|
||||
| 10 | Finding published=true | DB 中 finding.published = true |
|
||||
|
||||
## Mock LLM vs 真实 LLM
|
||||
|
||||
| 特性 | E2E_MOCK_LLM=1 | 真实 LLM |
|
||||
|------|----------------|----------|
|
||||
| 模型 | `RuntimeE2EMockLLM`(脚本驱动) | OpenAI/Anthropic/其他 |
|
||||
| Subagent | 必然调用(固定脚本) | 动态决策(根据 diff 复杂度) |
|
||||
| Findings | 固定 1 条(eval 安全问题) | 根据实际代码动态发现 |
|
||||
| 速度 | <1s | 10-60s |
|
||||
| 用途 | 集成链路验证 | 审查质量验证 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `ENCRYPTION_KEY is required`
|
||||
**原因**:`docker-compose.e2e.yml` 缺少 `ENCRYPTION_KEY` 环境变量。
|
||||
**修复**:已在 compose 文件中添加默认值。
|
||||
|
||||
### `Webhook签名验证失败`
|
||||
**原因**:请求的 HMAC 签名与 assistant 配置的 `WEBHOOK_SECRET` 不匹配。
|
||||
**修复**:确保 webhook payload 的签名计算使用与 Admin API 配置的 `WEBHOOK_SECRET` 相同的密钥。
|
||||
|
||||
### `could not read Username for 'http://gitea:3000'`
|
||||
**原因**:`GITEA_ACCESS_TOKEN` 未正确配置(默认值 `placeholder`)或 DB 配置中的值不正确。
|
||||
**修复**:通过 Admin API 更新 `GITEA_ACCESS_TOKEN` 为 seed.sh 生成的实际 token。
|
||||
|
||||
### `Gitea is not supposed to be run as root`
|
||||
**原因**:Gitea 容器以 root 运行,但 `gitea admin` 命令不允许 root 执行。
|
||||
**修复**:使用 `docker exec e2e-gitea su git -c "gitea admin user create ..."` 格式。
|
||||
|
||||
### Gitea API URL 指向 localhost
|
||||
**原因**:assistant DB 中 `GITEA_API_URL` 默认值为 `http://localhost:5174/api/v1`(自身地址)。
|
||||
**修复**:通过 Admin API 更新为 `http://gitea:3000/api/v1`(Docker 内部地址)。
|
||||
|
||||
### 评论未发布到 Gitea
|
||||
**原因**:Agent 引擎的 `publishPendingComments` 链路缺失(已修复)。
|
||||
**修复**:确保使用包含 `publishPendingComments` 逻辑的版本。
|
||||
|
||||
## 清理
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml down -v
|
||||
```
|
||||
|
||||
`-v` 会删除 Gitea 数据卷,下次启动需要重新 seed。
|
||||
20
e2e/seed.sh
20
e2e/seed.sh
@@ -26,12 +26,12 @@ for i in $(seq 1 30); do
|
||||
done
|
||||
|
||||
echo "=== [2/6] 创建管理员用户 ==="
|
||||
docker exec e2e-gitea gitea admin user create \
|
||||
--username "${ADMIN_USER}" \
|
||||
--password "${ADMIN_PASS}" \
|
||||
--email "${ADMIN_EMAIL}" \
|
||||
--admin \
|
||||
--must-change-password=false 2>/dev/null || echo " 用户已存在,跳过"
|
||||
docker exec e2e-gitea su git -c "gitea admin user create \
|
||||
--username '${ADMIN_USER}' \
|
||||
--password '${ADMIN_PASS}' \
|
||||
--email '${ADMIN_EMAIL}' \
|
||||
--admin \
|
||||
--must-change-password=false" 2>/dev/null || echo " 用户已存在,跳过"
|
||||
|
||||
echo "=== [3/6] 生成 API Token ==="
|
||||
TOKEN_RESPONSE=$(curl -sf -X POST "${GITEA_URL}/api/v1/users/${ADMIN_USER}/tokens" \
|
||||
@@ -129,7 +129,7 @@ for i in $(seq 1 20); do
|
||||
done
|
||||
|
||||
# Login to get JWT
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/login" \
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"password\": \"${ADMIN_DEFAULT_PASS}\"}" 2>/dev/null || true)
|
||||
ADMIN_JWT=$(echo "${LOGIN_RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true)
|
||||
@@ -138,7 +138,7 @@ if [ -z "${ADMIN_JWT}" ]; then
|
||||
echo " WARNING: 无法获取管理员 JWT,跳过 assistant 配置"
|
||||
else
|
||||
echo " JWT 获取成功,配置 assistant 设置..."
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/config" \
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" \
|
||||
-d "{
|
||||
@@ -146,10 +146,8 @@ else
|
||||
\"GITEA_API_URL\": \"http://gitea:3000/api/v1\",
|
||||
\"REVIEW_ENGINE\": \"agent\",
|
||||
\"REVIEW_WORKDIR\": \"/tmp/e2e-review\",
|
||||
\"REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE\": \"0.5\",
|
||||
\"REVIEW_ENABLE_HUMAN_GATE\": \"false\",
|
||||
\"REVIEW_ALLOWED_COMMANDS\": \"git,rg,cat,sed,wc\",
|
||||
\"REVIEW_COMMAND_TIMEOUT_MS\": \"30000\"
|
||||
\"REVIEW_COMMAND_TIMEOUT_MS\": \"120000\"
|
||||
}" > /dev/null 2>&1 && echo " Assistant 配置完成" || echo " WARNING: assistant 配置失败"
|
||||
fi
|
||||
|
||||
|
||||
179
e2e/test.sh
179
e2e/test.sh
@@ -2,7 +2,6 @@
|
||||
set -euo pipefail
|
||||
|
||||
# E2E Test Script
|
||||
# 验证 AI 代码审查是否在 PR 上产生了评论
|
||||
#
|
||||
# 前置条件:
|
||||
# 1. docker compose -f docker-compose.e2e.yml up -d
|
||||
@@ -17,10 +16,12 @@ fi
|
||||
|
||||
source "${ENV_FILE}"
|
||||
|
||||
MAX_WAIT=180 # 最多等待 3 分钟
|
||||
MAX_WAIT=240
|
||||
POLL_INTERVAL=5
|
||||
PASS=0
|
||||
FAIL=0
|
||||
RUN_ID=""
|
||||
LATEST_DETAIL='{}'
|
||||
|
||||
echo "=== E2E 测试开始 ==="
|
||||
echo " Gitea: ${GITEA_URL}"
|
||||
@@ -38,6 +39,12 @@ else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if [ "${E2E_MOCK_LLM:-}" = "1" ]; then
|
||||
echo " E2E_MOCK_LLM=1 (shell env)"
|
||||
else
|
||||
echo " E2E_MOCK_LLM 由 assistant 容器环境决定(docker-compose.e2e.yml 已配置)"
|
||||
fi
|
||||
|
||||
# ─── 测试 2: Gitea API 可用 ───
|
||||
echo "[TEST 2] Gitea API 可用性"
|
||||
VERSION=$(curl -sf "${GITEA_URL}/api/v1/version" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown")
|
||||
@@ -63,69 +70,121 @@ else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ─── 测试 4: 等待 AI 审查评论出现 ───
|
||||
echo "[TEST 4] AI 审查评论(最多等待 ${MAX_WAIT}s)"
|
||||
COMMENT_FOUND=false
|
||||
WAITED=0
|
||||
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
AI_COMMENTS=$(echo "${COMMENTS}" | python3 -c "
|
||||
import sys, json
|
||||
comments = json.load(sys.stdin)
|
||||
ai = [c for c in comments if 'AI' in c.get('body', '') or 'Agent' in c.get('body', '')]
|
||||
print(len(ai))
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${AI_COMMENTS}" -gt "0" ]; then
|
||||
COMMENT_FOUND=true
|
||||
echo " ✅ PASS: 发现 ${AI_COMMENTS} 条 AI 审查评论 (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
|
||||
echo " ⏳ 等待中... (${WAITED}/${MAX_WAIT}s, 已有评论: $(echo "${COMMENTS}" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0))"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
if [ "${COMMENT_FOUND}" = false ]; then
|
||||
echo " ❌ FAIL: ${MAX_WAIT}s 内未发现 AI 审查评论"
|
||||
FAIL=$((FAIL + 1))
|
||||
|
||||
echo " --- 调试信息 ---"
|
||||
echo " PR 所有评论:"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
|
||||
echo " Assistant review runs:"
|
||||
curl -sf "${ASSISTANT_URL}/admin/api/review/runs" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
fi
|
||||
|
||||
# ─── 测试 5: Review Run 状态检查 ───
|
||||
echo "[TEST 5] Review Run 状态"
|
||||
echo "[TEST 4] Admin 登录"
|
||||
ADMIN_JWT=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password":"password"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "${ADMIN_JWT}" ]; then
|
||||
echo " ✅ PASS: Admin JWT 获取成功"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Admin JWT 获取失败"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 5] 等待 review run 产出并成功(最多等待 ${MAX_WAIT}s)"
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "[]")
|
||||
RUN_COUNT=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('data',d if isinstance(d,list) else [])))" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${RUN_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: 发现 ${RUN_COUNT} 个 review run(s)"
|
||||
PASS=$((PASS + 1))
|
||||
WAITED=0
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "{}")
|
||||
RUN_ID=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0]['id'] if runs else '')" 2>/dev/null || echo "")
|
||||
RUN_STATUS=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0].get('status','') if runs else '')" 2>/dev/null || echo "")
|
||||
if [ -n "${RUN_ID}" ] && [ "${RUN_STATUS}" = "succeeded" ]; then
|
||||
echo " ✅ PASS: run=${RUN_ID} status=succeeded (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
echo " ⏳ 等待 run... (${WAITED}/${MAX_WAIT}s, run=${RUN_ID:-none}, status=${RUN_STATUS:-none})"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
echo "${RUNS}" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
runs = data.get('data', data if isinstance(data, list) else data.get('runs', []))
|
||||
for r in runs[:3]:
|
||||
print(f\" - {r.get('id','?')[:8]}... status={r.get('status','?')} attempts={r.get('attempts','?')}\")
|
||||
" 2>/dev/null || true
|
||||
if [ -z "${RUN_ID}" ]; then
|
||||
echo " ❌ FAIL: 未发现 review run"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if [ -n "${RUN_ID}" ]; then
|
||||
LATEST_DETAIL=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs/${RUN_ID}" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo '{}')
|
||||
fi
|
||||
|
||||
echo "[TEST 6] 会话树包含主/子 Agent 与工具调用"
|
||||
TREE_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
t=d.get("sessionTree") or {}
|
||||
main_type=t.get("agentType")
|
||||
main_tools=[x.get("toolName") for x in t.get("toolCalls",[])]
|
||||
inv=t.get("invocations",[])
|
||||
has_spawn="spawn_subagent" in main_tools
|
||||
child_ok=False
|
||||
if inv:
|
||||
child=inv[0].get("childSession") or {}
|
||||
child_tools=[x.get("toolName") for x in child.get("toolCalls",[])]
|
||||
child_ok=("search_code" in child_tools and "read_file" in child_tools)
|
||||
print("ok" if (main_type=="review-main-agent" and has_spawn and len(inv)>0 and child_ok) else "bad")
|
||||
' 2>/dev/null || echo "bad")
|
||||
|
||||
if [ "${TREE_ASSERT}" = "ok" ]; then
|
||||
echo " ✅ PASS: 主会话与子代理调用链存在(含 search_code/read_file)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: 无 review runs"
|
||||
echo " ❌ FAIL: sessionTree 未满足动态子代理断言"
|
||||
echo "${LATEST_DETAIL}" | python3 -m json.tool 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 7] run details 包含 findings 与评论记录"
|
||||
DETAIL_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
findings=d.get("findings",[])
|
||||
comments=d.get("comments",[])
|
||||
ok=(len(findings) > 0 and len(comments) > 0)
|
||||
print("ok" if ok else "bad")
|
||||
' 2>/dev/null || echo "bad")
|
||||
|
||||
if [ "${DETAIL_ASSERT}" = "ok" ]; then
|
||||
echo " ✅ PASS: run details 存在 findings/comments"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: run details 缺少 findings 或 comments"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 8] Gitea 评论产物(summary + line comments)"
|
||||
ISSUE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
LINE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
SUMMARY_COUNT=$(echo "${ISSUE_COMMENTS}" | python3 -c '
|
||||
import json,sys
|
||||
arr=json.load(sys.stdin)
|
||||
cnt=0
|
||||
for c in arr:
|
||||
body=c.get("body") or ""
|
||||
if "审查" in body or "review" in body.lower() or "AI" in body:
|
||||
cnt += 1
|
||||
print(cnt)
|
||||
' 2>/dev/null || echo "0")
|
||||
LINE_COUNT=$(echo "${LINE_COMMENTS}" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${SUMMARY_COUNT}" -gt "0" ] && [ "${LINE_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT}"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Gitea 评论产物不足(summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT})"
|
||||
echo " --- issue comments ---"
|
||||
echo "${ISSUE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
|
||||
echo " --- line comments ---"
|
||||
echo "${LINE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
@@ -138,10 +197,10 @@ echo " 失败: ${FAIL}/${TOTAL}"
|
||||
|
||||
if [ ${FAIL} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ 部分测试失败。如果 AI 评论测试失败,请确保:"
|
||||
echo " 1. OPENAI_API_KEY 已正确配置"
|
||||
echo " 2. assistant 容器的 GITEA_ACCESS_TOKEN 已设置为 seed 生成的 token"
|
||||
echo " 3. Webhook 已正确触发(检查 Gitea webhook 日志)"
|
||||
echo "⚠️ 部分测试失败。请检查:"
|
||||
echo " 1. docker compose e2e 容器均 healthy"
|
||||
echo " 2. assistant 容器环境含 E2E_MOCK_LLM=1 与正确 GITEA_ACCESS_TOKEN"
|
||||
echo " 3. webhook 已触发且 run details 可见 sessionTree/findings/comments"
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RepositoryManager } from './components/RepositoryManager';
|
||||
import { ConfigManager } from './components/ConfigManager';
|
||||
import { NotificationConfigPage } from './components/NotificationConfigPage';
|
||||
import { ReviewConfigPage } from './components/ReviewConfigPage';
|
||||
import ReviewSessionsPage from './pages/ReviewSessionsPage';
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { useTheme } from 'next-themes'
|
||||
import { ColorPaletteProvider } from './hooks/useColorPalette';
|
||||
@@ -54,6 +55,7 @@ function AppContent() {
|
||||
<Route path="config" element={<ConfigManager />} />
|
||||
<Route path="notifications" element={<NotificationConfigPage />} />
|
||||
<Route path="review-config" element={<ReviewConfigPage />} />
|
||||
<Route path="review-runs" element={<ReviewSessionsPage />} />
|
||||
<Route path="*" element={<Navigate to="/repos" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
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, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette } from 'lucide-react';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette, Layers } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
||||
@@ -11,6 +11,7 @@ const navItems = [
|
||||
{ path: '/config', label: '系统配置', icon: Sliders },
|
||||
{ path: '/notifications', label: '通知管理', icon: Bell },
|
||||
{ path: '/review-config', label: '审查配置', icon: FileSearch },
|
||||
{ path: '/review-runs', label: '审查任务', icon: Layers },
|
||||
] as const;
|
||||
|
||||
export default function DashboardPage() {
|
||||
|
||||
797
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
797
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
|
||||
import type { AgentSessionTree } from '@/services/reviewSessionService';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Bot, Cpu, Terminal, CheckCircle2, AlertCircle,
|
||||
ChevronRight, ChevronDown, Clock, FileText, Layers,
|
||||
AlertTriangle, CornerDownRight, HelpCircle, Info
|
||||
} from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper Components & Formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
case 'completed':
|
||||
return <Badge className="bg-success/20 text-success border-success/30">成功</Badge>;
|
||||
case 'failed':
|
||||
return <Badge className="bg-danger/20 text-danger border-danger/30">失败</Badge>;
|
||||
case 'running':
|
||||
case 'in_progress':
|
||||
return <Badge className="bg-primary/20 text-primary border-primary/30 animate-pulse">运行中</Badge>;
|
||||
case 'queued':
|
||||
return <Badge className="bg-warning/20 text-warning border-warning/30">排队中</Badge>;
|
||||
case 'ignored':
|
||||
return <Badge className="bg-muted text-muted-foreground border-border">已忽略</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: 'high' | 'medium' | 'low' }) {
|
||||
switch (severity) {
|
||||
case 'high':
|
||||
return <Badge className="bg-danger/20 text-danger border-danger/30 font-bold">高</Badge>;
|
||||
case 'medium':
|
||||
return <Badge className="bg-warning/20 text-warning border-warning/30 font-bold">中</Badge>;
|
||||
case 'low':
|
||||
return <Badge className="bg-info/20 text-info border-info/30 font-bold">低</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{severity}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(isoString?: string): string {
|
||||
if (!isoString) return '-';
|
||||
return new Date(isoString).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Session Tree Node Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TreeNodeProps {
|
||||
session: AgentSessionTree;
|
||||
level: number;
|
||||
onSelectSession: (session: AgentSessionTree) => void;
|
||||
selectedSessionId?: string;
|
||||
}
|
||||
|
||||
function AgentTreeNode({ session, level, onSelectSession, selectedSessionId }: TreeNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const hasChildren = session.invocations && session.invocations.some(inv => inv.childSession);
|
||||
const isSelected = selectedSessionId === session.id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{/* Node Row */}
|
||||
<div
|
||||
onClick={() => onSelectSession(session)}
|
||||
className={`flex items-center justify-between p-3 rounded-xl border transition-all duration-200 cursor-pointer mb-2 ${
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/10 theme-glow-primary'
|
||||
: 'border-border/60 bg-muted/30 hover:bg-accent/40 hover:border-border'
|
||||
}`}
|
||||
style={{ marginLeft: `${level * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
{level > 0 && <CornerDownRight className="w-4 h-4 text-muted-foreground/50" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-2 rounded-lg ${level === 0 ? 'bg-primary/10 text-primary' : 'bg-info/10 text-info'}`}>
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm text-foreground truncate">
|
||||
{level === 0 ? '主代理' : '子代理'}: {session.agentType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{session.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status={session.status} />
|
||||
{session.error && (
|
||||
<div title="代理执行出错">
|
||||
<AlertTriangle className="w-4 h-4 text-danger animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && session.invocations && (
|
||||
<div className="flex flex-col w-full">
|
||||
{session.invocations.map((inv) => {
|
||||
if (inv.childSession) {
|
||||
return (
|
||||
<AgentTreeNode
|
||||
key={inv.childSession.id}
|
||||
session={inv.childSession}
|
||||
level={level + 1}
|
||||
onSelectSession={onSelectSession}
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
);
|
||||
} else if (inv.status === 'failed') {
|
||||
// Failed subagent invocation without child session
|
||||
return (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-danger/30 bg-danger/5 mb-2"
|
||||
style={{ marginLeft: `${(level + 1) * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<CornerDownRight className="w-4 h-4 text-danger/50" />
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-danger/10 text-danger">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm text-danger truncate">
|
||||
子代理启动失败: {inv.agentType}
|
||||
</span>
|
||||
<span className="text-xs text-danger/80 font-mono truncate">
|
||||
{inv.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status="failed" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Session Detail Panel Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DetailPanelProps {
|
||||
session: AgentSessionTree;
|
||||
}
|
||||
|
||||
function AgentDetailPanel({ session }: DetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<'messages' | 'tools' | 'raw'>('messages');
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-muted/10 h-full flex flex-col">
|
||||
<CardHeader className="border-b border-border/50 pb-4 shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" />
|
||||
{session.parentSessionId ? '子代理详情' : '主代理详情'}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-mono text-xs text-muted-foreground break-all">
|
||||
ID: {session.id}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<StatusBadge status={session.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">代理类型</span>
|
||||
<span className="font-semibold text-foreground">{session.agentType}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">运行模型</span>
|
||||
<span className="font-mono font-semibold text-foreground">{session.model}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">启动时间</span>
|
||||
<span className="text-foreground">{formatDateTime(session.startedAt)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">结束时间</span>
|
||||
<span className="text-foreground">{formatDateTime(session.completedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.error && (
|
||||
<div className="mt-4 p-3 rounded-lg border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold">执行错误</div>
|
||||
<pre className="mt-1 font-mono text-xs whitespace-pre-wrap break-all">
|
||||
{typeof session.error === 'object' ? JSON.stringify(session.error, null, 2) : String(session.error)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
|
||||
<div className="border-b border-border/50 px-4 py-2 bg-muted/30 shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={activeTab === 'messages' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('messages')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 mr-1.5" />
|
||||
消息记录 ({session.messages?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'tools' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('tools')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<Terminal className="w-3.5 h-3.5 mr-1.5" />
|
||||
工具调用 ({session.toolCalls?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'raw' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('raw')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<Info className="w-3.5 h-3.5 mr-1.5" />
|
||||
元数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{activeTab === 'messages' && (
|
||||
<div className="space-y-4">
|
||||
{session.messages && session.messages.length > 0 ? (
|
||||
session.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex flex-col p-3 rounded-xl border ${
|
||||
msg.role === 'user'
|
||||
? 'border-primary/20 bg-primary/5 ml-8'
|
||||
: msg.role === 'assistant'
|
||||
? 'border-border bg-muted/40 mr-8'
|
||||
: 'border-warning/20 bg-warning/5 mx-4'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${
|
||||
msg.role === 'user' ? 'text-primary' : msg.role === 'assistant' ? 'text-foreground' : 'text-warning'
|
||||
}`}>
|
||||
{msg.role}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatDateTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap break-all font-sans leading-relaxed">
|
||||
{typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: typeof msg.content === 'object' && msg.content !== null && 'text' in msg.content
|
||||
? String(msg.content.text)
|
||||
: JSON.stringify(msg.content, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无消息记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tools' && (
|
||||
<div className="space-y-4">
|
||||
{session.toolCalls && session.toolCalls.length > 0 ? (
|
||||
session.toolCalls.map((tool) => (
|
||||
<div key={tool.id} className="border border-border/60 rounded-xl overflow-hidden bg-muted/20">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-3.5 h-3.5 text-primary" />
|
||||
<span className="font-mono text-sm font-bold text-foreground">{tool.toolName}</span>
|
||||
</div>
|
||||
<StatusBadge status={tool.status} />
|
||||
</div>
|
||||
<div className="p-3 space-y-3 text-xs font-mono">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">参数 (Arguments)</div>
|
||||
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(tool.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{tool.result !== undefined && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">结果 (Result)</div>
|
||||
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
|
||||
{typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{tool.error && (
|
||||
<div>
|
||||
<div className="text-danger mb-1">错误 (Error)</div>
|
||||
<pre className="p-2 rounded-lg bg-danger/5 border border-danger/20 text-danger overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{typeof tool.error === 'string' ? tool.error : JSON.stringify(tool.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无工具调用记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'raw' && (
|
||||
<div className="space-y-4 font-mono text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">元数据 (Metadata)</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(session.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{session.finalResult !== undefined && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">最终结果 (Final Result)</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(session.finalResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ReviewSessionsPage() {
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [selectedSession, setSelectedSession] = useState<AgentSessionTree | null>(null);
|
||||
|
||||
// Fetch runs list
|
||||
const { data: runsData, isLoading: isListLoading, isError: isListError, error: listError } = useQuery({
|
||||
queryKey: ['reviewRuns'],
|
||||
queryFn: () => fetchReviewRuns(50),
|
||||
});
|
||||
|
||||
// Fetch selected run details
|
||||
const { data: runDetails, isLoading: isDetailsLoading, isError: isDetailsError, error: detailsError } = useQuery({
|
||||
queryKey: ['reviewRunDetails', selectedRunId],
|
||||
queryFn: () => fetchReviewRunDetails(selectedRunId!),
|
||||
enabled: !!selectedRunId,
|
||||
});
|
||||
|
||||
const runs = runsData?.data ?? [];
|
||||
|
||||
// Handle run selection
|
||||
const handleSelectRun = (runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
setSelectedSession(null); // Reset selected session when switching runs
|
||||
};
|
||||
|
||||
// Automatically select first run if none selected
|
||||
if (!selectedRunId && runs.length > 0) {
|
||||
setSelectedRunId(runs[0].id);
|
||||
}
|
||||
|
||||
// Automatically select root session when run details load
|
||||
if (runDetails?.sessionTree && !selectedSession) {
|
||||
setSelectedSession(runDetails.sessionTree);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Sidebar: Runs List */}
|
||||
<aside className="w-80 border-r border-border/50 flex flex-col bg-muted/10 shrink-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-border/50 shrink-0">
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
审查任务列表
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">展示最近 50 次自动审查任务</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{isListLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-border/40 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4 bg-muted/60" />
|
||||
<Skeleton className="h-3 w-1/2 bg-muted/60" />
|
||||
</div>
|
||||
))
|
||||
) : isListError ? (
|
||||
<div className="theme-error-panel flex items-center gap-2 p-4">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<span className="text-sm font-medium">加载列表失败: {listError.message}</span>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
暂无审查任务记录
|
||||
</div>
|
||||
) : (
|
||||
runs.map((run) => {
|
||||
const isSelected = selectedRunId === run.id;
|
||||
return (
|
||||
<div
|
||||
key={run.id}
|
||||
onClick={() => handleSelectRun(run.id)}
|
||||
className={`p-3.5 rounded-xl border transition-all duration-200 cursor-pointer flex flex-col gap-2 ${
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/5 theme-glow-primary'
|
||||
: 'border-transparent hover:bg-accent/40 hover:border-border/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-bold text-sm text-foreground truncate flex-1">
|
||||
{run.owner}/{run.repo}
|
||||
</span>
|
||||
<StatusBadge status={run.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-border/60">
|
||||
{run.eventType === 'pull_request' ? `PR #${run.prNumber}` : 'Commit'}
|
||||
</Badge>
|
||||
<span className="truncate font-mono text-[10px]">
|
||||
{run.commitSha?.substring(0, 7) || run.headSha?.substring(0, 7) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground pt-1 border-t border-border/30">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(run.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<span>尝试: {run.attempts}/{run.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right Content: Run Details */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
{selectedRunId ? (
|
||||
isDetailsLoading ? (
|
||||
<div className="flex-1 p-6 space-y-6 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-1/3 bg-muted/60" />
|
||||
<Skeleton className="h-4 w-1/4 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
) : isDetailsError ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="theme-error-panel flex items-center gap-3 max-w-md">
|
||||
<AlertCircle className="w-6 h-6 text-danger shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold text-foreground">加载详情失败</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{detailsError.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !runDetails ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
未找到任务详情
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
<header className="p-6 border-b border-border/50 shrink-0 bg-muted/5">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-foreground tracking-tight">
|
||||
{runDetails.run.owner}/{runDetails.run.repo}
|
||||
</h1>
|
||||
<StatusBadge status={runDetails.run.status} />
|
||||
<Badge variant="outline" className="border-border/60">
|
||||
{runDetails.run.eventType === 'pull_request' ? `PR #${runDetails.run.prNumber}` : 'Commit'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
任务 ID: {runDetails.run.id} | Commit: {runDetails.run.commitSha || runDetails.run.headSha || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">创建时间</span>
|
||||
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.createdAt)}</span>
|
||||
</div>
|
||||
{runDetails.run.finishedAt && (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">完成时间</span>
|
||||
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.finishedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runDetails.run.error && (
|
||||
<div className="mt-4 p-3 rounded-xl border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-semibold">任务执行失败:</span> {runDetails.run.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Detail Tabs */}
|
||||
<Tabs defaultValue="observability" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-6 border-b border-border/50 bg-muted/5 shrink-0">
|
||||
<TabsList className="h-12 bg-transparent p-0 gap-6 border-b-0">
|
||||
<TabsTrigger
|
||||
value="observability"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
代理观测 (Observability)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="findings"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
审查结果 ({runDetails.findings?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="log"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
运行日志 ({runDetails.steps?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Tab Content: Observability */}
|
||||
<TabsContent value="observability" className="flex-1 overflow-hidden p-6 m-0 flex flex-col md:flex-row gap-6">
|
||||
{runDetails.sessionTree ? (
|
||||
<>
|
||||
{/* Left: Session Tree */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto pr-2">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
代理调用树 (Parent-Child Tree)
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<AgentTreeNode
|
||||
session={runDetails.sessionTree}
|
||||
level={0}
|
||||
onSelectSession={(session) => setSelectedSession(session)}
|
||||
selectedSessionId={selectedSession?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Selected Session Detail */}
|
||||
<div className="flex-1 h-full overflow-hidden">
|
||||
{selectedSession ? (
|
||||
<AgentDetailPanel session={selectedSession} />
|
||||
) : (
|
||||
<div className="h-full border border-dashed border-border/60 rounded-xl flex flex-col items-center justify-center text-muted-foreground p-6">
|
||||
<Bot className="w-12 h-12 text-muted-foreground/40 mb-3 animate-pulse" />
|
||||
<p className="text-sm font-medium">请在左侧选择一个代理节点查看详细调用轨迹</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground border border-dashed border-border/60 rounded-xl p-12">
|
||||
<HelpCircle className="w-12 h-12 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm font-medium">本次审查任务未使用 Agent 引擎,或暂无代理调用轨迹数据</p>
|
||||
<p className="text-xs text-muted-foreground/80 mt-1">请确保系统配置中已启用 Agent 审查引擎</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Content: Findings */}
|
||||
<TabsContent value="findings" className="flex-1 overflow-y-auto p-6 m-0 space-y-4">
|
||||
{runDetails.findings && runDetails.findings.length > 0 ? (
|
||||
runDetails.findings.map((finding) => (
|
||||
<Card key={finding.id} className="border-border/60 hover:border-border transition-all duration-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
<Badge variant="outline" className="bg-muted/50 border-border/60 text-xs">
|
||||
{finding.category}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{finding.path}:{finding.line}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-base font-bold text-foreground tracking-tight">
|
||||
{finding.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 flex items-center gap-1">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
置信度: {(finding.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<div className="font-semibold text-foreground mb-1">详细描述</div>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.detail}</p>
|
||||
</div>
|
||||
{finding.evidence && (
|
||||
<div>
|
||||
<div className="font-semibold text-foreground mb-1">代码证据</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{finding.evidence}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{finding.suggestion && (
|
||||
<div className="p-3.5 rounded-xl border border-success/20 bg-success/5">
|
||||
<div className="font-semibold text-success flex items-center gap-1.5 mb-1">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
修改建议
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.suggestion}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
|
||||
本次审查未发现任何问题
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Content: Run Log */}
|
||||
<TabsContent value="log" className="flex-1 overflow-y-auto p-6 m-0 space-y-6">
|
||||
{/* Steps */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
执行步骤 (Steps)
|
||||
</h3>
|
||||
<div className="border border-border/60 rounded-xl overflow-hidden bg-muted/10">
|
||||
<table className="w-full text-left border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border/50 text-muted-foreground font-semibold">
|
||||
<th className="p-3">步骤名称</th>
|
||||
<th className="p-3">状态</th>
|
||||
<th className="p-3">耗时</th>
|
||||
<th className="p-3">开始时间</th>
|
||||
<th className="p-3">结束时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{runDetails.steps && runDetails.steps.length > 0 ? (
|
||||
runDetails.steps.map((step) => (
|
||||
<tr key={step.id} className="hover:bg-accent/20 transition-colors">
|
||||
<td className="p-3 font-medium text-foreground">{step.stepName}</td>
|
||||
<td className="p-3">
|
||||
<StatusBadge status={step.status} />
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{step.latencyMs ? `${(step.latencyMs / 1000).toFixed(2)}s` : '-'}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.startedAt)}</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.finishedAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
暂无步骤记录
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
评论记录 (Comments)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{runDetails.comments && runDetails.comments.length > 0 ? (
|
||||
runDetails.comments.map((comment) => (
|
||||
<div key={comment.id} className="p-4 rounded-xl border border-border/60 bg-muted/20 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
{comment.path && (
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{comment.path}:{comment.line}
|
||||
</span>
|
||||
)}
|
||||
{comment.giteaCommentId && (
|
||||
<Badge variant="outline" className="text-[10px] border-border/60">
|
||||
Gitea ID: {comment.giteaCommentId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={comment.status} />
|
||||
<span className="text-muted-foreground">{formatDateTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap break-all leading-relaxed">
|
||||
{comment.body}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
|
||||
暂无评论记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-6">
|
||||
<Bot className="w-16 h-16 text-muted-foreground/30 mb-4 animate-pulse" />
|
||||
<h3 className="text-lg font-bold text-foreground">请选择一个审查任务</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">在左侧列表中选择一个任务以查看其详细的代理调用轨迹和审查结果</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
frontend/src/pages/__tests__/ReviewSessionsPage.test.tsx
Normal file
312
frontend/src/pages/__tests__/ReviewSessionsPage.test.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ReviewSessionsPage from '../ReviewSessionsPage';
|
||||
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
|
||||
|
||||
vi.mock('@/services/reviewSessionService', () => ({
|
||||
fetchReviewRuns: vi.fn(),
|
||||
fetchReviewRunDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('ReviewSessionsPage', () => {
|
||||
it('Scenario 1: renders main agent plus two subagents with statuses, tool counts, and model info', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-1',
|
||||
idempotencyKey: 'key-1',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'succeeded' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 42,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
sessionId: 'session-main',
|
||||
sequence: 1,
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
metadata: {},
|
||||
createdAt: '2026-05-25T00:00:05.000Z',
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
sessionId: 'session-main',
|
||||
sequence: 1,
|
||||
toolName: 'search_code',
|
||||
status: 'completed',
|
||||
arguments: {},
|
||||
createdAt: '2026-05-25T00:00:10.000Z',
|
||||
},
|
||||
],
|
||||
invocations: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
parentSessionId: 'session-main',
|
||||
childSessionId: 'session-sub-1',
|
||||
sequence: 1,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'completed',
|
||||
input: {},
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
childSession: {
|
||||
id: 'session-sub-1',
|
||||
parentSessionId: 'session-main',
|
||||
parentInvocationId: 'inv-1',
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:15.000Z',
|
||||
completedAt: '2026-05-25T00:00:30.000Z',
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
updatedAt: '2026-05-25T00:00:30.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inv-2',
|
||||
parentSessionId: 'session-main',
|
||||
childSessionId: 'session-sub-2',
|
||||
sequence: 2,
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
status: 'completed',
|
||||
input: {},
|
||||
createdAt: '2026-05-25T00:00:35.000Z',
|
||||
childSession: {
|
||||
id: 'session-sub-2',
|
||||
parentSessionId: 'session-main',
|
||||
parentInvocationId: 'inv-2',
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:35.000Z',
|
||||
completedAt: '2026-05-25T00:00:50.000Z',
|
||||
createdAt: '2026-05-25T00:00:35.000Z',
|
||||
updatedAt: '2026-05-25T00:00:50.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
// Wait for details to load and render
|
||||
const mainAgentText = await screen.findByText('主代理: review-main-agent');
|
||||
expect(mainAgentText).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByText('gpt-main').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assert subagents are rendered
|
||||
expect(screen.getByText('子代理: security-reviewer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-sub-a').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('子代理: quality-reviewer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-sub-b').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assert tool calls count is visible in the details panel tabs
|
||||
expect(screen.getByText('工具调用 (1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Scenario 2: renders failed subagent invocation and findings correctly', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-2',
|
||||
idempotencyKey: 'key-2',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'failed' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 43,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
runId: 'run-2',
|
||||
fingerprint: 'fp-1',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'src/db.ts',
|
||||
line: 10,
|
||||
title: 'SQL Injection vulnerability',
|
||||
detail: 'Direct string concatenation in query',
|
||||
evidence: 'db.query("SELECT * FROM users WHERE id = " + id)',
|
||||
suggestion: 'Use parameterized queries',
|
||||
published: false,
|
||||
},
|
||||
],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main-2',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'failed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [
|
||||
{
|
||||
id: 'inv-failed',
|
||||
parentSessionId: 'session-main-2',
|
||||
sequence: 1,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'failed',
|
||||
input: {},
|
||||
error: 'Failed to initialize subagent',
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
// Wait for details to load and render
|
||||
const failedSubagentText = await screen.findByText('子代理启动失败: security-reviewer');
|
||||
expect(failedSubagentText).toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Switch to findings tab
|
||||
const findingsTab = screen.getByText('审查结果 (1)');
|
||||
expect(findingsTab).toBeInTheDocument();
|
||||
await user.click(findingsTab);
|
||||
|
||||
// Assert finding title still renders
|
||||
const findingTitle = await screen.findByText('SQL Injection vulnerability');
|
||||
expect(findingTitle).toBeInTheDocument();
|
||||
expect(screen.getByText('Direct string concatenation in query')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Scenario 3: asserts no legacy review labels are visible', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-3',
|
||||
idempotencyKey: 'key-3',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'succeeded' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 44,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main-3',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-owner/test-repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const legacyLabels = ['tri' + 'age', 'speci' + 'alist', 'ju' + 'dge', 'pla' + 'nner'];
|
||||
legacyLabels.forEach((label) => {
|
||||
expect(screen.queryByText(label)).toBeNull();
|
||||
});
|
||||
expect(screen.queryByText('分流')).toBeNull();
|
||||
expect(screen.queryByText('专家')).toBeNull();
|
||||
expect(screen.queryByText('裁判')).toBeNull();
|
||||
expect(screen.queryByText('规划')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,14 +15,6 @@ export interface ProviderDto {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RoleAssignmentDto {
|
||||
role: string;
|
||||
providerId: string | null;
|
||||
providerName: string | null;
|
||||
providerType: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
@@ -75,16 +67,6 @@ export const deleteApiKey = async (id: string): Promise<void> => {
|
||||
await api.delete(`/llm/providers/${id}/key`);
|
||||
};
|
||||
|
||||
export const fetchRoles = async (): Promise<RoleAssignmentDto[]> => {
|
||||
const response = await api.get<RoleAssignmentDto[]>('/llm/roles');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const setRole = async (role: string, providerId: string | null, model: string | null): Promise<RoleAssignmentDto> => {
|
||||
const response = await api.put<RoleAssignmentDto>(`/llm/roles/${role}`, { providerId, model });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const testProvider = async (id: string): Promise<TestResult> => {
|
||||
const response = await api.post<TestResult>(`/llm/providers/${id}/test`);
|
||||
return response.data;
|
||||
|
||||
@@ -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,
|
||||
@@ -185,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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -233,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'],
|
||||
@@ -296,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);
|
||||
}
|
||||
@@ -352,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:
|
||||
@@ -10,9 +8,7 @@ metadata:
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
data:
|
||||
PORT: "5174"
|
||||
# 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.
|
||||
LOG_LEVEL: "error"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
|
||||
@@ -6,5 +6,4 @@ namespace: gitea-assistant
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- secret.yaml
|
||||
- qdrant.yaml
|
||||
- gitea-assistant.yaml
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: qdrant
|
||||
namespace: gitea-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
containers:
|
||||
- name: qdrant
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 6333
|
||||
protocol: TCP
|
||||
- name: grpc
|
||||
containerPort: 6334
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
volumeMounts:
|
||||
- name: qdrant-storage
|
||||
mountPath: /qdrant/storage
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
volumes:
|
||||
- name: qdrant-storage
|
||||
hostPath:
|
||||
# Customize this path to match your node's storage layout
|
||||
path: /opt/gitea-assistant/qdrant
|
||||
type: DirectoryOrCreate
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: qdrant
|
||||
namespace: gitea-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: qdrant
|
||||
ports:
|
||||
- name: http
|
||||
port: 6333
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
- name: grpc
|
||||
port: 6334
|
||||
targetPort: grpc
|
||||
protocol: TCP
|
||||
@@ -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"
|
||||
@@ -51,6 +51,7 @@
|
||||
"start:prod": "bun run dist/index.js",
|
||||
"lint": "biome check src/",
|
||||
"test": "bun test",
|
||||
"test:e2e": "bash ./e2e/test.sh",
|
||||
"prepare": "command -v husky >/dev/null 2>&1 && husky || true"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
agentDefinitionSchema,
|
||||
isAgentDefinition,
|
||||
normalizeAgentDefinition,
|
||||
} from '../agent-definition';
|
||||
|
||||
describe('agentDefinitionSchema', () => {
|
||||
test('parses a valid agent definition', () => {
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:fix-validator',
|
||||
whenToUse: 'Use for focused fix validation after a failing test run.',
|
||||
source: 'built-in',
|
||||
tools: ['readFile', 'searchCode'],
|
||||
disallowedTools: ['deleteFile'],
|
||||
skills: ['diagnostics'],
|
||||
hooks: {
|
||||
preToolUse: { enabled: true },
|
||||
},
|
||||
model: 'gpt-4.1-mini',
|
||||
maxTurns: 3,
|
||||
permissionMode: 'ask',
|
||||
background: true,
|
||||
isolation: 'workspace',
|
||||
getSystemPrompt: () => 'system prompt',
|
||||
});
|
||||
|
||||
expect(definition).toEqual({
|
||||
agentType: 'subagent',
|
||||
name: 'review:fix-validator',
|
||||
whenToUse: 'Use for focused fix validation after a failing test run.',
|
||||
source: 'built-in',
|
||||
tools: ['readFile', 'searchCode'],
|
||||
disallowedTools: ['deleteFile'],
|
||||
skills: ['diagnostics'],
|
||||
hooks: {
|
||||
preToolUse: { enabled: true },
|
||||
},
|
||||
model: 'gpt-4.1-mini',
|
||||
maxTurns: 3,
|
||||
permissionMode: 'ask',
|
||||
background: true,
|
||||
isolation: 'workspace',
|
||||
getSystemPrompt: definition.getSystemPrompt,
|
||||
});
|
||||
expect(isAgentDefinition(definition)).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes defaults for omitted runtime fields', () => {
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:intake',
|
||||
whenToUse: 'Use for initial task routing.',
|
||||
source: 'project',
|
||||
});
|
||||
|
||||
expect(definition.tools).toEqual([]);
|
||||
expect(definition.disallowedTools).toEqual([]);
|
||||
expect(definition.skills).toEqual([]);
|
||||
expect(definition.hooks).toEqual({});
|
||||
expect(definition.model).toBeUndefined();
|
||||
expect(definition.maxTurns).toBe(1);
|
||||
expect(definition.permissionMode).toBe('default');
|
||||
expect(definition.background).toBe(false);
|
||||
expect(definition.isolation).toBe('none');
|
||||
});
|
||||
|
||||
test('rejects missing required fields', () => {
|
||||
const result = agentDefinitionSchema.safeParse({
|
||||
agentType: 'subagent',
|
||||
source: 'built-in',
|
||||
model: 'gpt-4.1-mini',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('strips legacy business role fields', () => {
|
||||
const legacyKeys = ['plan' + 'ner', 'special' + 'ist', 'ju' + 'dge'];
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:modern',
|
||||
whenToUse: 'Use for modern runtime routing only.',
|
||||
source: 'user',
|
||||
model: 'gpt-4.1-mini',
|
||||
[legacyKeys[0]]: true,
|
||||
[legacyKeys[1]]: true,
|
||||
[legacyKeys[2]]: true,
|
||||
} as Record<string, unknown>);
|
||||
|
||||
for (const legacyKey of legacyKeys) {
|
||||
expect(legacyKey in definition).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
186
src/agent-kernel/definitions/__tests__/agent-registry.test.ts
Normal file
186
src/agent-kernel/definitions/__tests__/agent-registry.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
createAgentRegistry,
|
||||
loadAgentRegistry,
|
||||
loadProjectAgentDefinitions,
|
||||
parseAgentDefinitionMarkdown,
|
||||
} from '..';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function definition(source: 'built-in' | 'plugin' | 'user' | 'project', name: string) {
|
||||
return {
|
||||
agentType: 'reviewer',
|
||||
name,
|
||||
whenToUse: `Use ${name}`,
|
||||
source,
|
||||
model: `${name}-model`,
|
||||
};
|
||||
}
|
||||
|
||||
async function makeProjectRoot(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-registry-test-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
test('keeps all agents and resolves duplicates by built-in < plugin < user < project precedence', () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [definition('built-in', 'built-in-reviewer')],
|
||||
plugin: [definition('plugin', 'plugin-reviewer')],
|
||||
user: [definition('user', 'user-reviewer')],
|
||||
project: [definition('project', 'project-reviewer')],
|
||||
});
|
||||
|
||||
expect(registry.allAgents.map((agent) => agent.name)).toEqual([
|
||||
'built-in-reviewer',
|
||||
'plugin-reviewer',
|
||||
'user-reviewer',
|
||||
'project-reviewer',
|
||||
]);
|
||||
expect(registry.activeAgents).toHaveLength(1);
|
||||
expect(registry.getActiveAgent('reviewer')?.name).toBe('project-reviewer');
|
||||
expect(registry.getActiveAgent('reviewer')?.source).toBe('project');
|
||||
});
|
||||
|
||||
test('loads project definitions only from .gitea-assistant/agents/*.md', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const validDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
const ignoredDir = join(projectRoot, 'agents');
|
||||
await mkdir(validDir, { recursive: true });
|
||||
await mkdir(ignoredDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(validDir, 'reviewer.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Project Reviewer',
|
||||
'whenToUse: Use for project-specific review.',
|
||||
'tools: [readFile, searchCode]',
|
||||
'maxTurns: 2',
|
||||
'background: true',
|
||||
'---',
|
||||
'You are the project reviewer.',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(ignoredDir, 'ignored.md'),
|
||||
['---', 'agentType: ignored', 'name: Ignored', 'whenToUse: Never.', '---', 'Ignored.'].join(
|
||||
'\n'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const loaded = await loadProjectAgentDefinitions(projectRoot);
|
||||
|
||||
expect(loaded.failedFiles).toEqual([]);
|
||||
expect(loaded.definitions).toHaveLength(1);
|
||||
expect(loaded.definitions[0].agentType).toBe('reviewer');
|
||||
expect(loaded.definitions[0].source).toBe('project');
|
||||
expect(loaded.definitions[0].tools).toEqual(['readFile', 'searchCode']);
|
||||
expect(loaded.definitions[0].maxTurns).toBe(2);
|
||||
expect(loaded.definitions[0].background).toBe(true);
|
||||
expect(loaded.definitions[0].getSystemPrompt?.()).toBe('You are the project reviewer.');
|
||||
});
|
||||
|
||||
test('keeps optional model definitions valid through markdown loading', async () => {
|
||||
const parsed = parseAgentDefinitionMarkdown(
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: No Model Reviewer',
|
||||
'whenToUse: Use without model.',
|
||||
'---',
|
||||
'Prompt body.',
|
||||
].join('\n'),
|
||||
{ source: 'project', filePath: '/tmp/reviewer.md' }
|
||||
);
|
||||
|
||||
expect('code' in parsed).toBe(false);
|
||||
if ('code' in parsed) {
|
||||
throw new Error('expected valid definition');
|
||||
}
|
||||
expect(parsed.model).toBeUndefined();
|
||||
expect(parsed.getSystemPrompt?.()).toBe('Prompt body.');
|
||||
});
|
||||
|
||||
test('returns structured load errors for malformed frontmatter and empty body', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const agentsDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(agentsDir, 'bad-frontmatter.md'),
|
||||
'---\nagentType [reviewer]\n---\nPrompt.',
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'empty-body.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Empty Body',
|
||||
'whenToUse: Use never.',
|
||||
'---',
|
||||
' ',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'invalid-definition.md'),
|
||||
['---', 'agentType: reviewer', 'name: Missing Use', '---', 'Prompt.'].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const loaded = await loadProjectAgentDefinitions(projectRoot);
|
||||
|
||||
expect(loaded.definitions).toEqual([]);
|
||||
expect(loaded.failedFiles.map((error) => error.code).sort()).toEqual([
|
||||
'empty_body',
|
||||
'invalid_definition',
|
||||
'malformed_frontmatter',
|
||||
]);
|
||||
expect(loaded.failedFiles.every((error) => error.source === 'project')).toBe(true);
|
||||
expect(
|
||||
loaded.failedFiles.find((error) => error.code === 'invalid_definition')?.issues?.length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('loadAgentRegistry combines built-in, plugin, user, and loaded project definitions', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const agentsDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(agentsDir, 'reviewer.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Loaded Project',
|
||||
'whenToUse: Use loaded project.',
|
||||
'---',
|
||||
'Project prompt.',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = await loadAgentRegistry({
|
||||
projectRoot,
|
||||
builtIn: [definition('built-in', 'Built In')],
|
||||
plugin: [definition('plugin', 'Plugin')],
|
||||
user: [definition('user', 'User')],
|
||||
});
|
||||
|
||||
expect(registry.allAgents).toHaveLength(4);
|
||||
expect(registry.failedFiles).toEqual([]);
|
||||
expect(registry.getActiveAgent('reviewer')?.name).toBe('Loaded Project');
|
||||
expect(registry.getActiveAgent('reviewer')?.getSystemPrompt?.()).toBe('Project prompt.');
|
||||
});
|
||||
});
|
||||
84
src/agent-kernel/definitions/agent-definition.ts
Normal file
84
src/agent-kernel/definitions/agent-definition.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type AgentDefinitionSource = 'built-in' | 'project' | 'user' | 'plugin';
|
||||
|
||||
export const AGENT_DEFINITION_SOURCES = [
|
||||
'built-in',
|
||||
'project',
|
||||
'user',
|
||||
'plugin',
|
||||
] as const satisfies readonly AgentDefinitionSource[];
|
||||
|
||||
export type AgentPermissionMode = 'default' | 'ask' | 'deny';
|
||||
|
||||
export const AGENT_PERMISSION_MODES = [
|
||||
'default',
|
||||
'ask',
|
||||
'deny',
|
||||
] as const satisfies readonly AgentPermissionMode[];
|
||||
|
||||
export type AgentIsolation = 'none' | 'workspace' | 'process';
|
||||
|
||||
export const AGENT_ISOLATIONS = [
|
||||
'none',
|
||||
'workspace',
|
||||
'process',
|
||||
] as const satisfies readonly AgentIsolation[];
|
||||
|
||||
export interface AgentDefinitionHooks {
|
||||
sessionStart?: unknown;
|
||||
subagentStart?: unknown;
|
||||
permissionRequest?: unknown;
|
||||
preToolUse?: unknown;
|
||||
postToolUse?: unknown;
|
||||
postToolUseFailure?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const agentDefinitionHooksSchema: z.ZodType<AgentDefinitionHooks> = z
|
||||
.object({
|
||||
sessionStart: z.unknown().optional(),
|
||||
subagentStart: z.unknown().optional(),
|
||||
permissionRequest: z.unknown().optional(),
|
||||
preToolUse: z.unknown().optional(),
|
||||
postToolUse: z.unknown().optional(),
|
||||
postToolUseFailure: z.unknown().optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const agentDefinitionSchema = z
|
||||
.object({
|
||||
agentType: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
whenToUse: z.string().min(1),
|
||||
source: z.enum(AGENT_DEFINITION_SOURCES),
|
||||
tools: z.array(z.string()).default([]),
|
||||
disallowedTools: z.array(z.string()).default([]),
|
||||
skills: z.array(z.string()).default([]),
|
||||
hooks: agentDefinitionHooksSchema.default({}),
|
||||
model: z.string().min(1).optional(),
|
||||
maxTurns: z.number().int().positive().default(1),
|
||||
permissionMode: z.enum(AGENT_PERMISSION_MODES).default('default'),
|
||||
background: z.boolean().default(false),
|
||||
isolation: z.enum(AGENT_ISOLATIONS).default('none'),
|
||||
getSystemPrompt: z
|
||||
.custom<() => string>((value) => typeof value === 'function', {
|
||||
message: 'getSystemPrompt must be a function',
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export type AgentDefinition = z.infer<typeof agentDefinitionSchema>;
|
||||
|
||||
export function normalizeAgentDefinition(definition: unknown): AgentDefinition {
|
||||
return agentDefinitionSchema.parse(definition);
|
||||
}
|
||||
|
||||
export function isAgentDefinition(definition: unknown): definition is AgentDefinition {
|
||||
return agentDefinitionSchema.safeParse(definition).success;
|
||||
}
|
||||
|
||||
export function parseAgentDefinition(definition: unknown): AgentDefinition {
|
||||
return normalizeAgentDefinition(definition);
|
||||
}
|
||||
258
src/agent-kernel/definitions/agent-loader.ts
Normal file
258
src/agent-kernel/definitions/agent-loader.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ZodError } from 'zod';
|
||||
import type { AgentDefinition, AgentDefinitionSource } from './agent-definition';
|
||||
import { normalizeAgentDefinition } from './agent-definition';
|
||||
|
||||
export const PROJECT_AGENT_DEFINITIONS_DIR = '.gitea-assistant/agents';
|
||||
|
||||
export type AgentDefinitionLoadErrorCode =
|
||||
| 'missing_frontmatter'
|
||||
| 'malformed_frontmatter'
|
||||
| 'empty_body'
|
||||
| 'invalid_definition'
|
||||
| 'read_error';
|
||||
|
||||
export interface AgentDefinitionLoadError {
|
||||
source: AgentDefinitionSource;
|
||||
filePath: string;
|
||||
code: AgentDefinitionLoadErrorCode;
|
||||
message: string;
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
export interface AgentDefinitionLoadResult {
|
||||
definitions: AgentDefinition[];
|
||||
failedFiles: AgentDefinitionLoadError[];
|
||||
}
|
||||
|
||||
interface MarkdownParseOptions {
|
||||
source: AgentDefinitionSource;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
type FrontmatterRecord = Record<string, string | number | boolean | string[]>;
|
||||
|
||||
export function parseAgentDefinitionMarkdown(
|
||||
content: string,
|
||||
options: MarkdownParseOptions
|
||||
): AgentDefinition | AgentDefinitionLoadError {
|
||||
const extracted = extractFrontmatter(content, options);
|
||||
if (isLoadError(extracted)) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
const systemPrompt = extracted.body.trim();
|
||||
if (!systemPrompt) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'empty_body',
|
||||
message: 'Agent definition markdown body must contain the system prompt.',
|
||||
};
|
||||
}
|
||||
|
||||
const frontmatter = parseFrontmatter(extracted.frontmatter, options);
|
||||
if (isLoadError(frontmatter)) {
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeAgentDefinition({
|
||||
...frontmatter,
|
||||
source: options.source,
|
||||
getSystemPrompt: () => systemPrompt,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'invalid_definition',
|
||||
message: 'Agent definition frontmatter does not match AgentDefinition.',
|
||||
issues:
|
||||
error instanceof ZodError ? error.issues.map((issue) => issue.message) : [String(error)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadProjectAgentDefinitions(
|
||||
projectRoot: string
|
||||
): Promise<AgentDefinitionLoadResult> {
|
||||
const definitionsDir = join(projectRoot, PROJECT_AGENT_DEFINITIONS_DIR);
|
||||
const result: AgentDefinitionLoadResult = { definitions: [], failedFiles: [] };
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(definitionsDir);
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
definitions: [],
|
||||
failedFiles: [
|
||||
{
|
||||
source: 'project',
|
||||
filePath: definitionsDir,
|
||||
code: 'read_error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
for (const entry of entries.sort()) {
|
||||
if (!entry.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(definitionsDir, entry);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = parseAgentDefinitionMarkdown(content, { source: 'project', filePath });
|
||||
if (isLoadError(parsed)) {
|
||||
result.failedFiles.push(parsed);
|
||||
} else {
|
||||
result.definitions.push(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
result.failedFiles.push({
|
||||
source: 'project',
|
||||
filePath,
|
||||
code: 'read_error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractFrontmatter(
|
||||
content: string,
|
||||
options: MarkdownParseOptions
|
||||
): { frontmatter: string; body: string } | AgentDefinitionLoadError {
|
||||
const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
||||
if (!normalized.startsWith('---\n')) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'missing_frontmatter',
|
||||
message: 'Agent definition markdown must start with --- frontmatter.',
|
||||
};
|
||||
}
|
||||
|
||||
const closingMarker = '\n---\n';
|
||||
const closingIndex = normalized.indexOf(closingMarker, 4);
|
||||
if (closingIndex === -1) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'malformed_frontmatter',
|
||||
message: 'Agent definition markdown frontmatter must close with --- on its own line.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter: normalized.slice(4, closingIndex),
|
||||
body: normalized.slice(closingIndex + closingMarker.length),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFrontmatter(
|
||||
frontmatter: string,
|
||||
options: MarkdownParseOptions
|
||||
): FrontmatterRecord | AgentDefinitionLoadError {
|
||||
const parsed: FrontmatterRecord = {};
|
||||
const lines = frontmatter.split('\n');
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = /^(\w+):\s*(.*)$/.exec(line);
|
||||
if (!match) {
|
||||
return malformedFrontmatter(options, `Invalid frontmatter line: ${line}`);
|
||||
}
|
||||
|
||||
const key = match[1];
|
||||
const rawValue = match[2];
|
||||
if (rawValue === '') {
|
||||
const values: string[] = [];
|
||||
while (index + 1 < lines.length && /^\s+-\s+/.test(lines[index + 1])) {
|
||||
index += 1;
|
||||
values.push(unquote(lines[index].replace(/^\s+-\s+/, '').trim()));
|
||||
}
|
||||
parsed[key] = values;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = parseFrontmatterValue(rawValue.trim(), options);
|
||||
if (isLoadError(value)) {
|
||||
return value;
|
||||
}
|
||||
parsed[key] = value;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseFrontmatterValue(
|
||||
value: string,
|
||||
options: MarkdownParseOptions
|
||||
): string | number | boolean | string[] | AgentDefinitionLoadError {
|
||||
if (value.startsWith('[')) {
|
||||
if (!value.endsWith(']')) {
|
||||
return malformedFrontmatter(options, `Invalid inline array: ${value}`);
|
||||
}
|
||||
|
||||
const inner = value.slice(1, -1).trim();
|
||||
return inner ? inner.split(',').map((item) => unquote(item.trim())) : [];
|
||||
}
|
||||
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
return unquote(value);
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function malformedFrontmatter(
|
||||
options: MarkdownParseOptions,
|
||||
message: string
|
||||
): AgentDefinitionLoadError {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'malformed_frontmatter',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function isLoadError(value: unknown): value is AgentDefinitionLoadError {
|
||||
return typeof value === 'object' && value !== null && 'code' in value;
|
||||
}
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
62
src/agent-kernel/definitions/agent-registry.ts
Normal file
62
src/agent-kernel/definitions/agent-registry.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { AgentDefinition } from './agent-definition';
|
||||
import { normalizeAgentDefinition } from './agent-definition';
|
||||
import type { AgentDefinitionLoadError } from './agent-loader';
|
||||
import { loadProjectAgentDefinitions } from './agent-loader';
|
||||
|
||||
export interface AgentRegistry {
|
||||
allAgents: AgentDefinition[];
|
||||
activeAgents: AgentDefinition[];
|
||||
failedFiles: AgentDefinitionLoadError[];
|
||||
getActiveAgent(agentType: string): AgentDefinition | undefined;
|
||||
}
|
||||
|
||||
export interface AgentRegistryInput {
|
||||
builtIn?: unknown[];
|
||||
plugin?: unknown[];
|
||||
user?: unknown[];
|
||||
project?: unknown[];
|
||||
failedFiles?: AgentDefinitionLoadError[];
|
||||
}
|
||||
|
||||
export interface LoadAgentRegistryOptions extends AgentRegistryInput {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
export function createAgentRegistry(input: AgentRegistryInput = {}): AgentRegistry {
|
||||
const allAgents = [
|
||||
...(input.builtIn ?? []),
|
||||
...(input.plugin ?? []),
|
||||
...(input.user ?? []),
|
||||
...(input.project ?? []),
|
||||
].map((definition) => normalizeAgentDefinition(definition));
|
||||
const activeByType = new Map<string, AgentDefinition>();
|
||||
|
||||
for (const agent of allAgents) {
|
||||
activeByType.set(agent.agentType, agent);
|
||||
}
|
||||
|
||||
return {
|
||||
allAgents,
|
||||
activeAgents: Array.from(activeByType.values()),
|
||||
failedFiles: input.failedFiles ?? [],
|
||||
getActiveAgent(agentType: string): AgentDefinition | undefined {
|
||||
return activeByType.get(agentType);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadAgentRegistry(
|
||||
options: LoadAgentRegistryOptions = {}
|
||||
): Promise<AgentRegistry> {
|
||||
const projectLoadResult = options.projectRoot
|
||||
? await loadProjectAgentDefinitions(options.projectRoot)
|
||||
: { definitions: [], failedFiles: [] };
|
||||
|
||||
return createAgentRegistry({
|
||||
builtIn: options.builtIn,
|
||||
plugin: options.plugin,
|
||||
user: options.user,
|
||||
project: [...(options.project ?? []), ...projectLoadResult.definitions],
|
||||
failedFiles: [...(options.failedFiles ?? []), ...projectLoadResult.failedFiles],
|
||||
});
|
||||
}
|
||||
28
src/agent-kernel/definitions/index.ts
Normal file
28
src/agent-kernel/definitions/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
AGENT_DEFINITION_SOURCES,
|
||||
AGENT_ISOLATIONS,
|
||||
AGENT_PERMISSION_MODES,
|
||||
agentDefinitionSchema,
|
||||
isAgentDefinition,
|
||||
normalizeAgentDefinition,
|
||||
parseAgentDefinition,
|
||||
} from './agent-definition';
|
||||
export {
|
||||
PROJECT_AGENT_DEFINITIONS_DIR,
|
||||
loadProjectAgentDefinitions,
|
||||
parseAgentDefinitionMarkdown,
|
||||
} from './agent-loader';
|
||||
export { createAgentRegistry, loadAgentRegistry } from './agent-registry';
|
||||
export type {
|
||||
AgentDefinition,
|
||||
AgentDefinitionHooks,
|
||||
AgentDefinitionSource,
|
||||
AgentIsolation,
|
||||
AgentPermissionMode,
|
||||
} from './agent-definition';
|
||||
export type {
|
||||
AgentDefinitionLoadError,
|
||||
AgentDefinitionLoadErrorCode,
|
||||
AgentDefinitionLoadResult,
|
||||
} from './agent-loader';
|
||||
export type { AgentRegistry, AgentRegistryInput, LoadAgentRegistryOptions } from './agent-registry';
|
||||
@@ -0,0 +1,620 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import { ScriptedMockLLM, scriptedTurn } from '../../../llm/e2e-mock';
|
||||
import { createAgentRegistry } from '../../definitions';
|
||||
import { agentSessionRepository } from '../../session';
|
||||
import { SubagentRunner } from '../../subagents/subagent-runner';
|
||||
import { createSpawnSubagentTool } from '../../tools';
|
||||
import { MainAgentRunner } from '../main-agent-runner';
|
||||
import type { MainAgentTool } from '../types';
|
||||
|
||||
function baseAgentDefinition() {
|
||||
return {
|
||||
agentType: 'general-purpose',
|
||||
name: 'General Purpose',
|
||||
whenToUse: 'Use for delegated analysis.',
|
||||
source: 'built-in' as const,
|
||||
tools: ['search_code', 'read_file'],
|
||||
disallowedTools: [],
|
||||
skills: [],
|
||||
hooks: {},
|
||||
maxTurns: 6,
|
||||
permissionMode: 'default' as const,
|
||||
background: false,
|
||||
isolation: 'none' as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Scripted Mock LLM dynamic agent flows', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `dynamic-agent-scripted-mock-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
function makeTools(record: { submissions: unknown[] }) {
|
||||
const readFileTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'read_file',
|
||||
description: 'Read deterministic test fixture.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
execute: (args) => ({ path: (args as { path: string }).path, content: 'const value = 1;' }),
|
||||
};
|
||||
|
||||
const searchCodeTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'search_code',
|
||||
description: 'Search deterministic test fixture.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string' } },
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (args) => ({
|
||||
matches: [{ path: 'src/app.ts', line: 1, query: (args as { query: string }).query }],
|
||||
}),
|
||||
};
|
||||
|
||||
const submitReviewFindingsTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'submit_review_findings',
|
||||
description: 'Capture deterministic submission payload.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summaryMarkdown: { type: 'string' },
|
||||
findings: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
required: ['summaryMarkdown', 'findings'],
|
||||
},
|
||||
},
|
||||
execute: (args) => {
|
||||
record.submissions.push(structuredClone(args));
|
||||
return { accepted: true };
|
||||
},
|
||||
};
|
||||
|
||||
return { readFileTool, searchCodeTool, submitReviewFindingsTool };
|
||||
}
|
||||
|
||||
test('deterministically scripts main->spawn_subagent->submit_review_findings flow', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'main-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Inspect changed file',
|
||||
prompt: 'Check correctness risks.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-search-1', name: 'search_code', arguments: '{"query":"value"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({ content: 'Subagent summary: potential correctness issue found.' }),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({ summaryMarkdown: 'Found one issue.', findings: [] }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Review finalized.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
agentType: 'review-main-agent',
|
||||
userMessage: 'Start dynamic review.',
|
||||
maxTurns: 8,
|
||||
maxToolCalls: 8,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('Review finalized.');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'read_file',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(scriptedModel.toolCallSequence('subagent')).toEqual(['search_code', 'read_file']);
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'read_file',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].status).toBe('completed');
|
||||
expect(
|
||||
tree?.invocations[0].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['search_code', 'read_file']);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Found one issue.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('supports deterministic no-subagent completion flow', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'main-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({ summaryMarkdown: 'No issues found.', findings: [] }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Done without subagent.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Review directly.',
|
||||
maxTurns: 6,
|
||||
maxToolCalls: 6,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual(['read_file', 'submit_review_findings']);
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.status).toBe('completed');
|
||||
expect(tree?.finalResult).toEqual({
|
||||
status: 'completed',
|
||||
turns: 3,
|
||||
toolCalls: 2,
|
||||
finalText: 'Done without subagent.',
|
||||
});
|
||||
expect(tree?.invocations).toHaveLength(0);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'read_file',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'No issues found.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('supports multiple subagent spawns in one main run with distinct child sessions', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Run child one',
|
||||
prompt: 'Inspect alpha path.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-search-1', name: 'search_code', arguments: '{"query":"alpha"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'subagent', turn: scriptedTurn({ content: 'Child one summary.' }) },
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-2',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Run child two',
|
||||
prompt: 'Inspect beta path.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'subagent', turn: scriptedTurn({ content: 'Child two summary.' }) },
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Two children completed.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main completed multi-child flow.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
agentType: 'review-main-agent',
|
||||
userMessage: 'Run two delegated checks.',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(scriptedModel.toolCallSequence('subagent')).toEqual(['search_code', 'read_file']);
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(2);
|
||||
expect(tree?.invocations[0].status).toBe('completed');
|
||||
expect(tree?.invocations[1].status).toBe('completed');
|
||||
expect(tree?.invocations[0].childSessionId).not.toBe(tree?.invocations[1].childSessionId);
|
||||
expect(
|
||||
tree?.invocations[0].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['search_code']);
|
||||
expect(
|
||||
tree?.invocations[1].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['read_file']);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Two children completed.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('propagates structured subagent failure and still allows main completion', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Investigate quickly',
|
||||
prompt: 'Run child checks.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Subagent failed; no findings.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main handled child failure.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Run subagent and continue on failure.',
|
||||
maxTurns: 6,
|
||||
maxToolCalls: 6,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
const secondMainRequest = scriptedModel.calls.filter((call) => call.session === 'main')[1];
|
||||
const lastMessage = secondMainRequest.request.messages.at(-1);
|
||||
expect(lastMessage?.role).toBe('tool');
|
||||
expect(lastMessage?.content).toContain('No scripted mock turn queued for session');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].status).toBe('failed');
|
||||
expect(tree?.invocations[0].result).toMatchObject({
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'Error',
|
||||
message: "No scripted mock turn queued for session 'subagent'",
|
||||
},
|
||||
});
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Subagent failed; no findings.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('filters disallowed child tools and persists deterministic failed tool call path', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Restricted run',
|
||||
prompt: 'Try forbidden search first.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-denied-1', name: 'search_code', arguments: '{"query":"restricted"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({ content: 'Child observed denied tool and completed.' }),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Permission filtered as expected.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main completed restricted flow.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({
|
||||
builtIn: [
|
||||
{
|
||||
...baseAgentDefinition(),
|
||||
tools: ['search_code', 'read_file'],
|
||||
disallowedTools: ['search_code'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Run with restricted subagent tools.',
|
||||
maxTurns: 8,
|
||||
maxToolCalls: 8,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
const secondSubagentRequest = scriptedModel.calls.filter(
|
||||
(call) => call.session === 'subagent'
|
||||
)[1];
|
||||
expect(secondSubagentRequest.request.messages.at(-1)?.role).toBe('tool');
|
||||
expect(secondSubagentRequest.request.messages.at(-1)?.content).toContain('ToolNotFoundError');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['search_code', 'read_file'],
|
||||
disallowedToolNames: ['search_code'],
|
||||
deniedToolNames: ['search_code'],
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'search_code',
|
||||
status: 'failed',
|
||||
arguments: { query: 'restricted' },
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'search_code' is not registered",
|
||||
},
|
||||
});
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Permission filtered as expected.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
});
|
||||
358
src/agent-kernel/loop/__tests__/main-agent-runner.test.ts
Normal file
358
src/agent-kernel/loop/__tests__/main-agent-runner.test.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../../llm/types';
|
||||
import { agentSessionRepository } from '../../session/session-repository';
|
||||
import { MainAgentRunner } from '../main-agent-runner';
|
||||
import type { MainAgentModelClient, MainAgentTool } from '../types';
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient implements MainAgentModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
const lookupTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'lookup',
|
||||
description: 'Look up a deterministic value.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (argumentsValue) => ({ echoed: (argumentsValue as { query: string }).query }),
|
||||
};
|
||||
|
||||
describe('MainAgentRunner', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `main-agent-runner-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('runs tool call, appends tool result, then returns final answer', async () => {
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
content: null,
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'lookup', arguments: '{"query":"alpha"}' }],
|
||||
}),
|
||||
response({ content: 'final answer' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
agentType: 'main',
|
||||
model: 'mock-model',
|
||||
userMessage: 'answer with a tool',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 4,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('final answer');
|
||||
expect(result.turns).toBe(2);
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-1',
|
||||
content: JSON.stringify({ ok: true, value: { echoed: 'alpha' } }),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(tree?.toolCalls[0].result).toEqual({ echoed: 'alpha' });
|
||||
expect(tree?.finalResult).toEqual({
|
||||
status: 'completed',
|
||||
turns: 2,
|
||||
toolCalls: 1,
|
||||
finalText: 'final answer',
|
||||
});
|
||||
});
|
||||
|
||||
test('completes on final assistant answer with no tool calls', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([response({ content: 'plain final' })]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'answer directly',
|
||||
maxTurns: 2,
|
||||
maxToolCalls: 2,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCalls).toBe(0);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('stops runaway model at max turns', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'lookup', arguments: '{"query":"one"}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-2', name: 'lookup', arguments: '{"query":"two"}' }],
|
||||
}),
|
||||
]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'keep calling tools',
|
||||
maxTurns: 2,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_turns_reached');
|
||||
expect(result.turns).toBe(2);
|
||||
expect(result.toolCalls).toBe(2);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('stops before exceeding max tool calls', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{ id: 'call-1', name: 'lookup', arguments: '{"query":"one"}' },
|
||||
{ id: 'call-2', name: 'lookup', arguments: '{"query":"two"}' },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'too many tools',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 1,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_tool_calls_reached');
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.toolCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('records tool execution errors as structured tool results and continues', async () => {
|
||||
const failingTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'fail_lookup',
|
||||
description: 'Always fails.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => {
|
||||
throw new Error('lookup failed');
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'fail_lookup', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'recovered' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [failingTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'recover from tool error',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 2,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls[0].status).toBe('failed');
|
||||
expect(tree?.toolCalls[0].error).toEqual({ name: 'Error', message: 'lookup failed' });
|
||||
expect(modelClient.requests[1].messages.at(-1)?.content).toBe(
|
||||
JSON.stringify({ ok: false, error: { name: 'Error', message: 'lookup failed' } })
|
||||
);
|
||||
});
|
||||
|
||||
test('stops on maxEmptyResponses', async () => {
|
||||
const modelClient = new FakeModelClient([
|
||||
response({ content: '' }),
|
||||
response({ content: '' }),
|
||||
response({ content: 'should not reach' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test empty responses',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxEmptyResponses: 2,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_empty_responses');
|
||||
expect(result.turns).toBe(2);
|
||||
});
|
||||
|
||||
test('stops on maxConsecutiveToolFailures', async () => {
|
||||
const failTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'fail_tool',
|
||||
description: 'Always fails.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c1', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c2', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c3', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'should not reach' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [failTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test tool failures',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxConsecutiveToolFailures: 3,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_consecutive_tool_failures');
|
||||
});
|
||||
|
||||
test('refuses subagent spawn beyond maxSubagents and allows summary', async () => {
|
||||
const subagentTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'spawn_subagent',
|
||||
description: 'Spawn a subagent.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => ({ status: 'completed' }),
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c1', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c2', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c3', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'review complete with 2 subagents' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [subagentTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test subagent limit',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxSubagents: 2,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('review complete with 2 subagents');
|
||||
});
|
||||
});
|
||||
2
src/agent-kernel/loop/index.ts
Normal file
2
src/agent-kernel/loop/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './main-agent-runner';
|
||||
export * from './types';
|
||||
346
src/agent-kernel/loop/main-agent-runner.ts
Normal file
346
src/agent-kernel/loop/main-agent-runner.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import type { LLMMessage, LLMToolCall } from '../../llm/types';
|
||||
import { agentSessionRepository } from '../session/session-repository';
|
||||
import type {
|
||||
MainAgentRunInput,
|
||||
MainAgentRunResult,
|
||||
MainAgentRunnerOptions,
|
||||
MainAgentTerminalStatus,
|
||||
MainAgentTool,
|
||||
MainAgentTranscriptRepository,
|
||||
ToolExecutionResult,
|
||||
} from './types';
|
||||
|
||||
function parseToolArguments(toolCall: LLMToolCall): ToolExecutionResult {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(toolCall.arguments || '{}') };
|
||||
} catch (error) {
|
||||
const parsedError = error instanceof Error ? error : new Error(String(error));
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
name: parsedError.name,
|
||||
message: parsedError.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyToolResult(result: ToolExecutionResult): string {
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): ToolExecutionResult['error'] {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Error',
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
export class MainAgentRunner {
|
||||
private readonly modelClient: MainAgentRunnerOptions['modelClient'];
|
||||
private readonly transcriptRepository: MainAgentTranscriptRepository;
|
||||
private readonly toolsByName: Map<string, MainAgentTool>;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: MainAgentRunnerOptions) {
|
||||
this.modelClient = options.modelClient;
|
||||
this.transcriptRepository = options.transcriptRepository;
|
||||
this.toolsByName = new Map((options.tools ?? []).map((tool) => [tool.definition.name, tool]));
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async run(input: MainAgentRunInput): Promise<MainAgentRunResult> {
|
||||
const startedAt = this.now();
|
||||
const sessionId =
|
||||
input.sessionId ??
|
||||
this.transcriptRepository.createSession({
|
||||
agentType: input.session?.agentType ?? input.agentType ?? 'main',
|
||||
model: input.session?.model ?? input.model,
|
||||
parentSessionId: input.session?.parentSessionId,
|
||||
parentInvocationId: input.session?.parentInvocationId,
|
||||
status: input.session?.status,
|
||||
metadata: input.session?.metadata,
|
||||
}).id;
|
||||
|
||||
const messages: LLMMessage[] = [];
|
||||
if (input.systemPrompt) {
|
||||
messages.push({ role: 'system', content: input.systemPrompt });
|
||||
}
|
||||
|
||||
const userMessage: LLMMessage = { role: 'user', content: input.userMessage };
|
||||
messages.push(userMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: { text: input.userMessage },
|
||||
});
|
||||
|
||||
let turns = 0;
|
||||
let toolCalls = 0;
|
||||
let subagentCount = 0;
|
||||
let emptyResponseCount = 0;
|
||||
let consecutiveToolFailures = 0;
|
||||
const maxSubagents = input.maxSubagents ?? Number.POSITIVE_INFINITY;
|
||||
const maxEmptyResponses = input.maxEmptyResponses ?? 3;
|
||||
const maxConsecutiveToolFailures = input.maxConsecutiveToolFailures ?? 5;
|
||||
|
||||
while (true) {
|
||||
const budgetStatus = this.getBudgetStatus(
|
||||
input,
|
||||
startedAt,
|
||||
turns,
|
||||
emptyResponseCount,
|
||||
consecutiveToolFailures,
|
||||
maxEmptyResponses,
|
||||
maxConsecutiveToolFailures
|
||||
);
|
||||
if (budgetStatus) {
|
||||
return this.finish(sessionId, budgetStatus, turns, toolCalls, messages);
|
||||
}
|
||||
|
||||
const response = await this.modelClient.chat({
|
||||
messages,
|
||||
model: input.model,
|
||||
temperature: input.temperature,
|
||||
maxTokens: input.maxTokens,
|
||||
responseFormat: input.responseFormat,
|
||||
providerOptions: input.providerOptions,
|
||||
tools: [...this.toolsByName.values()].map((tool) => tool.definition),
|
||||
});
|
||||
|
||||
turns += 1;
|
||||
|
||||
if (!response.content?.trim() && response.toolCalls.length === 0) {
|
||||
emptyResponseCount += 1;
|
||||
messages.push({ role: 'assistant', content: '' });
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: { text: '' },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
emptyResponseCount = 0;
|
||||
|
||||
const assistantMessage: LLMMessage = {
|
||||
role: 'assistant',
|
||||
content: response.content ?? '',
|
||||
toolCalls: response.toolCalls,
|
||||
};
|
||||
messages.push(assistantMessage);
|
||||
|
||||
const assistantRecord = this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: {
|
||||
text: response.content ?? '',
|
||||
toolCalls: response.toolCalls,
|
||||
finishReason: response.finishReason,
|
||||
usage: response.usage,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.toolCalls.length === 0) {
|
||||
return this.finish(
|
||||
sessionId,
|
||||
'completed',
|
||||
turns,
|
||||
toolCalls,
|
||||
messages,
|
||||
response.content ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
for (const toolCall of response.toolCalls) {
|
||||
if (this.isTimedOut(input, startedAt)) {
|
||||
return this.finish(sessionId, 'timeout_reached', turns, toolCalls, messages);
|
||||
}
|
||||
if (toolCalls >= input.maxToolCalls) {
|
||||
return this.finish(sessionId, 'max_tool_calls_reached', turns, toolCalls, messages);
|
||||
}
|
||||
|
||||
if (toolCall.name === 'spawn_subagent') {
|
||||
if (subagentCount >= maxSubagents) {
|
||||
const refusalMessage: LLMMessage = {
|
||||
role: 'tool',
|
||||
toolCallId: toolCall.id,
|
||||
content: JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'BudgetExceeded',
|
||||
message: `Subagent limit reached (${maxSubagents}). Please summarize your findings instead.`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
messages.push(refusalMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'tool',
|
||||
content: {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: {
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'BudgetExceeded',
|
||||
message: `Subagent limit reached (${maxSubagents})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
subagentCount += 1;
|
||||
}
|
||||
|
||||
const result = await this.executeTool(toolCall, sessionId, input.model, turns);
|
||||
toolCalls += 1;
|
||||
if (!result.ok) {
|
||||
consecutiveToolFailures += 1;
|
||||
} else {
|
||||
consecutiveToolFailures = 0;
|
||||
}
|
||||
|
||||
this.transcriptRepository.appendToolCall({
|
||||
sessionId,
|
||||
messageId: assistantRecord.id,
|
||||
toolName: toolCall.name,
|
||||
status: result.ok ? 'completed' : 'failed',
|
||||
arguments: parseToolArguments(toolCall).value ?? {},
|
||||
result: result.ok ? result.value : undefined,
|
||||
error: result.ok ? undefined : result.error,
|
||||
});
|
||||
|
||||
const toolMessage: LLMMessage = {
|
||||
role: 'tool',
|
||||
toolCallId: toolCall.id,
|
||||
content: stringifyToolResult(result),
|
||||
};
|
||||
messages.push(toolMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'tool',
|
||||
content: {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.ok && consecutiveToolFailures >= maxConsecutiveToolFailures) {
|
||||
return this.finish(
|
||||
sessionId,
|
||||
'max_consecutive_tool_failures',
|
||||
turns,
|
||||
toolCalls,
|
||||
messages
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getBudgetStatus(
|
||||
input: MainAgentRunInput,
|
||||
startedAt: number,
|
||||
turns: number,
|
||||
emptyResponseCount: number,
|
||||
consecutiveToolFailures: number,
|
||||
maxEmptyResponses: number,
|
||||
maxConsecutiveToolFailures: number
|
||||
): MainAgentTerminalStatus | undefined {
|
||||
if (this.isTimedOut(input, startedAt)) return 'timeout_reached';
|
||||
if (turns >= input.maxTurns) return 'max_turns_reached';
|
||||
if (emptyResponseCount >= maxEmptyResponses) return 'max_empty_responses';
|
||||
if (consecutiveToolFailures >= maxConsecutiveToolFailures)
|
||||
return 'max_consecutive_tool_failures';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isTimedOut(input: MainAgentRunInput, startedAt: number): boolean {
|
||||
return this.now() - startedAt >= input.timeoutMs;
|
||||
}
|
||||
|
||||
private async executeTool(
|
||||
toolCall: LLMToolCall,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
turn: number
|
||||
): Promise<ToolExecutionResult> {
|
||||
const parsedArguments = parseToolArguments(toolCall);
|
||||
if (!parsedArguments.ok) return parsedArguments;
|
||||
|
||||
const tool = this.toolsByName.get(toolCall.name);
|
||||
if (!tool) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: `Tool '${toolCall.name}' is not registered`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await tool.execute(parsedArguments.value, {
|
||||
sessionId,
|
||||
model,
|
||||
toolCall,
|
||||
turn,
|
||||
});
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: normalizeError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private finish(
|
||||
sessionId: string,
|
||||
status: MainAgentTerminalStatus,
|
||||
turns: number,
|
||||
toolCalls: number,
|
||||
messages: LLMMessage[],
|
||||
finalText?: string
|
||||
): MainAgentRunResult {
|
||||
this.transcriptRepository.completeSession({
|
||||
sessionId,
|
||||
status: status === 'completed' ? 'completed' : 'failed',
|
||||
finalResult: {
|
||||
status,
|
||||
turns,
|
||||
toolCalls,
|
||||
finalText,
|
||||
},
|
||||
error: status === 'completed' ? undefined : { status },
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
sessionId,
|
||||
turns,
|
||||
toolCalls,
|
||||
finalText,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const mainAgentRunner = new MainAgentRunner({
|
||||
modelClient: {
|
||||
chat: () => {
|
||||
throw new Error('MainAgentRunner requires an injected model client');
|
||||
},
|
||||
},
|
||||
transcriptRepository: agentSessionRepository,
|
||||
});
|
||||
118
src/agent-kernel/loop/types.ts
Normal file
118
src/agent-kernel/loop/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
LLMChatRequest,
|
||||
LLMChatResponse,
|
||||
LLMMessage,
|
||||
LLMToolCall,
|
||||
LLMToolDefinition,
|
||||
} from '../../llm/types';
|
||||
import type {
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentToolCallRecord,
|
||||
CreateAgentSessionInput,
|
||||
} from '../session/types';
|
||||
|
||||
export type MainAgentTerminalStatus =
|
||||
| 'completed'
|
||||
| 'max_turns_reached'
|
||||
| 'max_tool_calls_reached'
|
||||
| 'max_subagents_reached'
|
||||
| 'timeout_reached'
|
||||
| 'max_empty_responses'
|
||||
| 'max_consecutive_tool_failures';
|
||||
|
||||
export interface MainAgentModelClient {
|
||||
chat(request: LLMChatRequest): Promise<LLMChatResponse>;
|
||||
}
|
||||
|
||||
export interface MainAgentToolContext {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
toolCall: LLMToolCall;
|
||||
turn: number;
|
||||
}
|
||||
|
||||
export type ToolPermissionScope =
|
||||
| 'read'
|
||||
| 'write'
|
||||
| 'command'
|
||||
| 'network'
|
||||
| 'git_write'
|
||||
| 'cross_session';
|
||||
|
||||
export type ToolPermissionBehavior = 'allow' | 'deny';
|
||||
|
||||
export interface MainAgentTool {
|
||||
definition: LLMToolDefinition;
|
||||
permissionScope?: ToolPermissionScope;
|
||||
execute(argumentsValue: unknown, context: MainAgentToolContext): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface MainAgentTranscriptRepository {
|
||||
createSession(input: CreateAgentSessionInput): AgentSessionRecord;
|
||||
appendMessage(input: {
|
||||
sessionId: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): AgentMessageRecord;
|
||||
appendToolCall(input: {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
toolName: string;
|
||||
status?: 'running' | 'completed' | 'failed';
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}): AgentToolCallRecord;
|
||||
completeSession(input: {
|
||||
sessionId: string;
|
||||
status: 'completed' | 'failed' | 'cancelled';
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
}): AgentSessionRecord;
|
||||
}
|
||||
|
||||
export interface MainAgentRunnerOptions {
|
||||
modelClient: MainAgentModelClient;
|
||||
transcriptRepository: MainAgentTranscriptRepository;
|
||||
tools?: MainAgentTool[];
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface MainAgentRunInput {
|
||||
session?: Omit<CreateAgentSessionInput, 'model'> & { model?: string };
|
||||
sessionId?: string;
|
||||
agentType?: string;
|
||||
model: string;
|
||||
systemPrompt?: string;
|
||||
userMessage: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
responseFormat?: 'text' | 'json';
|
||||
providerOptions?: Record<string, unknown>;
|
||||
maxTurns: number;
|
||||
maxToolCalls: number;
|
||||
maxSubagents?: number;
|
||||
timeoutMs: number;
|
||||
maxEmptyResponses?: number;
|
||||
maxConsecutiveToolFailures?: number;
|
||||
}
|
||||
|
||||
export interface MainAgentRunResult {
|
||||
status: MainAgentTerminalStatus;
|
||||
sessionId: string;
|
||||
turns: number;
|
||||
toolCalls: number;
|
||||
finalText?: string;
|
||||
messages: LLMMessage[];
|
||||
}
|
||||
|
||||
export interface ToolExecutionResult {
|
||||
ok: boolean;
|
||||
value?: unknown;
|
||||
error?: {
|
||||
name: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
45
src/agent-kernel/model/__tests__/model-resolver.test.ts
Normal file
45
src/agent-kernel/model/__tests__/model-resolver.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveAgentModel } from '../model-resolver';
|
||||
|
||||
describe('resolveAgentModel', () => {
|
||||
test('uses spawn override before every configured fallback', () => {
|
||||
const model = resolveAgentModel({
|
||||
spawnOverride: 'spawn-model',
|
||||
agentDefinition: { model: 'definition-model' },
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('spawn-model');
|
||||
});
|
||||
|
||||
test('falls back to AgentDefinition.model when spawn override is missing', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: { model: 'definition-model' },
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('definition-model');
|
||||
});
|
||||
|
||||
test('falls back to defaultSubagentModel when AgentDefinition.model is missing', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: {},
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('subagent-default-model');
|
||||
});
|
||||
|
||||
test('falls back to mainAgentModel when no subagent-specific model exists', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: {},
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('main-model');
|
||||
});
|
||||
});
|
||||
2
src/agent-kernel/model/index.ts
Normal file
2
src/agent-kernel/model/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { resolveAgentModel } from './model-resolver';
|
||||
export type { AgentModelResolutionInput } from './model-resolver';
|
||||
17
src/agent-kernel/model/model-resolver.ts
Normal file
17
src/agent-kernel/model/model-resolver.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AgentDefinition } from '../definitions';
|
||||
|
||||
export interface AgentModelResolutionInput {
|
||||
spawnOverride?: string;
|
||||
agentDefinition: Pick<AgentDefinition, 'model'>;
|
||||
defaultSubagentModel?: string;
|
||||
mainAgentModel: string;
|
||||
}
|
||||
|
||||
export function resolveAgentModel(input: AgentModelResolutionInput): string {
|
||||
return (
|
||||
input.spawnOverride ??
|
||||
input.agentDefinition.model ??
|
||||
input.defaultSubagentModel ??
|
||||
input.mainAgentModel
|
||||
);
|
||||
}
|
||||
224
src/agent-kernel/session/__tests__/session-repository.test.ts
Normal file
224
src/agent-kernel/session/__tests__/session-repository.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, getDatabase, initDatabase } from '../../../db/database';
|
||||
import { agentSessionRepository } from '../session-repository';
|
||||
|
||||
describe('agentSessionRepository', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `agent-session-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('migration creates transcript tables and can run idempotently', () => {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name IN (
|
||||
'agent_sessions', 'agent_messages', 'agent_tool_calls', 'agent_invocations'
|
||||
)
|
||||
ORDER BY name`
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual([
|
||||
'agent_invocations',
|
||||
'agent_messages',
|
||||
'agent_sessions',
|
||||
'agent_tool_calls',
|
||||
]);
|
||||
|
||||
closeDatabase();
|
||||
initDatabase();
|
||||
|
||||
const migrationRow = getDatabase()
|
||||
.query('SELECT COUNT(*) AS count FROM _migrations WHERE version = 5')
|
||||
.get() as { count: number };
|
||||
expect(migrationRow.count).toBe(1);
|
||||
});
|
||||
|
||||
test('queries parent-child transcript tree in insertion order', () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'gpt-main',
|
||||
metadata: { requestId: 'req-1' },
|
||||
});
|
||||
const secondMessage = agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'assistant',
|
||||
content: { text: 'second' },
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'user',
|
||||
content: { text: 'first but inserted second' },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: parent.id,
|
||||
messageId: secondMessage.id,
|
||||
toolName: 'search_code',
|
||||
arguments: { query: 'alpha' },
|
||||
result: { matches: 1 },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: parent.id,
|
||||
toolName: 'read_file',
|
||||
arguments: { path: 'src/index.ts' },
|
||||
result: { content: 'ok' },
|
||||
});
|
||||
|
||||
const firstInvocation = agentSessionRepository.createInvocation({
|
||||
parentSessionId: parent.id,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
input: { goal: 'security' },
|
||||
});
|
||||
const secondInvocation = agentSessionRepository.createInvocation({
|
||||
parentSessionId: parent.id,
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
input: { goal: 'quality' },
|
||||
});
|
||||
const child = agentSessionRepository.createSession({
|
||||
parentSessionId: parent.id,
|
||||
parentInvocationId: firstInvocation.id,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: child.id,
|
||||
role: 'assistant',
|
||||
content: { text: 'child transcript' },
|
||||
});
|
||||
agentSessionRepository.completeInvocation({
|
||||
invocationId: firstInvocation.id,
|
||||
status: 'completed',
|
||||
result: { summary: 'done' },
|
||||
childSessionId: child.id,
|
||||
});
|
||||
agentSessionRepository.completeInvocation({
|
||||
invocationId: secondInvocation.id,
|
||||
status: 'failed',
|
||||
error: { message: 'boom' },
|
||||
});
|
||||
agentSessionRepository.completeSession({
|
||||
sessionId: parent.id,
|
||||
status: 'completed',
|
||||
finalResult: { summary: 'parent done' },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.agentType).toBe('main');
|
||||
expect(tree?.messages.map((message) => message.content)).toEqual([
|
||||
{ text: 'second' },
|
||||
{ text: 'first but inserted second' },
|
||||
]);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'search_code',
|
||||
'read_file',
|
||||
]);
|
||||
expect(tree?.invocations.map((invocation) => invocation.agentType)).toEqual([
|
||||
'security-reviewer',
|
||||
'quality-reviewer',
|
||||
]);
|
||||
expect(tree?.invocations[0].childSession?.messages[0].content).toEqual({
|
||||
text: 'child transcript',
|
||||
});
|
||||
expect(tree?.invocations[1].error).toEqual({ message: 'boom' });
|
||||
|
||||
const completedTranscript = agentSessionRepository.getInvocationTranscript(firstInvocation.id);
|
||||
expect(completedTranscript?.invocation.id).toBe(firstInvocation.id);
|
||||
expect(completedTranscript?.childSession?.id).toBe(child.id);
|
||||
expect(completedTranscript?.childSession?.messages[0].content).toEqual({
|
||||
text: 'child transcript',
|
||||
});
|
||||
|
||||
const failedTranscript = agentSessionRepository.getInvocationTranscript(secondInvocation.id);
|
||||
expect(failedTranscript?.invocation.id).toBe(secondInvocation.id);
|
||||
expect(failedTranscript?.childSession).toBeUndefined();
|
||||
expect(agentSessionRepository.getInvocationTranscript('missing-invocation')).toBeNull();
|
||||
});
|
||||
|
||||
test('redacts sensitive JSON fields before storage', () => {
|
||||
const session = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'gpt-main',
|
||||
metadata: {
|
||||
apiKey: 'sk-live',
|
||||
nested: { authorization: 'Bearer token', safe: 'visible' },
|
||||
},
|
||||
});
|
||||
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: session.id,
|
||||
role: 'user',
|
||||
content: { password: 'p4ss', text: 'keep me' },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: session.id,
|
||||
toolName: 'call_provider',
|
||||
arguments: { token: 'tok_123', payload: { secret: 'hidden', value: 1 } },
|
||||
result: { ok: true, refreshToken: 'refresh_123' },
|
||||
});
|
||||
agentSessionRepository.completeSession({
|
||||
sessionId: session.id,
|
||||
status: 'failed',
|
||||
error: { message: 'bad', credentials: { api_key: 'secret-key' } },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(session.id);
|
||||
expect(tree?.metadata).toEqual({
|
||||
apiKey: '[REDACTED]',
|
||||
nested: { authorization: '[REDACTED]', safe: 'visible' },
|
||||
});
|
||||
expect(tree?.messages[0].content).toEqual({ password: '[REDACTED]', text: 'keep me' });
|
||||
expect(tree?.toolCalls[0].arguments).toEqual({
|
||||
token: '[REDACTED]',
|
||||
payload: { secret: '[REDACTED]', value: 1 },
|
||||
});
|
||||
expect(tree?.toolCalls[0].result).toEqual({ ok: true, refreshToken: '[REDACTED]' });
|
||||
expect(tree?.error).toEqual({
|
||||
message: 'bad',
|
||||
credentials: '[REDACTED]',
|
||||
});
|
||||
});
|
||||
|
||||
test('getSessionTreeByRunId finds the correct session tree by reviewRunId', () => {
|
||||
const runId = 'test-run-123';
|
||||
const session = agentSessionRepository.createSession({
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
metadata: { reviewRunId: runId },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTreeByRunId(runId);
|
||||
expect(tree).not.toBeNull();
|
||||
expect(tree?.id).toBe(session.id);
|
||||
expect(tree?.metadata.reviewRunId).toBe(runId);
|
||||
|
||||
const missingTree = agentSessionRepository.getSessionTreeByRunId('missing-run');
|
||||
expect(missingTree).toBeNull();
|
||||
});
|
||||
});
|
||||
18
src/agent-kernel/session/index.ts
Normal file
18
src/agent-kernel/session/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { agentSessionRepository, AgentSessionRepository } from './session-repository';
|
||||
export { redactSensitiveFields } from './redaction';
|
||||
export type {
|
||||
AgentInvocationRecord,
|
||||
AgentInvocationTranscript,
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentSessionStatus,
|
||||
AgentSessionTree,
|
||||
AgentToolCallRecord,
|
||||
AgentToolCallStatus,
|
||||
AppendAgentMessageInput,
|
||||
AppendAgentToolCallInput,
|
||||
CompleteAgentInvocationInput,
|
||||
CompleteAgentSessionInput,
|
||||
CreateAgentInvocationInput,
|
||||
CreateAgentSessionInput,
|
||||
} from './types';
|
||||
36
src/agent-kernel/session/redaction.ts
Normal file
36
src/agent-kernel/session/redaction.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const REDACTED_VALUE = '[REDACTED]';
|
||||
|
||||
const SENSITIVE_KEY_PARTS = [
|
||||
'apikey',
|
||||
'api_key',
|
||||
'authorization',
|
||||
'auth_token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'token',
|
||||
'password',
|
||||
'passwd',
|
||||
'secret',
|
||||
'credential',
|
||||
];
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const normalized = key.replace(/[-\s]/g, '_').toLowerCase();
|
||||
return SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
|
||||
}
|
||||
|
||||
export function redactSensitiveFields<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactSensitiveFields(item)) as T;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
redacted[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactSensitiveFields(childValue);
|
||||
}
|
||||
return redacted as T;
|
||||
}
|
||||
376
src/agent-kernel/session/session-repository.ts
Normal file
376
src/agent-kernel/session/session-repository.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '../../db/database';
|
||||
import { redactSensitiveFields } from './redaction';
|
||||
import type {
|
||||
AgentInvocationRecord,
|
||||
AgentInvocationTranscript,
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentSessionStatus,
|
||||
AgentSessionTree,
|
||||
AgentToolCallRecord,
|
||||
AgentToolCallStatus,
|
||||
AppendAgentMessageInput,
|
||||
AppendAgentToolCallInput,
|
||||
CompleteAgentInvocationInput,
|
||||
CompleteAgentSessionInput,
|
||||
CreateAgentInvocationInput,
|
||||
CreateAgentSessionInput,
|
||||
} from './types';
|
||||
|
||||
interface AgentSessionRow {
|
||||
id: string;
|
||||
parent_session_id: string | null;
|
||||
parent_invocation_id: string | null;
|
||||
agent_type: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
metadata_json: string;
|
||||
final_result_json: string | null;
|
||||
error_json: string | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AgentMessageRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content_json: string;
|
||||
metadata_json: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AgentToolCallRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
message_id: string | null;
|
||||
sequence: number;
|
||||
tool_name: string;
|
||||
status: AgentToolCallStatus;
|
||||
arguments_json: string;
|
||||
result_json: string | null;
|
||||
error_json: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface AgentInvocationRow {
|
||||
id: string;
|
||||
parent_session_id: string;
|
||||
child_session_id: string | null;
|
||||
sequence: number;
|
||||
agent_type: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
input_json: string;
|
||||
result_json: string | null;
|
||||
error_json: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(redactSensitiveFields(value));
|
||||
}
|
||||
|
||||
function parseJson(value: string | null): unknown | undefined {
|
||||
return value === null ? undefined : JSON.parse(value);
|
||||
}
|
||||
|
||||
function nextSequence(tableName: string, ownerColumn: string, ownerId: string): number {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM ${tableName} WHERE ${ownerColumn} = ?`
|
||||
)
|
||||
.get(ownerId) as { next_sequence: number };
|
||||
return row.next_sequence;
|
||||
}
|
||||
|
||||
function toSessionRecord(row: AgentSessionRow): AgentSessionRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
parentSessionId: row.parent_session_id ?? undefined,
|
||||
parentInvocationId: row.parent_invocation_id ?? undefined,
|
||||
agentType: row.agent_type,
|
||||
model: row.model,
|
||||
status: row.status,
|
||||
metadata: JSON.parse(row.metadata_json) as Record<string, unknown>,
|
||||
finalResult: parseJson(row.final_result_json),
|
||||
error: parseJson(row.error_json),
|
||||
startedAt: row.started_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toMessageRecord(row: AgentMessageRow): AgentMessageRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
sequence: row.sequence,
|
||||
role: row.role,
|
||||
content: JSON.parse(row.content_json),
|
||||
metadata: JSON.parse(row.metadata_json) as Record<string, unknown>,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toToolCallRecord(row: AgentToolCallRow): AgentToolCallRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
messageId: row.message_id ?? undefined,
|
||||
sequence: row.sequence,
|
||||
toolName: row.tool_name,
|
||||
status: row.status,
|
||||
arguments: JSON.parse(row.arguments_json),
|
||||
result: parseJson(row.result_json),
|
||||
error: parseJson(row.error_json),
|
||||
createdAt: row.created_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toInvocationRecord(row: AgentInvocationRow): AgentInvocationRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
parentSessionId: row.parent_session_id,
|
||||
childSessionId: row.child_session_id ?? undefined,
|
||||
sequence: row.sequence,
|
||||
agentType: row.agent_type,
|
||||
model: row.model,
|
||||
status: row.status,
|
||||
input: JSON.parse(row.input_json),
|
||||
result: parseJson(row.result_json),
|
||||
error: parseJson(row.error_json),
|
||||
createdAt: row.created_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class AgentSessionRepository {
|
||||
createSession(input: CreateAgentSessionInput): AgentSessionRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
db.query(
|
||||
`INSERT INTO agent_sessions (
|
||||
id, parent_session_id, parent_invocation_id, agent_type, model, status, metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.parentSessionId ?? null,
|
||||
input.parentInvocationId ?? null,
|
||||
input.agentType,
|
||||
input.model,
|
||||
input.status ?? 'running',
|
||||
stringifyJson(input.metadata ?? {})
|
||||
);
|
||||
|
||||
const session = this.getSession(id);
|
||||
if (!session) throw new Error('Failed to load created agent session');
|
||||
return session;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): AgentSessionRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_sessions WHERE id = ?')
|
||||
.get(sessionId) as AgentSessionRow | null;
|
||||
return row ? toSessionRecord(row) : null;
|
||||
}
|
||||
|
||||
appendMessage(input: AppendAgentMessageInput): AgentMessageRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const sequence = nextSequence('agent_messages', 'session_id', input.sessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_messages (id, session_id, sequence, role, content_json, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.sessionId,
|
||||
sequence,
|
||||
input.role,
|
||||
stringifyJson(input.content),
|
||||
stringifyJson(input.metadata ?? {})
|
||||
);
|
||||
return this.getMessage(id) as AgentMessageRecord;
|
||||
}
|
||||
|
||||
appendToolCall(input: AppendAgentToolCallInput): AgentToolCallRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const status = input.status ?? 'completed';
|
||||
const sequence = nextSequence('agent_tool_calls', 'session_id', input.sessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_tool_calls (
|
||||
id, session_id, message_id, sequence, tool_name, status, arguments_json, result_json, error_json, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.sessionId,
|
||||
input.messageId ?? null,
|
||||
sequence,
|
||||
input.toolName,
|
||||
status,
|
||||
stringifyJson(input.arguments ?? {}),
|
||||
input.result === undefined ? null : stringifyJson(input.result),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
status === 'running' ? null : new Date().toISOString()
|
||||
);
|
||||
return this.getToolCall(id) as AgentToolCallRecord;
|
||||
}
|
||||
|
||||
createInvocation(input: CreateAgentInvocationInput): AgentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const sequence = nextSequence('agent_invocations', 'parent_session_id', input.parentSessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_invocations (
|
||||
id, parent_session_id, child_session_id, sequence, agent_type, model, status, input_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.parentSessionId,
|
||||
input.childSessionId ?? null,
|
||||
sequence,
|
||||
input.agentType,
|
||||
input.model,
|
||||
input.status ?? 'running',
|
||||
stringifyJson(input.input ?? {})
|
||||
);
|
||||
return this.getInvocation(id) as AgentInvocationRecord;
|
||||
}
|
||||
|
||||
completeSession(input: CompleteAgentSessionInput): AgentSessionRecord {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`UPDATE agent_sessions
|
||||
SET status = ?, final_result_json = ?, error_json = ?, completed_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.status,
|
||||
input.finalResult === undefined ? null : stringifyJson(input.finalResult),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
input.sessionId
|
||||
);
|
||||
return this.getSession(input.sessionId) as AgentSessionRecord;
|
||||
}
|
||||
|
||||
completeInvocation(input: CompleteAgentInvocationInput): AgentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`UPDATE agent_invocations
|
||||
SET status = ?, child_session_id = COALESCE(?, child_session_id), result_json = ?, error_json = ?, completed_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.status,
|
||||
input.childSessionId ?? null,
|
||||
input.result === undefined ? null : stringifyJson(input.result),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
input.invocationId
|
||||
);
|
||||
return this.getInvocation(input.invocationId) as AgentInvocationRecord;
|
||||
}
|
||||
|
||||
getSessionTree(rootSessionId: string): AgentSessionTree | null {
|
||||
const session = this.getSession(rootSessionId);
|
||||
if (!session) return null;
|
||||
|
||||
const invocations = this.listInvocations(rootSessionId).map((invocation) => ({
|
||||
...invocation,
|
||||
childSession: invocation.childSessionId
|
||||
? (this.getSessionTree(invocation.childSessionId) ?? undefined)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
...session,
|
||||
messages: this.listMessages(rootSessionId),
|
||||
toolCalls: this.listToolCalls(rootSessionId),
|
||||
invocations,
|
||||
};
|
||||
}
|
||||
|
||||
getSessionTreeByRunId(runId: string): AgentSessionTree | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id FROM agent_sessions
|
||||
WHERE parent_session_id IS NULL
|
||||
AND json_extract(metadata_json, '$.reviewRunId') = ?`
|
||||
)
|
||||
.get(runId) as { id: string } | null;
|
||||
|
||||
if (!row) return null;
|
||||
return this.getSessionTree(row.id);
|
||||
}
|
||||
|
||||
listMessages(sessionId: string): AgentMessageRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_messages WHERE session_id = ? ORDER BY sequence ASC')
|
||||
.all(sessionId) as AgentMessageRow[];
|
||||
return rows.map(toMessageRecord);
|
||||
}
|
||||
|
||||
listToolCalls(sessionId: string): AgentToolCallRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_tool_calls WHERE session_id = ? ORDER BY sequence ASC')
|
||||
.all(sessionId) as AgentToolCallRow[];
|
||||
return rows.map(toToolCallRecord);
|
||||
}
|
||||
|
||||
listInvocations(parentSessionId: string): AgentInvocationRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_invocations WHERE parent_session_id = ? ORDER BY sequence ASC')
|
||||
.all(parentSessionId) as AgentInvocationRow[];
|
||||
return rows.map(toInvocationRecord);
|
||||
}
|
||||
|
||||
getInvocationTranscript(invocationId: string): AgentInvocationTranscript | null {
|
||||
const invocation = this.getInvocation(invocationId);
|
||||
if (!invocation) return null;
|
||||
|
||||
return {
|
||||
invocation,
|
||||
childSession: invocation.childSessionId
|
||||
? (this.getSessionTree(invocation.childSessionId) ?? undefined)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private getMessage(messageId: string): AgentMessageRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_messages WHERE id = ?')
|
||||
.get(messageId) as AgentMessageRow | null;
|
||||
return row ? toMessageRecord(row) : null;
|
||||
}
|
||||
|
||||
private getToolCall(toolCallId: string): AgentToolCallRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_tool_calls WHERE id = ?')
|
||||
.get(toolCallId) as AgentToolCallRow | null;
|
||||
return row ? toToolCallRecord(row) : null;
|
||||
}
|
||||
|
||||
private getInvocation(invocationId: string): AgentInvocationRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_invocations WHERE id = ?')
|
||||
.get(invocationId) as AgentInvocationRow | null;
|
||||
return row ? toInvocationRecord(row) : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentSessionRepository = new AgentSessionRepository();
|
||||
122
src/agent-kernel/session/types.ts
Normal file
122
src/agent-kernel/session/types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
export type AgentSessionStatus = 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
export type AgentToolCallStatus = 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface CreateAgentSessionInput {
|
||||
id?: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status?: AgentSessionStatus;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentSessionRecord {
|
||||
id: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
metadata: Record<string, unknown>;
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AppendAgentMessageInput {
|
||||
id?: string;
|
||||
sessionId: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AppendAgentToolCallInput {
|
||||
id?: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
toolName: string;
|
||||
status?: AgentToolCallStatus;
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentToolCallRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
sequence: number;
|
||||
toolName: string;
|
||||
status: AgentToolCallStatus;
|
||||
arguments: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentInvocationInput {
|
||||
id?: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status?: AgentSessionStatus;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentInvocationRecord {
|
||||
id: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
sequence: number;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
input: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface CompleteAgentSessionInput {
|
||||
sessionId: string;
|
||||
status: Exclude<AgentSessionStatus, 'running'>;
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface CompleteAgentInvocationInput {
|
||||
invocationId: string;
|
||||
status: Exclude<AgentSessionStatus, 'running'>;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
childSessionId?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionTree extends AgentSessionRecord {
|
||||
messages: AgentMessageRecord[];
|
||||
toolCalls: AgentToolCallRecord[];
|
||||
invocations: Array<AgentInvocationRecord & { childSession?: AgentSessionTree }>;
|
||||
}
|
||||
|
||||
export interface AgentInvocationTranscript {
|
||||
invocation: AgentInvocationRecord;
|
||||
childSession?: AgentSessionTree;
|
||||
}
|
||||
408
src/agent-kernel/subagents/__tests__/subagent-runner.test.ts
Normal file
408
src/agent-kernel/subagents/__tests__/subagent-runner.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../../llm/types';
|
||||
import type { AgentDefinition } from '../../definitions';
|
||||
import type { MainAgentModelClient, MainAgentTool, MainAgentToolContext } from '../../loop';
|
||||
import { agentSessionRepository } from '../../session';
|
||||
import type { SpawnSubagentExecutionInput } from '../../tools';
|
||||
import { SubagentRunner } from '../subagent-runner';
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient implements MainAgentModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
const lookupTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'lookup',
|
||||
description: 'Look up a deterministic value.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (argumentsValue) => ({ echoed: (argumentsValue as { query: string }).query }),
|
||||
};
|
||||
|
||||
const parentOnlyTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'parent_only',
|
||||
description: 'A parent-only tool that must not leak into subagents.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => ({ leaked: true }),
|
||||
};
|
||||
|
||||
function agentDefinition(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
|
||||
return {
|
||||
agentType: 'general-purpose',
|
||||
name: 'General Purpose',
|
||||
whenToUse: 'Use for general delegated work.',
|
||||
source: 'built-in',
|
||||
tools: [],
|
||||
disallowedTools: [],
|
||||
skills: [],
|
||||
hooks: {},
|
||||
maxTurns: 4,
|
||||
permissionMode: 'default',
|
||||
background: false,
|
||||
isolation: 'none',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function parentContext(sessionId: string): MainAgentToolContext {
|
||||
return {
|
||||
sessionId,
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: {
|
||||
id: 'call-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: '{}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function executionInput(
|
||||
sessionId: string,
|
||||
overrides: Partial<SpawnSubagentExecutionInput> = {}
|
||||
): SpawnSubagentExecutionInput {
|
||||
const definition = overrides.agentDefinition ?? agentDefinition();
|
||||
return {
|
||||
agentDefinition: definition,
|
||||
agentType: definition.agentType,
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Use lookup, then summarize.',
|
||||
isolation: 'none',
|
||||
parent: parentContext(sessionId),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SubagentRunner', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `subagent-runner-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('runs an isolated child loop and links invocation to the child session', async () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'main-model',
|
||||
metadata: { subagentDepth: 0 },
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'user',
|
||||
content: { text: 'parent prompt only' },
|
||||
});
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-lookup-1', name: 'lookup', arguments: '{"query":"alpha"}' }],
|
||||
}),
|
||||
response({ content: 'child concise summary' }),
|
||||
]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: ['lookup'] }) })
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
summary: 'child concise summary',
|
||||
messagesCount: 4,
|
||||
toolCallCount: 1,
|
||||
artifacts: { invocationId: expect.any(String) },
|
||||
});
|
||||
expect(result).not.toHaveProperty('messages');
|
||||
expect(result).not.toHaveProperty('toolCalls');
|
||||
expect(result).not.toHaveProperty('sessionId');
|
||||
expect(result).not.toHaveProperty('totalTokens');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.messages).toHaveLength(1);
|
||||
expect(tree?.messages[0].content).toEqual({ text: 'parent prompt only' });
|
||||
expect(tree?.toolCalls).toHaveLength(0);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0]).toMatchObject({
|
||||
parentSessionId: parent.id,
|
||||
childSessionId: tree?.invocations[0].childSessionId,
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(result.artifacts?.invocationId).toBe(tree?.invocations[0].id);
|
||||
expect(tree?.invocations[0].result).toEqual(result);
|
||||
const invocationTranscript = agentSessionRepository.getInvocationTranscript(
|
||||
tree?.invocations[0].id ?? 'missing'
|
||||
);
|
||||
expect(invocationTranscript?.invocation.result).toEqual(result);
|
||||
expect(invocationTranscript?.childSession?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(invocationTranscript?.childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
result: { echoed: 'alpha' },
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.parentSessionId).toBe(parent.id);
|
||||
expect(tree?.invocations[0].childSession?.parentInvocationId).toBe(tree?.invocations[0].id);
|
||||
expect(tree?.invocations[0].childSession?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
result: { echoed: 'alpha' },
|
||||
});
|
||||
expect(tree?.invocations[0].input).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: [],
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not leak parent tools into the child model tool definitions', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const modelClient = new FakeModelClient([response({ content: 'no tool needed' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool, parentOnlyTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: ['lookup'] }) })
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(modelClient.requests[0].tools?.map((tool) => tool.name)).toEqual(['lookup']);
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].input).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: ['parent_only'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('persists denied child tool calls as failed unregistered tool calls', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
let lookupExecutions = 0;
|
||||
const countedLookupTool: MainAgentTool = {
|
||||
...lookupTool,
|
||||
execute: () => {
|
||||
lookupExecutions += 1;
|
||||
return { shouldNotRun: true };
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-denied-lookup', name: 'lookup', arguments: '{"query":"blocked"}' }],
|
||||
}),
|
||||
response({ content: 'saw permission error and stopped' }),
|
||||
]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [countedLookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: [] }) })
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCallCount).toBe(1);
|
||||
expect(lookupExecutions).toBe(0);
|
||||
expect(modelClient.requests[0].tools).toEqual([]);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-denied-lookup',
|
||||
content: JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'lookup' is not registered",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
status: 'failed',
|
||||
arguments: { query: 'blocked' },
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'lookup' is not registered",
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: [],
|
||||
deniedToolNames: ['lookup'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('passes model prompt budgets and optional system prompt to MainAgentRunner', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const modelClient = new FakeModelClient([response({ content: 'system-aware result' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
defaultMaxToolCalls: 3,
|
||||
defaultTimeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, {
|
||||
agentDefinition: agentDefinition({
|
||||
agentType: 'code-auditor',
|
||||
model: 'definition-model',
|
||||
maxTurns: 2,
|
||||
getSystemPrompt: () => 'subagent system prompt',
|
||||
}),
|
||||
agentType: 'code-auditor',
|
||||
model: 'override-model',
|
||||
prompt: 'Audit deterministically.',
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(modelClient.requests[0]).toMatchObject({
|
||||
model: 'override-model',
|
||||
messages: [
|
||||
{ role: 'system', content: 'subagent system prompt' },
|
||||
{ role: 'user', content: 'Audit deterministically.' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('completes invocation with structured failure when child loop throws', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const runner = new SubagentRunner({
|
||||
modelClient: new FakeModelClient([]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
});
|
||||
|
||||
const result = await runner.execute(executionInput(parent.id));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'failed',
|
||||
summary: 'No fake model response queued',
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
error: { code: 'Error', message: 'No fake model response queued' },
|
||||
});
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].status).toBe('failed');
|
||||
expect(tree?.invocations[0].error).toEqual(result.error);
|
||||
expect(tree?.invocations[0].childSession?.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('blocks execution and returns structured error when recursion depth exceeds limit', async () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
metadata: { subagentDepth: 1 },
|
||||
});
|
||||
const modelClient = new FakeModelClient([response({ content: 'must not be used' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
maxDepth: 1,
|
||||
});
|
||||
|
||||
const result = await runner.execute(executionInput(parent.id));
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failed',
|
||||
summary: 'Subagent recursion depth limit exceeded (1).',
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: expect.any(String) },
|
||||
error: {
|
||||
code: 'recursion_depth_exceeded',
|
||||
message: 'Subagent recursion depth 2 exceeds max depth 1.',
|
||||
},
|
||||
});
|
||||
expect(modelClient.requests).toHaveLength(0);
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
childSessionId: undefined,
|
||||
result,
|
||||
error: result.error,
|
||||
});
|
||||
expect(tree?.invocations[0].childSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
6
src/agent-kernel/subagents/index.ts
Normal file
6
src/agent-kernel/subagents/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { SubagentRunner } from './subagent-runner';
|
||||
export type { SubagentResult, SubagentResultStatus } from './subagent-result';
|
||||
export type {
|
||||
SubagentRunnerOptions,
|
||||
SubagentRunnerTranscriptRepository,
|
||||
} from './subagent-runner';
|
||||
14
src/agent-kernel/subagents/subagent-result.ts
Normal file
14
src/agent-kernel/subagents/subagent-result.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type SubagentResultStatus = 'completed' | 'failed';
|
||||
|
||||
export interface SubagentResult {
|
||||
status: SubagentResultStatus;
|
||||
summary: string;
|
||||
messagesCount: number;
|
||||
toolCallCount: number;
|
||||
totalTokens?: number;
|
||||
artifacts?: Record<string, unknown>;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
243
src/agent-kernel/subagents/subagent-runner.ts
Normal file
243
src/agent-kernel/subagents/subagent-runner.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { MainAgentRunner } from '../loop';
|
||||
import type {
|
||||
MainAgentModelClient,
|
||||
MainAgentTerminalStatus,
|
||||
MainAgentTool,
|
||||
MainAgentTranscriptRepository,
|
||||
} from '../loop';
|
||||
import type {
|
||||
AgentInvocationRecord,
|
||||
AgentMessageRecord,
|
||||
CompleteAgentInvocationInput,
|
||||
CreateAgentInvocationInput,
|
||||
} from '../session';
|
||||
import type { AgentSessionRecord } from '../session';
|
||||
import { resolveAgentTools } from '../tools';
|
||||
import type { SpawnSubagentExecutionInput, SpawnSubagentExecutor } from '../tools';
|
||||
import type { SubagentResult } from './subagent-result';
|
||||
|
||||
export interface SubagentRunnerTranscriptRepository extends MainAgentTranscriptRepository {
|
||||
createInvocation(input: CreateAgentInvocationInput): AgentInvocationRecord;
|
||||
completeInvocation(input: CompleteAgentInvocationInput): AgentInvocationRecord;
|
||||
getSession?(sessionId: string): AgentSessionRecord | null;
|
||||
listMessages?(sessionId: string): AgentMessageRecord[];
|
||||
}
|
||||
|
||||
export interface SubagentRunnerOptions {
|
||||
modelClient: MainAgentModelClient;
|
||||
transcriptRepository: SubagentRunnerTranscriptRepository;
|
||||
tools?: MainAgentTool[];
|
||||
defaultMaxTurns?: number;
|
||||
defaultMaxToolCalls?: number;
|
||||
defaultTimeoutMs?: number;
|
||||
maxDepth?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function isCompletedStatus(status: MainAgentTerminalStatus): boolean {
|
||||
return status === 'completed';
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): { code: string; message: string } {
|
||||
if (error instanceof Error) {
|
||||
return { code: error.name, message: error.message };
|
||||
}
|
||||
return { code: 'Error', message: String(error) };
|
||||
}
|
||||
|
||||
function readDepth(session: AgentSessionRecord | null | undefined): number {
|
||||
const value = session?.metadata.subagentDepth;
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
function readTotalTokens(messages: AgentMessageRecord[]): number | undefined {
|
||||
let totalTokens = 0;
|
||||
let foundUsage = false;
|
||||
|
||||
for (const message of messages) {
|
||||
const content = message.content;
|
||||
if (typeof content !== 'object' || content === null || !('usage' in content)) continue;
|
||||
|
||||
const usage = (content as { usage?: unknown }).usage;
|
||||
if (typeof usage !== 'object' || usage === null || !('totalTokens' in usage)) continue;
|
||||
|
||||
const value = (usage as { totalTokens?: unknown }).totalTokens;
|
||||
if (typeof value !== 'number') continue;
|
||||
|
||||
totalTokens += value;
|
||||
foundUsage = true;
|
||||
}
|
||||
|
||||
return foundUsage ? totalTokens : undefined;
|
||||
}
|
||||
|
||||
export class SubagentRunner implements SpawnSubagentExecutor {
|
||||
private readonly modelClient: MainAgentModelClient;
|
||||
private readonly transcriptRepository: SubagentRunnerTranscriptRepository;
|
||||
private readonly tools: MainAgentTool[];
|
||||
private readonly defaultMaxTurns: number;
|
||||
private readonly defaultMaxToolCalls: number;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private readonly maxDepth: number;
|
||||
private readonly now?: () => number;
|
||||
|
||||
constructor(options: SubagentRunnerOptions) {
|
||||
this.modelClient = options.modelClient;
|
||||
this.transcriptRepository = options.transcriptRepository;
|
||||
this.tools = options.tools ?? [];
|
||||
this.defaultMaxTurns = options.defaultMaxTurns ?? 4;
|
||||
this.defaultMaxToolCalls = options.defaultMaxToolCalls ?? 8;
|
||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 60_000;
|
||||
this.maxDepth = options.maxDepth ?? 3;
|
||||
this.now = options.now;
|
||||
}
|
||||
|
||||
async execute(input: SpawnSubagentExecutionInput): Promise<SubagentResult> {
|
||||
const toolPermissions = resolveAgentTools({
|
||||
availableTools: this.tools,
|
||||
allowedToolNames: input.agentDefinition.tools,
|
||||
disallowedToolNames: input.agentDefinition.disallowedTools,
|
||||
allowListSpecified: true,
|
||||
});
|
||||
|
||||
const invocation = this.transcriptRepository.createInvocation({
|
||||
parentSessionId: input.parent.sessionId,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
input: {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
isolation: input.isolation,
|
||||
cwd: input.cwd,
|
||||
parentToolCallId: input.parent.toolCall.id,
|
||||
toolPermissions: {
|
||||
allowedToolNames: toolPermissions.allowedToolNames,
|
||||
disallowedToolNames: toolPermissions.disallowedToolNames,
|
||||
deniedToolNames: toolPermissions.deniedToolNames,
|
||||
unknownAllowedToolNames: toolPermissions.unknownAllowedToolNames,
|
||||
unknownDisallowedToolNames: toolPermissions.unknownDisallowedToolNames,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parentSession = this.transcriptRepository.getSession?.(input.parent.sessionId);
|
||||
const childDepth = readDepth(parentSession) + 1;
|
||||
|
||||
if (childDepth > this.maxDepth) {
|
||||
const result: SubagentResult = {
|
||||
status: 'failed',
|
||||
summary: `Subagent recursion depth limit exceeded (${this.maxDepth}).`,
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: invocation.id },
|
||||
error: {
|
||||
code: 'recursion_depth_exceeded',
|
||||
message: `Subagent recursion depth ${childDepth} exceeds max depth ${this.maxDepth}.`,
|
||||
},
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: 'failed',
|
||||
result,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const childSession = this.transcriptRepository.createSession({
|
||||
parentSessionId: input.parent.sessionId,
|
||||
parentInvocationId: invocation.id,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
metadata: {
|
||||
subagentDepth: childDepth,
|
||||
description: input.description,
|
||||
parentToolCallId: input.parent.toolCall.id,
|
||||
toolPermissions: {
|
||||
allowedToolNames: toolPermissions.allowedToolNames,
|
||||
disallowedToolNames: toolPermissions.disallowedToolNames,
|
||||
deniedToolNames: toolPermissions.deniedToolNames,
|
||||
unknownAllowedToolNames: toolPermissions.unknownAllowedToolNames,
|
||||
unknownDisallowedToolNames: toolPermissions.unknownDisallowedToolNames,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: this.modelClient,
|
||||
transcriptRepository: this.transcriptRepository,
|
||||
tools: toolPermissions.tools,
|
||||
now: this.now,
|
||||
});
|
||||
|
||||
try {
|
||||
const runResult = await runner.run({
|
||||
sessionId: childSession.id,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
systemPrompt: input.agentDefinition.getSystemPrompt?.(),
|
||||
userMessage: input.prompt,
|
||||
maxTurns: input.agentDefinition.maxTurns ?? this.defaultMaxTurns,
|
||||
maxToolCalls: this.defaultMaxToolCalls,
|
||||
timeoutMs: this.defaultTimeoutMs,
|
||||
});
|
||||
const totalTokens = this.transcriptRepository.listMessages
|
||||
? readTotalTokens(this.transcriptRepository.listMessages(childSession.id))
|
||||
: undefined;
|
||||
|
||||
const result: SubagentResult = {
|
||||
status: isCompletedStatus(runResult.status) ? 'completed' : 'failed',
|
||||
summary: runResult.finalText ?? runResult.status,
|
||||
messagesCount: runResult.messages.length,
|
||||
toolCallCount: runResult.toolCalls,
|
||||
...(totalTokens === undefined ? {} : { totalTokens }),
|
||||
artifacts: { invocationId: invocation.id },
|
||||
...(isCompletedStatus(runResult.status)
|
||||
? {}
|
||||
: {
|
||||
error: {
|
||||
code: runResult.status,
|
||||
message: `Subagent stopped with status ${runResult.status}.`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: result.status,
|
||||
result,
|
||||
error: result.error,
|
||||
childSessionId: childSession.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error);
|
||||
const result: SubagentResult = {
|
||||
status: 'failed',
|
||||
summary: normalized.message,
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: invocation.id },
|
||||
error: normalized,
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeSession({
|
||||
sessionId: childSession.id,
|
||||
status: 'failed',
|
||||
error: normalized,
|
||||
});
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: 'failed',
|
||||
result,
|
||||
error: normalized,
|
||||
childSessionId: childSession.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
352
src/agent-kernel/tools/__tests__/spawn-subagent-tool.test.ts
Normal file
352
src/agent-kernel/tools/__tests__/spawn-subagent-tool.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../../llm/types';
|
||||
import { createAgentRegistry } from '../../definitions';
|
||||
import { MainAgentRunner } from '../../loop';
|
||||
import type { MainAgentModelClient } from '../../loop';
|
||||
import { agentSessionRepository } from '../../session/session-repository';
|
||||
import { createSpawnSubagentTool } from '../spawn-subagent-tool';
|
||||
import type { SpawnSubagentExecutionInput, SpawnSubagentExecutor } from '../spawn-subagent-tool';
|
||||
|
||||
function agent(agentType: string, name: string, model?: string) {
|
||||
return {
|
||||
agentType,
|
||||
name,
|
||||
whenToUse: `Use ${name}.`,
|
||||
source: 'built-in' as const,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExecutor(result: unknown = { summary: 'subagent done', value: 42 }) {
|
||||
const calls: SpawnSubagentExecutionInput[] = [];
|
||||
const executor: SpawnSubagentExecutor = {
|
||||
execute: (input) => {
|
||||
calls.push(input);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
return { executor, calls };
|
||||
}
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient implements MainAgentModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
describe('createSpawnSubagentTool', () => {
|
||||
test('defaults to general-purpose when subagent_type is omitted', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'definition-model')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Summarize', prompt: 'Summarize the change.' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'definition-model',
|
||||
description: 'Summarize',
|
||||
result: { summary: 'subagent done', value: 42 },
|
||||
summary: 'subagent done',
|
||||
});
|
||||
expect(calls[0]).toMatchObject({
|
||||
agentType: 'general-purpose',
|
||||
model: 'definition-model',
|
||||
description: 'Summarize',
|
||||
prompt: 'Summarize the change.',
|
||||
isolation: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
test('spawns an explicit active subagent type', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [
|
||||
agent('general-purpose', 'General Purpose'),
|
||||
agent('code-reviewer', 'Code Reviewer'),
|
||||
],
|
||||
});
|
||||
const { executor, calls } = makeExecutor({ summary: 'reviewed' });
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: 'Review code',
|
||||
prompt: 'Review this diff.',
|
||||
subagent_type: 'code-reviewer',
|
||||
isolation: 'workspace',
|
||||
cwd: '/tmp/workspace',
|
||||
},
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'completed',
|
||||
agentType: 'code-reviewer',
|
||||
model: 'default-subagent-model',
|
||||
description: 'Review code',
|
||||
summary: 'reviewed',
|
||||
});
|
||||
expect(calls[0]).toMatchObject({
|
||||
agentType: 'code-reviewer',
|
||||
isolation: 'workspace',
|
||||
cwd: '/tmp/workspace',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns a structured error for unknown subagent types', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [
|
||||
agent('general-purpose', 'General Purpose'),
|
||||
agent('code-reviewer', 'Code Reviewer'),
|
||||
],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Unknown', prompt: 'Run missing agent.', subagent_type: 'missing-agent' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
code: 'unknown_subagent_type',
|
||||
message: "Subagent type 'missing-agent' is not active.",
|
||||
requestedType: 'missing-agent',
|
||||
availableTypes: ['code-reviewer', 'general-purpose'],
|
||||
});
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('uses model override before definition and fallback models', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'definition-model')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Override', prompt: 'Use override.', model: 'spawn-model' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'completed', model: 'spawn-model' });
|
||||
expect(calls[0].model).toBe('spawn-model');
|
||||
});
|
||||
|
||||
test('returns a structured unsupported result for background spawns', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Background', prompt: 'Run later.', run_in_background: true },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
code: 'background_execution_unsupported',
|
||||
message:
|
||||
'spawn_subagent background execution is not supported until the isolated SubagentRunner is implemented.',
|
||||
requestedType: 'general-purpose',
|
||||
availableTypes: ['general-purpose'],
|
||||
});
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns a structured validation error for missing required arguments', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose')],
|
||||
});
|
||||
const { executor } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Missing prompt' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'error',
|
||||
code: 'invalid_arguments',
|
||||
message: 'spawn_subagent requires non-empty description and prompt arguments.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawn_subagent MainAgentRunner integration', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `spawn-subagent-tool-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('executes through MainAgentRunner and persists the parent tool call', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'subagent-model')],
|
||||
});
|
||||
const { executor } = makeExecutor({ summary: 'finished by fake executor', value: 'ok' });
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Inspect this issue deterministically.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
response({ content: 'parent final' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
agentType: 'main',
|
||||
model: 'main-model',
|
||||
userMessage: 'delegate investigation',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 4,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(modelClient.requests[0].tools?.map((definition) => definition.name)).toContain(
|
||||
'spawn_subagent'
|
||||
);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-spawn-1',
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
value: {
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
result: { summary: 'finished by fake executor', value: 'ok' },
|
||||
summary: 'finished by fake executor',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls).toHaveLength(1);
|
||||
expect(tree?.toolCalls[0].toolName).toBe('spawn_subagent');
|
||||
expect(tree?.toolCalls[0].arguments).toEqual({
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Inspect this issue deterministically.',
|
||||
});
|
||||
expect(tree?.toolCalls[0].result).toEqual({
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
result: { summary: 'finished by fake executor', value: 'ok' },
|
||||
summary: 'finished by fake executor',
|
||||
});
|
||||
});
|
||||
});
|
||||
101
src/agent-kernel/tools/__tests__/tool-permissions.test.ts
Normal file
101
src/agent-kernel/tools/__tests__/tool-permissions.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { MainAgentTool } from '../../loop';
|
||||
import type { ToolPermissionScope } from '../../loop/types';
|
||||
import {
|
||||
DEFAULT_SCOPE_POLICY,
|
||||
evaluateToolPermission,
|
||||
resolveAgentTools,
|
||||
} from '../tool-permissions';
|
||||
|
||||
function tool(name: string, scope?: ToolPermissionScope): MainAgentTool {
|
||||
return {
|
||||
definition: {
|
||||
name,
|
||||
description: `${name} tool`,
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
permissionScope: scope,
|
||||
execute: () => ({ name }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('evaluateToolPermission', () => {
|
||||
test('allows read scope', () => {
|
||||
expect(evaluateToolPermission(tool('read_file', 'read')).behavior).toBe('allow');
|
||||
});
|
||||
|
||||
test('denies write scope', () => {
|
||||
expect(evaluateToolPermission(tool('write_file', 'write')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('denies command scope', () => {
|
||||
expect(evaluateToolPermission(tool('run_bash', 'command')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('denies network scope', () => {
|
||||
expect(evaluateToolPermission(tool('http_request', 'network')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('defaults to read scope when unspecified', () => {
|
||||
expect(evaluateToolPermission(tool('search_code')).behavior).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAgentTools', () => {
|
||||
const readTool = tool('read_file', 'read');
|
||||
const writeTool = tool('write_file', 'write');
|
||||
const searchTool = tool('search_code', 'read');
|
||||
|
||||
test('includes allowed tool names regardless of scope', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [writeTool],
|
||||
allowedToolNames: ['write_file'],
|
||||
disallowedToolNames: [],
|
||||
});
|
||||
expect(resolved.tools).toHaveLength(1);
|
||||
expect(resolved.tools[0].definition.name).toBe('write_file');
|
||||
});
|
||||
|
||||
test('excludes disallowed tool names regardless of scope', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool],
|
||||
allowedToolNames: ['read_file'],
|
||||
disallowedToolNames: ['read_file'],
|
||||
});
|
||||
expect(resolved.tools).toHaveLength(0);
|
||||
expect(resolved.deniedToolNames).toContain('read_file');
|
||||
});
|
||||
|
||||
test('filters by scope policy when not in allowed/disallowed lists', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool, writeTool, searchTool],
|
||||
allowedToolNames: [],
|
||||
disallowedToolNames: [],
|
||||
});
|
||||
const names = resolved.tools.map((t) => t.definition.name);
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('search_code');
|
||||
expect(names).not.toContain('write_file');
|
||||
});
|
||||
|
||||
test('reports unknown allowed/disallowed names', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool],
|
||||
allowedToolNames: ['missing_tool'],
|
||||
disallowedToolNames: ['ghost_tool'],
|
||||
});
|
||||
expect(resolved.unknownAllowedToolNames).toContain('missing_tool');
|
||||
expect(resolved.unknownDisallowedToolNames).toContain('ghost_tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_SCOPE_POLICY', () => {
|
||||
test('only allows read scope', () => {
|
||||
expect(DEFAULT_SCOPE_POLICY.read).toBe('allow');
|
||||
expect(DEFAULT_SCOPE_POLICY.write).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.command).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.network).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.git_write).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.cross_session).toBe('deny');
|
||||
});
|
||||
});
|
||||
12
src/agent-kernel/tools/index.ts
Normal file
12
src/agent-kernel/tools/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { createSpawnSubagentTool } from './spawn-subagent-tool';
|
||||
export { resolveAgentTools } from './tool-permissions';
|
||||
export type {
|
||||
SpawnSubagentExecutionInput,
|
||||
SpawnSubagentExecutor,
|
||||
SpawnSubagentInput,
|
||||
SpawnSubagentToolOptions,
|
||||
} from './spawn-subagent-tool';
|
||||
export type {
|
||||
ResolvedAgentTools,
|
||||
ResolveAgentToolsInput,
|
||||
} from './tool-permissions';
|
||||
195
src/agent-kernel/tools/spawn-subagent-tool.ts
Normal file
195
src/agent-kernel/tools/spawn-subagent-tool.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { AgentDefinition, AgentIsolation, AgentRegistry } from '../definitions';
|
||||
import type { MainAgentTool, MainAgentToolContext } from '../loop';
|
||||
import { resolveAgentModel } from '../model';
|
||||
|
||||
export interface SpawnSubagentInput {
|
||||
description: string;
|
||||
prompt: string;
|
||||
subagent_type?: string;
|
||||
model?: string;
|
||||
run_in_background?: boolean;
|
||||
isolation?: AgentIsolation;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentExecutionInput {
|
||||
agentDefinition: AgentDefinition;
|
||||
agentType: string;
|
||||
model: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
isolation?: AgentIsolation;
|
||||
cwd?: string;
|
||||
parent: MainAgentToolContext;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentExecutor {
|
||||
execute(input: SpawnSubagentExecutionInput): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentToolOptions {
|
||||
agentRegistry: AgentRegistry;
|
||||
executor: SpawnSubagentExecutor;
|
||||
defaultSubagentModel?: string;
|
||||
}
|
||||
|
||||
type SpawnSubagentToolResult =
|
||||
| {
|
||||
status: 'completed';
|
||||
agentType: string;
|
||||
model: string;
|
||||
description: string;
|
||||
result: unknown;
|
||||
summary?: unknown;
|
||||
}
|
||||
| {
|
||||
status: 'error';
|
||||
code: string;
|
||||
message: string;
|
||||
requestedType?: string;
|
||||
availableTypes?: string[];
|
||||
issues?: string[];
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function parseInput(
|
||||
argumentsValue: unknown
|
||||
): { ok: true; value: SpawnSubagentInput } | { ok: false; issues: string[] } {
|
||||
if (!isRecord(argumentsValue)) {
|
||||
return { ok: false, issues: ['arguments must be an object'] };
|
||||
}
|
||||
|
||||
const issues: string[] = [];
|
||||
const description = optionalString(argumentsValue.description);
|
||||
const prompt = optionalString(argumentsValue.prompt);
|
||||
|
||||
if (!description) issues.push('description is required');
|
||||
if (!prompt) issues.push('prompt is required');
|
||||
|
||||
if (issues.length > 0) return { ok: false, issues };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
description: description as string,
|
||||
prompt: prompt as string,
|
||||
subagent_type: optionalString(argumentsValue.subagent_type),
|
||||
model: optionalString(argumentsValue.model),
|
||||
run_in_background:
|
||||
typeof argumentsValue.run_in_background === 'boolean'
|
||||
? argumentsValue.run_in_background
|
||||
: undefined,
|
||||
isolation: optionalString(argumentsValue.isolation) as AgentIsolation | undefined,
|
||||
cwd: optionalString(argumentsValue.cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function availableTypes(registry: AgentRegistry): string[] {
|
||||
return registry.activeAgents.map((agent) => agent.agentType).sort();
|
||||
}
|
||||
|
||||
function resolveAgentType(
|
||||
input: SpawnSubagentInput,
|
||||
registry: AgentRegistry
|
||||
): AgentDefinition | undefined {
|
||||
const requestedType = input.subagent_type ?? 'general-purpose';
|
||||
return registry.getActiveAgent(requestedType);
|
||||
}
|
||||
|
||||
function extractSummary(result: unknown): unknown {
|
||||
if (isRecord(result) && 'summary' in result) return result.summary;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createSpawnSubagentTool(options: SpawnSubagentToolOptions): MainAgentTool {
|
||||
return {
|
||||
definition: {
|
||||
name: 'spawn_subagent',
|
||||
description:
|
||||
'Spawn a registered subagent with an explicit prompt and return its structured result.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: { type: 'string' },
|
||||
prompt: { type: 'string' },
|
||||
subagent_type: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
run_in_background: { type: 'boolean' },
|
||||
isolation: { type: 'string', enum: ['none', 'workspace', 'process'] },
|
||||
cwd: { type: 'string' },
|
||||
},
|
||||
required: ['description', 'prompt'],
|
||||
},
|
||||
},
|
||||
async execute(argumentsValue, context): Promise<SpawnSubagentToolResult> {
|
||||
const parsed = parseInput(argumentsValue);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'invalid_arguments',
|
||||
message: 'spawn_subagent requires non-empty description and prompt arguments.',
|
||||
issues: parsed.issues,
|
||||
};
|
||||
}
|
||||
|
||||
const input = parsed.value;
|
||||
const requestedType = input.subagent_type ?? 'general-purpose';
|
||||
const agentDefinition = resolveAgentType(input, options.agentRegistry);
|
||||
if (!agentDefinition) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'unknown_subagent_type',
|
||||
message: `Subagent type '${requestedType}' is not active.`,
|
||||
requestedType,
|
||||
availableTypes: availableTypes(options.agentRegistry),
|
||||
};
|
||||
}
|
||||
|
||||
const model = resolveAgentModel({
|
||||
spawnOverride: input.model,
|
||||
agentDefinition,
|
||||
defaultSubagentModel: options.defaultSubagentModel,
|
||||
mainAgentModel: context.model,
|
||||
});
|
||||
|
||||
if (input.run_in_background) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'background_execution_unsupported',
|
||||
message:
|
||||
'spawn_subagent background execution is not supported until the isolated SubagentRunner is implemented.',
|
||||
requestedType: agentDefinition.agentType,
|
||||
availableTypes: availableTypes(options.agentRegistry),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await options.executor.execute({
|
||||
agentDefinition,
|
||||
agentType: agentDefinition.agentType,
|
||||
model,
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
isolation: input.isolation ?? agentDefinition.isolation,
|
||||
cwd: input.cwd,
|
||||
parent: context,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
agentType: agentDefinition.agentType,
|
||||
model,
|
||||
description: input.description,
|
||||
result,
|
||||
summary: extractSummary(result),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
78
src/agent-kernel/tools/tool-permissions.ts
Normal file
78
src/agent-kernel/tools/tool-permissions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { MainAgentTool } from '../loop';
|
||||
import type { ToolPermissionBehavior, ToolPermissionScope } from '../loop/types';
|
||||
|
||||
export interface ResolveAgentToolsInput {
|
||||
availableTools: MainAgentTool[];
|
||||
allowedToolNames: string[];
|
||||
disallowedToolNames: string[];
|
||||
allowListSpecified?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedAgentTools {
|
||||
tools: MainAgentTool[];
|
||||
allowedToolNames: string[];
|
||||
disallowedToolNames: string[];
|
||||
deniedToolNames: string[];
|
||||
unknownAllowedToolNames: string[];
|
||||
unknownDisallowedToolNames: string[];
|
||||
}
|
||||
|
||||
export interface ToolPermissionDecision {
|
||||
behavior: ToolPermissionBehavior;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE_POLICY: Record<ToolPermissionScope, ToolPermissionBehavior> = {
|
||||
read: 'allow',
|
||||
write: 'deny',
|
||||
command: 'deny',
|
||||
network: 'deny',
|
||||
git_write: 'deny',
|
||||
cross_session: 'deny',
|
||||
};
|
||||
|
||||
function uniqueNames(names: string[]): string[] {
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
export function evaluateToolPermission(tool: MainAgentTool): ToolPermissionDecision {
|
||||
const scope = tool.permissionScope ?? 'read';
|
||||
const behavior = DEFAULT_SCOPE_POLICY[scope];
|
||||
return {
|
||||
behavior,
|
||||
reason: `Tool '${tool.definition.name}' ${behavior === 'allow' ? 'allowed' : 'denied'} for scope '${scope}'`,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentTools(input: ResolveAgentToolsInput): ResolvedAgentTools {
|
||||
const availableToolNames = uniqueNames(input.availableTools.map((tool) => tool.definition.name));
|
||||
const availableToolNamesSet = new Set(availableToolNames);
|
||||
const allowedToolNames = uniqueNames(input.allowedToolNames);
|
||||
const disallowedToolNames = uniqueNames(input.disallowedToolNames);
|
||||
const allowedToolNamesSet = new Set(allowedToolNames);
|
||||
const disallowedToolNamesSet = new Set(disallowedToolNames);
|
||||
|
||||
const tools = input.availableTools.filter((tool) => {
|
||||
const toolName = tool.definition.name;
|
||||
if (disallowedToolNamesSet.has(toolName)) return false;
|
||||
if (allowedToolNamesSet.size > 0) return allowedToolNamesSet.has(toolName);
|
||||
if (input.allowListSpecified) return false;
|
||||
return evaluateToolPermission(tool).behavior === 'allow';
|
||||
});
|
||||
const permittedToolNamesSet = new Set(tools.map((tool) => tool.definition.name));
|
||||
|
||||
return {
|
||||
tools,
|
||||
allowedToolNames,
|
||||
disallowedToolNames,
|
||||
deniedToolNames: availableToolNames.filter((toolName) => !permittedToolNamesSet.has(toolName)),
|
||||
unknownAllowedToolNames: allowedToolNames.filter(
|
||||
(toolName) => !availableToolNamesSet.has(toolName)
|
||||
),
|
||||
unknownDisallowedToolNames: disallowedToolNames.filter(
|
||||
(toolName) => !availableToolNamesSet.has(toolName)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export { DEFAULT_SCOPE_POLICY };
|
||||
@@ -86,7 +86,6 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(cfg.notification.feishu.webhookSecret).toBeUndefined();
|
||||
expect(cfg.notification.wecom.webhookUrl).toBeUndefined();
|
||||
expect(cfg.admin.giteaAdminToken).toBeUndefined();
|
||||
expect(cfg.review.qdrantUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns review size thresholds and token budget defaults', () => {
|
||||
@@ -99,6 +98,12 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(cfg.review.tokenBudgetMedium).toBe(45000);
|
||||
expect(cfg.review.tokenBudgetLarge).toBe(120000);
|
||||
});
|
||||
|
||||
test('returns runtime agent model defaults', () => {
|
||||
const cfg = configManager.getCurrent();
|
||||
expect(cfg.review.agentMainModel).toBe('gpt-4.1');
|
||||
expect(cfg.review.agentDefaultSubagentModel).toBe('gpt-4.1-mini');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. setOverrides() / getSource() ─────────────────────────────────────
|
||||
@@ -195,16 +200,6 @@ describe('ConfigManager (DB backend)', () => {
|
||||
// ─── 5. Type conversions ─────────────────────────────────────────────────
|
||||
|
||||
describe('type conversions in getCurrent()', () => {
|
||||
test('boolean field "true" → true', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'true' });
|
||||
expect(configManager.getCurrent().review.enableHumanGate).toBe(true);
|
||||
});
|
||||
|
||||
test('boolean field "false" → false', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'false' });
|
||||
expect(configManager.getCurrent().review.enableHumanGate).toBe(false);
|
||||
});
|
||||
|
||||
test('number field is parsed correctly', async () => {
|
||||
await configManager.setOverrides({ REVIEW_MAX_PARALLEL_RUNS: '4' });
|
||||
expect(configManager.getCurrent().review.maxParallelRuns).toBe(4);
|
||||
@@ -220,6 +215,17 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(configManager.getCurrent().review.tokenBudgetSmall).toBe(22222);
|
||||
});
|
||||
|
||||
test('agent model fields are read from overrides', async () => {
|
||||
await configManager.setOverrides({
|
||||
AGENT_MAIN_MODEL: 'main-override-model',
|
||||
AGENT_DEFAULT_SUBAGENT_MODEL: 'subagent-override-model',
|
||||
});
|
||||
|
||||
const cfg = configManager.getCurrent();
|
||||
expect(cfg.review.agentMainModel).toBe('main-override-model');
|
||||
expect(cfg.review.agentDefaultSubagentModel).toBe('subagent-override-model');
|
||||
});
|
||||
|
||||
test('comma-separated REVIEW_ALLOWED_COMMANDS parsed to array', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ALLOWED_COMMANDS: 'git, rg, cat' });
|
||||
expect(configManager.getCurrent().review.allowedCommands).toEqual(['git', 'rg', 'cat']);
|
||||
|
||||
31
src/config/__tests__/config-schema-agent-model.test.ts
Normal file
31
src/config/__tests__/config-schema-agent-model.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { CONFIG_FIELDS } from '../config-schema';
|
||||
|
||||
function findField(envKey: string) {
|
||||
const field = CONFIG_FIELDS.find((item) => item.envKey === envKey);
|
||||
expect(field).toBeDefined();
|
||||
return field!;
|
||||
}
|
||||
|
||||
describe('config-schema agent model fields', () => {
|
||||
test('AGENT_MAIN_MODEL exists with expected metadata and default', () => {
|
||||
const field = findField('AGENT_MAIN_MODEL');
|
||||
|
||||
expect(field.envKey).toBe('AGENT_MAIN_MODEL');
|
||||
expect(field.group).toBe('review');
|
||||
expect(field.type).toBe('string');
|
||||
expect(field.sensitive).toBe(false);
|
||||
expect(field.defaultValue).toBe('gpt-4.1');
|
||||
});
|
||||
|
||||
test('AGENT_DEFAULT_SUBAGENT_MODEL exists with expected metadata and default', () => {
|
||||
const field = findField('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
|
||||
expect(field.envKey).toBe('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
expect(field.group).toBe('review');
|
||||
expect(field.type).toBe('string');
|
||||
expect(field.sensitive).toBe(false);
|
||||
expect(field.defaultValue).toBe('gpt-4.1-mini');
|
||||
});
|
||||
});
|
||||
@@ -38,14 +38,13 @@ export interface AppConfig {
|
||||
maxParallelRuns: number;
|
||||
maxFilesPerRun: number;
|
||||
maxFileContentChars: number;
|
||||
autoPublishMinConfidence: number;
|
||||
enableHumanGate: boolean;
|
||||
allowedCommands: string[];
|
||||
commandTimeoutMs: number;
|
||||
llmMaxConcurrentCalls: number;
|
||||
llmRetryMaxAttempts: number;
|
||||
llmRetryBaseDelayMs: number;
|
||||
enableTriage: boolean;
|
||||
agentMainModel: string;
|
||||
agentDefaultSubagentModel: string;
|
||||
smallMaxFiles: number;
|
||||
smallMaxChangedLines: number;
|
||||
mediumMaxFiles: number;
|
||||
@@ -58,13 +57,6 @@ export interface AppConfig {
|
||||
codexModel: string;
|
||||
codexTimeoutMs: number;
|
||||
codexReviewPrompt: string | undefined;
|
||||
qdrantUrl: string | undefined;
|
||||
enableMemory: boolean;
|
||||
fewShotExamplesCount: number;
|
||||
enableReflection: boolean;
|
||||
maxReflectionRounds: number;
|
||||
enableDebate: boolean;
|
||||
debateThreshold: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,8 +161,6 @@ class ConfigManager {
|
||||
maxParallelRuns: toNumber('REVIEW_MAX_PARALLEL_RUNS', 2),
|
||||
maxFilesPerRun: toNumber('REVIEW_MAX_FILES_PER_RUN', 200),
|
||||
maxFileContentChars: toNumber('REVIEW_MAX_FILE_CONTENT_CHARS', 40000),
|
||||
autoPublishMinConfidence: toNumber('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE', 0.8),
|
||||
enableHumanGate: toBoolean('REVIEW_ENABLE_HUMAN_GATE', true),
|
||||
allowedCommands: toStringArray('REVIEW_ALLOWED_COMMANDS', [
|
||||
'git',
|
||||
'rg',
|
||||
@@ -178,11 +168,12 @@ class ConfigManager {
|
||||
'sed',
|
||||
'wc',
|
||||
]),
|
||||
commandTimeoutMs: toNumber('REVIEW_COMMAND_TIMEOUT_MS', 10000),
|
||||
commandTimeoutMs: toNumber('REVIEW_COMMAND_TIMEOUT_MS', 120000),
|
||||
llmMaxConcurrentCalls: toNumber('LLM_MAX_CONCURRENT_CALLS', 4),
|
||||
llmRetryMaxAttempts: toNumber('LLM_RETRY_MAX_ATTEMPTS', 3),
|
||||
llmRetryBaseDelayMs: toNumber('LLM_RETRY_BASE_DELAY_MS', 1000),
|
||||
enableTriage: toBoolean('ENABLE_TRIAGE', true),
|
||||
agentMainModel: values.AGENT_MAIN_MODEL ?? 'gpt-4.1',
|
||||
agentDefaultSubagentModel: values.AGENT_DEFAULT_SUBAGENT_MODEL ?? 'gpt-4.1-mini',
|
||||
smallMaxFiles: toNumber('REVIEW_SMALL_MAX_FILES', 3),
|
||||
smallMaxChangedLines: toNumber('REVIEW_SMALL_MAX_CHANGED_LINES', 80),
|
||||
mediumMaxFiles: toNumber('REVIEW_MEDIUM_MAX_FILES', 10),
|
||||
@@ -195,13 +186,6 @@ class ConfigManager {
|
||||
codexModel: values.CODEX_MODEL ?? 'o3',
|
||||
codexTimeoutMs: toNumber('CODEX_TIMEOUT_MS', 300000),
|
||||
codexReviewPrompt: values.CODEX_REVIEW_PROMPT,
|
||||
qdrantUrl: values.QDRANT_URL,
|
||||
enableMemory: toBoolean('ENABLE_MEMORY', false),
|
||||
fewShotExamplesCount: toNumber('FEW_SHOT_EXAMPLES_COUNT', 10),
|
||||
enableReflection: toBoolean('ENABLE_REFLECTION', false),
|
||||
maxReflectionRounds: toNumber('MAX_REFLECTION_ROUNDS', 2),
|
||||
enableDebate: toBoolean('ENABLE_DEBATE', false),
|
||||
debateThreshold: values.DEBATE_THRESHOLD ?? 'high',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory';
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review';
|
||||
|
||||
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||
|
||||
@@ -60,12 +60,6 @@ export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
description: 'Agent 审查模式、并发与沙箱设置',
|
||||
icon: 'file-check',
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆与学习',
|
||||
description: '向量记忆、反思与辩论系统',
|
||||
icon: 'brain',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -188,7 +182,7 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
group: 'review',
|
||||
label: '审查引擎',
|
||||
description: '代码审查模式:agent(任务化分级编排)或 codex(Codex CLI)',
|
||||
description: '代码审查模式:agent(内置 Agent 审查)或 codex(Codex CLI)',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['agent', 'codex'],
|
||||
@@ -236,26 +230,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
max: 1000000,
|
||||
defaultValue: 40000,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
group: 'review',
|
||||
label: '自动发布置信度',
|
||||
description: '自动发布评论所需的最小置信度(0~1)',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 0,
|
||||
max: 1,
|
||||
defaultValue: 0.8,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ENABLE_HUMAN_GATE',
|
||||
group: 'review',
|
||||
label: '人工审批',
|
||||
description: '是否启用人工审批队列(低置信度评论需人工确认后发布)',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ALLOWED_COMMANDS',
|
||||
group: 'review',
|
||||
@@ -272,9 +246,9 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
description: '单条本地命令的执行超时时间(毫秒)',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1000,
|
||||
min: 120000,
|
||||
max: 300000,
|
||||
defaultValue: 10000,
|
||||
defaultValue: 120000,
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
@@ -310,13 +284,22 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
defaultValue: 1000,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_TRIAGE',
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
group: 'review',
|
||||
label: '启用变更分流',
|
||||
description: '是否启用 Triage 分流(用 Planner 模型先评估变更复杂度,再按需派发 Specialist)',
|
||||
type: 'boolean',
|
||||
label: 'Agent 主模型',
|
||||
description: 'Agent runtime 在没有更具体模型配置时使用的主模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
defaultValue: 'gpt-4.1',
|
||||
},
|
||||
{
|
||||
envKey: 'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
group: 'review',
|
||||
label: 'Subagent 默认模型',
|
||||
description: 'Subagent 未声明模型且 spawn 未覆盖时使用的默认模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: 'gpt-4.1-mini',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_SMALL_MAX_FILES',
|
||||
@@ -442,75 +425,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
},
|
||||
|
||||
// ── 记忆与学习 ──────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'QDRANT_URL',
|
||||
group: 'memory',
|
||||
label: 'Qdrant 地址',
|
||||
description: 'Qdrant 向量数据库的连接 URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_MEMORY',
|
||||
group: 'memory',
|
||||
label: '启用记忆',
|
||||
description: '是否启用向量记忆系统(需配置 Qdrant)',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEW_SHOT_EXAMPLES_COUNT',
|
||||
group: 'memory',
|
||||
label: 'Few-shot 示例数',
|
||||
description: '检索的 few-shot 示例数量',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 0,
|
||||
max: 20,
|
||||
defaultValue: 10,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_REFLECTION',
|
||||
group: 'memory',
|
||||
label: '启用反思',
|
||||
description: '是否启用审查结果自我反思机制',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'MAX_REFLECTION_ROUNDS',
|
||||
group: 'memory',
|
||||
label: '最大反思轮数',
|
||||
description: '反思迭代的最大轮数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1,
|
||||
max: 5,
|
||||
defaultValue: 2,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_DEBATE',
|
||||
group: 'memory',
|
||||
label: '启用辩论',
|
||||
description: '是否启用多视角辩论机制',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'DEBATE_THRESHOLD',
|
||||
group: 'memory',
|
||||
label: '辩论阈值',
|
||||
description: '触发辩论的严重程度阈值',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['high', 'medium'],
|
||||
defaultValue: 'high',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
201
src/controllers/__tests__/agents.test.ts
Normal file
201
src/controllers/__tests__/agents.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Hono } from 'hono';
|
||||
import { jwt, sign } from 'hono/jwt';
|
||||
import config from '../../config';
|
||||
import { initMasterKey } from '../../crypto/secrets';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { agentsRouter } from '../agents';
|
||||
|
||||
function createProtectedTestApp(): Hono {
|
||||
const app = new Hono();
|
||||
app.use('/admin/api/*', (c, next) => {
|
||||
const middleware = jwt({ secret: config.admin.jwtSecret, alg: 'HS256' });
|
||||
return middleware(c, next);
|
||||
});
|
||||
app.route('/admin/api/agents', agentsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function createAdminToken(): Promise<string> {
|
||||
return sign(
|
||||
{
|
||||
sub: 'admin',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
config.admin.jwtSecret
|
||||
);
|
||||
}
|
||||
|
||||
async function jsonRequest(
|
||||
app: Hono,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
token?: string
|
||||
): Promise<{ status: number; data: any }> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const init: RequestInit = { method, headers };
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await app.request(`http://localhost/admin/api/agents${path}`, init);
|
||||
const text = await res.text();
|
||||
try {
|
||||
return { status: res.status, data: JSON.parse(text) };
|
||||
} catch {
|
||||
return { status: res.status, data: { _raw: text } };
|
||||
}
|
||||
}
|
||||
|
||||
describe('agents controller', () => {
|
||||
let dbPath: string;
|
||||
let app: Hono;
|
||||
let tempProjectRoot: string;
|
||||
let savedCwd: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
const savedEncryptionKey = process.env.ENCRYPTION_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDbDir = join(tmpdir(), `agents-ctrl-db-${randomUUID()}`);
|
||||
mkdirSync(tmpDbDir, { recursive: true });
|
||||
dbPath = join(tmpDbDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
|
||||
'hex'
|
||||
);
|
||||
|
||||
tempProjectRoot = join(tmpdir(), `agents-project-${randomUUID()}`);
|
||||
mkdirSync(join(tempProjectRoot, '.gitea-assistant', 'agents'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tempProjectRoot, '.gitea-assistant', 'agents', 'alpha.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: alpha-reviewer',
|
||||
'name: Alpha Reviewer',
|
||||
'whenToUse: Use alpha reviewer for repository checks.',
|
||||
'tools: [read_file, search_code]',
|
||||
'model: gpt-4.1',
|
||||
'maxTurns: 3',
|
||||
'---',
|
||||
'You are alpha reviewer.',
|
||||
].join('\n')
|
||||
);
|
||||
writeFileSync(
|
||||
join(tempProjectRoot, '.gitea-assistant', 'agents', 'broken.md'),
|
||||
['---', 'agentType: broken', 'name: Broken Agent', '---', ' '].join('\n')
|
||||
);
|
||||
|
||||
savedCwd = process.cwd();
|
||||
process.chdir(tempProjectRoot);
|
||||
|
||||
initMasterKey();
|
||||
initDatabase();
|
||||
app = createProtectedTestApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(savedCwd);
|
||||
closeDatabase();
|
||||
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (savedEncryptionKey === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'ENCRYPTION_KEY');
|
||||
} else {
|
||||
process.env.ENCRYPTION_KEY = savedEncryptionKey;
|
||||
}
|
||||
|
||||
try {
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
} catch {}
|
||||
try {
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
} catch {}
|
||||
try {
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
} catch {}
|
||||
try {
|
||||
if (existsSync(tempProjectRoot)) rmSync(tempProjectRoot, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test('GET /definitions returns active/all definitions and load errors', async () => {
|
||||
const token = await createAdminToken();
|
||||
const { status, data } = await jsonRequest(app, 'GET', '/definitions', undefined, token);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(Array.isArray(data.activeDefinitions)).toBe(true);
|
||||
expect(Array.isArray(data.allDefinitions)).toBe(true);
|
||||
expect(Array.isArray(data.loadErrors)).toBe(true);
|
||||
|
||||
const alpha = data.activeDefinitions.find((item: any) => item.agentType === 'alpha-reviewer');
|
||||
expect(alpha).toBeDefined();
|
||||
expect(alpha.source).toBe('project');
|
||||
expect(alpha.tools).toEqual(['read_file', 'search_code']);
|
||||
expect(alpha.model).toBe('gpt-4.1');
|
||||
expect(alpha.maxTurns).toBe(3);
|
||||
|
||||
const broken = data.loadErrors.find((item: any) => item.filePath.endsWith('broken.md'));
|
||||
expect(broken).toBeDefined();
|
||||
expect(broken.code).toBe('empty_body');
|
||||
});
|
||||
|
||||
test('GET /model-config returns runtime model defaults', async () => {
|
||||
const token = await createAdminToken();
|
||||
const { status, data } = await jsonRequest(app, 'GET', '/model-config', undefined, token);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data).toHaveProperty('agentMainModel');
|
||||
expect(data).toHaveProperty('agentDefaultSubagentModel');
|
||||
expect(data).toHaveProperty('source');
|
||||
});
|
||||
|
||||
test('PUT /model-config updates runtime model defaults', async () => {
|
||||
const token = await createAdminToken();
|
||||
const updateRes = await jsonRequest(
|
||||
app,
|
||||
'PUT',
|
||||
'/model-config',
|
||||
{
|
||||
agentMainModel: 'gpt-4.1-updated',
|
||||
agentDefaultSubagentModel: 'gpt-4.1-mini-updated',
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect(updateRes.data.agentMainModel).toBe('gpt-4.1-updated');
|
||||
expect(updateRes.data.agentDefaultSubagentModel).toBe('gpt-4.1-mini-updated');
|
||||
expect(updateRes.data.source.agentMainModel).toBe('db');
|
||||
expect(updateRes.data.source.agentDefaultSubagentModel).toBe('db');
|
||||
|
||||
const readBack = await jsonRequest(app, 'GET', '/model-config', undefined, token);
|
||||
expect(readBack.status).toBe(200);
|
||||
expect(readBack.data.agentMainModel).toBe('gpt-4.1-updated');
|
||||
expect(readBack.data.agentDefaultSubagentModel).toBe('gpt-4.1-mini-updated');
|
||||
});
|
||||
|
||||
test('returns 401 when missing authorization token', async () => {
|
||||
const defsRes = await jsonRequest(app, 'GET', '/definitions');
|
||||
expect(defsRes.status).toBe(401);
|
||||
|
||||
const getModelRes = await jsonRequest(app, 'GET', '/model-config');
|
||||
expect(getModelRes.status).toBe(401);
|
||||
|
||||
const putModelRes = await jsonRequest(app, 'PUT', '/model-config', {
|
||||
agentMainModel: 'nope',
|
||||
});
|
||||
expect(putModelRes.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { join } from 'node:path';
|
||||
import { Hono } from 'hono';
|
||||
import { initMasterKey } from '../../crypto/secrets';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { modelRoleRepo } from '../../db/repositories/model-role-repo';
|
||||
import { providerRepo } from '../../db/repositories/provider-repo';
|
||||
import { secretRepo } from '../../db/repositories/secret-repo';
|
||||
import { llmConfigRouter } from '../llm-config';
|
||||
@@ -150,19 +149,6 @@ describe('llm-config controller', () => {
|
||||
expect(data.hasKey).toBe(true);
|
||||
});
|
||||
|
||||
test('auto-binds all roles when first provider is created', async () => {
|
||||
await jsonRequest(app, 'POST', '/providers', {
|
||||
name: 'First Provider',
|
||||
type: 'gemini',
|
||||
defaultModel: 'gemini-pro',
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
const { data: roles } = await jsonRequest(app, 'GET', '/roles');
|
||||
const assignedRoles = roles.filter((r: any) => r.providerId !== null);
|
||||
expect(assignedRoles).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('rejects missing required fields', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'POST', '/providers', {
|
||||
name: 'Missing Type',
|
||||
@@ -239,7 +225,7 @@ describe('llm-config controller', () => {
|
||||
});
|
||||
|
||||
describe('DELETE /providers/:id', () => {
|
||||
test('deletes provider without role assignments', async () => {
|
||||
test('deletes provider', async () => {
|
||||
const created = providerRepo.create({
|
||||
name: 'ToDelete',
|
||||
type: 'anthropic',
|
||||
@@ -249,7 +235,6 @@ describe('llm-config controller', () => {
|
||||
const { status, data } = await jsonRequest(app, 'DELETE', `/providers/${created.id}`);
|
||||
expect(status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.removedRoleAssignments).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent provider', async () => {
|
||||
@@ -320,75 +305,19 @@ describe('llm-config controller', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Role Assignments ────────────────────────────────────────────
|
||||
|
||||
describe('GET /roles', () => {
|
||||
test('returns all MODEL_ROLES with null assignments when unassigned', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'GET', '/roles');
|
||||
expect(status).toBe(200);
|
||||
expect(data).toHaveLength(4);
|
||||
expect(data[0]).toHaveProperty('role');
|
||||
expect(data[0]).toHaveProperty('providerId');
|
||||
describe('removed legacy model binding API', () => {
|
||||
test('returns 404 for old role list endpoint', async () => {
|
||||
const legacyRolePath = ['/', 'roles'].join('');
|
||||
const { status } = await jsonRequest(app, 'GET', legacyRolePath);
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
test('returns assigned role info when set', async () => {
|
||||
const provider = providerRepo.create({
|
||||
name: 'RoleTest',
|
||||
type: 'openai_compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
});
|
||||
modelRoleRepo.set('planner', provider.id, 'gpt-4o');
|
||||
|
||||
const { data } = await jsonRequest(app, 'GET', '/roles');
|
||||
const planner = data.find((r: any) => r.role === 'planner');
|
||||
expect(planner.providerId).toBe(provider.id);
|
||||
expect(planner.providerName).toBe('RoleTest');
|
||||
expect(planner.model).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /roles/:role', () => {
|
||||
test('assigns a role to a provider+model', async () => {
|
||||
const provider = providerRepo.create({
|
||||
name: 'AssignTarget',
|
||||
type: 'anthropic',
|
||||
defaultModel: 'claude-3',
|
||||
});
|
||||
|
||||
const { status, data } = await jsonRequest(app, 'PUT', '/roles/planner', {
|
||||
providerId: provider.id,
|
||||
model: 'claude-3-5-sonnet',
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.role).toBe('planner');
|
||||
expect(data.providerId).toBe(provider.id);
|
||||
expect(data.model).toBe('claude-3-5-sonnet');
|
||||
});
|
||||
|
||||
test('rejects invalid role name', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'PUT', '/roles/invalid_role', {
|
||||
test('returns 404 for old role update endpoint', async () => {
|
||||
const legacyRolePath = ['/', 'roles', '/', 'old-role'].join('');
|
||||
const { status } = await jsonRequest(app, 'PUT', legacyRolePath, {
|
||||
providerId: 'some-id',
|
||||
model: 'model',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(data.message).toContain('Invalid role');
|
||||
});
|
||||
|
||||
test('rejects missing providerId or model', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'PUT', '/roles/planner', {
|
||||
providerId: 'some-id',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(data.message).toContain('providerId and model are required');
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent provider', async () => {
|
||||
const { status } = await jsonRequest(app, 'PUT', '/roles/planner', {
|
||||
providerId: 'non-existent',
|
||||
model: 'model',
|
||||
});
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Hono } from 'hono';
|
||||
import { sign } from 'hono/jwt';
|
||||
import { agentSessionRepository } from '../agent-kernel/session';
|
||||
import config from '../config';
|
||||
import { repositoryReviewPromptRepo } from '../db/repositories/repository-review-prompt-repo';
|
||||
import { reviewEngine } from '../review/engine';
|
||||
import { giteaService } from '../services/gitea';
|
||||
import { toErrorLogMeta } from '../utils/error-log';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const publicRoutes = new Hono();
|
||||
@@ -30,13 +33,55 @@ publicRoutes.post('/login', async (c) => {
|
||||
|
||||
// 获取仓库列表及 Webhook 状态
|
||||
protectedRoutes.get('/repositories', async (c) => {
|
||||
const page = Number.parseInt(c.req.query('page') || '1', 10);
|
||||
const query = c.req.query('q');
|
||||
const limit = 30; // 每页数量固定,或也可从查询参数获取
|
||||
const requestContext = {
|
||||
page,
|
||||
limit,
|
||||
query: query ?? null,
|
||||
requestUrl: c.req.url,
|
||||
method: c.req.method,
|
||||
runtime: process.versions.bun ? `bun-${process.versions.bun}` : process.version,
|
||||
nodeEnv: process.env.NODE_ENV ?? null,
|
||||
databasePath: process.env.DATABASE_PATH || './data/assistant.db',
|
||||
};
|
||||
|
||||
try {
|
||||
const page = Number.parseInt(c.req.query('page') || '1', 10);
|
||||
const query = c.req.query('q');
|
||||
const limit = 30; // 每页数量固定,或也可从查询参数获取
|
||||
logger.debug('开始获取仓库列表', requestContext);
|
||||
|
||||
const { repos, totalCount } = await giteaService.listAllRepositories(page, limit, query);
|
||||
logger.debug('仓库搜索接口返回成功', {
|
||||
...requestContext,
|
||||
reposCount: repos.length,
|
||||
totalCount,
|
||||
sampleRepos: repos
|
||||
.slice(0, 3)
|
||||
.map((repo) => (typeof repo.full_name === 'string' ? repo.full_name : null)),
|
||||
});
|
||||
|
||||
const webhookUrl = c.req.url.replace(/\/admin\/api\/repositories.*$/, '/webhook/gitea');
|
||||
const fullNames = repos
|
||||
.map((repo) => (typeof repo.full_name === 'string' ? repo.full_name : null))
|
||||
.filter((name): name is string => name !== null);
|
||||
logger.debug('准备批量读取项目级提示词', {
|
||||
...requestContext,
|
||||
fullNamesCount: fullNames.length,
|
||||
fullNamesSample: fullNames.slice(0, 5),
|
||||
});
|
||||
|
||||
let promptMap: Record<string, string>;
|
||||
try {
|
||||
promptMap = repositoryReviewPromptRepo.listProjectPrompts(fullNames);
|
||||
} catch (error: unknown) {
|
||||
logger.error('批量读取项目级提示词失败', {
|
||||
...requestContext,
|
||||
fullNamesCount: fullNames.length,
|
||||
fullNamesSample: fullNames.slice(0, 5),
|
||||
error: toErrorLogMeta(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reposWithStatus = await Promise.all(
|
||||
repos.map(async (repo) => {
|
||||
@@ -47,19 +92,54 @@ protectedRoutes.get('/repositories', async (c) => {
|
||||
name: repo.full_name,
|
||||
webhook_status: webhook ? 'active' : 'inactive',
|
||||
hook_id: webhook ? webhook.id : null,
|
||||
project_review_prompt: promptMap[repo.full_name] || null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
reposWithStatus.sort((a, b) => {
|
||||
if (a.webhook_status === b.webhook_status) {
|
||||
return 0;
|
||||
}
|
||||
return a.webhook_status === 'active' ? -1 : 1;
|
||||
});
|
||||
|
||||
return c.json({
|
||||
data: reposWithStatus,
|
||||
totalCount,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error('获取仓库列表失败:', {
|
||||
...requestContext,
|
||||
error: toErrorLogMeta(error),
|
||||
});
|
||||
return c.json({ message: 'Failed to fetch repositories', error: errorMessage }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
protectedRoutes.put('/repositories/:owner/:repo/project-prompt', async (c) => {
|
||||
const { owner, repo } = c.req.param();
|
||||
|
||||
try {
|
||||
const body = (await c.req.json()) as { project_review_prompt?: unknown };
|
||||
if (typeof body.project_review_prompt !== 'string') {
|
||||
return c.json({ message: 'project_review_prompt must be a string' }, 400);
|
||||
}
|
||||
|
||||
const normalizedPrompt = body.project_review_prompt.trim();
|
||||
if (!normalizedPrompt) {
|
||||
repositoryReviewPromptRepo.clearProjectPrompt(owner, repo);
|
||||
return c.json({ success: true, project_review_prompt: null });
|
||||
}
|
||||
|
||||
const updated = repositoryReviewPromptRepo.setProjectPrompt(owner, repo, normalizedPrompt);
|
||||
return c.json({ success: true, project_review_prompt: updated.project_prompt });
|
||||
} catch (error: any) {
|
||||
logger.error('获取仓库列表失败:', error);
|
||||
return c.json({ message: 'Failed to fetch repositories', error: error.message }, 500);
|
||||
logger.error(`更新 ${owner}/${repo} 的项目级审查提示词失败:`, error);
|
||||
return c.json({ message: 'Failed to update project review prompt', error: error.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -110,7 +190,11 @@ protectedRoutes.get('/review/runs/:runId', async (c) => {
|
||||
if (!result) {
|
||||
return c.json({ message: 'Run not found' }, 404);
|
||||
}
|
||||
return c.json(result);
|
||||
const sessionTree = agentSessionRepository.getSessionTreeByRunId(runId);
|
||||
return c.json({
|
||||
...result,
|
||||
sessionTree,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('获取审查任务详情失败:', error);
|
||||
return c.json({ message: 'Failed to fetch review run details', error: error.message }, 500);
|
||||
|
||||
129
src/controllers/agents.ts
Normal file
129
src/controllers/agents.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Hono } from 'hono';
|
||||
import {
|
||||
type AgentDefinition,
|
||||
type AgentDefinitionLoadError,
|
||||
loadAgentRegistry,
|
||||
} from '../agent-kernel/definitions';
|
||||
import { configManager } from '../config/config-manager';
|
||||
|
||||
export const agentsRouter = new Hono();
|
||||
|
||||
interface SerializableAgentDefinition {
|
||||
agentType: string;
|
||||
name: string;
|
||||
whenToUse: string;
|
||||
source: AgentDefinition['source'];
|
||||
tools: string[];
|
||||
disallowedTools: string[];
|
||||
skills: string[];
|
||||
model?: string;
|
||||
maxTurns: number;
|
||||
permissionMode: AgentDefinition['permissionMode'];
|
||||
background: boolean;
|
||||
isolation: AgentDefinition['isolation'];
|
||||
}
|
||||
|
||||
interface SerializableLoadError {
|
||||
source: AgentDefinitionLoadError['source'];
|
||||
filePath: string;
|
||||
code: AgentDefinitionLoadError['code'];
|
||||
message: string;
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
function toSerializableDefinition(definition: AgentDefinition): SerializableAgentDefinition {
|
||||
return {
|
||||
agentType: definition.agentType,
|
||||
name: definition.name,
|
||||
whenToUse: definition.whenToUse,
|
||||
source: definition.source,
|
||||
tools: definition.tools,
|
||||
disallowedTools: definition.disallowedTools,
|
||||
skills: definition.skills,
|
||||
model: definition.model,
|
||||
maxTurns: definition.maxTurns,
|
||||
permissionMode: definition.permissionMode,
|
||||
background: definition.background,
|
||||
isolation: definition.isolation,
|
||||
};
|
||||
}
|
||||
|
||||
function toSerializableLoadError(error: AgentDefinitionLoadError): SerializableLoadError {
|
||||
return {
|
||||
source: error.source,
|
||||
filePath: error.filePath,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
issues: error.issues,
|
||||
};
|
||||
}
|
||||
|
||||
agentsRouter.get('/definitions', async (c) => {
|
||||
const registry = await loadAgentRegistry({ projectRoot: process.cwd() });
|
||||
|
||||
return c.json({
|
||||
activeDefinitions: registry.activeAgents.map(toSerializableDefinition),
|
||||
allDefinitions: registry.allAgents.map(toSerializableDefinition),
|
||||
loadErrors: registry.failedFiles.map(toSerializableLoadError),
|
||||
});
|
||||
});
|
||||
|
||||
agentsRouter.get('/model-config', (c) => {
|
||||
const current = configManager.getCurrent();
|
||||
|
||||
return c.json({
|
||||
agentMainModel: current.review.agentMainModel,
|
||||
agentDefaultSubagentModel: current.review.agentDefaultSubagentModel,
|
||||
source: {
|
||||
agentMainModel: configManager.getSource('AGENT_MAIN_MODEL'),
|
||||
agentDefaultSubagentModel: configManager.getSource('AGENT_DEFAULT_SUBAGENT_MODEL'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
agentsRouter.put('/model-config', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
agentMainModel?: unknown;
|
||||
agentDefaultSubagentModel?: unknown;
|
||||
}>();
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
if (body.agentMainModel !== undefined) {
|
||||
if (typeof body.agentMainModel !== 'string' || !body.agentMainModel.trim()) {
|
||||
return c.json({ message: 'agentMainModel must be a non-empty string' }, 400);
|
||||
}
|
||||
updates.AGENT_MAIN_MODEL = body.agentMainModel.trim();
|
||||
}
|
||||
|
||||
if (body.agentDefaultSubagentModel !== undefined) {
|
||||
if (
|
||||
typeof body.agentDefaultSubagentModel !== 'string' ||
|
||||
!body.agentDefaultSubagentModel.trim()
|
||||
) {
|
||||
return c.json({ message: 'agentDefaultSubagentModel must be a non-empty string' }, 400);
|
||||
}
|
||||
updates.AGENT_DEFAULT_SUBAGENT_MODEL = body.agentDefaultSubagentModel.trim();
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return c.json(
|
||||
{
|
||||
message: 'At least one of agentMainModel or agentDefaultSubagentModel is required',
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
await configManager.setOverrides(updates);
|
||||
const current = configManager.getCurrent();
|
||||
|
||||
return c.json({
|
||||
agentMainModel: current.review.agentMainModel,
|
||||
agentDefaultSubagentModel: current.review.agentDefaultSubagentModel,
|
||||
source: {
|
||||
agentMainModel: configManager.getSource('AGENT_MAIN_MODEL'),
|
||||
agentDefaultSubagentModel: configManager.getSource('AGENT_DEFAULT_SUBAGENT_MODEL'),
|
||||
},
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user