Compare commits

..

2 Commits

Author SHA1 Message Date
yutengjing 5b19ca3990 💄 style: extend artifact code background 2026-06-12 11:29:19 +08:00
yutengjing c8b5e337c0 🐛 fix: keep artifact code panel scrolled 2026-06-12 11:12:34 +08:00
1273 changed files with 18072 additions and 46310 deletions
+29 -197
View File
@@ -19,23 +19,9 @@ also run as full cloud automation. Every test session follows the same
four-step contract:
```
Step -1: Plan approval → Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
```
## Step -1 — Plan approval for non-trivial tests
Skip directly to Step 0 if: the test is a single re-run after a fix, the plan
was already agreed on, or the user gave exact commands.
Otherwise, propose a test plan (surface, cases, expected evidence, assumptions)
and use the runtime structured question tool (`request_user_input` /
ask-user-question equivalent) with two fixed choices:
1. `开始执行 (Recommended)` — 测试方案没问题,开始执行
2. `先讨论下` — 方案有问题,先讨论下
Wait for the user's choice before proceeding.
## Step 0 — Environment setup + auth check (mandatory)
Step 0 is about getting the environment ready: **dependencies are healthy**
@@ -43,36 +29,6 @@ and **auth is green**. A test run that dies halfway on a missing dependency or
a login wall wastes the whole session — clear both gates BEFORE writing a
single test step.
### 0.0 Resolve the current test environment
Before starting a dev server, checking auth, opening agent-browser, or writing
test steps, print and confirm the current local test environment:
```bash
./.agents/skills/agent-testing/scripts/test-env.sh
```
This command is the source of truth for local test ports. It reads the current
shell plus `.env` files using the same precedence as `scripts/runWithEnv.mts`,
then prints:
- `APP_URL`
- `PORT`
- `SERVER_URL`
- `AUTH_TRUSTED_ORIGINS`
- `SPA_PORT`
- `MOBILE_SPA_PORT`
- `DESKTOP_PORT`
For commands that need these values, export them from the same resolver:
```bash
eval "$(./.agents/skills/agent-testing/scripts/test-env.sh --exports)"
```
Do not rely on hard-coded port tables. If the printed values do not match the
running dev server, fix/export the env first, then continue.
### 0.1 Dependencies are installed — root AND standalone apps
The root pnpm workspace does **NOT** cover every app: `pnpm-workspace.yaml`
@@ -82,9 +38,9 @@ lists `packages/**`, `e2e`, `apps/server`, and only `apps/desktop/src/main` —
refresh them, so install in every app the test will touch:
```bash
pnpm install # root workspace
cd apps/desktop && pnpm install # Electron surface
cd apps/cli && pnpm install # CLI surface
pnpm install # root workspace
cd apps/desktop && pnpm install # Electron surface
cd apps/cli && pnpm install # CLI surface
```
Symptom of a stale standalone install: the build/launch fails to resolve a
@@ -99,133 +55,27 @@ directory — a script launched while `cwd` is `apps/desktop` fails with
`No such file or directory`. Verify `pwd` is the repo root before launching
long-running scripts.
### 0.3 Init local dev env without `.env`
### 0.3 Auth is green
For Web smoke against local code, start a **normal local dev environment**.
First check the repo root for `.env`:
- If `.env` exists, use the existing local configuration and start the dev
server normally.
- If `.env` does not exist, use the agent-testing env bootstrap.
Do not start the standalone e2e server as the product under test.
Use `scripts/init-dev-env.sh`. It follows the e2e setup pattern — Postgres,
Redis, migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
skill and starts the repo's dev server (`pnpm run dev:next` / `bun run dev`),
not `e2e/scripts/setup.ts --start`. The script hard-blocks when root `.env`
exists, so it cannot accidentally override a user's local config. When `.env`
exists, do not call any `init-dev-env.sh` subcommand.
Decision flow:
**Auth is the gate for all automated testing.**
```bash
if [[ -f .env ]]; then
bun run dev
else
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
fi
./.agents/skills/agent-testing/scripts/setup-auth.sh status
```
Bootstrap flow when no `.env` exists:
```bash
# From repo root. Managed Postgres/Redis flow requires Docker Desktop.
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
If using an existing Postgres instead of the managed Docker DB, set
`DATABASE_URL` and `REDIS_URL`, then skip `setup-db`:
```bash
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
For backend-only checks, `dev-next` is available, but Web smoke needs the
full-stack `dev` command so Next can proxy the SPA HTML from Vite:
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
```
Useful subcommands:
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh env # print exports
./.agents/skills/agent-testing/scripts/init-dev-env.sh write # write .records/env/agent-testing-dev.env
./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate # migrations only
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user # seed user + CLI API key
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash # local QStash for workflow paths
./.agents/skills/agent-testing/scripts/init-dev-env.sh clean-db # remove managed DB container
```
Default script env:
- `APP_URL=http://localhost:3010`
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
- `DATABASE_DRIVER=node`
- `AGENT_RUNTIME_MODE=queue` so backend-only agent runtime checks use the
same queued execution path as production
- `REDIS_URL=redis://localhost:6380` for queue-mode agent runtime state
- `FEATURE_FLAGS=-agent_self_iteration` so local smoke does not require QStash
- Local QStash defaults (`QSTASH_URL`, `QSTASH_TOKEN`, signing keys) are exported;
run `init-dev-env.sh qstash` in a separate terminal when the path under test
triggers QStash/Workflow.
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
- S3 mock vars
- Managed DB container: `lobehub-agent-testing-postgres`
- Managed Redis container: `lobehub-agent-testing-redis`
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
onboarding already completed, plus a local API key in
`.records/env/agent-testing-cli.env` for CLI automation. When running Cucumber
against this dev server, pass the same script env into the test process too;
Cucumber has its own `BeforeAll` seed path and it must see `DATABASE_URL`
instead of silently skipping setup:
```bash
cd e2e
# Only in the no-.env branch.
eval "$(../.agents/skills/agent-testing/scripts/init-dev-env.sh env)"
BASE_URL=http://localhost:3010 HEADLESS=true bun run test:smoke
```
### 0.4 Auth is green for the selected surface
**Auth is the gate for automated testing, but the gate is surface-scoped.**
Pick the intended surface first when it is already clear from the task, then
check only that surface. Do not block a Web test on CLI device-code auth or an
Electron login state unless the test spans those surfaces.
```bash
./.agents/skills/agent-testing/scripts/setup-auth.sh status --surface web
```
Use `status` with no `--surface` only for cross-surface test plans.
| Surface | Mechanism | One-key path | Standard check |
| -------- | --------------------------------------------- | ------------------------ | ----------------------------------------- |
| CLI | Seeded API key, device-code fallback | `setup-auth.sh cli-seed` | `setup-auth.sh status --surface cli` |
| Web | Seeded better-auth login into `agent-browser` | `setup-auth.sh web-seed` | `setup-auth.sh status --surface web` |
| Electron | App's own persistent login state | Log in once in the app | `setup-auth.sh status --surface electron` |
| Bot | Native apps already logged in | — | per-platform screenshot |
| Surface | Mechanism | One-key path | Standard check |
| -------- | ------------------------------------------------- | ------------------------------ | ------------------------------ |
| CLI | OIDC Device Code Flow (`apps/cli/.lobehub-dev`) | `setup-auth.sh cli` | `setup-auth.sh status` |
| Web | better-auth cookie injection into `agent-browser` | `pbpaste \| setup-auth.sh web` | `setup-auth.sh web-verify` |
| Electron | App's own persistent login state | Log in once in the app | `app-probe.sh auth` |
| Bot | Native apps already logged in | — | per-platform screenshot |
Login-state checks are standardized — do NOT hand-roll `window.__LOBE_STORES`
eval snippets; use `scripts/app-probe.sh auth` (returns `{ isSignedIn, userId }`,
works for Electron CDP and web sessions via `AB_TARGET`).
For Web tests, the test surface is always `agent-browser --session lobehub-dev`.
Use `setup-auth.sh web-seed` first in the seeded local env. The user's normal
Chrome is only a source for copying the Cookie header when seed auth is not
available or `status --surface web` still fails. If Chrome is already logged in,
do not open a login page; verify agent-browser first, then request the Network
`Cookie:` header only if that verification fails. Full background and failure modes:
If `status` is not all green, fix auth first (the steps that need a human must be
requested from the user explicitly). Full background and failure modes:
[references/auth.md](./references/auth.md).
## Step 1 — Pick the surface by change scope
@@ -298,19 +148,17 @@ Surface guides above carry the detailed workflows. Shared infrastructure:
All under `.agents/skills/agent-testing/scripts/`:
| Script | Usage |
| ------------------------- | ---------------------------------------------------------------------------- |
| `test-env.sh` | Print/export the resolved local test env and ports |
| `setup-auth.sh` | One-stop auth setup & status check (`status` / `cli` / `web`) |
| `init-dev-env.sh` | Self-contained local dev env (`setup-db` / `seed-user` / `dev-next` / `dev`) |
| `app-probe.sh` | LobeHub app probes: `auth` / `route` / `ops` / `goto <path>` / `errors` |
| `record-gif.sh` | Frame-sequence → GIF for time-based behavior (streaming, timers, animations) |
| `report-init.sh` | Scaffold a structured test report (Step 3) |
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart, CDP 9222) |
| `capture-app-window.sh` | Screenshot a specific app window (general; used by bot tests) |
| `record-app-screen.sh` | Record app screen (video + periodic screenshots) |
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
| `agent-gateway/` | Gateway probe / dump / analyze tools |
| Script | Usage |
| ------------------------- | ------------------------------------------------------------------------------ |
| `setup-auth.sh` | One-stop auth setup & status check (`status` / `cli` / `web`) |
| `app-probe.sh` | LobeHub app probes: `auth` / `route` / `ops` / `goto <path>` / `errors` |
| `record-gif.sh` | Frame-sequence → GIF for time-based behavior (streaming, timers, animations) |
| `report-init.sh` | Scaffold a structured test report (Step 3) |
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart, CDP 9222) |
| `capture-app-window.sh` | Screenshot a specific app window (general; used by bot tests) |
| `record-app-screen.sh` | Record app screen (video + periodic screenshots) |
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
| `agent-gateway/` | Gateway probe / dump / analyze tools |
`app-probe.sh` is the LobeHub-specific fast path into app state — auth check,
current route, running operations, and `goto <path>` quick navigation
@@ -326,13 +174,12 @@ not a chat-only summary. Scaffold it up front and fill it as you test:
```bash
DIR=$(./.agents/skills/agent-testing/scripts/report-init.sh my-feature "Verify my feature")
# ... test, saving screenshots / CLI transcripts into $DIR/assets/ ...
# fill $DIR/report.md (scope, case table with inline evidence, verdict, score) and $DIR/result.json
# fill $DIR/report.md (case table, embedded evidence, verdict) and $DIR/result.json
```
Reports live in `.records/reports/<timestamp>-<slug>/` (gitignored): `report.md`
(human-readable, with screenshots/GIFs embedded directly in the case table),
`result.json` (machine-readable pass/fail + score), `assets/` (evidence).
Format spec and evidence rules:
(human-readable, with embedded screenshots), `result.json` (machine-readable
pass/fail + score), `assets/` (evidence). Format spec and evidence rules:
[references/report.md](./references/report.md).
Two hard rules worth front-loading:
@@ -340,21 +187,6 @@ Two hard rules worth front-loading:
- **Report language = the user's conversation language.** Write the ENTIRE
`report.md` (headings included) in the language the user is conversing in —
no mixed English. `result.json` keys/status values stay English.
- **The case table is the main reading surface.** Prefer the compact
`# | case | result | key observation | evidence` shape and embed the
screenshot/GIF in the evidence cell. Use separate evidence sections only for
long CLI transcripts, HAR summaries, or supplemental detail.
- **Visual evidence must render inline.** Screenshots and GIFs in `report.md`
must use Markdown image syntax like `![case 1](assets/case1.png)`. Do not
use bare file paths, Markdown links, or local file links as the primary
visual evidence; those make the report unreadable without opening each asset.
- **Final replies must include visual evidence links.** When a run includes UI
screenshots or GIFs, include the report directory and the most important
visual artifacts in the final chat response. Each item must include a stable
label, an evidence caption describing the observed UI outcome, and a
repo-relative path, for example:
`[Image #1 - error toast shows provider auth failure](<report-dir>/assets/foo.png)`.
Use repo-relative paths, not absolute paths.
- **Time-based behavior needs a GIF, not a screenshot.** If a case asserts
change over time (streaming output, a ticking timer, loading states,
animations), record it with `scripts/record-gif.sh` and embed the GIF —
+16 -26
View File
@@ -13,18 +13,17 @@ flakiness.
## Prerequisites
| Requirement | Details |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Dev server | `localhost:3010` — see [../references/dev-server.md](../references/dev-server.md) |
| Requirement | Details |
| ------------ | --------------------------------------------------------------------------------- |
| Dev server | `localhost:3010` — see [../references/dev-server.md](../references/dev-server.md) |
| CLI source | `apps/cli/` — runs from source, no rebuild; standalone `node_modules` — run `pnpm install` inside `apps/cli/` (root install does not cover it) |
| CLI dev mode | `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated settings |
| Auth | Seeded API key first; Device Code Flow only as fallback — see [../references/auth.md](../references/auth.md) |
| CLI dev mode | `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated credentials |
| Auth | Device Code Flow login — see [../references/auth.md](../references/auth.md) |
All CLI dev commands run from `apps/cli/`. Subsequent examples use `$CLI`:
```bash
source ../../.records/env/agent-testing-cli.env
CLI="bun src/index.ts"
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
```
## Workflow
@@ -40,23 +39,14 @@ check, start, and restart commands. Server-side code changes require a restart.
./.agents/skills/agent-testing/scripts/setup-auth.sh status
```
If the CLI is not ready in the seeded local environment:
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
source .records/env/agent-testing-cli.env
./.agents/skills/agent-testing/scripts/setup-auth.sh cli-seed
```
If the target environment is not seeded, use the interactive fallback:
If the CLI is not logged in, **the user must run the login themselves**
(interactive browser authorization):
```bash
cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3010
```
Seeded API-key auth does not store credentials. It writes local settings under
`$HOME/.lobehub-dev` and requires the generated env file to be sourced before
CLI commands. Details:
Credentials persist in `apps/cli/.lobehub-dev/`. Details:
[../references/auth.md](../references/auth.md).
### Step 3 — Test with CLI commands
@@ -143,10 +133,10 @@ $CLI provider test <provider-id>
## Troubleshooting
| Issue | Solution |
| --------------------------- | ------------------------------------------------------------------------------------------------------ |
| `No authentication found` | Source `.records/env/agent-testing-cli.env`, or run device-code `login --server http://localhost:3010` |
| `UNAUTHORIZED` on API calls | Re-run `init-dev-env.sh seed-user` and re-source the env file; for device-code fallback, re-run login |
| `ECONNREFUSED` | Dev server not running — see dev-server.md |
| CLI shows old data/behavior | Server needs restart to pick up code changes |
| Login opens wrong server | Must use `--server` flag (env var doesn't work) |
| Issue | Solution |
| --------------------------- | ----------------------------------------------- |
| `No authentication found` | Run `login --server http://localhost:3010` |
| `UNAUTHORIZED` on API calls | Token expired; re-run login |
| `ECONNREFUSED` | Dev server not running — see dev-server.md |
| CLI shows old data/behavior | Server needs restart to pick up code changes |
| Login opens wrong server | Must use `--server` flag (env var doesn't work) |
+49 -92
View File
@@ -1,72 +1,37 @@
# Auth Setup for Local Agent Testing
**Auth is the gate for all automated testing.** Complete
[Step 0.0](../SKILL.md#00-resolve-the-current-test-environment) first so
`SERVER_URL` and ports are resolved, then verify auth before writing any test
step.
Initialize helpers first:
**Auth is the gate for all automated testing.** Prepare and verify it before
writing any test step. The one-stop entry point is:
```bash
SCRIPT="./.agents/skills/agent-testing/scripts/setup-auth.sh"
TEST_ENV="./.agents/skills/agent-testing/scripts/test-env.sh"
eval "$($TEST_ENV --exports)"
SCRIPT=".agents/skills/agent-testing/scripts/setup-auth.sh"
$SCRIPT status # check server + CLI + web auth readiness
$SCRIPT cli # interactive CLI device-code login (must be run by the user)
pbpaste | $SCRIPT web # inject a copied Cookie header into the agent-browser session
$SCRIPT web-verify # live-check that the agent-browser session is authenticated
```
Quick reference after initialization:
| Command | Purpose |
| ------------------------------ | -------------------------------------------------- |
| `$SCRIPT status` | Check all surfaces (server + CLI + web + Electron) |
| `$SCRIPT status --surface web` | Check only the Web surface gate |
| `$SCRIPT cli-seed` | Configure CLI API-key auth from the seeded key |
| `$SCRIPT cli` | Interactive CLI device-code login (user must run) |
| `$SCRIPT open-chrome` | Open Chrome at `SERVER_URL` with DevTools |
| `$SCRIPT web-seed` | Sign in the seeded user and inject cookies |
| `pbpaste \| $SCRIPT web` | Inject a copied Cookie header into agent-browser |
| `$SCRIPT web-verify` | Live-check agent-browser session auth |
Use `localhost` for Web auth; better-auth cookies are stored for `localhost`,
not `127.0.0.1`.
`SERVER_URL` defaults to `http://localhost:3010` (this repo's `dev:next` port).
Override it when testing against another server (e.g. `SERVER_URL=http://localhost:3011`
in the cloud repo).
## Per-surface overview
| Surface | Mechanism | Persistence | Human interaction |
| -------- | ---------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------- |
| CLI | Seeded API key or OIDC Device Code Flow | `.records/env/agent-testing-cli.env` + `$HOME/.lobehub-dev` | No for seed path; yes for device-code fallback |
| Web | Seeded better-auth login or cookie copy | `~/.lobehub-agent-testing/web-state.json` + agent-browser session | No for seed path; copy cookie only as fallback |
| Electron | App's own login state | Electron user-data dir | Log in once manually in the app |
| Bot | Native apps (Discord/WeChat/…) logged in | Each app's own session | Once per app |
| Surface | Mechanism | Persistence | Human interaction |
| -------- | ---------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------- |
| CLI | OIDC Device Code Flow | `apps/cli/.lobehub-dev/settings.json` | Yes — browser authorization, every token expiry |
| Web | better-auth cookie injection | `~/.lobehub-agent-testing/web-state.json` + agent-browser session | Copy the Cookie header once per token rotation |
| Electron | App's own login state | Electron user-data dir | Log in once manually in the app |
| Bot | Native apps (Discord/WeChat/…) logged in | Each app's own session | Once per app |
## CLI — Seeded API key
## CLI — Device Code Flow
For the self-contained no-root-`.env` dev environment, seed the baseline user
and API key once:
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
source .records/env/agent-testing-cli.env
./.agents/skills/agent-testing/scripts/setup-auth.sh cli-seed
```
The seed step writes `LOBE_API_KEY` for humans and maps it to the CLI's current
auth variable, `LOBEHUB_CLI_API_KEY`. It also sets `LOBEHUB_SERVER` so CLI
commands hit the local server without needing a stored device-code token.
Use this for automated CLI verification:
```bash
cd apps/cli
source ../../.records/env/agent-testing-cli.env
bun src/index.ts <command>
```
## CLI — Device Code Flow fallback
Use device-code login only when testing against a non-seeded environment.
Credentials are isolated from the user's real CLI config via
`LOBEHUB_CLI_HOME=.lobehub-dev`, which the current CLI stores under
`$HOME/.lobehub-dev`.
`LOBEHUB_CLI_HOME=.lobehub-dev` (kept inside `apps/cli/`, gitignored).
Login requires interactive browser authorization, so **the user must run it
themselves** (e.g. via the `!` prefix in Claude Code):
```bash
cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3010
@@ -75,30 +40,10 @@ cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server htt
- The `--server` flag is required — an env var does NOT work and login will hit
the wrong server without it.
- Check state without logging in: `setup-auth.sh status` (verifies
`LOBEHUB_CLI_API_KEY` when present, otherwise checks the stored server URL).
`settings.json` exists and `serverUrl` matches).
- `UNAUTHORIZED` on API calls means the token expired — re-run login.
## Web — seeded better-auth login
The Web test surface is `agent-browser --session lobehub-dev`. The user's
ordinary Chrome is only a cookie source; Chrome screenshots, Chrome Network
records, and Chrome logged-in state do not prove the agent-browser test session
is authenticated.
For the seeded local dev environment, use the automatic path:
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/setup-auth.sh web-seed
```
`web-seed` posts the seeded email/password to
`/api/auth/sign-in/email`, stores the returned cookie jar under
`~/.lobehub-agent-testing/`, converts it to Playwright `storageState`, loads it
into the `agent-browser` session, and verifies the session does not land on
`/signin`.
## Web — manual cookie injection fallback
## Web — better-auth cookie injection (agent-browser)
`agent-browser --headed` on macOS often creates the Chromium window off-screen —
the user can't see or interact with it, so manual login inside the agent-browser
@@ -108,19 +53,31 @@ user's own logged-in Chrome and inject it as a Playwright-style state file.
Do **not** use this on production URLs — only local dev. Treat the cookie as a
secret: don't paste it into shared logs, PRs, or commit it anywhere.
### Web — decision flow
### One-key path
1. `$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
2. Not green and using the seeded local env → `$SCRIPT web-seed`.
3. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
4. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:`**Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
5. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
1. Ask the user to copy the Cookie header **from a Network request, NOT
`document.cookie`** (`document.cookie` cannot see HttpOnly cookies, which is
exactly where better-auth puts its session):
- Open the logged-in tab (`http://localhost:<port>/…`) in Chrome.
- `Cmd+Option+I`**Network** tab → refresh → click any same-origin request.
- Under **Request Headers**, right-click the `Cookie:` line → **Copy value**.
2. Inject and verify in one shot:
```bash
pbpaste | ./.agents/skills/agent-testing/scripts/setup-auth.sh web
```
The script filters the header down to the better-auth cookies
(`better-auth.session_token`, `better-auth.state`), builds the Playwright
`storageState` JSON, loads it into the `agent-browser` session (default name
`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
### Using the authenticated session
```bash
agent-browser --session lobehub-dev open "$SERVER_URL/"
agent-browser --session lobehub-dev open "http://localhost:3010/"
agent-browser --session lobehub-dev snapshot -i | head -20
# Look for the user's avatar/name in the sidebar, or absence of the signin form.
```
### Notes
@@ -133,12 +90,12 @@ agent-browser --session lobehub-dev snapshot -i | head -20
### Common failure modes
| Symptom | Cause | Fix |
| --------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Still redirects to `/signin` after injection | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
| Script reports `no better-auth cookies found` | User pasted the wrong value, or the cookie parser regressed | Keep the raw `Cookie:` header as-is; run `scripts/setup-auth.test.sh` if the input looks valid |
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-inject |
| Domain mismatch | Cookie domain must be `localhost` literally, no leading dot for local dev | — |
| Symptom | Cause | Fix |
| --------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------- |
| Still redirects to `/signin` after injection | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
| Script reports `no better-auth cookies found` | Separator wrong, or user pasted URL-decoded value | Keep the raw `Cookie:` header as-is |
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-inject |
| Domain mismatch | Cookie domain must be `localhost` literally, no leading dot for local dev | — |
## Electron
@@ -3,72 +3,33 @@
Single source of truth for starting / restarting the backend that all test
surfaces (CLI, Electron, Web) hit.
## Resolve ports first
Run `test-env.sh` as described in
[SKILL.md Step 0.0](../SKILL.md#00-resolve-the-current-test-environment)
before starting or probing any local test surface.
## Ports & modes
| Command | What it runs | Port source |
| ------------------- | --------------------------------------------------------- | ------------------- |
| `pnpm run dev:next` | Next.js backend (API + auth) | `PORT` |
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `PORT` + `SPA_PORT` |
| `bun run dev:spa` | Vite SPA only, proxies API to `PORT` | `SPA_PORT` |
| Command | What it runs | Port |
| ------------------- | --------------------------------------------------------- | --------------------------------- |
| `pnpm run dev:next` | Next.js backend (API + auth) | `3010` |
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `3010` (API) + SPA |
| `bun run dev:spa` | Vite SPA only, proxies API to `3010` | `9876` (prints a Debug Proxy URL) |
In the **cloud repo** (where this repo is the `lobehub/` submodule), local
worktree names map to fallback defaults only when `.env` and shell env do not
provide values:
| Workspace directory | Default `SERVER_URL` |
| ------------------- | -------------------------------- |
| `lobehub` | `http://localhost:3010` |
| `lobehub-cloud` | `http://localhost:3020` |
| `lobehub-cloud-1` | `http://localhost:3021` |
| `lobehub-cloud-N` | `http://localhost:$((3020 + N))` |
`test-env.sh` and `setup-auth.sh` both use the resolved env first and these
worktree defaults only as fallback. Treat the dev-server terminal output as the
final source of truth when testing a non-standard port, then export it for every
agent-testing command:
```bash
export SERVER_URL=http://localhost:<port-from-dev-output>
```
In the **cloud repo** (where this repo is the `lobehub/` submodule) the dev
server conventionally runs on `3011` — set `SERVER_URL=http://localhost:3011`
for the scripts in this skill when testing there.
## Health check
```bash
curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
curl -s -o /dev/null -w '%{http_code}' http://localhost:3010/
```
## Start / restart
```bash
# Start backend only.
# With root .env: use the existing local config.
# Agent runtime queue mode is required to mirror production async execution.
AGENT_RUNTIME_MODE=queue pnpm run dev:next
# Without root .env: use the self-contained agent-testing env.
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
# Full-stack SPA + backend. Required for Web smoke.
# With root .env:
AGENT_RUNTIME_MODE=queue bun run dev
# Without root .env:
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
# Local QStash. Run in a separate terminal only when testing workflow paths.
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash
# Start (from repo root)
pnpm run dev:next
# Restart — required to pick up server-side code changes
lsof -ti:"$PORT" | xargs kill
lsof -ti:3010 | xargs kill
pnpm run dev:next
# or, when no root .env exists:
# ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
```
## When a server restart is needed
@@ -87,15 +48,8 @@ in doubt.
## Troubleshooting
| Issue | Solution |
| ------------------------- | --------------------------------------------------------------------------------------------- |
| `ECONNREFUSED` | Server not running — start it |
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
| Stale data / old behavior | Server needs a restart to pick up code changes |
| Agent call runs inline | Set `AGENT_RUNTIME_MODE=queue`, make sure `REDIS_URL` is configured, then restart the server |
| Queue mode needs Redis | Run `init-dev-env.sh setup-db`, or provide `REDIS_URL=redis://...` for an existing Redis |
| QStash workflow failures | Start `init-dev-env.sh qstash` and make sure dev server inherited the script's `QSTASH_*` env |
Marketplace/community endpoints are not part of the local agent-testing auth
gate. Do not block local product-chain verification on marketplace API auth
unless the change explicitly targets marketplace behavior.
| Issue | Solution |
| ------------------------- | ------------------------------------------------------- |
| `ECONNREFUSED` | Server not running — start it |
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
| Stale data / old behavior | Server needs a restart to pick up code changes |
@@ -11,7 +11,7 @@ output):
```
.records/reports/<YYYYMMDD-HHMMSS>-<slug>/
├── report.md # human-readable report (case table with inline screenshots, verdict)
├── report.md # human-readable report (embedded screenshots, case table, verdict)
├── result.json # machine-readable results (pass/fail counts, score)
└── assets/ # evidence: screenshots, HAR files, CLI transcripts
```
@@ -25,16 +25,13 @@ output):
```
The script creates the directory, pre-fills branch / commit / date in both
files, and prints the directory path. The scaffold uses the compact report
shape below; translate its headings and table labels to the user's language
before delivery if needed.
files, and prints the directory path.
2. **Collect evidence as you test** — every asserted behavior gets one evidence
item in `$DIR/assets/`:
- UI (static state): `agent-browser screenshot` or `capture-app-window.sh`,
then **verify the screenshot with the Read tool before citing it** —
never cite an image you haven't looked at.
- UI (time-based behavior): **screenshot vs GIF is a judgment you must
make per case.** If the assertion is about change over time — streaming
output, a ticking timer, loading/progress states, animations,
@@ -51,91 +48,33 @@ output):
Embed it like an image: `![case 2](assets/case2-streaming.gif)`. Verify
at least the first/last frames visually (Read the GIF) before citing.
- CLI: exact command + trimmed output (`$CLI task list | tee "$DIR/assets/task-list.txt"`).
- Network: `agent-browser network requests` dumps or HAR files.
3. **Fill `report.md` as you go** — don't reconstruct from memory at the end.
The primary evidence belongs in the case table itself: each row should pair
the assertion with the screenshot/GIF or non-visual artifact that proves it,
so readers can scan the result without jumping between sections. UI evidence
must render inline with Markdown image syntax; a plain link or file path is
not acceptable as primary visual evidence.
4. **Set the verdict** in both `report.md` and `result.json`, then link the
report directory in your final answer to the user. If UI evidence exists,
list the key screenshot/GIF links in the final chat response. Use Markdown
link text as the evidence caption, for example:
`[Image #1 - observed outcome](<report-dir>/assets/case1.png)`.
report directory in your final answer to the user.
## Report language (hard rule)
**`report.md` MUST be written in the language the user is conversing in** —
the whole file, headings included. If the conversation is in Chinese, the
report is in Chinese; do not mix English prose into it. The scaffold headings
are placeholders — translate them when filling if the user is not conversing in
the scaffold language. Exceptions that stay as-is: code/commands, identifiers,
log excerpts, and `result.json` (its keys and status values are machine-read
and stay English; the `title` and case `name` fields follow the user's
language).
report is in Chinese; do not mix English prose into it. The scaffold's English
headings are placeholders — translate them when filling. Exceptions that stay
as-is: code/commands, identifiers, log excerpts, and `result.json` (its keys
and status values are machine-read and stay English; the `title` and case
`name` fields follow the user's language).
## report.md sections
Default report shape:
| Section | Content |
| ---------------- | -------------------------------------------------------------------------------------------- |
| **Scope** | What changed / what is being verified; branch, commit, date, surface, entry URL/page, focus |
| **Cases** | Compact table: `# \| Case \| Result \| Key observation \| Evidence` |
| **Verdict** | Overall verdict first (`pass` / `partial` / `fail`), then the concise reasons and follow-ups |
| **Verification** | Commands or automated checks run in this session, with trimmed results |
| **Score** | Pass/fail/blocked counts, optional 0100 score |
The case table is the main reading surface. Prefer one clear row per user
scenario or regression assertion, and put the screenshot/GIF directly in the
`Evidence` cell:
```markdown
| # | Case | Result | Key observation | Evidence |
| --- | ------------------------ | ------ | ----------------------------------------------------------------- | ------------------------------------------------ |
| 1 | Create a new page | pass | Title and body persisted after refresh | ![created page](assets/new-page-created.png) |
| 2 | Respect requested length | fail | Requested about 600 Chinese characters; final body was about 1286 | ![final article](assets/write-article-final.png) |
```
## Inline visual evidence
Screenshots and GIFs must be embedded so the report shows the image inline:
```markdown
![case 1 result](assets/case1-result.png)
![streaming response](assets/case2-streaming.gif)
```
Do **not** use these as the primary evidence for UI cases:
```markdown
[case 1 result](assets/case1-result.png)
assets/case1-result.png
file:///tmp/case1-result.png
```
Links are acceptable for non-visual artifacts such as CLI transcripts, HAR
files, or long logs. For videos, embed a representative screenshot/GIF inline in
the case row and link the full video as supplemental evidence.
Avoid the old wide table with separate `steps`, `expected`, and `actual`
columns unless the test is purely non-visual and truly needs that breakdown.
For UI reports, those columns make screenshot-backed reading harder. Put
procedural detail in the row's key observation only when it changes the
interpretation of the result.
Use an extra evidence/detail section only when the inline table cannot carry
the material cleanly, such as long CLI transcripts, HAR summaries, or multiple
screenshots for one case. In that situation, keep the table evidence cell as an
inline visual proof for UI cases or a concise link for non-visual artifacts,
then put the longer material under `Verification` or a brief
`Additional Evidence` section.
| Section | Content |
| --------------- | ---------------------------------------------------------------------------------- |
| **Scope** | What changed / what is being verified; branch + commit |
| **Environment** | Server URL, surfaces used (cli / electron / web / bot), relevant versions |
| **Cases** | Table: `# \| case \| surface \| steps \| expected \| actual \| status \| evidence` |
| **Evidence** | Embedded screenshots/GIFs (`![case 1](assets/case1.png)`), fenced CLI transcripts |
| **Verdict** | Pass/fail/blocked counts, optional 0100 score, open issues / follow-ups |
Status values: `pass` / `fail` / `blocked` (couldn't run — e.g. auth or env
missing; a blocked case is not a pass).
@@ -176,8 +115,7 @@ word the user reads first: `pass`, `fail`, or `partial`.
## Rules
- **No evidence, no claim** — every `pass`/`fail` in the case table must link
at least one asset. UI cases must inline-embed their primary screenshot/GIF;
non-visual CLI/network cases may link transcripts, HAR files, or logs.
at least one asset.
- **Screenshots must be visually verified** with the Read tool before being
cited.
- **Report failures faithfully** — a failing case with clear evidence is a good
@@ -1,459 +0,0 @@
#!/usr/bin/env bash
# init-dev-env.sh — self-contained local dev env for agent testing.
#
# This script initializes the env needed to run LobeHub's normal local dev
# server without depending on a root .env file. It follows the same shape as
# the e2e bootstrap (Postgres + migrations + auth/key-vault/S3 test env), but
# starts the repo's dev server, not the standalone e2e server.
#
# Guardrail: if repo-root .env exists, every non-help command exits immediately.
# Existing local config always wins.
#
# Usage:
# init-dev-env.sh env # print shell exports
# init-dev-env.sh write [file] # write a source-able env file
# init-dev-env.sh setup-db # start local Postgres/Redis and run migrations
# init-dev-env.sh migrate # run DB migrations against the configured DB
# init-dev-env.sh seed-user # seed the baseline test user + CLI API key
# init-dev-env.sh qstash # run local Upstash QStash dev server
# init-dev-env.sh dev-next # exec `pnpm run dev:next` with this env
# init-dev-env.sh dev # exec `bun run dev` with this env
# init-dev-env.sh clean-db # remove the managed Postgres/Redis containers
#
# Overrides:
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres REDIS_PORT=6380 REDIS_CONTAINER=lobehub-agent-testing-redis QSTASH_DEV_PORT=8080
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
ROOT_ENV_FILE="$REPO_ROOT/.env"
SERVER_PORT="${SERVER_PORT:-3010}"
DB_PORT="${DB_PORT:-5433}"
DB_CONTAINER="${DB_CONTAINER:-lobehub-agent-testing-postgres}"
DATABASE_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:${DB_PORT}/postgres}"
REDIS_PORT="${REDIS_PORT:-6380}"
REDIS_CONTAINER="${REDIS_CONTAINER:-lobehub-agent-testing-redis}"
REDIS_URL="${REDIS_URL:-redis://localhost:${REDIS_PORT}}"
ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-dev.env"
CLI_ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-cli.env"
AGENT_TESTING_API_KEY="${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}"
QSTASH_DEV_PORT="${QSTASH_DEV_PORT:-8080}"
QSTASH_LOCAL_TOKEN="${QSTASH_LOCAL_TOKEN:-eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=}"
QSTASH_LOCAL_CURRENT_SIGNING_KEY="${QSTASH_LOCAL_CURRENT_SIGNING_KEY:-sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r}"
QSTASH_LOCAL_NEXT_SIGNING_KEY="${QSTASH_LOCAL_NEXT_SIGNING_KEY:-sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs}"
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; }
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; }
note() { printf ' %s\n' "$1"; }
guard_no_root_env() {
if [[ -f "$ROOT_ENV_FILE" ]]; then
bad "root .env exists: $ROOT_ENV_FILE"
note "Use the existing local configuration instead of init-dev-env.sh."
note "Start normally from repo root, e.g. pnpm run dev:next or bun run dev."
exit 1
fi
}
apply_env() {
export AGENT_RUNTIME_MODE="${AGENT_RUNTIME_MODE:-queue}"
export APP_URL="${APP_URL:-http://localhost:${SERVER_PORT}}"
export AUTH_EMAIL_VERIFICATION="${AUTH_EMAIL_VERIFICATION:-0}"
export AUTH_SECRET="${AUTH_SECRET:-agent-testing-local-auth-secret-32chars}"
export DATABASE_DRIVER="${DATABASE_DRIVER:-node}"
export DATABASE_URL
export FEATURE_FLAGS="${FEATURE_FLAGS:--agent_self_iteration}"
export KEY_VAULTS_SECRET="${KEY_VAULTS_SECRET:-r2gbBPKyJ8ZRKCLKt+I3DImfcL+wGxaQyRC56xtm9Uk=}"
export NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION="${NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION:-0}"
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=6144}"
export PORT="${PORT:-$SERVER_PORT}"
export QSTASH_CURRENT_SIGNING_KEY="${QSTASH_CURRENT_SIGNING_KEY:-$QSTASH_LOCAL_CURRENT_SIGNING_KEY}"
export QSTASH_DEV_PORT
export QSTASH_NEXT_SIGNING_KEY="${QSTASH_NEXT_SIGNING_KEY:-$QSTASH_LOCAL_NEXT_SIGNING_KEY}"
export QSTASH_TOKEN="${QSTASH_TOKEN:-$QSTASH_LOCAL_TOKEN}"
export QSTASH_URL="${QSTASH_URL:-http://127.0.0.1:${QSTASH_DEV_PORT}}"
export REDIS_URL
export S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-agent-testing-access-key}"
export S3_BUCKET="${S3_BUCKET:-agent-testing-bucket}"
export S3_ENDPOINT="${S3_ENDPOINT:-https://agent-testing-s3.localhost}"
export S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY:-agent-testing-secret-key}"
}
env_keys() {
printf '%s\n' \
APP_URL \
AGENT_RUNTIME_MODE \
AUTH_EMAIL_VERIFICATION \
AUTH_SECRET \
DATABASE_DRIVER \
DATABASE_URL \
FEATURE_FLAGS \
KEY_VAULTS_SECRET \
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION \
NODE_OPTIONS \
PORT \
QSTASH_CURRENT_SIGNING_KEY \
QSTASH_DEV_PORT \
QSTASH_NEXT_SIGNING_KEY \
QSTASH_TOKEN \
QSTASH_URL \
REDIS_URL \
S3_ACCESS_KEY_ID \
S3_BUCKET \
S3_ENDPOINT \
S3_SECRET_ACCESS_KEY
}
print_env() {
apply_env
while IFS= read -r key; do
printf 'export %s=%q\n' "$key" "${!key}"
done < <(env_keys)
}
write_env() {
local file="${1:-$ENV_FILE_DEFAULT}"
apply_env
mkdir -p "$(dirname "$file")"
{
printf '# Source this file before starting LobeHub local dev server.\n'
printf '# Generated by %s\n' "$0"
while IFS= read -r key; do
printf 'export %s=%q\n' "$key" "${!key}"
done < <(env_keys)
} > "$file"
ok "wrote env file: $file"
note "source it with: source $file"
}
require_docker() {
if ! command -v docker > /dev/null 2>&1; then
bad "docker CLI is not available"
note "Install/start Docker Desktop, or provide DATABASE_URL for an existing Postgres."
return 1
fi
}
wait_for_db() {
printf ' waiting for Postgres'
until docker exec "$DB_CONTAINER" pg_isready -U postgres > /dev/null 2>&1; do
printf '.'
sleep 2
done
printf '\n'
}
wait_for_redis() {
printf ' waiting for Redis'
until docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; do
printf '.'
sleep 1
done
printf '\n'
}
start_db() {
require_docker
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
ok "Postgres container already running: $DB_CONTAINER"
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
docker start "$DB_CONTAINER" > /dev/null
ok "started existing Postgres container: $DB_CONTAINER"
else
docker run -d \
--name "$DB_CONTAINER" \
-e POSTGRES_PASSWORD=postgres \
-p "${DB_PORT}:5432" \
paradedb/paradedb:latest > /dev/null
ok "created Postgres container: $DB_CONTAINER"
fi
wait_for_db
}
start_redis() {
require_docker
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
ok "Redis container already running: $REDIS_CONTAINER"
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker start "$REDIS_CONTAINER" > /dev/null
ok "started existing Redis container: $REDIS_CONTAINER"
else
docker run -d \
--name "$REDIS_CONTAINER" \
-p "${REDIS_PORT}:6379" \
redis:7-alpine > /dev/null
ok "created Redis container: $REDIS_CONTAINER"
fi
wait_for_redis
}
migrate_db() {
apply_env
cd "$REPO_ROOT"
bun run db:migrate
}
seed_user() {
apply_env
export AGENT_TESTING_API_KEY
export AGENT_TESTING_CLI_ENV_FILE="${AGENT_TESTING_CLI_ENV_FILE:-$CLI_ENV_FILE_DEFAULT}"
cd "$REPO_ROOT"
node <<'NODE'
const bcrypt = require('bcryptjs');
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const pg = require('pg');
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is required to seed the baseline test user.');
}
const TEST_USER = {
email: 'agent-testing@lobehub.com',
fullName: 'Agent Testing User',
id: 'user_agent_testing_001',
password: 'TestPassword123!',
username: 'agent_testing_user',
};
const TEST_API_KEY = {
id: 'api_key_agent_testing_001',
key: process.env.AGENT_TESTING_API_KEY || 'sk-lh-agenttesting0001',
name: 'Agent Testing CLI API Key',
};
const validateApiKeyFormat = (apiKey) => /^sk-lh-[\da-z]{16}$/.test(apiKey);
const hashApiKey = (apiKey) => {
const secret = process.env.KEY_VAULTS_SECRET;
if (!secret) throw new Error('KEY_VAULTS_SECRET is required to seed the baseline API key.');
return crypto.createHmac('sha256', secret).update(apiKey).digest('hex');
};
const encryptWithKeyVaultsSecret = (plaintext) => {
const secret = process.env.KEY_VAULTS_SECRET;
if (!secret) throw new Error('KEY_VAULTS_SECRET is required to seed the baseline API key.');
const rawKey = Buffer.from(secret, 'base64');
if (![16, 24, 32].includes(rawKey.length)) {
throw new Error(
`KEY_VAULTS_SECRET must decode to 16, 24, or 32 bytes, got ${rawKey.length} bytes.`,
);
}
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(`aes-${rawKey.length * 8}-gcm`, rawKey, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
};
const writeCliEnvFile = () => {
const file = process.env.AGENT_TESTING_CLI_ENV_FILE || '.records/env/agent-testing-cli.env';
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
[
'# Source this file before running LobeHub CLI agent tests.',
'# Generated by init-dev-env.sh seed-user',
`export LOBE_API_KEY=${TEST_API_KEY.key}`,
`export LOBEHUB_CLI_API_KEY="${'${LOBE_API_KEY}'}"`,
`export LOBEHUB_SERVER=${process.env.APP_URL}`,
'export LOBEHUB_CLI_HOME=.lobehub-dev',
'',
].join('\n'),
);
return file;
};
const client = new pg.Client({ connectionString: databaseUrl });
(async () => {
if (!validateApiKeyFormat(TEST_API_KEY.key)) {
throw new Error(`Invalid AGENT_TESTING_API_KEY format: ${TEST_API_KEY.key}`);
}
await client.connect();
const now = new Date().toISOString();
const onboarding = JSON.stringify({ finishedAt: now, version: 1 });
const passwordHash = await bcrypt.hash(TEST_USER.password, 10);
const encryptedApiKey = encryptWithKeyVaultsSecret(TEST_API_KEY.key);
const apiKeyHash = hashApiKey(TEST_API_KEY.key);
await client.query(
`INSERT INTO users (id, email, normalized_email, username, full_name, email_verified, onboarding, created_at, updated_at, last_active_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $8)
ON CONFLICT (id) DO UPDATE SET onboarding = $7, updated_at = $8`,
[
TEST_USER.id,
TEST_USER.email,
TEST_USER.email.toLowerCase(),
TEST_USER.username,
TEST_USER.fullName,
true,
onboarding,
now,
],
);
await client.query(
`INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6)
ON CONFLICT DO NOTHING`,
[
'agent_testing_account_001',
TEST_USER.id,
TEST_USER.email,
'credential',
passwordHash,
now,
],
);
await client.query(
`INSERT INTO api_keys (id, name, key, key_hash, enabled, expires_at, user_id, workspace_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NULL, $6, NULL, $7, $7)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
key = EXCLUDED.key,
key_hash = EXCLUDED.key_hash,
enabled = EXCLUDED.enabled,
expires_at = NULL,
updated_at = EXCLUDED.updated_at`,
[
TEST_API_KEY.id,
TEST_API_KEY.name,
encryptedApiKey,
apiKeyHash,
true,
TEST_USER.id,
now,
],
);
const cliEnvFile = writeCliEnvFile();
console.log('seeded baseline user:');
console.log(` email: ${TEST_USER.email}`);
console.log(` password: ${TEST_USER.password}`);
console.log('seeded baseline API key:');
console.log(` LOBE_API_KEY: ${TEST_API_KEY.key}`);
console.log(` CLI env: ${cliEnvFile}`);
})()
.finally(() => client.end())
.catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
cmd_status() {
apply_env
echo "agent-testing local dev env:"
note "APP_URL=$APP_URL"
note "AGENT_RUNTIME_MODE=$AGENT_RUNTIME_MODE"
note "DATABASE_URL=$DATABASE_URL"
note "PORT=$PORT"
note "QSTASH_URL=$QSTASH_URL"
note "REDIS_URL=$REDIS_URL"
if command -v docker > /dev/null 2>&1; then
ok "docker CLI available"
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
ok "managed Postgres running: $DB_CONTAINER"
else
note "managed Postgres is not running: $DB_CONTAINER"
fi
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
ok "managed Redis running: $REDIS_CONTAINER"
else
note "managed Redis is not running: $REDIS_CONTAINER"
fi
else
bad "docker CLI is not available"
fi
}
cmd_qstash() {
apply_env
cd "$REPO_ROOT"
note "starting local QStash dev server at $QSTASH_URL"
note "keep this process running while testing workflow paths"
exec pnpm run qstash -- -port "$QSTASH_DEV_PORT"
}
cmd_dev_next() {
apply_env
cd "$REPO_ROOT"
exec pnpm run dev:next
}
cmd_dev() {
apply_env
cd "$REPO_ROOT"
exec bun run dev
}
cmd_clean_db() {
require_docker
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
docker stop "$DB_CONTAINER" > /dev/null
fi
if docker ps -a --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
docker rm "$DB_CONTAINER" > /dev/null
ok "removed Postgres container: $DB_CONTAINER"
else
note "Postgres container not found: $DB_CONTAINER"
fi
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker stop "$REDIS_CONTAINER" > /dev/null
fi
if docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker rm "$REDIS_CONTAINER" > /dev/null
ok "removed Redis container: $REDIS_CONTAINER"
else
note "Redis container not found: $REDIS_CONTAINER"
fi
}
usage() {
sed -n '3,24p' "$0" >&2
}
COMMAND="${1:-status}"
case "$COMMAND" in
help|-h|--help) usage; exit 0 ;;
*) guard_no_root_env ;;
esac
case "$COMMAND" in
env) print_env ;;
write) shift; write_env "${1:-}" ;;
setup-db)
start_db
start_redis
migrate_db
;;
migrate) migrate_db ;;
seed-user) seed_user ;;
qstash) cmd_qstash ;;
dev-next) cmd_dev_next ;;
dev) cmd_dev ;;
clean-db) cmd_clean_db ;;
status) cmd_status ;;
*)
usage
exit 2
;;
esac
@@ -24,53 +24,39 @@ DATE_HUMAN=$(date '+%Y-%m-%d %H:%M')
DATE_ISO=$(date '+%Y-%m-%dT%H:%M:%S%z')
cat > "$DIR/report.md" << EOF
# 测试报告:$TITLE
# Test Report: $TITLE
## 范围
## Scope
<!-- 测试目标 / 变更范围 / 重点风险 -->
<!-- What changed / what is being verified -->
- 分支:\`$BRANCH\`
- 当前提交:\`$COMMIT\`
- 日期:$DATE_HUMAN
- 表面:<!-- CLI / Electron + CDP / Web / Bot:<platform> -->
- 测试页 / 入口:<!-- e.g. /settings or http://localhost:3010 -->
- 重点:<!-- 本轮最关心的体验、功能或回归点 -->
- Branch: \`$BRANCH\`
- Commit: \`$COMMIT\`
- Date: $DATE_HUMAN
## 用例
## Environment
| # | 用例 | 结果 | 关键现象 | 证据 |
| - | ---- | ---- | -------- | ---- |
| 1 | | 待测 | | ![用例 1](assets/case1.png) |
- Server: <!-- e.g. http://localhost:3010 -->
- Surfaces: <!-- cli / electron / web / bot:<platform> -->
## 结论
## Cases
整体结论:\`pending\`。
| # | Case | Surface | Steps | Expected | Actual | Status | Evidence |
| - | ---- | ------- | ----- | -------- | ------ | ------ | -------- |
| 1 | | | | | | | |
<!-- 用 1-2 段概括用户最需要知道的结果;失败和阻塞必须明确说明影响。 -->
## Evidence
仍需处理 / 跟进:
<!-- Embed screenshots: ![case 1](assets/case1.png) -->
<!-- CLI transcripts in fenced blocks, with the exact command -->
- <!-- TODO -->
## Verdict
## 本轮验证
<!-- 如有自动化或命令行验证,保留精简命令与结果;没有则写“未运行额外自动化验证”。 -->
\`\`\`bash
# command
\`\`\`
结果:
- <!-- TODO -->
## 评分
- 通过:0
- 失败:0
- 阻塞:0
- 评分:— / 100
- Passed: 0 / 0
- Failed: 0
- Blocked: 0
- Score (optional): —
- Open issues / follow-ups:
EOF
cat > "$DIR/result.json" << EOF
@@ -5,114 +5,29 @@
# test step. Background and failure modes: ../references/auth.md
#
# Usage:
# setup-auth.sh status # check server + CLI + web + Electron readiness
# setup-auth.sh status --surface web # check only the Web surface gate
# setup-auth.sh cli-seed # configure CLI API-key auth from seeded local env
# setup-auth.sh status # check server + CLI + web auth readiness
# setup-auth.sh cli # interactive CLI device-code login (run by a human)
# setup-auth.sh open-chrome # open SERVER_URL in Chrome and show DevTools
# setup-auth.sh web-seed # sign in seeded user and inject cookies automatically
# setup-auth.sh web # stdin = Cookie header -> inject into agent-browser session
# setup-auth.sh web-verify # live-check the agent-browser session is authenticated
#
# Env:
# SERVER_URL (default from test-env.sh) dev server under test
# SERVER_URL (default http://localhost:3010) dev server under test
# SESSION (default lobehub-dev) agent-browser session name
# AUTH_DIR (default ~/.lobehub-agent-testing) where web state is persisted
# SEED_EMAIL / SEED_PASSWORD seeded better-auth login
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
workspace_root_for_port() {
local root="$REPO_ROOT"
local name
name="$(basename "$root")"
if [[ "$name" == "lobehub" ]]; then
local parent
parent="$(cd "$root/.." && pwd)"
local parent_name
parent_name="$(basename "$parent")"
if [[ "$parent_name" == lobehub-cloud* ]]; then
root="$parent"
fi
fi
printf '%s\n' "$root"
}
default_server_url() {
local env_resolver resolved
env_resolver="$(dirname "${BASH_SOURCE[0]}")/test-env.sh"
if [[ -x "$env_resolver" ]]; then
resolved="$("$env_resolver" --value SERVER_URL 2> /dev/null || true)"
if [[ -n "$resolved" ]]; then
printf '%s\n' "$resolved"
return 0
fi
fi
local root name suffix port
root="$(workspace_root_for_port)"
name="$(basename "$root")"
case "$name" in
lobehub-cloud)
port=3020
;;
lobehub-cloud-*)
suffix="${name#lobehub-cloud-}"
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
port=$((3020 + 10#$suffix))
else
port=3010
fi
;;
*)
port=3010
;;
esac
printf 'http://localhost:%s\n' "$port"
}
SERVER_URL="${SERVER_URL:-$(default_server_url)}"
SERVER_URL="${SERVER_URL:-http://localhost:3010}"
SESSION="${SESSION:-lobehub-dev}"
AUTH_DIR="${AUTH_DIR:-$HOME/.lobehub-agent-testing}"
STATE_FILE="$AUTH_DIR/web-state.json"
CLI_HOME_NAME="${LOBEHUB_CLI_HOME:-.lobehub-dev}"
CLI_HOME="$HOME/${CLI_HOME_NAME#/}"
CLI_CREDENTIALS_FILE="$CLI_HOME/credentials.json"
SEED_EMAIL="${SEED_EMAIL:-agent-testing@lobehub.com}"
SEED_PASSWORD="${SEED_PASSWORD:-TestPassword123!}"
SEED_API_KEY="${SEED_API_KEY:-${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}}"
CLI_ENV_FILE="${CLI_ENV_FILE:-$REPO_ROOT/.records/env/agent-testing-cli.env}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
CLI_HOME="$REPO_ROOT/apps/cli/.lobehub-dev"
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; }
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; }
note() { printf ' %s\n' "$1"; }
usage() {
cat << EOF
Usage:
$0 status [--surface all|cli|web|electron]
$0 cli-seed
$0 cli
$0 open-chrome [--dry-run]
$0 web-seed
$0 web
$0 web-verify
Env:
SERVER_URL=$SERVER_URL
SESSION=$SESSION
AUTH_DIR=$AUTH_DIR
SEED_EMAIL=$SEED_EMAIL
CLI_HOME=$CLI_HOME
EOF
}
check_server() {
local code
code=$(curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/" 2> /dev/null || true)
@@ -126,35 +41,11 @@ check_server() {
}
check_cli() {
local api_key="${LOBEHUB_CLI_API_KEY:-${LOBE_API_KEY:-}}"
if [[ -n "$api_key" ]]; then
local body_file code
body_file="$(mktemp)"
code=$(curl -sS -o "$body_file" -w '%{http_code}' \
-H "Authorization: Bearer $api_key" \
"$SERVER_URL/api/v1/users/me?includeCount=0" 2> /dev/null || true)
if [[ "$code" =~ ^[23] ]]; then
rm -f "$body_file"
ok "CLI API-key auth valid for $SERVER_URL"
return 0
fi
bad "CLI API-key auth failed for $SERVER_URL (http_code='$code')"
note "seed the local API key first:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
note "source $CLI_ENV_FILE"
rm -f "$body_file"
return 1
fi
if [[ -f "$CLI_HOME/settings.json" ]] && grep -q "$SERVER_URL" "$CLI_HOME/settings.json" && [[ -f "$CLI_CREDENTIALS_FILE" ]]; then
ok "CLI device-code credentials configured for $SERVER_URL (creds: $CLI_HOME)"
if [[ -f "$CLI_HOME/settings.json" ]] && grep -q "$SERVER_URL" "$CLI_HOME/settings.json"; then
ok "CLI logged in to $SERVER_URL (creds: apps/cli/.lobehub-dev)"
else
bad "CLI not logged in to $SERVER_URL"
note "automated path:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user && source $CLI_ENV_FILE && $0 cli-seed"
note "interactive fallback:"
note "ask the user to run:"
note "cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server $SERVER_URL"
return 1
fi
@@ -163,24 +54,13 @@ check_cli() {
check_web() {
if [[ -f "$STATE_FILE" ]]; then
ok "web auth state saved ($STATE_FILE)"
note "live-verify: $0 web-verify"
else
bad "no web auth state for agent-browser"
note "for the seeded local user, run: $0 web-seed"
note "or copy the Cookie header from Chrome DevTools (Network tab), then:"
note "copy the Cookie header from Chrome DevTools (Network tab), then:"
note "pbpaste | $0 web (see references/auth.md)"
return 1
fi
cmd_web_verify --skip-server-check
}
check_agent_browser() {
if command -v agent-browser > /dev/null 2>&1; then
ok "agent-browser available"
else
bad "agent-browser command not found"
note "install or expose agent-browser before Web/Electron UI testing"
return 1
fi
}
check_electron() {
@@ -204,75 +84,16 @@ check_electron() {
}
cmd_status() {
local surface="all"
while [[ $# -gt 0 ]]; do
case "$1" in
--surface)
if [[ $# -lt 2 ]]; then
echo "--surface requires one of: all, cli, web, electron" >&2
return 2
fi
surface="${2:-}"
shift 2
;;
--surface=*)
surface="${1#*=}"
shift
;;
all|cli|web|electron)
surface="$1"
shift
;;
-h|--help)
usage
return 0
;;
*)
echo "unknown status option: $1" >&2
usage >&2
return 2
;;
esac
done
case "$surface" in
all|cli|web|electron) ;;
"")
echo "--surface requires one of: all, cli, web, electron" >&2
return 2
;;
*)
echo "unknown surface: $surface" >&2
usage >&2
return 2
;;
esac
echo "agent-testing auth status (surface=$surface, SERVER_URL=$SERVER_URL):"
echo "agent-testing auth status (SERVER_URL=$SERVER_URL):"
local rc=0
case "$surface" in
all)
check_server || rc=1
check_cli || rc=1
check_web || rc=1
check_electron || rc=1
;;
cli)
check_server || rc=1
check_cli || rc=1
;;
web)
check_server || rc=1
check_web || rc=1
;;
electron)
check_electron || rc=1
;;
esac
check_server || rc=1
check_cli || rc=1
check_web || rc=1
check_electron || rc=1
if [[ $rc -eq 0 ]]; then
echo "$surface auth green — safe to start automated testing on this surface."
echo "all green — safe to start automated testing."
else
echo "$surface auth NOT ready — fix the ✘ items before writing any test step."
echo "auth NOT ready — fix the ✘ items before writing any test step."
fi
return $rc
}
@@ -284,148 +105,23 @@ cmd_cli() {
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server "$SERVER_URL"
}
write_cli_seed_env() {
mkdir -p "$(dirname "$CLI_ENV_FILE")"
cat > "$CLI_ENV_FILE" << EOF
# Source this file before running LobeHub CLI agent tests.
# Generated by setup-auth.sh cli-seed
export LOBE_API_KEY=$SEED_API_KEY
export LOBEHUB_CLI_API_KEY="\${LOBE_API_KEY}"
export LOBEHUB_SERVER=$SERVER_URL
export LOBEHUB_CLI_HOME=.lobehub-dev
EOF
}
write_cli_settings() {
mkdir -p "$CLI_HOME"
python3 - "$CLI_HOME/settings.json" "$SERVER_URL" << 'PY'
import json
import os
import sys
path, server_url = sys.argv[1], sys.argv[2]
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
json.dump({"serverUrl": server_url}, f, indent=2)
f.write("\n")
os.chmod(path, 0o600)
PY
}
cmd_cli_seed() {
check_server || return 1
write_cli_seed_env
write_cli_settings
ok "wrote CLI seed env: $CLI_ENV_FILE"
note "source it before CLI commands: source $CLI_ENV_FILE"
note "settings saved at: $CLI_HOME/settings.json"
LOBE_API_KEY="$SEED_API_KEY" LOBEHUB_CLI_API_KEY="$SEED_API_KEY" check_cli
}
cmd_open_chrome() {
local mode="${1:-}"
if [[ "$mode" != "" && "$mode" != "--dry-run" ]]; then
echo "unknown open-chrome option: $mode" >&2
usage >&2
return 2
fi
if [[ "$mode" == "--dry-run" ]]; then
echo "would open Google Chrome at $SERVER_URL/"
echo "would press Cmd+Option+I to open DevTools"
echo "would open DevTools command menu and run 'Show Network'"
return 0
fi
if [[ "$(uname -s)" != "Darwin" ]]; then
bad "open-chrome is macOS-only"
note "open $SERVER_URL/ in your browser and open DevTools manually"
return 1
fi
if ! command -v osascript > /dev/null 2>&1; then
bad "osascript not found"
note "open $SERVER_URL/ in Chrome and press Cmd+Option+I manually"
return 1
fi
SERVER_URL="$SERVER_URL" osascript << 'OSA'
set targetUrl to (system attribute "SERVER_URL") & "/"
tell application "Google Chrome"
activate
if (count of windows) = 0 then
make new window
end if
tell front window to make new tab with properties {URL:targetUrl}
end tell
delay 1
tell application "System Events"
tell process "Google Chrome"
set frontmost to true
keystroke "i" using {command down, option down}
delay 1
keystroke "p" using {command down, shift down}
delay 0.2
keystroke "Show Network"
key code 36
end tell
end tell
OSA
ok "opened Chrome at $SERVER_URL/ and requested DevTools Network panel"
}
cookie_header_from_jar() {
local jar="$1"
awk '
BEGIN { first = 1 }
/^$/ { next }
/^#/ {
if ($0 !~ /^#HttpOnly_/) next
sub(/^#HttpOnly_/, "")
}
NF >= 7 {
if (!first) printf "; "
printf "%s=%s", $6, $7
first = 0
}
END {
if (!first) printf "\n"
}
' "$jar"
}
# Build a Playwright storageState file from a raw Cookie header on stdin,
# keeping only the better-auth cookies. See references/auth.md for why the
# header must come from a Network request (HttpOnly) and why httpOnly=false.
cmd_web() {
mkdir -p "$AUTH_DIR"
local raw
raw="$(cat)"
COOKIE_INPUT="$raw" python3 - "$STATE_FILE" << 'PY'
import json, os, sys, time
python3 - "$STATE_FILE" << 'PY'
import json, sys, time
raw = os.environ.get("COOKIE_INPUT", "").strip()
cookie_lines = []
for line in raw.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.lower().startswith("cookie:"):
cookie_lines.append(stripped.split(":", 1)[1].strip())
else:
cookie_lines.append(stripped)
raw = sys.stdin.read().strip()
if raw.lower().startswith("cookie:"):
raw = raw.split(":", 1)[1].strip()
raw = "; ".join(cookie_lines)
WANTED = {"better-auth.session_token", "better-auth.session_data", "better-auth.state"}
WANTED = {"better-auth.session_token", "better-auth.state"}
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
cookies = []
for pair in raw.split(";"):
pair = pair.strip()
for pair in raw.split("; "):
if "=" not in pair:
continue
name, _, value = pair.partition("=")
@@ -450,79 +146,14 @@ with open(sys.argv[1], "w") as f:
json.dump({"cookies": cookies, "origins": []}, f, indent=2)
print(f"wrote {len(cookies)} cookie(s) to {sys.argv[1]}")
PY
agent-browser --session "$SESSION" state load "$STATE_FILE"
cmd_web_verify
}
cmd_web_seed() {
check_server || return 1
mkdir -p "$AUTH_DIR"
local cookie_jar="$AUTH_DIR/web-seed-cookie.jar"
local response_body="$AUTH_DIR/web-seed-response.json"
local payload code
payload="$(
SEED_EMAIL="$SEED_EMAIL" SEED_PASSWORD="$SEED_PASSWORD" python3 - << 'PY'
import json
import os
print(json.dumps({
"callbackURL": "/",
"email": os.environ["SEED_EMAIL"],
"password": os.environ["SEED_PASSWORD"],
}))
PY
)"
code=$(curl -sS -o "$response_body" -w '%{http_code}' \
-c "$cookie_jar" \
-H 'Content-Type: application/json' \
-X POST "$SERVER_URL/api/auth/sign-in/email" \
--data "$payload" 2> /dev/null || true)
if [[ ! "$code" =~ ^[23] ]]; then
bad "seed user sign-in failed at $SERVER_URL/api/auth/sign-in/email (http_code='$code')"
note "make sure the seed user exists:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
return 1
fi
local cookie_header
cookie_header="$(cookie_header_from_jar "$cookie_jar")"
if [[ -z "$cookie_header" ]]; then
bad "seed sign-in succeeded but no cookies were written to $cookie_jar"
return 1
fi
printf '%s\n' "$cookie_header" | cmd_web
}
cmd_web_verify() {
local skip_server_check="${1:-}"
if [[ "$skip_server_check" != "--skip-server-check" ]]; then
check_server || return 1
fi
if [[ ! -f "$STATE_FILE" ]]; then
bad "no web auth state for agent-browser"
note "for the seeded local user, run: $0 web-seed"
note "or copy the Cookie header from Chrome DevTools (Network tab), then:"
note "pbpaste | $0 web"
return 1
fi
check_agent_browser || return 1
if ! agent-browser --session "$SESSION" state load "$STATE_FILE" > /dev/null; then
bad "failed to load web auth state into agent-browser session '$SESSION'"
return 1
fi
if ! agent-browser --session "$SESSION" open "$SERVER_URL/" > /dev/null; then
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
return 1
fi
agent-browser --session "$SESSION" open "$SERVER_URL/" > /dev/null
local url
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
if [[ -z "$url" ]]; then
bad "agent-browser session '$SESSION' did not report a current URL"
return 1
fi
url=$(agent-browser --session "$SESSION" get url)
if [[ "$url" == *"/signin"* || "$url" == *"/login"* ]]; then
bad "agent-browser session '$SESSION' NOT authenticated (landed on $url)"
note "re-copy the Cookie header and re-run: pbpaste | $0 web"
@@ -532,22 +163,12 @@ cmd_web_verify() {
}
case "${1:-status}" in
status)
shift || true
cmd_status "$@"
;;
cli-seed) cmd_cli_seed ;;
status) cmd_status ;;
cli) cmd_cli ;;
open-chrome)
shift || true
cmd_open_chrome "$@"
;;
web-seed) cmd_web_seed ;;
web) cmd_web ;;
web-verify) cmd_web_verify ;;
-h|--help) usage ;;
*)
echo "Usage: $0 {status|cli-seed|cli|open-chrome|web-seed|web|web-verify}" >&2
echo "Usage: $0 {status|cli|web|web-verify}" >&2
exit 2
;;
esac
@@ -1,197 +0,0 @@
#!/usr/bin/env bash
# Smoke tests for setup-auth.sh. Uses a temporary agent-browser stub and local
# HTTP server, so it does not need real browser auth.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$SCRIPT_DIR/setup-auth.sh"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_contains() {
local file="$1"
local text="$2"
grep -Fq "$text" "$file" || fail "expected '$text' in $file"
}
tmp_dir="$(mktemp -d)"
server_pid=""
cleanup() {
if [[ -n "$server_pid" ]]; then
kill "$server_pid" > /dev/null 2>&1 || true
wait "$server_pid" > /dev/null 2>&1 || true
fi
rm -rf "$tmp_dir"
}
trap cleanup EXIT
export HOME="$tmp_dir/home"
port="$(python3 - << 'PY'
import socket
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
print(sock.getsockname()[1])
sock.close()
PY
)"
python3 - "$port" << 'PY' > "$tmp_dir/http.log" 2>&1 &
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import sys
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/api/v1/users/me"):
if self.headers.get("authorization") != "Bearer sk-lh-agenttesting0001":
self.send_response(401)
self.end_headers()
self.wfile.write(b'{"success":false}')
return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"success":true,"data":{"id":"user_agent_testing_001"}}')
return
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
def do_POST(self):
length = int(self.headers.get("content-length") or "0")
if length:
self.rfile.read(length)
if self.path != "/api/auth/sign-in/email":
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header(
"Set-Cookie",
"better-auth.session_token=seed.token; Path=/; HttpOnly; SameSite=Lax",
)
self.send_header(
"Set-Cookie",
"better-auth.session_data=seed.data; Path=/; HttpOnly; SameSite=Lax",
)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"ok":true}')
def log_message(self, format, *args):
return
ThreadingHTTPServer(("localhost", int(sys.argv[1])), Handler).serve_forever()
PY
server_pid="$!"
server_url="http://localhost:$port"
for _ in {1..50}; do
if curl -s -o /dev/null "$server_url/"; then
break
fi
sleep 0.1
done
curl -s -o /dev/null "$server_url/" || fail "test HTTP server did not start"
mkdir -p "$tmp_dir/bin" "$tmp_dir/auth"
cat > "$tmp_dir/bin/agent-browser" << 'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "--session" ]]; then
shift 2
fi
case "${1:-}" in
state)
[[ "${2:-}" == "load" ]] || exit 2
[[ -f "${3:-}" ]] || exit 1
;;
open)
printf '%s\n' "${2:-}" > "${AGENT_BROWSER_URL_FILE:?}"
;;
get)
[[ "${2:-}" == "url" ]] || exit 2
cat "${AGENT_BROWSER_URL_FILE:?}"
;;
*)
echo "unexpected agent-browser command: $*" >&2
exit 2
;;
esac
SH
chmod +x "$tmp_dir/bin/agent-browser"
export PATH="$tmp_dir/bin:$PATH"
export AUTH_DIR="$tmp_dir/auth"
export SESSION="setup-auth-test"
export SERVER_URL="$server_url"
export AGENT_BROWSER_URL_FILE="$tmp_dir/current-url"
cookie_header="Cookie: foo=bar; better-auth.session_token=test.token; better-auth.session_data=encoded%3D; theme=dark"
printf '%s\n' "$cookie_header" | "$SCRIPT" web > "$tmp_dir/web.out"
python3 - "$AUTH_DIR/web-state.json" << 'PY'
import json, sys
with open(sys.argv[1]) as f:
state = json.load(f)
names = {cookie["name"] for cookie in state["cookies"]}
expected = {"better-auth.session_token", "better-auth.session_data"}
if names != expected:
raise SystemExit(f"unexpected cookies: {sorted(names)}")
PY
"$SCRIPT" web-seed > "$tmp_dir/web-seed.out"
python3 - "$AUTH_DIR/web-state.json" << 'PY'
import json, sys
with open(sys.argv[1]) as f:
state = json.load(f)
values = {cookie["name"]: cookie["value"] for cookie in state["cookies"]}
expected = {
"better-auth.session_token": "seed.token",
"better-auth.session_data": "seed.data",
}
if values != expected:
raise SystemExit(f"unexpected seeded cookies: {values}")
PY
"$SCRIPT" status --surface web > "$tmp_dir/status.out"
assert_contains "$tmp_dir/status.out" "surface=web"
assert_contains "$tmp_dir/status.out" "web auth green"
"$SCRIPT" cli-seed > "$tmp_dir/cli-seed.out"
assert_contains "$tmp_dir/cli-seed.out" "CLI API-key auth valid"
assert_contains "$tmp_dir/cli-seed.out" "settings saved at: $HOME/.lobehub-dev/settings.json"
if "$SCRIPT" status --surface cli > "$tmp_dir/cli-no-env.out"; then
fail "cli status without API key unexpectedly passed"
fi
assert_contains "$tmp_dir/cli-no-env.out" "CLI not logged in"
LOBEHUB_CLI_API_KEY=sk-lh-agenttesting0001 "$SCRIPT" status --surface cli > "$tmp_dir/cli-status.out"
assert_contains "$tmp_dir/cli-status.out" "CLI API-key auth valid"
assert_contains "$tmp_dir/cli-status.out" "cli auth green"
if printf 'foo=bar\n' | "$SCRIPT" web > "$tmp_dir/invalid.out" 2> "$tmp_dir/invalid.err"; then
fail "invalid cookie unexpectedly passed"
fi
assert_contains "$tmp_dir/invalid.err" "no better-auth cookies found"
echo "setup-auth tests passed"
@@ -1,377 +0,0 @@
#!/usr/bin/env bash
# Print the resolved local test environment for agent-testing.
#
# This is intentionally read-only. It mirrors scripts/runWithEnv.mts precedence:
# .env -> .env.$NODE_ENV -> .env.local -> .env.$NODE_ENV.local, then shell env.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
NODE_ENV="${NODE_ENV:-development}"
VALUE_APP_URL=""
VALUE_PORT=""
VALUE_SERVER_URL=""
VALUE_AUTH_TRUSTED_ORIGINS=""
VALUE_SPA_PORT=""
VALUE_MOBILE_SPA_PORT=""
VALUE_DESKTOP_PORT=""
SOURCE_APP_URL=""
SOURCE_PORT=""
SOURCE_SERVER_URL=""
SOURCE_AUTH_TRUSTED_ORIGINS=""
SOURCE_SPA_PORT=""
SOURCE_MOBILE_SPA_PORT=""
SOURCE_DESKTOP_PORT=""
LOADED_ENV_FILES=""
keys() {
printf '%s\n' \
APP_URL \
PORT \
SERVER_URL \
AUTH_TRUSTED_ORIGINS \
SPA_PORT \
MOBILE_SPA_PORT \
DESKTOP_PORT
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
workspace_root() {
local root="$REPO_ROOT"
local name
name="$(basename "$root")"
if [[ "$name" == "lobehub" ]]; then
local parent parent_name
parent="$(cd "$root/.." && pwd)"
parent_name="$(basename "$parent")"
if [[ "$parent_name" == lobehub-cloud* ]]; then
root="$parent"
fi
fi
printf '%s\n' "$root"
}
workspace_offset() {
local name="$1"
case "$name" in
lobehub-cloud)
printf '0\n'
;;
lobehub-cloud-*)
local suffix="${name#lobehub-cloud-}"
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$((10#$suffix))"
else
printf '\n'
fi
;;
*)
printf '\n'
;;
esac
}
default_port() {
local base="$1"
local fallback="$2"
local root name offset
root="$(workspace_root)"
name="$(basename "$root")"
offset="$(workspace_offset "$name")"
if [[ -n "$offset" ]]; then
printf '%s\n' "$((base + offset))"
else
printf '%s\n' "$fallback"
fi
}
url_port() {
local url="$1"
local hostport
hostport="${url#*://}"
hostport="${hostport%%/*}"
if [[ "$hostport" == *:* ]]; then
local port="${hostport##*:}"
if [[ "$port" =~ ^[0-9]+$ ]]; then
printf '%s\n' "$port"
return 0
fi
fi
return 1
}
url_origin() {
local url="$1"
local scheme rest hostport
if [[ "$url" == *"://"* ]]; then
scheme="${url%%://*}"
rest="${url#*://}"
hostport="${rest%%/*}"
printf '%s://%s\n' "$scheme" "$hostport"
else
printf '%s\n' "$url"
fi
}
set_value() {
local key="$1"
local value="$2"
local source="$3"
case "$key" in
APP_URL) VALUE_APP_URL="$value"; SOURCE_APP_URL="$source" ;;
PORT) VALUE_PORT="$value"; SOURCE_PORT="$source" ;;
SERVER_URL) VALUE_SERVER_URL="$value"; SOURCE_SERVER_URL="$source" ;;
AUTH_TRUSTED_ORIGINS) VALUE_AUTH_TRUSTED_ORIGINS="$value"; SOURCE_AUTH_TRUSTED_ORIGINS="$source" ;;
SPA_PORT) VALUE_SPA_PORT="$value"; SOURCE_SPA_PORT="$source" ;;
MOBILE_SPA_PORT) VALUE_MOBILE_SPA_PORT="$value"; SOURCE_MOBILE_SPA_PORT="$source" ;;
DESKTOP_PORT) VALUE_DESKTOP_PORT="$value"; SOURCE_DESKTOP_PORT="$source" ;;
esac
}
value_for() {
case "$1" in
APP_URL) printf '%s\n' "$VALUE_APP_URL" ;;
PORT) printf '%s\n' "$VALUE_PORT" ;;
SERVER_URL) printf '%s\n' "$VALUE_SERVER_URL" ;;
AUTH_TRUSTED_ORIGINS) printf '%s\n' "$VALUE_AUTH_TRUSTED_ORIGINS" ;;
SPA_PORT) printf '%s\n' "$VALUE_SPA_PORT" ;;
MOBILE_SPA_PORT) printf '%s\n' "$VALUE_MOBILE_SPA_PORT" ;;
DESKTOP_PORT) printf '%s\n' "$VALUE_DESKTOP_PORT" ;;
esac
}
source_for() {
case "$1" in
APP_URL) printf '%s\n' "$SOURCE_APP_URL" ;;
PORT) printf '%s\n' "$SOURCE_PORT" ;;
SERVER_URL) printf '%s\n' "$SOURCE_SERVER_URL" ;;
AUTH_TRUSTED_ORIGINS) printf '%s\n' "$SOURCE_AUTH_TRUSTED_ORIGINS" ;;
SPA_PORT) printf '%s\n' "$SOURCE_SPA_PORT" ;;
MOBILE_SPA_PORT) printf '%s\n' "$SOURCE_MOBILE_SPA_PORT" ;;
DESKTOP_PORT) printf '%s\n' "$SOURCE_DESKTOP_PORT" ;;
esac
}
is_tracked_key() {
case "$1" in
APP_URL|PORT|SERVER_URL|AUTH_TRUSTED_ORIGINS|SPA_PORT|MOBILE_SPA_PORT|DESKTOP_PORT) return 0 ;;
*) return 1 ;;
esac
}
parse_env_file() {
local file="$1"
local root="$2"
local label="${file#$root/}"
local line key value
[[ -f "$file" ]] || return 0
if [[ -z "$LOADED_ENV_FILES" ]]; then
LOADED_ENV_FILES="$label"
else
LOADED_ENV_FILES="$LOADED_ENV_FILES, $label"
fi
while IFS= read -r line || [[ -n "$line" ]]; do
line="$(trim "$line")"
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ "$line" == export[[:space:]]* ]]; then
line="$(trim "${line#export}")"
fi
[[ "$line" == *=* ]] || continue
key="$(trim "${line%%=*}")"
value="$(trim "${line#*=}")"
is_tracked_key "$key" || continue
if [[ "$value" == \"*\" && "$value" == *\" && ${#value} -ge 2 ]]; then
value="${value:1:${#value}-2}"
elif [[ "$value" == \'* && "$value" == *\' && ${#value} -ge 2 ]]; then
value="${value:1:${#value}-2}"
fi
set_value "$key" "$value" "$label"
done < "$file"
}
apply_env_files() {
local root="$1"
parse_env_file "$root/.env" "$root"
parse_env_file "$root/.env.$NODE_ENV" "$root"
parse_env_file "$root/.env.local" "$root"
parse_env_file "$root/.env.$NODE_ENV.local" "$root"
}
apply_shell_overrides() {
local key value
while IFS= read -r key; do
if [[ -n "${!key+x}" ]]; then
value="${!key}"
set_value "$key" "$value" "shell"
fi
done < <(keys)
}
resolve_defaults() {
local app_port spa_port mobile_spa_port desktop_port
app_port="$(default_port 3020 3010)"
spa_port="$(default_port 9800 9876)"
mobile_spa_port="$(default_port 3810 3012)"
desktop_port="$(default_port 3030 3015)"
if [[ -z "$VALUE_APP_URL" ]]; then
set_value APP_URL "http://localhost:$app_port" "inferred"
fi
if [[ -z "$VALUE_PORT" ]]; then
if app_port="$(url_port "$VALUE_APP_URL")"; then
set_value PORT "$app_port" "inferred from APP_URL"
else
set_value PORT "$(default_port 3020 3010)" "inferred"
fi
fi
if [[ -z "$VALUE_SERVER_URL" ]]; then
set_value SERVER_URL "$VALUE_APP_URL" "from APP_URL"
fi
if [[ -z "$VALUE_SPA_PORT" ]]; then
set_value SPA_PORT "$spa_port" "inferred"
fi
if [[ -z "$VALUE_MOBILE_SPA_PORT" ]]; then
set_value MOBILE_SPA_PORT "$mobile_spa_port" "inferred"
fi
if [[ -z "$VALUE_DESKTOP_PORT" ]]; then
set_value DESKTOP_PORT "$desktop_port" "inferred"
fi
if [[ -z "$VALUE_AUTH_TRUSTED_ORIGINS" ]]; then
set_value AUTH_TRUSTED_ORIGINS "$(url_origin "$VALUE_APP_URL"),http://localhost:$VALUE_SPA_PORT" "inferred"
fi
}
contains_origin() {
local list="$1"
local expected="$2"
local item
IFS=',' read -r -a items <<< "$list"
for item in "${items[@]}"; do
item="$(trim "$item")"
[[ "$item" == "$expected" ]] && return 0
done
return 1
}
print_exports() {
local key value
while IFS= read -r key; do
value="$(value_for "$key")"
printf 'export %s=%q\n' "$key" "$value"
done < <(keys)
}
print_value() {
local key="$1"
if ! is_tracked_key "$key"; then
echo "unknown key: $key" >&2
exit 2
fi
value_for "$key"
}
print_human() {
local root="$1"
local key value source
echo "agent-testing test env:"
printf ' workspace: %s\n' "$root"
printf ' NODE_ENV: %s\n' "$NODE_ENV"
printf ' env files: %s\n' "${LOADED_ENV_FILES:-none}"
echo
echo "resolved values:"
while IFS= read -r key; do
value="$(value_for "$key")"
source="$(source_for "$key")"
printf ' %-22s %s (%s)\n' "$key=$value" "" "$source"
done < <(keys)
echo
echo "checks:"
local app_origin spa_origin app_port
app_origin="$(url_origin "$VALUE_APP_URL")"
spa_origin="http://localhost:$VALUE_SPA_PORT"
if app_port="$(url_port "$VALUE_APP_URL")" && [[ "$app_port" == "$VALUE_PORT" ]]; then
printf ' OK PORT matches APP_URL (%s)\n' "$VALUE_PORT"
else
printf ' WARN PORT (%s) does not match APP_URL (%s)\n' "$VALUE_PORT" "$VALUE_APP_URL"
fi
if contains_origin "$VALUE_AUTH_TRUSTED_ORIGINS" "$app_origin"; then
printf ' OK AUTH_TRUSTED_ORIGINS includes %s\n' "$app_origin"
else
printf ' WARN AUTH_TRUSTED_ORIGINS is missing %s\n' "$app_origin"
fi
if contains_origin "$VALUE_AUTH_TRUSTED_ORIGINS" "$spa_origin"; then
printf ' OK AUTH_TRUSTED_ORIGINS includes %s\n' "$spa_origin"
else
printf ' WARN AUTH_TRUSTED_ORIGINS is missing %s\n' "$spa_origin"
fi
}
usage() {
cat << EOF
Usage:
$0 # print resolved test environment
$0 --exports # print source-able export lines
$0 --value KEY # print one resolved value
Tracked keys:
APP_URL PORT SERVER_URL AUTH_TRUSTED_ORIGINS SPA_PORT MOBILE_SPA_PORT DESKTOP_PORT
EOF
}
ROOT="$(workspace_root)"
apply_env_files "$ROOT"
apply_shell_overrides
resolve_defaults
case "${1:-}" in
"")
print_human "$ROOT"
;;
--exports)
print_exports
;;
--value)
print_value "${2:-}"
;;
-h|--help)
usage
;;
*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
esac
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# Smoke tests for test-env.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
fail() {
echo "FAIL: $*" >&2
exit 1
}
assert_eq() {
local actual="$1"
local expected="$2"
[[ "$actual" == "$expected" ]] || fail "expected '$expected', got '$actual'"
}
assert_contains() {
local file="$1"
local text="$2"
grep -Fq "$text" "$file" || fail "expected '$text' in $file"
}
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
mkdir -p "$tmp_dir/lobehub-cloud-1/.agents/skills" "$tmp_dir/lobehub/.agents/skills"
ln -s "$SCRIPT_DIR/.." "$tmp_dir/lobehub-cloud-1/.agents/skills/agent-testing"
ln -s "$SCRIPT_DIR/.." "$tmp_dir/lobehub/.agents/skills/agent-testing"
cloud_script="$tmp_dir/lobehub-cloud-1/.agents/skills/agent-testing/scripts/test-env.sh"
oss_script="$tmp_dir/lobehub/.agents/skills/agent-testing/scripts/test-env.sh"
assert_eq "$("$cloud_script" --value SERVER_URL)" "http://localhost:3021"
assert_eq "$("$cloud_script" --value SPA_PORT)" "9801"
assert_eq "$("$cloud_script" --value MOBILE_SPA_PORT)" "3811"
assert_eq "$("$cloud_script" --value DESKTOP_PORT)" "3031"
assert_eq "$("$oss_script" --value SERVER_URL)" "http://localhost:3010"
cat > "$tmp_dir/lobehub-cloud-1/.env" << 'EOF'
APP_URL=http://localhost:4123
PORT=4123
AUTH_TRUSTED_ORIGINS=http://localhost:4123,http://localhost:9823
SPA_PORT=9823
MOBILE_SPA_PORT=3823
DESKTOP_PORT=3043
EOF
assert_eq "$("$cloud_script" --value SERVER_URL)" "http://localhost:4123"
assert_eq "$("$cloud_script" --value SPA_PORT)" "9823"
"$cloud_script" --exports > "$tmp_dir/exports.out"
assert_contains "$tmp_dir/exports.out" "export APP_URL=http://localhost:4123"
assert_contains "$tmp_dir/exports.out" "export SERVER_URL=http://localhost:4123"
assert_contains "$tmp_dir/exports.out" "export AUTH_TRUSTED_ORIGINS=http://localhost:4123\\,http://localhost:9823"
echo "test-env tests passed"
+4 -13
View File
@@ -10,32 +10,23 @@ backend-only changes prefer [../cli/index.md](../cli/index.md).
## Prerequisites
- Complete [Step 0.0](../SKILL.md#00-resolve-the-current-test-environment) (resolve ports) and [Step -1](../SKILL.md#step--1--plan-approval-for-non-trivial-tests) (plan approval) first.
- Local dev server running — [../references/dev-server.md](../references/dev-server.md)
- Web auth verified in agent-browser — prefer `setup-auth.sh web-seed`, see [auth decision flow](../references/auth.md#web--decision-flow).
## Option A — agent-browser with seeded auth (recommended)
- Web auth injected into agent-browser — [../references/auth.md](../references/auth.md):
```bash
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/setup-auth.sh web-seed
pbpaste | ./.agents/skills/agent-testing/scripts/setup-auth.sh web # after copying the Cookie header
```
Then drive the verified session:
## Option A — agent-browser with injected auth (recommended)
```bash
SESSION=lobehub-dev
agent-browser --session $SESSION open "$SERVER_URL/"
agent-browser --session $SESSION open "http://localhost:3010/"
agent-browser --session $SESSION snapshot -i
# interact via refs — full command reference: ../references/agent-browser.md
```
Use this session as the evidence source. Do not use ordinary Chrome screenshots
or Chrome Network records as proof for Web tests; ordinary Chrome is only a
fallback source for copying cookies into agent-browser when the seeded login is
not available.
### Watch the API while driving the UI
```bash
-7
View File
@@ -53,12 +53,6 @@ For Modal specifically, see the dedicated **modal** skill — use the imperative
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
| Navigation | Burger, Menu, SideNav, Tabs |
## Loading indicators
**Do NOT use antd `Spin` / `<Spin />`.** Use a project loader
(`NeuralNetworkLoading`, `DotsLoading`, …) — see the **ux** skill ("Loading
visuals") for the component table and when to use each.
## State
When a feature component manages more than 3 pieces of state (`useState`/`useReducer`/derived state), extract the logic into a custom hook (e.g. `useXxx`). Keep the component focused on rendering — the hook holds state and handlers, so logic can be unit-tested without rendering the component.
@@ -118,7 +112,6 @@ errorElement: <ErrorBoundary />;
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
| antd `Spin` / `<Spin />` for loading | Use `NeuralNetworkLoading` / project loaders (see the **ux** skill) |
| `import { Select } from '@lobehub/ui'` | `import { Select } from '@lobehub/ui/base-ui'` |
| `import { Modal } from '@lobehub/ui'` + `<Modal open>` declarative | `createModal` / `confirmModal` from `@lobehub/ui/base-ui` (see modal skill) |
| `import { DropdownMenu/Popover/Switch } from '@lobehub/ui'` | Import same name from `@lobehub/ui/base-ui` instead |
-3
View File
@@ -43,9 +43,6 @@ cd packages/database && TEST_SERVER_DB=1 bunx vitest run --silent='passed-only'
2. **Tests must pass type check** - Run `bun run type-check` after writing tests
3. **After 1-2 failed fix attempts, stop and ask for help**
4. **Test behavior, not implementation details**
5. **Regression tests for bug fixes** - After fixing a bug, add a regression test that fails before the fix and passes after, to prevent recurrence
6. **No new component tests** - Only update existing React component tests. Complex logic should be extracted into hooks and tested there instead
7. **All source changes before any test changes** - Complete all source file edits first, then update tests in a separate pass. Interleaving disrupts reasoning about the source changes, especially across many files
## Basic Test Structure
-283
View File
@@ -1,283 +0,0 @@
---
name: ux
description: 'LobeHub product design values / principles / checklists. Load this skill whenever the work touches user-interface features or implementation — designing or building any user-facing flow — to get better UX results.'
user-invocable: false
---
# UX — Design Values & Execution Checklists
How LobeHub products should feel, and concrete rules to get there. Use this when
**building or reviewing** any user-facing flow. For component/styling choices see
**react**, for wording see **microcopy**, for imperative modal wiring see **modal**.
## Design values
LobeHub follows four product design values — **Natural・Meaningful・Certainty・
Growth**. Read them before designing:
**[references/design-values.md](references/design-values.md)** (definitions +
conflict priority).
> The checklists below are the execution layer. Each item is tagged with the
> value(s) it serves; for what those values mean, see the file above.
## How this is organized
The checklists are grouped by **interaction type** — the kind of thing the user
is doing. Jump to the module that matches the surface you're building (reading a
list, editing content, running an action, …); each module collects the rules
specific to that interaction. The same surface often spans several modules (an
editable list is Read + Edit + Act) — walk each that applies.
---
## 1. Read — viewing data & lists
Any surface that **displays** records, lists, or detail. Covers the states a data
view can be in, behavior at scale, and keeping the user's place visible.
### 1.1 Data states: empty / loading / error・Meaningful・Certainty
Every data surface has **four** states — design all of them, not just "has data".
- [ ] **Empty state is a purpose-built page, not a blank screen.** It explains what
this is, why it's empty, and gives a clear next action (CTA + value props).
✅ Devices: an empty "Connect your first device" page with primary/secondary
connect paths and "what you can do once connected" cards — ❌ not a bare title
over skeleton rows or a blank body. _(Meaningful)_
- [ ] **Distinguish the empty variants** — "no data yet" (onboarding CTA) vs
"no match for filters" (clear-filters affordance) are different screens. _(Certainty)_
- [ ] **Loading state** designed (skeleton / NeuralNetworkLoading), not a flash of
blank or layout shift. _(Natural)_
- [ ] **Error state** designed — surface the reason and a retry/back path. _(Meaningful)_
### 1.2 Lists at scale・Certainty・Natural
A list/data page must be designed for its **whole range of sizes**, not just the
demo data.
- [ ] **Walk the scale: 1 / 2 / 5 / 20 / 100 / 1k10k rows.** Pick the right
mechanism per range — plain render → load-more / pagination → virtual scroll;
add batch-select / bulk actions once counts get large. _(Certainty)_
- [ ] **Co-design empty / loading / error with the data state** (see §1.1). A list
isn't done until all four render well. _(Natural)_
### 1.3 Selection visibility in scrolled lists・Certainty・Natural
A capped / scrollable / virtualized list mounts at `scrollTop = 0`. If the
active item sits below the fold, the user lands on a valid selection that is
**off-screen** — and reads it as "nothing is selected" or a broken page. Any
list that can open with a pre-selected item must **scroll that item into view**.
This is an easy case to miss: it only shows up once the list is long enough and
the selection is restored rather than freshly clicked.
- [ ] **Scroll the active item into view on mount / restore.** When the selection
is restored from a URL query, deep link, or persisted state (not a fresh
click), bring it into view — the container starts at the top otherwise. ✅
The nested thread list is capped to \~9 rows; a thread restored from
`?thread=` below the fold is scrolled into view on mount. _(Certainty)_
- [ ] **Hardest when the selection has no other anchor.** If the parent/container
row isn't highlighted while a child is active (no breadcrumb, no header
echo), an off-screen active row means **zero** visible feedback — design
for exactly this case. _(Meaningful)_
- [ ] **Use `block: 'nearest'` (or equivalent).** Only scroll when the row is
actually off-screen; an already-visible selection must not jump. _(Natural)_
- [ ] **Re-run once async rows mount.** The active id is usually known before the
list finishes loading; key the scroll off a list-ready signal (e.g. row
count), not only off the id, so a restored selection still lands when the
data arrives. _(Certainty)_
- [ ] **Mirror it across duplicated list variants** so the behavior can't regress
in just one (e.g. parallel agent / group lists). _(Certainty)_
### 1.4 Option visibility in pickers・Certainty・Meaningful
- [ ] **Pickers list every valid target.** Watch for options dropped by backend
list queries (pagination, `virtual` flags, scope filters) and add them back.
✅ The default "LobeAI" (inbox) agent is `virtual` and excluded from the
sidebar list, so the move picker re-adds it. An empty picker must mean
"genuinely none", never "we filtered out the only option". _(Meaningful)_
---
## 2. Edit — entering & changing content
Any surface where the user **types or edits**. Input is expensive effort; the
overriding rule is **never lose it**.
### 2.1 Protect in-progress edits・Certainty・Meaningful
Typed / edited content is real user effort; losing it is one of the most
infuriating outcomes a product can produce. Whenever an editor holds unsaved
input, assume the exit can be **accidental** — a misclick, a refresh, a crash, a
navigation, a failed save — and build a safety net: back the draft up locally and
recover it.
- [ ] **Back up the draft locally as the user types.** Persist to
localStorage / IndexedDB / store so a refresh, crash, accidental close, or
navigation doesn't vaporize the content. _(Certainty)_
- [ ] **Restore on return.** Coming back to the same editing context auto-restores
(or offers to restore) the unsaved draft, rather than showing a blank field. _(Meaningful)_
- [ ] **Guard destructive exits.** Closing / navigating / switching items away
from a dirty editor warns or auto-saves — never silently discards. _(Certainty)_
- [ ] **Survive a failed save.** If the save errors, keep the user's content in
the field / draft and let them retry; never clear the input on failure. _(Meaningful)_
- [ ] **Scope the draft to its target** (per topic / message / item id) so drafts
don't bleed across entities or resurrect on the wrong item. _(Certainty)_
---
## 3. Act — operations, flows & buttons
Any surface where the user **performs an action** — a single op, a bulk op, or a
multi-step flow. Covers momentum, focus, and full entity lifecycle.
### 3.1 Flow & momentum・Natural・Meaningful
Every action chain must **push the user forward**, never dead-end or block the flow.
- [ ] **Forward momentum** — after any operation, lead the user to the next step,
don't just stop. _(Meaningful)_
- [ ] **Success state = primary "go to result", secondary "dismiss"** — the strong
button is the forward action (take me to the result); "Done" is the weak/
secondary button. ✅ After moving topics: primary = "Go to «target»", secondary
\= "Done". _(Meaningful・Natural)_
- [ ] **Bulk ⇄ single-item parity** — an action on a multi-select toolbar must also
be reachable on a single item (its context menu), and vice versa. _(Certainty)_
- [ ] **Confirm → in-progress → done, in one surface** — bulk/irreversible/async
ops use a modal state machine: a confirm step stating exactly what happens →
an in-progress view with **dismissal locked** → a done (or error) view in the
same modal. Never fire-and-forget with only a toast; never leave a dead
spinner. _(Certainty・Meaningful)_
### 3.2 One primary button per surface・Certainty
- [ ] **One primary button per surface.** The single primary CTA tells the user the
core action; everything else is secondary/tertiary. Never a pile of primary
buttons competing for attention. _(Certainty)_
### 3.3 Entity lifecycle completeness・Meaningful・Certainty
The recurring trap: a feature ships only the **display** of a list, but edit /
delete / management are never built — so the user can add something and then be
stuck with it. For every entity a user can see, design its **full lifecycle**:
create / read / update / delete, plus state transitions (enable/disable,
connect/disconnect, install/uninstall). A read-only list the user can't manage
breaks the flow.
**The allowed operation set depends on the entity's source / ownership** — decide
it explicitly _before_ building. Worked example, the tools/connectors list:
| Entity class | Add | Edit | Remove |
| ----------------------------------- | ------- | --------- | ------------------ |
| Official / built-in (skills, tools) | — | — | ✗ not removable |
| Community (installed MCP) | install | configure | uninstall / remove |
| User-custom (custom connector) | create | edit | delete |
- [ ] **No display-only features.** For every listed entity, enumerate CRUD +
lifecycle ops and build the ones that apply. _(Meaningful)_
- [ ] **Operation set per source/ownership class** — built-in may be read-only;
anything the user _installed_ must be removable; anything the user _created_
must be editable **and** deletable. _(Certainty)_
- [ ] **Each item exposes its allowed ops** (hover action / context menu / detail
page), and there's a clear entry point to add/create where applicable. _(Natural)_
- [ ] **An intentionally-absent op is a documented decision, not an oversight**
(e.g. official tools can't be deleted — by design). _(Certainty)_
---
## 4. Feedback — loading & system response
How the product **answers back** while and after the user acts — loading visuals
and proactive guardrails.
### 4.1 Loading visuals・Natural
**Never use antd `Spin`** — it doesn't match the product's loading visual. Use a
project loader:
| Need | Component |
| --------------------------- | ----------------------------------------------------------------------------- |
| Default loading (in-flight) | `NeuralNetworkLoading` from `@/components/NeuralNetworkLoading` (`size` prop) |
| Inline dots | `DotsLoading` / `BubblesLoading` from `@/components` |
| Branded full-page | `Loading` from `@/components/Loading/BrandTextLoading` |
| List / card placeholder | a skeleton (e.g. `SkeletonList`) |
When in doubt, reach for `NeuralNetworkLoading` — it's the default in-flight
indicator (e.g. modal "in progress" states).
### 4.2 Capability-gated features・Certainty・Meaningful
A feature can be fully built and still produce a broken result when the selected
model — or its still-loading config — **can't deliver the capability the feature
depends on** (for example, an agentic run on a model without tool calling). This
is usually the user's configuration choice, not a defect; but if the product stays
silent the user reads it as the product being broken. When a feature's success
depends on a capability the current config may lack, the product owes a
**proactive, non-blocking reminder** — a guardrail, not a gate.
- [ ] **Surface the mismatch, don't fail silently.** When a feature needs a model
capability (tool calling, vision, reasoning, long context) the current model
lacks, show a soft inline warning at the point of action — never a hard block
or a modal that stops the user. _(Meaningful)_
- [ ] **Stay reactive.** The reminder clears the moment the user switches to a
capable model — derive it from live state, not a one-shot check. _(Natural)_
- [ ] **Don't warn while config is loading.** A capability that hasn't resolved yet
looks "unsupported"; warning then is a false alarm — exactly the glitch users
mistake for a product bug. Warn only on a _resolved_ unsupported state. _(Certainty)_
- [ ] **Scope to the mode that needs it.** Show only when the capability-dependent
mode is on; one reminder per root cause, never a pile of overlapping notices. _(Natural・Certainty)_
- [ ] **State the problem and the remedy.** The copy says what's wrong _and_ what
the user should do about it. _(Meaningful)_
---
## 5. Grow — discoverability & progressive disclosure
How the product **deepens** as the user's needs deepen.
### 5.1 Progressive disclosure・Growth
The product should grow with the user — deeper power shows up as needs deepen.
- [ ] **Progressive disclosure** — keep the novice path clean; reveal advanced
capabilities as the user gets there, don't dump everything at once. _(Growth・Natural)_
- [ ] **Surface related actions at the moment of need** — make the next capability
discoverable in context (e.g. after the first item exists, offer what to do
with it), not buried in a far-off menu. _(Growth・Meaningful)_
---
## Quick review checklist
**Read — viewing data & lists**
- [ ] Empty / loading / error states are all designed; empty is a real page with a CTA.
- [ ] List designed across 1 → 10k rows (virtual scroll / pagination / batch as needed).
- [ ] Capped/scrollable/virtualized list scrolls the restored active item into view on mount (`block: 'nearest'`, re-run after async rows mount).
- [ ] Pickers show all valid targets (default/inbox included); empty = truly none.
**Edit — entering & changing content**
- [ ] Editors back up in-progress input locally and recover it after refresh/crash/failed-save; destructive exits warn, never silently discard.
**Act — operations, flows & buttons**
- [ ] Action leads the user forward; success offers a primary "go to result".
- [ ] Bulk action has a single-item entry (and vice versa).
- [ ] Async/bulk/irreversible action: confirm → in-progress (locked) → done/error.
- [ ] Exactly one primary button per surface.
- [ ] Listed entities have their full lifecycle (not display-only); ops match source (built-in / installed / custom).
**Feedback — loading & system response**
- [ ] No antd `Spin`; use `NeuralNetworkLoading` / project loaders.
- [ ] Capability-gated feature warns (soft, reactive, load-gated) when the model can't deliver it; copy gives the remedy.
**Grow — discoverability & progressive disclosure**
- [ ] Advanced capability is progressively disclosed / discoverable at the moment of need.
## Related skills
- **modal** — imperative `createModal` state-machine wiring for confirm/progress/done.
- **microcopy** — wording for confirm / done / empty / error states.
- **react** — component priority, `Button` usage, styling.
@@ -1,51 +0,0 @@
# LobeHub Design Values (设计价值观)
The philosophy behind every LobeHub interface. Read this before designing or
reviewing a flow; the per-aspect execution rules live in the parent
[SKILL.md](../SKILL.md) and each checklist item is tagged with the value(s) it serves.
Adapted from Ant Design's design values
(<https://ant.design/docs/spec/values-cn>, <https://zhuanlan.zhihu.com/p/44809866>).
LobeHub adopts all four.
## 自然 (Natural)
Minimise cognitive load. Digital products keep getting more complex while human
attention stays scarce — so the interface should feel as effortless as the
physical world. The next step should be obvious without thinking; the product
proactively carries the user forward (sensible defaults, AI-assisted decisions,
smooth transitions) rather than making them stop and figure things out.
## 意义感 (Meaningful)
Every screen is rooted in the user's real goal, not an isolated feature. Make the
objective clear, give immediate feedback on the result of each action, and always
point at the next meaningful step. Calibrate difficulty — neither a patronising
over-simplification nor an overwhelming wall — so the user keeps a sense of
progress and accomplishment.
## 确定性 (Certainty)
Low-entropy, predictable interactions. Reuse the same patterns, components, and
wording so behaviour is never surprising. Keep a single clear focus per surface,
and design **every** state (empty / loading / error / success) so nothing is left
undefined. Restraint over cleverness: fewer, consistent rules beat many bespoke
ones.
## 生长性 (Growth)
The product grows together with the user. As needs deepen and roles evolve,
surface advanced capabilities progressively and make related features
discoverable at the moment they become relevant — without crowding the novice
path. Bridge product value to the user's changing scenarios and aim for
humanmachine symbiosis (人机共生): the user and the agent co-evolve, each making
the other more capable over time.
## Priority when values conflict
For moment-to-moment interaction decisions: **意义感 ≳ 自然 > 确定性** — never
sacrifice the user's goal or forward momentum just to keep things uniform.
**生长性 (Growth)** is a longer-horizon lens: weigh it when shaping how a feature
is discovered and how it scales with the user, not when resolving a single-screen
layout trade-off.
@@ -49,4 +49,4 @@ Migration owner: @{pr-author}
The migration owner is responsible for rollout follow-up and incident handling for this schema change.
> \[!NOTE]: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'` or from commit metadata. Do not hardcode a username.
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'` or from commit metadata. Do not hardcode a username.
@@ -18,4 +18,4 @@
@{pr-author}
> \[!NOTE]: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
> **Note for Claude**: Replace `{pr-author}` with the actual PR author. Retrieve via `gh pr view <number> --json author --jq '.author.login'`. Do not hardcode a username.
@@ -86,7 +86,7 @@ New AI model or provider support, typically contributed via community PRs.
- These PR title prefixes (`feat` / `style`) are in the auto-tag trigger list
- No special branch naming or manual release steps required — merging the PR triggers auto patch +1
### When an agent is involved
### When Claude is involved
If asked to add model support, just create a normal feature PR. The title prefix will trigger the release automatically.
+5 -14
View File
@@ -425,14 +425,14 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# MCP_TOOL_TIMEOUT=60000
# #######################################
# ######### Composio Service ############
# ######### Klavis Service ##############
# #######################################
# Composio API Key for accessing hosted integrations (Gmail, Slack, etc.)
# Get your API key from: https://composio.dev
# Klavis API Key for accessing Strata hosted MCP servers
# Get your API key from: https://klavis.io
# IMPORTANT: This key is stored server-side only and NEVER exposed to the client
# When this key is set, Composio integration will be automatically enabled
# COMPOSIO_API_KEY=your_composio_api_key_here
# When this key is set, Klavis integration will be automatically enabled
# KLAVIS_API_KEY=your_klavis_api_key_here
# #######################################
# #### Message Gateway (IM Integration) ##
@@ -445,15 +445,6 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
# #######################################
# ######### Agent Gateway Mode ##########
# #######################################
# Enable Gateway Mode for self-hosted deployments. Requires AGENT_GATEWAY_URL.
# ENABLE_AGENT_GATEWAY=1
# AGENT_GATEWAY_URL=https://agent-gateway.example.com
# AGENT_GATEWAY_SERVICE_TOKEN=your_service_token_here
# #######################################
# ########### Messenger Bot #############
# #######################################
+25 -1
View File
@@ -19,6 +19,12 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Clean issue notice
uses: actions-cool/issues-helper@e361abf610221f09495ad510cb1e69328d839e1c # v3.7.6
with:
actions: 'close-issues'
labels: '🚨 Sync Fail'
- name: Sync upstream changes
id: sync
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
@@ -27,4 +33,22 @@ jobs:
upstream_sync_branch: main
target_sync_branch: main
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
test_mode: false
test_mode: false
- name: Sync check
if: failure()
uses: actions-cool/issues-helper@e361abf610221f09495ad510cb1e69328d839e1c # v3.7.6
with:
actions: 'create-issue'
title: '🚨 同步失败 | Sync Fail'
labels: '🚨 Sync Fail'
body: |
Due to a change in the workflow file of the [LobeChat][lobechat] upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork. Please refer to the detailed [Tutorial][tutorial-en-US] for instructions.
由于 [LobeChat][lobechat] 上游仓库的 workflow 文件变更,导致 GitHub 自动暂停了本次自动更新,你需要手动 Sync Fork 一次,请查看 [详细教程][tutorial-zh-CN]
![](https://github-production-user-asset-6210df.s3.amazonaws.com/17870709/273954625-df80c890-0822-4ac2-95e6-c990785cbed5.png)
[lobechat]: https://github.com/lobehub/lobe-chat
[tutorial-zh-CN]: https://lobehub.com/zh/docs/self-hosting/advanced/upstream-sync
[tutorial-en-US]: https://lobehub.com/docs/self-hosting/advanced/upstream-sync
+3 -2
View File
@@ -59,7 +59,6 @@ bun.lockb
# Build outputs
dist/
public/_spa/
public/_spa-auth/
public/spa/
es/
lib/
@@ -93,8 +92,10 @@ public/swe-worker*
# Generated files
src/app/spa/[variants]/[[...path]]/spaHtmlTemplates.ts
src/app/spa-auth/authHtmlTemplate.ts
public/*.js
public/sitemap.xml
public/sitemap-index.xml
sitemap*.xml
robots.txt
# Git hooks
-2
View File
@@ -136,5 +136,3 @@ bun run type-check
### Code Review
Before reviewing a PR / diff / branch change, read the **review-checklist** skill (`.agents/skills/review-checklist/SKILL.md`) — it lists the recurring mistakes specific to this codebase.
When designing or reviewing user-facing flows (empty/loading/error states, confirmations, async feedback, button hierarchy, lists at scale, pickers), follow the **ux** skill (`.agents/skills/ux/SKILL.md`) — LobeHub's design values (自然 / 意义感 / 确定性) plus per-aspect execution checklists.
-1
View File
@@ -29,7 +29,6 @@
},
"devDependencies": {
"@lobechat/agent-gateway-client": "workspace:*",
"@lobechat/device-control": "workspace:*",
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/device-identity": "workspace:*",
"@lobechat/heterogeneous-agents": "workspace:*",
-1
View File
@@ -1,6 +1,5 @@
packages:
- '../../packages/agent-gateway-client'
- '../../packages/device-control'
- '../../packages/device-gateway-client'
- '../../packages/device-identity'
- '../../packages/heterogeneous-agents'
+5 -41
View File
@@ -2,16 +2,9 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
defaultGetLocalFilePreview,
defaultGetProjectFileIndex,
type DeviceControlDeps,
executeDeviceRpc,
} from '@lobechat/device-control';
import type {
AgentRunRequestMessage,
DeviceSystemInfo,
RpcRequestMessage,
SystemInfoRequestMessage,
ToolCallRequestMessage,
} from '@lobechat/device-gateway-client';
@@ -269,23 +262,19 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// Handle tool call requests
client.on('tool_call_request', async (request: ToolCallRequestMessage) => {
const { operationId, requestId, timeout, toolCall } = request;
const { requestId, timeout, toolCall } = request;
if (isDaemonChild) {
appendLog(
`[TOOL] ${toolCall.apiName}${operationId ? ` op=${operationId}` : ''} (${requestId})`,
);
appendLog(`[TOOL] ${toolCall.apiName} (${requestId})`);
} else {
log.toolCall(toolCall.apiName, requestId, toolCall.arguments, operationId);
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
}
const result = await executeToolCall(toolCall.apiName, toolCall.arguments, timeout);
if (isDaemonChild) {
appendLog(
`[RESULT] ${result.success ? 'OK' : 'FAIL'}${operationId ? ` op=${operationId}` : ''} (${requestId})`,
);
appendLog(`[RESULT] ${result.success ? 'OK' : 'FAIL'} (${requestId})`);
} else {
log.toolResult(requestId, result.success, result.content, operationId);
log.toolResult(requestId, result.success, result.content);
}
client.sendToolCallResponse({
@@ -299,31 +288,6 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
});
});
// Handle generic server-internal device RPCs (git / workspace / file ops).
// Shares the `@lobechat/device-control` dispatcher with the desktop app so the
// CLI exposes the same remote-device control surface. File preview / index use
// the package's portable defaults (no preview-protocol approval on the CLI).
const deviceControlDeps: DeviceControlDeps = {
getLocalFilePreview: defaultGetLocalFilePreview,
getProjectFileIndex: defaultGetProjectFileIndex,
};
client.on('rpc_request', async (request: RpcRequestMessage) => {
const { method, params, requestId } = request;
if (isDaemonChild) appendLog(`[RPC] ${method} (${requestId})`);
else info(`Received rpc_request: method=${method} (${requestId})`);
try {
const data = await executeDeviceRpc(method, params, deviceControlDeps);
client.sendRpcResponse({ requestId, result: { data, success: true } });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (isDaemonChild) appendLog(`[RPC ERROR] ${method}: ${message} (${requestId})`);
else error(`rpc_request method=${method} failed: ${message}`);
client.sendRpcResponse({ requestId, result: { error: message, success: false } });
}
});
// Handle gateway-dispatched agent runs (heterogeneous agents, e.g. Claude
// Code). Mirrors the desktop app: spawn `lh hetero exec`, which owns the full
// execution + server-ingest pipeline. Ack with the spawn outcome — `accepted`
-47
View File
@@ -649,53 +649,6 @@ describe('hetero exec command', () => {
]);
});
it('finishes with result "error" when a terminal error event is pushed despite a clean exit', async () => {
// CC relays an API/rate-limit error as an in-stream `error` event but still
// exits 0. The finish result must NOT be derived from the exit code alone,
// otherwise the topic/task is wrongly marked completed.
mockSpawnAgent.mockReturnValue(
createFakeHandle({
events: [
{
data: {
error: 'API Error: Server is temporarily limiting requests · Rate limited',
message: 'API Error: Server is temporarily limiting requests · Rate limited',
},
operationId: 'op-err',
stepIndex: 0,
timestamp: 1,
type: 'error',
},
],
exitCode: 0,
}),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--topic',
'topic-1',
'--operation-id',
'op-err',
'--render',
'none',
]);
expect(mockHeteroFinishMutate).toHaveBeenCalledTimes(1);
expect(mockHeteroFinishMutate.mock.calls[0][0]).toMatchObject({
error: {
message: 'API Error: Server is temporarily limiting requests · Rate limited',
type: 'AgentRuntimeError',
},
result: 'error',
});
});
it('resets the per-message text accumulator at message boundaries (no cross-message duplication)', async () => {
// The `replace` snapshot accumulator must not span
// message boundaries. Two assistant messages separated by a
+7 -35
View File
@@ -467,11 +467,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
* sessionId — CC session id from `system.init` (undefined on resume failure)
* ingestError — true when a batch could not be flushed after retries
* resumeNotFound — true when a resume-not-found error was intercepted
* sawTerminalError — true when a terminal `error` event was pushed to the
* ingester (CC can relay an API/rate-limit error this way
* and still exit 0, so the exit code alone is not enough)
* terminalErrorMessage — the message from that terminal `error` event, used
* as the task-level error detail in the finish payload
* stderrContent — accumulated stderr (only when interceptResumeErrors=true)
*/
const runOneAgent = async (
@@ -482,11 +477,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
code: number | null;
ingestError: boolean;
resumeNotFound: boolean;
sawTerminalError: boolean;
sessionId: string | undefined;
signal: NodeJS.Signals | null;
stderrContent: string;
terminalErrorMessage: string | undefined;
}> => {
// One raw-dump file pair per spawn attempt (the resume retry is a second
// attempt). The stdout tee runs inside `spawnAgent` before the adapter.
@@ -556,8 +549,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
// into the ingester. When intercepting resume errors, a matching
// `error` event is withheld from the ingester and flags a retry instead.
let resumeNotFound = false;
let sawTerminalError = false;
let terminalErrorMessage: string | undefined;
const ingestError = false;
try {
for await (const event of handle.events) {
@@ -572,16 +563,6 @@ const exec = async (options: ExecOptions): Promise<void> => {
continue;
}
}
// A terminal `error` event (e.g. an API/rate-limit error relayed by CC)
// must mark the run as failed even when the child exits 0 — track it so
// the finish result is not derived from the exit code alone. Capture the
// message too, so the finish payload can surface it as the task-level
// error detail (CC relays these on stdout, not stderr).
if (event.type === 'error') {
sawTerminalError = true;
const data = event.data as Record<string, unknown> | undefined;
terminalErrorMessage = String(data?.message ?? data?.error ?? '') || undefined;
}
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
serverIngester?.push(event);
}
@@ -627,11 +608,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
code,
ingestError,
resumeNotFound,
sawTerminalError,
sessionId: handle.sessionId,
signal,
stderrContent,
terminalErrorMessage,
};
};
@@ -696,23 +675,16 @@ const exec = async (options: ExecOptions): Promise<void> => {
result = { ...result, ingestError: true };
}
// CC relays API/rate-limit errors as an in-stream terminal `error` event but
// still exits 0, so the exit code alone would report `success`. Treat any
// pushed terminal error as a failed run so the topic/task is marked failed.
const exitedClean =
!result.ingestError && !result.sawTerminalError && (code === 0 || signal === 'SIGTERM');
const exitedClean = !result.ingestError && (code === 0 || signal === 'SIGTERM');
// When the run failed, pass an error detail so the server surfaces a useful
// message instead of the generic "Agent execution failed" fallback. Prefer
// the in-stream terminal error (CC relays API/rate-limit errors here while
// exiting 0, so stderr is empty); otherwise fall back to the stderr tail.
// Trim to the last 1 KB — the tail is most informative and keeps the tRPC
// payload small.
// When the run failed, pass stderr as the error detail so the server can
// surface a useful message instead of the generic "Agent execution failed"
// fallback. Trim to the last 1 KB — the tail is most informative and
// keeps the tRPC payload small.
const stderrTail = result.stderrContent.trim();
const errorDetail = result.terminalErrorMessage || stderrTail;
const finishError =
!exitedClean && errorDetail
? { message: errorDetail.slice(-1024), type: 'AgentRuntimeError' }
!exitedClean && stderrTail
? { message: stderrTail.slice(-1024), type: 'AgentRuntimeError' }
: undefined;
try {
+5 -6
View File
@@ -1,3 +1,4 @@
/* eslint-disable no-console */
import pc from 'picocolors';
let verbose = false;
@@ -40,20 +41,18 @@ export const log = {
console.log(`${timestamp()} ${pc.bold('[STATUS]')} ${color(status)}`);
},
toolCall: (apiName: string, requestId: string, args?: string, operationId?: string) => {
toolCall: (apiName: string, requestId: string, args?: string) => {
console.log(
`${timestamp()} ${pc.magenta('[TOOL]')} ${pc.bold(apiName)}${operationId ? ` ${pc.dim(`op=${operationId}`)}` : ''} ${pc.dim(`(${requestId})`)}`,
`${timestamp()} ${pc.magenta('[TOOL]')} ${pc.bold(apiName)} ${pc.dim(`(${requestId})`)}`,
);
if (args && verbose) {
console.log(` ${pc.dim(args)}`);
}
},
toolResult: (requestId: string, success: boolean, content?: string, operationId?: string) => {
toolResult: (requestId: string, success: boolean, content?: string) => {
const icon = success ? pc.green('OK') : pc.red('FAIL');
console.log(
`${timestamp()} ${pc.magenta('[RESULT]')} ${icon}${operationId ? ` ${pc.dim(`op=${operationId}`)}` : ''} ${pc.dim(`(${requestId})`)}`,
);
console.log(`${timestamp()} ${pc.magenta('[RESULT]')} ${icon} ${pc.dim(`(${requestId})`)}`);
if (content && verbose) {
const preview = content.length > 200 ? content.slice(0, 200) + '...' : content;
console.log(` ${pc.dim(preview)}`);
-1
View File
@@ -56,7 +56,6 @@
"@electron-toolkit/utils": "^4.0.0",
"@lobechat/chat-adapter-imessage": "workspace:*",
"@lobechat/desktop-bridge": "workspace:*",
"@lobechat/device-control": "workspace:*",
"@lobechat/device-gateway-client": "workspace:*",
"@lobechat/device-identity": "workspace:*",
"@lobechat/electron-client-ipc": "workspace:*",
-1
View File
@@ -8,7 +8,6 @@ packages:
- '../../packages/electron-client-ipc'
- '../../packages/file-loaders'
- '../../packages/desktop-bridge'
- '../../packages/device-control'
- '../../packages/device-gateway-client'
- '../../packages/device-identity'
- '../../packages/local-file-shell'
@@ -3,7 +3,6 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { type DeviceControlDeps, executeDeviceRpc as runDeviceRpc } from '@lobechat/device-control';
import type {
AgentRunRequestMessage,
GatewayMcpStdioParams,
@@ -14,8 +13,11 @@ import type {
GetCommandOutputParams,
GlobFilesParams,
GrepContentParams,
InitWorkspaceParams,
KillCommandParams,
ListLocalFileParams,
ListProjectSkillsParams,
LocalFilePreviewUrlParams,
LocalReadFileParams,
LocalReadFilesParams,
LocalSearchFilesParams,
@@ -28,16 +30,15 @@ import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import ImessageBridgeService from '@/services/imessageBridgeSrv';
import { createLogger } from '@/utils/logger';
import GitCtr from './GitCtr';
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
import { ControllerModule, IpcMethod } from './index';
import LocalFileCtr from './LocalFileCtr';
import McpCtr from './McpCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import ShellCommandCtr from './ShellCommandCtr';
const logger = createLogger('controllers:GatewayConnectionCtr');
import WorkspaceCtr from './WorkspaceCtr';
/**
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
@@ -166,6 +167,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.app.getController(LocalFileCtr);
}
private get workspaceCtr() {
return this.app.getController(WorkspaceCtr);
}
private get gitCtr() {
return this.app.getController(GitCtr);
}
private get shellCommandCtr() {
return this.app.getController(ShellCommandCtr);
}
@@ -344,33 +353,91 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.localSystemRuntime;
}
/**
* Platform-specific handlers the shared `@lobechat/device-control` dispatcher
* delegates to. Git + workspace-scan methods run inside device-control over
* `@lobechat/local-file-shell`; only file preview / index (and preview
* approval) are desktop-specific and routed back to the controllers here.
*/
private get deviceControlDeps(): DeviceControlDeps {
return {
approveProjectRoot: async (root) => {
try {
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
} catch (error) {
logger.error(`Failed to approve project preview root ${root}:`, error);
}
},
getLocalFilePreview: (params) => this.localFileCtr.getLocalFilePreview(params),
getProjectFileIndex: (params) => this.localFileCtr.getProjectFileIndex(params),
};
}
/**
* Dispatch a generic server-internal device RPC (not an agent tool call) by
* method name. The dispatch logic lives in `@lobechat/device-control` so the
* desktop main process and the CLI daemon share one device RPC surface.
* method name. Currently only `initWorkspace` (scan the bound project root for
* skills + AGENTS.md); add new server-only device methods here.
*/
private async executeDeviceRpc(method: string, params: unknown): Promise<unknown> {
return runDeviceRpc(method, params, this.deviceControlDeps);
switch (method) {
case 'initWorkspace': {
return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams);
}
case 'getGitBranch': {
return this.gitCtr.getGitBranch((params as { path: string }).path);
}
case 'getLinkedPullRequest': {
return this.gitCtr.getLinkedPullRequest(params as { branch: string; path: string });
}
case 'getGitWorkingTreeStatus': {
return this.gitCtr.getGitWorkingTreeStatus((params as { path: string }).path);
}
case 'getGitAheadBehind': {
return this.gitCtr.getGitAheadBehind((params as { path: string }).path);
}
case 'listGitBranches': {
return this.gitCtr.listGitBranches((params as { path: string }).path);
}
case 'checkoutGitBranch': {
return this.gitCtr.checkoutGitBranch(
params as { branch: string; create?: boolean; path: string },
);
}
case 'pullGitBranch': {
return this.gitCtr.pullGitBranch(params as { path: string });
}
case 'pushGitBranch': {
return this.gitCtr.pushGitBranch(params as { path: string });
}
case 'getGitWorkingTreePatches': {
return this.gitCtr.getGitWorkingTreePatches((params as { path: string }).path);
}
case 'getGitWorkingTreeFiles': {
return this.gitCtr.getGitWorkingTreeFiles((params as { path: string }).path);
}
case 'getProjectFileIndex': {
return this.localFileCtr.getProjectFileIndex(params as { scope?: string });
}
case 'getLocalFilePreview': {
return this.localFileCtr.getLocalFilePreview(params as LocalFilePreviewUrlParams);
}
case 'listProjectSkills': {
return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams);
}
case 'getGitBranchDiff': {
return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string });
}
case 'listGitRemoteBranches': {
return this.gitCtr.listGitRemoteBranches((params as { path: string }).path);
}
case 'revertGitFile': {
return this.gitCtr.revertGitFile(params as { filePath: string; path: string });
}
case 'statPath': {
return this.workspaceCtr.statPath(params as { path: string });
}
default: {
throw new Error(`Unknown device RPC method: ${method}`);
}
}
}
private async executeToolCall(
File diff suppressed because it is too large Load Diff
@@ -23,7 +23,7 @@ import type {
HeteroExecImageRef,
} from '@lobechat/heterogeneous-agents/protocol';
import { buildHeteroExecStdinPayload } from '@lobechat/heterogeneous-agents/protocol';
import type { AgentStreamEvent, UsageData } from '@lobechat/heterogeneous-agents/spawn';
import type { AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
import {
AgentStreamPipeline,
buildAgentInput,
@@ -188,21 +188,6 @@ interface AgentSession {
modelVerificationLastAttemptAt?: number;
modelVerificationLastAttemptSessionId?: string;
process?: ChildProcess;
/**
* Absolute CLI path resolved by spawn preflight detection. Used for spawn()
* when the configured command is bare: detection can find the CLI through
* the login-shell PATH or a well-known install location (e.g. the Codex.app
* bundled CLI) that plain spawn() with the inherited env can't resolve.
*/
resolvedCommandPath?: string;
/**
* PATH the preflight detector used to resolve `resolvedCommandPath`, set only
* when it fell back to the login-shell PATH. Merged into the child PATH at
* spawn so a `#!/usr/bin/env node` shim still finds its interpreter — the
* shim resolving in preflight doesn't guarantee `node` is on the leaner
* inherited PATH (Finder-launched Electron).
*/
resolvedCommandSearchPath?: string;
resumeSessionId?: string;
sessionId: string;
verifiedModel?: string;
@@ -485,20 +470,11 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
session.agentType === 'claude-code' ? 'claude-code' : 'codex',
command,
);
const cliMissingError = this.buildCliMissingError(session);
if (!status || status.available) {
// Spawn through the detector-resolved absolute path when the configured
// command is bare — detection may have located the CLI somewhere plain
// spawn() can't (login-shell PATH, Codex.app bundled CLI, …).
const useResolvedPath = Boolean(status?.path) && !command.includes(path.sep);
session.resolvedCommandPath = useResolvedPath ? status!.path : undefined;
// Carry the login-shell PATH the detector resolved through, so a
// `#!/usr/bin/env node` shim spawned by absolute path still finds `node`.
session.resolvedCommandSearchPath = useResolvedPath ? status!.resolvedPathEnv : undefined;
return;
}
if (!status || status.available || !cliMissingError) return;
return this.buildCliMissingError(session);
return cliMissingError;
}
private get shouldTraceCliOutput(): boolean {
@@ -935,7 +911,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
let spawnPlan;
let traceSession;
let cwd: string;
let initialCumulativeUsage: UsageData | undefined;
let spawnEnv: NodeJS.ProcessEnv;
try {
const driver = getHeterogeneousAgentDriver(session.agentType);
@@ -959,12 +934,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
// Forward the user's proxy settings to the CLI. The main-process undici
// dispatcher doesn't reach child processes — they need env vars.
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
const inheritedEnv = buildInheritedSpawnEnv();
// When preflight resolved the CLI via the login-shell PATH, spawn with
// that PATH (a superset of the inherited one) so a `#!/usr/bin/env node`
// shim finds its interpreter. `session.env` still wins if it sets PATH.
if (session.resolvedCommandSearchPath) inheritedEnv.PATH = session.resolvedCommandSearchPath;
spawnEnv = { ...inheritedEnv, ...proxyEnv, ...session.env };
spawnEnv = { ...buildInheritedSpawnEnv(), ...proxyEnv, ...session.env };
if (session.agentType === 'codex') {
const initialModel = await resolveCodexInitialModel({
@@ -975,12 +945,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
session.model = initialModel.model;
session.modelSource = initialModel.source;
}
if (session.agentSessionId) {
initialCumulativeUsage = (
await readCodexSessionModel(session.agentSessionId, { env: spawnEnv })
)?.cumulativeUsage;
}
}
traceSession = await this.createCliTraceSession({
@@ -1002,10 +966,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
const useStdin = spawnPlan.stdinPayload !== undefined;
const cliArgs = spawnPlan.args;
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(
session.resolvedCommandPath ?? session.command,
cliArgs,
);
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(session.command, cliArgs);
logger.info(
'Spawning agent:',
@@ -1040,7 +1001,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
reject,
resolve,
session,
initialCumulativeUsage,
spawnEnv,
traceSession,
useStdin,
@@ -1110,7 +1070,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
private handleSpawnedAgentProcess({
cwd,
initialCumulativeUsage,
intervention,
params,
proc,
@@ -1129,7 +1088,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
reject: (reason?: unknown) => void;
resolve: () => void;
session: AgentSession;
initialCumulativeUsage?: UsageData | undefined;
spawnEnv: NodeJS.ProcessEnv;
spawnPlan: HeterogeneousAgentBuildPlan;
traceSession: CliTraceSession | undefined;
@@ -1170,7 +1128,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
const pipeline = new AgentStreamPipeline({
agentType: session.agentType,
cwd,
initialCumulativeUsage,
initialModel: session.model,
operationId: params.operationId,
});
@@ -437,15 +437,11 @@ export default class LocalFileCtr extends ControllerModule {
@IpcMethod()
async getLocalFilePreviewUrl({
accept,
allowExternalFile,
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
try {
const url = await this.app.localFileProtocolManager.createPreviewUrl({
accept,
allowExternalFile,
filePath,
workspaceRoot: workingDirectory,
});
@@ -463,15 +459,11 @@ export default class LocalFileCtr extends ControllerModule {
@IpcMethod()
async getLocalFilePreview({
accept,
allowExternalFile,
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewResult> {
try {
const preview = await this.app.localFileProtocolManager.readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot: workingDirectory,
});
+207 -16
View File
@@ -1,53 +1,244 @@
import {
initWorkspace as runInitWorkspace,
listProjectSkills as runListProjectSkills,
statPath as runStatPath,
type WorkspaceScanDeps,
} from '@lobechat/device-control';
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import {
type InitWorkspaceParams,
type InitWorkspaceResult,
type ListProjectSkillsParams,
type ListProjectSkillsResult,
type ProjectSkillItem,
} from '@lobechat/electron-client-ipc';
import { detectRepoType } from '@/utils/git';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:WorkspaceCtr');
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
// Cap recursion to guard against pathological directory trees.
const MAX_SKILL_FILE_COUNT = 1000;
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
const results: string[] = [];
const stack: string[] = [dir];
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
const current = stack.pop()!;
let entries;
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (entry.isFile()) {
results.push(toPosixRelativePath(path.relative(dir, full)));
if (results.length >= MAX_SKILL_FILE_COUNT) break;
}
}
}
return results.sort();
};
// Parse a minimal YAML frontmatter block for SKILL.md files.
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
const match = raw.match(SKILL_FRONTMATTER_RE);
if (!match) return {};
const fields: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const colonIdx = line.indexOf(':');
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
if (!key || key.startsWith('#')) continue;
let value = line.slice(colonIdx + 1).trim();
if (value.startsWith('|') || value.startsWith('>')) continue;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
fields[key] = value;
}
return fields;
};
/**
* WorkspaceCtr
*
* Thin IPC layer over `@lobechat/device-control`'s workspace-scan helpers
* (skills discovery under `.agents/skills` / `.claude/skills` + project-root
* instructions). The scan logic is shared with the device-control RPC dispatch
* so the local desktop IPC path, the remote device RPC, and the CLI all run
* identical scans; the desktop-only preview-protocol approval is injected here.
* Owns "project workspace" scanning: discovering agent skills (`.agents/skills`
* / `.claude/skills`) and project-root instructions (`AGENTS.md` / `CLAUDE.md`)
* under a bound project directory. Split out of LocalFileCtr so the
* workspace/agent-config concern is distinct from generic local file ops.
*/
export default class WorkspaceCtr extends ControllerModule {
static override readonly groupName = 'workspace';
private get scanDeps(): WorkspaceScanDeps {
return { approveProjectRoot: (root) => this.approveProjectRootForPreview(root) };
/**
* Scan one skill source directory (e.g. `.agents/skills`) under `root` and
* return parsed frontmatter for each `SKILL.md`. Returns `[]` when the source
* directory is absent or unreadable. Unsorted — callers sort/merge.
*/
private async scanSkillsInSource(
root: string,
source: ProjectSkillItem['source'],
): Promise<ProjectSkillItem[]> {
const dir = path.join(root, source);
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
// Directory does not exist or is not readable.
return [];
}
const skills = await Promise.all(
entries
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map(async (entry) => {
const skillDir = path.join(dir, entry.name);
const skillFile = path.join(skillDir, 'SKILL.md');
try {
const raw = await readFile(skillFile, 'utf8');
const fields = parseSkillFrontmatter(raw);
const files = await listSkillFilesRecursive(skillDir);
return {
description: fields.description || undefined,
fileCount: files.length,
files,
name: fields.name || entry.name,
path: skillFile,
skillDir,
source,
};
} catch {
return null;
}
}),
);
return skills.filter((skill): skill is ProjectSkillItem => skill !== null);
}
/**
* Scan agent skill directories under the project root and return parsed
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
* to surface skills available in the current project. Returns the first
* source directory that yields any skills (`.agents/skills` wins).
*/
@IpcMethod()
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
return runListProjectSkills(params, this.scanDeps);
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
for (const source of sources) {
const skills = (await this.scanSkillsInSource(root, source)).sort((a, b) =>
a.name.localeCompare(b.name),
);
if (skills.length > 0) {
await this.approveProjectRootForPreview(root);
return { root, skills, source };
}
}
return { root, skills: [], source: null };
}
/**
* One-call "workspace init" scan of a bound project directory: merge the
* project skills from BOTH `.agents/skills` and `.claude/skills` (deduped by
* name, `.agents/skills` winning) and read the project-root agent
* instructions file (`AGENTS.md`, else `CLAUDE.md`). Driven server-side at run
* start via the generic device RPC (not an LLM-visible tool) and cached onto
* `devices.workingDirs[].workspace`.
*
* Approves the root for the `lobe-file://` preview protocol (same as
* `listProjectSkills`) so the user can later click through to the scanned
* skills / instructions in the UI.
*/
@IpcMethod()
async initWorkspace(params: InitWorkspaceParams): Promise<InitWorkspaceResult> {
return runInitWorkspace(params, this.scanDeps);
const root = params.scope;
const sources = ['.agents/skills', '.claude/skills'] as const;
const seen = new Set<string>();
const skills: ProjectSkillItem[] = [];
for (const source of sources) {
for (const skill of await this.scanSkillsInSource(root, source)) {
if (seen.has(skill.name)) continue;
seen.add(skill.name);
skills.push(skill);
}
}
skills.sort((a, b) => a.name.localeCompare(b.name));
const instructions = await this.readWorkspaceInstructions(root);
// Approve regardless of what was found — the run is now bound to this root,
// so any later click-through to it should resolve through the preview
// protocol even if the project carries neither skills nor instructions.
await this.approveProjectRootForPreview(root);
return { instructions, root, skills };
}
/**
* Check whether a path exists on this device and is a directory, plus its git
* repo type (`git` / `github` / none). Used to validate a manually-entered
* working directory from a web / remote client (which can't browse this
* device's filesystem) before binding it, and to render the right dir icon.
*/
@IpcMethod()
async statPath(params: {
path: string;
}): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' }> {
return runStatPath(params);
try {
const stats = await stat(params.path);
if (!stats.isDirectory()) return { exists: true, isDirectory: false };
const repoType = await detectRepoType(params.path);
return { exists: true, isDirectory: true, repoType };
} catch {
return { exists: false, isDirectory: false };
}
}
/**
* Read the project-root agent instructions files. Collects every present
* candidate (`AGENTS.md`, then `CLAUDE.md`) rather than first-match, since both
* can coexist. Each body is capped so a pathologically large file can't bloat
* the cached `workingDirs` payload or the injected system role.
*/
private async readWorkspaceInstructions(
root: string,
): Promise<InitWorkspaceResult['instructions']> {
const MAX_INSTRUCTIONS_BYTES = 64 * 1024;
const candidates = ['AGENTS.md', 'CLAUDE.md'] as const;
const instructions: InitWorkspaceResult['instructions'] = [];
for (const source of candidates) {
try {
const raw = await readFile(path.join(root, source), 'utf8');
const content =
raw.length > MAX_INSTRUCTIONS_BYTES ? raw.slice(0, MAX_INSTRUCTIONS_BYTES) : raw;
instructions.push({ content, source });
} catch {
// File absent or unreadable; skip it.
}
}
return instructions;
}
private async approveProjectRootForPreview(root: string) {
@@ -1,6 +1,15 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { dequoteGitPath, quoteGitPath } from '../workingTree';
import { dequoteGitPath, quoteGitPath } from '../GitCtr';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
describe('quoteGitPath', () => {
it('leaves plain ASCII paths unquoted (including spaces)', () => {
@@ -24,6 +33,8 @@ describe('quoteGitPath', () => {
});
it('puts the prefix inside the quotes', () => {
// Real git output for `git diff` of a tab-containing file:
// diff --git "a/with\there" "b/with\there"
expect(quoteGitPath('a/', 'with\there')).toBe('"a/with\\there"');
expect(quoteGitPath('b/', 'with\there')).toBe('"b/with\\there"');
});
@@ -40,6 +51,7 @@ describe('quoteGitPath', () => {
];
for (const original of cases) {
const quoted = quoteGitPath('b/', original);
// Strip the surrounding quotes + b/ prefix, then de-escape.
expect(quoted.startsWith('"b/')).toBe(true);
expect(quoted.endsWith('"')).toBe(true);
const stripped = quoted.slice(1, -1).slice('b/'.length);
@@ -480,87 +480,6 @@ describe('HeterogeneousAgentCtr', () => {
expect(spawnCalls).toHaveLength(0);
});
it('spawns through the detector-resolved absolute path when the bare command is off PATH', async () => {
// Codex desktop app case: `codex` is not on PATH, but the preflight
// detector finds the CLI bundled inside Codex.app. Spawning the bare
// command would ENOENT — spawn must use the resolved absolute path.
const resolvedPath = '/Applications/Codex.app/Contents/Resources/codex';
const detect = vi.fn().mockResolvedValue({ available: true, path: resolvedPath });
const { proc } = createFakeProc();
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
toolDetectorManager: { detect },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'codex',
command: 'codex',
});
await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId });
expect(spawnCalls[0].command).toBe(resolvedPath);
});
it('carries the detector login-shell PATH into the spawn env for `env node` shims', async () => {
// `codex` resolved via the login-shell PATH (mise/nvm). Spawning the
// absolute shim under the leaner inherited PATH would fail at its
// `#!/usr/bin/env node` shebang — the resolved PATH must reach the child.
const resolvedPath = '/Users/h/.local/share/mise/shims/codex';
const searchPath = '/Users/h/.local/share/mise/shims:/usr/bin:/bin';
const detect = vi
.fn()
.mockResolvedValue({ available: true, path: resolvedPath, resolvedPathEnv: searchPath });
const { proc } = createFakeProc();
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
toolDetectorManager: { detect },
} as any);
const { sessionId } = await ctr.startSession({ agentType: 'codex', command: 'codex' });
await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId });
expect(spawnCalls[0].command).toBe(resolvedPath);
expect(spawnCalls[0].options.env.PATH).toBe(searchPath);
});
it('keeps an explicit path-like command for spawn instead of the detector result', async () => {
// detectHeterogeneousCliCommand validates the custom path via --version.
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
optionsOrCallback: unknown,
callback?: (error: Error | null, result: { stderr: string; stdout: string }) => void,
) => {
const resolvedCallback =
typeof optionsOrCallback === 'function' ? optionsOrCallback : callback;
(resolvedCallback as any)?.(null, { stderr: '', stdout: 'codex-cli 0.99.0' });
},
);
const detect = vi.fn();
const { proc } = createFakeProc();
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({
appStoragePath,
storeManager: { get: vi.fn() },
toolDetectorManager: { detect },
} as any);
const { sessionId } = await ctr.startSession({
agentType: 'codex',
command: '/custom/bin/codex',
});
await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId });
expect(detect).not.toHaveBeenCalled();
expect(spawnCalls[0].command).toBe('/custom/bin/codex');
});
it('passes prompt via stdin to codex exec instead of argv', async () => {
const prompt = '--run a shell-like prompt safely';
const { cliArgs, command, writes } = await runSendPrompt(prompt);
@@ -225,8 +225,6 @@ describe('LocalFileCtr', () => {
});
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
accept: undefined,
allowExternalFile: undefined,
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
@@ -249,52 +247,6 @@ describe('LocalFileCtr', () => {
success: false,
});
});
it('should forward image-only preview URL constraints', async () => {
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
'localfile://file/workspace/image.png?token=abc',
);
const result = await localFileCtr.getLocalFilePreviewUrl({
accept: 'image',
path: '/workspace/image.png',
workingDirectory: '/workspace',
});
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
accept: 'image',
allowExternalFile: undefined,
filePath: '/workspace/image.png',
workspaceRoot: '/workspace',
});
expect(result).toEqual({
success: true,
url: 'localfile://file/workspace/image.png?token=abc',
});
});
it('should forward user-approved external preview URL access', async () => {
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
);
const result = await localFileCtr.getLocalFilePreviewUrl({
allowExternalFile: true,
path: '/tmp/worktree-switcher-demo.html',
workingDirectory: '/tmp',
});
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
allowExternalFile: true,
accept: undefined,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(result).toEqual({
success: true,
url: 'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
});
});
});
describe('getLocalFilePreview', () => {
@@ -311,8 +263,6 @@ describe('LocalFileCtr', () => {
});
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
accept: undefined,
allowExternalFile: undefined,
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
@@ -339,64 +289,6 @@ describe('LocalFileCtr', () => {
success: false,
});
});
it('should forward image-only preview read constraints', async () => {
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue({
buffer: Buffer.from('image-bytes'),
contentType: 'image/png',
realPath: '/workspace/image.png',
});
const result = await localFileCtr.getLocalFilePreview({
accept: 'image',
path: '/workspace/image.png',
workingDirectory: '/workspace',
});
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
accept: 'image',
allowExternalFile: undefined,
filePath: '/workspace/image.png',
workspaceRoot: '/workspace',
});
expect(result).toEqual({
preview: {
base64: Buffer.from('image-bytes').toString('base64'),
contentType: 'image/png',
type: 'image',
},
success: true,
});
});
it('should forward user-approved external preview reads', async () => {
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue({
buffer: Buffer.from('<h1>Demo</h1>'),
contentType: 'text/html',
realPath: '/tmp/worktree-switcher-demo.html',
});
const result = await localFileCtr.getLocalFilePreview({
allowExternalFile: true,
path: '/tmp/worktree-switcher-demo.html',
workingDirectory: '/tmp',
});
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
allowExternalFile: true,
accept: undefined,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(result).toEqual({
preview: {
content: '<h1>Demo</h1>',
contentType: 'text/html',
type: 'text',
},
success: true,
});
});
});
describe('handleWriteFile', () => {
@@ -21,7 +21,6 @@ const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
const logger = createLogger('core:LocalFileProtocolManager');
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
const EXTERNAL_PREVIEW_APPROVAL_TTL_MS = 10 * 60 * 1000;
const normalizeAbsolutePath = (filePath: string): string | null => {
const normalized = path.normalize(filePath);
@@ -55,18 +54,6 @@ export interface PreviewFileReadResult {
realPath: string;
}
type PreviewFileAccept = 'image';
const normalizeContentType = (contentType: string): string =>
contentType.split(';')[0].trim().toLowerCase();
const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileAccept): boolean => {
if (!accept) return true;
const normalizedContentType = normalizeContentType(contentType);
return accept === 'image' && normalizedContentType.startsWith('image/');
};
/**
* Custom `localfile://` protocol for project file previews.
*
@@ -82,8 +69,6 @@ const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileA
export class LocalFileProtocolManager {
private readonly approvedWorkspaceRoots = new Set<string>();
private readonly externalPreviewApprovals = new Map<string, number>();
private readonly indexedProjectRoots = new Set<string>();
private handlerRegistered = false;
@@ -228,29 +213,16 @@ export class LocalFileProtocolManager {
}
async createPreviewUrl({
accept,
allowExternalFile,
filePath,
workspaceRoot,
}: {
accept?: PreviewFileAccept;
allowExternalFile?: boolean;
filePath: string;
workspaceRoot: string;
}): Promise<string | null> {
const normalizedFilePath = normalizeAbsolutePath(filePath);
if (!normalizedFilePath) return null;
const realFilePath = accept
? (
await this.readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot,
})
)?.realPath
: await this.resolveApprovedPreviewPath({ allowExternalFile, filePath, workspaceRoot });
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
if (!realFilePath) return null;
this.cleanupExpiredTokens();
@@ -265,38 +237,22 @@ export class LocalFileProtocolManager {
}
async readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot,
}: {
accept?: PreviewFileAccept;
allowExternalFile?: boolean;
filePath: string;
workspaceRoot: string;
}): Promise<PreviewFileReadResult | null> {
const realFilePath = await this.resolveApprovedPreviewPath({
allowExternalFile,
filePath,
persistExternalApproval: false,
workspaceRoot,
});
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
if (!realFilePath) return null;
const fileStat = await stat(realFilePath);
if (!fileStat.isFile()) return null;
const buffer = await readFile(realFilePath);
const contentType = resolveLocalFileMimeType(realFilePath, buffer);
if (!isAcceptedPreviewContentType(contentType, accept)) return null;
if (allowExternalFile) {
this.grantExternalPreviewApproval(realFilePath);
}
return {
buffer,
contentType,
contentType: resolveLocalFileMimeType(realFilePath, buffer),
realPath: realFilePath,
};
}
@@ -341,14 +297,10 @@ export class LocalFileProtocolManager {
}
private async resolveApprovedPreviewPath({
allowExternalFile,
filePath,
persistExternalApproval = true,
workspaceRoot,
}: {
allowExternalFile?: boolean;
filePath: string;
persistExternalApproval?: boolean;
workspaceRoot: string;
}): Promise<string | null> {
const normalizedFilePath = normalizeAbsolutePath(filePath);
@@ -363,44 +315,15 @@ export class LocalFileProtocolManager {
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
const workspaceRootApproved =
this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) ||
this.indexedProjectRoots.has(normalizedRealWorkspaceRoot);
if (
workspaceRootApproved &&
isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
) {
return normalizedRealFilePath;
return null;
}
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
if (this.hasExternalPreviewApproval(normalizedRealFilePath)) return normalizedRealFilePath;
if (allowExternalFile) {
return this.approveExternalPreviewFile(normalizedRealFilePath, {
persist: persistExternalApproval,
});
}
return null;
}
private async approveExternalPreviewFile(
realFilePath: string,
{ persist = true }: { persist?: boolean } = {},
): Promise<string | null> {
const fileStat = await stat(realFilePath);
if (!fileStat.isFile()) return null;
if (persist) {
this.grantExternalPreviewApproval(realFilePath);
}
return realFilePath;
}
private grantExternalPreviewApproval(realFilePath: string) {
this.cleanupExpiredExternalPreviewApprovals();
this.externalPreviewApprovals.set(realFilePath, Date.now() + EXTERNAL_PREVIEW_APPROVAL_TTL_MS);
return normalizedRealFilePath;
}
private cleanupExpiredTokens() {
@@ -412,15 +335,6 @@ export class LocalFileProtocolManager {
}
}
private cleanupExpiredExternalPreviewApprovals() {
const now = Date.now();
for (const [realPath, expiresAt] of this.externalPreviewApprovals) {
if (expiresAt <= now) {
this.externalPreviewApprovals.delete(realPath);
}
}
}
private hasPreviewToken(token: string): boolean {
const record = this.previewTokens.get(token);
if (!record) return false;
@@ -439,16 +353,4 @@ export class LocalFileProtocolManager {
return record.realPath === realResolvedPath;
}
private hasExternalPreviewApproval(realFilePath: string): boolean {
const expiresAt = this.externalPreviewApprovals.get(realFilePath);
if (!expiresAt) return false;
if (expiresAt <= Date.now()) {
this.externalPreviewApprovals.delete(realFilePath);
return false;
}
return true;
}
}
@@ -15,15 +15,6 @@ export interface ToolStatus {
error?: string;
lastChecked?: Date;
path?: string;
/**
* PATH value used to resolve/validate the command, surfaced only when it
* differs from the detector process's `process.env.PATH` (e.g. resolution
* fell back to the login-shell PATH). A caller that spawns the resolved
* `path` must carry this into the child's PATH, or a `#!/usr/bin/env node`
* shim that resolved here still fails with `env: node: No such file or
* directory` under the leaner inherited env.
*/
resolvedPathEnv?: string;
version?: string;
}
@@ -119,21 +119,6 @@ describe('LocalFileProtocolManager', () => {
expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
});
it('does not mint image-only preview URLs for text files', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveWorkspaceRoot('/Users/alice/project');
mockReadFile.mockResolvedValue(Buffer.from('const value = 1;'));
const url = await manager.createPreviewUrl({
accept: 'image',
filePath: '/Users/alice/project/App.tsx',
workspaceRoot: '/Users/alice/project',
});
expect(url).toBeNull();
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
});
it('decodes percent-encoded characters in the path', async () => {
const manager = new LocalFileProtocolManager();
manager.registerHandler();
@@ -263,31 +248,6 @@ describe('LocalFileProtocolManager', () => {
expect(url).toBeNull();
});
it('mints preview URLs for user-approved external files only', async () => {
const manager = new LocalFileProtocolManager();
const url = await manager.createPreviewUrl({
allowExternalFile: true,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
if (!url) throw new Error('Expected external local file preview URL');
expect(url).toContain('token=');
const repeatedUrl = await manager.createPreviewUrl({
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(repeatedUrl).toContain('token=');
const neighborUrl = await manager.createPreviewUrl({
filePath: '/tmp/other.html',
workspaceRoot: '/tmp',
});
expect(neighborUrl).toBeNull();
});
it('can approve a project root derived from an already approved nested scope', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveWorkspaceRoot('/Users/alice/project/packages/app');
@@ -336,41 +296,6 @@ describe('LocalFileProtocolManager', () => {
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
});
it('does not return text payloads for image-only preview reads', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveIndexedProjectRoot('/Users/alice/project');
mockReadFile.mockResolvedValue(Buffer.from('SECRET=value'));
const result = await manager.readPreviewFile({
accept: 'image',
filePath: '/Users/alice/project/.env',
workspaceRoot: '/Users/alice/project',
});
expect(result).toBeNull();
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/.env');
});
it('does not keep external approval when an image-only external preview rejects text', async () => {
const manager = new LocalFileProtocolManager();
mockReadFile.mockResolvedValue(Buffer.from('SECRET=value'));
const result = await manager.readPreviewFile({
accept: 'image',
allowExternalFile: true,
filePath: '/tmp/secret.txt',
workspaceRoot: '/tmp',
});
expect(result).toBeNull();
const repeatedUrl = await manager.createPreviewUrl({
filePath: '/tmp/secret.txt',
workspaceRoot: '/tmp',
});
expect(repeatedUrl).toBeNull();
});
it('does not read preview payloads outside the approved workspace root', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveIndexedProjectRoot('/Users/alice/project');
+2 -45
View File
@@ -16,12 +16,6 @@ import type { App } from '../App';
// Create logger
const logger = createLogger('core:Tray');
// Debounce window for distinguishing a single-click from the leading edge of
// a double-click. Electron delivers two `click` events before `double-click`,
// so we defer the single-click action until this window passes — the
// `double-click` handler clears it if it arrives in time.
const CLICK_DEBOUNCE_MS = 250;
export interface TrayOptions {
/**
* Tray icon path (relative to resource directory)
@@ -60,12 +54,6 @@ export class Tray {
*/
private _contextMenu?: ElectronMenu;
/**
* Pending single-click timer. Cleared by the double-click handler so a
* double-click never accidentally fires startSession before showMainWindow.
*/
private _clickTimer?: NodeJS.Timeout;
/**
* Identifier
*/
@@ -130,25 +118,10 @@ export class Tray {
// Set default context menu
this.setContextMenu();
// Left-click: deferred so a follow-up `double-click` can pre-empt it.
// Left-click: open Quick Composer.
this._tray.on('click', () => {
logger.debug(`[${this.identifier}] Tray clicked`);
if (this._clickTimer) clearTimeout(this._clickTimer);
this._clickTimer = setTimeout(() => {
this._clickTimer = undefined;
this.onClick();
}, CLICK_DEBOUNCE_MS);
});
// Double-click (macOS / Windows): cancel the pending single-click and
// surface the main window instead.
this._tray.on('double-click', () => {
logger.debug(`[${this.identifier}] Tray double-clicked`);
if (this._clickTimer) {
clearTimeout(this._clickTimer);
this._clickTimer = undefined;
}
this.onDoubleClick();
this.onClick();
});
// Right-click: pop the stored context menu manually so left-click stays
@@ -216,18 +189,6 @@ export class Tray {
}
}
/**
* Handle tray double-click event — surfaces the main window.
*/
onDoubleClick() {
logger.debug(`[${this.identifier}] Tray double-click → showMainWindow`);
try {
this.app.browserManager.showMainWindow();
} catch (error) {
logger.error(`[${this.identifier}] Failed to show main window:`, error);
}
}
/**
* Replace the tray context menu with a pre-built Electron Menu instance.
* Stored in-house and popped up manually on right-click to preserve
@@ -298,10 +259,6 @@ export class Tray {
*/
destroy() {
logger.debug(`Destroying tray instance: ${this.identifier}`);
if (this._clickTimer) {
clearTimeout(this._clickTimer);
this._clickTimer = undefined;
}
if (this._tray) {
this._tray.destroy();
this._tray = undefined;
@@ -189,7 +189,7 @@ describe('Tray', () => {
expect(mockElectronTray.setContextMenu).not.toHaveBeenCalled();
});
it('should register click, double-click and right-click listeners', () => {
it('should register both click and right-click listeners', () => {
tray = new Tray(
{
iconPath: 'tray.png',
@@ -200,7 +200,6 @@ describe('Tray', () => {
const events = mockElectronTray.on.mock.calls.map((c: any[]) => c[0]);
expect(events).toContain('click');
expect(events).toContain('double-click');
expect(events).toContain('right-click');
});
@@ -347,96 +346,6 @@ describe('Tray', () => {
});
});
describe('onDoubleClick', () => {
beforeEach(() => {
tray = new Tray(
{
iconPath: 'tray.png',
identifier: 'test-tray',
},
mockApp,
);
});
it('should show the main window', () => {
tray.onDoubleClick();
expect(mockApp.browserManager.showMainWindow).toHaveBeenCalled();
});
it('should not start the capture session', () => {
tray.onDoubleClick();
expect(mockApp.screenCaptureManager.startSession).not.toHaveBeenCalled();
});
it('should not throw when showMainWindow throws', () => {
vi.mocked(mockApp.browserManager.showMainWindow).mockImplementationOnce(() => {
throw new Error('window failed');
});
expect(() => tray.onDoubleClick()).not.toThrow();
});
});
describe('click vs double-click handling', () => {
let clickHandler: (() => void) | undefined;
let doubleClickHandler: (() => void) | undefined;
beforeEach(() => {
vi.useFakeTimers();
tray = new Tray(
{
iconPath: 'tray.png',
identifier: 'test-tray',
},
mockApp,
);
clickHandler = mockElectronTray.on.mock.calls.find((c: any[]) => c[0] === 'click')?.[1];
doubleClickHandler = mockElectronTray.on.mock.calls.find(
(c: any[]) => c[0] === 'double-click',
)?.[1];
});
afterEach(() => {
vi.useRealTimers();
});
it('should debounce single click before calling startSession', () => {
expect(clickHandler).toBeDefined();
clickHandler?.();
expect(mockApp.screenCaptureManager.startSession).not.toHaveBeenCalled();
vi.advanceTimersByTime(250);
expect(mockApp.screenCaptureManager.startSession).toHaveBeenCalledTimes(1);
});
it('should cancel the pending single click when double-click fires', () => {
expect(clickHandler).toBeDefined();
expect(doubleClickHandler).toBeDefined();
clickHandler?.();
clickHandler?.();
doubleClickHandler?.();
vi.advanceTimersByTime(1000);
expect(mockApp.screenCaptureManager.startSession).not.toHaveBeenCalled();
expect(mockApp.browserManager.showMainWindow).toHaveBeenCalledTimes(1);
});
it('should only fire startSession once per single-click burst', () => {
clickHandler?.();
clickHandler?.();
vi.advanceTimersByTime(250);
expect(mockApp.screenCaptureManager.startSession).toHaveBeenCalledTimes(1);
});
});
describe('updateIcon', () => {
beforeEach(() => {
tray = new Tray(
@@ -1,6 +1,5 @@
import * as childProcess from 'node:child_process';
import * as os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -181,76 +180,6 @@ describe('cliAgentDetectors', () => {
expect(status.path).toBe('/usr/local/bin/claude');
expect(execMock).not.toHaveBeenCalled();
expect(execFileMock).toHaveBeenCalledTimes(2);
// Resolved on the inherited PATH — nothing extra to carry into spawn.
expect(status.resolvedPathEnv).toBeUndefined();
});
it('falls back to the Codex.app bundled CLI when `codex` is not on any PATH', async () => {
const originalPath = process.env.PATH;
const originalShell = process.env.SHELL;
// Deterministic env: no SHELL → no login-shell lookup, merged PATH
// equals process.env.PATH → no second `which` attempt.
process.env.PATH = '/usr/bin:/bin';
delete process.env.SHELL;
try {
callExecFileError(new Error('not found')); // which codex
callExecFile('codex-cli 0.138.0'); // bundled CLI --version
const { codexDetector } = await import('../cliAgentDetectors');
const status = await codexDetector.detect();
expect(status.available).toBe(true);
expect(status.path).toBe('/Applications/Codex.app/Contents/Resources/codex');
expect(status.version).toBe('codex-cli 0.138.0');
expect(execFileMock).toHaveBeenCalledTimes(2);
expect(execFileMock.mock.calls[0]![0]).toBe('which');
expect(execFileMock.mock.calls[1]![0]).toBe(
'/Applications/Codex.app/Contents/Resources/codex',
);
} finally {
process.env.PATH = originalPath;
if (originalShell === undefined) delete process.env.SHELL;
else process.env.SHELL = originalShell;
}
});
it('stays unavailable when neither PATH nor the well-known locations have codex', async () => {
const originalPath = process.env.PATH;
const originalShell = process.env.SHELL;
process.env.PATH = '/usr/bin:/bin';
delete process.env.SHELL;
try {
callExecFileError(new Error('not found')); // which codex
callExecFileError(new Error('ENOENT')); // /Applications candidate
callExecFileError(new Error('ENOENT')); // ~/Applications candidate
const { codexDetector } = await import('../cliAgentDetectors');
const status = await codexDetector.detect();
expect(status.available).toBe(false);
expect(execFileMock).toHaveBeenCalledTimes(3);
expect(execFileMock.mock.calls[2]![0]).toBe(
path.join(os.homedir(), 'Applications', 'Codex.app', 'Contents', 'Resources', 'codex'),
);
} finally {
process.env.PATH = originalPath;
if (originalShell === undefined) delete process.env.SHELL;
else process.env.SHELL = originalShell;
}
});
it('does not probe well-known locations for an explicit path-like command', async () => {
callExecFileError(new Error('ENOENT')); // /custom/bin/codex --version
const { detectHeterogeneousCliCommand } = await import('../cliAgentDetectors');
const status = await detectHeterogeneousCliCommand('codex', '/custom/bin/codex');
expect(status.available).toBe(false);
// Only the explicit path's --version attempt — no fallback probing.
expect(execFileMock).toHaveBeenCalledTimes(1);
});
it('falls back to the login shell PATH for tools installed by shell setup', async () => {
@@ -271,12 +200,6 @@ describe('cliAgentDetectors', () => {
expect(status.available).toBe(true);
expect(status.path).toBe('/Users/Hanam/.local/share/mise/shims/gemini');
expect(status.version).toBe('gemini 0.2.0');
// The login-shell PATH that resolved the shim must be surfaced so the
// spawn site can carry it into the child env (mise/nvm `node` lives
// there, not on the leaner inherited PATH).
expect(status.resolvedPathEnv).toBe(
'/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin',
);
expect(execFileMock).toHaveBeenCalledTimes(4);
expect(execFileMock.mock.calls[0]![0]).toBe('which');
@@ -1,5 +1,5 @@
import { exec, execFile } from 'node:child_process';
import { homedir, platform } from 'node:os';
import { platform } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
@@ -190,11 +190,6 @@ const detectValidatedCommand = async (
return {
available: true,
path: resolvedPath,
// `env` is set only when resolution fell back to the login-shell PATH.
// Surface that PATH so the spawn site can carry it into the child env —
// otherwise a `#!/usr/bin/env node` shim resolved here can't find `node`
// under the leaner inherited PATH (Finder-launched Electron).
resolvedPathEnv: env?.PATH,
version: output.split(/\r?\n/)[0],
};
} catch {
@@ -214,27 +209,6 @@ const HETEROGENEOUS_CLI_AGENT_OPTIONS = {
Pick<ValidatedDetectorOptions, 'validateKeywords'>
>;
// Well-known absolute install locations probed when a bare command isn't on
// PATH. The Codex desktop app bundles a fully functional CLI inside Codex.app
// (sharing ~/.codex auth/config) but never symlinks it into PATH, so
// `which codex` misses an otherwise working install.
const getWellKnownCommandPaths = (agentType: HeterogeneousCliAgentType): string[] => {
if (platform() !== 'darwin') return [];
switch (agentType) {
case 'codex': {
const bundledCli = path.join('Codex.app', 'Contents', 'Resources', 'codex');
return [
path.join('/Applications', bundledCli),
path.join(homedir(), 'Applications', bundledCli),
];
}
default: {
return [];
}
}
};
export const detectHeterogeneousCliCommand = async (
agentType: HeterogeneousCliAgentType,
command: string,
@@ -242,20 +216,7 @@ export const detectHeterogeneousCliCommand = async (
const validator = HETEROGENEOUS_CLI_AGENT_OPTIONS[agentType];
if (!validator) return { available: false };
const status = await detectValidatedCommand(command, validator);
if (status.available) return status;
// A bare command missing from PATH may still live at a well-known install
// location (e.g. the Codex desktop app's bundled CLI). Don't second-guess
// an explicit user-configured path.
if (!command.trim().includes(path.sep)) {
for (const candidate of getWellKnownCommandPaths(agentType)) {
const fallbackStatus = await detectValidatedCommand(candidate, validator);
if (fallbackStatus.available) return fallbackStatus;
}
}
return status;
return detectValidatedCommand(command, validator);
};
/**
@@ -300,17 +261,14 @@ export const claudeCodeDetector: IToolDetector = createValidatedDetector({
/**
* OpenAI Codex CLI
* @see https://github.com/openai/codex
*
* Goes through `detectHeterogeneousCliCommand` so the Codex.app bundled-CLI
* fallback applies here too, keeping the manager path and the custom-command
* path in sync.
*/
export const codexDetector: IToolDetector = {
export const codexDetector: IToolDetector = createValidatedDetector({
candidates: ['codex'],
description: 'Codex - OpenAI agentic coding CLI',
detect: () => detectHeterogeneousCliCommand('codex', 'codex'),
name: 'codex',
priority: 2,
};
validateKeywords: ['codex'],
});
/**
* Google Gemini CLI
@@ -15,21 +15,13 @@ const mocks = vi.hoisted(() => ({
),
}));
interface MockGlobalConfigOptions {
agentGatewayUrl?: string;
enableAgentGateway?: boolean;
}
const mockGlobalConfigDependencies = (
enableBusinessFeatures: boolean,
options: MockGlobalConfigOptions = {},
) => {
const mockGlobalConfigDependencies = (enableBusinessFeatures: boolean) => {
vi.doMock('@lobechat/business-const', () => ({
ENABLE_BUSINESS_FEATURES: enableBusinessFeatures,
}));
vi.doMock('@/config/composio', () => ({
composioEnv: {},
vi.doMock('@/config/klavis', () => ({
klavisEnv: {},
}));
vi.doMock('@/const/version', () => ({
@@ -37,12 +29,7 @@ const mockGlobalConfigDependencies = (
}));
vi.doMock('@/envs/app', () => ({
appEnv: {
...(options.agentGatewayUrl ? { AGENT_GATEWAY_URL: options.agentGatewayUrl } : {}),
...(options.enableAgentGateway === undefined
? {}
: { ENABLE_AGENT_GATEWAY: options.enableAgentGateway }),
},
appEnv: {},
getAppConfig: vi.fn(() => ({
DEFAULT_AGENT_CONFIG: '',
})),
@@ -126,18 +113,6 @@ const loadCapturedProviderConfig = async (enableBusinessFeatures: boolean) => {
>;
};
const loadServerConfig = async (
enableBusinessFeatures: boolean,
options?: MockGlobalConfigOptions,
) => {
vi.resetModules();
mocks.genServerAiProvidersConfig.mockClear();
mockGlobalConfigDependencies(enableBusinessFeatures, options);
const { getServerGlobalConfig } = await import('./index');
return getServerGlobalConfig();
};
describe('getServerGlobalConfig', () => {
afterEach(() => {
vi.restoreAllMocks();
@@ -164,36 +139,4 @@ describe('getServerGlobalConfig', () => {
expect(providerConfig[ModelProvider.OpenAI]).toBeUndefined();
expect(providerConfig[ModelProvider.DeepSeek].enabled).toBe(true);
});
it('should enable gateway mode for business builds', async () => {
await expect(loadServerConfig(true)).resolves.toMatchObject({
enableGatewayMode: true,
});
});
it('should enable gateway mode for self-hosted builds only when explicitly enabled with a gateway url', async () => {
await expect(
loadServerConfig(false, {
agentGatewayUrl: 'https://gateway.test.com',
enableAgentGateway: true,
}),
).resolves.toMatchObject({
agentGatewayUrl: 'https://gateway.test.com',
enableGatewayMode: true,
});
await expect(
loadServerConfig(false, {
agentGatewayUrl: 'https://gateway.test.com',
enableAgentGateway: false,
}),
).resolves.toMatchObject({
agentGatewayUrl: 'https://gateway.test.com',
enableGatewayMode: false,
});
await expect(loadServerConfig(false, { enableAgentGateway: true })).resolves.toMatchObject({
enableGatewayMode: false,
});
});
});
+2 -4
View File
@@ -1,7 +1,7 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { ModelProvider } from 'model-bank';
import { composioEnv } from '@/config/composio';
import { klavisEnv } from '@/config/klavis';
import { isDesktop } from '@/const/version';
import { appEnv, getAppConfig } from '@/envs/app';
import { authEnv } from '@/envs/auth';
@@ -104,9 +104,7 @@ export const getServerGlobalConfig = async () => {
disableEmailPassword: authEnv.AUTH_DISABLE_EMAIL_PASSWORD,
enableBusinessFeatures: ENABLE_BUSINESS_FEATURES,
enableEmailVerification: authEnv.AUTH_EMAIL_VERIFICATION,
enableComposio: !!composioEnv.COMPOSIO_API_KEY,
enableGatewayMode:
ENABLE_BUSINESS_FEATURES || (!!appEnv.ENABLE_AGENT_GATEWAY && !!appEnv.AGENT_GATEWAY_URL),
enableKlavis: !!klavisEnv.KLAVIS_API_KEY,
enableLobehubSkill: !!(appEnv.MARKET_TRUSTED_CLIENT_SECRET && appEnv.MARKET_TRUSTED_CLIENT_ID),
enableMagicLink: authEnv.AUTH_ENABLE_MAGIC_LINK,
enableMarketTrustedClient: !!(
@@ -14,14 +14,14 @@ import {
} from '@lobechat/agent-runtime';
import { LobeActivatorIdentifier } from '@lobechat/builtin-tool-activator';
import {
type ComposioServiceSummary,
type CredSummary,
generateComposioServicesList,
generateCredsList,
generateKlavisServicesList,
type KlavisServiceSummary,
} from '@lobechat/builtin-tool-creds';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { BRANDING_PROVIDER } from '@lobechat/business-const';
import { COMPOSIO_APP_TYPES } from '@lobechat/const';
import { KLAVIS_SERVER_TYPES } from '@lobechat/const';
import {
type AgentContextDocument,
type AgentGroupConfig,
@@ -38,12 +38,7 @@ import {
ToolResolver,
} from '@lobechat/context-engine';
import { parse } from '@lobechat/conversation-flow';
import {
applyModelExtendParams,
type ChatStreamPayload,
consumeStreamUntilDone,
type ModelExtendParams,
} from '@lobechat/model-runtime';
import { consumeStreamUntilDone } from '@lobechat/model-runtime';
import {
context as otelContext,
SpanKind,
@@ -66,15 +61,13 @@ import { chainCompressContext } from '@lobechat/prompts';
import {
type ChatToolPayload,
type ExecSubAgentParams,
type ExecVirtualSubAgentParams,
type MessageToolCall,
type UIChatMessage,
} from '@lobechat/types';
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
import debug from 'debug';
import type { ExtendParamsType } from 'model-bank';
import { composioEnv } from '@/config/composio';
import { klavisEnv } from '@/config/klavis';
import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message';
import { TopicModel } from '@/database/models/topic';
import { UserModel } from '@/database/models/user';
@@ -86,10 +79,6 @@ import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/type
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import type { HookDispatcher } from '@/server/services/agentRuntime/hooks/HookDispatcher';
import type {
ExecGroupMemberParams,
ExecGroupMemberResult,
} from '@/server/services/agentRuntime/types';
import {
type DeviceAccessReason,
isDeviceToolIdentifier,
@@ -99,7 +88,6 @@ import { FileService } from '@/server/services/file';
import { MessageService } from '@/server/services/message';
import { OnboardingService } from '@/server/services/onboarding';
import {
type ServerAgentMemberRunner,
type ServerSubAgentRunner,
type ToolExecutionResultResponse,
type ToolExecutionService,
@@ -335,7 +323,7 @@ const buildPostProcessUrl = (
};
/**
* Build the per-tool-call server virtual sub-agent runner injected into the tool
* Build the per-tool-call server sub-agent runner injected into the tool
* execution context. Closes over the current tool payload + parent message so
* the `callSubAgent` server tool can fork a child op without re-deriving the
* message anchor (which it cannot do correctly from its own context).
@@ -343,18 +331,17 @@ const buildPostProcessUrl = (
* The runner creates the pending placeholder tool message that anchors the
* isolation thread (so the UI shows a loading state and the completion bridge
* has a message to backfill), then kicks off the child op asynchronously and
* returns immediately. Returns `undefined` when virtual sub-agent execution is
* not available (no `execVirtualSubAgent` callback, or missing agent/topic
* context).
* returns immediately. Returns `undefined` when sub-agent execution is not
* available (no `execSubAgent` callback, or missing agent/topic context).
*/
const buildServerVirtualSubAgentRunner = (
const buildServerSubAgentRunner = (
ctx: RuntimeExecutorContext,
state: AgentState,
chatToolPayload: ChatToolPayload,
parentMessageId: string,
): ServerSubAgentRunner | undefined => {
const execVirtualSubAgent = ctx.execVirtualSubAgent;
if (!execVirtualSubAgent) return undefined;
const execSubAgent = ctx.execSubAgent;
if (!execSubAgent) return undefined;
const agentId = state.metadata?.agentId;
const topicId = ctx.topicId ?? state.metadata?.topicId;
@@ -377,15 +364,16 @@ const buildServerVirtualSubAgentRunner = (
topicId,
});
// 2. Fork the virtual child op anchored to the placeholder. The virtual
// entry marks the child as `isSubAgent` and registers the completion
// bridge that backfills this tool message and resumes the parent op.
const result = (await execVirtualSubAgent({
// 2. Fork the child op anchored to the placeholder. `resumeParentOnComplete`
// tells execSubAgent to register the completion bridge that
// backfills this tool message and resumes the parent op.
const result = (await execSubAgent({
agentId: targetAgentId ?? agentId,
groupId: state.metadata?.groupId ?? undefined,
instruction,
parentMessageId: placeholder.id,
parentOperationId: ctx.operationId,
resumeParentOnComplete: true,
timeout,
title: description,
topicId,
@@ -399,7 +387,7 @@ const buildServerVirtualSubAgentRunner = (
await ctx.messageModel.deleteMessage(placeholder.id);
} catch (error) {
log(
'buildServerVirtualSubAgentRunner: failed to clean up placeholder %s: %O',
'buildServerSubAgentRunner: failed to clean up placeholder %s: %O',
placeholder.id,
error,
);
@@ -416,147 +404,6 @@ const buildServerVirtualSubAgentRunner = (
};
};
/**
* Build the per-tool "call agent member" runner for the group orchestration
* server tool (`lobe-group-management`). Mirrors {@link buildServerVirtualSubAgentRunner}
* but for group members: it owns the group tool message (the parked tool call)
* and the per-member anchors that drive the K=N member barrier.
*
* For each `agentMember.run(...)` it:
* 1. creates the group tool placeholder (`tool_call_id` = the group-management
* call id) stamped with the barrier target + finish disposition;
* 2. for a single member uses that placeholder as the member anchor; for
* multiple members creates one child anchor per member under it;
* 3. forks each member via `ctx.execGroupMember` (in-group or isolated);
* 4. backfills anchors for members that failed to start so the barrier can
* still complete, and tears everything down when none started.
*
* Returns `undefined` when group-member execution is unavailable (no
* `execGroupMember` callback, or missing agent/topic/group context).
*/
const buildServerAgentMemberRunner = (
ctx: RuntimeExecutorContext,
state: AgentState,
chatToolPayload: ChatToolPayload,
parentMessageId: string,
): ServerAgentMemberRunner | undefined => {
const execGroupMember = ctx.execGroupMember;
if (!execGroupMember) return undefined;
const agentId = state.metadata?.agentId;
const topicId = ctx.topicId ?? state.metadata?.topicId;
const groupId = state.metadata?.groupId ?? undefined;
if (!agentId || !topicId || !groupId) return undefined;
return {
run: async ({ members, mode, onComplete, disableTools, timeout }) => {
const expectedMembers = members.length;
if (expectedMembers === 0) return { started: false, startedCount: 0 };
// 1. Group tool placeholder — the parked tool call the supervisor op waits
// on. Stamped with the barrier target + finish disposition so the resume
// path (and verify watchdog) resolve resume-vs-finish on their own.
const groupTool = await ctx.messageModel.create({
agentId,
content: '',
parentId: parentMessageId,
plugin: chatToolPayload as any,
pluginState: { expectedMembers, onComplete, status: 'pending' },
role: 'tool',
threadId: state.metadata?.threadId,
tool_call_id: chatToolPayload.id,
topicId,
});
// 2. Per-member anchors. A single member collapses onto the group tool
// message; multiple members each get a child anchor under it.
const anchorIds: string[] = [];
if (expectedMembers === 1) {
anchorIds.push(groupTool.id);
} else {
for (let i = 0; i < expectedMembers; i += 1) {
const memberToolCallId = `${chatToolPayload.id}::m${i}`;
const anchor = await ctx.messageModel.create({
agentId,
content: '',
parentId: groupTool.id,
plugin: { ...(chatToolPayload as any), id: memberToolCallId },
pluginState: { status: 'pending' },
role: 'tool',
threadId: state.metadata?.threadId,
tool_call_id: memberToolCallId,
topicId,
});
anchorIds.push(anchor.id);
}
}
// 3. Fork members.
let startedCount = 0;
await Promise.all(
members.map(async (member, i) => {
const anchorMessageId = anchorIds[i];
try {
const result = await execGroupMember({
agentId: member.agentId,
anchorMessageId,
disableTools,
expectedMembers,
groupId,
groupToolMessageId: groupTool.id,
instruction: member.instruction,
mode,
onComplete,
parentOperationId: ctx.operationId,
timeout,
topicId,
});
if (result?.started) {
startedCount += 1;
return;
}
} catch (error) {
log(
'buildServerAgentMemberRunner: member %s failed to start: %O',
member.agentId,
error,
);
}
// Member failed to start — its completion bridge will never fire, so
// backfill the anchor as errored to keep the K=N barrier reachable.
try {
await ctx.messageModel.updateToolMessage(anchorMessageId, {
content: `Agent member "${member.agentId}" failed to start.`,
pluginState: { status: 'error' },
});
} catch (error) {
log(
'buildServerAgentMemberRunner: failed to mark anchor %s as errored: %O',
anchorMessageId,
error,
);
}
}),
);
// None started — no bridge will ever fire, so tear down the placeholders
// and let the caller surface an inline tool error instead of parking.
if (startedCount === 0) {
for (const id of new Set([...anchorIds, groupTool.id])) {
try {
await ctx.messageModel.deleteMessage(id);
} catch (error) {
log('buildServerAgentMemberRunner: cleanup failed for %s: %O', id, error);
}
}
return { started: false, startedCount: 0 };
}
return { started: true, startedCount };
},
};
};
const shouldRetryLLM = (kind: LLMErrorKind, attempt: number, maxRetries: number) =>
kind === 'retry' && attempt <= maxRetries;
@@ -675,23 +522,11 @@ export interface RuntimeExecutorContext {
discordContext?: any;
evalContext?: EvalContext;
/**
* Callback to fork a group member ("call agent member") under a
* `lobe-group-management` tool call. Injected by AiAgentService; powers the
* per-tool `agentMember` runner (in-group + isolated members, K=N barrier).
*/
execGroupMember?: (params: ExecGroupMemberParams) => Promise<ExecGroupMemberResult>;
/**
* Callback to run a legacy agent invocation server-side.
* Callback to spawn a sub-agent task server-side.
* Injected by AiAgentService so exec_sub_agent / exec_sub_agents executors
* can dispatch callAgent-triggered runs without a circular import.
* can dispatch callAgent-triggered tasks without a circular import.
*/
execSubAgent?: (params: ExecSubAgentParams) => Promise<unknown>;
/**
* Callback to fork a `lobe-agent.callSubAgent` virtual child run. Unlike
* execSubAgent, this path installs the async completion bridge and marks the
* child operation as a sub-agent.
*/
execVirtualSubAgent?: (params: ExecVirtualSubAgentParams) => Promise<unknown>;
hookDispatcher?: HookDispatcher;
loadAgentState?: (operationId: string) => Promise<AgentState | null>;
messageModel: MessageModel;
@@ -879,7 +714,6 @@ export const createRuntimeExecutors = (
type ContentPart = { text: string; type: 'text' } | { image: string; type: 'image' };
let shouldReplayAssistantReasoning = false;
let preserveThinkingForPayload: boolean | undefined;
let resolvedExtendParams: ModelExtendParams | undefined;
// Process messages through serverMessagesEngine to inject system role, knowledge, etc.
// Rebuild params from agentConfig at execution time (capabilities built dynamically)
@@ -895,36 +729,19 @@ export const createRuntimeExecutors = (
: undefined;
const preserveThinkingRequested = preserveThinkingConfigured === true;
const readExtendParams = (
card: (typeof builtinModels)[number] | undefined,
): string[] | undefined =>
card &&
'settings' in card &&
card.settings &&
typeof card.settings === 'object' &&
'extendParams' in card.settings
? (card.settings as { extendParams?: string[] }).extendParams
: undefined;
const modelCard = builtinModels.find(
(item) =>
item.providerId === provider &&
(item.id === model || item.config?.deploymentName === model),
);
let modelExtendParams = readExtendParams(modelCard);
// Aggregation providers (e.g. `lobehub`) may serve a model without copying
// its origin `settings.extendParams`. Fall back to the canonical model card
// (matched by id across any provider) so reasoning/thinking params like
// `thinkingLevel` still reach the model. Mirrors the client-side
// `transformToAiModelList` re-namespacing behavior.
if (!modelExtendParams || modelExtendParams.length === 0) {
const canonicalCard = builtinModels.find(
(item) => item.id === model || item.config?.deploymentName === model,
);
modelExtendParams = readExtendParams(canonicalCard);
}
const modelExtendParams =
modelCard &&
'settings' in modelCard &&
modelCard.settings &&
typeof modelCard.settings === 'object' &&
'extendParams' in modelCard.settings
? (modelCard.settings as { extendParams?: string[] }).extendParams
: undefined;
const modelSupportsPreserveThinkingFromCard =
Array.isArray(modelExtendParams) && modelExtendParams.includes('preserveThinking');
@@ -939,19 +756,6 @@ export const createRuntimeExecutors = (
modelSupportsPreserveThinking && typeof preserveThinkingConfigured === 'boolean'
? preserveThinkingConfigured
: undefined;
// Resolve model extend params (thinkingLevel, reasoning effort, urlContext, …)
// from the agent chat config so the server-side agent runtime forwards the same
// runtime params the client chat service does. Without this, e.g. Gemini 3 Pro's
// `thinkingLevel` never reaches the request and thought summaries come back empty.
if (agentConfig.chatConfig) {
resolvedExtendParams = applyModelExtendParams({
chatConfig: agentConfig.chatConfig,
extendParams: modelExtendParams as ExtendParamsType[] | undefined,
model,
});
}
const messagesForContext = shouldReplayAssistantReasoning
? (llmPayload.messages as UIChatMessage[])
: stripAssistantReasoningForReplay(llmPayload.messages as UIChatMessage[]);
@@ -1188,38 +992,39 @@ export const createRuntimeExecutors = (
}
}
// {{COMPOSIO_SERVICES_LIST}} — used by lobe-creds system role (Composio integrations section).
let composioServicesListStr = '';
if (ctx.serverDB && ctx.userId && !!composioEnv.COMPOSIO_API_KEY) {
// {{KLAVIS_SERVICES_LIST}} — used by lobe-creds system role (Klavis integrations section).
// Mirrors client-side: klavisStoreSelectors.getServers() filtered by connection status.
let klavisServicesListStr = '';
if (ctx.serverDB && ctx.userId && !!klavisEnv.KLAVIS_API_KEY) {
try {
const { PluginModel } = await import('@/database/models/plugin');
const pluginModel = new PluginModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
const allPlugins = await pluginModel.query();
const validComposioIds = new Set(COMPOSIO_APP_TYPES.map((t) => t.identifier));
const validKlavisIds = new Set(KLAVIS_SERVER_TYPES.map((t) => t.identifier));
const connectedIds = new Set(
allPlugins
.filter(
(p) =>
validComposioIds.has(p.identifier) &&
(p.customParams as any)?.composio?.status === 'ACTIVE',
validKlavisIds.has(p.identifier) &&
(p.customParams as any)?.klavis?.isAuthenticated === true,
)
.map((p) => p.identifier),
);
const connected: ComposioServiceSummary[] = COMPOSIO_APP_TYPES.filter((t) =>
const connected: KlavisServiceSummary[] = KLAVIS_SERVER_TYPES.filter((t) =>
connectedIds.has(t.identifier),
).map((t) => ({ identifier: t.identifier, name: t.label }));
const available: ComposioServiceSummary[] = COMPOSIO_APP_TYPES.filter(
const available: KlavisServiceSummary[] = KLAVIS_SERVER_TYPES.filter(
(t) => !connectedIds.has(t.identifier),
).map((t) => ({ identifier: t.identifier, name: t.label }));
composioServicesListStr = generateComposioServicesList(connected, available);
klavisServicesListStr = generateKlavisServicesList(connected, available);
log(
'Fetched Composio services for {{COMPOSIO_SERVICES_LIST}}: connected=%d, available=%d',
'Fetched Klavis services for {{KLAVIS_SERVICES_LIST}}: connected=%d, available=%d',
connected.length,
available.length,
);
} catch (error) {
log(
'Failed to fetch Composio services for {{COMPOSIO_SERVICES_LIST}} substitution: %O',
'Failed to fetch Klavis services for {{KLAVIS_SERVICES_LIST}} substitution: %O',
error,
);
}
@@ -1243,7 +1048,7 @@ export const createRuntimeExecutors = (
sandbox_enabled: sandboxEnabled,
sandbox_uploaded_files: sandboxUploadedFiles,
CREDS_LIST: credsListStr,
COMPOSIO_SERVICES_LIST: composioServicesListStr,
KLAVIS_SERVICES_LIST: klavisServicesListStr,
// Memory tool variables
memory_effort: memoryEffort,
},
@@ -1393,9 +1198,6 @@ export const createRuntimeExecutors = (
model,
stream,
tools,
// ModelExtendParams keeps provider-specific effort/thinking values as loose
// strings (e.g. hy3's 'no_think'); the runtime payload narrows them, so cast.
...(resolvedExtendParams as Partial<ChatStreamPayload>),
...(typeof preserveThinkingForPayload === 'boolean' && {
preserveThinking: preserveThinkingForPayload,
}),
@@ -2637,7 +2439,7 @@ export const createRuntimeExecutors = (
execution = { attempts: 1, result: dispatchResult };
} else {
// Inject source from sourceMap so BuiltinToolsExecutor can route
// lobehubSkill / composio tools correctly (LLM responses don't carry source)
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
if (toolSource && !chatToolPayload.source) {
chatToolPayload.source = toolSource;
}
@@ -2654,14 +2456,7 @@ export const createRuntimeExecutors = (
toolExecutionService.executeTool(chatToolPayload, {
activeDeviceId: state.metadata?.activeDeviceId,
agentId: state.metadata?.agentId,
agentMember: buildServerAgentMemberRunner(
ctx,
state,
chatToolPayload,
payload.parentMessageId,
),
documentId: state.metadata?.documentId,
editingAgentId: state.metadata?.editingAgentId,
execSubAgent: ctx.execSubAgent,
executionTimeoutMs: timeoutMs,
groupId: state.metadata?.groupId,
@@ -2681,7 +2476,7 @@ export const createRuntimeExecutors = (
scope: state.metadata?.scope,
serverDB: ctx.serverDB,
skipResultTruncation: true,
subAgent: buildServerVirtualSubAgentRunner(
subAgent: buildServerSubAgentRunner(
ctx,
state,
chatToolPayload,
@@ -2923,15 +2718,14 @@ export const createRuntimeExecutors = (
log('[%s:%d] Tool execution completed', operationId, stepIndex);
// When a legacy callAgent task result carries execSubAgent / execSubAgents
// state, the GeneralChatAgent needs `stop: true` in the payload to detect
// it and emit the matching exec_sub_agent / exec_sub_agents instruction.
// Without this flag the agent falls through to the normal LLM-call path
// and the background agent run is never spawned.
const legacyAgentInvocationStateType = executionResult.state?.type as string | undefined;
const isLegacyAgentInvocationState =
legacyAgentInvocationStateType === 'execSubAgent' ||
legacyAgentInvocationStateType === 'execSubAgents';
// When the tool result carries an execSubAgent / execSubAgents state the
// GeneralChatAgent needs `stop: true` in the payload to detect it and
// emit the matching exec_sub_agent / exec_sub_agents instruction. Without
// this flag the agent falls through to the normal LLM-call path and the
// sub-agent is never spawned.
const execTaskStateType = executionResult.state?.type as string | undefined;
const isExecTaskState =
execTaskStateType === 'execSubAgent' || execTaskStateType === 'execSubAgents';
executeToolSpan.setAttributes(
buildExecuteToolResultAttributes({ attempts: execution.attempts, success: isSuccess }),
@@ -2947,7 +2741,7 @@ export const createRuntimeExecutors = (
isSuccess,
// Pass tool message ID as parentMessageId for the next LLM call
parentMessageId: toolMessageId,
...(isLegacyAgentInvocationState && { stop: true }),
...(isExecTaskState && { stop: true }),
toolCall: chatToolPayload,
toolCallId: chatToolPayload.id,
},
@@ -3224,7 +3018,7 @@ export const createRuntimeExecutors = (
execution = { attempts: 1, result: dispatchResult };
} else {
// Inject source from sourceMap so BuiltinToolsExecutor can route
// lobehubSkill / composio tools correctly (LLM responses don't carry source)
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
const batchToolSource =
state.operationToolSet?.sourceMap?.[chatToolPayload.identifier] ??
state.toolSourceMap?.[chatToolPayload.identifier];
@@ -3243,12 +3037,6 @@ export const createRuntimeExecutors = (
toolExecutionService.executeTool(chatToolPayload, {
activeDeviceId: state.metadata?.activeDeviceId,
agentId: state.metadata?.agentId,
agentMember: buildServerAgentMemberRunner(
ctx,
state,
chatToolPayload,
payload.parentMessageId,
),
documentId: state.metadata?.documentId,
execSubAgent: ctx.execSubAgent,
executionTimeoutMs: timeoutMs,
@@ -3260,7 +3048,7 @@ export const createRuntimeExecutors = (
scope: state.metadata?.scope,
serverDB: ctx.serverDB,
skipResultTruncation: true,
subAgent: buildServerVirtualSubAgentRunner(
subAgent: buildServerSubAgentRunner(
ctx,
state,
chatToolPayload,
@@ -58,9 +58,6 @@ vi.mock('@/server/services/message', () => ({
// @lobechat/model-runtime resolves to @cloud/business-model-runtime which has
// cloud-specific dependencies that are unavailable in the test environment
vi.mock('@lobechat/model-runtime', () => ({
// The executor resolves extend params via this helper; an empty result keeps
// the runtime payload unchanged, matching this suite's pre-existing behavior.
applyModelExtendParams: vi.fn(() => ({})),
consumeStreamUntilDone: vi.fn().mockResolvedValue(undefined),
// `llmErrorClassification.ts` reads these at module-load time; an empty
// spec map is fine here because this suite never exercises the runtime
@@ -79,11 +76,11 @@ vi.mock('model-bank', () => ({
LOBE_DEFAULT_MODEL_LIST: mockBuiltinModels,
}));
// composioEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
vi.mock('@/config/composio', () => ({
getComposioConfig: vi.fn(),
getServerComposioApiKey: vi.fn().mockReturnValue(undefined),
composioEnv: { COMPOSIO_API_KEY: undefined },
// klavisEnv uses @t3-oss/env-nextjs which throws in jsdom (treats it as client context)
vi.mock('@/config/klavis', () => ({
getKlavisConfig: vi.fn(),
getServerKlavisApiKey: vi.fn().mockReturnValue(undefined),
klavisEnv: { KLAVIS_API_KEY: undefined },
}));
// fileEnv uses @t3-oss/env-core; stub the only field the runtime reads so the
@@ -128,7 +125,6 @@ describe('RuntimeExecutors', () => {
mockMessageModel = {
create: vi.fn().mockResolvedValue({ id: 'msg-123' }),
deleteMessage: vi.fn().mockResolvedValue({ success: true }),
// call_llm does a parent existence preflight; return a truthy row by
// default so existing tests don't have to stub it.
findById: vi.fn().mockResolvedValue({ id: 'msg-existing' }),
@@ -4854,9 +4850,10 @@ describe('RuntimeExecutors', () => {
...overrides,
});
it('call_tool preserves stop:true for legacy execSubAgent state', async () => {
it('call_tool sets stop:true in tool_result payload when tool returns execSubAgent state', async () => {
// Simulate agentManagement.callAgent returning execSubAgent state
mockToolExecutionService.executeTool.mockResolvedValue({
content: 'Legacy async task result',
content: '🚀 Triggered async task to call agent "target-agent"',
executionTime: 10,
state: {
parentMessageId: 'tool-msg-id',
@@ -4897,112 +4894,13 @@ describe('RuntimeExecutors', () => {
expect((result.nextContext?.payload as any).stop).toBe(true);
});
it('call_tool lets server callAgent run as a deferred tool via the subAgent runner', async () => {
const mockExecVirtualSubAgent = vi
.fn()
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
const ctxWithCallback = {
...ctx,
execVirtualSubAgent: mockExecVirtualSubAgent,
topicId: 'topic-123',
};
mockMessageModel.create.mockResolvedValueOnce({ id: 'tool-msg-id' });
mockToolExecutionService.executeTool.mockImplementation(
async (_payload: any, context: any) => {
const subAgent = await context.subAgent.run({
agentId: 'target-agent-id',
description: 'Call agent target-agent',
instruction: 'Do something useful',
timeout: 1_800_000,
});
return {
content: '',
deferred: true,
executionTime: 10,
state: {
status: 'pending',
subOperationId: subAgent.subOperationId,
targetAgentId: 'target-agent-id',
threadId: subAgent.threadId,
},
success: subAgent.started,
};
},
);
const executors = createRuntimeExecutors(ctxWithCallback);
const state = createMockState();
const instruction = {
payload: {
parentMessageId: 'assistant-msg-id',
toolCalling: {
apiName: 'callAgent',
arguments: JSON.stringify({
agentId: 'target-agent-id',
instruction: 'Do something useful',
runAsTask: true,
}),
id: 'tool-call-1',
identifier: 'lobe-agent-management',
type: 'default' as const,
},
},
type: 'call_tool' as const,
};
const result = await executors.call_tool!(instruction, state);
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'parent-agent-id',
plugin: expect.objectContaining({
apiName: 'callAgent',
identifier: 'lobe-agent-management',
}),
pluginState: { status: 'pending' },
parentId: 'assistant-msg-id',
role: 'tool',
tool_call_id: 'tool-call-1',
topicId: 'topic-123',
}),
);
expect(mockExecVirtualSubAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'target-agent-id',
instruction: 'Do something useful',
parentMessageId: 'tool-msg-id',
parentOperationId: 'op-123',
title: 'Call agent target-agent',
topicId: 'topic-123',
}),
);
expect(result.newState.status).toBe('waiting_for_async_tool');
expect(result.newState.pendingToolsCalling).toEqual([
expect.objectContaining({
apiName: 'callAgent',
id: 'tool-call-1',
identifier: 'lobe-agent-management',
}),
]);
expect(result.events).toEqual([
expect.objectContaining({
canResume: true,
reason: 'async_tool',
type: 'interrupted',
}),
]);
expect(result.nextContext).toBeUndefined();
});
it('exec_sub_agent executor creates task message and calls execSubAgent callback', async () => {
const mockExecSubAgent = vi
const mockExecSubAgentTask = vi
.fn()
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
const ctxWithCallback = {
...ctx,
execSubAgent: mockExecSubAgent,
execSubAgent: mockExecSubAgentTask,
topicId: 'topic-123',
};
@@ -5028,9 +4926,6 @@ describe('RuntimeExecutors', () => {
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'parent-agent-id',
metadata: expect.objectContaining({
targetAgentId: 'target-agent-id',
}),
role: 'task',
parentId: 'tool-msg-id',
topicId: 'topic-123',
@@ -5038,7 +4933,7 @@ describe('RuntimeExecutors', () => {
);
// execSubAgent callback fired with targetAgentId
expect(mockExecSubAgent).toHaveBeenCalledWith(
expect(mockExecSubAgentTask).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'target-agent-id',
instruction: 'Do something useful',
@@ -5052,10 +4947,10 @@ describe('RuntimeExecutors', () => {
});
it('exec_sub_agent blocks nested dispatch when current state is already a sub-agent', async () => {
const mockExecSubAgent = vi.fn();
const mockExecSubAgentTask = vi.fn();
const ctxWithCallback = {
...ctx,
execSubAgent: mockExecSubAgent,
execSubAgentTask: mockExecSubAgentTask,
topicId: 'topic-123',
};
@@ -5088,7 +4983,7 @@ describe('RuntimeExecutors', () => {
success: false,
});
expect(mockMessageModel.create).not.toHaveBeenCalled();
expect(mockExecSubAgent).not.toHaveBeenCalled();
expect(mockExecSubAgentTask).not.toHaveBeenCalled();
});
it('exec_sub_agent gracefully skips dispatch when execSubAgent not injected', async () => {
@@ -132,14 +132,6 @@ describe('formatErrorForState', () => {
expect(result.countAsFailure).toBeUndefined();
expect(result.numericId).toBeUndefined();
});
it('classifies a raw Drizzle "Failed query" Error via its message instead of a bare 500', () => {
const result = formatErrorForState(new Error('Failed query: rollback\nparams: '));
expect(result.type).toBe(AgentRuntimeErrorType.DatabasePersistError);
expect(result.numericId).toBe(7004);
expect(result.attribution).toBe('harness');
});
});
describe('ProviderBizError refinement', () => {
@@ -659,59 +659,6 @@ describe('createServerAgentToolsEngine', () => {
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should disable RemoteDevice when a device is explicitly bound (locked to the selection)', () => {
// A user-selected (bound) device locks the run to that device — the
// activate-device tool is never offered, so the model cannot switch.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
canUseDevice: true,
deviceContext: {
autoActivated: true,
boundDeviceId: 'device-001',
deviceOnline: true,
gatewayConfigured: true,
},
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should disable RemoteDevice when the bound device is OFFLINE — no silent hop to another machine', () => {
// The bound device going offline makes the plan device-unrouted, so
// `autoActivated` is false. Without the `boundDeviceId` gate the tool
// would resurface and let the model activate a *different* online device.
// The explicit selection must keep the run locked instead.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
canUseDevice: true,
deviceContext: {
boundDeviceId: 'device-001',
deviceOnline: true,
gatewayConfigured: true,
},
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should enable RemoteDevice in bot conversations when caller is trusted (canUseDevice=true)', () => {
// The `!isBotConversation` clause was dropped in — the
// confused-deputy concern that motivated it is now handled at a
@@ -86,7 +86,7 @@ export const createServerToolsEngine = (
// Combine all manifests, then drop anything whose identifier the caller
// has explicitly forbidden for this turn. The post-merge filter closes
// the second half of the wall: an installed plugin or a
// Skill/Composio manifest claiming `lobe-remote-device` would otherwise
// Skill/Klavis manifest claiming `lobe-remote-device` would otherwise
// slip through `buildAllowedBuiltinTools` (which only touches the
// builtin source).
const combinedManifests = [...pluginManifests, ...builtinManifests, ...additionalManifests];
@@ -231,20 +231,12 @@ export const createServerAgentToolsEngine = (
// Only auto-enable in bot conversations; otherwise let user's plugin selection take effect
...(isBotConversation && { [MessageManifest.identifier]: true }),
// Remote-device proxy: shown only for device-capable targets when the
// server has a proxy, no specific device is auto-activated yet, AND the
// user has NOT explicitly selected a device. Once a device is explicitly
// selected (`boundDeviceId`), the run is locked to it: we never expose the
// activate-device tool, so the model can never switch to another machine —
// not even when the selected device is offline (the run stays unrouted
// until that device comes back, rather than silently hopping elsewhere).
// External bot senders never reach it: the plan degrades denied targets to
// `none` (→ not deviceCapable) and the physical manifest walls drop it for
// `canUseDevice=false` turns.
// server has a proxy but no specific device is auto-activated yet (user
// must pick). External bot senders never reach it: the plan degrades
// denied targets to `none` (→ not deviceCapable) and the physical
// manifest walls drop it for `canUseDevice=false` turns.
[RemoteDeviceManifest.identifier]:
deviceCapable &&
hasDeviceProxy &&
!deviceContext?.autoActivated &&
!deviceContext?.boundDeviceId,
deviceCapable && hasDeviceProxy && !deviceContext?.autoActivated,
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
[WebBrowsingManifest.identifier]: isSearchEnabled,
};
@@ -264,7 +256,7 @@ export const createServerAgentToolsEngine = (
: isChatMode
? chatModeAllowedToolIds
: defaultToolIds,
// Post-merge wall: a plugin or Skill/Composio manifest claiming a
// Post-merge wall: a plugin or Skill/Klavis manifest claiming a
// device identifier survives `buildAllowedBuiltinTools` (which only
// filters the builtin source). Excluding the identifiers here drops
// them from the combined `manifestSchemas` so the activator cannot
@@ -22,7 +22,7 @@ export interface ServerAgentToolsContext {
* Configuration options for createServerToolsEngine
*/
export interface ServerAgentToolsEngineConfig {
/** Additional manifests to include (e.g., Composio tools) */
/** Additional manifests to include (e.g., Klavis tools) */
additionalManifests?: LobeToolManifest[];
/**
* Override the list of builtin tools fed into the engine's
@@ -39,7 +39,7 @@ export interface ServerAgentToolsEngineConfig {
/**
* Identifiers to drop from `manifestSchemas` after combining plugin,
* builtin, and additional manifests. Filtering builtins alone is not
* enough: an installed plugin or a Skill/Composio manifest can declare
* enough: an installed plugin or a Skill/Klavis manifest can declare
* `identifier: 'lobe-remote-device'` and slip past `buildAllowedBuiltinTools`.
* This is the final post-merge wall referenced in .
*/
@@ -9,16 +9,10 @@ import { KnowledgeBaseModel } from '@/database/models/knowledgeBase';
import { SessionModel } from '@/database/models/session';
import { UserModel } from '@/database/models/user';
import { AgentService } from '@/server/services/agent';
import { EditLockService } from '@/server/services/editLock';
import { publishResourceEvent } from '@/server/services/resourceEvents';
import { KnowledgeType } from '@/types/knowledgeBase';
import { agentRouter } from '../agent';
vi.mock('@/server/services/resourceEvents', () => ({ publishResourceEvent: vi.fn() }));
const publishResourceEventMock = vi.mocked(publishResourceEvent);
vi.mock('@/database/models/user', () => ({
UserModel: {
findById: vi.fn(),
@@ -335,122 +329,4 @@ describe('agentRouter', () => {
expect(agentModelMock.update).toHaveBeenCalledWith(mockInput.id, { pinned: false });
});
});
describe('edit lock', () => {
const wsCtx = () => ({ ...mockCtx, workspaceId: 'ws-1' });
describe('updateAgentConfig write guard', () => {
it('rejects the update when another member holds the lock', async () => {
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
const caller = agentRouter.createCaller(wsCtx());
await expect(
caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } }),
).rejects.toMatchObject({ code: 'CONFLICT' });
expect(agentServiceMock.updateAgentConfig).not.toHaveBeenCalled();
});
it('allows the update when no other member holds the lock', async () => {
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
const caller = agentRouter.createCaller(wsCtx());
await caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } });
expect(agentServiceMock.updateAgentConfig).toHaveBeenCalledWith('agent-1', {
systemRole: 'x',
});
});
it('does not check the lock for personal (non-workspace) agents', async () => {
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
const caller = agentRouter.createCaller(mockCtx);
await caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } });
expect(guardSpy).not.toHaveBeenCalled();
expect(agentServiceMock.updateAgentConfig).toHaveBeenCalled();
});
});
describe('acquireAgentLock', () => {
it('returns unlocked without touching the lock service for personal agents', async () => {
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
const caller = agentRouter.createCaller(mockCtx);
const result = await caller.acquireAgentLock({ agentId: 'agent-1' });
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
expect(acquireSpy).not.toHaveBeenCalled();
});
it('broadcasts lock.changed on a holder edge (first claim)', async () => {
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
});
const caller = agentRouter.createCaller(wsCtx());
await caller.acquireAgentLock({ agentId: 'agent-1' });
expect(publishResourceEventMock).toHaveBeenCalledWith(
{ id: 'agent-1', type: 'agent' },
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
);
});
it('does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
});
const caller = agentRouter.createCaller(wsCtx());
await caller.acquireAgentLock({ agentId: 'agent-1' });
expect(publishResourceEventMock).not.toHaveBeenCalled();
});
});
describe('getAgentLock', () => {
it('reports another member as the holder', async () => {
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue('other-user');
const caller = agentRouter.createCaller(wsCtx());
const result = await caller.getAgentLock({ agentId: 'agent-1' });
expect(result).toEqual({ expiresAt: null, holderId: 'other-user', lockedByOther: true });
});
});
describe('releaseAgentLock', () => {
it('broadcasts unlocked only when it actually freed the lock', async () => {
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
const caller = agentRouter.createCaller(wsCtx());
await caller.releaseAgentLock({ agentId: 'agent-1' });
expect(publishResourceEventMock).toHaveBeenCalledWith(
{ id: 'agent-1', type: 'agent' },
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
);
});
it('does NOT broadcast when the lease expired / was taken over', async () => {
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
const caller = agentRouter.createCaller(wsCtx());
await caller.releaseAgentLock({ agentId: 'agent-1' });
expect(publishResourceEventMock).not.toHaveBeenCalled();
});
});
});
});
@@ -7,15 +7,9 @@ import * as ChatGroupModelModule from '@/database/models/chatGroup';
import * as UserModelModule from '@/database/models/user';
import * as AgentGroupRepoModule from '@/database/repositories/agentGroup';
import * as ChatGroupServiceModule from '@/server/services/agentGroup';
import { EditLockService } from '@/server/services/editLock';
import { publishResourceEvent } from '@/server/services/resourceEvents';
import { agentGroupRouter } from '../agentGroup';
vi.mock('@/server/services/resourceEvents', () => ({ publishResourceEvent: vi.fn() }));
const publishResourceEventMock = vi.mocked(publishResourceEvent);
describe('agentGroupRouter', () => {
const userId = 'testUserId';
let mockCtx: any;
@@ -445,126 +439,4 @@ describe('agentGroupRouter', () => {
expect(result).toEqual(mockUpdatedGroup);
});
});
describe('edit lock', () => {
const wsCtx = () => ({ serverDB: {}, userId, workspaceId: 'ws-1' });
describe('updateGroup write guard', () => {
it('rejects the update when another member holds the lock', async () => {
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
const caller = agentGroupRouter.createCaller(wsCtx());
await expect(
caller.updateGroup({ id: 'group-1', value: { title: 'New' } }),
).rejects.toMatchObject({ code: 'CONFLICT' });
expect(chatGroupModelMock.update).not.toHaveBeenCalled();
});
it('allows the update when no other member holds the lock', async () => {
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
chatGroupModelMock.update.mockResolvedValue({ id: 'group-1' });
const caller = agentGroupRouter.createCaller(wsCtx());
await caller.updateGroup({ id: 'group-1', value: { title: 'New' } });
expect(chatGroupModelMock.update).toHaveBeenCalled();
});
it('does not check the lock for personal (non-workspace) groups', async () => {
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
chatGroupModelMock.update.mockResolvedValue({ id: 'group-1' });
const caller = agentGroupRouter.createCaller(mockCtx);
await caller.updateGroup({ id: 'group-1', value: { title: 'New' } });
expect(guardSpy).not.toHaveBeenCalled();
expect(chatGroupModelMock.update).toHaveBeenCalled();
});
});
describe('acquireGroupLock', () => {
it('returns unlocked without touching the lock service for personal groups', async () => {
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
const caller = agentGroupRouter.createCaller(mockCtx);
const result = await caller.acquireGroupLock({ id: 'group-1' });
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
expect(acquireSpy).not.toHaveBeenCalled();
});
it('broadcasts lock.changed on a holder edge (first claim)', async () => {
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
});
const caller = agentGroupRouter.createCaller(wsCtx());
await caller.acquireGroupLock({ id: 'group-1' });
expect(publishResourceEventMock).toHaveBeenCalledWith(
{ id: 'group-1', type: 'chatGroup' },
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
);
});
it('does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
expiresAt: new Date(),
holderId: userId,
lockedByOther: false,
});
const caller = agentGroupRouter.createCaller(wsCtx());
await caller.acquireGroupLock({ id: 'group-1' });
expect(publishResourceEventMock).not.toHaveBeenCalled();
});
});
describe('getGroupLock', () => {
it('reports another member as the holder', async () => {
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue('other-user');
const caller = agentGroupRouter.createCaller(wsCtx());
const result = await caller.getGroupLock({ id: 'group-1' });
expect(result).toEqual({ expiresAt: null, holderId: 'other-user', lockedByOther: true });
});
it('returns unlocked for personal groups', async () => {
const caller = agentGroupRouter.createCaller(mockCtx);
const result = await caller.getGroupLock({ id: 'group-1' });
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
});
});
describe('releaseGroupLock', () => {
it('broadcasts unlocked only when it actually freed the lock', async () => {
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
const caller = agentGroupRouter.createCaller(wsCtx());
await caller.releaseGroupLock({ id: 'group-1' });
expect(publishResourceEventMock).toHaveBeenCalledWith(
{ id: 'group-1', type: 'chatGroup' },
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
);
});
it('does NOT broadcast when the lease expired / was taken over', async () => {
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
const caller = agentGroupRouter.createCaller(wsCtx());
await caller.releaseGroupLock({ id: 'group-1' });
expect(publishResourceEventMock).not.toHaveBeenCalled();
});
});
});
});
@@ -119,7 +119,7 @@ describe('aiChatRouter', () => {
expect(mockCreateUserAndAssistantMessages).toHaveBeenCalledTimes(1);
expect(mockCreateUserAndAssistantMessages).toHaveBeenCalledWith(
expect.any(Object),
expect.not.objectContaining({ touchTopicUpdatedAt: expect.anything() }),
expect.objectContaining({ touchTopicUpdatedAt: false }),
);
expect(mockGet).toHaveBeenCalledWith(
@@ -161,7 +161,7 @@ describe('aiChatRouter', () => {
expect(mockCreateMessage).toHaveBeenCalled();
expect(mockCreateUserAndAssistantMessages).toHaveBeenCalledWith(
expect.any(Object),
expect.not.objectContaining({ touchTopicUpdatedAt: expect.anything() }),
expect.objectContaining({ touchTopicUpdatedAt: true }),
);
expect(mockGet).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1,284 +0,0 @@
// @vitest-environment node
/**
* Integration test for the server `lobe-agent-management.callAgent` deferred
* execution flow.
*
* Verifies the full lifecycle end-to-end on the in-memory runtime:
* 1. Parent op LLM emits a `lobe-agent-management____callAgent` tool call.
* 2. The real server executor parks the parent, creates a pending tool
* placeholder, and forks the target agent as a child op.
* 3. The child op completes.
* 4. The completion bridge backfills the placeholder and resumes the parent.
* 5. The parent reaches `done`.
*/
import { type LobeChatDatabase } from '@lobechat/database';
import { agentOperations, agents, messagePlugins, messages } from '@lobechat/database/schemas';
import { getTestDB } from '@lobechat/database/test-utils';
import { and, eq } from 'drizzle-orm';
import OpenAI from 'openai';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { inMemoryAgentStateManager } from '@/server/modules/AgentRuntime/InMemoryAgentStateManager';
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
import { aiAgentRouter } from '../../../aiAgent';
import { cleanupTestUser, createTestUser } from '../setup';
import { createMockResponsesStream, waitForOperationComplete } from './helpers';
process.env.OPENAI_API_KEY = 'sk-test-fake-api-key-for-testing';
let testDB: LobeChatDatabase;
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: vi.fn(() => testDB),
}));
vi.mock('@/server/services/file', () => ({
FileService: vi.fn().mockImplementation(() => ({
getFullFileUrl: vi.fn().mockImplementation((path: string) => (path ? `/files${path}` : null)),
})),
}));
let mockResponsesCreate: ReturnType<typeof vi.spyOn>;
let serverDB: LobeChatDatabase;
let userId: string;
let parentAgentId: string;
let targetAgentId: string;
const TARGET_ANSWER = 'The target agent completed the delegated callAgent work.';
const PARENT_FINAL = 'I received the target agent result and the delegated work is complete.';
const createTestContext = () => ({ jwtPayload: { userId }, userId });
const createCallAgentResponse = () => {
const responseId = `resp_call_agent_${Date.now()}`;
const msgItemId = `msg_call_agent_${Date.now()}`;
const callId = 'call_agent_1';
const fnCall = {
arguments: JSON.stringify({
agentId: targetAgentId,
instruction: 'Handle the delegated backend integration task.',
runAsTask: true,
taskTitle: 'Delegated backend integration task',
timeout: 30_000,
}),
call_id: callId,
name: 'lobe-agent-management____callAgent',
type: 'function_call',
};
return createMockResponsesStream([
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [],
status: 'in_progress',
},
type: 'response.created',
},
{
item: {
content: [],
id: msgItemId,
role: 'assistant',
status: 'in_progress',
type: 'message',
},
output_index: 0,
type: 'response.output_item.added',
},
{
content_index: 0,
delta: 'I will delegate this to the target agent.',
item_id: msgItemId,
output_index: 0,
type: 'response.output_text.delta',
},
{ item: fnCall, output_index: 1, type: 'response.output_item.added' },
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [
{
content: [{ text: 'I will delegate this to the target agent.', type: 'output_text' }],
id: msgItemId,
role: 'assistant',
status: 'completed',
type: 'message',
},
fnCall,
],
status: 'completed',
usage: { input_tokens: 30, output_tokens: 20, total_tokens: 50 },
},
type: 'response.completed',
},
]);
};
const createFinalTextResponse = (content: string) => {
const responseId = `resp_final_${Date.now()}_${content.length}`;
const msgItemId = `msg_final_${Date.now()}_${content.length}`;
return createMockResponsesStream([
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [],
status: 'in_progress',
},
type: 'response.created',
},
{
content_index: 0,
delta: content,
item_id: msgItemId,
output_index: 0,
type: 'response.output_text.delta',
},
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [
{
content: [{ text: content, type: 'output_text' }],
id: msgItemId,
role: 'assistant',
status: 'completed',
type: 'message',
},
],
status: 'completed',
usage: { input_tokens: 40, output_tokens: 20, total_tokens: 60 },
},
type: 'response.completed',
},
]);
};
beforeEach(async () => {
serverDB = await getTestDB();
testDB = serverDB;
userId = await createTestUser(serverDB);
const insertedAgents = await serverDB
.insert(agents)
.values([
{
chatConfig: {},
model: 'gpt-5-pro',
plugins: ['lobe-agent-management'],
provider: 'openai',
systemRole: 'You are a supervisor that delegates work to other agents.',
title: 'callAgent Supervisor',
userId,
},
{
chatConfig: {},
model: 'gpt-5-pro',
plugins: [],
provider: 'openai',
systemRole: 'You are the target agent. Return a concise result.',
title: 'callAgent Target',
userId,
},
])
.returning();
parentAgentId = insertedAgents[0].id;
targetAgentId = insertedAgents[1].id;
mockResponsesCreate = vi.spyOn(OpenAI.Responses.prototype, 'create');
});
afterEach(async () => {
await cleanupTestUser(serverDB, userId);
vi.clearAllMocks();
vi.restoreAllMocks();
inMemoryAgentStateManager.clear();
inMemoryStreamEventManager.clear();
});
describe('Server callAgent deferred execution', () => {
it('parks the parent, runs the target agent, backfills the tool message and resumes', async () => {
let callCount = 0;
mockResponsesCreate.mockImplementation(() => {
callCount++;
if (callCount === 1) return Promise.resolve(createCallAgentResponse() as any);
if (callCount === 2) return Promise.resolve(createFinalTextResponse(TARGET_ANSWER) as any);
return Promise.resolve(createFinalTextResponse(PARENT_FINAL) as any);
});
const caller = aiAgentRouter.createCaller(createTestContext());
const createResult = await caller.execAgent({
agentId: parentAgentId,
prompt: 'Delegate this work to the target agent and report back.',
userInterventionConfig: { approvalMode: 'headless' },
});
expect(createResult.success).toBe(true);
const finalState = await waitForOperationComplete(
inMemoryAgentStateManager,
createResult.operationId,
{ maxWaitTime: 20_000 },
);
expect(finalState.status).toBe('done');
expect(finalState.pendingToolsCalling ?? []).toHaveLength(0);
expect(mockResponsesCreate).toHaveBeenCalledTimes(3);
const childOps = await serverDB
.select()
.from(agentOperations)
.where(eq(agentOperations.parentOperationId, createResult.operationId));
expect(childOps).toHaveLength(1);
expect(childOps[0]).toMatchObject({
agentId: targetAgentId,
status: 'done',
});
const toolMessages = await serverDB
.select({
content: messages.content,
role: messages.role,
state: messagePlugins.state,
identifier: messagePlugins.identifier,
apiName: messagePlugins.apiName,
toolCallId: messagePlugins.toolCallId,
})
.from(messages)
.innerJoin(messagePlugins, eq(messagePlugins.id, messages.id))
.where(
and(
eq(messages.userId, userId),
eq(messagePlugins.identifier, 'lobe-agent-management'),
eq(messagePlugins.apiName, 'callAgent'),
),
);
expect(toolMessages).toHaveLength(1);
expect(toolMessages[0]).toMatchObject({
apiName: 'callAgent',
content: TARGET_ANSWER,
identifier: 'lobe-agent-management',
role: 'tool',
toolCallId: 'call_agent_1',
});
expect(toolMessages[0].state).toMatchObject({
status: 'completed',
threadId: childOps[0].threadId,
});
}, 30_000);
});
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { topicRouter } from '../../topic';
import { cleanupTestUser, createTestAgent, createTestContext, createTestUser } from './setup';
import { cleanupTestUser, createTestContext, createTestUser } from './setup';
// We need to mock getServerDB to return our test database instance
let testDB: LobeChatDatabase;
@@ -332,79 +332,31 @@ describe('Topic Router Integration Tests', () => {
});
});
// BM25 search requires pg_search extension (ParadeDB), not available in the
// default integration test DB (PGlite). Run with TEST_SERVER_DB=1 +
// DATABASE_TEST_URL pointing at a ParadeDB instance to exercise these.
// BM25 search requires pg_search extension (ParadeDB), not available in integration test DB
describe.skip('searchTopics', () => {
it('should search topics using agentId', async () => {
const caller = topicRouter.createCaller(createTestContext(userId));
// Topics are agent-native: stored with agentId directly.
await serverDB.insert(topics).values([
{ agentId: testAgentId, title: 'TypeScript Discussion', userId },
{ agentId: testAgentId, title: 'JavaScript Basics', userId },
]);
// Create test topics
await caller.createTopic({
title: 'TypeScript Discussion',
sessionId: testSessionId,
});
await caller.createTopic({
title: 'JavaScript Basics',
sessionId: testSessionId,
});
// Search using agentId
const result = await caller.searchTopics({
agentId: testAgentId,
keywords: 'TypeScript',
agentId: testAgentId,
});
expect(result.length).toBeGreaterThan(0);
expect(result[0].title).toContain('TypeScript');
});
// Regression for the "No topics match these filters" bug: topics created by
// the new agent system carry `agentId` directly with a NULL `sessionId`.
// The old search resolved agentId -> sessionId and filtered by the
// container only, so these rows were never matched even though the topics
// list (which filters by agentId) showed them.
it('should find agentId-scoped topics that have no sessionId', async () => {
const caller = topicRouter.createCaller(createTestContext(userId));
// Insert a topic the way the agent runtime does: agentId set, sessionId null.
await serverDB.insert(topics).values({
agentId: testAgentId,
sessionId: null,
title: 'rinabrown84@gmail.com',
userId,
});
const result = await caller.searchTopics({
agentId: testAgentId,
keywords: 'rinabrown84@gmail.com',
});
expect(result.length).toBeGreaterThan(0);
expect(result[0].title).toBe('rinabrown84@gmail.com');
});
// The agent scope mirrors the topics list exactly (agentId only). A row that
// shares this agent's resolved session but is owned by a DIFFERENT agent
// must not leak in — the bug the constrained-session-fallback review flagged.
it('should not leak another agent topic that shares the session mapping', async () => {
const caller = topicRouter.createCaller(createTestContext(userId));
const otherAgentId = await createTestAgent(serverDB, userId);
await serverDB.insert(topics).values([
{ agentId: testAgentId, title: 'mine rinabrown84@gmail.com', userId },
// Same session, different agent — used to leak via the session fallback.
{
agentId: otherAgentId,
sessionId: testSessionId,
title: 'theirs rinabrown84@gmail.com',
userId,
},
]);
const result = await caller.searchTopics({
agentId: testAgentId,
keywords: 'rinabrown84@gmail.com',
});
expect(result.map((t) => t.title)).toEqual(['mine rinabrown84@gmail.com']);
});
});
describe('updateTopic', () => {
@@ -767,7 +719,7 @@ describe('Topic Router Integration Tests', () => {
sessionId: testSessionId,
});
const allTopics = await caller.queryTopics();
const allTopics = await caller.getAllTopics();
expect(allTopics).toHaveLength(2);
});
@@ -4,15 +4,12 @@ import { pushTokenRouter } from '@/server/routers/lambda/pushToken';
const mockUpsert = vi.fn();
const mockUnregister = vi.fn();
const mockDeleteByExpoTokenAndDevice = vi.fn();
vi.mock('@/database/models/pushToken', () => ({
PushTokenModel: vi.fn(() => ({
unregister: mockUnregister,
upsert: mockUpsert,
})),
deletePushTokenByExpoTokenAndDevice: (...args: unknown[]) =>
mockDeleteByExpoTokenAndDevice(...args),
}));
const createCaller = (ctxOverrides: Partial<any> = {}) => {
@@ -94,90 +91,18 @@ describe('pushTokenRouter', () => {
});
describe('unregister', () => {
it('should delete by (expoToken, deviceId) when expoToken is provided', async () => {
mockDeleteByExpoTokenAndDevice.mockResolvedValueOnce(undefined);
const caller = createCaller();
const result = await caller.unregister({
deviceId: 'device-1',
expoToken: 'ExponentPushToken[abc]',
});
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalledWith(expect.anything(), {
deviceId: 'device-1',
expoToken: 'ExponentPushToken[abc]',
});
expect(result).toEqual({ success: true });
// Legacy (userId, deviceId) path must not fire when expoToken is present
expect(mockUnregister).not.toHaveBeenCalled();
});
it('should fall back to (userId, deviceId) for legacy clients with a session', async () => {
// Path B — v1.0.7 only sends deviceId; if the request still carries a
// valid session we MUST delete the row, otherwise PushChannel keeps
// notifying a signed-out device (Expo DeviceNotRegistered only fires on
// uninstall, not logout).
it('should call model.unregister with deviceId', async () => {
mockUnregister.mockResolvedValueOnce(undefined);
const caller = createCaller();
const result = await caller.unregister({ deviceId: 'device-1' });
await caller.unregister({ deviceId: 'device-1' });
expect(mockUnregister).toHaveBeenCalledWith('device-1');
expect(mockDeleteByExpoTokenAndDevice).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
it('should silently succeed without expoToken AND without session', async () => {
// Path C — v1.0.7 + dead session: the only safe move is silent OK.
// Orphan row will be cleaned up by the process-push-receipts worker via
// Expo DeviceNotRegistered receipts. Returning 200 here stops the storm.
const caller = createCaller({ userId: undefined });
const result = await caller.unregister({ deviceId: 'device-1' });
expect(mockDeleteByExpoTokenAndDevice).not.toHaveBeenCalled();
expect(mockUnregister).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
it('should succeed for an unauthenticated caller carrying expoToken', async () => {
// New clients (>=1.0.8) hit Path A regardless of session.
const caller = createCaller({ userId: undefined });
const result = await caller.unregister({
deviceId: 'device-1',
expoToken: 'ExponentPushToken[abc]',
});
expect(result).toEqual({ success: true });
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalled();
expect(mockUnregister).not.toHaveBeenCalled();
});
it('should prefer expoToken precision over the legacy userId fallback', async () => {
// If both are available, always take Path A — the (expoToken, deviceId)
// pair is more precise and doesn't risk deleting a wrong row.
const caller = createCaller();
await caller.unregister({
deviceId: 'device-1',
expoToken: 'ExponentPushToken[abc]',
});
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalled();
expect(mockUnregister).not.toHaveBeenCalled();
});
it('should reject empty deviceId', async () => {
const caller = createCaller();
await expect(caller.unregister({ deviceId: '' })).rejects.toThrow();
});
it('should reject empty expoToken when provided', async () => {
const caller = createCaller();
await expect(
caller.unregister({ deviceId: 'device-1', expoToken: '' }),
).rejects.toThrow();
});
});
});
@@ -36,7 +36,6 @@ export const compareDocumentHistoryItemsInputSchema = z.object({
});
export const updateDocumentInputSchema = z.object({
breakAutosaveWindow: z.boolean().optional(),
content: z.string().optional(),
editorData: z.string().optional(),
fileType: z.string().optional(),
@@ -59,7 +58,6 @@ export interface DocumentHistoryListItem {
isCurrent: boolean;
savedAt: string;
saveSource: DocumentHistorySaveSource;
userId: string;
}
export interface ListHistoryOutput {
@@ -125,7 +123,6 @@ export interface CompareHistoryItemsInput {
}
export interface UpdateDocumentInput {
breakAutosaveWindow?: boolean;
content?: string;
editorData?: string;
fileType?: string;
-60
View File
@@ -17,8 +17,6 @@ import { workspaceMembers } from '@/database/schemas';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { AgentService } from '@/server/services/agent';
import { EditLockService } from '@/server/services/editLock';
import { publishResourceEvent } from '@/server/services/resourceEvents';
import { TransferErrorCode } from '@/types/transferError';
const agentProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
@@ -30,7 +28,6 @@ const agentProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
agentService: new AgentService(ctx.serverDB, ctx.userId, wsId),
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
editLockService: new EditLockService(ctx.userId),
fileModel: new FileModel(ctx.serverDB, ctx.userId, wsId),
knowledgeBaseModel: new KnowledgeBaseModel(ctx.serverDB, ctx.userId, wsId),
sessionModel: new SessionModel(ctx.serverDB, ctx.userId, wsId),
@@ -443,19 +440,6 @@ export const agentRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
// Collaborative edit lock: reject writes to a workspace agent another
// member is actively editing. Inert until a client acquires the lock.
if (ctx.workspaceId) {
const blockedBy = await ctx.editLockService.getBlockingHolder('agent', input.agentId);
if (blockedBy) {
throw new TRPCError({
cause: { data: { code: 'DocumentLocked' } },
code: 'CONFLICT',
message: 'Agent is being edited by another user',
});
}
}
// Use AgentService to update and return the updated agent data
return ctx.agentService.updateAgentConfig(input.agentId, input.value);
}),
@@ -474,48 +458,4 @@ export const agentRouter = router({
.mutation(async ({ input, ctx }) => {
return ctx.agentModel.update(input.id, { pinned: input.pinned });
}),
acquireAgentLock: agentProcedure
.use(withScopedPermission('agent:update'))
.input(z.object({ agentId: z.string() }))
.mutation(async ({ ctx, input }) => {
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
const prev = await ctx.editLockService.getActiveHolder('agent', input.agentId);
const result = await ctx.editLockService.acquire('agent', input.agentId);
if ((result.holderId ?? null) !== (prev ?? null)) {
void publishResourceEvent(
{ id: input.agentId, type: 'agent' },
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
);
}
return result;
}),
getAgentLock: agentProcedure
.use(withScopedPermission('agent:update'))
.input(z.object({ agentId: z.string() }))
.query(async ({ ctx, input }) => {
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
const holder = await ctx.editLockService.getActiveHolder('agent', input.agentId);
return {
expiresAt: null,
holderId: holder ?? null,
lockedByOther: Boolean(holder) && holder !== ctx.userId,
};
}),
releaseAgentLock: agentProcedure
.use(withScopedPermission('agent:update'))
.input(z.object({ agentId: z.string() }))
.mutation(async ({ ctx, input }) => {
if (!ctx.workspaceId) return;
// Only broadcast "unlocked" when we actually released our own lock — if the
// lease expired and another member took over, the lock is still held.
const released = await ctx.editLockService.release('agent', input.agentId);
if (!released) return;
void publishResourceEvent(
{ id: input.agentId, type: 'agent' },
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
);
}),
});
@@ -14,8 +14,6 @@ import { type ChatGroupConfig } from '@/database/types/chatGroup';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { AgentGroupService } from '@/server/services/agentGroup';
import { EditLockService } from '@/server/services/editLock';
import { publishResourceEvent } from '@/server/services/resourceEvents';
import { TransferErrorCode } from '@/types/transferError';
/**
@@ -57,7 +55,6 @@ const agentGroupProcedure = wsCompatProcedure.use(serverDatabase).use(async (opt
agentGroupService: new AgentGroupService(ctx.serverDB, ctx.userId, wsId),
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
editLockService: new EditLockService(ctx.userId),
userModel: new UserModel(ctx.serverDB, ctx.userId),
},
});
@@ -405,19 +402,6 @@ export const agentGroupRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
// Collaborative edit lock: reject writes to a workspace group another
// member is actively editing. Inert until a client acquires the lock.
if (ctx.workspaceId) {
const blockedBy = await ctx.editLockService.getBlockingHolder('chatGroup', input.id);
if (blockedBy) {
throw new TRPCError({
cause: { data: { code: 'DocumentLocked' } },
code: 'CONFLICT',
message: 'Group is being edited by another user',
});
}
}
return ctx.chatGroupModel.update(input.id, {
...input.value,
config: ctx.agentGroupService.normalizeGroupConfig(
@@ -425,47 +409,6 @@ export const agentGroupRouter = router({
),
});
}),
acquireGroupLock: agentGroupProcedureWrite
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
const prev = await ctx.editLockService.getActiveHolder('chatGroup', input.id);
const result = await ctx.editLockService.acquire('chatGroup', input.id);
if ((result.holderId ?? null) !== (prev ?? null)) {
void publishResourceEvent(
{ id: input.id, type: 'chatGroup' },
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
);
}
return result;
}),
getGroupLock: agentGroupProcedureWrite
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
const holder = await ctx.editLockService.getActiveHolder('chatGroup', input.id);
return {
expiresAt: null,
holderId: holder ?? null,
lockedByOther: Boolean(holder) && holder !== ctx.userId,
};
}),
releaseGroupLock: agentGroupProcedureWrite
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
if (!ctx.workspaceId) return;
// Only broadcast "unlocked" when we actually released our own lock — if the
// lease expired and another member took over, the lock is still held.
const released = await ctx.editLockService.release('chatGroup', input.id);
if (!released) return;
void publishResourceEvent(
{ id: input.id, type: 'chatGroup' },
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
);
}),
});
export type AgentGroupRouter = typeof agentGroupRouter;
@@ -85,7 +85,6 @@ export const agentSignalRouter = router({
return enqueueAgentSignalSourceEvent(sourceEvent, {
agentId: input.agentId,
userId: ctx.userId,
workspaceId: ctx.workspaceId ?? undefined,
});
}),
listReceipts: agentSignalProcedure
@@ -139,8 +139,6 @@ const ExecAgentSchema = z
.object({
defaultTaskAssigneeAgentId: z.string().optional(),
documentId: z.string().optional().nullable(),
/** The agent being edited when scope is 'agent_builder' (not the builder builtin itself). */
editingAgentId: z.string().optional(),
groupId: z.string().optional().nullable(),
initialTopicMetadata: z
.object({
+1
View File
@@ -370,6 +370,7 @@ export const aiChatRouter = router({
{ assistantMessage, userMessage },
{
...(modelTiming ? { timing: modelTiming } : {}),
touchTopicUpdatedAt: !isCreateNewTopic,
},
);
},
-256
View File
@@ -1,256 +0,0 @@
import { type ToolManifest } from '@lobechat/types';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { getServerComposioAuthConfigId } from '@/config/composio';
import { PluginModel } from '@/database/models/plugin';
import { getComposioClient } from '@/libs/composio';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
const composioProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const client = getComposioClient();
const pluginModel = new PluginModel(opts.ctx.serverDB, opts.ctx.userId);
return opts.next({
ctx: { ...opts.ctx, composioClient: client, pluginModel },
});
});
export const composioRouter = router({
createConnection: composioProcedure
.input(
z.object({
appSlug: z.string(),
identifier: z.string(),
label: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const { appSlug, identifier, label } = input;
const { userId } = ctx;
const callbackUrl = `${process.env.APP_URL || process.env.NEXTAUTH_URL || ''}/api/composio/oauth/callback`;
// Prefer a pre-configured auth config (e.g. a custom/white-label config
// created in the Composio dashboard), pinned per toolkit via env. Falls
// back to discovering an existing config for this toolkit, and finally to
// auto-creating a Composio-managed one.
let authConfigId = getServerComposioAuthConfigId(identifier);
if (!authConfigId) {
const authConfigs = await (ctx.composioClient.authConfigs as any).list();
let authConfig = authConfigs?.items?.find(
(c: any) => c.toolkit?.slug?.toLowerCase() === appSlug.toLowerCase(),
);
if (!authConfig) {
authConfig = await (ctx.composioClient.authConfigs as any).create(appSlug, {
name: appSlug,
type: 'use_composio_managed_auth',
});
}
authConfigId = authConfig.id;
}
if (!authConfigId) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Failed to resolve a Composio auth config for "${appSlug}".`,
});
}
// Composio-managed OAuth auth configs no longer support `initiate`; use
// `link` (POST /api/v3/connected_accounts/link) to get the redirect URL.
const connReq = await (ctx.composioClient.connectedAccounts as any).link(
userId,
authConfigId,
{ callbackUrl },
);
let rawTools: any[] = [];
try {
const toolsResp = await (ctx.composioClient.tools as any).getRawComposioTools({
toolkits: [appSlug],
});
rawTools = toolsResp?.items || toolsResp || [];
} catch {
// tools may not be available before auth
}
const manifest: ToolManifest = {
api: Array.isArray(rawTools)
? rawTools.map((tool: any) => ({
description: tool.description || '',
name: tool.slug || tool.name || '',
parameters: tool.inputParameters ||
tool.inputSchema || {
properties: {},
type: 'object',
},
}))
: [],
identifier,
meta: {
avatar: '🔌',
description: `Composio: ${label}`,
title: label,
},
type: 'default',
};
await ctx.pluginModel.create({
customParams: {
composio: {
appSlug,
authConfigId,
connectedAccountId: connReq.id,
redirectUrl: connReq.redirectUrl,
status: 'PENDING',
},
},
identifier,
manifest,
source: 'composio',
type: 'plugin',
});
return {
authConfigId,
connectedAccountId: connReq.id,
identifier,
redirectUrl: connReq.redirectUrl,
};
}),
deleteConnection: composioProcedure
.input(
z.object({
connectedAccountId: z.string(),
identifier: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
try {
await (ctx.composioClient.connectedAccounts as any).delete(input.connectedAccountId);
} catch (error) {
console.warn('[Composio] Failed to delete remote connection:', error);
}
await ctx.pluginModel.delete(input.identifier);
return { success: true };
}),
getComposioPlugins: composioProcedure.query(async ({ ctx }) => {
const allPlugins = await ctx.pluginModel.query();
return allPlugins.filter((plugin) => plugin.customParams?.composio);
}),
getConnection: composioProcedure
.input(
z.object({
connectedAccountId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
try {
const account = await (ctx.composioClient.connectedAccounts as any).get(
input.connectedAccountId,
);
return {
appSlug: account?.toolkit?.slug || '',
connectedAccountId: input.connectedAccountId,
error: undefined as 'AUTH_ERROR' | undefined,
status: (account?.status || 'PENDING') as string,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const isAuthError = errorMessage.includes('401') || errorMessage.includes('Unauthorized');
if (isAuthError) {
return {
appSlug: '',
connectedAccountId: input.connectedAccountId,
error: 'AUTH_ERROR' as const,
status: 'FAILED',
};
}
throw error;
}
}),
removeComposioPlugin: composioProcedure
.input(z.object({ identifier: z.string() }))
.mutation(async ({ input, ctx }) => {
await ctx.pluginModel.delete(input.identifier);
return { success: true };
}),
updateComposioPlugin: composioProcedure
.input(
z.object({
appSlug: z.string(),
authConfigId: z.string(),
connectedAccountId: z.string(),
identifier: z.string(),
label: z.string(),
redirectUrl: z.string().optional(),
status: z.string(),
tools: z.array(
z.object({
description: z.string().optional(),
inputSchema: z.any().optional(),
name: z.string(),
}),
),
}),
)
.mutation(async ({ input, ctx }) => {
const {
identifier,
label,
appSlug,
authConfigId,
connectedAccountId,
tools,
status,
redirectUrl,
} = input;
const existingPlugin = await ctx.pluginModel.findById(identifier);
const manifest: ToolManifest = {
api: tools.map((tool) => ({
description: tool.description || '',
name: tool.name,
parameters: tool.inputSchema || { properties: {}, type: 'object' },
})),
identifier,
meta: existingPlugin?.manifest?.meta || {
avatar: '🔌',
description: `Composio: ${label}`,
title: label,
},
type: 'default',
};
const customParams = {
composio: { appSlug, authConfigId, connectedAccountId, redirectUrl, status },
};
if (existingPlugin) {
await ctx.pluginModel.update(identifier, { customParams, manifest });
} else {
await ctx.pluginModel.create({
customParams,
identifier,
manifest,
source: 'composio',
type: 'plugin',
});
}
return { savedCount: tools.length };
}),
});
export type ComposioRouter = typeof composioRouter;
+2 -34
View File
@@ -115,33 +115,6 @@ export const connectorRouter = router({
return toolsByConnector;
}),
/**
* Return the connector record with decrypted user-set credentials so the
* edit form can pre-fill accurately. Only the connector owner can call this
* (enforced by connectorProcedure ownership check).
*
* Machine-managed secrets are intentionally excluded:
* - OAuth access/refresh tokens (type 'oauth2') stripped, returned as null
* - oidcConfig.clientSecret (DCR-registered secret) stripped
* User-set credentials (bearer token, custom headers) are returned as-is so
* the edit form can display them.
*/
getForEdit: connectorProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const connector = await ctx.connectorModel.findById(input.id);
if (!connector)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Connector not found' });
const { oidcConfig, credentials, ...rest } = connector;
const safeOidcConfig = oidcConfig ? { ...oidcConfig, clientSecret: undefined } : oidcConfig;
// OAuth tokens are machine-managed — don't return them; the UI only needs
// to know an OAuth flow is configured (reflected via oidcConfig presence).
const safeCredentials = credentials?.type === 'oauth2' ? null : credentials;
return { ...rest, credentials: safeCredentials, oidcConfig: safeOidcConfig };
}),
/**
* The exact redirect URI the server will send to the OAuth/DCR endpoints.
* The Add modal must display THIS value (not a client-derived origin) so the
@@ -295,14 +268,9 @@ export const connectorRouter = router({
await ctx.connectorModel.update(input.id, {
...patch,
// undefined → leave untouched; null → clear; object → encrypt the JSON string.
// When credentials are cleared, also drop the cached expiry timestamp so
// token-refresh logic doesn't act on a stale value for the new server.
...(credentials === undefined
? {}
: {
credentials: credentials ? JSON.stringify(credentials) : null,
...(credentials === null ? { tokenExpiresAt: null } : {}),
}),
: { credentials: credentials ? JSON.stringify(credentials) : null }),
} as any);
}),
@@ -390,7 +358,7 @@ export const connectorRouter = router({
}),
/**
* Sync tools from a client-provided list (for Lobehub OAuth skills, Composio, etc.
* Sync tools from a client-provided list (for Lobehub OAuth skills, Klavis, etc.
* that already have their tool list available on the client side).
* Idempotent safe to call whenever the detail panel opens.
*/
+1 -69
View File
@@ -119,22 +119,6 @@ export const deviceRouter = router({
return result ?? null;
}),
/**
* List the git worktrees attached to the same repository as a directory on a
* remote device, via the device's `listGitWorktrees` RPC. Lets the web/remote
* worktree picker mirror the local desktop's, populated over IPC.
*/
listGitWorktrees: deviceProcedure
.input(z.object({ deviceId: z.string(), path: z.string() }))
.query(async ({ ctx, input }) => {
const result = await deviceGateway.listGitWorktrees({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
});
return result ?? [];
}),
/**
* List the local branches of a directory on a remote device, via the device's
* `listGitBranches` RPC. Lets the web/remote branch switcher populate the same
@@ -179,50 +163,6 @@ export const deviceRouter = router({
}),
),
/**
* Rename a branch in a directory on a remote device, via the device's
* `renameGitBranch` RPC.
*/
renameGitBranch: deviceProcedure
.input(
z.object({
deviceId: z.string(),
from: z.string(),
path: z.string(),
to: z.string(),
}),
)
.mutation(async ({ ctx, input }) =>
deviceGateway.renameGitBranch({
deviceId: input.deviceId,
from: input.from,
path: input.path,
to: input.to,
userId: ctx.userId,
}),
),
/**
* Delete a branch in a directory on a remote device, via the device's
* `deleteGitBranch` RPC.
*/
deleteGitBranch: deviceProcedure
.input(
z.object({
branch: z.string(),
deviceId: z.string(),
path: z.string(),
}),
)
.mutation(async ({ ctx, input }) =>
deviceGateway.deleteGitBranch({
branch: input.branch,
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
}),
),
/**
* Pull (`--ff-only`) the current branch of a directory on a remote device, via
* the device's `pullGitBranch` RPC.
@@ -335,17 +275,9 @@ export const deviceRouter = router({
* receives render data, not a `localfile://` URL; saving remains unsupported.
*/
getLocalFilePreview: deviceProcedure
.input(
z.object({
accept: z.enum(['image']).optional(),
deviceId: z.string(),
path: z.string(),
workingDirectory: z.string(),
}),
)
.input(z.object({ deviceId: z.string(), path: z.string(), workingDirectory: z.string() }))
.query(async ({ ctx, input }) =>
deviceGateway.getLocalFilePreview({
accept: input.accept,
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
@@ -253,27 +253,6 @@ export const documentRouter = router({
return ctx.documentService.queryDocuments(input);
}),
acquireDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.documentService.acquireDocumentLock(input.id);
}),
getDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.documentService.getDocumentLock(input.id);
}),
releaseDocumentLock: documentProcedure
.use(withScopedPermission('document:update'))
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.documentService.releaseDocumentLock(input.id);
}),
updateDocument: documentProcedure
.use(withScopedPermission('document:update'))
.input(updateDocumentInputSchema)
+2 -3
View File
@@ -37,7 +37,6 @@ import { briefRouter } from './brief';
import { changelogRouter } from './changelog';
import { chunkRouter } from './chunk';
import { comfyuiRouter } from './comfyui';
import { composioRouter } from './composio';
import { configRouter } from './config';
import { connectorRouter } from './connector';
import { deviceRouter } from './device';
@@ -51,6 +50,7 @@ import { generationTopicRouter } from './generationTopic';
import { homeRouter } from './home';
import { imageRouter } from './image';
import { importerRouter } from './importer';
import { klavisRouter } from './klavis';
import { knowledgeRouter } from './knowledge';
import { knowledgeBaseRouter } from './knowledgeBase';
import { llmGenerationTracingRouter } from './llmGenerationTracing';
@@ -115,8 +115,7 @@ export const lambdaRouter = router({
home: homeRouter,
image: imageRouter,
importer: importerRouter,
composio: composioRouter,
klavis: klavisRouter,
knowledge: knowledgeRouter,
knowledgeBase: knowledgeBaseRouter,
llmGenerationTracing: llmGenerationTracingRouter,
+284
View File
@@ -0,0 +1,284 @@
import { type ToolManifest } from '@lobechat/types';
import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { PluginModel } from '@/database/models/plugin';
import { getKlavisClient } from '@/libs/klavis';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
/**
* Klavis procedure with API key validation and database access
*/
const klavisProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const client = getKlavisClient();
const wsId = opts.ctx.workspaceId ?? undefined;
const pluginModel = new PluginModel(opts.ctx.serverDB, opts.ctx.userId, wsId);
return opts.next({
ctx: { ...opts.ctx, klavisClient: client, pluginModel },
});
});
export const klavisRouter = router({
/**
* Create a single MCP server instance and save to database
* Returns: { serverUrl, instanceId, oauthUrl?, identifier, serverName }
*/
createServerInstance: klavisProcedure
.use(withScopedPermission('agent:update'))
.input(
z.object({
/** Identifier for storage (e.g., 'google-calendar') */
identifier: z.string(),
/** Server name for Klavis API (e.g., 'Google Calendar') */
serverName: z.string(),
userId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const { serverName, userId, identifier } = input;
// Create a single server instance
const response = await ctx.klavisClient.mcpServer.createServerInstance({
serverName: serverName as any,
userId,
});
const { serverUrl, instanceId, oauthUrl } = response;
// Get the tool list for this server
const toolsResponse = await ctx.klavisClient.mcpServer.getTools(serverName as any);
const tools = toolsResponse.tools || [];
// Save to database using the provided identifier (format: lowercase, spaces replaced with hyphens)
const manifest: ToolManifest = {
api: tools.map((tool: any) => ({
description: tool.description || '',
name: tool.name,
parameters: tool.inputSchema || { properties: {}, type: 'object' },
})),
identifier,
meta: {
avatar: '🔌',
description: `LobeHub Mcp Server: ${serverName}`,
title: serverName,
},
type: 'default',
};
// Save to database with oauthUrl and isAuthenticated status
const isAuthenticated = !oauthUrl; // If there's no oauthUrl, authentication is not required or already authenticated
await ctx.pluginModel.create({
customParams: {
klavis: {
instanceId,
isAuthenticated,
oauthUrl,
serverName,
serverUrl,
},
},
identifier,
manifest,
source: 'klavis',
type: 'plugin',
});
return {
identifier,
instanceId,
isAuthenticated,
oauthUrl,
serverName,
serverUrl,
};
}),
/**
* Delete a server instance
*/
deleteServerInstance: klavisProcedure
.use(withScopedPermission('agent:update'))
.input(
z.object({
/** Identifier for storage (e.g., 'google-calendar') */
identifier: z.string(),
instanceId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
// Call Klavis API to delete server instance
await ctx.klavisClient.mcpServer.deleteServerInstance(input.instanceId);
// Delete from database (using identifier)
await ctx.pluginModel.delete(input.identifier);
return { success: true };
}),
/**
* Get Klavis plugins from database
*/
getKlavisPlugins: klavisProcedure.query(async ({ ctx }) => {
const allPlugins = await ctx.pluginModel.query();
// Filter plugins that have klavis customParams
return allPlugins.filter((plugin) => plugin.customParams?.klavis);
}),
/**
* Get server instance status from Klavis API
* Returns error object instead of throwing on auth errors (useful for polling)
*/
getServerInstance: klavisProcedure
.input(
z.object({
instanceId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
try {
const response = await ctx.klavisClient.mcpServer.getServerInstance(input.instanceId);
return {
authNeeded: response.authNeeded,
error: undefined,
externalUserId: response.externalUserId,
instanceId: response.instanceId,
isAuthenticated: response.isAuthenticated,
oauthUrl: response.oauthUrl,
platform: response.platform,
serverName: response.serverName,
};
} catch (error) {
// Check if this is an authentication error
const errorMessage = error instanceof Error ? error.message : String(error);
const isAuthError =
errorMessage.includes('Invalid API key or instance ID') ||
errorMessage.includes('Status code: 401');
// For auth errors, return error object instead of throwing
// This prevents 500 errors in logs during polling
if (isAuthError) {
return {
authNeeded: true,
error: 'AUTH_ERROR',
externalUserId: undefined,
instanceId: input.instanceId,
isAuthenticated: false,
oauthUrl: undefined,
platform: undefined,
serverName: undefined,
};
}
// For other errors, still throw
throw error;
}
}),
getUserIntergrations: klavisProcedure
.input(
z.object({
userId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
const response = await ctx.klavisClient.user.getUserIntegrations(input.userId);
return {
integrations: response.integrations,
};
}),
/**
* Remove Klavis plugin from database by identifier
*/
removeKlavisPlugin: klavisProcedure
.use(withScopedPermission('agent:update'))
.input(
z.object({
/** Identifier for storage (e.g., 'google-calendar') */
identifier: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
await ctx.pluginModel.delete(input.identifier);
return { success: true };
}),
/**
* Update Klavis plugin with tools and auth status in database
*/
updateKlavisPlugin: klavisProcedure
.use(withScopedPermission('agent:update'))
.input(
z.object({
/** Identifier for storage (e.g., 'google-calendar') */
identifier: z.string(),
instanceId: z.string(),
isAuthenticated: z.boolean(),
oauthUrl: z.string().optional(),
/** Server name for Klavis API (e.g., 'Google Calendar') */
serverName: z.string(),
serverUrl: z.string(),
tools: z.array(
z.object({
description: z.string().optional(),
inputSchema: z.any().optional(),
name: z.string(),
}),
),
}),
)
.mutation(async ({ input, ctx }) => {
const { identifier, serverName, serverUrl, instanceId, tools, isAuthenticated, oauthUrl } =
input;
// Get existing plugin (using identifier)
const existingPlugin = await ctx.pluginModel.findById(identifier);
// Build manifest containing all tools
const manifest: ToolManifest = {
api: tools.map((tool) => ({
description: tool.description || '',
name: tool.name,
parameters: tool.inputSchema || { properties: {}, type: 'object' },
})),
identifier,
meta: existingPlugin?.manifest?.meta || {
avatar: '🔌',
description: `LobeHub Mcp Server: ${serverName}`,
title: serverName,
},
type: 'default',
};
const customParams = {
klavis: {
instanceId,
isAuthenticated,
oauthUrl,
serverName,
serverUrl,
},
};
// Update or create plugin
if (existingPlugin) {
await ctx.pluginModel.update(identifier, { customParams, manifest });
} else {
await ctx.pluginModel.create({
customParams,
identifier,
manifest,
source: 'klavis',
type: 'plugin',
});
}
return { savedCount: tools.length };
}),
});
export type KlavisRouter = typeof klavisRouter;
@@ -53,7 +53,7 @@ export const oauthDeviceFlowRouter = router({
);
if (!providerDetail?.keyVaults) {
return { status: 'PENDING' };
return { isAuthenticated: false };
}
const keyVaults = providerDetail.keyVaults as Record<string, any>;
@@ -63,12 +63,12 @@ export const oauthDeviceFlowRouter = router({
return {
avatarUrl: keyVaults.githubAvatarUrl as string | undefined,
expiresAt: keyVaults.oauthTokenExpiresAt || keyVaults.bearerTokenExpiresAt,
status: 'ACTIVE',
isAuthenticated: true,
username: keyVaults.githubUsername as string | undefined,
};
}
return { status: 'PENDING' };
return { isAuthenticated: false };
}),
/**
+7 -53
View File
@@ -1,13 +1,10 @@
import { z } from 'zod';
import {
deletePushTokenByExpoTokenAndDevice,
PushTokenModel,
} from '@/database/models/pushToken';
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
import { PushTokenModel } from '@/database/models/pushToken';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
const authedPushTokenProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const pushTokenProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
return opts.next({
@@ -16,7 +13,7 @@ const authedPushTokenProcedure = authedProcedure.use(serverDatabase).use(async (
});
export const pushTokenRouter = router({
register: authedPushTokenProcedure
register: pushTokenProcedure
.input(
z.object({
appVersion: z.string().optional(),
@@ -30,53 +27,10 @@ export const pushTokenRouter = router({
return ctx.pushTokenModel.upsert(input);
}),
/**
* Public on purpose: clients call this during sign-out, and in the wild many
* of those calls arrive after the session is already gone (expired OIDC
* token / cleared cookie). Authenticating by session here causes a 401
* storm on every such logout.
*
* Authorization model (Path A new clients 1.0.8): the caller presents the
* (deviceId, expoToken) pair it received at registration. Holding both = proof
* of ownership of the row, same trust model as APNs/FCM unregister.
*
* Backwards compat for v1.0.7 (only sends `deviceId`):
* - Path B when the request still carries a valid session, fall back to
* the original (userId, deviceId) delete. This covers the *active*
* sign-out path so PushChannel doesn't keep notifying a signed-out device
* until the user uninstalls (Expo's DeviceNotRegistered receipt only
* fires on uninstall, not on logout).
* - Path C when there's no session either, silently succeed. The orphan
* row will be cleaned up by the existing `process-push-receipts` worker
* via Expo's DeviceNotRegistered receipts. Returning 200 here is what
* actually stops the 401 storm in production.
*/
unregister: publicProcedure
.use(serverDatabase)
.input(
z.object({
deviceId: z.string().min(1),
expoToken: z.string().min(1).optional(),
}),
)
unregister: pushTokenProcedure
.input(z.object({ deviceId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const { deviceId, expoToken } = input;
// Path A: new clients — precise delete by (expoToken, deviceId), no session needed
if (expoToken) {
await deletePushTokenByExpoTokenAndDevice(ctx.serverDB, { deviceId, expoToken });
return { success: true };
}
// Path B: legacy v1.0.7 + valid session — fall back to (userId, deviceId)
if (ctx.userId) {
const pushTokenModel = new PushTokenModel(ctx.serverDB, ctx.userId);
await pushTokenModel.unregister(deviceId);
return { success: true };
}
// Path C: legacy v1.0.7 with no session — silent OK, cron worker cleans up
return { success: true };
return ctx.pushTokenModel.unregister(input.deviceId);
}),
});
-55
View File
@@ -14,8 +14,6 @@ import { TopicModel } from '@/database/models/topic';
import { workspaceMembers } from '@/database/schemas';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { EditLockService } from '@/server/services/editLock';
import { publishResourceEvent } from '@/server/services/resourceEvents';
import { TaskService } from '@/server/services/task';
import { TaskLifecycleService } from '@/server/services/taskLifecycle';
import { TaskRunnerService } from '@/server/services/taskRunner';
@@ -28,7 +26,6 @@ const taskProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
ctx: {
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
briefModel: new BriefModel(ctx.serverDB, ctx.userId, wsId),
editLockService: new EditLockService(ctx.userId),
taskLifecycle: new TaskLifecycleService(ctx.serverDB, ctx.userId, wsId),
taskModel: new TaskModel(ctx.serverDB, ctx.userId, wsId),
taskService: new TaskService(ctx.serverDB, ctx.userId, wsId),
@@ -930,20 +927,6 @@ export const taskRouter = router({
const model = ctx.taskModel;
await assertAssigneeAgentBelongsToUser(ctx.agentModel, data.assigneeAgentId);
const resolved = await resolveOrThrow(model, id);
// Collaborative edit lock: reject writes to a workspace task another member
// is actively editing. Inert until a client acquires the lock.
if (ctx.workspaceId) {
const blockedBy = await ctx.editLockService.getBlockingHolder('task', resolved.id);
if (blockedBy) {
throw new TRPCError({
cause: { data: { code: 'DocumentLocked' } },
code: 'CONFLICT',
message: 'Task is being edited by another user',
});
}
}
const resolvedParentTaskId =
parentTaskId === undefined
? undefined
@@ -964,44 +947,6 @@ export const taskRouter = router({
}
}),
acquireTaskLock: taskProcedureWrite.input(idInput).mutation(async ({ ctx, input }) => {
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
const prev = await ctx.editLockService.getActiveHolder('task', resolved.id);
const result = await ctx.editLockService.acquire('task', resolved.id);
if ((result.holderId ?? null) !== (prev ?? null)) {
void publishResourceEvent(
{ id: resolved.id, type: 'task' },
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
);
}
return result;
}),
getTaskLock: taskProcedureWrite.input(idInput).query(async ({ ctx, input }) => {
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
const holder = await ctx.editLockService.getActiveHolder('task', resolved.id);
return {
expiresAt: null,
holderId: holder ?? null,
lockedByOther: Boolean(holder) && holder !== ctx.userId,
};
}),
releaseTaskLock: taskProcedureWrite.input(idInput).mutation(async ({ ctx, input }) => {
if (!ctx.workspaceId) return;
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
// Only broadcast "unlocked" when we actually released our own lock — if the
// lease expired and another member took over, the lock is still held.
const released = await ctx.editLockService.release('task', resolved.id);
if (!released) return;
void publishResourceEvent(
{ id: resolved.id, type: 'task' },
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
);
}),
updateConfig: taskProcedureWrite
.input(idInput.merge(z.object({ config: z.record(z.unknown()) })))
.mutation(async ({ input, ctx }) => {
+4 -35
View File
@@ -162,18 +162,6 @@ export const topicRouter = router({
return ctx.topicModel.batchDeleteBySessionId(resolved.sessionId);
}),
batchMoveTopics: topicProcedure
.use(withScopedPermission('topic:update'))
.input(
z.object({
targetAgentId: z.string(),
topicIds: z.array(z.string()),
}),
)
.mutation(async ({ input, ctx }) => {
return ctx.topicModel.batchMoveToAgent(input.topicIds, input.targetAgentId);
}),
cloneTopic: topicProcedure
.use(withScopedPermission('topic:create'))
.input(z.object({ id: z.string(), newTitle: z.string().optional() }))
@@ -251,18 +239,9 @@ export const topicRouter = router({
return ctx.topicShareModel.create(input.topicId, input.visibility);
}),
queryTopics: topicProcedure
.input(
z
.object({
pageSize: z.number().max(500).optional(),
statuses: z.array(z.string()).optional(),
})
.optional(),
)
.query(async ({ input, ctx }) => {
return ctx.topicModel.queryTopics({ pageSize: input?.pageSize, statuses: input?.statuses });
}),
getAllTopics: topicProcedure.query(async ({ ctx }) => {
return ctx.topicModel.queryAll();
}),
getShareInfo: topicProcedure
.input(z.object({ topicId: z.string() }))
@@ -591,17 +570,7 @@ export const topicRouter = router({
ctx.workspaceId ?? undefined,
);
// Scope the search exactly like the topics list (`query`): by agentId
// directly (the new agent system stamps every topic with an agentId).
// Passing only the resolved sessionId used to miss every agentId-scoped
// topic — the cause of "no topics match" in the per-agent Topics search.
// `containerId` is only the fallback for legacy callers that pass no
// agentId/groupId.
return ctx.topicModel.queryByKeyword(input.keywords, {
agentId: input.agentId,
containerId: resolved.sessionId,
groupId: input.groupId,
});
return ctx.topicModel.queryByKeyword(input.keywords, resolved.sessionId);
}),
/**
+10 -12
View File
@@ -1,12 +1,11 @@
import { z } from 'zod';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { LlmGenerationTracingModel } from '@/database/models/llmGenerationTracing';
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
import { VerifyCriterionModel } from '@/database/models/verifyCriterion';
import { VerifyRubricModel } from '@/database/models/verifyRubric';
import { router } from '@/libs/trpc/lambda';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import {
VerifyExecutorService,
@@ -36,19 +35,18 @@ const checkItemSchema = z.object({
verifierType: verifierTypeSchema,
});
const verifyProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const verifyProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
const workspaceId = ctx.workspaceId ?? undefined;
return opts.next({
ctx: {
criterionModel: new VerifyCriterionModel(ctx.serverDB, ctx.userId, workspaceId),
executorService: new VerifyExecutorService(ctx.serverDB, ctx.userId, workspaceId),
tracingModel: new LlmGenerationTracingModel(ctx.serverDB, ctx.userId, workspaceId),
feedbackService: new VerifyFeedbackService(ctx.serverDB, ctx.userId, workspaceId),
operationModel: new AgentOperationModel(ctx.serverDB, ctx.userId, workspaceId),
planGenerator: new VerifyPlanGeneratorService(ctx.serverDB, ctx.userId, workspaceId),
resultModel: new VerifyCheckResultModel(ctx.serverDB, ctx.userId, workspaceId),
rubricModel: new VerifyRubricModel(ctx.serverDB, ctx.userId, workspaceId),
criterionModel: new VerifyCriterionModel(ctx.serverDB, ctx.userId),
executorService: new VerifyExecutorService(ctx.serverDB, ctx.userId),
tracingModel: new LlmGenerationTracingModel(ctx.serverDB, ctx.userId),
feedbackService: new VerifyFeedbackService(ctx.serverDB, ctx.userId),
operationModel: new AgentOperationModel(ctx.serverDB, ctx.userId),
planGenerator: new VerifyPlanGeneratorService(ctx.serverDB, ctx.userId),
resultModel: new VerifyCheckResultModel(ctx.serverDB, ctx.userId),
rubricModel: new VerifyRubricModel(ctx.serverDB, ctx.userId),
},
});
});
+14 -3
View File
@@ -3,7 +3,8 @@ import { z } from 'zod';
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { TopicModel } from '@/database/models/topic';
import { router } from '@/libs/trpc/lambda';
import { getServerDB } from '@/database/server';
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { type BatchTaskResult } from '@/types/service';
@@ -94,7 +95,12 @@ export const topicRouter = router({
return data.id;
}),
getTopics: topicProcedure
getAllTopics: topicProcedure.query(async ({ ctx }) => {
return ctx.topicModel.queryAll();
}),
// TODO: this procedure should be used with authedProcedure
getTopics: publicProcedure
.input(
z.object({
containerId: z.string().nullable().optional(),
@@ -103,7 +109,12 @@ export const topicRouter = router({
}),
)
.query(async ({ input, ctx }) => {
return ctx.topicModel.query(input);
if (!ctx.userId) return [];
const serverDB = await getServerDB();
const topicModel = new TopicModel(serverDB, ctx.userId, ctx.workspaceId ?? undefined);
return topicModel.query(input);
}),
hasTopics: topicProcedure.query(async ({ ctx }) => {
-115
View File
@@ -1,115 +0,0 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { PluginModel } from '@/database/models/plugin';
import { getComposioClient } from '@/libs/composio';
import { authedProcedure, publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { MCPService } from '@/server/services/mcp';
const composioProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const composioClient = getComposioClient();
const pluginModel = new PluginModel(opts.ctx.serverDB, opts.ctx.userId);
return opts.next({ ctx: { ...opts.ctx, composioClient, pluginModel } });
});
export const composioToolsRouter = router({
executeAction: composioProcedure
.input(
z.object({
identifier: z.string(),
toolArgs: z.record(z.unknown()).optional(),
toolSlug: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// Resolve the connected account server-side from the caller's own plugin
// record (PluginModel is user-scoped). Never trust a connectedAccountId
// supplied by the client — that would let a user drive another user's
// connection.
const plugin = await ctx.pluginModel.findById(input.identifier);
const connectedAccountId = plugin?.customParams?.composio?.connectedAccountId;
if (!connectedAccountId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `No Composio connection found for "${input.identifier}".`,
});
}
const result = await (ctx.composioClient.tools as any).execute(input.toolSlug, {
arguments: input.toolArgs || {},
connectedAccountId,
// Toolkit version resolves to "latest"; allow manual execution without a
// pinned version (Composio otherwise throws ComposioToolVersionRequiredError).
dangerouslySkipVersionCheck: true,
userId: ctx.userId,
});
if (!result) {
return {
content: 'Unknown error',
state: { content: [{ text: 'Unknown error', type: 'text' }], isError: true },
success: false,
};
}
const data = result as any;
const content = data?.data || data?.result || data;
const contentStr = typeof content === 'string' ? content : JSON.stringify(content);
return await MCPService.processToolCallResult({
content: [{ text: contentStr, type: 'text' }],
isError: false,
});
}),
getActions: publicProcedure.input(z.object({ appSlug: z.string() })).query(async ({ input }) => {
const client = getComposioClient();
const response = await (client.tools as any).getRawComposioTools({
toolkits: [input.appSlug],
});
const items = response?.items || response || [];
const tools = Array.isArray(items)
? items.map((tool: any) => ({
description: tool.description || '',
inputSchema: tool.inputParameters ||
tool.inputSchema || {
properties: {},
type: 'object',
},
name: tool.slug || tool.name || '',
}))
: [];
return { tools };
}),
listActions: composioProcedure
.input(z.object({ appSlug: z.string() }))
.query(async ({ ctx, input }) => {
// Use getRawComposioTools (raw tool defs with slug/inputParameters), NOT
// tools.get() — the latter returns provider-wrapped (OpenAI-format) tools
// whose name/params live under `.function`, so slug/name/inputSchema come
// back empty and every tool collapses to the same `${identifier}____` name.
const response = await (ctx.composioClient.tools as any).getRawComposioTools({
toolkits: [input.appSlug],
});
const items = response?.items || response || [];
const tools = Array.isArray(items)
? items.map((tool: any) => ({
description: tool.description || '',
inputSchema: tool.inputParameters ||
tool.inputSchema || {
properties: {},
type: 'object',
},
name: tool.slug || tool.name || '',
}))
: [];
return { tools };
}),
});
+2 -3
View File
@@ -1,14 +1,13 @@
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { composioToolsRouter } from './composio';
import { klavisRouter } from './klavis';
import { marketRouter } from './market';
import { mcpRouter } from './mcp';
import { searchRouter } from './search';
export const toolsRouter = router({
healthcheck: publicProcedure.query(() => "i'm live!"),
composio: composioToolsRouter,
klavis: klavisRouter,
market: marketRouter,
mcp: mcpRouter,
search: searchRouter,
+141
View File
@@ -0,0 +1,141 @@
import { z } from 'zod';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { ConnectorModel } from '@/database/models/connector';
import { ConnectorToolModel } from '@/database/models/connectorTool';
import { ConnectorToolPermission } from '@/database/schemas';
import { getKlavisClient } from '@/libs/klavis';
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { MCPService } from '@/server/services/mcp';
/**
* Klavis procedure with client initialized in context
*/
const klavisProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const klavisClient = getKlavisClient();
return opts.next({
ctx: { ...opts.ctx, klavisClient },
});
});
/**
* Klavis router for tools
* Contains callTool and listTools which call external Klavis API
*/
export const klavisRouter = router({
/**
* Call a tool on a Klavis Strata server
*/
callTool: klavisProcedure
.input(
z.object({
/** Klavis server identifier (e.g. 'gmail', 'google-calendar') for precise permission lookup */
identifier: z.string().optional(),
serverUrl: z.string(),
toolArgs: z.record(z.unknown()).optional(),
toolName: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
// ── Connector tool permission gate ────────────────────────────────────
// Use identifier + toolName when available for a precise lookup (avoids
// same-name collisions across connectors). Falls back to toolName-only
// if identifier is absent (legacy callers).
if (ctx.userId && ctx.serverDB) {
const wsId = ctx.workspaceId ?? undefined;
const connectorToolModel = new ConnectorToolModel(ctx.serverDB, ctx.userId, wsId);
let connectorTool:
| Awaited<ReturnType<typeof connectorToolModel.findByToolName>>
| undefined;
if (input.identifier) {
const connectorModel = new ConnectorModel(ctx.serverDB, ctx.userId, wsId);
const [connector] = await connectorModel.queryByIdentifiers([input.identifier]);
if (connector) {
const tools = await connectorToolModel.queryByConnector(connector.id);
connectorTool = tools.find((t) => t.toolName === input.toolName);
}
} else {
connectorTool = await connectorToolModel.findByToolName(input.toolName);
}
if (connectorTool?.permission === ConnectorToolPermission.disabled) {
const message =
`The tool "${input.toolName}" has been disabled by the user and cannot be executed. ` +
`Please inform the user that this tool is currently disabled. ` +
`They can re-enable it in Settings > Connectors.`;
return {
content: message,
state: { content: [{ text: message, type: 'text' }], isError: false },
success: true,
};
}
}
// ── End permission gate ───────────────────────────────────────────────
const response = await ctx.klavisClient.mcpServer.callTools({
serverUrl: input.serverUrl,
toolArgs: input.toolArgs,
toolName: input.toolName,
});
// Handle error case
if (!response.success || !response.result) {
return {
content: response.error || 'Unknown error',
state: {
content: [{ text: response.error || 'Unknown error', type: 'text' }],
isError: true,
},
success: false,
};
}
// Process the response using the common MCP tool call result processor
const processedResult = await MCPService.processToolCallResult({
content: (response.result.content || []) as any[],
isError: response.result.isError,
});
return processedResult;
}),
/**
* Get tools by server name (public endpoint, no auth required)
*/
getTools: publicProcedure
.input(
z.object({
serverName: z.string(),
}),
)
.query(async ({ input }) => {
const klavisClient = getKlavisClient();
const response = await klavisClient.mcpServer.getTools(input.serverName as any);
return {
tools: response.tools,
};
}),
/**
* List tools available on a Klavis Strata server
*/
listTools: klavisProcedure
.input(
z.object({
serverUrl: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const response = await ctx.klavisClient.mcpServer.listTools({
serverUrl: input.serverUrl,
});
return {
tools: response.tools,
};
}),
});
@@ -231,57 +231,6 @@ describe('AgentService', () => {
// Avatar should not be present for non-builtin agents
expect((result as any)?.avatar).toBeUndefined();
});
it('should NOT inherit the member personal default model for a workspace inbox', async () => {
// Workspace inbox is persisted with an empty model/provider.
const mockAgent = {
id: 'agent-1',
slug: 'inbox',
};
const serverDefaultConfig = { model: 'system-default-model', provider: 'system-provider' };
const mockAgentModel = {
getBuiltinAgent: vi.fn().mockResolvedValue(mockAgent),
};
(AgentModel as any).mockImplementation(() => mockAgentModel);
(parseAgentConfig as any).mockReturnValue(serverDefaultConfig);
// The member opening the workspace inbox has a personal default model.
mockUserModel.getUserSettingsDefaultAgentConfig.mockResolvedValueOnce({
config: { model: 'opus-4.6', provider: 'anthropic' },
});
const workspaceService = new AgentService(mockDb, mockUserId, mockWorkspaceId);
const result = await workspaceService.getBuiltinAgent('inbox');
// Should fall back to the system default, NOT the member's personal model.
expect(result?.model).toBe('system-default-model');
expect(result?.provider).toBe('system-provider');
});
it('should still apply the personal default model for a personal inbox', async () => {
const mockAgent = {
id: 'agent-1',
slug: 'inbox',
};
const mockAgentModel = {
getBuiltinAgent: vi.fn().mockResolvedValue(mockAgent),
};
(AgentModel as any).mockImplementation(() => mockAgentModel);
(parseAgentConfig as any).mockReturnValue({});
mockUserModel.getUserSettingsDefaultAgentConfig.mockResolvedValueOnce({
config: { model: 'user-preferred-model', provider: 'user-provider' },
});
// No workspaceId → personal scope keeps the personal default behavior.
const newService = new AgentService(mockDb, mockUserId);
const result = await newService.getBuiltinAgent('inbox');
expect(result?.model).toBe('user-preferred-model');
expect(result?.provider).toBe('user-provider');
});
});
describe('getAgentConfig', () => {
+4 -16
View File
@@ -174,13 +174,6 @@ export class AgentService {
* 2. serverDefaultAgentConfig - from environment variable
* 3. userDefaultAgentConfig - from user settings (defaultAgent.config)
* 4. agent - actual agent config from database
*
* Workspace exception: a workspace is a shared resource, so its agents must
* NOT inherit any individual member's *personal* default model. Otherwise a
* shared agent persisted with an empty model (e.g. the workspace inbox)
* resolves to whoever opens it the creator's personal default leaks in and
* the workspace looks "initialized" with their model. For workspace-scoped
* reads we skip the user layer and fall back to the system default instead.
*/
private mergeDefaultConfig(
agent: any,
@@ -188,17 +181,12 @@ export class AgentService {
): LobeAgentConfig | null {
if (!agent) return null;
// Merge configs in order: DEFAULT -> server -> [user] -> agent
const serverDefaultAgentConfig = getServerDefaultAgentConfig();
const baseConfig = merge(DEFAULT_AGENT_CONFIG, serverDefaultAgentConfig);
// Skip the personal default layer for workspace-scoped agents (see above).
if (this.workspaceId) {
return merge(baseConfig, cleanObject(agent));
}
const userDefaultAgentConfig =
(defaultAgentConfig as { config?: PartialDeep<LobeAgentConfig> })?.config || {};
// Merge configs in order: DEFAULT -> server -> user -> agent
const serverDefaultAgentConfig = getServerDefaultAgentConfig();
const baseConfig = merge(DEFAULT_AGENT_CONFIG, serverDefaultAgentConfig);
const withUserConfig = merge(baseConfig, userDefaultAgentConfig);
return merge(withUserConfig, cleanObject(agent));
@@ -98,8 +98,6 @@ describe('AgentDocumentsService', () => {
findByFilename: vi.fn(),
findSkillDocsByAgent: vi.fn(),
hasByAgent: vi.fn(),
listByAgent: vi.fn(),
listByDocumentIds: vi.fn(),
rename: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
@@ -284,19 +282,21 @@ describe('AgentDocumentsService', () => {
describe('listDocuments', () => {
it('should return a list of documents with documentId, filename, id, and title', async () => {
mockModel.listByAgent.mockResolvedValue([
mockModel.findByAgent.mockResolvedValue([
{
content: 'c1',
documentId: 'documents-1',
filename: 'a.md',
id: 'doc-1',
loadPosition: undefined,
policy: null,
title: 'A',
},
{
content: 'c2',
documentId: 'documents-2',
filename: 'b.md',
id: 'doc-2',
loadPosition: undefined,
policy: null,
title: 'B',
},
]);
@@ -304,8 +304,7 @@ describe('AgentDocumentsService', () => {
const service = new AgentDocumentsService(db, userId);
const result = await service.listDocuments('agent-1');
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1');
expect(mockModel.findByAgent).not.toHaveBeenCalled();
expect(mockModel.findByAgent).toHaveBeenCalledWith('agent-1');
expect(result).toEqual([
{
documentId: 'documents-1',
@@ -323,16 +322,6 @@ describe('AgentDocumentsService', () => {
},
]);
});
it('should pass sourceType filtering to the model', async () => {
mockModel.listByAgent.mockResolvedValue([]);
const service = new AgentDocumentsService(db, userId);
await service.listDocuments('agent-1', 'web');
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1', { sourceType: 'web' });
expect(mockModel.findByAgent).not.toHaveBeenCalled();
});
});
describe('listDocumentsForTopic', () => {
@@ -341,19 +330,19 @@ describe('AgentDocumentsService', () => {
{ id: 'documents-2', title: 'B' },
{ id: 'documents-1', title: 'A' },
]);
mockModel.listByDocumentIds.mockResolvedValue([
mockModel.findByDocumentIds.mockResolvedValue([
{
documentId: 'documents-1',
filename: 'a.md',
id: 'agent-doc-1',
loadPosition: undefined,
policy: null,
title: 'A',
},
{
documentId: 'documents-2',
filename: 'b.md',
id: 'agent-doc-2',
loadPosition: undefined,
policy: null,
title: 'B',
},
]);
@@ -362,11 +351,10 @@ describe('AgentDocumentsService', () => {
const result = await service.listDocumentsForTopic('agent-1', 'topic-1');
expect(mockTopicDocumentModel.findByTopicId).toHaveBeenCalledWith('topic-1');
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', [
expect(mockModel.findByDocumentIds).toHaveBeenCalledWith('agent-1', [
'documents-2',
'documents-1',
]);
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
expect(result).toEqual([
{
documentId: 'documents-2',
@@ -384,19 +372,6 @@ describe('AgentDocumentsService', () => {
},
]);
});
it('should pass sourceType filtering to the topic document summary query', async () => {
mockTopicDocumentModel.findByTopicId.mockResolvedValue([{ id: 'documents-1' }]);
mockModel.listByDocumentIds.mockResolvedValue([]);
const service = new AgentDocumentsService(db, userId);
await service.listDocumentsForTopic('agent-1', 'topic-1', 'web');
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', ['documents-1'], {
sourceType: 'web',
});
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
});
});
describe('getDocumentByFilename', () => {
@@ -13,8 +13,6 @@ import type {
AgentDocument,
AgentDocumentContextPayload,
AgentDocumentContextRow,
AgentDocumentListItem,
AgentDocumentListSourceType,
AgentDocumentWithRules,
ToolUpdateLoadRule,
} from '@/database/models/agentDocuments';
@@ -613,27 +611,54 @@ export class AgentDocumentsService {
}
}
async listDocuments(agentId: string, sourceType?: AgentDocumentListSourceType) {
if (!sourceType) return this.agentDocumentModel.listByAgent(agentId);
return this.agentDocumentModel.listByAgent(agentId, { sourceType });
async listDocuments(agentId: string, sourceType?: 'all' | 'file' | 'web') {
const docs = await this.agentDocumentModel.findByAgent(agentId);
const filtered =
sourceType && sourceType !== 'all' ? docs.filter((d) => d.sourceType === sourceType) : docs;
return filtered.map((d) => ({
...deriveAgentDocumentFields(d),
description: d.description,
documentId: d.documentId,
fileType: d.fileType,
filename: d.filename,
id: d.id,
loadPosition: d.policy?.context?.position,
parentId: d.parentId,
sourceType: d.sourceType,
templateId: d.templateId,
title: d.title,
updatedAt: d.updatedAt,
}));
}
async listDocumentsForTopic(
agentId: string,
topicId: string,
sourceType?: AgentDocumentListSourceType,
sourceType?: 'all' | 'file' | 'web',
) {
const topicDocs = await this.topicDocumentModel.findByTopicId(topicId);
const documentIds = topicDocs.map((doc) => doc.id);
const docs = sourceType
? await this.agentDocumentModel.listByDocumentIds(agentId, documentIds, { sourceType })
: await this.agentDocumentModel.listByDocumentIds(agentId, documentIds);
const docs = await this.agentDocumentModel.findByDocumentIds(agentId, documentIds);
const docsByDocumentId = new Map(docs.map((doc) => [doc.documentId, doc]));
return topicDocs
.map((topicDoc) => docsByDocumentId.get(topicDoc.id))
.filter((doc): doc is AgentDocumentListItem => Boolean(doc));
.filter((doc): doc is AgentDocumentWithRules => Boolean(doc))
.filter((doc) => !sourceType || sourceType === 'all' || doc.sourceType === sourceType)
.map((doc) => ({
...deriveAgentDocumentFields(doc),
description: doc.description,
documentId: doc.documentId,
fileType: doc.fileType,
filename: doc.filename,
id: doc.id,
loadPosition: doc.policy?.context?.position,
parentId: doc.parentId,
sourceType: doc.sourceType,
templateId: doc.templateId,
title: doc.title,
updatedAt: doc.updatedAt,
}));
}
async getDocumentByFilename(agentId: string, filename: string) {
@@ -10,7 +10,6 @@ import type { LobeChatDatabase } from '@/database/type';
import { AgentRuntimeCoordinator } from '@/server/modules/AgentRuntime/AgentRuntimeCoordinator';
import { OperationTraceRecorder } from './OperationTraceRecorder';
import { createDefaultSnapshotStore } from './snapshotStore';
const log = debug('lobe-server:abandon-operation');
@@ -128,3 +127,25 @@ export class AbandonOperationService {
return result;
}
}
function createDefaultSnapshotStore(): ISnapshotStore | null {
if (process.env.ENABLE_AGENT_S3_TRACING === '1') {
try {
const { S3SnapshotStore } = require('@/server/modules/AgentTracing');
return new S3SnapshotStore();
} catch {
/* S3SnapshotStore not available */
}
}
if (process.env.NODE_ENV === 'development') {
try {
const { FileSnapshotStore } = require('@lobechat/agent-tracing');
return new FileSnapshotStore();
} catch {
/* agent-tracing not available */
}
}
return null;
}
@@ -17,9 +17,6 @@ import {
} from './types';
vi.mock('@lobechat/model-runtime', () => ({
// RuntimeExecutors (loaded transitively) resolves extend params via this
// helper; an empty result keeps the runtime payload unchanged.
applyModelExtendParams: vi.fn(() => ({})),
getModelPropertyWithFallback: vi.fn(),
// `llmErrorClassification.ts` reads these at module-load time; an empty
// spec map is fine here because this suite never exercises the runtime
@@ -1699,7 +1696,7 @@ describe('AgentRuntimeService', () => {
expect(casSpy).not.toHaveBeenCalled();
});
it('arms the first verify (attempt 1, 15s) when the parent has not parked yet and scheduleVerifyOnHold is set', async () => {
it('arms a one-shot verify when the parent has not parked yet and scheduleVerifyOnHold is set', async () => {
// Child completed before the parent's parking step persisted its state.
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [],
@@ -1715,15 +1712,14 @@ describe('AgentRuntimeService', () => {
expect(won).toBe(false);
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({
delay: 15_000,
operationId: parentOpId,
payload: { asyncToolVerifyAttempt: 1, verifyAsyncToolBarrier: true },
payload: { verifyAsyncToolBarrier: true },
stepIndex: 2,
}),
);
});
it('arms a verify when the barrier is unsatisfied and scheduleVerifyOnHold is set', async () => {
it('arms a one-shot verify when the barrier is unsatisfied and scheduleVerifyOnHold is set', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
@@ -1740,104 +1736,7 @@ describe('AgentRuntimeService', () => {
expect(won).toBe(false);
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({
payload: { asyncToolVerifyAttempt: 1, verifyAsyncToolBarrier: true },
}),
);
});
it('re-arms the next verify with exponential backoff while the barrier holds', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
stepCount: 1,
});
(service as any).serverDB.query = {
messagePlugins: { findFirst: vi.fn().mockResolvedValue(null) },
};
// A verify handler running as attempt 2 re-arms attempt 3 (60s).
await service.tryResumeParentFromAsyncTool(
{ parentOperationId: parentOpId },
{ scheduleVerifyOnHold: true, verifyAttempt: 3 },
);
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({
delay: 60_000,
payload: { asyncToolVerifyAttempt: 3, verifyAsyncToolBarrier: true },
}),
);
});
it('stops re-arming once the bounded attempts are exhausted', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
stepCount: 1,
});
(service as any).serverDB.query = {
messagePlugins: { findFirst: vi.fn().mockResolvedValue(null) },
};
const won = await service.tryResumeParentFromAsyncTool(
{ parentOperationId: parentOpId },
{ scheduleVerifyOnHold: true, verifyAttempt: 6 },
);
expect(won).toBe(false);
expect(mockQueueService.scheduleMessage).not.toHaveBeenCalled();
});
it('trusts a just-backfilled message id without re-reading it (read-your-writes)', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
stepCount: 3,
});
// Plugin row exists (created at park) but its state still reads stale.
const findById = vi.fn().mockResolvedValue({ content: '' });
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi.fn().mockResolvedValue({ id: 'msg-tc1', state: null, toolCallId: 'tc1' }),
},
};
(service as any).messageModel.findById = findById;
const casSpy = vi
.spyOn(AgentOperationModel.prototype, 'tryResumeFromAsyncTool')
.mockResolvedValue(true);
const won = await service.tryResumeParentFromAsyncTool(
{ parentOperationId: parentOpId },
{ knownFulfilledMessageId: 'msg-tc1' },
);
expect(won).toBe(true);
expect(casSpy).toHaveBeenCalledWith(parentOpId);
// The stale read must be skipped — barrier trusted the local backfill.
expect(findById).not.toHaveBeenCalled();
});
it('arms a fallback verify when a parked op has no pending tools', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [],
status: 'waiting_for_async_tool',
stepCount: 4,
});
const casSpy = vi.spyOn(AgentOperationModel.prototype, 'tryResumeFromAsyncTool');
const won = await service.tryResumeParentFromAsyncTool(
{ parentOperationId: parentOpId },
{ scheduleVerifyOnHold: true },
);
expect(won).toBe(false);
expect(casSpy).not.toHaveBeenCalled();
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({
payload: { asyncToolVerifyAttempt: 1, verifyAsyncToolBarrier: true },
stepIndex: 4,
}),
expect.objectContaining({ payload: { verifyAsyncToolBarrier: true } }),
);
});
@@ -1856,186 +1755,6 @@ describe('AgentRuntimeService', () => {
expect(won).toBe(false);
expect(mockQueueService.scheduleMessage).not.toHaveBeenCalled();
});
it('schedules a finish step when the parked tool requests onComplete=finish (skipCallSupervisor / delegate)', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({
pendingToolsCalling: [{ id: 'tc1' }],
status: 'waiting_for_async_tool',
stepCount: 4,
});
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi.fn().mockResolvedValue({
id: 'msg-tc1',
state: { onComplete: 'finish', status: 'completed' },
toolCallId: 'tc1',
}),
},
};
(service as any).messageModel.findById = vi.fn().mockResolvedValue({ content: 'answer' });
vi.spyOn(AgentOperationModel.prototype, 'tryResumeFromAsyncTool').mockResolvedValue(true);
const won = await service.tryResumeParentFromAsyncTool({ parentOperationId: parentOpId });
expect(won).toBe(true);
expect(mockQueueService.scheduleMessage).toHaveBeenCalledWith(
expect.objectContaining({ payload: { finishAfterAsyncTool: true }, stepIndex: 4 }),
);
});
});
describe('completeGroupActionMember', () => {
const memberState = {
messages: [
{ content: 'question', role: 'user' },
{ content: 'final answer', role: 'assistant' },
],
metadata: { agentId: 'agent-a' },
modelRuntimeConfig: { model: 'gpt-test' },
status: 'done',
usage: { llm: { tokens: { total: 42 } }, tools: { totalCalls: 2 } },
};
let updateToolMessage: ReturnType<typeof vi.fn>;
let resumeSpy: MockInstance<AgentRuntimeService['tryResumeParentFromAsyncTool']>;
beforeEach(() => {
updateToolMessage = vi.fn().mockResolvedValue({ success: true });
(service as any).messageModel.updateToolMessage = updateToolMessage;
resumeSpy = vi.spyOn(service, 'tryResumeParentFromAsyncTool').mockResolvedValue(true);
});
it('single in-group member: backfills a receipt onto the group tool and resumes', async () => {
const won = await service.completeGroupActionMember({
anchorMessageId: 'grp-tool-1',
expectedMembers: 1,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(won).toBe(true);
expect(updateToolMessage).toHaveBeenCalledWith(
'grp-tool-1',
expect.objectContaining({
content: 'Agent agent-a responded in the group.',
pluginState: expect.objectContaining({ status: 'completed' }),
}),
);
expect(resumeSpy).toHaveBeenCalledWith(
{ parentOperationId: 'parent-1' },
{ scheduleVerifyOnHold: true },
);
});
it('single isolated member: backfills the final answer', async () => {
await service.completeGroupActionMember({
anchorMessageId: 'grp-tool-1',
expectedMembers: 1,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'isolated',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(updateToolMessage).toHaveBeenCalledWith(
'grp-tool-1',
expect.objectContaining({ content: 'final answer' }),
);
});
it('multi-member: holds (no group-tool backfill, no resume) until the barrier is met', async () => {
(service as any).serverDB.query = {
messagePlugins: { findFirst: vi.fn() },
messages: {
findMany: vi
.fn()
.mockResolvedValue([{ content: 'a note', id: 'anchor-0', role: 'tool' }]),
},
};
mockCoordinator.loadAgentState.mockResolvedValue({
status: 'waiting_for_async_tool',
stepCount: 1,
});
const won = await service.completeGroupActionMember({
anchorMessageId: 'anchor-0',
expectedMembers: 2,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(won).toBe(false);
expect(updateToolMessage).toHaveBeenCalledWith('anchor-0', expect.anything());
expect(updateToolMessage).not.toHaveBeenCalledWith('grp-tool-1', expect.anything());
expect(resumeSpy).not.toHaveBeenCalled();
});
it('multi-member: last completion backfills the group tool and resumes', async () => {
(service as any).serverDB.query = {
messagePlugins: { findFirst: vi.fn() },
messages: {
findMany: vi.fn().mockResolvedValue([
{ content: 'a', id: 'anchor-0', role: 'tool' },
{ content: 'b', id: 'anchor-1', role: 'tool' },
]),
},
};
const won = await service.completeGroupActionMember({
anchorMessageId: 'anchor-1',
expectedMembers: 2,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-2',
parentOperationId: 'parent-1',
reason: 'done',
});
expect(won).toBe(true);
expect(updateToolMessage).toHaveBeenCalledWith('anchor-1', expect.anything());
expect(updateToolMessage).toHaveBeenCalledWith(
'grp-tool-1',
expect.objectContaining({
content: 'All 2 agent members completed.',
pluginState: expect.objectContaining({ status: 'completed' }),
}),
);
expect(resumeSpy).toHaveBeenCalled();
});
it('throws when the anchor backfill fails so the webhook redelivers', async () => {
updateToolMessage.mockResolvedValue({ success: false });
await expect(
service.completeGroupActionMember({
anchorMessageId: 'grp-tool-1',
expectedMembers: 1,
finalState: memberState as any,
groupToolMessageId: 'grp-tool-1',
mode: 'in_group',
onComplete: 'resume',
operationId: 'child-1',
parentOperationId: 'parent-1',
reason: 'done',
}),
).rejects.toThrow(/failed to backfill anchor/);
expect(resumeSpy).not.toHaveBeenCalled();
});
});
describe('completeSubAgentBridge', () => {
@@ -2086,7 +1805,7 @@ describe('AgentRuntimeService', () => {
});
expect(resumeSpy).toHaveBeenCalledWith(
{ parentOperationId: 'parent-op-1' },
{ knownFulfilledMessageId: 'tool-msg-1', scheduleVerifyOnHold: true },
{ scheduleVerifyOnHold: true },
);
});
@@ -2140,93 +1859,4 @@ describe('AgentRuntimeService', () => {
expect(resumeSpy).not.toHaveBeenCalled();
});
});
describe('resolveAsyncToolOnComplete', () => {
it('returns finish when ANY pending tool requests finish (not just the first)', async () => {
// First pending tool resumes; a later one is a group finish action. The
// disposition must scan all pending tools, not only pending[0].
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi
.fn()
.mockResolvedValueOnce({ state: { status: 'completed' } })
.mockResolvedValueOnce({ state: { onComplete: 'finish', status: 'completed' } }),
},
};
const result = await (service as any).resolveAsyncToolOnComplete([
{ id: 'tc1' },
{ id: 'tc2' },
]);
expect(result).toBe('finish');
});
it('returns resume when no pending tool requests finish', async () => {
(service as any).serverDB.query = {
messagePlugins: {
findFirst: vi.fn().mockResolvedValue({ state: { status: 'completed' } }),
},
};
const result = await (service as any).resolveAsyncToolOnComplete([
{ id: 'tc1' },
{ id: 'tc2' },
]);
expect(result).toBe('resume');
});
});
describe('group member timeout watchdog', () => {
const timeoutParams = {
anchorMessageId: 'anchor-1',
expectedMembers: 1,
groupToolMessageId: 'grp-tool-1',
memberOperationId: 'member-op-1',
mode: 'isolated' as const,
onComplete: 'resume' as const,
parentOperationId: 'parent-1',
};
it('no-ops when the member already reached a terminal state', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({ status: 'done' });
const interruptSpy = vi.spyOn(service, 'interruptOperation');
const bridgeSpy = vi.spyOn(service, 'completeGroupActionMember');
const result = await service.executeStep({
groupMemberTimeout: timeoutParams,
operationId: 'member-op-1',
stepIndex: 0,
} as any);
expect(result.success).toBe(true);
expect(result.nextStepScheduled).toBe(false);
expect(interruptSpy).not.toHaveBeenCalled();
expect(bridgeSpy).not.toHaveBeenCalled();
});
it('interrupts the member and bridges a timeout when it is still running', async () => {
mockCoordinator.loadAgentState.mockResolvedValue({ status: 'running' });
const interruptSpy = vi.spyOn(service, 'interruptOperation').mockResolvedValue(true);
const bridgeSpy = vi.spyOn(service, 'completeGroupActionMember').mockResolvedValue(true);
const result = await service.executeStep({
groupMemberTimeout: timeoutParams,
operationId: 'member-op-1',
stepIndex: 0,
} as any);
expect(interruptSpy).toHaveBeenCalledWith('member-op-1');
expect(bridgeSpy).toHaveBeenCalledWith(
expect.objectContaining({
onComplete: 'resume',
operationId: 'member-op-1',
parentOperationId: 'parent-1',
reason: 'timeout',
}),
);
expect(result.nextStepScheduled).toBe(true);
});
});
});
@@ -20,18 +20,12 @@ import {
trace as otelTrace,
} from '@lobechat/observability-otel/api';
import {
asyncToolResumeCounter,
buildInvokeAgentAttributes,
buildInvokeAgentResultAttributes,
invokeAgentSpanName,
tracer as agentRuntimeTracer,
} from '@lobechat/observability-otel/modules/agent-runtime';
import {
type ChatToolPayload,
type ExecSubAgentParams,
type ExecVirtualSubAgentParams,
type UIChatMessage,
} from '@lobechat/types';
import { type ChatToolPayload, type ExecSubAgentParams, type UIChatMessage } from '@lobechat/types';
import debug from 'debug';
import urlJoin from 'url-join';
@@ -62,16 +56,10 @@ import { CompletionLifecycle } from './CompletionLifecycle';
import { hookDispatcher } from './hooks';
import { HumanInterventionHandler } from './HumanInterventionHandler';
import { OperationTraceRecorder } from './OperationTraceRecorder';
import { createDefaultSnapshotStore } from './snapshotStore';
import { buildStepPresentation, formatTokenCount } from './stepPresentation';
import {
type AgentExecutionParams,
type AgentExecutionResult,
type ExecGroupMemberParams,
type ExecGroupMemberResult,
type GroupActionMemberBridgeParams,
type GroupActionOnComplete,
type GroupMemberTimeoutParams,
type OperationCreationParams,
type OperationCreationResult,
type OperationStatusResult,
@@ -90,37 +78,13 @@ if (process.env.VERCEL) {
const log = debug('lobe-server:agent-runtime-service');
/**
* Base delay before the first `verifyAsyncToolBarrier` re-check fires after a
* Delay before a one-shot `verifyAsyncToolBarrier` re-check fires after a
* sub-agent completion found the parent not yet resumable. Long enough for
* the parent's parking step to finish persisting, short enough that a lost
* resume is recovered promptly. Subsequent attempts back off exponentially
* see {@link asyncToolVerifyDelayMs}.
* resume is recovered promptly.
*/
const ASYNC_TOOL_VERIFY_DELAY_MS = 15_000;
/**
* Maximum number of bounded watchdog re-checks armed per parked parent. The
* watchdog re-arms after each unsatisfied check (instead of the old single
* shot) so a transient miss a read-replica lag, a sibling dying between
* backfill and resume is retried rather than leaving the parent stuck in
* `waiting_for_async_tool` forever. With exponential backoff from a 15s base,
* 5 attempts span ~15s ~7.75min total before giving up. For details see: async sub-agent suspend/resume stability hardening bounded watchdog retry with exponential backoff instead of single-shot verification.
*/
const ASYNC_TOOL_VERIFY_MAX_ATTEMPTS = 5;
/** Hard ceiling on a single backoff delay so late attempts don't overshoot. */
const ASYNC_TOOL_VERIFY_MAX_DELAY_MS = 240_000;
/**
* Exponential backoff delay for the Nth (1-based) watchdog re-check:
* 15s, 30s, 60s, 120s, 240s, capped at {@link ASYNC_TOOL_VERIFY_MAX_DELAY_MS}.
*/
const asyncToolVerifyDelayMs = (attempt: number): number =>
Math.min(
ASYNC_TOOL_VERIFY_DELAY_MS * 2 ** (Math.max(1, attempt) - 1),
ASYNC_TOOL_VERIFY_MAX_DELAY_MS,
);
/**
* Format error for storage in message pluginError metadata.
* Handles Error objects which don't serialize properly with JSON.stringify.
@@ -162,24 +126,13 @@ const toAgentSignalSnapshotEvents = (
*/
export interface AgentRuntimeDelegate {
/**
* Fork a group member ("call agent member") under a `lobe-group-management`
* tool call. Handles both in-group (non-isolated, shared group session) and
* isolated members, installing the group-action member completion bridge that
* enforces the K=N member barrier before resuming/finishing the supervisor.
*/
execGroupMember?: (params: ExecGroupMemberParams) => Promise<ExecGroupMemberResult>;
/**
* Run a legacy agent invocation through the full high-level pipeline
* Fork a sub-agent through the full high-level pipeline
* (AiAgentService.execSubAgent execAgent: agent-config resolution, tool
* engine, context engineering, createOperation).
* engine, context engineering, createOperation). Returns a deferred result;
* the parent op parks (`waiting_for_async_tool`) until the completion bridge
* backfills the placeholder and resumes it.
*/
execSubAgent?: (params: ExecSubAgentParams) => Promise<unknown>;
/**
* Fork a `lobe-agent.callSubAgent` virtual child run. The child is marked as a
* sub-agent and owns the completion bridge that backfills the parent tool
* placeholder before resuming the parked parent operation.
*/
execVirtualSubAgent?: (params: ExecVirtualSubAgentParams) => Promise<unknown>;
}
export interface AgentRuntimeServiceOptions {
@@ -294,7 +247,7 @@ export class AgentRuntimeService {
this.queueService =
options?.queueService === null ? null : (options?.queueService ?? new QueueService());
this.traceRecorder = new OperationTraceRecorder(
options?.snapshotStore ?? createDefaultSnapshotStore(),
options?.snapshotStore ?? this.createDefaultSnapshotStore(),
);
this.agentFactory = options?.agentFactory;
this.delegate = options?.delegate ?? {};
@@ -625,39 +578,17 @@ export class AgentRuntimeService {
rejectionReason,
rejectAndContinue,
resumeAsyncTool,
finishAfterAsyncTool,
groupMemberTimeout,
toolMessageId,
verifyAsyncToolBarrier,
asyncToolVerifyAttempt,
externalRetryCount = 0,
} = params;
// Group member timeout watchdog: enforce a member's deadline without claiming
// the step lock. No-op if the member already finished; otherwise interrupt it
// and bridge a `timeout` completion so the parked supervisor resumes/finishes.
if (groupMemberTimeout) {
return this.handleGroupMemberTimeout(groupMemberTimeout);
}
// Watchdog re-check for a parked async-tool wait: re-run the barrier + CAS
// without claiming the step lock or executing anything. Idempotent — the
// CAS guarantees at most one real resume regardless of how many checks run.
// Opt back into `scheduleVerifyOnHold` with the next attempt so an
// unsatisfied barrier re-arms (bounded backoff) instead of giving up after
// a single shot — bounded watchdog retry ensures transient misses are recovered.
if (verifyAsyncToolBarrier) {
const attempt = asyncToolVerifyAttempt ?? 1;
log(
'[%s][%d] Running async-tool barrier verify (attempt %d)',
operationId,
stepIndex,
attempt,
);
const resumed = await this.tryResumeParentFromAsyncTool(
{ parentOperationId: operationId },
{ scheduleVerifyOnHold: true, verifyAttempt: attempt + 1 },
);
log('[%s][%d] Running async-tool barrier verify', operationId, stepIndex);
const resumed = await this.tryResumeParentFromAsyncTool({ parentOperationId: operationId });
return {
nextStepScheduled: resumed,
state: {},
@@ -908,29 +839,6 @@ export class AgentRuntimeService {
);
}
// Finish a parked supervisor op WITHOUT another LLM turn (group
// orchestration skipCallSupervisor / delegate). Refresh messages so the
// final group conversation is captured, transition straight to `done`,
// and let the standard `!shouldContinue` finalization below record
// completion + dispatch hooks. Skips runtime.step entirely.
let forcedFinishState: AgentState | undefined;
if (finishAfterAsyncTool && currentState.status === 'waiting_for_async_tool') {
const refreshed = await this.refreshMessagesFromDB(currentState);
currentState = structuredClone(currentState);
currentState.messages = refreshed;
currentState.pendingToolsCalling = [];
currentState.status = 'done';
currentState.interruption = undefined;
currentState.lastModified = new Date().toISOString();
forcedFinishState = currentState;
log(
'[%s][%d] Finishing parked supervisor op after async tool (%d messages)',
operationId,
stepIndex,
refreshed.length,
);
}
// Pre-step computation: extract device context from DB messages
// Follows front-end computeStepContext pattern — computed at step boundary, not inside executors
if (!currentState.metadata?.activeDeviceId) {
@@ -948,11 +856,9 @@ export class AgentRuntimeService {
}
}
// Execute step (skipped when force-finishing a parked supervisor op).
// Execute step
const startAt = Date.now();
const stepResult = forcedFinishState
? { events: [], newState: forcedFinishState, nextContext: undefined }
: await runtime.step(currentState, currentContext);
const stepResult = await runtime.step(currentState, currentContext);
// Inner runtime.step() catches model-runtime exceptions and stuffs the
// raw error into newState.error without re-throwing — so the outer
@@ -1711,35 +1617,12 @@ export class AgentRuntimeService {
*/
async tryResumeParentFromAsyncTool(
params: { parentOperationId: string },
options?: {
/**
* Message id of a tool placeholder the caller just backfilled to a
* terminal state. Trusted by the barrier as fulfilled without re-reading
* `message_plugins` closes the read-your-writes gap where the barrier
* query hits a read replica that hasn't seen the just-committed write.
*/
knownFulfilledMessageId?: string;
/**
* Group orchestration disposition (skipCallSupervisor / delegate finish).
* When omitted, resolved from the parked tool message's pluginState.
*/
onComplete?: GroupActionOnComplete;
scheduleVerifyOnHold?: boolean;
/** 1-based watchdog attempt to arm when the parent isn't resumable yet. */
verifyAttempt?: number;
},
options?: { scheduleVerifyOnHold?: boolean },
): Promise<boolean> {
const { parentOperationId } = params;
const state = await this.coordinator.loadAgentState(parentOperationId);
if (!state) {
// State expired (Redis TTL) or never persisted — nothing left to resume.
// Surface it: a missing state at completion time is how a parent silently
// strands. There is no stepCount/status to arm a verify against.
log('[%s] async-tool resume: parent state missing/expired, cannot resume', parentOperationId);
asyncToolResumeCounter.add(1, { outcome: 'no_state' });
return false;
}
if (!state) return false;
if (state.status !== 'waiting_for_async_tool') {
// Not parked (yet). Either the op already resumed/finished — nothing to
@@ -1750,58 +1633,26 @@ export class AgentRuntimeService {
}
const pending = (state.pendingToolsCalling ?? []) as ChatToolPayload[];
if (pending.length === 0) {
// Parked but no pending tools recorded — usually the parked snapshot's
// `pendingToolsCalling` hasn't finished persisting yet. Warn, report, and
// arm a fallback re-check rather than returning silently (the old bug).
log(
'[%s] async-tool resume: parked op has no pending tools, arming fallback',
parentOperationId,
);
asyncToolResumeCounter.add(1, { outcome: 'no_pending' });
await this.maybeScheduleAsyncToolVerify(parentOperationId, state, options);
return false;
}
if (pending.length === 0) return false;
// Barrier: every pending tool must have a fulfilled tool_result message.
const allFulfilled = await this.allPendingToolsFulfilled(
pending,
options?.knownFulfilledMessageId,
);
const allFulfilled = await this.allPendingToolsFulfilled(pending);
if (!allFulfilled) {
log('[%s] async-tool barrier not yet satisfied, holding', parentOperationId);
asyncToolResumeCounter.add(1, { outcome: 'barrier_held' });
await this.maybeScheduleAsyncToolVerify(parentOperationId, state, options);
return false;
}
// Group orchestration's skipCallSupervisor / delegate ends the supervisor
// op without another LLM turn: the same CAS gate flips the parked op, but
// the scheduled step finishes it (`finishAfterAsyncTool`) instead of
// re-entering the LLM (`resumeAsyncTool`). Self-describing so the generic
// verify watchdog resolves it correctly: the option (if any) wins, else the
// hint persisted on the parked tool message's pluginState, else resume.
const onComplete: GroupActionOnComplete =
options?.onComplete ?? (await this.resolveAsyncToolOnComplete(pending));
// Single-fire guard: only one concurrent completion flips the op.
const won = await new AgentOperationModel(this.serverDB, this.userId).tryResumeFromAsyncTool(
parentOperationId,
);
if (!won) {
log('[%s] lost async-tool resume CAS, no-op', parentOperationId);
asyncToolResumeCounter.add(1, { outcome: 'lost_cas' });
return false;
}
asyncToolResumeCounter.add(1, { outcome: 'resumed' });
log(
'[%s] won async-tool resume CAS, scheduling step %d (onComplete: %s)',
parentOperationId,
state.stepCount,
onComplete,
);
log('[%s] won async-tool resume CAS, scheduling step %d', parentOperationId, state.stepCount);
if (this.queueService) {
await this.queueService.scheduleMessage({
@@ -1809,8 +1660,7 @@ export class AgentRuntimeService {
delay: 100,
endpoint: `${this.baseURL}/run`,
operationId: parentOperationId,
payload:
onComplete === 'finish' ? { finishAfterAsyncTool: true } : { resumeAsyncTool: true },
payload: { resumeAsyncTool: true },
priority: 'high',
stepIndex: state.stepCount,
});
@@ -1822,60 +1672,36 @@ export class AgentRuntimeService {
}
/**
* Arm the next bounded `verifyAsyncToolBarrier` re-check for a parent op whose
* resume attempt found it not yet resumable. Skipped for terminal states
* (nothing left to resume) and when the caller didn't opt in.
*
* Unlike the original single shot, the watchdog re-arms after each unsatisfied
* check: the verify handler re-enters here with `verifyAttempt + 1`, backing
* off exponentially up to {@link ASYNC_TOOL_VERIFY_MAX_ATTEMPTS}. A transient
* miss (read-replica lag, a sibling dying between backfill and resume) is thus
* retried instead of permanently stranding the parent. Once attempts are
* exhausted the chain stops and the `verify_exhausted` metric fires so the
* orphan is observable. For details see: async sub-agent suspend/resume stability hardening bounded watchdog retry with exponential backoff.
* Arm a one-shot delayed `verifyAsyncToolBarrier` re-check for a parent op
* whose resume attempt found it not yet resumable. Skipped for terminal
* states (nothing left to resume) and when the caller didn't opt in the
* verify execution itself never re-arms, keeping retries bounded to one
* per completion event.
*/
private async maybeScheduleAsyncToolVerify(
parentOperationId: string,
state: AgentState,
options?: { scheduleVerifyOnHold?: boolean; verifyAttempt?: number },
options?: { scheduleVerifyOnHold?: boolean },
): Promise<void> {
if (!options?.scheduleVerifyOnHold || !this.queueService) return;
const status = state.status as string;
if (status === 'done' || status === 'error' || status === 'interrupted') return;
const attempt = options.verifyAttempt ?? 1;
if (attempt > ASYNC_TOOL_VERIFY_MAX_ATTEMPTS) {
// Bounded retries spent and the parent is still not resumable — give up
// re-arming and report so the stuck wait can be detected, not silently
// accumulated.
log(
'[%s] async-tool barrier verify exhausted after %d attempts, giving up (status: %s)',
parentOperationId,
ASYNC_TOOL_VERIFY_MAX_ATTEMPTS,
status,
);
asyncToolResumeCounter.add(1, { outcome: 'verify_exhausted' });
return;
}
const delay = asyncToolVerifyDelayMs(attempt);
log(
'[%s] scheduling async-tool barrier verify attempt %d/%d in %dms (status: %s)',
'[%s] scheduling async-tool barrier verify in %dms (status: %s)',
parentOperationId,
attempt,
ASYNC_TOOL_VERIFY_MAX_ATTEMPTS,
delay,
ASYNC_TOOL_VERIFY_DELAY_MS,
status,
);
try {
await this.queueService.scheduleMessage({
context: undefined,
delay,
delay: ASYNC_TOOL_VERIFY_DELAY_MS,
endpoint: `${this.baseURL}/run`,
operationId: parentOperationId,
payload: { asyncToolVerifyAttempt: attempt, verifyAsyncToolBarrier: true },
payload: { verifyAsyncToolBarrier: true },
priority: 'high',
stepIndex: state.stepCount,
});
@@ -1956,40 +1782,22 @@ export class AgentRuntimeService {
);
}
// 2. Barrier + CAS + resume the parent op (infra errors propagate too).
// Pass the just-backfilled message id so the barrier trusts this write
// instead of re-reading a possibly-stale replica.
return this.tryResumeParentFromAsyncTool(
{ parentOperationId },
{ knownFulfilledMessageId: toolMessageId, scheduleVerifyOnHold: true },
);
// 2. Barrier + CAS + resume the parent op (infra errors propagate too)
return this.tryResumeParentFromAsyncTool({ parentOperationId }, { scheduleVerifyOnHold: true });
}
/**
* Whether every pending tool call has a fulfilled tool_result message i.e.
* a tool message exists for its `tool_call_id` with non-empty content or a
* terminal pluginState. Looks up by `tool_call_id` (plugin id === message id).
*
* `knownFulfilledMessageId` short-circuits the per-tool content/state read for
* a placeholder the caller just backfilled in the same request: its terminal
* write is a local fact, so re-reading it (possibly from a lagging read
* replica) would only risk a false negative that strands the parent. The
* plugin row itself predates the park, so the `tool_call_id → plugin.id`
* lookup still resolves; only the freshly written content/state is trusted.
*/
private async allPendingToolsFulfilled(
pending: ChatToolPayload[],
knownFulfilledMessageId?: string,
): Promise<boolean> {
private async allPendingToolsFulfilled(pending: ChatToolPayload[]): Promise<boolean> {
for (const tc of pending) {
const plugin = await this.serverDB.query.messagePlugins.findFirst({
where: (mp, { eq }) => eq(mp.toolCallId, tc.id),
});
if (!plugin) return false;
// Trust the caller's own just-committed backfill (read-your-writes).
if (knownFulfilledMessageId && plugin.id === knownFulfilledMessageId) continue;
const message = await this.messageModel.findById(plugin.id);
const pluginState = plugin.state as { status?: string } | null;
const fulfilled =
@@ -2001,252 +1809,6 @@ export class AgentRuntimeService {
return true;
}
/**
* Resolve the resume disposition for a parked op from the disposition hint
* persisted on its first pending tool message's pluginState. Group
* orchestration stamps `onComplete: 'finish'` there for skipCallSupervisor /
* delegate; everything else (sub-agents, client tools) resolves to `resume`.
* Self-describing so the generic verify watchdog finishes the right ops.
*/
private async resolveAsyncToolOnComplete(
pending: ChatToolPayload[],
): Promise<GroupActionOnComplete> {
// A batched turn can park multiple deferred/client tools. If ANY of them is
// a group action requesting finish (skipCallSupervisor / delegate), the
// orchestration must finish — reading only pending[0] would miss a group
// finish call that isn't the first pending tool and wrongly resume.
for (const tool of pending) {
const plugin = await this.serverDB.query.messagePlugins.findFirst({
where: (mp, { eq }) => eq(mp.toolCallId, tool.id),
});
const pluginState = plugin?.state as { onComplete?: string } | null;
if (pluginState?.onComplete === 'finish') return 'finish';
}
return 'resume';
}
/**
* Count fulfilled member anchors under a group-management tool call child
* `role: 'tool'` messages whose content is non-empty or whose pluginState is
* terminal. The K=N member barrier for broadcast / executeAgentTasks: the
* group tool message is only backfilled (satisfying the parked op's
* single-tool barrier) once this reaches the expected member count.
*/
private async countFulfilledMemberAnchors(groupToolMessageId: string): Promise<number> {
const children = await this.serverDB.query.messages.findMany({
where: (m, { and, eq }) => and(eq(m.parentId, groupToolMessageId), eq(m.role, 'tool')),
});
let fulfilled = 0;
for (const child of children) {
if (child.content && child.content.length > 0) {
fulfilled += 1;
continue;
}
const plugin = await this.serverDB.query.messagePlugins.findFirst({
where: (mp, { eq }) => eq(mp.id, child.id),
});
const pluginState = plugin?.state as { status?: string } | null;
if (pluginState?.status === 'completed' || pluginState?.status === 'error') fulfilled += 1;
}
return fulfilled;
}
/**
* Completion bridge for the group orchestration "call agent member" path
* (`lobe-group-management`: speak / broadcast / delegate / executeAgentTask(s)).
* Mirrors {@link completeSubAgentBridge} but enforces a K=N member barrier:
*
* 1. Backfill this member's anchor tool message (in_group a short receipt,
* since the member already spoke in the shared group conversation;
* isolated the member's final answer from its hidden thread).
* 2. Multi-member actions: hold until every member anchor is fulfilled, then
* backfill the supervisor's group tool message so the parked op's
* single-tool barrier passes. Single-member actions collapse the anchor
* onto the group tool call, so step 1 already satisfies the barrier.
* 3. Barrier-check + CAS resume/finish the parked supervisor via
* `tryResumeParentFromAsyncTool` (finish disposition read from the group
* tool message's pluginState).
*
* THROWS on infra failure of any backfill so the queue-mode callback returns
* non-2xx and QStash redelivers backfills are idempotent and the resume is
* CAS-guarded, so redelivery is safe.
*/
async completeGroupActionMember(params: GroupActionMemberBridgeParams): Promise<boolean> {
const {
anchorMessageId,
expectedMembers,
groupToolMessageId,
mode,
operationId,
parentOperationId,
reason,
threadId,
} = params;
const failed = reason === 'error' || reason === 'interrupted' || reason === 'timeout';
const finalState =
params.finalState ?? (await this.coordinator.loadAgentState(operationId)) ?? undefined;
log(
'[%s] group-member bridge → parent %s (mode: %s, reason: %s, %d members)',
operationId,
parentOperationId,
mode,
reason,
expectedMembers,
);
// 1. Backfill this member's anchor.
const messages = Array.isArray(finalState?.messages) ? finalState.messages : [];
const lastAssistant = [...messages]
.reverse()
.find((m: { role?: string }) => m?.role === 'assistant');
const agentLabel = (finalState?.metadata?.agentId as string | undefined) ?? 'member';
const anchorContent = failed
? `Agent member did not complete (${reason}).`
: mode === 'in_group'
? `Agent ${agentLabel} responded in the group.`
: (lastAssistant?.content as string | undefined) ||
'Agent member completed without a textual answer.';
const anchorBackfill = await this.messageModel.updateToolMessage(anchorMessageId, {
content: anchorContent,
pluginError: failed ? formatErrorForMetadata(finalState?.error) : undefined,
pluginState: {
model: finalState?.modelRuntimeConfig?.model,
status: failed ? 'error' : 'completed',
threadId,
totalToolCalls: finalState?.usage?.tools?.totalCalls,
totalTokens: finalState?.usage?.llm?.tokens?.total,
},
});
if (!anchorBackfill.success) {
throw new Error(
`Group-member bridge: failed to backfill anchor ${anchorMessageId} for parent ${parentOperationId}`,
);
}
// 2. K=N member barrier (multi-member actions only — single-member actions
// use the group tool call itself as the anchor, already backfilled above).
if (expectedMembers > 1 && anchorMessageId !== groupToolMessageId) {
const fulfilled = await this.countFulfilledMemberAnchors(groupToolMessageId);
if (fulfilled < expectedMembers) {
log(
'[%s] group-member barrier %d/%d, holding parent %s',
operationId,
fulfilled,
expectedMembers,
parentOperationId,
);
const parentState = await this.coordinator.loadAgentState(parentOperationId);
if (parentState) {
await this.maybeScheduleAsyncToolVerify(parentOperationId, parentState, {
scheduleVerifyOnHold: true,
});
}
return false;
}
// All members done — backfill the group tool call so the parked op's
// single-tool barrier ([groupTool]) passes. Idempotent across racing
// last-committers; the resume/finish CAS guarantees one transition.
const groupBackfill = await this.messageModel.updateToolMessage(groupToolMessageId, {
content: `All ${expectedMembers} agent members completed.`,
pluginState: { expectedMembers, status: 'completed' },
});
if (!groupBackfill.success) {
throw new Error(
`Group-member bridge: failed to backfill group tool ${groupToolMessageId} for parent ${parentOperationId}`,
);
}
}
// 3. Barrier + CAS + resume/finish the parked supervisor op.
return this.tryResumeParentFromAsyncTool({ parentOperationId }, { scheduleVerifyOnHold: true });
}
/**
* Schedule the group-member timeout watchdog. Fired `delayMs` after the member
* op is forked; if the member hasn't finished by then, the watchdog interrupts
* it and bridges a `timeout` completion so the parked supervisor doesn't wait
* forever. No-op when the queue is disabled or the timeout is non-positive.
*/
async scheduleGroupMemberTimeout(
params: GroupMemberTimeoutParams,
delayMs: number,
): Promise<void> {
if (!this.queueService || !(delayMs > 0)) return;
try {
await this.queueService.scheduleMessage({
context: undefined,
delay: delayMs,
endpoint: `${this.baseURL}/run`,
// Keyed on the member op so the /run worker can resolve userId from its
// metadata, same trust chain as every other scheduled step.
operationId: params.memberOperationId,
payload: { groupMemberTimeout: params },
priority: 'normal',
stepIndex: 0,
});
log(
'[%s] scheduled group-member timeout in %dms (parent %s)',
params.memberOperationId,
delayMs,
params.parentOperationId,
);
} catch (error) {
log(
'[%s] failed to schedule group-member timeout (non-fatal): %O',
params.memberOperationId,
error,
);
}
}
/**
* Enforce a group member's timeout. No-op if the member already reached a
* terminal state (its own completion bridge handles that). Otherwise interrupt
* the member and bridge a `timeout` completion backfilling its anchor and
* resuming/finishing the parked supervisor via the K=N barrier. The member's
* own interrupt bridge may also fire; both are idempotent (anchor rewrite +
* CAS-guarded resume).
*/
private async handleGroupMemberTimeout(
params: GroupMemberTimeoutParams,
): Promise<AgentExecutionResult> {
const state = await this.coordinator.loadAgentState(params.memberOperationId);
const status = state?.status as string | undefined;
if (!state || status === 'done' || status === 'error' || status === 'interrupted') {
log(
'[%s] group-member timeout: member already terminal (%s), no-op',
params.memberOperationId,
status,
);
return { nextStepScheduled: false, state: {}, success: true };
}
log(
'[%s] group-member timeout fired, interrupting + bridging timeout to parent %s',
params.memberOperationId,
params.parentOperationId,
);
await this.interruptOperation(params.memberOperationId);
const resumed = await this.completeGroupActionMember({
anchorMessageId: params.anchorMessageId,
expectedMembers: params.expectedMembers,
finalState: state,
groupToolMessageId: params.groupToolMessageId,
mode: params.mode,
onComplete: params.onComplete,
operationId: params.memberOperationId,
parentOperationId: params.parentOperationId,
reason: 'timeout',
});
return { nextStepScheduled: resumed, state: {}, success: true };
}
/**
* Reload the conversation messages from the database and flatten them for the
* runtime. Used when resuming a parked op so the next LLM step sees tool
@@ -2302,7 +1864,10 @@ export class AgentRuntimeService {
if (!tool || typeof tool !== 'object') continue;
const toolPayload = tool as { id?: unknown; result_msg_id?: unknown };
if (typeof toolPayload.id === 'string' && typeof toolPayload.result_msg_id === 'string') {
if (
typeof toolPayload.id === 'string' &&
typeof toolPayload.result_msg_id === 'string'
) {
toolResultMessageIds.set(toolPayload.id, toolPayload.result_msg_id);
}
}
@@ -2379,8 +1944,6 @@ export class AgentRuntimeService {
userTimezone: metadata?.userTimezone,
evalContext: metadata?.evalContext,
execSubAgent: this.delegate.execSubAgent,
execVirtualSubAgent: this.delegate.execVirtualSubAgent,
execGroupMember: this.delegate.execGroupMember,
hookDispatcher,
loadAgentState: this.coordinator.loadAgentState.bind(this.coordinator),
messageModel: this.messageModel,
@@ -2404,6 +1967,34 @@ export class AgentRuntimeService {
return { agent, runtime };
}
/**
* Create default snapshot store based on environment.
* - ENABLE_AGENT_S3_TRACING=1 S3SnapshotStore
* - NODE_ENV=development FileSnapshotStore
* - Otherwise null (no tracing)
*/
private createDefaultSnapshotStore(): ISnapshotStore | null {
if (process.env.ENABLE_AGENT_S3_TRACING === '1') {
try {
const { S3SnapshotStore } = require('@/server/modules/AgentTracing');
return new S3SnapshotStore();
} catch {
// S3SnapshotStore not available
}
}
if (process.env.NODE_ENV === 'development') {
try {
const { FileSnapshotStore } = require('@lobechat/agent-tracing');
return new FileSnapshotStore();
} catch {
// agent-tracing not available
}
}
return null;
}
/**
* Compute device context from DB messages at step boundary.
* Uses findInMessages visitor to scan tool messages for device activation.
@@ -344,16 +344,11 @@ export class CompletionLifecycle {
metadata?.assistantMessageId,
metadata?.userId || this.userId,
);
void runVerifyOnCompletion(
this.serverDB,
metadata?.userId || this.userId,
{
deliverable: event.lastAssistantContent ?? '',
goal,
operationId,
},
this.workspaceId,
);
void runVerifyOnCompletion(this.serverDB, metadata?.userId || this.userId, {
deliverable: event.lastAssistantContent ?? '',
goal,
operationId,
});
}
if (reason === 'error') {
@@ -1,71 +0,0 @@
// @vitest-environment node
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createDefaultSnapshotStore, shouldUseAgentS3Tracing } from '../snapshotStore';
const s3SnapshotStoreMock = vi.fn(() => ({ kind: 's3' }));
const fileSnapshotStoreMock = vi.fn(() => ({ kind: 'file' }));
const setEnv = (nodeEnv: string, agentS3Tracing?: string) => {
vi.stubEnv('NODE_ENV', nodeEnv);
vi.stubEnv('ENABLE_AGENT_S3_TRACING', agentS3Tracing);
};
const loadModule = vi.fn((moduleName: string) => {
if (moduleName === '@/server/modules/AgentTracing') {
return { S3SnapshotStore: s3SnapshotStoreMock };
}
if (moduleName === '@lobechat/agent-tracing') {
return { FileSnapshotStore: fileSnapshotStoreMock };
}
throw new Error(`Unexpected module: ${moduleName}`);
});
describe('agent runtime snapshot store defaults', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
});
it('enables S3 tracing by default in production when env is unset', () => {
setEnv('production');
expect(shouldUseAgentS3Tracing()).toBe(true);
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
});
it('uses the local file snapshot store in development when env is unset', () => {
setEnv('development');
expect(shouldUseAgentS3Tracing()).toBe(false);
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 'file' });
expect(loadModule).toHaveBeenCalledWith('@lobechat/agent-tracing');
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
expect(fileSnapshotStoreMock).toHaveBeenCalledTimes(1);
});
it('lets ENABLE_AGENT_S3_TRACING=1 force S3 tracing outside production', () => {
setEnv('development', '1');
expect(shouldUseAgentS3Tracing()).toBe(true);
expect(createDefaultSnapshotStore(loadModule)).toEqual({ kind: 's3' });
expect(loadModule).toHaveBeenCalledWith('@/server/modules/AgentTracing');
expect(s3SnapshotStoreMock).toHaveBeenCalledTimes(1);
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
});
it('lets an explicit ENABLE_AGENT_S3_TRACING value disable the production default', () => {
setEnv('production', '0');
expect(shouldUseAgentS3Tracing()).toBe(false);
expect(createDefaultSnapshotStore(loadModule)).toBeNull();
expect(loadModule).not.toHaveBeenCalled();
expect(s3SnapshotStoreMock).not.toHaveBeenCalled();
expect(fileSnapshotStoreMock).not.toHaveBeenCalled();
});
});
@@ -1,59 +0,0 @@
import type { ISnapshotStore } from '@lobechat/agent-tracing';
const ENABLE_AGENT_S3_TRACING_VALUE = '1';
type SnapshotStoreConstructor = new () => ISnapshotStore;
type SnapshotStoreModuleLoader = (moduleName: string) => unknown;
interface FileSnapshotStoreModule {
FileSnapshotStore: SnapshotStoreConstructor;
}
interface S3SnapshotStoreModule {
S3SnapshotStore: SnapshotStoreConstructor;
}
const nodeRequire: SnapshotStoreModuleLoader = (moduleName) => require(moduleName);
export const shouldUseAgentS3Tracing = () => {
const explicitValue = process.env.ENABLE_AGENT_S3_TRACING;
if (explicitValue !== undefined) return explicitValue === ENABLE_AGENT_S3_TRACING_VALUE;
return process.env.NODE_ENV === 'production';
};
/**
* Create default snapshot store based on environment.
* - ENABLE_AGENT_S3_TRACING=1 -> S3SnapshotStore
* - NODE_ENV=production with ENABLE_AGENT_S3_TRACING unset -> S3SnapshotStore
* - NODE_ENV=development -> FileSnapshotStore
* - Otherwise -> null (no tracing)
*/
export const createDefaultSnapshotStore = (
loadModule: SnapshotStoreModuleLoader = nodeRequire,
): ISnapshotStore | null => {
if (shouldUseAgentS3Tracing()) {
try {
const { S3SnapshotStore } = loadModule(
'@/server/modules/AgentTracing',
) as S3SnapshotStoreModule;
return new S3SnapshotStore();
} catch {
// S3SnapshotStore not available
}
}
if (process.env.NODE_ENV === 'development') {
try {
const { FileSnapshotStore } = loadModule(
'@lobechat/agent-tracing',
) as FileSnapshotStoreModule;
return new FileSnapshotStore();
} catch {
// agent-tracing not available
}
}
return null;
};
+7 -132
View File
@@ -121,30 +121,8 @@ export type StepCompletionReason =
export interface AgentExecutionParams {
approvedToolCall?: any;
/**
* 1-based attempt number carried by a `verifyAsyncToolBarrier` re-check so the
* bounded watchdog can back off and stop after a fixed number of tries. Absent
* (treated as attempt 1) on the first re-check armed by a completion bridge.
*/
asyncToolVerifyAttempt?: number;
context?: AgentRuntimeContext;
externalRetryCount?: number;
/**
* Finish (rather than resume) a `waiting_for_async_tool` supervisor op after
* its group members have completed. Used by `skipCallSupervisor` / delegate in
* group orchestration: the orchestration ends without another supervisor LLM
* turn. Scheduled by the group-action member barrier via
* `tryResumeParentFromAsyncTool({ onComplete: 'finish' })`.
*/
finishAfterAsyncTool?: boolean;
/**
* Watchdog payload to enforce a group member's timeout: when the member op
* hasn't reached a terminal state by its deadline, interrupt it and bridge a
* `timeout` completion so the parked supervisor resumes/finishes instead of
* waiting forever. Scheduled by `scheduleGroupMemberTimeout` after the member
* op is forked.
*/
groupMemberTimeout?: GroupMemberTimeoutParams;
humanInput?: any;
operationId: string;
/**
@@ -166,13 +144,10 @@ export interface AgentExecutionParams {
/**
* Watchdog re-check for a parked `waiting_for_async_tool` op: re-runs the
* resume barrier + CAS without claiming the step lock or executing a step.
* A no-op when the op already resumed. While the barrier is still unsatisfied
* it re-arms the next check with exponential backoff (see
* `asyncToolVerifyAttempt`) up to a bounded number of attempts, so a transient
* miss is retried rather than permanently stranding the parent. First armed by
* `tryResumeParentFromAsyncTool` when a sub-agent completion found the parent
* not yet resumable (covers the child-finishes-before-parent-parks race and
* transient barrier failures).
* A no-op when the op already resumed or the barrier is still unsatisfied.
* Scheduled one-shot by `tryResumeParentFromAsyncTool` when a sub-agent
* completion found the parent not yet resumable (covers the
* child-finishes-before-parent-parks race and transient barrier failures).
*/
verifyAsyncToolBarrier?: boolean;
}
@@ -205,106 +180,6 @@ export interface SubAgentBridgeParams {
toolMessageId: string;
}
// ==================== Group Orchestration (call agent member) ====================
/** Whether a group member runs in the shared group session or an isolated thread. */
export type GroupActionMemberMode = 'in_group' | 'isolated';
/** Whether the supervisor resumes or finishes once all members complete. */
export type GroupActionOnComplete = 'resume' | 'finish';
/**
* Params for the group-action member completion bridge see
* `AgentRuntimeService.completeGroupActionMember`. Mirrors the sub-agent bridge
* but enforces a K=N member barrier: each member backfills its own anchor, and
* the supervisor's group tool message is only backfilled (which satisfies the
* parked op's barrier) once every member's anchor is fulfilled.
*/
export interface GroupActionMemberBridgeParams {
/**
* The per-member anchor `role: 'tool'` message to backfill. Equals
* `groupToolMessageId` when `expectedMembers === 1` (single-member actions
* collapse the anchor onto the group tool call itself).
*/
anchorMessageId: string;
/** Total members forked under this group tool call — the K=N barrier target. */
expectedMembers: number;
/** Child member op's final state — passed in local mode; loaded otherwise. */
finalState?: AgentState;
/** The supervisor's parked group-management tool message (`tool_call_id` = call id). */
groupToolMessageId: string;
/** in_group → backfill a short note; isolated → backfill the member's final answer. */
mode: GroupActionMemberMode;
/** Resume the supervisor LLM, or finish the orchestration (skipCallSupervisor/delegate). */
onComplete: GroupActionOnComplete;
/** Child (member) operation ID. */
operationId: string;
parentOperationId: string;
reason: string;
/** Isolation thread id (isolated mode only). */
threadId?: string;
}
/**
* Watchdog payload that enforces a group member's timeout. Scheduled after an
* isolated member op is forked; when it fires, if the member op hasn't reached a
* terminal state it is interrupted and a `timeout` completion is bridged so the
* parked supervisor resumes/finishes (satisfying the K=N barrier) instead of
* waiting indefinitely.
*/
export interface GroupMemberTimeoutParams {
anchorMessageId: string;
expectedMembers: number;
groupToolMessageId: string;
/** The forked member operation id whose deadline this enforces. */
memberOperationId: string;
mode: GroupActionMemberMode;
onComplete: GroupActionOnComplete;
parentOperationId: string;
}
/**
* Params handed to the {@link AgentRuntimeDelegate.execGroupMember} callback
* fork one group member (in-group or isolated) under a group-management tool
* call, installing the group-action member completion bridge.
*/
export interface ExecGroupMemberParams {
/** Member agent id. */
agentId: string;
/** Per-member anchor message id the bridge backfills. */
anchorMessageId: string;
/** Disable tools for this member (broadcast — voice opinions only). */
disableTools?: boolean;
/** K=N barrier target stored on the group tool message. */
expectedMembers: number;
/** Group id. */
groupId: string;
/** Supervisor's group-management tool message id (the parked tool call). */
groupToolMessageId: string;
/** Optional supervisor instruction guiding the member's response. */
instruction?: string;
/** in_group (non-isolated group session) or isolated (own thread). */
mode: GroupActionMemberMode;
/** Resume or finish the supervisor once all members complete. */
onComplete: GroupActionOnComplete;
/** Parent (supervisor) operation id. */
parentOperationId: string;
/** Per-member timeout (ms), isolated mode. */
timeout?: number;
/** Group topic id. */
topicId: string;
}
export interface ExecGroupMemberResult {
error?: string;
/** Forked member operation id (when started). */
operationId?: string;
/** Whether the member op was forked. */
started: boolean;
/** Isolation thread id (isolated mode only). */
threadId?: string;
}
export interface OperationCreationParams {
activeDeviceId?: string;
agentConfig?: any;
@@ -346,9 +221,6 @@ export interface OperationCreationParams {
deviceAccessPolicy?: { canUseDevice: boolean; reason: DeviceAccessReason };
/** Device system info for placeholder variable replacement in Local System systemRole */
deviceSystemInfo?: Record<string, string>;
/** Discord context for injecting channel/guild info into agent system message */
discordContext?: any;
evalContext?: any;
/**
* Resolved execution plan for the run (see `resolveExecutionPlan`).
* Forwarded into `state.metadata.executionPlan` so step-level layers (the
@@ -356,6 +228,9 @@ export interface OperationCreationParams {
* device capability from raw config.
*/
executionPlan?: ExecutionPlan;
/** Discord context for injecting channel/guild info into agent system message */
discordContext?: any;
evalContext?: any;
/**
* External lifecycle hooks
* Registered once, auto-adapt to local (in-memory) or production (webhook) mode
@@ -54,17 +54,6 @@ const MEMORY_WRITE_TARGET_BY_API_NAME: Record<string, { idKey: string; layer: La
[MemoryApiName.updateIdentityMemory]: { idKey: 'identityId', layer: LayersEnum.Identity },
};
const TOOL_NAME_SEPARATOR = '____';
const DEFAULT_MEMORY_TARGET_TITLE = 'Memory saved';
const getMemoryWriteApiNameFromToolName = (name: unknown) => {
const toolName = getString(name);
if (!toolName) return;
const slashIndex = toolName.indexOf('/');
const apiName = slashIndex >= 0 ? toolName.slice(slashIndex + 1) : toolName;
return MEMORY_WRITE_API_NAME_SET.has(apiName) ? apiName : undefined;
};
const hasSuccessfulMemoryWrite = (state: AgentState) => {
const byTool = state.usage?.tools?.byTool ?? [];
@@ -83,19 +72,6 @@ const hasFailedMemoryWrite = (state: AgentState) => {
);
};
const getSuccessfulMemoryWriteTargetConfig = (state: AgentState) => {
const byTool = state.usage?.tools?.byTool ?? [];
for (const entry of byTool) {
if (entry.calls <= entry.errors) continue;
const apiName = getMemoryWriteApiNameFromToolName(entry.name);
if (!apiName) continue;
return MEMORY_WRITE_TARGET_BY_API_NAME[apiName];
}
};
const getString = (value: unknown) => {
return pickTrimmedString(value);
};
@@ -281,15 +257,6 @@ export const resolveMemoryActionTargetFromState = (
if (target) return target;
}
}
const targetConfig = getSuccessfulMemoryWriteTargetConfig(state);
if (!targetConfig) return;
return {
memoryLayer: targetConfig.layer,
title: DEFAULT_MEMORY_TARGET_TITLE,
type: 'memory',
};
};
/**
@@ -1,4 +1,3 @@
import { LayersEnum } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import type { AgentSignalOperationMarker } from '@/server/services/agentSignal/operationMarker';
@@ -70,42 +69,6 @@ describe('buildSelfIterationReceipts', () => {
});
});
it('preserves structured memory target metadata for layer-specific navigation', () => {
const [, memory] = buildSelfIterationReceipts({
...baseInput,
mutations: [
{
apiName: 'writeMemory',
data: {
kind: 'mutation',
resourceId: 'pref_1',
status: 'applied',
summary: 'Use concise implementation notes.',
target: {
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
title: 'Prefers concise implementation notes',
type: 'memory',
},
},
kind: 'mutation',
toolCallId: 'call_pref',
},
],
});
expect(memory.title).toBe('Prefers concise implementation notes');
expect(memory.target).toEqual({
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
summary: 'Use concise implementation notes.',
title: 'Prefers concise implementation notes',
type: 'memory',
});
});
it('maps proposal creation to a proposed review receipt without a target', () => {
const [, proposal] = buildSelfIterationReceipts({
...baseInput,
@@ -1,6 +1,5 @@
// @vitest-environment node
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import { LayersEnum } from '@lobechat/types';
import { describe, expect, it, vi } from 'vitest';
import { createCompletionPolicy } from '../../../../policies/completionPolicy';
@@ -150,18 +149,7 @@ describe('S2 completion loop (policy → handler → projection → persist)', (
mutations: [
{
apiName: 'writeMemory',
data: {
resourceId: 'pref_1',
status: 'applied',
summary: 'Saved tone preference',
target: {
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
title: 'Tone preference',
type: 'memory',
},
},
data: { resourceId: 'mem_1', status: 'applied', summary: 'Saved tone preference' },
kind: 'mutation',
},
],
@@ -181,12 +169,7 @@ describe('S2 completion loop (policy → handler → projection → persist)', (
expect(memory.anchorMessageId).toBe('assistant_msg_1');
expect(memory.triggerMessageId).toBe('user_msg_1');
expect(memory.topicId).toBe('topic_1');
expect(memory.target).toMatchObject({
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
type: 'memory',
});
expect(memory.target).toMatchObject({ id: 'mem_1', type: 'memory' });
});
it('no-ops when the completion carries no self-iteration payload (no marker stamped)', async () => {
@@ -1,5 +1,4 @@
import { MemoryApiName, MemoryIdentifier } from '@lobechat/builtin-tool-memory';
import { LayersEnum } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { extractSelfIterationCompletionPayload } from '../extractCompletionPayload';
@@ -58,7 +57,7 @@ describe('extractSelfIterationCompletionPayload', () => {
expect(result?.artifacts).toHaveLength(1);
});
it('synthesizes a writeMemory mutation with a preference target for a memory-kind run', () => {
it('synthesizes a writeMemory mutation for a memory-kind run from finalState usage', () => {
const result = extractSelfIterationCompletionPayload(
buildState(
{
@@ -67,34 +66,6 @@ describe('extractSelfIterationCompletionPayload', () => {
userId: 'user_1',
},
{
messages: [
{
id: 'msg_preference',
role: 'assistant',
tool_calls: [
{
function: {
arguments: JSON.stringify({
summary: 'Prefer direct implementation with focused tests.',
title: 'Prefers direct implementation',
withPreference: {
conclusionDirectives: 'Prefer direct implementation with focused tests.',
},
}),
name: `${MemoryIdentifier}____${MemoryApiName.addPreferenceMemory}`,
},
id: 'call_preference',
type: 'function',
},
],
},
{
content:
'Preference memory "Prefers direct implementation" saved with memoryId: "mem_1" and preferenceId: "pref_1"',
role: 'tool',
tool_call_id: 'call_preference',
},
],
status: 'finished',
usage: {
tools: {
@@ -116,48 +87,6 @@ describe('extractSelfIterationCompletionPayload', () => {
expect(result?.mutations).toHaveLength(1);
expect(result?.mutations[0].apiName).toBe('writeMemory');
expect((result?.mutations[0].data as { status?: string }).status).toBe('applied');
expect((result?.mutations[0].data as { resourceId?: string }).resourceId).toBe('pref_1');
expect((result?.mutations[0].data as { target?: Record<string, unknown> }).target).toEqual({
id: 'pref_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
summary: 'Prefer direct implementation with focused tests.',
title: 'Prefers direct implementation',
type: 'memory',
});
});
it('falls back to the successful memory tool api when finalState lacks tool call details', () => {
const result = extractSelfIterationCompletionPayload(
buildState(
{
agentId: 'agent_user_1',
agentSignal: { kind: 'memory', sourceId: 'mem-src_fallback' },
userId: 'user_1',
},
{
status: 'finished',
usage: {
tools: {
byTool: [
{
calls: 1,
errors: 0,
name: `${MemoryIdentifier}/${MemoryApiName.addPreferenceMemory}`,
},
],
},
},
},
),
);
expect(result?.mutations).toHaveLength(1);
expect((result?.mutations[0].data as { target?: Record<string, unknown> }).target).toEqual({
memoryLayer: LayersEnum.Preference,
title: 'Memory saved',
type: 'memory',
});
});
it('yields no memory mutation when the memory run did not apply a write', () => {
@@ -1,5 +1,3 @@
import { LayersEnum } from '@lobechat/types';
import type { AgentSignalOperationMarker } from '@/server/services/agentSignal/operationMarker';
import type { AgentSignalReceipt } from '../../receiptService';
@@ -55,9 +53,6 @@ const str = (value: unknown): string | undefined =>
const isSkippedStatus = (status: unknown): boolean =>
typeof status === 'string' && status.startsWith('skipped');
const isMemoryLayer = (value: unknown): value is LayersEnum =>
Object.values(LayersEnum).includes(value as LayersEnum);
export interface BuildSelfIterationReceiptsInput {
agentId: string;
/** Non-actionable idea / intent recorder outputs (kind: artifact). */
@@ -156,15 +151,9 @@ export const buildSelfIterationReceipts = (
? 'skipped'
: (SUCCESS_STATUS_BY_API[apiName] ?? 'applied');
const target = isRecord(data.target) ? data.target : undefined;
const targetId = str(target?.id) ?? str(data.resourceId);
const memoryId = kind === 'memory' ? str(target?.memoryId) : undefined;
const memoryLayer =
kind === 'memory' && isMemoryLayer(target?.memoryLayer) ? target.memoryLayer : undefined;
const summaryText = str(data.summary);
const targetTitle = str(target?.title);
const title =
targetTitle ?? summaryText ?? DEFAULT_TITLE_BY_API[apiName] ?? 'Agent Signal action';
const resourceId = str(data.resourceId);
const title = summaryText ?? DEFAULT_TITLE_BY_API[apiName] ?? 'Agent Signal action';
return [
{
@@ -183,9 +172,7 @@ export const buildSelfIterationReceipts = (
? {}
: {
target: {
...(targetId ? { id: targetId } : {}),
...(memoryId ? { memoryId } : {}),
...(memoryLayer ? { memoryLayer } : {}),
...(resourceId ? { id: resourceId } : {}),
...(summaryText ? { summary: summaryText } : {}),
title,
type: kind,
@@ -49,10 +49,9 @@ const extractMemoryMutations = (finalState: AgentState): ToolResultWithKind[] =>
apiName: 'writeMemory',
data: {
kind: 'mutation',
...(result.target ? { target: result.target } : {}),
resourceId: result.target?.id ?? result.target?.memoryId,
status: 'applied',
summary: result.detail ?? result.target?.summary,
summary: result.detail,
},
kind: 'mutation',
},
@@ -141,9 +141,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
})),
}));
@@ -97,9 +97,9 @@ vi.mock('@/server/services/market', () => ({
})),
}));
vi.mock('@/server/services/composio', () => ({
ComposioService: vi.fn().mockImplementation(() => ({
getComposioManifests: vi.fn().mockResolvedValue([]),
vi.mock('@/server/services/klavis', () => ({
KlavisService: vi.fn().mockImplementation(() => ({
getKlavisManifests: vi.fn().mockResolvedValue([]),
})),
}));

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