Compare commits

..

1 Commits

Author SHA1 Message Date
Arvin Xu 01ac0c438a 🐛 fix: stop remote Codex processes 2026-06-13 17:14:03 +08:00
2068 changed files with 31781 additions and 163143 deletions
+38 -181
View File
@@ -16,26 +16,12 @@ description: >
One skill for all agentic end-to-end testing — local-first today, designed to
also run as full cloud automation. Every test session follows the same
contract:
four-step contract:
```
Step -1: Plan approval → Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report → Step 4: Publish to LobeHub
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`
@@ -111,7 +67,7 @@ First check the repo root for `.env`:
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
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`
@@ -132,19 +88,19 @@ fi
Bootstrap flow when no `.env` exists:
```bash
# From repo root. Managed Postgres/Redis flow requires Docker Desktop.
# From repo root. Managed DB 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`:
`DATABASE_URL` and 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
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
For backend-only checks, `dev-next` is available, but Web smoke needs the
@@ -157,12 +113,10 @@ full-stack `dev` command so Next can proxy the SPA HTML from Vite:
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
./.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 clean-db # remove managed DB container
```
Default script env:
@@ -170,23 +124,15 @@ 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`
onboarding already completed for manual or agent-browser checks. 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
@@ -196,36 +142,27 @@ 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
### 0.4 Auth is green
**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.
**Auth is the gate for all automated testing.**
```bash
./.agents/skills/agent-testing/scripts/setup-auth.sh status --surface web
./.agents/skills/agent-testing/scripts/setup-auth.sh status
```
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
@@ -300,7 +237,6 @@ 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` |
@@ -326,112 +262,33 @@ 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/result.json (scenario, context, cases[], summary.conclusion) — the report;
# $DIR/report.md holds only the narrative tail (跟进 / 本轮验证 / 评分)
# fill $DIR/report.md (scope, case table with inline evidence, verdict, score) and $DIR/result.json
```
Reports live in `.records/reports/<timestamp>-<slug>/` (gitignored): `result.json`
(the structured report — scenario/context/cases/summary), `report.md` (narrative
tail), `assets/` (evidence). Format spec and evidence rules:
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:
[references/report.md](./references/report.md).
Two hard rules worth front-loading:
- **Report language = the user's conversation language.** Write `report.md` and
every human-facing string in `result.json` (case `name`/`observation`,
`summary.conclusion`, scope `focus`/`entry`) in the language the user is
conversing in. `result.json` keys/status values stay English.
- **`result.json` is the report; the verify page renders it.** Each tested
behavior is one entry in `cases[]` (`{ name, result, observation, evidence }`);
the published `/verify/<id>` page builds the scope header from
`scenario`+`context`, the check list from `cases[]`, and the headline verdict
from `summary.conclusion`. So do NOT hand-build a 用例 table or a 范围 block in
`report.md` — they double up on the page. `report.md` is the narrative tail
only (跟进 / 本轮验证 / 评分).
- **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 —
a static screenshot cannot prove the behavior.
## Step 4 — Publish to LobeHub (mandatory)
The local report under `.records/reports/` is the working artifact; the
**deliverable is the report opened in LobeHub**. Do not stop at local files —
push the session up with the CLI so the user (and later reviewers) can open it
at a stable URL with the evidence rendered inline.
**Publish targets PRODUCTION (`https://app.lobehub.com`), not the local dev
server.** The product-under-test usually runs against a local env whose seeded
CLI profile (`.records/env/agent-testing-cli.env`) points the CLI at
`http://localhost:3010` via `LOBEHUB_SERVER` / `LOBE_API_KEY` /
`LOBEHUB_CLI_HOME=.lobehub-dev`. Those overrides are for _running_ the backend
test — they are wrong for _publishing_: a localhost run yields a URL nobody else
can open, and a local env's stub S3 makes file-evidence uploads fail
(`fetch failed`). The deliverable must live on production, with the user's real
login (`~/.lobehub`) and real storage.
So run the publish in a CLEAN environment that strips the local dev overrides,
which falls back to the CLI defaults (`https://app.lobehub.com` + `~/.lobehub`):
```bash
# Publish to PRODUCTION — strip the local dev CLI overrides so `lh` uses its
# production defaults (app.lobehub.com + the user's real ~/.lobehub login).
env -u LOBEHUB_SERVER -u LOBE_API_KEY -u LOBEHUB_CLI_API_KEY -u LOBEHUB_CLI_HOME \
lh verify ingest-report "$DIR" --source agent-testing --open --json
```
Production auth is the user's own device-code login, not the seeded local key.
Verify it first in the same clean env; if it returns "No authentication found",
have the user log in (the flow prints a URL + code to authorize in the browser),
then re-run the publish:
```bash
env -u LOBEHUB_SERVER -u LOBE_API_KEY -u LOBEHUB_CLI_API_KEY -u LOBEHUB_CLI_HOME lh verify run list --json # [] = authed
env -u LOBEHUB_SERVER -u LOBE_API_KEY -u LOBEHUB_CLI_API_KEY -u LOBEHUB_CLI_HOME lh login # only if not authed
```
`verify ingest-report` reads `$DIR` and, in one call, creates a standalone
verification session and uploads everything:
- `result.json.cases[]` → one check result each (verdict + key observation)
- each case's `evidence` file(s) → uploaded to storage and attached to that result
- `report.md` → the session's full report body, plus the `summary` stats
It prints the `verifyRunId` and, with `--open`, the in-app path
`/verify/<verifyRunId>` — the report viewer (verdict, stats, every check, and the
inline screenshot/text evidence). On production that resolves to
`https://app.lobehub.com/verify/<verifyRunId>`. **Include that full production
link in the final chat reply** alongside the local report dir.
Notes:
- `result.json` cases use `{ id?, name, result, observation?, evidence? }`;
`evidence` is a path (or array of paths) relative to `$DIR`. `result`/`verdict`
map onto `passed | failed | uncertain` (pass/ok→passed, fail/error→failed,
else→uncertain).
- Need finer control? The same data is reachable through the atomic commands —
`verify run create`, `verify result ingest`, `verify evidence upload`
(`--file` or `--content`), `verify report upsert` — so a session can be built
incrementally instead of from a report dir.
- File evidence uploads through the app's storage (S3/R2). Against a stub or
unreachable bucket (common in local dev) the file PUT fails; `ingest-report`
logs a warning, **skips that one artifact**, and still finishes the session,
results, and report. So the published session is real and openable — but it is
**missing the skipped evidence**, which is easy to mistake for a complete
report. If the evidence must appear, publish against an env with real storage
(e.g. production) or attach it inline with `verify evidence upload --content`.
## Directory map
```
+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) |
+48 -96
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,24 +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. If repo-root `.env` exists and `web-seed` fails, do **not** seed or modify the current DB; treat it as an existing local environment and use Cookie injection.
4. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
5. 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`).
6. `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:
`ENABLE_MOCK_DEV_USER` is not Web auth. It only affects server-side API context
and does not satisfy Better Auth or stop the SPA from redirecting to `/signin`.
Do not use it as a substitute for `status --surface web` or Cookie injection.
```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
@@ -138,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,44 +3,22 @@
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 on `9876` |
| `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
@@ -48,24 +26,20 @@ curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
```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
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
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
# 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
@@ -87,15 +61,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,20 +11,11 @@ output):
```
.records/reports/<YYYYMMDD-HHMMSS>-<slug>/
├── report.md # narrative TAIL only (跟进 / 本轮验证 / 评分) — rendered as the page's "Details"
├── result.json # the structured source: scenario + context + cases + summary.conclusion
├── report.md # human-readable report (case table with inline screenshots, verdict)
├── result.json # machine-readable results (pass/fail counts, score)
└── assets/ # evidence: screenshots, HAR files, CLI transcripts
```
**`result.json` is the report — `report.md` is just its tail.** The published
verify page (`/verify/<id>`) renders itself from `result.json`: the scope header
from `scenario` + `context` (branch / commit / surfaces / entry / focus), the
per-check table from `cases[]`, the overall conclusion from `summary.conclusion`
(shown at the top under the scope block), and the stats from `summary`. So
`report.md` must NOT repeat the scope block or a 用例 table — those double up on
the page. It carries only the non-duplicate narrative (仍需跟进 / 本轮验证 /
评分), rendered as the page's collapsible "Details".
## Workflow
1. **Scaffold up front** — before running the first test step:
@@ -65,37 +56,15 @@ the page. It carries only the non-duplicate narrative (仍需跟进 / 本轮验
- Network: `agent-browser network requests` dumps or HAR files.
3. **Fill `result.json` as you go** — it is the report. Each tested behavior is
one entry in `cases[]` (`{ id, name, result, observation, evidence }`), where
`evidence` is a path under `assets/` (screenshot / GIF / transcript). Set the
scope fields (`scenario: "coding"`, `branch`, `commit`, `surfaces`, `entry`,
`focus`) and write the one-paragraph verdict into `summary.conclusion`. The
page pairs each check with its evidence inline, so you don't hand-build a
table. `report.md` holds only the narrative tail (跟进 / 本轮验证 / 评分).
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)`.
5. **Publish to LobeHub** (Step 4 of the skill) — upload the finished session so
it's viewable in-app, not just on disk. **Publish to PRODUCTION
(`app.lobehub.com`) with the user's real login, NOT the local dev CLI** —
strip the local dev overrides so `lh` uses its production defaults:
```bash
env -u LOBEHUB_SERVER -u LOBE_API_KEY -u LOBEHUB_CLI_API_KEY -u LOBEHUB_CLI_HOME \
lh verify ingest-report "$DIR" --source agent-testing --open --json
```
This creates a standalone verification session and uploads the cases (as check
results), each case's `evidence` files, and `report.md` (as the report body),
then prints `/verify/<verifyRunId>` (→ `https://app.lobehub.com/verify/<id>`).
Include that full production link in the final reply alongside the local
report dir. See SKILL.md → Step 4 for why production (a localhost URL isn't
shareable and a local stub S3 fails file-evidence uploads), the production
login check, and the atomic commands (`verify run|result|evidence|report …`).
report directory in your final answer to the user.
## Report language (hard rule)
@@ -179,7 +148,6 @@ missing; a blocked case is not a pass).
"name": "task tree returns nested children",
"surface": "cli",
"status": "pass",
"observation": "root returned 3 nested children, depth 2",
"evidence": ["assets/task-tree.txt"]
}
],
@@ -202,12 +170,6 @@ missing; a blocked case is not a pass).
polish, copy quality); omit it for purely binary runs. `verdict` is the single
word the user reads first: `pass`, `fail`, or `partial`.
When published (Step 4), `verify ingest-report` maps each case onto a check
result: `name`→title, `status`/`result`→verdict, `observation` (or
`keyObservation`)→the result's key observation, and `evidence` paths→uploaded
artifacts. `summary.{total,passed,failed,blocked}` and `verdict` become the
report's stats + overall verdict; `report.md` becomes the report body.
## Rules
- **No evidence, no claim** — every `pass`/`fail` in the case table must link
@@ -12,16 +12,15 @@
# 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 setup-db # start local Postgres 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 seed-user # seed the baseline test user
# 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
# init-dev-env.sh clean-db # remove the managed Postgres container
#
# 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
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres
set -euo pipefail
@@ -32,16 +31,7 @@ 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"; }
@@ -57,7 +47,6 @@ guard_no_root_env() {
}
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}"
@@ -68,12 +57,6 @@ apply_env() {
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}"
@@ -83,7 +66,6 @@ apply_env() {
env_keys() {
printf '%s\n' \
APP_URL \
AGENT_RUNTIME_MODE \
AUTH_EMAIL_VERIFICATION \
AUTH_SECRET \
DATABASE_DRIVER \
@@ -93,12 +75,6 @@ env_keys() {
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 \
@@ -144,15 +120,6 @@ wait_for_db() {
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
@@ -173,25 +140,6 @@ start_db() {
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"
@@ -200,14 +148,9 @@ migrate_db() {
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;
@@ -223,72 +166,13 @@ const TEST_USER = {
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)
@@ -320,35 +204,9 @@ const client = new pg.Client({ connectionString: databaseUrl });
],
);
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) => {
@@ -362,11 +220,8 @@ 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
@@ -374,24 +229,11 @@ cmd_status() {
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"
@@ -415,15 +257,6 @@ cmd_clean_db() {
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() {
@@ -442,12 +275,10 @@ case "$COMMAND" in
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 ;;
@@ -23,41 +23,65 @@ COMMIT=$(git -C "$REPO_ROOT" rev-parse --short HEAD 2> /dev/null || echo "unknow
DATE_HUMAN=$(date '+%Y-%m-%d %H:%M')
DATE_ISO=$(date '+%Y-%m-%dT%H:%M:%S%z')
# report.md is rendered as the verify page's "Details" tail — free-form COMMENT.
# The scope (范围), per-case table (用例), overall conclusion, and the score are
# all STRUCTURED on the page now (result.json scenario/context + cases +
# summary.conclusion + summary.score), so DON'T repeat any of them here or they
# double up. Keep only non-duplicate detail: repro commands, caveats, follow-ups.
cat > "$DIR/report.md" << EOF
<!-- Rendered as the verify page's "Details" tail. The page already shows the
title / scope / checks / conclusion / score, so DON'T add an H1 title or
repeat them here — write only the non-duplicate comment below. -->
# 测试报告:$TITLE
## 备注 / 说明
## 范围
<!-- 复现命令、注意事项、仍需跟进项;没有则写“无”。 -->
<!-- 测试目标 / 变更范围 / 重点风险 -->
- 分支:\`$BRANCH\`
- 当前提交:\`$COMMIT\`
- 日期:$DATE_HUMAN
- 表面:<!-- CLI / Electron + CDP / Web / Bot:<platform> -->
- 测试页 / 入口:<!-- e.g. /settings or http://localhost:3010 -->
- 重点:<!-- 本轮最关心的体验、功能或回归点 -->
## 用例
| # | 用例 | 结果 | 关键现象 | 证据 |
| - | ---- | ---- | -------- | ---- |
| 1 | | 待测 | | ![用例 1](assets/case1.png) |
## 结论
整体结论:\`pending\`。
<!-- 用 1-2 段概括用户最需要知道的结果;失败和阻塞必须明确说明影响。 -->
仍需处理 / 跟进:
- <!-- TODO -->
## 本轮验证
<!-- 如有自动化或命令行验证,保留精简命令与结果;没有则写“未运行额外自动化验证”。 -->
\`\`\`bash
# command
\`\`\`
结果:
- <!-- TODO -->
## 评分
- 通过:0
- 失败:0
- 阻塞:0
- 评分:— / 100
EOF
# result.json drives the structured page. \`summary.conclusion\` is the overall
# conclusion shown at the top (under the scope block); \`summary.score\` (0-100)
# becomes the \`score\` stat; \`scenario\`/\`context\` fields
# (branch/commit/surfaces/entry/focus) render the scope header.
cat > "$DIR/result.json" << EOF
{
"title": "$TITLE",
"scenario": "coding",
"createdAt": "$DATE_ISO",
"branch": "$BRANCH",
"commit": "$COMMIT",
"surfaces": [],
"entry": "",
"focus": "",
"cases": [],
"summary": { "total": 0, "passed": 0, "failed": 0, "blocked": 0, "verdict": "pending", "conclusion": "", "score": null }
"summary": { "total": 0, "passed": 0, "failed": 0, "blocked": 0, "verdict": "pending" }
}
EOF
@@ -5,115 +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"
ROOT_ENV_FILE="$REPO_ROOT/.env"
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)
@@ -127,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
@@ -164,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() {
@@ -205,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
}
@@ -285,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("=")
@@ -451,85 +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')"
if [[ -f "$ROOT_ENV_FILE" ]]; then
note "root .env exists; do not seed or modify this DB for Web auth."
note "Use Chrome Cookie injection instead: $0 open-chrome, then pbpaste | $0 web"
else
note "make sure the seed user exists:"
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
fi
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" wait --load networkidle > /dev/null 2>&1 || true
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"
@@ -539,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
+1 -5
View File
@@ -38,7 +38,7 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
## Default Debug Order
1. Prove whether the raw CLI output is correct before touching UI code. The app records every real session — read the most recent one via `cat .heerogeneous-tracing/.last-live-trace` rather than hand-rolling a `claude -p` repro (see references/debug-workflow\.md §2).
1. Prove whether the raw CLI output is correct before touching UI code.
2. If raw output is correct, compare it with adapter output. In dev, `executeHeterogeneousAgent` exposes `window.__HETERO_AGENT_TRACE`.
3. If adapted events look correct, inspect `persistToolBatch`, `persistToolResult`, step transitions, and subagent routing.
4. Turn the repro into a focused test before fixing.
@@ -77,10 +77,6 @@ Use this skill when the bug or feature lives in the external CLI agent pipeline,
look for `tool_result for unknown toolCallId` and missing `result_msg_id` backfill.
- Subagent tools show up in the main bubble:
check for subagent chunks reaching the main gateway handler.
- Wrong terminal-error guide (e.g. "usage limit reached" shown for a network drop):
a classifier is branching on a structured field whose mere presence isn't its meaning.
Grep the field across all event states in a real trace before trusting it — see
references/debug-workflow\.md §8 (CC `rate_limit_info` rides on `status: "allowed"` too).
## References
@@ -3,13 +3,12 @@
## Contents
1. Pipeline map
2. Capture raw CLI traces first (incl. in-app live traces)
2. Capture raw CLI traces first
3. Compare raw and adapted events
4. Check step boundaries before persistence
5. Check tool persistence invariants
6. Focused tests
7. Repro-to-fix workflow
8. Verify a structured-field classifier against a real trace
## 1. Pipeline Map
@@ -28,54 +27,6 @@ Start at the leftmost broken layer. Do not jump straight to UI rendering unless
## 2. Capture Raw CLI Traces First
### In-app live traces (the faithful capture — prefer this)
The running app already records every CLI session it spawns. This is the most
faithful trace you can get, because it captures the **exact** spawn args, env
keys, cwd, `--resume`/`--mcp-config` flags, model, and stdin that the app used —
things a hand-rolled `claude -p` / `codex exec` repro will not reproduce. Reach
for this before reproducing manually. The recorder lives in
`apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts`
(`createCliTraceSession`, `shouldTraceCliOutput`, `resolveTraceRootDir`).
When it records:
- **Dev build** (`!app.isPackaged`): always.
- **Packaged build**: only when the user flips the Help-menu developer toggle
(`heteroTracingEnabled`). Off by default so normal runs aren't polluted.
- Never under `NODE_ENV=test`.
Where it writes:
- Toggle **off** (plain dev run): `<cwd>/.heerogeneous-tracing/` — i.e. inside
the repo you're running against. (Yes, the dir name is misspelled
`heerogeneous`; it is the real path.)
- Toggle **on**: `<appStoragePath>/heteroAgent/tracing/` — keeps traces out of
the user's project. This is the only path packaged builds ever use.
Layout per session — `.../<agentType>/<YYYYMMDD-HHMMSS>-<sessionId>/`:
- `meta.json` — spawn `args`, `command`, `cwd`, `envKeys`, `model`,
`resumeSessionId`/`agentSessionId`, attachment summaries. **Read this first**
to know exactly how the CLI was invoked.
- `stdin.txt` — the stream-json request fed to the CLI.
- `stdout.jsonl` — the raw provider NDJSON (the trace you actually read).
- `stderr.log` — CLI stderr.
- `exit.json``{ code, signal, finishedAt }`.
`.heerogeneous-tracing/.last-live-trace` always points at the most recent
session dir, so the fast path to "what just happened" is:
```bash
dir=$(cat .heerogeneous-tracing/.last-live-trace)
cat "$dir/meta.json" # how the CLI was spawned
wc -l "$dir/stdout.jsonl" # raw event count
```
Reproduce the same session yourself by reusing the recorded `meta.json` `args`
together with `stdin.txt` (the args already include `--resume <sessionId>`),
instead of guessing flags.
### Codex raw JSONL
Use a read-only prompt and save traces under the repo-local scratch directory `.heerogeneous-tracing/`.
@@ -293,55 +244,3 @@ When the bug comes from a real trace, distill it into the closest existing test
6. Only then do an Electron smoke test with the `agent-testing` skill if UI confirmation is still needed.
Do not start with a broad Electron repro if a raw trace or adapter test can prove the fault zone faster.
## 8. Verify A Structured-Field Classifier Against A Real Trace
Whenever the adapter **branches on a structured field** from the raw stream —
`status`, `usage`, `rateLimitType`, `stop_reason`, `parent_tool_use_id`,
`subtype`, etc. — do not trust your mental model of the wire format. The field
you key on almost always also appears on **benign / non-target** events, and a
classifier that ignores the surrounding state will misfire on those.
The procedure (recurring — run it every time):
1. Pull the most recent real session: `dir=$(cat .heerogeneous-tracing/.last-live-trace)`.
2. Grep the field across **every** event state, not just the failing one, and
count by co-occurring state. Example:
```bash
# Which event statuses carry a rate_limit_info block?
grep -o '"status":"[a-z]*"' "$dir/stdout.jsonl" | sort | uniq -c
grep -c 'rate_limit_info' "$dir/stdout.jsonl"
```
3. If the field rides on states you did not account for, the classifier needs an
extra gate. Add the trace as a fixture/assertion to the adapter test so the
regression can't come back.
### Worked example: CC usage-limit vs. transient throttle (`fix/cc-rate-limit-quota-misclassify`)
- **Symptom:** an unrelated terminal failure (e.g. an `ECONNRESET` network drop)
rendered a bogus "usage limit reached, resets at X" guide.
- **What the trace showed:** Anthropic stamps a `rate_limit_info` block —
carrying `resetsAt` and `rateLimitType` (e.g. `seven_day`) — onto events even
when the request **goes through** (`status: "allowed"`). In real traces those
reset-window fields appear on \~all `rate_limit_info` blocks, the vast majority
of which are `allowed`, not `rejected`. So the window is rolling-window
_metadata for an allowed call_, NOT evidence the limit was hit.
- **The bug:** `isUserQuotaRateLimit` keyed only on the presence of a reset
window (`info.resetsAt != null || info.rateLimitType != null`). A later
terminal error inherited the last allowed event's window → false positive.
- **The fix:** require `status === 'rejected'` **and** a concrete reset window.
A bare `rejected` with no window is the transient server throttle → leave it
to the overloaded (retry) classifier. Status codes (429 / 529) and message
text are deliberately not consulted — only this structured signal decides the
guide.
- `packages/heterogeneous-agents/src/adapters/claudeCode.ts` →
`isUserQuotaRateLimit`
- regression assertions in
`packages/heterogeneous-agents/src/adapters/claudeCode.test.ts`
The general lesson: a field's **presence** is not its **meaning**. Confirm which
event states a discriminator field co-occurs with in a real recorded trace
before branching on it.
-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 |
+2 -2
View File
@@ -18,8 +18,8 @@ Periodic review of the project-local skill set under `.agents/skills/`. The goal
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
```bash
find -L .agents/skills -name SKILL.md | wc -l # total count, including symlinked skills
find -L .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length, including symlinked skills
find .agents/skills -name SKILL.md | wc -l # total count
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
```
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
-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
-318
View File
@@ -1,318 +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)_
- [ ] **Always-rendered chrome still needs a body empty state.** When a surface
keeps its toolbar / header mounted even with no data (so a create / `+`
affordance stays reachable), the **body** below it must still render an empty
placeholder — persistent chrome is not an excuse to leave the content area
blank. ✅ The agent **Documents** tab keeps its new-folder / new-doc toolbar
and renders an `Empty` below it when there are no documents — ❌ not a toolbar
over dead space. _(Meaningful)_
- [ ] **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)_
### 1.5 Default view reflects entry intent & data state・Certainty・Meaningful
A surface with multiple tabs / views / panels has a **landing** selection. Don't
hardcode it to "the first tab" — derive it from **(a) how the user got here** (the
intent their navigation carried) and **(b) which views actually have data**. A
static default that lands the user on an empty tab while a sibling holds exactly
what they came for reads as broken. This pairs with §1.1: the empty state is the
fallback _within_ a view; this rule is about not landing on that empty view in the
first place when a better one exists.
- [ ] **Open on the tab the entry implies.** When navigation carries intent — the
user clicked a Skill, a file, a record of a specific type — land on the view
that shows it, not the static first tab. ✅ Opening a document page by clicking
a **skill** lands the right panel on the **Skills** tab; opening a plain
document lands on **Documents**. _(Meaningful)_
- [ ] **Fall back to a populated view when the default would be empty.** If the
default tab has no data but a sibling does, default to the populated one so
the surface opens on content. ✅ An agent with only skills (no documents)
opens the panel on **Skills** instead of an empty **Documents** tab. _(Certainty)_
- [ ] **Decide from resolved state, not mid-load.** Compute the default once the
data has loaded — choosing off an empty _in-flight_ list flips the tab as data
arrives. Hold the static default while loading, switch on resolved-empty. _(Certainty)_
- [ ] **A manual choice wins and sticks.** Once the user picks a tab, stop
auto-selecting — track "user-picked" separately (e.g. a nullable `pickedTab`
that overrides the derived default) so later data changes don't yank them off
their choice. _(Natural)_
---
## 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. Always-rendered chrome (toolbar/header) still gets a body empty state.
- [ ] 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.
- [ ] Multi-tab/view surface lands on the tab the entry intent implies (and falls back to a populated view, decided from resolved state); a manual pick sticks.
**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 #############
# #######################################
+1 -1
View File
@@ -6,7 +6,7 @@ const prComment = async ({ github, context, releaseUrl, artifactsUrl, version, t
const COMMENT_IDENTIFIER = '<!-- DESKTOP-BUILD-COMMENT -->';
/**
* Generate comment body content
* 生成评论内容
*/
const generateCommentBody = async () => {
try {
+1 -1
View File
@@ -34,7 +34,7 @@ module.exports = defineConfig({
markdown: {
reference:
'You need to maintain the component format of the mdx file; the output text does not need to be wrapped in any code block syntax on the outermost layer.\n' +
fs.readFileSync(path.join(__dirname, 'docs/glossary.mdx'), 'utf8'),
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf8'),
entry: ['./README.md', './docs/**/*.md', './docs/**/*.mdx'],
entryLocale: 'en-US',
outputLocales: ['zh-CN'],
-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.
-25
View File
@@ -2,31 +2,6 @@
# Changelog
## [Version 2.2.6](https://github.com/lobehub/lobe-chat/compare/v2.2.6-canary.8...v2.2.6)
<sup>Released on **2026-06-17**</sup>
#### ✨ Features
- **agent**: improve connector, document, and fleet workflows.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### What's improved
- **agent**: improve connector, document, and fleet workflows, closes [#15936](https://github.com/lobehub/lobe-chat/issues/15936) ([3f82033](https://github.com/lobehub/lobe-chat/commit/3f82033))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.2.1](https://github.com/lobehub/lobe-chat/compare/v0.0.0-nightly.pr15228.13999...v2.2.1)
<sup>Released on **2026-05-29**</sup>
-37
View File
@@ -1,7 +1,4 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
@@ -80,40 +77,6 @@ describe('lh file - E2E', () => {
});
});
// ── upload (local file) ───────────────────────────────
describe('upload', () => {
it('should upload a local file passed as a positional argument', () => {
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-${Date.now()}.txt`);
fs.writeFileSync(tmpFile, 'hello from lh e2e upload');
try {
const result = runJson<{ id: string }>(`file upload ${tmpFile} --json id`);
expect(result).toHaveProperty('id');
if (result.id) run(`file delete ${result.id} --yes`);
} finally {
fs.rmSync(tmpFile, { force: true });
}
});
it('should upload a local file passed via --file', () => {
const tmpFile = path.join(os.tmpdir(), `lh-e2e-upload-f-${Date.now()}.txt`);
fs.writeFileSync(tmpFile, 'hello from lh e2e --file upload');
try {
const result = runJson<{ id: string }>(`file upload --file ${tmpFile} --json id`);
expect(result).toHaveProperty('id');
if (result.id) run(`file delete ${result.id} --yes`);
} finally {
fs.rmSync(tmpFile, { force: true });
}
});
it('should error when the local file does not exist', () => {
expect(() => run('file upload -f /no/such/lh-file.txt')).toThrow();
});
});
// ── recent ────────────────────────────────────────────
describe('recent', () => {
+1 -7
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.35" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.29" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -41,9 +41,6 @@ Show a manual page for the CLI or a subcommand
.B connect
Connect to the device gateway and listen for tool calls
.TP
.B disconnect
Disconnect from the device gateway (alias for `connect stop`)
.TP
.B device
Manage connected devices
.TP
@@ -130,9 +127,6 @@ Manage evaluation workflows
.TP
.B migrate
Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)
.TP
.B update
Update the LobeHub CLI to the latest published version
.SH OPTIONS
.TP
.B \-V, \-\-version
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.35",
"version": "0.0.29",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -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:*",
@@ -37,7 +36,6 @@
"@lobechat/tool-runtime": "workspace:*",
"@trpc/client": "^11.8.1",
"@types/node": "^24.13.2",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"commander": "^13.1.0",
"dayjs": "^1.11.19",
@@ -46,7 +44,6 @@
"fast-glob": "^3.3.3",
"ignore": "^7.0.5",
"picocolors": "^1.1.1",
"semver": "^7.7.3",
"superjson": "^2.2.6",
"tsdown": "^0.21.4",
"typescript": "^6.0.3",
-3
View File
@@ -1,6 +1,5 @@
packages:
- '../../packages/agent-gateway-client'
- '../../packages/device-control'
- '../../packages/device-gateway-client'
- '../../packages/device-identity'
- '../../packages/heterogeneous-agents'
@@ -13,5 +12,3 @@ packages:
- '../../packages/business/const'
- '../../packages/file-loaders'
- '.'
allowBuilds:
esbuild: true
+13 -39
View File
@@ -12,8 +12,7 @@ import { log } from '../utils/logger';
export type TrpcClient = ReturnType<typeof createTRPCClient<LambdaRouter>>;
export type ToolsTrpcClient = ReturnType<typeof createTRPCClient<ToolsRouter>>;
const PERSONAL_KEY = '__personal__';
const _clients = new Map<string, TrpcClient>();
let _client: TrpcClient | undefined;
let _toolsClient: ToolsTrpcClient | undefined;
async function getAuthAndServer() {
@@ -54,40 +53,21 @@ async function getAuthAndServer() {
};
}
/**
* Resolve the workspace scope for outbound tRPC calls.
*
* Precedence: explicit caller arg → `LOBEHUB_WORKSPACE_ID` env (inherited
* from a workspace-dispatched parent process, e.g. openclaw spawned by the
* device's `runHeteroTask`) → personal mode. Without this, agentNotify
* callbacks on workspace topics would resolve through personal-mode
* TopicModel and 404.
*/
function resolveWorkspaceId(explicit?: string): string | undefined {
if (explicit) return explicit;
const fromEnv = process.env.LOBEHUB_WORKSPACE_ID;
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
}
export async function getTrpcClient(workspaceId?: string): Promise<TrpcClient> {
const wsId = resolveWorkspaceId(workspaceId);
const cacheKey = wsId ?? PERSONAL_KEY;
const cached = _clients.get(cacheKey);
if (cached) return cached;
export async function getTrpcClient(): Promise<TrpcClient> {
if (_client) return _client;
const { headers, serverUrl } = await getAuthAndServer();
const client = createTRPCClient<LambdaRouter>({
_client = createTRPCClient<LambdaRouter>({
links: [
httpLink({
headers: wsId ? { ...headers, 'X-Workspace-Id': wsId } : headers,
headers,
transformer: superjson,
url: `${serverUrl}/trpc/lambda`,
}),
],
});
_clients.set(cacheKey, client);
return client;
return _client;
}
/**
@@ -97,19 +77,13 @@ export async function getTrpcClient(workspaceId?: string): Promise<TrpcClient> {
* via env/stored creds and `process.exit(1)` when none exist, which would
* abort an otherwise-valid explicit-token session.
*/
export function createLambdaClient(
auth: {
serverUrl: string;
token: string;
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
},
/** When set, scopes the request to a workspace (e.g. workspace-device enrollment). */
workspaceId?: string,
): TrpcClient {
const headers: Record<string, string> = {
...(auth.tokenType === 'apiKey' ? { 'X-API-Key': auth.token } : { 'Oidc-Auth': auth.token }),
...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}),
};
export function createLambdaClient(auth: {
serverUrl: string;
token: string;
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
}): TrpcClient {
const headers =
auth.tokenType === 'apiKey' ? { 'X-API-Key': auth.token } : { 'Oidc-Auth': auth.token };
return createTRPCClient<LambdaRouter>({
links: [httpLink({ headers, transformer: superjson, url: `${auth.serverUrl}/trpc/lambda` })],
-19
View File
@@ -440,25 +440,6 @@ describe('connect command', () => {
});
});
describe('disconnect (alias for connect stop)', () => {
it('should stop running daemon', async () => {
mockRunningPid = 12345;
const program = createProgram();
await program.parseAsync(['node', 'test', 'disconnect']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Daemon stopped'));
});
it('should warn if no daemon is running', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'disconnect']);
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No daemon'));
});
});
describe('connect status', () => {
it('should show no daemon running', async () => {
const program = createProgram();
+58 -148
View File
@@ -2,22 +2,16 @@ 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';
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { getValidToken } from '../auth/refresh';
import { resolveToken } from '../auth/resolveToken';
import { CLI_API_KEY_ENV } from '../constants/auth';
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
@@ -33,13 +27,7 @@ import {
writeStatus,
} from '../daemon/manager';
import { spawnHeteroAgentRun } from '../device/agentRun';
import {
mintWorkspaceConnectToken,
registerDevice,
registerWorkspaceDevice,
resolveDeviceIdentity,
resolveWorkspaceDeviceIdentity,
} from '../device/register';
import { registerDevice, resolveDeviceIdentity } from '../device/register';
import { loadOrCreateConnectionId, loadSettings, normalizeUrl, saveSettings } from '../settings';
import { executeToolCall } from '../tools';
import { cleanupAllProcesses } from '../tools/shell';
@@ -52,8 +40,6 @@ interface ConnectOptions {
gateway?: string;
token?: string;
verbose?: boolean;
/** Enroll this machine as a device of the given workspace (admin only). */
workspace?: string;
}
export function registerConnectCommand(program: Command) {
@@ -63,7 +49,6 @@ export function registerConnectCommand(program: Command) {
.option('--token <jwt>', 'JWT access token')
.option('--gateway <url>', 'Device gateway URL')
.option('--device-id <id>', 'Device ID (auto-generated if not provided)')
.option('--workspace <id>', 'Enroll as a device of this workspace (admin only)')
.option('-v, --verbose', 'Enable verbose logging')
.option('-d, --daemon', 'Run as a background daemon process')
.option('--daemon-child', 'Internal: runs as the daemon child process')
@@ -82,7 +67,17 @@ export function registerConnectCommand(program: Command) {
});
// Subcommands
connectCmd.command('stop').description('Stop the background daemon process').action(handleStop);
connectCmd
.command('stop')
.description('Stop the background daemon process')
.action(() => {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
});
connectCmd
.command('status')
@@ -146,27 +141,10 @@ export function registerConnectCommand(program: Command) {
}
handleDaemonStart({ ...options, daemon: true });
});
// Top-level alias for `connect stop`. Users who run `lh connect` naturally
// reach for `lh disconnect` to undo it; the nested `connect stop` is not
// discoverable enough on its own.
program
.command('disconnect')
.description('Disconnect from the device gateway (alias for `connect stop`)')
.action(handleStop);
}
// --- Internal helpers ---
function handleStop() {
const stopped = stopDaemon();
if (stopped) {
log.info('Daemon stopped.');
} else {
log.warn('No daemon is running.');
}
}
function handleDaemonStart(options: ConnectOptions) {
const existingPid = getRunningDaemonPid();
if (existingPid !== null) {
@@ -193,7 +171,6 @@ function buildDaemonArgs(options: ConnectOptions): string[] {
if (options.token) args.push('--token', options.token);
if (options.gateway) args.push('--gateway', options.gateway);
if (options.deviceId) args.push('--device-id', options.deviceId);
if (options.workspace) args.push('--workspace', options.workspace);
if (options.verbose) args.push('--verbose');
return args;
@@ -218,43 +195,10 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
const resolvedGatewayUrl = gatewayUrl || OFFICIAL_GATEWAY_URL;
// Workspace enrollment: the device joins a workspace pool (reachable by all
// members) instead of the personal pool. It authenticates with a minted
// workspace-device token (carrying the `workspace_id` claim) and uses a
// workspace-derived deviceId. `auth` stays the admin's identity — used only to
// (re-)mint the connect token and register the row.
const workspaceId = options.workspace;
// Resolve a stable device identity. An explicit `--device-id` wins (lets a
// user pin a VM to a fixed identity); otherwise derive from the machine id so
// the same machine maps to one device across reconnects.
const identity = workspaceId
? resolveWorkspaceDeviceIdentity(workspaceId, options.deviceId)
: resolveDeviceIdentity(auth.userId, options.deviceId);
// The token the gateway socket authenticates with. Re-minted on refresh for
// workspace devices (see `refreshConnectToken`).
let connectToken = auth.token;
let connectTokenType: 'apiKey' | 'jwt' | 'serviceToken' = auth.tokenType;
if (workspaceId) {
const minted = await mintWorkspaceConnectToken(auth, workspaceId);
connectToken = minted.token;
connectTokenType = 'jwt';
}
// Re-resolve the admin auth and, for workspace mode, re-mint the connect token.
const refreshConnectToken = async (): Promise<string | undefined> => {
const refreshed = await resolveToken({});
if (!refreshed) return undefined;
auth = refreshed;
if (workspaceId) {
const minted = await mintWorkspaceConnectToken(auth, workspaceId);
connectToken = minted.token;
return connectToken;
}
connectToken = refreshed.token;
return connectToken;
};
// the same machine + user maps to one device across reconnects.
const identity = resolveDeviceIdentity(auth.userId, options.deviceId);
// Freeform channel label (`cli` by default); `LOBEHUB_CLI_CHANNEL` lets a
// dev build tag itself `cli-dev` so the gateway can prioritise / display it.
@@ -267,10 +211,9 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
gatewayUrl: resolvedGatewayUrl,
logger: isDaemonChild ? createDaemonLogger() : log,
serverUrl: auth.serverUrl,
token: connectToken,
tokenType: connectTokenType,
userId: workspaceId ? undefined : auth.userId,
workspaceId,
token: auth.token,
tokenType: auth.tokenType,
userId: auth.userId,
});
const info = (msg: string) => {
@@ -349,31 +292,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`
@@ -419,21 +337,15 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
updateStatus('reconnecting');
});
// Proactive token refresh — schedule before the connect token expires. For a
// workspace device `refreshConnectToken` re-mints the workspace token; for a
// personal device it refreshes the user token. Scheduling watches the actual
// connect token, so the workspace token's shorter life is respected.
const startProactiveRefresh = (): (() => void) | null =>
// Proactive token refresh — schedule before JWT expires
const startProactiveRefresh = () =>
scheduleProactiveRefresh(
connectToken,
connectTokenType,
async () => {
const newToken = await refreshConnectToken();
if (newToken) {
client.updateToken(newToken);
cancelRefreshTimer = startProactiveRefresh();
}
return newToken;
auth,
(refreshed) => {
client.updateToken(refreshed.token);
auth = refreshed;
// Schedule next refresh based on the new token
cancelRefreshTimer = startProactiveRefresh();
},
info,
error,
@@ -444,15 +356,15 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// (e.g., auto-reconnect may send an expired JWT before proactive refresh fires)
let authFailedRefreshAttempted = false;
client.on('auth_failed', async (reason) => {
if (connectTokenType === 'jwt' && !authFailedRefreshAttempted) {
if (auth.tokenType === 'jwt' && !authFailedRefreshAttempted) {
authFailedRefreshAttempted = true;
info(`Authentication failed (${reason}). Attempting token refresh...`);
try {
const prev = connectToken;
const newToken = await refreshConnectToken();
if (newToken && newToken !== prev) {
const refreshed = await resolveToken({});
if (refreshed && refreshed.token !== auth.token) {
info('Token refreshed successfully. Reconnecting...');
client.updateToken(newToken);
client.updateToken(refreshed.token);
auth = refreshed;
authFailedRefreshAttempted = false;
cancelRefreshTimer = startProactiveRefresh();
await client.reconnect();
@@ -473,7 +385,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// Handle auth expired — refresh token and reconnect automatically
client.on('auth_expired', async () => {
if (connectTokenType === 'apiKey') {
if (auth.tokenType === 'apiKey') {
// API keys don't expire; ignore stale auth_expired signals
return;
}
@@ -481,10 +393,11 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
info('Authentication expired. Attempting to refresh token...');
try {
const newToken = await refreshConnectToken();
if (newToken) {
const refreshed = await resolveToken({});
if (refreshed) {
info('Token refreshed successfully. Reconnecting...');
client.updateToken(newToken);
client.updateToken(refreshed.token);
auth = refreshed;
cancelRefreshTimer = startProactiveRefresh();
await client.reconnect();
return;
@@ -534,8 +447,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
try {
// Reuse the already-resolved auth (respects `--token` mode) so we don't
// re-discover creds and exit when none are found.
if (workspaceId) await registerWorkspaceDevice(auth, identity, workspaceId);
else await registerDevice(auth, identity);
await registerDevice(auth, identity);
} catch (err) {
error(`Device registration failed (non-fatal): ${(err as Error).message}`);
}
@@ -583,49 +495,47 @@ function parseJwtExp(token: string): number | undefined {
}
/**
* Schedule a proactive token refresh before the (connect) token expires.
* `refresh` performs the actual refresh — re-minting a workspace token or
* refreshing the user token — and returns the new token. Returns a cleanup
* function that cancels the scheduled timer.
* Schedule a proactive token refresh before the JWT expires.
* Returns a cleanup function that cancels the scheduled timer.
*/
function scheduleProactiveRefresh(
token: string,
tokenType: string,
refresh: () => Promise<string | undefined>,
auth: { token: string; tokenType: string },
onRefreshed: (newAuth: Awaited<ReturnType<typeof resolveToken>>) => void,
info: (msg: string) => void,
error: (msg: string) => void,
): (() => void) | null {
if (tokenType !== 'jwt') return null;
if (auth.tokenType !== 'jwt') return null;
const exp = parseJwtExp(token);
const exp = parseJwtExp(auth.token);
if (!exp) return null;
const lifetimeMs = exp * 1000 - Date.now();
if (lifetimeMs <= 0) {
// Token already expired — refresh once on next tick.
const refreshAt = (exp - PROACTIVE_REFRESH_BUFFER) * 1000;
const delay = refreshAt - Date.now();
if (delay < 0) {
// Already past the refresh window — refresh immediately on next tick
void doRefresh();
return null;
}
// Refresh ahead of expiry, but never let the buffer meet or exceed the token's
// remaining lifetime: a buffer >= lifetime collapses the refresh window to <=0
// and busy-loops re-minting (e.g. a 1h token with a 1h buffer). Cap the buffer
// at half the remaining lifetime so a short-lived token refreshes about once per
// half-life instead of spinning.
const bufferMs = Math.min(PROACTIVE_REFRESH_BUFFER * 1000, lifetimeMs / 2);
const delay = lifetimeMs - bufferMs;
const timer = setTimeout(() => void doRefresh(), delay);
return () => clearTimeout(timer);
async function doRefresh() {
try {
const newToken = await refresh();
if (!newToken) {
// Use the same buffer so getValidToken actually triggers a refresh
const result = await getValidToken(PROACTIVE_REFRESH_BUFFER);
if (!result) {
error('Proactive token refresh failed — no valid credentials.');
return;
}
if (newToken !== token) info('Proactively refreshed token.');
const refreshed = await resolveToken({});
// Only notify if the token actually changed to avoid reschedule loops
if (refreshed.token !== auth.token) {
info('Proactively refreshed token.');
onRefreshed(refreshed);
}
} catch {
error('Proactive token refresh failed.');
}
+3 -117
View File
@@ -1,7 +1,3 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -21,9 +17,6 @@ const { mockTrpcClient } = vi.hoisted(() => ({
removeFiles: { mutate: vi.fn() },
updateFile: { mutate: vi.fn() },
},
upload: {
createS3PreSignedUrl: { mutate: vi.fn() },
},
},
}));
@@ -45,11 +38,9 @@ describe('file command', () => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
for (const group of [mockTrpcClient.file, mockTrpcClient.upload]) {
for (const method of Object.values(group)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
for (const method of Object.values(mockTrpcClient.file)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
});
@@ -214,111 +205,6 @@ describe('file command', () => {
expect(mockTrpcClient.file.createFile.mutate).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
});
it('should upload a local file passed as a positional argument', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-${process.pid}.txt`);
fs.writeFileSync(tmpFile, 'hello world');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
mockTrpcClient.file.createFile.mutate.mockResolvedValue({
id: 'f-local',
url: 'files/x.txt',
});
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledWith(
'https://s3/presigned',
expect.objectContaining({ method: 'PUT' }),
);
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({
fileType: 'text/plain',
name: path.basename(tmpFile),
url: expect.stringContaining('.txt'),
}),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('File created'));
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should upload a local file passed via --file', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-f-${process.pid}.json`);
fs.writeFileSync(tmpFile, '{}');
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' } as Response);
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({ isExist: false });
mockTrpcClient.upload.createS3PreSignedUrl.mutate.mockResolvedValue('https://s3/presigned');
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-json' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', '--file', tmpFile]);
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileType: 'application/json' }),
);
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should skip the S3 upload when the local file hash already exists', async () => {
const tmpFile = path.join(os.tmpdir(), `lh-upload-dedup-${process.pid}.txt`);
fs.writeFileSync(tmpFile, 'dedup me');
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockTrpcClient.file.checkFileHash.mutate.mockResolvedValue({
isExist: true,
url: 'files/2024-01-01/existing.txt',
});
mockTrpcClient.file.createFile.mutate.mockResolvedValue({ id: 'f-dedup' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', tmpFile]);
// No pre-sign and no S3 PUT should happen
expect(mockTrpcClient.upload.createS3PreSignedUrl.mutate).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
// The record reuses the existing url
expect(mockTrpcClient.file.createFile.mutate).toHaveBeenCalledWith(
expect.objectContaining({ url: 'files/2024-01-01/existing.txt' }),
);
} finally {
fetchSpy.mockRestore();
fs.rmSync(tmpFile, { force: true });
}
});
it('should error when local file does not exist', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload', '-f', '/no/such/file.txt']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('File not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should error when no source is provided', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'file', 'upload']);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Provide a local file path'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('edit', () => {
+7 -49
View File
@@ -4,7 +4,6 @@ import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
export function registerFileCommand(program: Command) {
const file = program.command('file').description('Manage files');
@@ -114,20 +113,18 @@ export function registerFileCommand(program: Command) {
// ── upload ───────────────────────────────────────────
file
.command('upload [source]')
.description('Upload a file from a local path or a URL')
.option('-f, --file <path>', 'Local file path to upload')
.option('--hash <hash>', 'File hash for deduplication check (URL mode)')
.option('--name <name>', 'File name (URL mode)')
.option('--type <type>', 'File MIME type (URL mode)')
.option('--size <size>', 'File size in bytes (URL mode)')
.command('upload <url>')
.description('Upload a file by URL (checks hash first)')
.option('--hash <hash>', 'File hash for deduplication check')
.option('--name <name>', 'File name')
.option('--type <type>', 'File MIME type')
.option('--size <size>', 'File size in bytes')
.option('--parent-id <id>', 'Parent folder ID')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (
source: string | undefined,
url: string,
options: {
file?: string;
hash?: string;
json?: string | boolean;
name?: string;
@@ -136,47 +133,8 @@ export function registerFileCommand(program: Command) {
type?: string;
},
) => {
const isUrl = (value: string) =>
value.startsWith('http://') || value.startsWith('https://');
// Resolve the local file path: explicit --file, or a positional that is
// not a URL (e.g. `lh file upload ./games_list.txt`).
const localPath = options.file ?? (source && !isUrl(source) ? source : undefined);
const client = await getTrpcClient();
// ── Local file upload ──
if (localPath) {
let result;
try {
result = await uploadLocalFile(client, localPath, { parentId: options.parentId });
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
if (options.json !== undefined) {
const fields = typeof options.json === 'string' ? options.json : undefined;
outputJson(result, fields);
return;
}
const r = result as any;
console.log(`${pc.green('✓')} File created: ${pc.bold(r.id || '')}`);
if (r.url) console.log(` URL: ${pc.dim(r.url)}`);
return;
}
// ── URL upload ──
if (!source) {
log.error('Provide a local file path, --file <path>, or a URL to upload.');
process.exit(1);
return;
}
const url = source;
// Check hash first if provided
if (options.hash) {
const check = await client.file.checkFileHash.mutate({ hash: options.hash });
-140
View File
@@ -1,7 +1,3 @@
import { rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -10,9 +6,6 @@ import { registerGenerateCommand } from './generate';
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
asr: {
transcribe: { mutate: vi.fn() },
},
generation: {
deleteGeneration: { mutate: vi.fn() },
getGenerationStatus: { query: vi.fn() },
@@ -42,15 +35,6 @@ const { writeFileSync: mockWriteFileSync } = vi.hoisted(() => ({
writeFileSync: vi.fn(),
}));
const { uploadLocalFile: mockUploadLocalFile } = vi.hoisted(() => ({
uploadLocalFile: vi.fn(),
}));
vi.mock('../utils/uploadLocalFile', async (importOriginal) => {
const actual: Record<string, unknown> = await importOriginal();
return { ...actual, uploadLocalFile: mockUploadLocalFile };
});
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
vi.mock('node:fs', async (importOriginal) => {
@@ -385,130 +369,6 @@ describe('generate command', () => {
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should upload large local audio and transcribe by fileId', async () => {
// Real >3MB temp file so existsSync/statSync (unmocked) see it as large.
const bigPath = path.join(os.tmpdir(), `lh-asr-test-${process.pid}-${Date.now()}.mp3`);
await fsWriteFile(bigPath, Buffer.alloc(4 * 1024 * 1024));
mockUploadLocalFile.mockResolvedValue({ id: 'file_999' });
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'big result' });
try {
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'asr', bigPath]);
expect(mockUploadLocalFile).toHaveBeenCalledWith(expect.anything(), bigPath);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileId: 'file_999', model: 'whisper-1', provider: 'openai' }),
);
// never inlines bytes for the large file
expect(mockTrpcClient.asr.transcribe.mutate.mock.calls[0][0]).not.toHaveProperty(
'audioBase64',
);
expect(stdoutSpy).toHaveBeenCalledWith('big result');
} finally {
await fsRm(bigPath, { force: true });
}
});
it('should download and transcribe an audio URL', async () => {
const fetchMock = vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers(),
ok: true,
});
vi.stubGlobal('fetch', fetchMock);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'hello world' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/audio/sample.mp3',
]);
expect(fetchMock).toHaveBeenCalledWith('https://example.com/audio/sample.mp3');
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({
audioBase64: Buffer.from('audio-bytes').toString('base64'),
fileName: 'sample.mp3',
model: 'whisper-1',
provider: 'openai',
}),
);
expect(stdoutSpy).toHaveBeenCalledWith('hello world');
expect(exitSpy).not.toHaveBeenCalled();
});
it('should derive an extension and mime type from Content-Type when the URL has none', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers({ 'content-type': 'audio/mpeg; charset=binary' }),
ok: true,
}),
);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
const program = createProgram();
await program.parseAsync(['node', 'test', 'generate', 'asr', 'https://example.com/download']);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({
fileName: 'download.mp3',
mimeType: 'audio/mpeg',
}),
);
});
it('should prefer the filename from Content-Disposition', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
arrayBuffer: vi.fn().mockResolvedValue(new TextEncoder().encode('audio-bytes').buffer),
headers: new Headers({
'content-disposition': 'attachment; filename="recording.wav"',
}),
ok: true,
}),
);
mockTrpcClient.asr.transcribe.mutate.mockResolvedValue({ text: 'ok' });
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/files/abc123?sig=xyz',
]);
expect(mockTrpcClient.asr.transcribe.mutate).toHaveBeenCalledWith(
expect.objectContaining({ fileName: 'recording.wav' }),
);
});
it('should exit when audio URL download fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }),
);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'asr',
'https://example.com/missing.mp3',
]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to download audio'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe('delete', () => {
+39 -167
View File
@@ -1,27 +1,16 @@
import { existsSync, statSync } from 'node:fs';
import { readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import { createReadStream, existsSync } from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import { getTrpcClient } from '../../api/client';
import { getAuthInfo } from '../../api/http';
import { log } from '../../utils/logger';
import { uploadLocalFile } from '../../utils/uploadLocalFile';
// Audio at or below this size is sent inline as base64; anything larger is
// uploaded first and transcribed by `fileId`. Kept in sync with the server-side
// inline cap in `apps/server/src/routers/lambda/asr.ts`.
const MAX_INLINE_AUDIO_BYTES = 3 * 1024 * 1024;
export function registerAsrCommand(parent: Command) {
parent
.command('asr <audio-file>')
.description(
'Convert speech to text (automatic speech recognition). Accepts a local path or a URL',
)
.description('Convert speech to text (automatic speech recognition)')
.option('--model <model>', 'STT model', 'whisper-1')
.option('--provider <provider>', 'AI provider', 'openai')
.option('--language <lang>', 'Language code (e.g. en, zh)')
.option('--json', 'Output raw JSON')
.action(
@@ -31,175 +20,58 @@ export function registerAsrCommand(parent: Command) {
json?: boolean;
language?: string;
model: string;
provider: string;
},
) => {
const isUrl = audioFile.startsWith('http://') || audioFile.startsWith('https://');
if (!isUrl && !existsSync(audioFile)) {
if (!existsSync(audioFile)) {
log.error(`File not found: ${audioFile}`);
process.exit(1);
return;
}
// Resolve the input to a local file path (downloading URLs to a temp
// file) so large audio can reuse the shared upload flow.
let localPath: string;
let fileName: string;
let mimeType: string | undefined;
let size: number;
let tempPath: string | undefined;
try {
if (isUrl) {
const downloaded = await fetchAudioFromUrl(audioFile);
fileName = downloaded.name;
mimeType = downloaded.mimeType;
size = downloaded.bytes.byteLength;
tempPath = path.join(os.tmpdir(), `lh-asr-${process.pid}-${Date.now()}-${fileName}`);
await writeFile(tempPath, downloaded.bytes);
localPath = tempPath;
} else {
localPath = audioFile;
fileName = path.basename(audioFile);
size = statSync(audioFile).size;
}
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
const { serverUrl, headers } = await getAuthInfo();
const sttOptions: Record<string, any> = { model: options.model };
if (options.language) sttOptions.language = options.language;
const formData = new FormData();
const fileBuffer = await readFileAsBlob(audioFile);
formData.append('speech', fileBuffer, path.basename(audioFile));
formData.append('options', JSON.stringify(sttOptions));
// Remove Content-Type for multipart/form-data (let fetch set it with boundary)
const { 'Content-Type': _, ...formHeaders } = headers;
const res = await fetch(`${serverUrl}/webapi/stt/openai`, {
body: formData,
headers: formHeaders,
method: 'POST',
});
if (!res.ok) {
const errText = await res.text();
log.error(`ASR failed: ${res.status} ${errText}`);
process.exit(1);
return;
}
try {
const client = await getTrpcClient();
const result = await res.json();
let result: { text: string };
if (size > MAX_INLINE_AUDIO_BYTES) {
// Large audio: upload to storage, then transcribe by fileId so the
// bytes never travel inline through tRPC.
process.stderr.write(
`Audio is ${(size / 1024 / 1024).toFixed(1)}MB — uploading before transcription…\n`,
);
const record = (await uploadLocalFile(client, localPath)) as { id: string };
result = await client.asr.transcribe.mutate({
fileId: record.id,
language: options.language,
model: options.model,
provider: options.provider,
});
} else {
const bytes = await readFile(localPath);
result = await client.asr.transcribe.mutate({
audioBase64: Buffer.from(bytes).toString('base64'),
fileName,
language: options.language,
mimeType,
model: options.model,
provider: options.provider,
});
}
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
process.stdout.write(result.text);
process.stdout.write('\n');
}
} catch (error) {
log.error(`ASR failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
} finally {
if (tempPath) {
await rm(tempPath, { force: true }).catch(() => {});
}
if (options.json) {
console.log(JSON.stringify(result, null, 2));
} else {
const text = (result as any).text || JSON.stringify(result);
process.stdout.write(text);
process.stdout.write('\n');
}
},
);
}
// Common audio MIME types mapped to a file extension the transcription
// provider can recognize. Keep the extensions within the set OpenAI's
// /audio/transcriptions endpoint accepts.
const AUDIO_MIME_TO_EXT: Record<string, string> = {
'audio/aac': 'aac',
'audio/flac': 'flac',
'audio/m4a': 'm4a',
'audio/mp3': 'mp3',
'audio/mp4': 'm4a',
'audio/mpeg': 'mp3',
'audio/mpga': 'mp3',
'audio/ogg': 'ogg',
'audio/opus': 'ogg',
'audio/wav': 'wav',
'audio/wave': 'wav',
'audio/webm': 'webm',
'audio/x-m4a': 'm4a',
'audio/x-wav': 'wav',
};
async function fetchAudioFromUrl(
url: string,
): Promise<{ bytes: Uint8Array; mimeType?: string; name: string }> {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to download audio: ${res.status} ${res.statusText}`);
}
const bytes = new Uint8Array(await res.arrayBuffer());
// Strip any parameters from the Content-Type (e.g. `audio/mpeg; charset=...`).
const contentType = res.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
const mimeType = contentType?.startsWith('audio/') ? contentType : undefined;
// Prefer the name the server advertises, then the URL path, then a fallback.
const name =
fileNameFromContentDisposition(res.headers.get('content-disposition')) ||
basenameFromUrl(url) ||
'audio';
// Transcription providers infer the audio format from the file extension, so
// make sure the name carries one. Signed URLs and /download endpoints often
// have no extension in the path — in that case borrow it from the
// Content-Type when we recognize it.
const ext = contentType ? AUDIO_MIME_TO_EXT[contentType] : undefined;
const finalName = path.extname(name) || !ext ? name : `${name}.${ext}`;
return { bytes, mimeType, name: finalName };
}
// Extract a file name from a Content-Disposition header, handling both the
// plain `filename="x"` form and the RFC 5987 extended `filename*=UTF-8''x` form.
function fileNameFromContentDisposition(header: string | null): string | undefined {
if (!header) return undefined;
// Extended form takes precedence and may be percent-encoded.
const extended = /filename\*=\s*(?:UTF-8|ISO-8859-1)?''([^;]+)/i.exec(header);
if (extended?.[1]) {
try {
return path.basename(decodeURIComponent(extended[1].trim()));
} catch {
// Malformed encoding — fall through to the plain form.
}
}
const plain = /filename=\s*"?([^";]+)"?/i.exec(header);
const value = plain?.[1]?.trim();
return value ? path.basename(value) : undefined;
}
// Derive the (URL-decoded) last path segment of a URL, if any.
function basenameFromUrl(url: string): string | undefined {
let pathname: string;
try {
pathname = new URL(url).pathname;
} catch {
return undefined;
}
const base = path.basename(pathname);
if (!base) return undefined;
try {
return decodeURIComponent(base);
} catch {
return base;
async function readFileAsBlob(filePath: string): Promise<Blob> {
const chunks: Uint8Array[] = [];
const stream = createReadStream(filePath);
for await (const chunk of stream) {
chunks.push(chunk as Uint8Array);
}
return new Blob(chunks);
}
-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 {
+74 -13
View File
@@ -1,12 +1,14 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { getAuthInfo } from '../api/http';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
function formatFileType(fileType: string): string {
if (!fileType) return '';
@@ -322,22 +324,81 @@ export function registerKbCommand(program: Command) {
.description('Upload a file to a knowledge base')
.option('--parent <parentId>', 'Parent folder ID')
.action(async (knowledgeBaseId: string, filePath: string, options: { parent?: string }) => {
const client = await getTrpcClient();
let result;
try {
result = await uploadLocalFile(client, filePath, {
knowledgeBaseId,
parentId: options.parent,
});
} catch (error) {
log.error(error instanceof Error ? error.message : String(error));
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
log.error(`File not found: ${resolved}`);
process.exit(1);
return;
}
const stat = fs.statSync(resolved);
const fileName = path.basename(resolved);
const fileBuffer = fs.readFileSync(resolved);
// Compute SHA-256 hash
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// Detect MIME type from extension
const ext = path.extname(fileName).toLowerCase().slice(1);
const mimeMap: Record<string, string> = {
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
gif: 'image/gif',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
json: 'application/json',
md: 'text/markdown',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
pdf: 'application/pdf',
png: 'image/png',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
svg: 'image/svg+xml',
txt: 'text/plain',
webp: 'image/webp',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
const fileType = mimeMap[ext] || 'application/octet-stream';
const client = await getTrpcClient();
const { serverUrl, headers } = await getAuthInfo();
// 1. Get presigned URL
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
const pathname = `files/${date}/${hash}.${ext}`;
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
// 2. Upload to S3
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
const uploadRes = await fetch(presignedUrl, {
body: fileBuffer,
headers: { 'Content-Type': fileType },
method: 'PUT',
});
if (!uploadRes.ok) {
log.error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
process.exit(1);
}
// 3. Create file record
const result = await client.file.createFile.mutate({
fileType,
hash,
knowledgeBaseId,
metadata: {
date,
dirname: '',
filename: fileName,
path: pathname,
},
name: fileName,
parentId: options.parent,
size: stat.size,
url: pathname,
});
console.log(
`${pc.green('✓')} Uploaded ${pc.bold(path.basename(filePath))}${pc.bold((result as any).id)}`,
`${pc.green('✓')} Uploaded ${pc.bold(fileName)}${pc.bold((result as any).id)}`,
);
});
}
+1 -31
View File
@@ -1,8 +1,7 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { clearCredentials } from '../auth/credentials';
import { stopDaemon } from '../daemon/manager';
import { log } from '../utils/logger';
import { registerLogoutCommand } from './logout';
@@ -10,10 +9,6 @@ vi.mock('../auth/credentials', () => ({
clearCredentials: vi.fn(),
}));
vi.mock('../daemon/manager', () => ({
stopDaemon: vi.fn(),
}));
vi.mock('../utils/logger', () => ({
log: {
debug: vi.fn(),
@@ -24,11 +19,6 @@ vi.mock('../utils/logger', () => ({
}));
describe('logout command', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(stopDaemon).mockReturnValue(false);
});
function createProgram() {
const program = new Command();
program.exitOverride();
@@ -54,24 +44,4 @@ describe('logout command', () => {
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Already logged out'));
});
it('should stop the connect daemon before clearing credentials', async () => {
vi.mocked(stopDaemon).mockReturnValue(true);
vi.mocked(clearCredentials).mockReturnValue(true);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(stopDaemon).toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Disconnected device daemon'));
});
it('should still attempt daemon teardown when no credentials exist', async () => {
vi.mocked(clearCredentials).mockReturnValue(false);
const program = createProgram();
await program.parseAsync(['node', 'test', 'logout']);
expect(stopDaemon).toHaveBeenCalled();
});
});
-9
View File
@@ -1,7 +1,6 @@
import type { Command } from 'commander';
import { clearCredentials } from '../auth/credentials';
import { stopDaemon } from '../daemon/manager';
import { log } from '../utils/logger';
export function registerLogoutCommand(program: Command) {
@@ -9,14 +8,6 @@ export function registerLogoutCommand(program: Command) {
.command('logout')
.description('Log out and remove stored credentials')
.action(() => {
// Tear down the connect daemon first — otherwise it keeps the device
// online on the gateway with the cached token even after credentials are
// gone, leaving the machine remotely driveable past "logout".
const stopped = stopDaemon();
if (stopped) {
log.info('Disconnected device daemon.');
}
const removed = clearCredentials();
if (removed) {
log.info('Logged out. Credentials removed.');
-58
View File
@@ -100,19 +100,6 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(visibleModels, null, 2));
});
it('should normalize the legacy `stt` type to `asr` when filtering', async () => {
mockTrpcClient.aiModel.getAiProviderModelList.query.mockResolvedValue([
{ displayName: 'Whisper', enabled: true, id: 'whisper-1', type: 'asr' },
]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'list', 'openai', '--type', 'stt']);
expect(mockTrpcClient.aiModel.getAiProviderModelList.query).toHaveBeenCalledWith(
expect.objectContaining({ id: 'openai', type: 'asr' }),
);
});
});
describe('view', () => {
@@ -170,28 +157,6 @@ describe('model command', () => {
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Created model'));
});
it('should normalize the legacy `stt` type to `asr`', async () => {
mockTrpcClient.aiModel.createAiModel.mutate.mockResolvedValue('whisper-1');
const program = createProgram();
await program.parseAsync([
'node',
'test',
'model',
'create',
'--id',
'whisper-1',
'--provider',
'openai',
'--type',
'stt',
]);
expect(mockTrpcClient.aiModel.createAiModel.mutate).toHaveBeenCalledWith(
expect.objectContaining({ id: 'whisper-1', providerId: 'openai', type: 'asr' }),
);
});
});
describe('edit', () => {
@@ -219,29 +184,6 @@ describe('model command', () => {
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Updated model'));
});
it('should normalize the legacy `stt` type to `asr`', async () => {
mockTrpcClient.aiModel.updateAiModel.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'model',
'edit',
'whisper-1',
'--provider',
'openai',
'--type',
'stt',
]);
expect(mockTrpcClient.aiModel.updateAiModel.mutate).toHaveBeenCalledWith({
id: 'whisper-1',
providerId: 'openai',
value: expect.objectContaining({ type: 'asr' }),
});
});
it('should error when no changes specified', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'model', 'edit', 'gpt-4', '--provider', 'openai']);
+8 -15
View File
@@ -7,11 +7,6 @@ import { log } from '../utils/logger';
const isVisibleModel = (model: { visible?: boolean }) => model.visible !== false;
// The model type `stt` was renamed to the standard `asr`. Accept the legacy
// alias on CLI input and forward/compare `asr`, so existing scripts and muscle
// memory keep working against the new router schema.
const normalizeModelType = (type: string): string => (type === 'stt' ? 'asr' : type);
export function registerModelCommand(program: Command) {
const model = program.command('model').description('Manage AI models');
@@ -24,7 +19,7 @@ export function registerModelCommand(program: Command) {
.option('--enabled', 'Only show enabled models')
.option(
'--type <type>',
'Filter by model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
'Filter by model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
)
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
@@ -34,20 +29,18 @@ export function registerModelCommand(program: Command) {
) => {
const client = await getTrpcClient();
const typeFilter = options.type ? normalizeModelType(options.type) : undefined;
const input: Record<string, any> = { id: providerId };
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
if (options.enabled) input.enabled = true;
if (typeFilter) input.type = typeFilter;
if (options.type) input.type = options.type;
const result = await client.aiModel.getAiProviderModelList.query(input as any);
let items = (Array.isArray(result) ? result : ((result as any).items ?? [])).filter(
isVisibleModel,
);
if (typeFilter) {
items = items.filter((m: any) => m.type === typeFilter);
if (options.type) {
items = items.filter((m: any) => m.type === options.type);
}
if (options.json !== undefined) {
@@ -113,7 +106,7 @@ export function registerModelCommand(program: Command) {
.option('--display-name <name>', 'Display name')
.option(
'--type <type>',
'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)',
'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)',
'chat',
)
.action(
@@ -123,7 +116,7 @@ export function registerModelCommand(program: Command) {
const input: Record<string, any> = {
id: options.id,
providerId: options.provider,
type: normalizeModelType(options.type || 'chat'),
type: options.type || 'chat',
};
if (options.displayName) input.displayName = options.displayName;
@@ -139,7 +132,7 @@ export function registerModelCommand(program: Command) {
.description('Update model info')
.requiredOption('--provider <providerId>', 'Provider ID')
.option('--display-name <name>', 'Display name')
.option('--type <type>', 'Model type (chat|embedding|tts|asr|image|video|text2music|realtime)')
.option('--type <type>', 'Model type (chat|embedding|tts|stt|image|video|text2music|realtime)')
.action(
async (id: string, options: { displayName?: string; provider: string; type?: string }) => {
if (!options.displayName && !options.type) {
@@ -151,7 +144,7 @@ export function registerModelCommand(program: Command) {
const value: Record<string, any> = {};
if (options.displayName) value.displayName = options.displayName;
if (options.type) value.type = normalizeModelType(options.type);
if (options.type) value.type = options.type;
await client.aiModel.updateAiModel.mutate({
id,
-57
View File
@@ -1,57 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildInstallCommand, isNewerVersion } from './update';
describe('isNewerVersion', () => {
it('compares core versions', () => {
expect(isNewerVersion('1.2.3', '1.2.2')).toBe(true);
expect(isNewerVersion('1.2.2', '1.2.3')).toBe(false);
expect(isNewerVersion('1.2.3', '1.2.3')).toBe(false);
expect(isNewerVersion('2.0.0', '1.9.9')).toBe(true);
});
it('tolerates a leading v and missing segments', () => {
expect(isNewerVersion('v1.2.0', '1.2.0')).toBe(false);
expect(isNewerVersion('1.2', '1.2.0')).toBe(false);
expect(isNewerVersion('1.3', '1.2.9')).toBe(true);
});
it('ranks a stable release above a prerelease of the same core', () => {
expect(isNewerVersion('1.2.3', '1.2.3-beta.1')).toBe(true);
expect(isNewerVersion('1.2.3-beta.1', '1.2.3')).toBe(false);
expect(isNewerVersion('1.2.3-beta.2', '1.2.3-beta.1')).toBe(true);
expect(isNewerVersion('1.2.3-beta.1', '1.2.3-beta.1')).toBe(false);
});
it('orders numeric prerelease identifiers numerically, not lexicographically', () => {
// The bug a raw string compare gets wrong: beta.10 must outrank beta.9.
expect(isNewerVersion('1.0.0-beta.10', '1.0.0-beta.9')).toBe(true);
expect(isNewerVersion('1.0.0-beta.9', '1.0.0-beta.10')).toBe(false);
expect(isNewerVersion('1.0.0-beta.2', '1.0.0-beta.10')).toBe(false);
});
it('returns false for an unparseable latest version', () => {
expect(isNewerVersion('not-a-version', '1.0.0')).toBe(false);
});
});
describe('buildInstallCommand', () => {
it('builds the global install command per package manager', () => {
expect(buildInstallCommand('npm', '@lobehub/cli@1.0.0')).toEqual({
args: ['install', '-g', '@lobehub/cli@1.0.0'],
command: 'npm',
});
expect(buildInstallCommand('pnpm', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'pnpm',
});
expect(buildInstallCommand('bun', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'bun',
});
expect(buildInstallCommand('yarn', '@lobehub/cli@1.0.0')).toEqual({
args: ['global', 'add', '@lobehub/cli@1.0.0'],
command: 'yarn',
});
});
});
-179
View File
@@ -1,179 +0,0 @@
import { spawn } from 'node:child_process';
import { realpathSync } from 'node:fs';
import type { Command } from 'commander';
import pc from 'picocolors';
import semver from 'semver';
// Pull package metadata from the shared `src/pkg.ts` module (resolved at the
// bundled entry's depth) rather than a local `require('../../package.json')`,
// which would point outside the package once bundled into dist/index.js.
import { cliPackageName, cliVersion } from '../pkg';
import { log } from '../utils/logger';
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
interface UpdateOptions {
check?: boolean;
packageManager?: PackageManager;
tag?: string;
}
/**
* Detect which package manager installed the CLI so we run the matching global
* upgrade command. We first trust an explicit `npm_config_user_agent` (set when
* invoked through a package-manager script) and otherwise infer from the path of
* the running binary. Falls back to npm.
*/
export function detectPackageManager(): PackageManager {
const ua = process.env.npm_config_user_agent;
if (ua) {
if (ua.startsWith('pnpm')) return 'pnpm';
if (ua.startsWith('yarn')) return 'yarn';
if (ua.startsWith('bun')) return 'bun';
if (ua.startsWith('npm')) return 'npm';
}
try {
const binPath = realpathSync(process.argv[1] ?? '').replaceAll('\\', '/');
if (binPath.includes('/pnpm/')) return 'pnpm';
if (binPath.includes('/.bun/') || binPath.includes('/bun/')) return 'bun';
if (binPath.includes('/yarn/') || binPath.includes('/.yarn/')) return 'yarn';
} catch {
// ignore fall back to npm
}
return 'npm';
}
/** Build the global-install command for the detected package manager. */
export function buildInstallCommand(
pm: PackageManager,
spec: string,
): { args: string[]; command: string } {
switch (pm) {
case 'pnpm': {
return { args: ['add', '-g', spec], command: 'pnpm' };
}
case 'yarn': {
return { args: ['global', 'add', spec], command: 'yarn' };
}
case 'bun': {
return { args: ['add', '-g', spec], command: 'bun' };
}
default: {
return { args: ['install', '-g', spec], command: 'npm' };
}
}
}
/**
* Whether `latest` is a newer version than `current`. Delegates to `semver` so
* prerelease identifiers order correctly (e.g. `1.0.0-beta.10` > `1.0.0-beta.9`,
* which a lexicographic compare gets wrong). Tolerates a leading `v` and missing
* segments via coercion; an unparseable `latest` is treated as "not newer".
*/
export function isNewerVersion(latest: string, current: string): boolean {
const latestParsed = semver.coerce(latest, { includePrerelease: true }) ?? semver.parse(latest);
const currentParsed =
semver.coerce(current, { includePrerelease: true }) ?? semver.parse(current);
if (!latestParsed || !currentParsed) return false;
return semver.gt(latestParsed, currentParsed);
}
async function fetchLatestVersion(name: string, tag: string): Promise<string> {
const url = `https://registry.npmjs.org/${name}/${encodeURIComponent(tag)}`;
const res = await fetch(url, { headers: { accept: 'application/json' } });
if (!res.ok) {
throw new Error(`npm registry returned status ${res.status} for tag "${tag}"`);
}
const data = (await res.json()) as { version?: string };
if (!data.version) {
throw new Error('npm registry response is missing the "version" field');
}
return data.version;
}
function runInstall(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
shell: process.platform === 'win32',
stdio: 'inherit',
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${code ?? 'null'}`));
});
});
}
export function registerUpdateCommand(program: Command) {
program
.command('update')
.description('Update the LobeHub CLI to the latest published version')
.option('--check', 'Only check for a newer version without installing')
.option('--tag <tag>', 'npm dist-tag to update to', 'latest')
.option(
'--package-manager <pm>',
`Force a package manager (${PACKAGE_MANAGERS.join(', ')}) instead of auto-detecting`,
)
.action(async (options: UpdateOptions) => {
if (options.packageManager && !PACKAGE_MANAGERS.includes(options.packageManager)) {
log.error(
`Unsupported package manager "${options.packageManager}". Use one of: ${PACKAGE_MANAGERS.join(', ')}.`,
);
process.exit(1);
return;
}
const current = cliVersion;
const tag = options.tag || 'latest';
log.info(`Current version: ${pc.bold(current)}`);
let latest: string;
try {
latest = await fetchLatestVersion(cliPackageName, tag);
} catch (error) {
log.error(`Unable to check for updates: ${(error as Error).message}`);
process.exit(1);
return;
}
log.info(`Latest version: ${pc.bold(latest)} ${pc.dim(`(${tag})`)}`);
if (!isNewerVersion(latest, current)) {
log.info(pc.green('Already on the latest version.'));
return;
}
if (options.check) {
log.info(
`Update available: ${current}${pc.green(latest)}. Run ${pc.cyan('lh update')} to upgrade.`,
);
return;
}
const pm = options.packageManager || detectPackageManager();
const spec = `${cliPackageName}@${latest}`;
const { args, command } = buildInstallCommand(pm, spec);
log.info(`Upgrading via ${pc.bold(pm)}: ${pc.dim([command, ...args].join(' '))}`);
try {
await runInstall(command, args);
log.info(pc.green(`Successfully updated to ${latest}. Restart any running sessions.`));
} catch (error) {
log.error(`Update failed: ${(error as Error).message}`);
log.error(`You can upgrade manually: ${[command, ...args].join(' ')}`);
process.exit(1);
}
});
}
-42
View File
@@ -88,45 +88,3 @@ describe('verify rubric config commands', () => {
expect(printed).toContain('4');
});
});
describe('verify evidence upload command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockGetTrpcClient.mockReset();
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit ${code}`);
}) as any);
});
afterEach(() => {
exitSpy.mockRestore();
});
const run = async (args: string[]) => {
const program = new Command();
program.exitOverride();
registerVerifyCommand(program);
await program.parseAsync(['node', 'lh', 'verify', ...args]);
};
it('rejects evidence with both file and inline content', async () => {
await expect(
run([
'evidence',
'upload',
'--check',
'result-1',
'--type',
'text',
'--file',
'artifact.txt',
'--content',
'inline payload',
]),
).rejects.toThrow('process.exit 1');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockGetTrpcClient).not.toHaveBeenCalled();
});
});
+7 -478
View File
@@ -1,13 +1,9 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
// ── Helpers ────────────────────────────────────────────────
@@ -36,36 +32,6 @@ function assertEnum<T extends string>(value: T | undefined, allowed: T[], flag:
}
}
type Verdict = 'failed' | 'passed' | 'uncertain';
type EvidenceType = 'dom_snapshot' | 'gif' | 'screenshot' | 'text' | 'transcript' | 'video';
/** Map a free-form case/summary result token onto the verify verdict vocabulary. */
function toVerdict(raw: unknown): Verdict {
const s = String(raw ?? '').toLowerCase();
if (['pass', 'passed', 'ok', 'success'].includes(s)) return 'passed';
if (['fail', 'failed', 'error'].includes(s)) return 'failed';
return 'uncertain'; // partial / blocked / skipped / pending / unknown
}
/** Pick an evidence medium from a file extension. */
function evidenceTypeForFile(file: string): EvidenceType {
const ext = path.extname(file).toLowerCase().slice(1);
if (ext === 'gif') return 'gif';
if (['png', 'jpg', 'jpeg', 'webp', 'svg', 'bmp'].includes(ext)) return 'screenshot';
if (['mp4', 'webm', 'mov', 'm4v'].includes(ext)) return 'video';
if (['html', 'htm'].includes(ext)) return 'dom_snapshot';
return 'text';
}
/** Normalize a case's `evidence` field (string | string[] | {path}[]) to path strings. */
function evidencePaths(evidence: unknown): string[] {
if (!evidence) return [];
const arr = Array.isArray(evidence) ? evidence : [evidence];
return arr
.map((e) => (typeof e === 'string' ? e : (e?.path ?? e?.file)))
.filter((p): p is string => typeof p === 'string' && p.length > 0);
}
// ── Command Registration ───────────────────────────────────
export function registerVerifyCommand(program: Command) {
@@ -402,9 +368,9 @@ export function registerVerifyCommand(program: Command) {
console.log(`${pc.green('✓')} Skipped verification for run ${pc.bold(operationId)}`);
});
// ════════════ execute (agent path) ════════════
// ════════════ run / results ════════════
verify
.command('execute <operationId>')
.command('run <operationId>')
.description('Execute the confirmed plan against a deliverable (LLM judge)')
.requiredOption('--goal <goal>', "The run's task")
.requiredOption('--deliverable <text>', 'The output to judge')
@@ -440,147 +406,13 @@ export function registerVerifyCommand(program: Command) {
},
);
// ════════════ run (verification session entity) ════════════
const run = verify.command('run').description('Verification sessions (verify_runs)');
run
.command('create')
.description('Create a standalone verification session')
.option('--source <source>', 'agent | agent-testing', 'agent-testing')
.option('--operation <id>', 'Link to an existing Agent Run')
.option('--title <title>', 'Session title')
.option('--goal <goal>', 'Goal/task being verified')
verify
.command('results <operationId>')
.description('List check results for a run')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
goal?: string;
json?: boolean | string;
operation?: string;
source?: string;
title?: string;
}) => {
const client = await getTrpcClient();
const created = await client.verify.createRun.mutate({
goal: options.goal,
operationId: options.operation,
source: options.source as any,
title: options.title,
});
if (options.json !== undefined) {
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Created run ${pc.bold(created.id)}`);
},
);
run
.command('list')
.description('List recent verification sessions')
.option('--json [fields]', 'Output JSON')
.action(async (options: { json?: boolean | string }) => {
.action(async (operationId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const runs = await client.verify.listRuns.query();
if (options.json !== undefined) {
outputJson(runs, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (runs.length === 0) return void console.log('No runs found.');
printTable(
runs.map((r: any) => [
r.id,
truncate(r.title || '', 40),
r.source,
r.status ?? '',
r.operationId ? 'agent' : 'standalone',
r.createdAt ? timeAgo(r.createdAt) : '',
]),
['ID', 'TITLE', 'SOURCE', 'STATUS', 'KIND', 'CREATED'],
);
});
run
.command('get <runId>')
.description('Show a verification session')
.option('--json [fields]', 'Output JSON')
.action(async (runId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const item = await client.verify.getRun.query({ verifyRunId: runId });
if (options.json !== undefined) {
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (!item) return void console.log('Run not found.');
console.log(JSON.stringify(item, null, 2));
});
// ════════════ result (check result entity) ════════════
const result = verify.command('result').description('Check results (verify_check_results)');
result
.command('ingest')
.description('Upsert one check result by (run, checkItemId) from a supplied verdict')
.requiredOption('--run <verifyRunId>', 'Target session id')
.requiredOption('--check <checkItemId>', 'Stable check item id within the session')
.requiredOption('--verdict <verdict>', 'passed|failed|uncertain')
.option('--title <title>', 'Check title')
.option('--index <n>', 'Display index')
.option('--confidence <n>', '0-1 confidence')
.option('--status <status>', 'pending|running|passed|failed|skipped (derived from verdict)')
.option('--evidence <text>', 'Key observation (stored as Toulmin evidence)')
.option('--suggestion <text>', 'Remediation hint')
.option('--soft', 'Non-blocking (required=false); defaults to blocking')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
check: string;
confidence?: string;
evidence?: string;
index?: string;
json?: boolean | string;
run: string;
soft?: boolean;
status?: string;
suggestion?: string;
title?: string;
verdict: string;
}) => {
const client = await getTrpcClient();
const created = await client.verify.ingestResult.mutate({
checkItemId: options.check,
checkItemIndex: options.index ? Number.parseInt(options.index, 10) : undefined,
checkItemTitle: options.title,
confidence: options.confidence ? Number.parseFloat(options.confidence) : undefined,
required: options.soft ? false : undefined,
status: options.status as any,
suggestion: options.suggestion,
toulmin: options.evidence ? { evidence: options.evidence } : undefined,
verdict: options.verdict as any,
verifyRunId: options.run,
});
if (options.json !== undefined) {
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Result ${pc.bold(created.id)} (${created.verdict})`);
},
);
result
.command('list')
.description('List check results — by session (--run) or by Agent Run (--operation)')
.option('--run <verifyRunId>', 'List by verification session')
.option('--operation <operationId>', 'List by Agent Run')
.option('--json [fields]', 'Output JSON')
.action(async (options: { json?: boolean | string; operation?: string; run?: string }) => {
if (!options.run && !options.operation) {
log.error('Provide either --run or --operation');
process.exit(1);
}
const client = await getTrpcClient();
const results = options.run
? await client.verify.listResultsByRun.query({ verifyRunId: options.run })
: await client.verify.listResults.query({ operationId: options.operation! });
const results = await client.verify.listResults.query({ operationId });
if (options.json !== undefined) {
outputJson(results, typeof options.json === 'string' ? options.json : undefined);
return;
@@ -589,143 +421,6 @@ export function registerVerifyCommand(program: Command) {
printResults(results);
});
// ════════════ evidence (artifact entity) ════════════
const evidence = verify.command('evidence').description('Evidence artifacts (verify_evidence)');
evidence
.command('upload')
.description('Attach an evidence artifact (file or inline text) to a check result')
.requiredOption('--check <checkResultId>', 'Target check result id')
.requiredOption('--type <type>', 'screenshot|gif|video|text|dom_snapshot|transcript')
.option('--file <path>', 'Local file to upload as the artifact')
.option('--content <text>', 'Inline text payload (instead of a file)')
.option('--by <capturedBy>', 'agent-browser|cdp|cli|program|llm_judge', 'cli')
.option('--desc <text>', 'Human-readable caption')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
by?: string;
check: string;
content?: string;
desc?: string;
file?: string;
json?: boolean | string;
type: string;
}) => {
if (Boolean(options.file) === Boolean(options.content)) {
log.error('Provide exactly one of --file or --content');
process.exit(1);
}
const client = await getTrpcClient();
let fileId: string | undefined;
if (options.file) {
const uploaded = await uploadLocalFile(client, options.file);
fileId = uploaded.id;
}
const ev = await client.verify.uploadEvidence.mutate({
capturedBy: options.by as any,
checkResultId: options.check,
content: options.content,
description: options.desc,
fileId,
type: options.type as any,
});
if (options.json !== undefined) {
outputJson(ev, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(
`${pc.green('✓')} Evidence ${pc.bold(ev.id)}${fileId ? ` (file ${fileId})` : ''}`,
);
},
);
evidence
.command('list <checkResultId>')
.description('List evidence for a check result')
.option('--json [fields]', 'Output JSON')
.action(async (checkResultId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const rows = await client.verify.listEvidence.query({ checkResultId });
if (options.json !== undefined) {
outputJson(rows, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (rows.length === 0) return void console.log('No evidence.');
printTable(
rows.map((e: any) => [
e.id,
e.type,
e.capturedBy ?? '',
e.fileId ? 'file' : 'inline',
truncate(e.description || '', 40),
]),
['ID', 'TYPE', 'BY', 'PAYLOAD', 'DESC'],
);
});
// ════════════ report (narrative entity) ════════════
const report = verify.command('report').description('Verification reports (verify_reports)');
report
.command('upsert')
.description('Write (overwrite) the report for a session')
.requiredOption('--run <verifyRunId>', 'Target session id')
.option('--verdict <verdict>', 'passed|failed|uncertain')
.option('--summary <text>', 'Short summary')
.option('--content <markdown>', 'Full markdown body')
.option('--total <n>', 'Total checks')
.option('--passed <n>', 'Passed checks')
.option('--failed <n>', 'Failed checks')
.option('--uncertain <n>', 'Uncertain checks')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
content?: string;
failed?: string;
json?: boolean | string;
passed?: string;
run: string;
summary?: string;
total?: string;
uncertain?: string;
verdict?: string;
}) => {
const num = (s?: string) => (s === undefined ? undefined : Number.parseInt(s, 10));
const client = await getTrpcClient();
const created = await client.verify.upsertReport.mutate({
content: options.content,
failedChecks: num(options.failed),
passedChecks: num(options.passed),
summary: options.summary,
totalChecks: num(options.total),
uncertainChecks: num(options.uncertain),
verdict: options.verdict as any,
verifyRunId: options.run,
});
if (options.json !== undefined) {
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Report ${pc.bold(created.id)} (${created.verdict ?? '—'})`);
},
);
report
.command('get <runId>')
.description('Show the report for a session')
.option('--json [fields]', 'Output JSON')
.action(async (runId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const item = await client.verify.getReport.query({ verifyRunId: runId });
if (options.json !== undefined) {
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (!item) return void console.log('No report.');
console.log(JSON.stringify(item, null, 2));
});
// ════════════ feedback ════════════
verify
.command('decision <resultId> <decision>')
@@ -736,172 +431,6 @@ export function registerVerifyCommand(program: Command) {
await client.verify.submitDecision.mutate({ decision, resultId });
console.log(`${pc.green('✓')} Recorded ${pc.bold(decision)} on result ${pc.bold(resultId)}`);
});
// ════════════ ingest (aggregate convenience over the atomic commands) ════════════
verify
.command('ingest-report <reportDir>')
.description(
'Ingest a local agent-testing report (result.json + report.md + assets) as a verify session',
)
.option('--source <source>', 'agent | agent-testing', 'agent-testing')
.option('--operation <id>', 'Link the session to an existing Agent Run')
.option('--title <title>', 'Override the session title')
.option('--goal <goal>', 'The goal/task being verified')
.option('--open', 'Print the in-app URL to open the report')
.option('--json [fields]', 'Output JSON')
.action(
async (
reportDir: string,
options: {
goal?: string;
json?: boolean | string;
open?: boolean;
operation?: string;
source?: string;
title?: string;
},
) => {
const dir = path.resolve(reportDir);
const resultPath = path.join(dir, 'result.json');
if (!existsSync(resultPath)) {
log.error(`result.json not found in ${dir}`);
process.exit(1);
}
let result: any;
try {
result = JSON.parse(readFileSync(resultPath, 'utf8'));
} catch {
log.error('result.json is not valid JSON');
process.exit(1);
}
const cases: any[] = Array.isArray(result.cases) ? result.cases : [];
const summary = result.summary ?? {};
const reportMdPath = path.join(dir, 'report.md');
const content = existsSync(reportMdPath) ? readFileSync(reportMdPath, 'utf8') : undefined;
// The scenario's context for the report's scope header, lifted from
// result.json's top-level fields. Drop empty keys so the bag stays clean.
const surfaces = Array.isArray(result.surfaces)
? result.surfaces.filter((s: unknown) => typeof s === 'string')
: undefined;
const contextEntries = Object.entries({
branch: typeof result.branch === 'string' ? result.branch : undefined,
commit: typeof result.commit === 'string' ? result.commit : undefined,
entry: typeof result.entry === 'string' ? result.entry : undefined,
focus: typeof result.focus === 'string' ? result.focus : options.goal,
surfaces: surfaces && surfaces.length > 0 ? surfaces : undefined,
testedAt: typeof result.createdAt === 'string' ? result.createdAt : undefined,
}).filter(([, v]) => v !== undefined);
const context = contextEntries.length > 0 ? Object.fromEntries(contextEntries) : undefined;
// The harness verifies software changes; tag the run so the viewer renders
// the coding scope header. Overridable via result.json `scenario`.
const scenario = result.scenario === 'coding' ? 'coding' : ('coding' as const);
const client = await getTrpcClient();
// 1. Create the verification session.
const run = await client.verify.createRun.mutate({
context,
goal: options.goal ?? (typeof result.focus === 'string' ? result.focus : undefined),
operationId: options.operation,
scenario,
source: options.source as any,
title: options.title ?? result.title,
});
// 2. Ingest each case as a check result + its evidence.
let uploaded = 0;
for (const [index, c] of cases.entries()) {
const checkItemId = String(c.id ?? c.checkItemId ?? `case-${index + 1}`);
const verdict = toVerdict(c.result ?? c.status ?? c.verdict);
const observation = c.keyObservation ?? c.observation ?? c.note;
const checkResult = await client.verify.ingestResult.mutate({
checkItemId,
checkItemIndex: index,
checkItemTitle: c.name ?? c.case ?? c.title ?? checkItemId,
required: c.required ?? true,
// The case's key observation is recorded as Toulmin evidence; a real
// remediation hint (if the report provides one) goes to `suggestion`.
suggestion: typeof c.suggestion === 'string' ? c.suggestion : undefined,
toulmin: typeof observation === 'string' ? { evidence: observation } : undefined,
verdict,
verifierType: 'agent',
verifyRunId: run.id,
});
for (const rel of evidencePaths(c.evidence)) {
const abs = path.isAbsolute(rel) ? rel : path.join(dir, rel);
if (!existsSync(abs)) {
log.warn(`evidence not found, skipping: ${rel}`);
continue;
}
try {
const file = await uploadLocalFile(client, abs);
await client.verify.uploadEvidence.mutate({
capturedBy: 'cli',
checkResultId: checkResult.id,
// The filename, not the case title — the title already heads the
// check card, so reusing it here just triples the same text.
description: path.basename(abs),
fileId: file.id,
type: evidenceTypeForFile(abs),
});
uploaded += 1;
} catch (e) {
// A stub/unreachable storage bucket (common in local dev) fails the
// file PUT — don't abort the whole ingest over one artifact; the
// session, results, and report are the deliverable.
log.warn(`evidence upload failed, skipping ${path.basename(abs)}: ${String(e)}`);
}
}
}
// 3. Write the report. `summary` is the overall conclusion (rendered at
// the top of the report page); `content` is the full markdown detail.
const conclusion =
typeof summary.conclusion === 'string'
? summary.conclusion
: typeof summary.note === 'string'
? summary.note
: undefined;
// A 0-100 quality score lands on overallConfidence (0-1); the report page
// surfaces it as the `score` stat.
const score =
typeof summary.score === 'number'
? Math.max(0, Math.min(1, summary.score / 100))
: undefined;
await client.verify.upsertReport.mutate({
content,
failedChecks: summary.failed,
overallConfidence: score,
passedChecks: summary.passed,
summary: conclusion,
totalChecks: summary.total ?? cases.length,
uncertainChecks: (summary.blocked ?? 0) + (summary.uncertain ?? 0) || undefined,
verdict: summary.verdict ? toVerdict(summary.verdict) : undefined,
verifyRunId: run.id,
});
if (options.json !== undefined) {
outputJson(
{ cases: cases.length, evidence: uploaded, verifyRunId: run.id },
typeof options.json === 'string' ? options.json : undefined,
);
return;
}
console.log(
`${pc.green('✓')} Ingested ${pc.bold(String(cases.length))} case(s), ${pc.bold(String(uploaded))} evidence file(s)`,
);
console.log(`${pc.bold('verifyRunId')}: ${run.id}`);
if (options.open) {
console.log(`${pc.bold('open')}: /verify/${run.id}`);
}
},
);
}
function printResults(results: any[]): void {
-82
View File
@@ -19,22 +19,11 @@ vi.mock('node:os', async (importOriginal) => {
};
});
// Mock only `execFileSync` (used by isDaemonProcess to read a process command
// line); keep the real `spawn` so nothing else changes.
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<Record<string, any>>();
return { ...actual, execFileSync: vi.fn() };
});
// eslint-disable-next-line import-x/first
import { execFileSync } from 'node:child_process';
// eslint-disable-next-line import-x/first
import {
appendLog,
getLogPath,
getRunningDaemonPid,
isDaemonProcess,
isProcessAlive,
readPid,
readStatus,
@@ -46,15 +35,9 @@ import {
writeStatus,
} from './manager';
// A command line that matches the daemon signature (`connect … --daemon-child`).
const DAEMON_COMMAND = '/usr/local/bin/node /path/to/cli.js connect --daemon-child';
describe('daemon manager', () => {
beforeEach(async () => {
await mkdir(mockDir, { recursive: true });
// Default: any inspected PID looks like our daemon. Tests that need a
// reused / unrelated PID override this per-case.
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
});
afterEach(() => {
@@ -97,36 +80,6 @@ describe('daemon manager', () => {
});
});
describe('isDaemonProcess', () => {
it('should return true when the command line matches the daemon signature', () => {
vi.mocked(execFileSync).mockReturnValue(DAEMON_COMMAND as any);
expect(isDaemonProcess(12345)).toBe(true);
expect(execFileSync).toHaveBeenCalledWith(
'ps',
['-ww', '-p', '12345', '-o', 'command='],
expect.any(Object),
);
});
it('should return false for an unrelated process command line', () => {
vi.mocked(execFileSync).mockReturnValue('/usr/bin/vim notes.txt' as any);
expect(isDaemonProcess(12345)).toBe(false);
});
it('should return false when the signature is only partially present', () => {
// `connect` without the internal `--daemon-child` flag is not our daemon.
vi.mocked(execFileSync).mockReturnValue('/usr/bin/node /path/cli connect' as any);
expect(isDaemonProcess(12345)).toBe(false);
});
it('should return false when ps is unavailable / throws', () => {
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('ps: command not found');
});
expect(isDaemonProcess(12345)).toBe(false);
});
});
describe('getRunningDaemonPid', () => {
it('should return null when no PID file', () => {
expect(getRunningDaemonPid()).toBeNull();
@@ -157,23 +110,6 @@ describe('daemon manager', () => {
expect(readStatus()).toBeNull();
});
it('should treat a live but reused (non-daemon) PID as stale and clean up', () => {
// process.pid is alive, but the inspected command line is not our daemon —
// simulates the OS reusing a dead daemon's PID for an unrelated process.
writePid(process.pid);
writeStatus({
connectionStatus: 'connected',
gatewayUrl: 'https://test.com',
pid: process.pid,
startedAt: new Date().toISOString(),
});
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
expect(getRunningDaemonPid()).toBeNull();
expect(readPid()).toBeNull();
expect(readStatus()).toBeNull();
});
});
describe('status file', () => {
@@ -296,23 +232,5 @@ describe('daemon manager', () => {
killSpy.mockRestore();
});
it('should NOT SIGTERM a live PID that is not our daemon', () => {
// Stale daemon.pid whose PID was reused by an unrelated, living process.
writePid(process.pid);
vi.mocked(execFileSync).mockReturnValue('/usr/bin/some-other-process' as any);
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const result = stopDaemon();
expect(result).toBe(false);
// Only the liveness probe (signal 0) is allowed — never a real SIGTERM.
expect(killSpy).not.toHaveBeenCalledWith(process.pid, 'SIGTERM');
// Stale metadata is cleaned up so we don't keep re-checking it.
expect(readPid()).toBeNull();
killSpy.mockRestore();
});
});
});
+3 -33
View File
@@ -1,4 +1,4 @@
import { execFileSync, spawn } from 'node:child_process';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -70,34 +70,6 @@ export function isProcessAlive(pid: number): boolean {
}
}
/**
* Verify a live PID actually belongs to a LobeHub connect daemon.
*
* A bare `isProcessAlive` check is not enough: if a daemon dies without cleaning
* up `daemon.pid` (crash, `kill -9`, reboot), the OS can later reuse that PID
* for an unrelated process. Acting on the stale PID would let `lh logout` /
* `connect stop` SIGTERM a stranger. The daemon is always spawned as
* `<node> … connect … --daemon-child`, so we confirm that signature in the
* process command line before trusting the PID.
*
* Best-effort and deliberately conservative: if the command line can't be read
* (e.g. `ps` is unavailable), we return `false` so callers never kill a process
* we can't positively identify.
*/
export function isDaemonProcess(pid: number): boolean {
try {
// `-ww` disables column truncation so the trailing `--daemon-child` flag is
// never cut off; stderr is silenced so a dead PID just yields an empty match.
const command = execFileSync('ps', ['-ww', '-p', String(pid), '-o', 'command='], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return command.includes('--daemon-child') && command.includes('connect');
} catch {
return false;
}
}
/**
* Get the PID of a running daemon, cleaning up stale PID files.
* Returns null if no daemon is running.
@@ -106,11 +78,9 @@ export function getRunningDaemonPid(): number | null {
const pid = readPid();
if (pid === null) return null;
// Require both liveness AND identity — a live-but-reused PID is treated as
// stale so we never act on a process that isn't ours.
if (isProcessAlive(pid) && isDaemonProcess(pid)) return pid;
if (isProcessAlive(pid)) return pid;
// Stale PID file — process is dead or the PID now belongs to someone else.
// Stale PID file — process is dead
removePid();
removeStatus();
return null;
+1 -6
View File
@@ -4,17 +4,12 @@ import path from 'node:path';
export interface TaskEntry {
agentId?: string;
agentType: 'hermes' | 'openclaw';
agentType: string;
operationId: string;
pid: number;
startedAt: string;
taskId: string;
topicId: string;
/**
* Workspace that owns the dispatched topic. Persisted so the cancel-time
* notify still scopes to the right workspace after the daemon restarts.
*/
workspaceId?: string;
}
function getRegistryPath(): string {
+42 -2
View File
@@ -4,14 +4,24 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { spawnHeteroAgentRun } from './agentRun';
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
const { removeTaskMock, saveTaskMock, spawnMock } = vi.hoisted(() => ({
removeTaskMock: vi.fn(),
saveTaskMock: vi.fn(),
spawnMock: vi.fn(),
}));
vi.mock('node:child_process', () => ({ spawn: spawnMock }));
vi.mock('../daemon/taskRegistry', () => ({
removeTask: removeTaskMock,
saveTask: saveTaskMock,
}));
const makeFakeChild = () => {
const makeFakeChild = (pid = 1234) => {
const child = new EventEmitter() as EventEmitter & {
pid: number;
stdin: { end: ReturnType<typeof vi.fn>; write: ReturnType<typeof vi.fn> };
};
child.pid = pid;
child.stdin = { end: vi.fn(), write: vi.fn() };
return child;
};
@@ -27,6 +37,8 @@ const baseParams = {
describe('spawnHeteroAgentRun', () => {
afterEach(() => {
removeTaskMock.mockReset();
saveTaskMock.mockReset();
spawnMock.mockReset();
});
@@ -66,6 +78,7 @@ describe('spawnHeteroAgentRun', () => {
]);
expect(opts).toMatchObject({
cwd: '/work/dir',
detached: process.platform !== 'win32',
env: expect.objectContaining({
LOBEHUB_JWT: 'jwt-token',
LOBEHUB_SERVER: 'https://app.lobehub.com',
@@ -79,6 +92,15 @@ describe('spawnHeteroAgentRun', () => {
await expect(ackPromise).resolves.toEqual({ status: 'accepted' });
expect(child.stdin.write).toHaveBeenCalledWith(JSON.stringify('hi'));
expect(child.stdin.end).toHaveBeenCalledTimes(1);
expect(saveTaskMock).toHaveBeenCalledWith(
expect.objectContaining({
agentType: 'claudeCode',
operationId: 'op-1',
pid: 1234,
taskId: 'op-1',
topicId: 'tpc-1',
}),
);
});
it('rejects (no stuck run) when the child errors before spawning, e.g. bad cwd', async () => {
@@ -90,6 +112,24 @@ describe('spawnHeteroAgentRun', () => {
await expect(ackPromise).resolves.toEqual({ reason: 'spawn ENOENT', status: 'rejected' });
expect(child.stdin.write).not.toHaveBeenCalled();
expect(removeTaskMock).toHaveBeenCalledWith('op');
});
it('removes the registered task when the child exits', async () => {
const child = makeFakeChild(4321);
spawnMock.mockReturnValue(child);
const ackPromise = spawnHeteroAgentRun({
...baseParams,
operationId: 'op-exit',
topicId: 'tpc-exit',
});
child.emit('spawn');
await ackPromise;
child.emit('exit', 0, null);
expect(removeTaskMock).toHaveBeenCalledWith('op-exit');
});
it('appends --resume when resuming a session', () => {
+25
View File
@@ -5,6 +5,8 @@ import {
type HeteroExecImageRef,
} from '@lobechat/heterogeneous-agents/protocol';
import { removeTask, saveTask } from '../daemon/taskRegistry';
export interface SpawnHeteroAgentRunParams {
agentType: string;
cwd?: string;
@@ -101,6 +103,7 @@ export function spawnHeteroAgentRun(
const child = spawn(process.execPath, [...process.execArgv, ...cliArgs], {
cwd: workDir,
detached: process.platform !== 'win32',
env: {
...process.env,
LOBEHUB_JWT: jwt,
@@ -109,7 +112,27 @@ export function spawnHeteroAgentRun(
stdio: ['pipe', 'inherit', 'inherit'],
});
let taskSaved = false;
const saveRunningTask = () => {
if (taskSaved || child.pid === undefined) return;
taskSaved = true;
saveTask({
agentType,
operationId,
pid: child.pid,
startedAt: new Date().toISOString(),
taskId: operationId,
topicId,
});
};
saveRunningTask();
child.once('spawn', () => {
if (child.pid !== undefined) {
saveRunningTask();
}
// Only safe to write stdin once the process actually started.
try {
child.stdin?.write(stdinPayload);
@@ -123,11 +146,13 @@ export function spawnHeteroAgentRun(
});
child.once('error', (err) => {
removeTask(operationId);
logger?.error?.(`hetero exec spawn failed (op=${operationId}): ${err.message}`);
settle({ reason: err.message, status: 'rejected' });
});
child.on('exit', (code, signal) => {
removeTask(operationId);
logger?.info?.(`hetero exec exited (op=${operationId}) code=${code} signal=${signal}`);
});
});
-42
View File
@@ -38,45 +38,3 @@ export async function registerDevice(
platform: process.platform,
});
}
type Auth = { serverUrl: string; token: string; tokenType: 'apiKey' | 'jwt' | 'serviceToken' };
/**
* Identity for a WORKSPACE device: derived from the workspaceId (namespaced) so
* the same physical machine enrolled into a workspace is a distinct device from
* its personal identity, and stable across reconnects.
*/
export function resolveWorkspaceDeviceIdentity(
workspaceId: string,
explicitDeviceId?: string,
): DeviceIdentity {
if (explicitDeviceId) return { deviceId: explicitDeviceId, identitySource: 'fallback' };
return deriveDeviceId(`workspace:${workspaceId}`);
}
/**
* Mint a workspace-device connect token (owner-only on the server). The returned
* token carries the `workspace_id` claim the gateway routes by.
*/
export async function mintWorkspaceConnectToken(
auth: Auth,
workspaceId: string,
): Promise<{ token: string; workspaceId: string }> {
const trpc = createLambdaClient(auth, workspaceId);
return trpc.device.mintWorkspaceConnectToken.mutate();
}
/** Register this machine as a device of the given workspace (owner-only). */
export async function registerWorkspaceDevice(
auth: Auth,
identity: DeviceIdentity,
workspaceId: string,
): Promise<void> {
const trpc = createLambdaClient(auth, workspaceId);
await trpc.device.registerWorkspaceDevice.mutate({
deviceId: identity.deviceId,
hostname: os.hostname(),
identitySource: identity.identitySource,
platform: process.platform,
});
}
-16
View File
@@ -1,16 +0,0 @@
import { createRequire } from 'node:module';
/**
* Single source of truth for this package's own metadata.
*
* Must live directly under `src/` (depth 1), the same depth as the bundled
* entry `dist/index.js`, so `../package.json` resolves to `@lobehub/cli`'s own
* package.json both when running from source (`bun src/index.ts`) and from the
* tsdown bundle (`dist/index.js`). A module one directory deeper would resolve
* the path outside the package once everything is bundled into a single file.
*/
const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { name: string; version: string };
export const cliPackageName = pkg.name;
export const cliVersion = pkg.version;
+7 -5
View File
@@ -1,3 +1,5 @@
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
@@ -31,10 +33,11 @@ import { registerStatusCommand } from './commands/status';
import { registerTaskCommand } from './commands/task';
import { registerThreadCommand } from './commands/thread';
import { registerTopicCommand } from './commands/topic';
import { registerUpdateCommand } from './commands/update';
import { registerUserCommand } from './commands/user';
import { registerVerifyCommand } from './commands/verify';
import { cliVersion } from './pkg';
const require = createRequire(import.meta.url);
const { version } = require('../package.json');
export function createProgram() {
const program = new Command();
@@ -42,7 +45,7 @@ export function createProgram() {
program
.name('lh')
.description('LobeHub CLI - manage and connect to LobeHub services')
.version(cliVersion);
.version(version);
registerLoginCommand(program);
registerLogoutCommand(program);
@@ -77,9 +80,8 @@ export function createProgram() {
registerConfigCommand(program);
registerEvalCommand(program);
registerMigrateCommand(program);
registerUpdateCommand(program);
return program;
}
export { cliPackageName, cliVersion } from './pkg';
export { version as cliVersion };
+22 -49
View File
@@ -1,8 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getTrpcClient } from '../../api/client';
import { removeTask, saveTask } from '../../daemon/taskRegistry';
import { runHeteroTask } from '../heteroTask';
import { cancelHeteroTask, runHeteroTask } from '../heteroTask';
// ─── Mocks ───
@@ -35,8 +34,6 @@ vi.mock('../../api/client', () => ({
}),
}));
const getTrpcClientMock = vi.mocked(getTrpcClient);
vi.mock('../../utils/logger', () => ({
log: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
@@ -251,56 +248,32 @@ describe('runHeteroTask (openclaw)', () => {
expect(removeTask).toHaveBeenCalledWith('task-1');
killSpy.mockRestore();
});
});
it('threads workspaceId into the saved task entry and the spawned child env', async () => {
const child = makeMockChild(6666);
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentId: 'agent-ws',
agentType: 'openclaw',
operationId: 'op-ws',
prompt: 'workspace dispatch',
taskId: 'task-ws',
topicId: 'topic-ws',
workspaceId: 'ws-42',
});
expect(saveTask).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-42' }));
const [, , spawnOpts] = spawnMock.mock.calls[0] as [
string,
string[],
{ env: NodeJS.ProcessEnv },
];
expect(spawnOpts.env.LOBEHUB_WORKSPACE_ID).toBe('ws-42');
describe('cancelHeteroTask', () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(taskStore)) delete taskStore[key];
});
it('passes workspaceId to getTrpcClient when the close handler auto-notifies', async () => {
const child = makeMockChild(7777);
spawnMock.mockReturnValue(child);
afterEach(() => {
vi.restoreAllMocks();
});
await runHeteroTask({
agentId: 'agent-ws',
agentType: 'openclaw',
operationId: 'op-ws-2',
prompt: 'ws prompt',
taskId: 'task-ws-2',
topicId: 'topic-ws-2',
workspaceId: 'ws-99',
});
it('signals the process group for a registered codex task', async () => {
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
taskStore['op-codex'] = {
agentType: 'codex',
operationId: 'op-codex',
pid: 4321,
startedAt: '2026-01-01T00:00:00.000Z',
taskId: 'op-codex',
topicId: 'topic-1',
};
getTrpcClientMock.mockClear();
// Abnormal exit triggers sendAutoNotify + sendDoneSignal — both must scope
// to the dispatching workspace or agentNotify resolves the topic in
// personal mode and 404s.
child._emit('close', 1, null);
// Await microtask drain so the close-handler promise chain settles.
await new Promise((r) => setImmediate(r));
const result = await cancelHeteroTask({ taskId: 'op-codex' });
expect(getTrpcClientMock.mock.calls.length).toBeGreaterThan(0);
for (const call of getTrpcClientMock.mock.calls) {
expect(call[0]).toBe('ws-99');
}
expect(result).toBe(JSON.stringify({ pid: 4321, signal: 'SIGINT', taskId: 'op-codex' }));
expect(killSpy).toHaveBeenCalledWith(process.platform === 'win32' ? 4321 : -4321, 'SIGINT');
});
});
+31 -37
View File
@@ -57,13 +57,6 @@ export interface RunHeteroTaskParams {
prompt: string;
taskId: string;
topicId: string;
/**
* Workspace id seeded by the server when the dispatched topic lives in a
* workspace. Threaded into auto-notify calls (as `X-Workspace-Id`) and into
* the spawned child's `LOBEHUB_WORKSPACE_ID` env so its own `lh notify`
* shells inherit the same scope.
*/
workspaceId?: string;
}
export interface CancelHeteroTaskParams {
@@ -71,15 +64,27 @@ export interface CancelHeteroTaskParams {
taskId: string;
}
function signalTaskProcess(pid: number, signal: NodeJS.Signals): void {
if (process.platform === 'win32') {
process.kill(pid, signal);
return;
}
try {
process.kill(-pid, signal);
} catch {
process.kill(pid, signal);
}
}
async function sendAutoNotify(
topicId: string,
taskId: string,
text: string,
agentId?: string,
workspaceId?: string,
): Promise<void> {
try {
const client = await getTrpcClient(workspaceId);
const client = await getTrpcClient();
await client.agentNotify.notify.mutate({
agentId,
content: text,
@@ -98,13 +103,9 @@ async function sendAutoNotify(
* `sendAutoNotify` which writes an error message AND triggers completion via
* the `done` flag.
*/
async function sendDoneSignal(
topicId: string,
agentId?: string,
workspaceId?: string,
): Promise<void> {
async function sendDoneSignal(topicId: string, agentId?: string): Promise<void> {
try {
const client = await getTrpcClient(workspaceId);
const client = await getTrpcClient();
await client.agentNotify.notify.mutate({
agentId,
content: '',
@@ -150,15 +151,9 @@ function buildNotifyProtocol(lhPath: string, topicId: string): string {
}
export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string> {
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId, workspaceId } = params;
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
const workDir = cwd || process.cwd();
const lhPath = resolveLhPath();
// Propagate workspace scope into the spawned child so its own `lh notify`
// invocations (and any grandchildren it shells out) inherit the same scope
// via getTrpcClient → resolveWorkspaceId.
const childEnv: NodeJS.ProcessEnv = workspaceId
? { ...process.env, LOBEHUB_WORKSPACE_ID: workspaceId }
: { ...process.env };
if (agentType === 'openclaw') {
// openclaw agent --local is one-shot: each invocation processes one message and exits.
@@ -200,7 +195,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
{
cwd: workDir,
detached: true,
env: childEnv,
env: { ...process.env },
stdio: 'ignore',
},
);
@@ -219,7 +214,6 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
startedAt: new Date().toISOString(),
taskId,
topicId,
workspaceId,
});
log.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
@@ -235,12 +229,12 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
: `Task failed (exit code: ${code})`;
// Send error message first, THEN signal done (sequential).
// Fire-and-forget both, but ensure done is always sent even if notify fails.
void sendAutoNotify(topicId, taskId, text, agentId, workspaceId).finally(() =>
sendDoneSignal(topicId, agentId, workspaceId),
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
);
} else {
// Clean exit — openclaw already sent its final message; just signal done.
void sendDoneSignal(topicId, agentId, workspaceId);
void sendDoneSignal(topicId, agentId);
}
});
@@ -272,7 +266,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
const child = spawn('hermes', hermesArgs, {
cwd: workDir,
detached: true,
env: childEnv,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'ignore'],
});
@@ -288,7 +282,6 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
startedAt: new Date().toISOString(),
taskId,
topicId,
workspaceId,
});
log.info(`Hermes task started: taskId=${taskId} pid=${pid}`);
@@ -304,8 +297,8 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void sendAutoNotify(topicId, taskId, text, agentId, workspaceId).finally(() =>
sendDoneSignal(topicId, agentId, workspaceId),
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
);
return;
}
@@ -318,11 +311,11 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
if (sessionId) saveHermesSessionId(topicId, sessionId);
if (response) {
void sendAutoNotify(topicId, taskId, response, agentId, workspaceId).finally(() =>
sendDoneSignal(topicId, agentId, workspaceId),
void sendAutoNotify(topicId, taskId, response, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
);
} else {
void sendDoneSignal(topicId, agentId, workspaceId);
void sendDoneSignal(topicId, agentId);
}
});
@@ -340,9 +333,11 @@ export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
}
// Both openclaw and hermes: kill by PID and let the child's close handler send the notify.
// Signal the whole process group when available. Local CLI agent runs
// (claude-code / codex) can spawn their own tool subprocesses, so a
// parent-only signal is not enough.
try {
process.kill(entry.pid, signal);
signalTaskProcess(entry.pid, signal);
} catch (err) {
// Process already exited — exit handler won't fire; clean up manually.
log.warn(
@@ -354,7 +349,6 @@ export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<
taskId,
'Task already completed or cancelled',
entry.agentId,
entry.workspaceId,
);
}
-125
View File
@@ -1,125 +0,0 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { TrpcClient } from '../api/client';
/**
* Minimal extension → MIME map for files uploaded from the local filesystem.
* Unknown extensions fall back to `application/octet-stream`.
*/
const MIME_MAP: Record<string, string> = {
aac: 'audio/aac',
csv: 'text/csv',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
flac: 'audio/flac',
gif: 'image/gif',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
json: 'application/json',
m4a: 'audio/mp4',
md: 'text/markdown',
mp3: 'audio/mpeg',
mp4: 'video/mp4',
ogg: 'audio/ogg',
pdf: 'application/pdf',
png: 'image/png',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
svg: 'image/svg+xml',
txt: 'text/plain',
wav: 'audio/wav',
webm: 'audio/webm',
webp: 'image/webp',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
};
/**
* Detect a MIME type from a file name's extension.
*/
export const detectMimeType = (fileName: string): string => {
const ext = path.extname(fileName).toLowerCase().slice(1);
return MIME_MAP[ext] || 'application/octet-stream';
};
export interface UploadLocalFileOptions {
knowledgeBaseId?: string;
parentId?: string;
}
/**
* Read a file from the local filesystem, upload it to S3 via a pre-signed URL,
* and create the corresponding file record. Shared by `file upload` and
* `kb upload`.
*
* @returns the created file record
*/
export const uploadLocalFile = async (
client: TrpcClient,
filePath: string,
options: UploadLocalFileOptions = {},
) => {
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) {
throw new Error(`File not found: ${resolved}`);
}
const stat = fs.statSync(resolved);
if (!stat.isFile()) {
throw new Error(`Not a file: ${resolved}`);
}
const fileName = path.basename(resolved);
const fileBuffer = fs.readFileSync(resolved);
// Compute SHA-256 hash for deduplication
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
const ext = path.extname(fileName).toLowerCase().slice(1);
const fileType = detectMimeType(fileName);
const date = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
// 1. Dedup: if the same bytes are already stored (and the object still
// exists), skip the S3 upload entirely and reuse the existing url.
const existing = (await client.file.checkFileHash.mutate({ hash })) as {
isExist?: boolean;
url?: string;
};
let pathname: string;
if (existing?.isExist && existing.url) {
pathname = existing.url;
} else {
// 2. Get a pre-signed upload URL and PUT the bytes to S3
pathname = ext ? `files/${date}/${hash}.${ext}` : `files/${date}/${hash}`;
const presigned = await client.upload.createS3PreSignedUrl.mutate({ pathname });
const presignedUrl = typeof presigned === 'string' ? presigned : (presigned as any).url;
const uploadRes = await fetch(presignedUrl, {
body: fileBuffer,
headers: { 'Content-Type': fileType },
method: 'PUT',
});
if (!uploadRes.ok) {
throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`);
}
}
// 3. Create the file record
return await client.file.createFile.mutate({
fileType,
hash,
knowledgeBaseId: options.knowledgeBaseId,
metadata: {
date,
dirname: '',
filename: fileName,
path: pathname,
},
name: fileName,
parentId: options.parentId,
size: stat.size,
url: pathname,
});
};
+2 -3
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:*",
@@ -127,8 +126,8 @@
],
"overrides": {
"node-gyp": "^12.4.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"vitest": "3.2.6"
}
}
-7
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'
@@ -17,9 +16,3 @@ packages:
- './stubs/business-const'
- './stubs/types'
- '.'
allowBuilds:
electron: set this to true or false
electron-winstaller: set this to true or false
esbuild: set this to true or false
get-windows: set this to true or false
node-mac-permissions: set this to true or false
@@ -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.
@@ -77,12 +78,6 @@ interface PlatformTaskEntry {
operationId: string;
pid: number;
topicId: string;
/**
* Workspace that owns the dispatched topic used at exit time so the
* cleanup notify still scopes to the workspace agentNotify resolves the
* topic in (the server seeds this via the `runHeteroTask` args).
*/
workspaceId?: string;
}
/**
@@ -172,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);
}
@@ -350,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(
@@ -530,7 +591,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
prompt: string;
taskId: string;
topicId: string;
workspaceId?: string;
},
);
return { content: json, state: safeJsonParse(json), success: true };
@@ -772,9 +832,8 @@ export default class GatewayConnectionCtr extends ControllerModule {
prompt: string;
taskId: string;
topicId: string;
workspaceId?: string;
}): Promise<string> {
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId, workspaceId } = args;
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = args;
const workDir = cwd || process.cwd();
const [serverUrl, accessToken] = await Promise.all([
@@ -782,15 +841,11 @@ export default class GatewayConnectionCtr extends ControllerModule {
this.remoteServerConfigCtr.getAccessToken(),
]);
// Inject auth + workspace scope into child env so `lh notify` can
// authenticate AND target the same workspace as the dispatched topic
// (without LOBEHUB_WORKSPACE_ID, the CLI's notify falls back to personal
// mode and the workspace topic 404s).
// Inject auth into child env so `lh notify` can authenticate without CLI config.
const childEnv: NodeJS.ProcessEnv = {
...process.env,
...(accessToken && { LOBEHUB_JWT: accessToken }),
...(serverUrl && { LOBEHUB_SERVER: serverUrl }),
...(workspaceId && { LOBEHUB_WORKSPACE_ID: workspaceId }),
};
if (agentType === 'openclaw') {
@@ -835,14 +890,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
if (pid === undefined) throw new Error('Failed to get PID for openclaw process');
child.unref();
this.platformTasks.set(taskId, {
agentId,
agentType,
operationId,
pid,
topicId,
workspaceId,
});
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
child.on('close', (code, signal) => {
this.platformTasks.delete(taskId);
@@ -850,31 +898,11 @@ export default class GatewayConnectionCtr extends ControllerModule {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void this.sendNotify({
agentId,
content: text,
role: 'assistant',
topicId,
workspaceId,
}).finally(() =>
this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
}),
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
);
} else {
void this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
});
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
}
});
@@ -913,14 +941,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
child.unref();
this.platformTasks.set(taskId, {
agentId,
agentType,
operationId,
pid,
topicId,
workspaceId,
});
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
@@ -934,21 +955,8 @@ export default class GatewayConnectionCtr extends ControllerModule {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void this.sendNotify({
agentId,
content: text,
role: 'assistant',
topicId,
workspaceId,
}).finally(() =>
this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
}),
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
);
return;
}
@@ -961,31 +969,11 @@ export default class GatewayConnectionCtr extends ControllerModule {
if (sessionId) this.hermesSessionMap.set(topicId, sessionId);
if (response) {
void this.sendNotify({
agentId,
content: response,
role: 'assistant',
topicId,
workspaceId,
}).finally(() =>
this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
}),
void this.sendNotify({ agentId, content: response, role: 'assistant', topicId }).finally(
() => this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
);
} else {
void this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
});
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
}
});
@@ -1013,7 +1001,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
content: 'Task already completed or cancelled',
role: 'assistant',
topicId: entry.topicId,
workspaceId: entry.workspaceId,
});
}
@@ -1031,12 +1018,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
done?: boolean;
role: string;
topicId: string;
/**
* Workspace scope for the notify. When set, attaches `X-Workspace-Id` so
* agentNotify resolves the workspace-owned topic instead of falling back
* to personal mode (which would 404 the lookup).
*/
workspaceId?: string;
}): Promise<void> {
try {
const [serverUrl, token] = await Promise.all([
@@ -1045,16 +1026,12 @@ export default class GatewayConnectionCtr extends ControllerModule {
]);
if (!serverUrl || !token) return;
const { workspaceId, ...body } = params;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Oidc-Auth': token,
};
if (workspaceId) headers['X-Workspace-Id'] = workspaceId;
await fetch(`${serverUrl}/trpc/lambda/agentNotify.notify`, {
body: JSON.stringify({ json: body }),
headers,
body: JSON.stringify({ json: params }),
headers: {
'Content-Type': 'application/json',
'Oidc-Auth': token,
},
method: 'POST',
});
} catch {
File diff suppressed because it is too large Load Diff
@@ -366,14 +366,14 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async readFiles({ paths, cwd }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
logger.debug('Starting batch file reading:', { count: paths.length });
const results: LocalReadFileResult[] = [];
for (const filePath of paths) {
logger.debug('Reading single file:', { filePath });
const result = await readLocalFile({ cwd, path: filePath });
const result = await readLocalFile({ path: filePath });
results.push(result);
}
@@ -400,9 +400,9 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async handleMoveFiles({ items, cwd }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
logger.debug('Starting batch file move:', { itemsCount: items?.length });
return moveLocalFiles({ cwd, items });
return moveLocalFiles({ items });
}
@IpcMethod()
@@ -418,9 +418,9 @@ export default class LocalFileCtr extends ControllerModule {
}
@IpcMethod()
async handleWriteFile({ path: filePath, content, cwd }: WriteLocalFileParams) {
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
logger.debug(`Writing file ${filePath}`, { contentLength: content?.length });
return writeLocalFile({ content, cwd, path: filePath });
return writeLocalFile({ content, path: filePath });
}
@IpcMethod()
@@ -438,14 +438,12 @@ 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,
});
@@ -464,14 +462,12 @@ 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);
@@ -226,7 +226,6 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
accept: undefined,
allowExternalFile: undefined,
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
@@ -263,7 +262,6 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
accept: 'image',
allowExternalFile: undefined,
filePath: '/workspace/image.png',
workspaceRoot: '/workspace',
});
@@ -272,29 +270,6 @@ describe('LocalFileCtr', () => {
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', () => {
@@ -312,7 +287,6 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
accept: undefined,
allowExternalFile: undefined,
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
@@ -355,7 +329,6 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
accept: 'image',
allowExternalFile: undefined,
filePath: '/workspace/image.png',
workspaceRoot: '/workspace',
});
@@ -368,35 +341,6 @@ describe('LocalFileCtr', () => {
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);
@@ -60,7 +59,10 @@ type PreviewFileAccept = 'image';
const normalizeContentType = (contentType: string): string =>
contentType.split(';')[0].trim().toLowerCase();
const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileAccept): boolean => {
const isAcceptedPreviewContentType = (
contentType: string,
accept?: PreviewFileAccept,
): boolean => {
if (!accept) return true;
const normalizedContentType = normalizeContentType(contentType);
@@ -82,8 +84,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;
@@ -229,12 +229,10 @@ export class LocalFileProtocolManager {
async createPreviewUrl({
accept,
allowExternalFile,
filePath,
workspaceRoot,
}: {
accept?: PreviewFileAccept;
allowExternalFile?: boolean;
filePath: string;
workspaceRoot: string;
}): Promise<string | null> {
@@ -245,12 +243,11 @@ export class LocalFileProtocolManager {
? (
await this.readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot,
})
)?.realPath
: await this.resolveApprovedPreviewPath({ allowExternalFile, filePath, workspaceRoot });
: await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
if (!realFilePath) return null;
this.cleanupExpiredTokens();
@@ -266,21 +263,14 @@ 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);
@@ -290,10 +280,6 @@ export class LocalFileProtocolManager {
const contentType = resolveLocalFileMimeType(realFilePath, buffer);
if (!isAcceptedPreviewContentType(contentType, accept)) return null;
if (allowExternalFile) {
this.grantExternalPreviewApproval(realFilePath);
}
return {
buffer,
contentType,
@@ -341,14 +327,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 +345,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 +365,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 +383,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;
}
}
@@ -263,31 +263,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');
@@ -351,26 +326,6 @@ describe('LocalFileProtocolManager', () => {
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,5 +1,5 @@
// apps/desktop/src/main/menus/impl/BaseMenuPlatform.ts
import type { BaseWindow, MenuItemConstructorOptions } from 'electron';
import type { MenuItemConstructorOptions } from 'electron';
import { BrowserWindow } from 'electron';
import type { App } from '@/core/App';
@@ -34,26 +34,6 @@ export abstract class BaseMenuPlatform {
];
}
protected closeFocusedTabOrWindow(targetWindow?: BaseWindow | null): void {
const focused =
targetWindow && 'webContents' in targetWindow
? (targetWindow as BrowserWindow)
: BrowserWindow.getFocusedWindow();
if (!focused) return;
if (focused.webContents.isDevToolsOpened()) {
focused.webContents.closeDevTools();
return;
}
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
}
private buildZoomMenuItemOption(
action: ZoomAction,
label: string,
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, Menu, shell } from 'electron';
import { app, dialog, Menu, shell } from 'electron';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
@@ -7,9 +7,6 @@ import { LinuxMenu } from './linux';
// Mock Electron modules
vi.mock('electron', () => ({
BrowserWindow: class BrowserWindow {
static getFocusedWindow = vi.fn();
},
Menu: {
buildFromTemplate: vi.fn((template) => ({ template })),
setApplicationMenu: vi.fn(),
@@ -342,100 +339,6 @@ describe('LinuxMenu', () => {
expect(closeItem.role).toBeUndefined();
});
it('should close open DevTools before delegating CmdOrCtrl+W to renderer window logic', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => true),
},
};
closeItem.click(undefined, focusedWindow);
expect(focusedWindow.webContents.closeDevTools).toHaveBeenCalled();
expect(focusedWindow.close).not.toHaveBeenCalled();
expect(mockApp.browserManager.getMainWindow).not.toHaveBeenCalled();
});
it('should broadcast tab close when CmdOrCtrl+W targets the main window', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const mainBrowserWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => false),
},
};
const broadcast = vi.fn();
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue({
broadcast,
browserWindow: mainBrowserWindow,
} as any);
closeItem.click(undefined, mainBrowserWindow);
expect(broadcast).toHaveBeenCalledWith('closeCurrentTabOrWindow');
expect(mainBrowserWindow.close).not.toHaveBeenCalled();
});
it('should close non-main windows when CmdOrCtrl+W has no DevTools panel to close', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const mainBrowserWindow = {
webContents: {
isDevToolsOpened: vi.fn(() => false),
},
};
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => false),
},
};
vi.mocked(mockApp.browserManager.getMainWindow).mockReturnValue({
broadcast: vi.fn(),
browserWindow: mainBrowserWindow,
} as any);
closeItem.click(undefined, focusedWindow);
expect(focusedWindow.close).toHaveBeenCalled();
});
it('should use the focused window when Electron does not pass a menu target window', () => {
linuxMenu.buildAndSetAppMenu();
const template = (Menu.buildFromTemplate as any).mock.calls[0][0];
const fileMenu = template.find((item: any) => item.label === 'File');
const closeItem = fileMenu.submenu.find((item: any) => item.label === 'Close');
const focusedWindow = {
close: vi.fn(),
webContents: {
closeDevTools: vi.fn(),
isDevToolsOpened: vi.fn(() => true),
},
};
vi.mocked(BrowserWindow.getFocusedWindow).mockReturnValue(focusedWindow as any);
closeItem.click();
expect(focusedWindow.webContents.closeDevTools).toHaveBeenCalled();
});
it('should use role for minimize (accelerator handled by Electron)', () => {
linuxMenu.buildAndSetAppMenu();
+11 -2
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, clipboard, dialog, Menu, shell } from 'electron';
import { app, BrowserWindow, clipboard, dialog, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -122,7 +122,16 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform {
{ type: 'separator' },
{
accelerator: 'CmdOrCtrl+W',
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
label: t('window.close'),
},
{ label: t('window.minimize'), role: 'minimize' },
+11 -2
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, clipboard, Menu, shell } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -164,7 +164,16 @@ export class MacOSMenu extends BaseMenuPlatform implements IMenuPlatform {
{ type: 'separator' },
{
accelerator: 'CmdOrCtrl+W',
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
label: t('window.close'),
},
],
+11 -2
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import type { MenuItemConstructorOptions } from 'electron';
import { app, clipboard, Menu, shell } from 'electron';
import { app, BrowserWindow, clipboard, Menu, shell } from 'electron';
import { isDev } from '@/const/env';
import { HETERO_AGENT_DIR } from '@/const/heteroAgent';
@@ -185,7 +185,16 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform {
{ label: t('window.minimize'), role: 'minimize' },
{
accelerator: 'CmdOrCtrl+W',
click: (_item, targetWindow) => this.closeFocusedTabOrWindow(targetWindow),
click: () => {
const focused = BrowserWindow.getFocusedWindow();
if (!focused) return;
const mainWindow = this.app.browserManager.getMainWindow();
if (focused === mainWindow.browserWindow) {
mainWindow.broadcast('closeCurrentTabOrWindow');
} else {
focused.close();
}
},
label: t('window.close'),
},
],
@@ -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,
@@ -72,9 +67,8 @@ import {
} from '@lobechat/types';
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
import debug from 'debug';
import { type ExtendParamsType, ModelProvider } 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 +80,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 +89,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,
@@ -416,147 +405,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;
@@ -674,12 +522,6 @@ export interface RuntimeExecutorContext {
botPlatformContext?: BotPlatformContext;
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.
* Injected by AiAgentService so exec_sub_agent / exec_sub_agents executors
@@ -879,7 +721,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,39 +736,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),
);
const canonicalModelCard = builtinModels.find(
(item) => item.id === model || item.config?.deploymentName === model,
);
const modelKnowledgeCutoff =
modelCard?.knowledgeCutoff ??
(provider === ModelProvider.LobeHub ? canonicalModelCard?.knowledgeCutoff : undefined);
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) {
modelExtendParams = readExtendParams(canonicalModelCard);
}
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');
@@ -942,19 +763,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[]);
@@ -1191,38 +999,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,
);
}
@@ -1246,18 +1055,12 @@ 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,
},
userTimezone: ctx.userTimezone,
capabilities: {
isCanUseAudio: (m: string, p: string) => {
const info =
builtinModels.find((item) => item.id === m && item.providerId === p) ??
builtinModels.find((item) => item.id === m);
return info?.abilities?.audio ?? false;
},
isCanUseFC: (m: string, p: string) => {
const info = builtinModels.find((item) => item.id === m && item.providerId === p);
return info?.abilities?.functionCall ?? true;
@@ -1303,7 +1106,6 @@ export const createRuntimeExecutors = (
},
messages: messagesForContext,
model,
modelKnowledgeCutoff,
provider,
systemRole: agentConfig.systemRole ?? undefined,
toolDiscoveryConfig,
@@ -1403,9 +1205,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,
}),
@@ -2647,7 +2446,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;
}
@@ -2664,14 +2463,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,
@@ -2704,10 +2496,6 @@ export const createRuntimeExecutors = (
toolResultMaxLength,
topicId: ctx.topicId,
userId: ctx.userId,
// Device-bound cwd folded into deviceSystemInfo at operation
// creation; resume-safe via computeDeviceContext (recovers it
// from the prior tool message's pluginState.metadata).
workingDirectory: state.metadata?.deviceSystemInfo?.workingDirectory,
workspaceId: state.metadata?.workspaceId ?? ctx.workspaceId,
}),
{
@@ -3238,7 +3026,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];
@@ -3257,12 +3045,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,
@@ -14,7 +14,6 @@ const mockBuiltinModels = vi.hoisted(() => [
{
abilities: { functionCall: true, video: false, vision: true },
id: 'gpt-4',
knowledgeCutoff: '2024-06',
providerId: 'openai',
},
{
@@ -59,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
@@ -78,16 +74,13 @@ vi.mock('@/business/client/model-bank/loadModels', () => ({
// model-bank is a TypeScript source file that cannot be dynamically imported in vitest
vi.mock('model-bank', () => ({
LOBE_DEFAULT_MODEL_LIST: mockBuiltinModels,
ModelProvider: {
LobeHub: 'lobehub',
},
}));
// 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
@@ -132,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' }),
@@ -1579,87 +1571,6 @@ describe('RuntimeExecutors', () => {
);
});
it('should pass model knowledge cutoff into serverMessagesEngine', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
const instruction = {
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'openai',
},
type: 'call_llm' as const,
};
await executors.call_llm!(instruction, state);
expect(engineSpy).toHaveBeenCalledWith(
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
);
});
it('should resolve LobeHub routed model knowledge cutoff by model id fallback', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
await executors.call_llm!(
{
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'lobehub',
},
type: 'call_llm' as const,
},
state,
);
expect(engineSpy).toHaveBeenCalledWith(
expect.objectContaining({ modelKnowledgeCutoff: '2024-06' }),
);
});
it('should omit model knowledge cutoff for unknown non-LobeHub providers', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
agentConfig: {
plugins: [],
systemRole: 'You are a helpful assistant',
},
};
const executors = createRuntimeExecutors(ctxWithConfig);
const state = createMockState();
await executors.call_llm!(
{
payload: {
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-4',
provider: 'custom-openai',
},
type: 'call_llm' as const,
},
state,
);
expect(engineSpy.mock.calls[0][0]).toHaveProperty('modelKnowledgeCutoff', undefined);
});
it('should keep current turn when agent historyCount is 0', async () => {
const ctxWithConfig: RuntimeExecutorContext = {
...ctx,
@@ -4939,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',
@@ -4982,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',
};
@@ -5113,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',
@@ -5123,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',
@@ -5137,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',
};
@@ -5173,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 () => {
@@ -1,4 +1,3 @@
import { AgentRuntimeErrorType } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { formatErrorEventData } from '../formatErrorEventData';
@@ -63,75 +62,6 @@ describe('formatErrorEventData', () => {
});
describe('business-typed errors (must not be overridden)', () => {
it('preserves traceable runtime payload body for gateway error events', () => {
const out = formatErrorEventData(
{
error: { message: 'Upstream failed', traceId: 'trace-123' },
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: 'openai',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Upstream failed',
provider: 'openai',
traceId: 'trace-123',
},
error: 'Upstream failed',
errorType: AgentRuntimeErrorType.ProviderBizError,
phase: 'llm_execution',
});
});
it('uses the normalized runtime type for gateway error events', () => {
const out = formatErrorEventData(
{
error: { message: 'Payment required', status: 402, traceId: 'trace-402' },
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: 'lobehub',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Payment required',
provider: 'lobehub',
status: 402,
traceId: 'trace-402',
},
error: 'Payment required',
errorType: AgentRuntimeErrorType.InsufficientQuota,
phase: 'llm_execution',
});
});
it('uses the normalized runtime message when the payload message is a placeholder', () => {
const out = formatErrorEventData(
{
error: { message: 'Payment required', status: 402, traceId: 'trace-402' },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'error',
provider: 'lobehub',
},
'llm_execution',
);
expect(out).toMatchObject({
body: {
message: 'Payment required',
provider: 'lobehub',
status: 402,
traceId: 'trace-402',
},
error: 'Payment required',
errorType: AgentRuntimeErrorType.InsufficientQuota,
phase: 'llm_execution',
});
});
it('preserves ConversationParentMissing errorType and message even when .cause has PG info', () => {
// Mirrors createConversationParentMissingError from messagePersistErrors.ts:
// the user-facing errorType lives on the error object directly, and the
@@ -1,11 +1,5 @@
import { pickNonEmptyString, toRecord } from '@lobechat/utils/object';
import { formatErrorForState } from './formatErrorForState';
import { formatPgError, pgErrorType, unwrapPgError } from './pgError';
const isErrorType = (value: unknown): value is string | number =>
typeof value === 'string' || typeof value === 'number';
/**
* Normalize an arbitrary thrown value into the shape the runtime stream-event
* protocol expects. Extracts a human-readable `error` string and a best-effort
@@ -29,38 +23,55 @@ const isErrorType = (value: unknown): value is string | number =>
* DB failures by SQLSTATE.
*/
export const formatErrorEventData = (error: unknown, phase: string) => {
const payload = toRecord(error);
const rawPayloadErrorType = payload?.errorType ?? payload?.type;
const payloadErrorType = isErrorType(rawPayloadErrorType) ? rawPayloadErrorType : undefined;
const structuredError =
error instanceof Error || payloadErrorType === undefined
? undefined
: formatErrorForState(payload);
const body = structuredError?.body;
const hasPayloadErrorType = payloadErrorType !== undefined;
let errorType = hasPayloadErrorType
? String(structuredError?.type ?? payloadErrorType)
: undefined;
const payloadError = payload?.error;
let errorMessage =
pickNonEmptyString(structuredError?.message) ??
pickNonEmptyString(payload?.message) ??
pickNonEmptyString(payloadError) ??
pickNonEmptyString(toRecord(payloadError)?.message) ??
(error instanceof Error ? pickNonEmptyString(error.message) : pickNonEmptyString(error)) ??
errorType ??
'Unknown error';
let errorMessage = 'Unknown error';
let errorType: string | undefined;
// True when `errorType` came from a business-typed field on the error
// payload (step 1 above). Driver class names assigned via `error.name`
// do NOT set this flag, so raw `PostgresError` / `DatabaseError` instances
// still fall through to the PG unwrap step.
let hasBusinessErrorType = false;
if (error && typeof error === 'object') {
const payload = error as { error?: unknown; errorType?: unknown; message?: unknown };
if (typeof payload.errorType === 'string') {
errorType = payload.errorType;
hasBusinessErrorType = true;
}
if (typeof payload.message === 'string' && payload.message.length > 0) {
errorMessage = payload.message;
} else if (typeof payload.error === 'string' && payload.error.length > 0) {
errorMessage = payload.error;
} else if (
payload.error &&
typeof payload.error === 'object' &&
'message' in payload.error &&
typeof payload.error.message === 'string'
) {
errorMessage = payload.error.message;
} else if (error instanceof Error && error.message.length > 0) {
errorMessage = error.message;
} else if (errorType) {
errorMessage = errorType;
}
} else if (error instanceof Error && error.message.length > 0) {
errorMessage = error.message;
errorType = error.name;
} else if (typeof error === 'string' && error.length > 0) {
errorMessage = error;
}
if (!errorType && error instanceof Error && error.name) {
errorType = error.name;
}
// Enrichment: run PG unwrap whenever no payload errorType was
// Enrichment: run PG unwrap whenever no *business-typed* errorType was
// declared. This covers both Drizzle-wrapped errors (PG info under .cause)
// AND raw top-level driver errors like `PostgresError` / `DatabaseError`
// which carry a specific `name` but are still real PG errors deserving
// `pg_<sqlstate>` classification on the dashboard.
if (!hasPayloadErrorType) {
if (!hasBusinessErrorType) {
const pg = unwrapPgError(error);
if (pg) {
errorMessage = formatPgError(pg);
@@ -69,7 +80,6 @@ export const formatErrorEventData = (error: unknown, phase: string) => {
}
return {
...(body === undefined ? {} : { body }),
error: errorMessage,
errorType,
phase,
@@ -16,35 +16,7 @@ describe('formatErrorForState', () => {
expect(result.type).toBe(AgentRuntimeErrorType.InvalidProviderAPIKey);
expect(result.message).toBe('Invalid API key');
expect(result.body).toEqual({
detail: 'Unauthorized',
message: 'Invalid API key',
provider: 'openai',
});
});
it('preserves top-level context from ChatCompletionErrorPayload', () => {
const budget = { required: 12 };
const result = formatErrorForState({
budget,
error: { message: 'Budget exceeded' },
errorType: ChatErrorType.FreePlanLimit,
provider: 'lobehub',
});
expect(result).toMatchObject({
attribution: 'user',
body: {
budget,
message: 'Budget exceeded',
provider: 'lobehub',
},
category: 'quota',
httpStatus: 402,
message: 'Budget exceeded',
type: ChatErrorType.FreePlanLimit,
});
expect(result.body).toEqual({ detail: 'Unauthorized' });
});
it('wraps standard Error as InternalServerError', () => {
@@ -160,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', () => {
@@ -208,43 +172,6 @@ describe('formatErrorForState', () => {
expect(result.category).toBe('quota');
});
it('keeps payload.error available when _responseBody is present', () => {
const result = formatErrorForState({
_responseBody: { provider: 'lobehub' },
error: { status: 402 },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'opaque upstream message',
});
expect(result).toMatchObject({
body: {
error: { status: 402 },
message: 'opaque upstream message',
provider: 'lobehub',
},
category: 'quota',
type: AgentRuntimeErrorType.InsufficientQuota,
});
});
it('merges payload status into an existing _responseBody error object', () => {
const result = formatErrorForState({
_responseBody: { error: { message: 'Payment required' }, provider: 'lobehub' },
error: { status: 402 },
errorType: AgentRuntimeErrorType.ProviderBizError,
message: 'opaque upstream message',
});
expect(result).toMatchObject({
body: {
error: { message: 'Payment required', status: 402 },
provider: 'lobehub',
},
category: 'quota',
type: AgentRuntimeErrorType.InsufficientQuota,
});
});
it('keeps a genuine residual as ProviderBizError (E8002)', () => {
const result = formatErrorForState({
errorType: AgentRuntimeErrorType.ProviderBizError,
@@ -1,6 +1,5 @@
import { getErrorCodeSpec, refineErrorCode } from '@lobechat/model-runtime';
import { AgentRuntimeErrorType, ChatErrorType, type ChatMessageError } from '@lobechat/types';
import { isRecord } from '@lobechat/utils';
/** Pull a usable HTTP status out of the nested upstream error object. */
const extractHttpStatus = (body: unknown): number | undefined => {
@@ -20,80 +19,6 @@ const extractProvider = (body: unknown): string | undefined => {
return typeof p === 'string' ? p : undefined;
};
const extractMessage = (value: unknown): string | undefined => {
if (!isRecord(value)) return undefined;
const message = value.message;
if (typeof message === 'string' && message) return message;
const nestedError = value.error;
if (isRecord(nestedError)) {
const nestedMessage = nestedError.message;
if (typeof nestedMessage === 'string' && nestedMessage) return nestedMessage;
}
};
interface ChatCompletionErrorPayloadLike {
_responseBody?: unknown;
budget?: unknown;
error?: unknown;
errorType: ChatMessageError['type'];
message?: string;
provider?: unknown;
}
const mergePayloadError = (
sourceBody: Record<string, unknown>,
payload: ChatCompletionErrorPayloadLike,
): unknown | undefined => {
if (payload._responseBody === undefined || payload.error === undefined) return undefined;
if (!('error' in sourceBody)) return payload.error;
if (isRecord(sourceBody.error) && isRecord(payload.error)) {
return { ...payload.error, ...sourceBody.error };
}
};
const buildPayloadBody = (
payload: ChatCompletionErrorPayloadLike,
originalError: unknown,
message: string,
): unknown => {
// Runtime payloads often keep UI context (for example quota hints) next to
// `error`, while `error` itself only carries the display message. Merge both
// layers so normalizing `{ errorType, error }` does not drop the fields the
// chat error renderer needs later.
const sourceBody = payload._responseBody ?? payload.error ?? originalError;
const context: Record<string, unknown> = {};
if (payload.budget !== undefined) context.budget = payload.budget;
if (typeof payload.provider === 'string') context.provider = payload.provider;
if (isRecord(sourceBody)) {
const payloadError = mergePayloadError(sourceBody, payload);
return {
...sourceBody,
// `_responseBody` is the display-facing body, but gateway/model-runtime
// still carries status/provider details in `error` for some failures:
// `{ _responseBody: { error: { message } }, error: { status: 402 } }`.
...(payloadError === undefined ? {} : { error: payloadError }),
...(payload.budget !== undefined && !('budget' in sourceBody)
? { budget: payload.budget }
: {}),
...(typeof payload.provider === 'string' && !('provider' in sourceBody)
? { provider: payload.provider }
: {}),
...('message' in sourceBody ? {} : { message }),
};
}
return {
...context,
...(sourceBody === undefined ? {} : { error: sourceBody }),
message,
};
};
/**
* Merge classification metadata from `ERROR_CODE_SPECS` onto a normalized
* `ChatMessageError`. Codes that aren't in the spec table (fallbacks like
@@ -154,16 +79,14 @@ const enrichWithSpec = (formatted: ChatMessageError): ChatMessageError => {
*/
export const formatErrorForState = (error: unknown): ChatMessageError => {
if (error && typeof error === 'object' && 'errorType' in error) {
const payload = error as ChatCompletionErrorPayloadLike;
const message =
(payload.message && payload.message !== 'error' ? payload.message : undefined) ??
extractMessage(payload._responseBody) ??
extractMessage(payload.error) ??
String(payload.errorType);
const payload = error as {
error?: unknown;
errorType: ChatMessageError['type'];
message?: string;
};
return enrichWithSpec({
body: buildPayloadBody(payload, error, message),
message,
body: payload.error || error,
message: payload.message || String(payload.errorType),
type: payload.errorType,
});
}
@@ -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
@@ -9,6 +9,7 @@
* - Gets model capabilities from provided function
* - No dependency on frontend stores (useToolStore, useAgentStore, etc.)
*/
import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents';
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
import { KnowledgeBaseManifest } from '@lobechat/builtin-tool-knowledge-base';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
@@ -27,11 +28,7 @@ import { ToolsEngine } from '@lobechat/context-engine';
import { type RuntimeEnvMode, type RuntimePlatform } from '@lobechat/types';
import debug from 'debug';
import {
executionTargetToRuntimeMode,
resolveExecutionTarget,
resolveToolMode,
} from '@/helpers/executionTarget';
import { executionTargetToRuntimeMode, resolveExecutionTarget } from '@/helpers/executionTarget';
import {
buildAllowedBuiltinTools,
DEVICE_TOOL_IDENTIFIERS,
@@ -89,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];
@@ -134,6 +131,7 @@ export const createServerAgentToolsEngine = (
disableLocalSystem = false,
executionPlan,
globalMemoryEnabled = false,
hasAgentDocuments = false,
hasEnabledKnowledgeBases = false,
isBotConversation = false,
model,
@@ -159,7 +157,7 @@ export const createServerAgentToolsEngine = (
const executionTarget =
executionPlan?.target ??
resolveExecutionTarget(agentConfig.agencyConfig, {
clientExecutionAvailable: platform === 'desktop',
isDesktop: platform === 'desktop',
});
const runtimeMode: RuntimeEnvMode = executionTargetToRuntimeMode(executionTarget);
// Device tools (local-system, remote-device proxy) only exist for
@@ -172,7 +170,9 @@ export const createServerAgentToolsEngine = (
const isSearchEnabled = searchMode !== 'off';
// Tool mode: explicit `toolMode` wins; otherwise derive from `enableAgentMode`
// (undefined = agent). `custom` = toolset is exactly the agent's plugins.
const toolMode = resolveToolMode(agentConfig.chatConfig ?? undefined);
const toolMode: 'agent' | 'chat' | 'custom' =
agentConfig.chatConfig?.toolMode ??
(agentConfig.chatConfig?.enableAgentMode === false ? 'chat' : 'agent');
const isChatMode = toolMode === 'chat';
const isCustomMode = toolMode === 'custom';
@@ -231,20 +231,13 @@ 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,
};
@@ -263,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 .
*/
@@ -101,6 +101,8 @@ export interface ServerCreateAgentToolsEngineParams {
executionPlan?: ExecutionPlan;
/** Whether the user's global memory setting is enabled */
globalMemoryEnabled?: boolean;
/** Whether agent has agent documents */
hasAgentDocuments?: boolean;
/** Whether agent has enabled knowledge bases */
hasEnabledKnowledgeBases?: boolean;
/** Whether the request originates from a bot conversation (auto-enables message tool) */
@@ -70,25 +70,6 @@ describe('serverMessagesEngine', () => {
expect(result[0].content).toBe(systemRole + '\n\n' + getCurrentDateContent());
});
it('should inject model knowledge cutoff when provided', async () => {
const messages = createBasicMessages();
const result = await serverMessagesEngine({
messages,
model: 'gpt-4',
modelKnowledgeCutoff: '2024-06',
provider: 'openai',
systemRole: 'You are a helpful assistant',
});
expect(result[0].role).toBe('system');
expect(result[0].content).toBe(
'You are a helpful assistant\n\n' +
getCurrentDateContent() +
'\n\nModel knowledge cutoff: 2024-06',
);
});
it('should handle empty messages', async () => {
const result = await serverMessagesEngine({
messages: [],
@@ -51,7 +51,6 @@ const createServerVariableGenerators = (params: {
export const serverMessagesEngine = async ({
messages = [],
model,
modelKnowledgeCutoff,
provider,
systemRole,
inputTemplate,
@@ -84,7 +83,6 @@ export const serverMessagesEngine = async ({
const engine = new MessagesEngine({
// Capability injection
capabilities: {
isCanUseAudio: capabilities?.isCanUseAudio,
isCanUseFC: capabilities?.isCanUseFC,
isCanUseVideo: capabilities?.isCanUseVideo,
isCanUseVision: capabilities?.isCanUseVision,
@@ -122,7 +120,6 @@ export const serverMessagesEngine = async ({
// Model info
model,
modelKnowledgeCutoff,
provider,
systemRole,
@@ -23,8 +23,6 @@ import type { RuntimeInitialContext, UIChatMessage } from '@lobechat/types';
* Model capability checker functions for server-side
*/
export interface ServerModelCapabilities {
/** Check if audio input is supported */
isCanUseAudio?: (model: string, provider: string) => boolean;
/** Check if function calling is supported */
isCanUseFC?: (model: string, provider: string) => boolean;
/** Check if video is supported */
@@ -132,8 +130,6 @@ export interface ServerMessagesEngineParams {
/** Model ID */
model: string;
/** Model knowledge cutoff date, e.g. `2024-06`. Omit when unknown. */
modelKnowledgeCutoff?: string;
/** Page content context (optional, for document editing) */
pageContentContext?: PageContentContext;
-1
View File
@@ -279,7 +279,6 @@ export const imageRouter = router({
modelUsage,
provider,
userId: ctx.userId,
workspaceId,
});
}
+8 -8
View File
@@ -20,8 +20,8 @@ import { GenerationModel } from '@/database/models/generation';
import { asyncAuthedProcedure, asyncRouter as router } from '@/libs/trpc/async';
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
import { VideoGenerationService } from '@/server/services/generation/video';
import { buildVideoGenerationFilePayload } from '@/server/services/generation/videoFile';
import { FileSource } from '@/types/files';
import { sanitizeFileName } from '@/utils/sanitizeFileName';
const log = debug('lobe-video:async');
@@ -196,11 +196,13 @@ export const videoRouter = router({
url: processResult.videoKey,
width: processResult.width,
},
buildVideoGenerationFilePayload({
generationId,
processResult,
prompt: batch?.prompt,
}),
{
fileHash: processResult.fileHash,
fileType: processResult.mimeType,
name: `${sanitizeFileName(batch?.prompt ?? '', generationId)}.mp4`,
size: processResult.fileSize,
url: processResult.videoKey,
},
FileSource.VideoGeneration,
);
@@ -237,7 +239,6 @@ export const videoRouter = router({
provider,
usage: undefined,
userId: ctx.userId,
workspaceId,
});
log('Charge completed successfully for asyncTask: %s', asyncTaskId);
} catch (chargeError) {
@@ -313,7 +314,6 @@ export const videoRouter = router({
prechargeResult,
provider,
userId: ctx.userId,
workspaceId,
});
log('Precharge refunded successfully for asyncTask: %s', asyncTaskId);
} catch (refundError) {
@@ -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,124 +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,
ownerId: null,
});
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,
ownerId: null,
});
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();
});
});
});
});
@@ -1,146 +0,0 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as AgentDocumentModels from '@/database/models/agentDocuments';
import { createCallerFactory } from '@/libs/trpc/lambda';
import { createContextInner } from '@/libs/trpc/lambda/context';
import { AgentDocumentsService } from '@/server/services/agentDocuments';
import { agentDocumentRouter } from '../agentDocument';
const mocks = vi.hoisted(() => ({
associate: vi.fn(),
createTopic: vi.fn(),
findByAgentAndDocumentTrigger: vi.fn(),
findRowByDocumentId: vi.fn(),
getServerDB: vi.fn(),
}));
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: mocks.getServerDB,
}));
vi.mock('@/database/models/agentDocuments', async (importOriginal) => {
const actual = await importOriginal<typeof AgentDocumentModels>();
return {
...actual,
AgentDocumentModel: vi.fn(),
};
});
vi.mock('@/database/models/topic', () => ({
TopicModel: vi.fn().mockImplementation(() => ({
create: mocks.createTopic,
findByAgentAndDocumentTrigger: mocks.findByAgentAndDocumentTrigger,
})),
}));
vi.mock('@/database/models/topicDocument', () => ({
TopicDocumentModel: vi.fn().mockImplementation(() => ({
associate: mocks.associate,
})),
}));
vi.mock('@/server/services/agentDocuments', () => ({
AgentDocumentsService: vi.fn(),
}));
vi.mock('@/server/services/agentDocumentVfs', () => ({
AgentDocumentVfsService: vi.fn(),
}));
vi.mock('@/server/services/agentDocuments/toolOutcome', () => ({
emitAgentDocumentToolOutcomeSafely: vi.fn(),
}));
const createCaller = createCallerFactory(agentDocumentRouter);
describe('agentDocumentRouter.getOrCreateChatTopic', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getServerDB.mockResolvedValue({ kind: 'server-db' });
vi.mocked(AgentDocumentsService).mockImplementation(
() =>
({ findRowByDocumentId: mocks.findRowByDocumentId }) as unknown as AgentDocumentsService,
);
});
it('returns the existing topic when a doc-anchored row is already linked', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue({ id: 'topic-existing' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
const result = await caller.getOrCreateChatTopic({
agentId: 'agent-1',
documentId: 'docs_abc',
});
expect(result).toEqual({ topicId: 'topic-existing' });
expect(mocks.findByAgentAndDocumentTrigger).toHaveBeenCalledWith({
agentId: 'agent-1',
documentId: 'docs_abc',
trigger: 'document',
});
expect(mocks.createTopic).not.toHaveBeenCalled();
expect(mocks.associate).not.toHaveBeenCalled();
});
it('creates a new doc-anchored topic and associates it when none exists', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue({
filename: 'spec.md',
id: 'agent-document-1',
title: 'Spec',
});
mocks.createTopic.mockResolvedValue({ id: 'topic-new' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
const result = await caller.getOrCreateChatTopic({
agentId: 'agent-1',
documentId: 'docs_abc',
});
expect(result).toEqual({ topicId: 'topic-new' });
expect(mocks.createTopic).toHaveBeenCalledWith({
agentId: 'agent-1',
title: 'Spec',
trigger: 'document',
});
expect(mocks.associate).toHaveBeenCalledWith({
documentId: 'docs_abc',
topicId: 'topic-new',
});
});
it('falls back to the filename when the document has no title', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue({
filename: 'fallback.md',
id: 'agent-document-1',
title: undefined,
});
mocks.createTopic.mockResolvedValue({ id: 'topic-new' });
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
await caller.getOrCreateChatTopic({ agentId: 'agent-1', documentId: 'docs_abc' });
expect(mocks.createTopic).toHaveBeenCalledWith({
agentId: 'agent-1',
title: 'fallback.md',
trigger: 'document',
});
});
it('throws NOT_FOUND when the document is missing or not owned by the agent', async () => {
mocks.findByAgentAndDocumentTrigger.mockResolvedValue(undefined);
mocks.findRowByDocumentId.mockResolvedValue(undefined);
const caller = createCaller(await createContextInner({ userId: 'user-1' }));
await expect(
caller.getOrCreateChatTopic({ agentId: 'agent-1', documentId: 'docs_missing' }),
).rejects.toThrow(/Document not found/);
expect(mocks.createTopic).not.toHaveBeenCalled();
expect(mocks.associate).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,128 +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,
ownerId: null,
});
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,
ownerId: null,
});
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();
});
});
});
});
@@ -9,6 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { aiAgentRouter } from '../aiAgent';
import { cleanupTestUser, createTestUser } from './integration/setup';
const { mockExecuteToolCall, mockSandboxCallTool } = vi.hoisted(() => ({
mockExecuteToolCall: vi.fn(),
mockSandboxCallTool: vi.fn(),
}));
// Mock getServerDB to return our test database instance
let testDB: LobeChatDatabase;
vi.mock('@/database/core/db-adaptor', () => ({
@@ -29,6 +34,18 @@ vi.mock('@/server/services/aiChat', () => ({
AiChatService: vi.fn().mockImplementation(() => ({})),
}));
vi.mock('@/server/services/deviceGateway', () => ({
deviceGateway: {
executeToolCall: mockExecuteToolCall,
},
}));
vi.mock('@/server/services/sandbox', () => ({
createSandboxService: vi.fn(() => ({
callTool: mockSandboxCallTool,
})),
}));
describe('aiAgentRouter.interruptTask', () => {
let serverDB: LobeChatDatabase;
let userId: string;
@@ -43,6 +60,10 @@ describe('aiAgentRouter.interruptTask', () => {
userId = await createTestUser(serverDB);
mockInterruptOperation.mockReset();
mockInterruptOperation.mockResolvedValue(true);
mockExecuteToolCall.mockReset();
mockExecuteToolCall.mockResolvedValue({ success: true });
mockSandboxCallTool.mockReset();
mockSandboxCallTool.mockResolvedValue({ success: true });
// Create test agent
const [agent] = await serverDB
@@ -203,6 +224,104 @@ describe('aiAgentRouter.interruptTask', () => {
expect(updatedThread.status).toBe(ThreadStatus.Cancel);
});
it('should dispatch cancelHeteroTask for a device-dispatched codex operation', async () => {
await serverDB
.update(topics)
.set({
metadata: {
runningOperation: {
assistantMessageId: 'assistant-msg-1',
deviceId: 'device-1',
heteroType: 'codex',
operationId: 'op-codex',
},
},
})
.where(eq(topics.id, testTopicId));
const caller = aiAgentRouter.createCaller(createTestContext());
const result = await caller.interruptTask({
operationId: 'op-codex',
topicId: testTopicId,
});
expect(result.success).toBe(true);
expect(mockExecuteToolCall).toHaveBeenCalledWith(
{ deviceId: 'device-1', userId },
{
apiName: 'cancelHeteroTask',
arguments: JSON.stringify({ signal: 'SIGINT', taskId: 'op-codex' }),
identifier: 'cancelHeteroTask',
},
5000,
);
const [updatedTopic] = await serverDB.select().from(topics).where(eq(topics.id, testTopicId));
expect(updatedTopic.metadata?.runningOperation?.cancelRequestedAt).toBeDefined();
});
it('should kill the sandbox background command for a sandbox codex operation', async () => {
await serverDB
.update(topics)
.set({
metadata: {
runningOperation: {
assistantMessageId: 'assistant-msg-1',
heteroType: 'codex',
operationId: 'op-sandbox',
sandboxCommandId: 'cmd-1',
},
},
})
.where(eq(topics.id, testTopicId));
const caller = aiAgentRouter.createCaller(createTestContext());
const result = await caller.interruptTask({
operationId: 'op-sandbox',
topicId: testTopicId,
});
expect(result.success).toBe(true);
expect(mockSandboxCallTool).toHaveBeenCalledWith('killCommand', { commandId: 'cmd-1' });
const [updatedTopic] = await serverDB.select().from(topics).where(eq(topics.id, testTopicId));
expect(updatedTopic.metadata?.runningOperation?.cancelRequestedAt).toBeDefined();
});
it('should not cancel a topic runningOperation that belongs to another operation', async () => {
await serverDB
.update(topics)
.set({
metadata: {
runningOperation: {
assistantMessageId: 'assistant-msg-current',
deviceId: 'device-current',
heteroType: 'codex',
operationId: 'op-current',
sandboxCommandId: 'cmd-current',
},
},
})
.where(eq(topics.id, testTopicId));
const caller = aiAgentRouter.createCaller(createTestContext());
const result = await caller.interruptTask({
operationId: 'op-stale',
topicId: testTopicId,
});
expect(result.success).toBe(true);
expect(mockExecuteToolCall).not.toHaveBeenCalled();
expect(mockSandboxCallTool).not.toHaveBeenCalled();
const [updatedTopic] = await serverDB.select().from(topics).where(eq(topics.id, testTopicId));
expect(updatedTopic.metadata?.runningOperation?.cancelRequestedAt).toBeUndefined();
expect(updatedTopic.metadata?.runningOperation?.operationId).toBe('op-current');
});
});
describe('interrupt failure handling', () => {
@@ -29,12 +29,10 @@ describe('aiModelRouter', () => {
it('should create ai model', async () => {
const mockCreate = vi.fn().mockResolvedValue({ id: 'model-1' });
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
@@ -46,68 +44,12 @@ describe('aiModelRouter', () => {
});
expect(result).toBe('model-1');
expect(mockFindByIdAndProvider).toHaveBeenCalledWith('test-model', 'test-provider');
expect(mockCreate).toHaveBeenCalledWith({
id: 'test-model',
providerId: 'test-provider',
});
});
it('should reject duplicate ai model before creating', async () => {
const mockCreate = vi.fn();
const mockFindByIdAndProvider = vi.fn().mockResolvedValue({ id: 'test-model' });
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
const caller = aiModelRouter.createCaller(mockCtx);
await expect(
caller.createAiModel({
id: 'test-model',
providerId: 'test-provider',
}),
).rejects.toMatchObject({
code: 'CONFLICT',
message: 'Model "test-model" already exists',
});
expect(mockCreate).not.toHaveBeenCalled();
});
it('should convert duplicate insert races to conflict errors', async () => {
const duplicateError = Object.assign(new Error('failed query'), {
cause: Object.assign(new Error('duplicate key'), {
code: '23505',
constraint: 'ai_models_id_provider_id_user_id_pk',
}),
});
const mockCreate = vi.fn().mockRejectedValue(duplicateError);
const mockFindByIdAndProvider = vi.fn().mockResolvedValue(null);
vi.mocked(AiModelModel).mockImplementation(
() =>
({
create: mockCreate,
findByIdAndProvider: mockFindByIdAndProvider,
}) as any,
);
const caller = aiModelRouter.createCaller(mockCtx);
await expect(
caller.createAiModel({
id: 'test-model',
providerId: 'test-provider',
}),
).rejects.toMatchObject({
code: 'CONFLICT',
message: 'Model "test-model" already exists',
});
});
it('should get ai model by id', async () => {
const mockModel = {
id: 'model-1',
@@ -1,118 +0,0 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { asrRouter } from '../asr';
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: vi.fn(() => ({})),
}));
const transcribeMock = vi.fn();
vi.mock('@/server/modules/ModelRuntime', () => ({
initModelRuntimeFromDB: vi.fn(async () => ({ transcribe: transcribeMock })),
}));
const findByIdMock = vi.fn();
vi.mock('@/database/models/file', () => ({
FileModel: vi.fn(() => ({ findById: findByIdMock })),
}));
const getFileByteArrayMock = vi.fn();
vi.mock('@/server/services/file', () => ({
FileService: vi.fn(() => ({ getFileByteArray: getFileByteArrayMock })),
}));
const caller = asrRouter.createCaller({ jwtPayload: { userId: 'u1' }, userId: 'u1' } as any);
beforeEach(() => {
transcribeMock.mockResolvedValue({ text: 'hello world' });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('asrRouter.transcribe', () => {
it('transcribes inline base64 audio', async () => {
const res = await caller.transcribe({
audioBase64: Buffer.from('audio-bytes').toString('base64'),
fileName: 'clip.mp3',
model: 'whisper-1',
provider: 'openai',
});
expect(res).toEqual({ text: 'hello world' });
expect(findByIdMock).not.toHaveBeenCalled();
const payload = transcribeMock.mock.calls[0][0];
expect(payload.file).toBeInstanceOf(File);
expect(payload.fileName).toBe('clip.mp3');
expect(await payload.file.text()).toBe('audio-bytes');
});
it('resolves a fileId by downloading the bytes from storage', async () => {
findByIdMock.mockResolvedValue({
fileType: 'audio/mp4',
name: 'meeting.m4a',
url: 's3-key/meeting.m4a',
});
getFileByteArrayMock.mockResolvedValue(new Uint8Array(Buffer.from('from-s3')));
const res = await caller.transcribe({ fileId: 'file_123', model: 'whisper-1' });
expect(res).toEqual({ text: 'hello world' });
expect(findByIdMock).toHaveBeenCalledWith('file_123');
expect(getFileByteArrayMock).toHaveBeenCalledWith('s3-key/meeting.m4a');
const payload = transcribeMock.mock.calls[0][0];
expect(payload.fileName).toBe('meeting.m4a');
expect(payload.file.type).toBe('audio/mp4');
expect(await payload.file.text()).toBe('from-s3');
});
it('rejects when neither fileId nor audioBase64 is provided', async () => {
await expect(caller.transcribe({ model: 'whisper-1' } as any)).rejects.toThrow();
});
it('rejects oversized inline base64 and guides to fileId', async () => {
// > 3MB decoded → base64 string exceeds the cap
const tooBig = 'A'.repeat(5 * 1024 * 1024);
await expect(caller.transcribe({ audioBase64: tooBig, model: 'whisper-1' })).rejects.toThrow(
/fileId/i,
);
expect(transcribeMock).not.toHaveBeenCalled();
});
it('rejects when both fileId and audioBase64 are provided', async () => {
await expect(
caller.transcribe({
audioBase64: Buffer.from('x').toString('base64'),
fileId: 'file_123',
model: 'whisper-1',
} as any),
).rejects.toThrow();
});
it('throws NOT_FOUND when the fileId does not exist', async () => {
findByIdMock.mockResolvedValue(undefined);
await expect(caller.transcribe({ fileId: 'missing', model: 'whisper-1' })).rejects.toThrow(
/not found/i,
);
expect(getFileByteArrayMock).not.toHaveBeenCalled();
});
it('throws NOT_FOUND when the stored object is gone (NoSuchKey)', async () => {
findByIdMock.mockResolvedValue({
fileType: 'audio/mp4',
name: 'gone.m4a',
url: 's3-key/gone.m4a',
});
getFileByteArrayMock.mockRejectedValue({ Code: 'NoSuchKey' });
await expect(caller.transcribe({ fileId: 'file_x', model: 'whisper-1' })).rejects.toThrow(
/no longer available/i,
);
});
});
@@ -1,70 +0,0 @@
import { TRPCError } from '@trpc/server';
import { describe, expect, it, vi } from 'vitest';
import type { DeviceModel } from '@/database/models/device';
import { assertWorkspaceRootApproved } from '../deviceWorkspaceGuard';
const mockModel = (row: { defaultCwd?: string | null; workingDirs?: { path: string }[] } | null) =>
({
findByDeviceId: vi.fn().mockResolvedValue(row),
}) as unknown as DeviceModel;
describe('assertWorkspaceRootApproved', () => {
it('allows a root that exactly matches a bound workingDir', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).resolves.toBeUndefined();
});
it('allows a root nested inside a bound workingDir', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj/packages/app'),
).resolves.toBeUndefined();
});
it('allows a root matching defaultCwd when no workingDirs match', async () => {
const model = mockModel({ defaultCwd: '/Users/me/default', workingDirs: [] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/default'),
).resolves.toBeUndefined();
});
it('rejects a root that escapes the approved roots (filesystem root)', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(assertWorkspaceRootApproved(model, 'dev-1', '/')).rejects.toMatchObject({
code: 'FORBIDDEN',
});
});
it('rejects a sibling directory that shares a path prefix but is not contained', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj-evil'),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects when the device has no approved roots at all', async () => {
const model = mockModel({ workingDirs: [] });
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects when the device row is missing', async () => {
const model = mockModel(null);
await expect(
assertWorkspaceRootApproved(model, 'dev-1', '/Users/me/proj'),
).rejects.toBeInstanceOf(TRPCError);
});
it('rejects an empty workspace root with BAD_REQUEST before hitting the DB', async () => {
const model = mockModel({ workingDirs: [{ path: '/Users/me/proj' }] });
await expect(assertWorkspaceRootApproved(model, 'dev-1', '')).rejects.toMatchObject({
code: 'BAD_REQUEST',
});
expect(model.findByDeviceId).not.toHaveBeenCalled();
});
});

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