mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-19 13:54:10 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b19ca3990 | |||
| c8b5e337c0 |
@@ -19,23 +19,9 @@ also run as full cloud automation. Every test session follows the same
|
||||
four-step contract:
|
||||
|
||||
```
|
||||
Step -1: Plan approval → Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
|
||||
Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
|
||||
```
|
||||
|
||||
## Step -1 — Plan approval for non-trivial tests
|
||||
|
||||
Skip directly to Step 0 if: the test is a single re-run after a fix, the plan
|
||||
was already agreed on, or the user gave exact commands.
|
||||
|
||||
Otherwise, propose a test plan (surface, cases, expected evidence, assumptions)
|
||||
and use the runtime structured question tool (`request_user_input` /
|
||||
ask-user-question equivalent) with two fixed choices:
|
||||
|
||||
1. `开始执行 (Recommended)` — 测试方案没问题,开始执行
|
||||
2. `先讨论下` — 方案有问题,先讨论下
|
||||
|
||||
Wait for the user's choice before proceeding.
|
||||
|
||||
## Step 0 — Environment setup + auth check (mandatory)
|
||||
|
||||
Step 0 is about getting the environment ready: **dependencies are healthy**
|
||||
@@ -43,36 +29,6 @@ and **auth is green**. A test run that dies halfway on a missing dependency or
|
||||
a login wall wastes the whole session — clear both gates BEFORE writing a
|
||||
single test step.
|
||||
|
||||
### 0.0 Resolve the current test environment
|
||||
|
||||
Before starting a dev server, checking auth, opening agent-browser, or writing
|
||||
test steps, print and confirm the current local test environment:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/test-env.sh
|
||||
```
|
||||
|
||||
This command is the source of truth for local test ports. It reads the current
|
||||
shell plus `.env` files using the same precedence as `scripts/runWithEnv.mts`,
|
||||
then prints:
|
||||
|
||||
- `APP_URL`
|
||||
- `PORT`
|
||||
- `SERVER_URL`
|
||||
- `AUTH_TRUSTED_ORIGINS`
|
||||
- `SPA_PORT`
|
||||
- `MOBILE_SPA_PORT`
|
||||
- `DESKTOP_PORT`
|
||||
|
||||
For commands that need these values, export them from the same resolver:
|
||||
|
||||
```bash
|
||||
eval "$(./.agents/skills/agent-testing/scripts/test-env.sh --exports)"
|
||||
```
|
||||
|
||||
Do not rely on hard-coded port tables. If the printed values do not match the
|
||||
running dev server, fix/export the env first, then continue.
|
||||
|
||||
### 0.1 Dependencies are installed — root AND standalone apps
|
||||
|
||||
The root pnpm workspace does **NOT** cover every app: `pnpm-workspace.yaml`
|
||||
@@ -82,9 +38,9 @@ lists `packages/**`, `e2e`, `apps/server`, and only `apps/desktop/src/main` —
|
||||
refresh them, so install in every app the test will touch:
|
||||
|
||||
```bash
|
||||
pnpm install # root workspace
|
||||
cd apps/desktop && pnpm install # Electron surface
|
||||
cd apps/cli && pnpm install # CLI surface
|
||||
pnpm install # root workspace
|
||||
cd apps/desktop && pnpm install # Electron surface
|
||||
cd apps/cli && pnpm install # CLI surface
|
||||
```
|
||||
|
||||
Symptom of a stale standalone install: the build/launch fails to resolve a
|
||||
@@ -99,133 +55,27 @@ directory — a script launched while `cwd` is `apps/desktop` fails with
|
||||
`No such file or directory`. Verify `pwd` is the repo root before launching
|
||||
long-running scripts.
|
||||
|
||||
### 0.3 Init local dev env without `.env`
|
||||
### 0.3 Auth is green
|
||||
|
||||
For Web smoke against local code, start a **normal local dev environment**.
|
||||
First check the repo root for `.env`:
|
||||
|
||||
- If `.env` exists, use the existing local configuration and start the dev
|
||||
server normally.
|
||||
- If `.env` does not exist, use the agent-testing env bootstrap.
|
||||
|
||||
Do not start the standalone e2e server as the product under test.
|
||||
|
||||
Use `scripts/init-dev-env.sh`. It follows the e2e setup pattern — Postgres,
|
||||
Redis, migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
|
||||
skill and starts the repo's dev server (`pnpm run dev:next` / `bun run dev`),
|
||||
not `e2e/scripts/setup.ts --start`. The script hard-blocks when root `.env`
|
||||
exists, so it cannot accidentally override a user's local config. When `.env`
|
||||
exists, do not call any `init-dev-env.sh` subcommand.
|
||||
|
||||
Decision flow:
|
||||
**Auth is the gate for all automated testing.**
|
||||
|
||||
```bash
|
||||
if [[ -f .env ]]; then
|
||||
bun run dev
|
||||
else
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
fi
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh status
|
||||
```
|
||||
|
||||
Bootstrap flow when no `.env` exists:
|
||||
|
||||
```bash
|
||||
# From repo root. Managed Postgres/Redis flow requires Docker Desktop.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
```
|
||||
|
||||
If using an existing Postgres instead of the managed Docker DB, set
|
||||
`DATABASE_URL` and `REDIS_URL`, then skip `setup-db`:
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
|
||||
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
```
|
||||
|
||||
For backend-only checks, `dev-next` is available, but Web smoke needs the
|
||||
full-stack `dev` command so Next can proxy the SPA HTML from Vite:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
|
||||
```
|
||||
|
||||
Useful subcommands:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh env # print exports
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh write # write .records/env/agent-testing-dev.env
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate # migrations only
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user # seed user + CLI API key
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash # local QStash for workflow paths
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh clean-db # remove managed DB container
|
||||
```
|
||||
|
||||
Default script env:
|
||||
|
||||
- `APP_URL=http://localhost:3010`
|
||||
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
|
||||
- `DATABASE_DRIVER=node`
|
||||
- `AGENT_RUNTIME_MODE=queue` so backend-only agent runtime checks use the
|
||||
same queued execution path as production
|
||||
- `REDIS_URL=redis://localhost:6380` for queue-mode agent runtime state
|
||||
- `FEATURE_FLAGS=-agent_self_iteration` so local smoke does not require QStash
|
||||
- Local QStash defaults (`QSTASH_URL`, `QSTASH_TOKEN`, signing keys) are exported;
|
||||
run `init-dev-env.sh qstash` in a separate terminal when the path under test
|
||||
triggers QStash/Workflow.
|
||||
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
|
||||
- S3 mock vars
|
||||
- Managed DB container: `lobehub-agent-testing-postgres`
|
||||
- Managed Redis container: `lobehub-agent-testing-redis`
|
||||
|
||||
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
|
||||
onboarding already completed, plus a local API key in
|
||||
`.records/env/agent-testing-cli.env` for CLI automation. When running Cucumber
|
||||
against this dev server, pass the same script env into the test process too;
|
||||
Cucumber has its own `BeforeAll` seed path and it must see `DATABASE_URL`
|
||||
instead of silently skipping setup:
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
# Only in the no-.env branch.
|
||||
eval "$(../.agents/skills/agent-testing/scripts/init-dev-env.sh env)"
|
||||
BASE_URL=http://localhost:3010 HEADLESS=true bun run test:smoke
|
||||
```
|
||||
|
||||
### 0.4 Auth is green for the selected surface
|
||||
|
||||
**Auth is the gate for automated testing, but the gate is surface-scoped.**
|
||||
Pick the intended surface first when it is already clear from the task, then
|
||||
check only that surface. Do not block a Web test on CLI device-code auth or an
|
||||
Electron login state unless the test spans those surfaces.
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh status --surface web
|
||||
```
|
||||
|
||||
Use `status` with no `--surface` only for cross-surface test plans.
|
||||
|
||||
| Surface | Mechanism | One-key path | Standard check |
|
||||
| -------- | --------------------------------------------- | ------------------------ | ----------------------------------------- |
|
||||
| CLI | Seeded API key, device-code fallback | `setup-auth.sh cli-seed` | `setup-auth.sh status --surface cli` |
|
||||
| Web | Seeded better-auth login into `agent-browser` | `setup-auth.sh web-seed` | `setup-auth.sh status --surface web` |
|
||||
| Electron | App's own persistent login state | Log in once in the app | `setup-auth.sh status --surface electron` |
|
||||
| Bot | Native apps already logged in | — | per-platform screenshot |
|
||||
| Surface | Mechanism | One-key path | Standard check |
|
||||
| -------- | ------------------------------------------------- | ------------------------------ | ------------------------------ |
|
||||
| CLI | OIDC Device Code Flow (`apps/cli/.lobehub-dev`) | `setup-auth.sh cli` | `setup-auth.sh status` |
|
||||
| Web | better-auth cookie injection into `agent-browser` | `pbpaste \| setup-auth.sh web` | `setup-auth.sh web-verify` |
|
||||
| Electron | App's own persistent login state | Log in once in the app | `app-probe.sh auth` |
|
||||
| Bot | Native apps already logged in | — | per-platform screenshot |
|
||||
|
||||
Login-state checks are standardized — do NOT hand-roll `window.__LOBE_STORES`
|
||||
eval snippets; use `scripts/app-probe.sh auth` (returns `{ isSignedIn, userId }`,
|
||||
works for Electron CDP and web sessions via `AB_TARGET`).
|
||||
|
||||
For Web tests, the test surface is always `agent-browser --session lobehub-dev`.
|
||||
Use `setup-auth.sh web-seed` first in the seeded local env. The user's normal
|
||||
Chrome is only a source for copying the Cookie header when seed auth is not
|
||||
available or `status --surface web` still fails. If Chrome is already logged in,
|
||||
do not open a login page; verify agent-browser first, then request the Network
|
||||
`Cookie:` header only if that verification fails. Full background and failure modes:
|
||||
If `status` is not all green, fix auth first (the steps that need a human must be
|
||||
requested from the user explicitly). Full background and failure modes:
|
||||
[references/auth.md](./references/auth.md).
|
||||
|
||||
## Step 1 — Pick the surface by change scope
|
||||
@@ -298,19 +148,17 @@ Surface guides above carry the detailed workflows. Shared infrastructure:
|
||||
|
||||
All under `.agents/skills/agent-testing/scripts/`:
|
||||
|
||||
| Script | Usage |
|
||||
| ------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `test-env.sh` | Print/export the resolved local test env and ports |
|
||||
| `setup-auth.sh` | One-stop auth setup & status check (`status` / `cli` / `web`) |
|
||||
| `init-dev-env.sh` | Self-contained local dev env (`setup-db` / `seed-user` / `dev-next` / `dev`) |
|
||||
| `app-probe.sh` | LobeHub app probes: `auth` / `route` / `ops` / `goto <path>` / `errors` |
|
||||
| `record-gif.sh` | Frame-sequence → GIF for time-based behavior (streaming, timers, animations) |
|
||||
| `report-init.sh` | Scaffold a structured test report (Step 3) |
|
||||
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart, CDP 9222) |
|
||||
| `capture-app-window.sh` | Screenshot a specific app window (general; used by bot tests) |
|
||||
| `record-app-screen.sh` | Record app screen (video + periodic screenshots) |
|
||||
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
|
||||
| `agent-gateway/` | Gateway probe / dump / analyze tools |
|
||||
| Script | Usage |
|
||||
| ------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `setup-auth.sh` | One-stop auth setup & status check (`status` / `cli` / `web`) |
|
||||
| `app-probe.sh` | LobeHub app probes: `auth` / `route` / `ops` / `goto <path>` / `errors` |
|
||||
| `record-gif.sh` | Frame-sequence → GIF for time-based behavior (streaming, timers, animations) |
|
||||
| `report-init.sh` | Scaffold a structured test report (Step 3) |
|
||||
| `electron-dev.sh` | Manage Electron dev env (start/stop/status/restart, CDP 9222) |
|
||||
| `capture-app-window.sh` | Screenshot a specific app window (general; used by bot tests) |
|
||||
| `record-app-screen.sh` | Record app screen (video + periodic screenshots) |
|
||||
| `record-electron-demo.sh` | Record Electron app demo with ffmpeg |
|
||||
| `agent-gateway/` | Gateway probe / dump / analyze tools |
|
||||
|
||||
`app-probe.sh` is the LobeHub-specific fast path into app state — auth check,
|
||||
current route, running operations, and `goto <path>` quick navigation
|
||||
@@ -326,13 +174,12 @@ not a chat-only summary. Scaffold it up front and fill it as you test:
|
||||
```bash
|
||||
DIR=$(./.agents/skills/agent-testing/scripts/report-init.sh my-feature "Verify my feature")
|
||||
# ... test, saving screenshots / CLI transcripts into $DIR/assets/ ...
|
||||
# fill $DIR/report.md (scope, case table with inline evidence, verdict, score) and $DIR/result.json
|
||||
# fill $DIR/report.md (case table, embedded evidence, verdict) and $DIR/result.json
|
||||
```
|
||||
|
||||
Reports live in `.records/reports/<timestamp>-<slug>/` (gitignored): `report.md`
|
||||
(human-readable, with screenshots/GIFs embedded directly in the case table),
|
||||
`result.json` (machine-readable pass/fail + score), `assets/` (evidence).
|
||||
Format spec and evidence rules:
|
||||
(human-readable, with embedded screenshots), `result.json` (machine-readable
|
||||
pass/fail + score), `assets/` (evidence). Format spec and evidence rules:
|
||||
[references/report.md](./references/report.md).
|
||||
|
||||
Two hard rules worth front-loading:
|
||||
@@ -340,21 +187,6 @@ Two hard rules worth front-loading:
|
||||
- **Report language = the user's conversation language.** Write the ENTIRE
|
||||
`report.md` (headings included) in the language the user is conversing in —
|
||||
no mixed English. `result.json` keys/status values stay English.
|
||||
- **The case table is the main reading surface.** Prefer the compact
|
||||
`# | case | result | key observation | evidence` shape and embed the
|
||||
screenshot/GIF in the evidence cell. Use separate evidence sections only for
|
||||
long CLI transcripts, HAR summaries, or supplemental detail.
|
||||
- **Visual evidence must render inline.** Screenshots and GIFs in `report.md`
|
||||
must use Markdown image syntax like ``. 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 —
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,72 +3,33 @@
|
||||
Single source of truth for starting / restarting the backend that all test
|
||||
surfaces (CLI, Electron, Web) hit.
|
||||
|
||||
## Resolve ports first
|
||||
|
||||
Run `test-env.sh` as described in
|
||||
[SKILL.md Step 0.0](../SKILL.md#00-resolve-the-current-test-environment)
|
||||
before starting or probing any local test surface.
|
||||
|
||||
## Ports & modes
|
||||
|
||||
| Command | What it runs | Port source |
|
||||
| ------------------- | --------------------------------------------------------- | ------------------- |
|
||||
| `pnpm run dev:next` | Next.js backend (API + auth) | `PORT` |
|
||||
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `PORT` + `SPA_PORT` |
|
||||
| `bun run dev:spa` | Vite SPA only, proxies API to `PORT` | `SPA_PORT` |
|
||||
| Command | What it runs | Port |
|
||||
| ------------------- | --------------------------------------------------------- | --------------------------------- |
|
||||
| `pnpm run dev:next` | Next.js backend (API + auth) | `3010` |
|
||||
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `3010` (API) + SPA |
|
||||
| `bun run dev:spa` | Vite SPA only, proxies API to `3010` | `9876` (prints a Debug Proxy URL) |
|
||||
|
||||
In the **cloud repo** (where this repo is the `lobehub/` submodule), local
|
||||
worktree names map to fallback defaults only when `.env` and shell env do not
|
||||
provide values:
|
||||
|
||||
| Workspace directory | Default `SERVER_URL` |
|
||||
| ------------------- | -------------------------------- |
|
||||
| `lobehub` | `http://localhost:3010` |
|
||||
| `lobehub-cloud` | `http://localhost:3020` |
|
||||
| `lobehub-cloud-1` | `http://localhost:3021` |
|
||||
| `lobehub-cloud-N` | `http://localhost:$((3020 + N))` |
|
||||
|
||||
`test-env.sh` and `setup-auth.sh` both use the resolved env first and these
|
||||
worktree defaults only as fallback. Treat the dev-server terminal output as the
|
||||
final source of truth when testing a non-standard port, then export it for every
|
||||
agent-testing command:
|
||||
|
||||
```bash
|
||||
export SERVER_URL=http://localhost:<port-from-dev-output>
|
||||
```
|
||||
In the **cloud repo** (where this repo is the `lobehub/` submodule) the dev
|
||||
server conventionally runs on `3011` — set `SERVER_URL=http://localhost:3011`
|
||||
for the scripts in this skill when testing there.
|
||||
|
||||
## Health check
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3010/
|
||||
```
|
||||
|
||||
## Start / restart
|
||||
|
||||
```bash
|
||||
# Start backend only.
|
||||
# With root .env: use the existing local config.
|
||||
# Agent runtime queue mode is required to mirror production async execution.
|
||||
AGENT_RUNTIME_MODE=queue pnpm run dev:next
|
||||
|
||||
# Without root .env: use the self-contained agent-testing env.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
|
||||
|
||||
# Full-stack SPA + backend. Required for Web smoke.
|
||||
# With root .env:
|
||||
AGENT_RUNTIME_MODE=queue bun run dev
|
||||
|
||||
# Without root .env:
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
|
||||
# Local QStash. Run in a separate terminal only when testing workflow paths.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash
|
||||
# Start (from repo root)
|
||||
pnpm run dev:next
|
||||
|
||||
# Restart — required to pick up server-side code changes
|
||||
lsof -ti:"$PORT" | xargs kill
|
||||
lsof -ti:3010 | xargs kill
|
||||
pnpm run dev:next
|
||||
# or, when no root .env exists:
|
||||
# ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
|
||||
```
|
||||
|
||||
## When a server restart is needed
|
||||
@@ -87,15 +48,8 @@ in doubt.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `ECONNREFUSED` | Server not running — start it |
|
||||
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
|
||||
| Stale data / old behavior | Server needs a restart to pick up code changes |
|
||||
| Agent call runs inline | Set `AGENT_RUNTIME_MODE=queue`, make sure `REDIS_URL` is configured, then restart the server |
|
||||
| Queue mode needs Redis | Run `init-dev-env.sh setup-db`, or provide `REDIS_URL=redis://...` for an existing Redis |
|
||||
| QStash workflow failures | Start `init-dev-env.sh qstash` and make sure dev server inherited the script's `QSTASH_*` env |
|
||||
|
||||
Marketplace/community endpoints are not part of the local agent-testing auth
|
||||
gate. Do not block local product-chain verification on marketplace API auth
|
||||
unless the change explicitly targets marketplace behavior.
|
||||
| Issue | Solution |
|
||||
| ------------------------- | ------------------------------------------------------- |
|
||||
| `ECONNREFUSED` | Server not running — start it |
|
||||
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
|
||||
| Stale data / old behavior | Server needs a restart to pick up code changes |
|
||||
|
||||
@@ -11,7 +11,7 @@ output):
|
||||
|
||||
```
|
||||
.records/reports/<YYYYMMDD-HHMMSS>-<slug>/
|
||||
├── report.md # human-readable report (case table with inline screenshots, verdict)
|
||||
├── report.md # human-readable report (embedded screenshots, case table, verdict)
|
||||
├── result.json # machine-readable results (pass/fail counts, score)
|
||||
└── assets/ # evidence: screenshots, HAR files, CLI transcripts
|
||||
```
|
||||
@@ -25,16 +25,13 @@ output):
|
||||
```
|
||||
|
||||
The script creates the directory, pre-fills branch / commit / date in both
|
||||
files, and prints the directory path. The scaffold uses the compact report
|
||||
shape below; translate its headings and table labels to the user's language
|
||||
before delivery if needed.
|
||||
files, and prints the directory path.
|
||||
|
||||
2. **Collect evidence as you test** — every asserted behavior gets one evidence
|
||||
item in `$DIR/assets/`:
|
||||
- UI (static state): `agent-browser screenshot` or `capture-app-window.sh`,
|
||||
then **verify the screenshot with the Read tool before citing it** —
|
||||
never cite an image you haven't looked at.
|
||||
|
||||
- UI (time-based behavior): **screenshot vs GIF is a judgment you must
|
||||
make per case.** If the assertion is about change over time — streaming
|
||||
output, a ticking timer, loading/progress states, animations,
|
||||
@@ -51,91 +48,33 @@ output):
|
||||
|
||||
Embed it like an image: ``. Verify
|
||||
at least the first/last frames visually (Read the GIF) before citing.
|
||||
|
||||
- CLI: exact command + trimmed output (`$CLI task list | tee "$DIR/assets/task-list.txt"`).
|
||||
|
||||
- Network: `agent-browser network requests` dumps or HAR files.
|
||||
|
||||
3. **Fill `report.md` as you go** — don't reconstruct from memory at the end.
|
||||
The primary evidence belongs in the case table itself: each row should pair
|
||||
the assertion with the screenshot/GIF or non-visual artifact that proves it,
|
||||
so readers can scan the result without jumping between sections. UI evidence
|
||||
must render inline with Markdown image syntax; a plain link or file path is
|
||||
not acceptable as primary visual evidence.
|
||||
|
||||
4. **Set the verdict** in both `report.md` and `result.json`, then link the
|
||||
report directory in your final answer to the user. If UI evidence exists,
|
||||
list the key screenshot/GIF links in the final chat response. Use Markdown
|
||||
link text as the evidence caption, for example:
|
||||
`[Image #1 - observed outcome](<report-dir>/assets/case1.png)`.
|
||||
report directory in your final answer to the user.
|
||||
|
||||
## Report language (hard rule)
|
||||
|
||||
**`report.md` MUST be written in the language the user is conversing in** —
|
||||
the whole file, headings included. If the conversation is in Chinese, the
|
||||
report is in Chinese; do not mix English prose into it. The scaffold headings
|
||||
are placeholders — translate them when filling if the user is not conversing in
|
||||
the scaffold language. Exceptions that stay as-is: code/commands, identifiers,
|
||||
log excerpts, and `result.json` (its keys and status values are machine-read
|
||||
and stay English; the `title` and case `name` fields follow the user's
|
||||
language).
|
||||
report is in Chinese; do not mix English prose into it. The scaffold's English
|
||||
headings are placeholders — translate them when filling. Exceptions that stay
|
||||
as-is: code/commands, identifiers, log excerpts, and `result.json` (its keys
|
||||
and status values are machine-read and stay English; the `title` and case
|
||||
`name` fields follow the user's language).
|
||||
|
||||
## report.md sections
|
||||
|
||||
Default report shape:
|
||||
|
||||
| Section | Content |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------- |
|
||||
| **Scope** | What changed / what is being verified; branch, commit, date, surface, entry URL/page, focus |
|
||||
| **Cases** | Compact table: `# \| Case \| Result \| Key observation \| Evidence` |
|
||||
| **Verdict** | Overall verdict first (`pass` / `partial` / `fail`), then the concise reasons and follow-ups |
|
||||
| **Verification** | Commands or automated checks run in this session, with trimmed results |
|
||||
| **Score** | Pass/fail/blocked counts, optional 0–100 score |
|
||||
|
||||
The case table is the main reading surface. Prefer one clear row per user
|
||||
scenario or regression assertion, and put the screenshot/GIF directly in the
|
||||
`Evidence` cell:
|
||||
|
||||
```markdown
|
||||
| # | Case | Result | Key observation | Evidence |
|
||||
| --- | ------------------------ | ------ | ----------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| 1 | Create a new page | pass | Title and body persisted after refresh |  |
|
||||
| 2 | Respect requested length | fail | Requested about 600 Chinese characters; final body was about 1286 |  |
|
||||
```
|
||||
|
||||
## Inline visual evidence
|
||||
|
||||
Screenshots and GIFs must be embedded so the report shows the image inline:
|
||||
|
||||
```markdown
|
||||

|
||||

|
||||
```
|
||||
|
||||
Do **not** use these as the primary evidence for UI cases:
|
||||
|
||||
```markdown
|
||||
[case 1 result](assets/case1-result.png)
|
||||
assets/case1-result.png
|
||||
file:///tmp/case1-result.png
|
||||
```
|
||||
|
||||
Links are acceptable for non-visual artifacts such as CLI transcripts, HAR
|
||||
files, or long logs. For videos, embed a representative screenshot/GIF inline in
|
||||
the case row and link the full video as supplemental evidence.
|
||||
|
||||
Avoid the old wide table with separate `steps`, `expected`, and `actual`
|
||||
columns unless the test is purely non-visual and truly needs that breakdown.
|
||||
For UI reports, those columns make screenshot-backed reading harder. Put
|
||||
procedural detail in the row's key observation only when it changes the
|
||||
interpretation of the result.
|
||||
|
||||
Use an extra evidence/detail section only when the inline table cannot carry
|
||||
the material cleanly, such as long CLI transcripts, HAR summaries, or multiple
|
||||
screenshots for one case. In that situation, keep the table evidence cell as an
|
||||
inline visual proof for UI cases or a concise link for non-visual artifacts,
|
||||
then put the longer material under `Verification` or a brief
|
||||
`Additional Evidence` section.
|
||||
| Section | Content |
|
||||
| --------------- | ---------------------------------------------------------------------------------- |
|
||||
| **Scope** | What changed / what is being verified; branch + commit |
|
||||
| **Environment** | Server URL, surfaces used (cli / electron / web / bot), relevant versions |
|
||||
| **Cases** | Table: `# \| case \| surface \| steps \| expected \| actual \| status \| evidence` |
|
||||
| **Evidence** | Embedded screenshots/GIFs (``), fenced CLI transcripts |
|
||||
| **Verdict** | Pass/fail/blocked counts, optional 0–100 score, open issues / follow-ups |
|
||||
|
||||
Status values: `pass` / `fail` / `blocked` (couldn't run — e.g. auth or env
|
||||
missing; a blocked case is not a pass).
|
||||
@@ -176,8 +115,7 @@ word the user reads first: `pass`, `fail`, or `partial`.
|
||||
## Rules
|
||||
|
||||
- **No evidence, no claim** — every `pass`/`fail` in the case table must link
|
||||
at least one asset. UI cases must inline-embed their primary screenshot/GIF;
|
||||
non-visual CLI/network cases may link transcripts, HAR files, or logs.
|
||||
at least one asset.
|
||||
- **Screenshots must be visually verified** with the Read tool before being
|
||||
cited.
|
||||
- **Report failures faithfully** — a failing case with clear evidence is a good
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# init-dev-env.sh — self-contained local dev env for agent testing.
|
||||
#
|
||||
# This script initializes the env needed to run LobeHub's normal local dev
|
||||
# server without depending on a root .env file. It follows the same shape as
|
||||
# the e2e bootstrap (Postgres + migrations + auth/key-vault/S3 test env), but
|
||||
# starts the repo's dev server, not the standalone e2e server.
|
||||
#
|
||||
# Guardrail: if repo-root .env exists, every non-help command exits immediately.
|
||||
# Existing local config always wins.
|
||||
#
|
||||
# Usage:
|
||||
# init-dev-env.sh env # print shell exports
|
||||
# init-dev-env.sh write [file] # write a source-able env file
|
||||
# init-dev-env.sh setup-db # start local Postgres/Redis and run migrations
|
||||
# init-dev-env.sh migrate # run DB migrations against the configured DB
|
||||
# init-dev-env.sh seed-user # seed the baseline test user + CLI API key
|
||||
# init-dev-env.sh qstash # run local Upstash QStash dev server
|
||||
# init-dev-env.sh dev-next # exec `pnpm run dev:next` with this env
|
||||
# init-dev-env.sh dev # exec `bun run dev` with this env
|
||||
# init-dev-env.sh clean-db # remove the managed Postgres/Redis containers
|
||||
#
|
||||
# Overrides:
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres REDIS_PORT=6380 REDIS_CONTAINER=lobehub-agent-testing-redis QSTASH_DEV_PORT=8080
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||
ROOT_ENV_FILE="$REPO_ROOT/.env"
|
||||
|
||||
SERVER_PORT="${SERVER_PORT:-3010}"
|
||||
DB_PORT="${DB_PORT:-5433}"
|
||||
DB_CONTAINER="${DB_CONTAINER:-lobehub-agent-testing-postgres}"
|
||||
DATABASE_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:${DB_PORT}/postgres}"
|
||||
REDIS_PORT="${REDIS_PORT:-6380}"
|
||||
REDIS_CONTAINER="${REDIS_CONTAINER:-lobehub-agent-testing-redis}"
|
||||
REDIS_URL="${REDIS_URL:-redis://localhost:${REDIS_PORT}}"
|
||||
ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-dev.env"
|
||||
CLI_ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-cli.env"
|
||||
AGENT_TESTING_API_KEY="${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}"
|
||||
QSTASH_DEV_PORT="${QSTASH_DEV_PORT:-8080}"
|
||||
QSTASH_LOCAL_TOKEN="${QSTASH_LOCAL_TOKEN:-eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=}"
|
||||
QSTASH_LOCAL_CURRENT_SIGNING_KEY="${QSTASH_LOCAL_CURRENT_SIGNING_KEY:-sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r}"
|
||||
QSTASH_LOCAL_NEXT_SIGNING_KEY="${QSTASH_LOCAL_NEXT_SIGNING_KEY:-sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs}"
|
||||
|
||||
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; }
|
||||
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; }
|
||||
note() { printf ' %s\n' "$1"; }
|
||||
|
||||
guard_no_root_env() {
|
||||
if [[ -f "$ROOT_ENV_FILE" ]]; then
|
||||
bad "root .env exists: $ROOT_ENV_FILE"
|
||||
note "Use the existing local configuration instead of init-dev-env.sh."
|
||||
note "Start normally from repo root, e.g. pnpm run dev:next or bun run dev."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
apply_env() {
|
||||
export AGENT_RUNTIME_MODE="${AGENT_RUNTIME_MODE:-queue}"
|
||||
export APP_URL="${APP_URL:-http://localhost:${SERVER_PORT}}"
|
||||
export AUTH_EMAIL_VERIFICATION="${AUTH_EMAIL_VERIFICATION:-0}"
|
||||
export AUTH_SECRET="${AUTH_SECRET:-agent-testing-local-auth-secret-32chars}"
|
||||
export DATABASE_DRIVER="${DATABASE_DRIVER:-node}"
|
||||
export DATABASE_URL
|
||||
export FEATURE_FLAGS="${FEATURE_FLAGS:--agent_self_iteration}"
|
||||
export KEY_VAULTS_SECRET="${KEY_VAULTS_SECRET:-r2gbBPKyJ8ZRKCLKt+I3DImfcL+wGxaQyRC56xtm9Uk=}"
|
||||
export NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION="${NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION:-0}"
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=6144}"
|
||||
export PORT="${PORT:-$SERVER_PORT}"
|
||||
export QSTASH_CURRENT_SIGNING_KEY="${QSTASH_CURRENT_SIGNING_KEY:-$QSTASH_LOCAL_CURRENT_SIGNING_KEY}"
|
||||
export QSTASH_DEV_PORT
|
||||
export QSTASH_NEXT_SIGNING_KEY="${QSTASH_NEXT_SIGNING_KEY:-$QSTASH_LOCAL_NEXT_SIGNING_KEY}"
|
||||
export QSTASH_TOKEN="${QSTASH_TOKEN:-$QSTASH_LOCAL_TOKEN}"
|
||||
export QSTASH_URL="${QSTASH_URL:-http://127.0.0.1:${QSTASH_DEV_PORT}}"
|
||||
export REDIS_URL
|
||||
export S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-agent-testing-access-key}"
|
||||
export S3_BUCKET="${S3_BUCKET:-agent-testing-bucket}"
|
||||
export S3_ENDPOINT="${S3_ENDPOINT:-https://agent-testing-s3.localhost}"
|
||||
export S3_SECRET_ACCESS_KEY="${S3_SECRET_ACCESS_KEY:-agent-testing-secret-key}"
|
||||
}
|
||||
|
||||
env_keys() {
|
||||
printf '%s\n' \
|
||||
APP_URL \
|
||||
AGENT_RUNTIME_MODE \
|
||||
AUTH_EMAIL_VERIFICATION \
|
||||
AUTH_SECRET \
|
||||
DATABASE_DRIVER \
|
||||
DATABASE_URL \
|
||||
FEATURE_FLAGS \
|
||||
KEY_VAULTS_SECRET \
|
||||
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION \
|
||||
NODE_OPTIONS \
|
||||
PORT \
|
||||
QSTASH_CURRENT_SIGNING_KEY \
|
||||
QSTASH_DEV_PORT \
|
||||
QSTASH_NEXT_SIGNING_KEY \
|
||||
QSTASH_TOKEN \
|
||||
QSTASH_URL \
|
||||
REDIS_URL \
|
||||
S3_ACCESS_KEY_ID \
|
||||
S3_BUCKET \
|
||||
S3_ENDPOINT \
|
||||
S3_SECRET_ACCESS_KEY
|
||||
}
|
||||
|
||||
print_env() {
|
||||
apply_env
|
||||
while IFS= read -r key; do
|
||||
printf 'export %s=%q\n' "$key" "${!key}"
|
||||
done < <(env_keys)
|
||||
}
|
||||
|
||||
write_env() {
|
||||
local file="${1:-$ENV_FILE_DEFAULT}"
|
||||
apply_env
|
||||
mkdir -p "$(dirname "$file")"
|
||||
{
|
||||
printf '# Source this file before starting LobeHub local dev server.\n'
|
||||
printf '# Generated by %s\n' "$0"
|
||||
while IFS= read -r key; do
|
||||
printf 'export %s=%q\n' "$key" "${!key}"
|
||||
done < <(env_keys)
|
||||
} > "$file"
|
||||
ok "wrote env file: $file"
|
||||
note "source it with: source $file"
|
||||
}
|
||||
|
||||
require_docker() {
|
||||
if ! command -v docker > /dev/null 2>&1; then
|
||||
bad "docker CLI is not available"
|
||||
note "Install/start Docker Desktop, or provide DATABASE_URL for an existing Postgres."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_db() {
|
||||
printf ' waiting for Postgres'
|
||||
until docker exec "$DB_CONTAINER" pg_isready -U postgres > /dev/null 2>&1; do
|
||||
printf '.'
|
||||
sleep 2
|
||||
done
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
wait_for_redis() {
|
||||
printf ' waiting for Redis'
|
||||
until docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; do
|
||||
printf '.'
|
||||
sleep 1
|
||||
done
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
start_db() {
|
||||
require_docker
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
|
||||
ok "Postgres container already running: $DB_CONTAINER"
|
||||
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
|
||||
docker start "$DB_CONTAINER" > /dev/null
|
||||
ok "started existing Postgres container: $DB_CONTAINER"
|
||||
else
|
||||
docker run -d \
|
||||
--name "$DB_CONTAINER" \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-p "${DB_PORT}:5432" \
|
||||
paradedb/paradedb:latest > /dev/null
|
||||
ok "created Postgres container: $DB_CONTAINER"
|
||||
fi
|
||||
|
||||
wait_for_db
|
||||
}
|
||||
|
||||
start_redis() {
|
||||
require_docker
|
||||
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
ok "Redis container already running: $REDIS_CONTAINER"
|
||||
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
docker start "$REDIS_CONTAINER" > /dev/null
|
||||
ok "started existing Redis container: $REDIS_CONTAINER"
|
||||
else
|
||||
docker run -d \
|
||||
--name "$REDIS_CONTAINER" \
|
||||
-p "${REDIS_PORT}:6379" \
|
||||
redis:7-alpine > /dev/null
|
||||
ok "created Redis container: $REDIS_CONTAINER"
|
||||
fi
|
||||
|
||||
wait_for_redis
|
||||
}
|
||||
|
||||
migrate_db() {
|
||||
apply_env
|
||||
cd "$REPO_ROOT"
|
||||
bun run db:migrate
|
||||
}
|
||||
|
||||
seed_user() {
|
||||
apply_env
|
||||
export AGENT_TESTING_API_KEY
|
||||
export AGENT_TESTING_CLI_ENV_FILE="${AGENT_TESTING_CLI_ENV_FILE:-$CLI_ENV_FILE_DEFAULT}"
|
||||
cd "$REPO_ROOT"
|
||||
node <<'NODE'
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const pg = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required to seed the baseline test user.');
|
||||
}
|
||||
|
||||
const TEST_USER = {
|
||||
email: 'agent-testing@lobehub.com',
|
||||
fullName: 'Agent Testing User',
|
||||
id: 'user_agent_testing_001',
|
||||
password: 'TestPassword123!',
|
||||
username: 'agent_testing_user',
|
||||
};
|
||||
|
||||
const TEST_API_KEY = {
|
||||
id: 'api_key_agent_testing_001',
|
||||
key: process.env.AGENT_TESTING_API_KEY || 'sk-lh-agenttesting0001',
|
||||
name: 'Agent Testing CLI API Key',
|
||||
};
|
||||
|
||||
const validateApiKeyFormat = (apiKey) => /^sk-lh-[\da-z]{16}$/.test(apiKey);
|
||||
|
||||
const hashApiKey = (apiKey) => {
|
||||
const secret = process.env.KEY_VAULTS_SECRET;
|
||||
if (!secret) throw new Error('KEY_VAULTS_SECRET is required to seed the baseline API key.');
|
||||
|
||||
return crypto.createHmac('sha256', secret).update(apiKey).digest('hex');
|
||||
};
|
||||
|
||||
const encryptWithKeyVaultsSecret = (plaintext) => {
|
||||
const secret = process.env.KEY_VAULTS_SECRET;
|
||||
if (!secret) throw new Error('KEY_VAULTS_SECRET is required to seed the baseline API key.');
|
||||
|
||||
const rawKey = Buffer.from(secret, 'base64');
|
||||
if (![16, 24, 32].includes(rawKey.length)) {
|
||||
throw new Error(
|
||||
`KEY_VAULTS_SECRET must decode to 16, 24, or 32 bytes, got ${rawKey.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(`aes-${rawKey.length * 8}-gcm`, rawKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
};
|
||||
|
||||
const writeCliEnvFile = () => {
|
||||
const file = process.env.AGENT_TESTING_CLI_ENV_FILE || '.records/env/agent-testing-cli.env';
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
'# Source this file before running LobeHub CLI agent tests.',
|
||||
'# Generated by init-dev-env.sh seed-user',
|
||||
`export LOBE_API_KEY=${TEST_API_KEY.key}`,
|
||||
`export LOBEHUB_CLI_API_KEY="${'${LOBE_API_KEY}'}"`,
|
||||
`export LOBEHUB_SERVER=${process.env.APP_URL}`,
|
||||
'export LOBEHUB_CLI_HOME=.lobehub-dev',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
return file;
|
||||
};
|
||||
|
||||
const client = new pg.Client({ connectionString: databaseUrl });
|
||||
|
||||
(async () => {
|
||||
if (!validateApiKeyFormat(TEST_API_KEY.key)) {
|
||||
throw new Error(`Invalid AGENT_TESTING_API_KEY format: ${TEST_API_KEY.key}`);
|
||||
}
|
||||
|
||||
await client.connect();
|
||||
const now = new Date().toISOString();
|
||||
const onboarding = JSON.stringify({ finishedAt: now, version: 1 });
|
||||
const passwordHash = await bcrypt.hash(TEST_USER.password, 10);
|
||||
const encryptedApiKey = encryptWithKeyVaultsSecret(TEST_API_KEY.key);
|
||||
const apiKeyHash = hashApiKey(TEST_API_KEY.key);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO users (id, email, normalized_email, username, full_name, email_verified, onboarding, created_at, updated_at, last_active_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $8)
|
||||
ON CONFLICT (id) DO UPDATE SET onboarding = $7, updated_at = $8`,
|
||||
[
|
||||
TEST_USER.id,
|
||||
TEST_USER.email,
|
||||
TEST_USER.email.toLowerCase(),
|
||||
TEST_USER.username,
|
||||
TEST_USER.fullName,
|
||||
true,
|
||||
onboarding,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[
|
||||
'agent_testing_account_001',
|
||||
TEST_USER.id,
|
||||
TEST_USER.email,
|
||||
'credential',
|
||||
passwordHash,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO api_keys (id, name, key, key_hash, enabled, expires_at, user_id, workspace_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NULL, $6, NULL, $7, $7)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
key = EXCLUDED.key,
|
||||
key_hash = EXCLUDED.key_hash,
|
||||
enabled = EXCLUDED.enabled,
|
||||
expires_at = NULL,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[
|
||||
TEST_API_KEY.id,
|
||||
TEST_API_KEY.name,
|
||||
encryptedApiKey,
|
||||
apiKeyHash,
|
||||
true,
|
||||
TEST_USER.id,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
const cliEnvFile = writeCliEnvFile();
|
||||
|
||||
console.log('seeded baseline user:');
|
||||
console.log(` email: ${TEST_USER.email}`);
|
||||
console.log(` password: ${TEST_USER.password}`);
|
||||
console.log('seeded baseline API key:');
|
||||
console.log(` LOBE_API_KEY: ${TEST_API_KEY.key}`);
|
||||
console.log(` CLI env: ${cliEnvFile}`);
|
||||
})()
|
||||
.finally(() => client.end())
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
apply_env
|
||||
echo "agent-testing local dev env:"
|
||||
note "APP_URL=$APP_URL"
|
||||
note "AGENT_RUNTIME_MODE=$AGENT_RUNTIME_MODE"
|
||||
note "DATABASE_URL=$DATABASE_URL"
|
||||
note "PORT=$PORT"
|
||||
note "QSTASH_URL=$QSTASH_URL"
|
||||
note "REDIS_URL=$REDIS_URL"
|
||||
if command -v docker > /dev/null 2>&1; then
|
||||
ok "docker CLI available"
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
|
||||
ok "managed Postgres running: $DB_CONTAINER"
|
||||
else
|
||||
note "managed Postgres is not running: $DB_CONTAINER"
|
||||
fi
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
ok "managed Redis running: $REDIS_CONTAINER"
|
||||
else
|
||||
note "managed Redis is not running: $REDIS_CONTAINER"
|
||||
fi
|
||||
else
|
||||
bad "docker CLI is not available"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_qstash() {
|
||||
apply_env
|
||||
cd "$REPO_ROOT"
|
||||
note "starting local QStash dev server at $QSTASH_URL"
|
||||
note "keep this process running while testing workflow paths"
|
||||
exec pnpm run qstash -- -port "$QSTASH_DEV_PORT"
|
||||
}
|
||||
|
||||
cmd_dev_next() {
|
||||
apply_env
|
||||
cd "$REPO_ROOT"
|
||||
exec pnpm run dev:next
|
||||
}
|
||||
|
||||
cmd_dev() {
|
||||
apply_env
|
||||
cd "$REPO_ROOT"
|
||||
exec bun run dev
|
||||
}
|
||||
|
||||
cmd_clean_db() {
|
||||
require_docker
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
|
||||
docker stop "$DB_CONTAINER" > /dev/null
|
||||
fi
|
||||
if docker ps -a --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
|
||||
docker rm "$DB_CONTAINER" > /dev/null
|
||||
ok "removed Postgres container: $DB_CONTAINER"
|
||||
else
|
||||
note "Postgres container not found: $DB_CONTAINER"
|
||||
fi
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
docker stop "$REDIS_CONTAINER" > /dev/null
|
||||
fi
|
||||
if docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
|
||||
docker rm "$REDIS_CONTAINER" > /dev/null
|
||||
ok "removed Redis container: $REDIS_CONTAINER"
|
||||
else
|
||||
note "Redis container not found: $REDIS_CONTAINER"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
sed -n '3,24p' "$0" >&2
|
||||
}
|
||||
|
||||
COMMAND="${1:-status}"
|
||||
|
||||
case "$COMMAND" in
|
||||
help|-h|--help) usage; exit 0 ;;
|
||||
*) guard_no_root_env ;;
|
||||
esac
|
||||
|
||||
case "$COMMAND" in
|
||||
env) print_env ;;
|
||||
write) shift; write_env "${1:-}" ;;
|
||||
setup-db)
|
||||
start_db
|
||||
start_redis
|
||||
migrate_db
|
||||
;;
|
||||
migrate) migrate_db ;;
|
||||
seed-user) seed_user ;;
|
||||
qstash) cmd_qstash ;;
|
||||
dev-next) cmd_dev_next ;;
|
||||
dev) cmd_dev ;;
|
||||
clean-db) cmd_clean_db ;;
|
||||
status) cmd_status ;;
|
||||
*)
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -24,53 +24,39 @@ DATE_HUMAN=$(date '+%Y-%m-%d %H:%M')
|
||||
DATE_ISO=$(date '+%Y-%m-%dT%H:%M:%S%z')
|
||||
|
||||
cat > "$DIR/report.md" << EOF
|
||||
# 测试报告:$TITLE
|
||||
# Test Report: $TITLE
|
||||
|
||||
## 范围
|
||||
## Scope
|
||||
|
||||
<!-- 测试目标 / 变更范围 / 重点风险 -->
|
||||
<!-- What changed / what is being verified -->
|
||||
|
||||
- 分支:\`$BRANCH\`
|
||||
- 当前提交:\`$COMMIT\`
|
||||
- 日期:$DATE_HUMAN
|
||||
- 表面:<!-- CLI / Electron + CDP / Web / Bot:<platform> -->
|
||||
- 测试页 / 入口:<!-- e.g. /settings or http://localhost:3010 -->
|
||||
- 重点:<!-- 本轮最关心的体验、功能或回归点 -->
|
||||
- Branch: \`$BRANCH\`
|
||||
- Commit: \`$COMMIT\`
|
||||
- Date: $DATE_HUMAN
|
||||
|
||||
## 用例
|
||||
## Environment
|
||||
|
||||
| # | 用例 | 结果 | 关键现象 | 证据 |
|
||||
| - | ---- | ---- | -------- | ---- |
|
||||
| 1 | | 待测 | |  |
|
||||
- Server: <!-- e.g. http://localhost:3010 -->
|
||||
- Surfaces: <!-- cli / electron / web / bot:<platform> -->
|
||||
|
||||
## 结论
|
||||
## Cases
|
||||
|
||||
整体结论:\`pending\`。
|
||||
| # | Case | Surface | Steps | Expected | Actual | Status | Evidence |
|
||||
| - | ---- | ------- | ----- | -------- | ------ | ------ | -------- |
|
||||
| 1 | | | | | | | |
|
||||
|
||||
<!-- 用 1-2 段概括用户最需要知道的结果;失败和阻塞必须明确说明影响。 -->
|
||||
## Evidence
|
||||
|
||||
仍需处理 / 跟进:
|
||||
<!-- Embed screenshots:  -->
|
||||
<!-- CLI transcripts in fenced blocks, with the exact command -->
|
||||
|
||||
- <!-- TODO -->
|
||||
## Verdict
|
||||
|
||||
## 本轮验证
|
||||
|
||||
<!-- 如有自动化或命令行验证,保留精简命令与结果;没有则写“未运行额外自动化验证”。 -->
|
||||
|
||||
\`\`\`bash
|
||||
# command
|
||||
\`\`\`
|
||||
|
||||
结果:
|
||||
|
||||
- <!-- TODO -->
|
||||
|
||||
## 评分
|
||||
|
||||
- 通过:0
|
||||
- 失败:0
|
||||
- 阻塞:0
|
||||
- 评分:— / 100
|
||||
- Passed: 0 / 0
|
||||
- Failed: 0
|
||||
- Blocked: 0
|
||||
- Score (optional): —
|
||||
- Open issues / follow-ups:
|
||||
EOF
|
||||
|
||||
cat > "$DIR/result.json" << EOF
|
||||
|
||||
@@ -5,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"
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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/`).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 / 1k–10k 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
|
||||
human–machine 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
@@ -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 #############
|
||||
# #######################################
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Clean issue notice
|
||||
uses: actions-cool/issues-helper@e361abf610221f09495ad510cb1e69328d839e1c # v3.7.6
|
||||
with:
|
||||
actions: 'close-issues'
|
||||
labels: '🚨 Sync Fail'
|
||||
|
||||
- name: Sync upstream changes
|
||||
id: sync
|
||||
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4
|
||||
@@ -27,4 +33,22 @@ jobs:
|
||||
upstream_sync_branch: main
|
||||
target_sync_branch: main
|
||||
target_repo_token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, no need to set
|
||||
test_mode: false
|
||||
test_mode: false
|
||||
|
||||
- name: Sync check
|
||||
if: failure()
|
||||
uses: actions-cool/issues-helper@e361abf610221f09495ad510cb1e69328d839e1c # v3.7.6
|
||||
with:
|
||||
actions: 'create-issue'
|
||||
title: '🚨 同步失败 | Sync Fail'
|
||||
labels: '🚨 Sync Fail'
|
||||
body: |
|
||||
Due to a change in the workflow file of the [LobeChat][lobechat] upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork. Please refer to the detailed [Tutorial][tutorial-en-US] for instructions.
|
||||
|
||||
由于 [LobeChat][lobechat] 上游仓库的 workflow 文件变更,导致 GitHub 自动暂停了本次自动更新,你需要手动 Sync Fork 一次,请查看 [详细教程][tutorial-zh-CN]
|
||||
|
||||

|
||||
|
||||
[lobechat]: https://github.com/lobehub/lobe-chat
|
||||
[tutorial-zh-CN]: https://lobehub.com/zh/docs/self-hosting/advanced/upstream-sync
|
||||
[tutorial-en-US]: https://lobehub.com/docs/self-hosting/advanced/upstream-sync
|
||||
|
||||
+3
-2
@@ -59,7 +59,6 @@ bun.lockb
|
||||
# Build outputs
|
||||
dist/
|
||||
public/_spa/
|
||||
public/_spa-auth/
|
||||
public/spa/
|
||||
es/
|
||||
lib/
|
||||
@@ -93,8 +92,10 @@ public/swe-worker*
|
||||
|
||||
# Generated files
|
||||
src/app/spa/[variants]/[[...path]]/spaHtmlTemplates.ts
|
||||
src/app/spa-auth/authHtmlTemplate.ts
|
||||
public/*.js
|
||||
public/sitemap.xml
|
||||
public/sitemap-index.xml
|
||||
sitemap*.xml
|
||||
robots.txt
|
||||
|
||||
# Git hooks
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
[](#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>
|
||||
|
||||
@@ -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,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.32" "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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.32",
|
||||
"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:*",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
packages:
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/device-control'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2,16 +2,9 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
defaultGetLocalFilePreview,
|
||||
defaultGetProjectFileIndex,
|
||||
type DeviceControlDeps,
|
||||
executeDeviceRpc,
|
||||
} from '@lobechat/device-control';
|
||||
import type {
|
||||
AgentRunRequestMessage,
|
||||
DeviceSystemInfo,
|
||||
RpcRequestMessage,
|
||||
SystemInfoRequestMessage,
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
@@ -74,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')
|
||||
@@ -138,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) {
|
||||
@@ -276,23 +262,19 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
|
||||
// Handle tool call requests
|
||||
client.on('tool_call_request', async (request: ToolCallRequestMessage) => {
|
||||
const { operationId, requestId, timeout, toolCall } = request;
|
||||
const { requestId, timeout, toolCall } = request;
|
||||
if (isDaemonChild) {
|
||||
appendLog(
|
||||
`[TOOL] ${toolCall.apiName}${operationId ? ` op=${operationId}` : ''} (${requestId})`,
|
||||
);
|
||||
appendLog(`[TOOL] ${toolCall.apiName} (${requestId})`);
|
||||
} else {
|
||||
log.toolCall(toolCall.apiName, requestId, toolCall.arguments, operationId);
|
||||
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
|
||||
}
|
||||
|
||||
const result = await executeToolCall(toolCall.apiName, toolCall.arguments, timeout);
|
||||
|
||||
if (isDaemonChild) {
|
||||
appendLog(
|
||||
`[RESULT] ${result.success ? 'OK' : 'FAIL'}${operationId ? ` op=${operationId}` : ''} (${requestId})`,
|
||||
);
|
||||
appendLog(`[RESULT] ${result.success ? 'OK' : 'FAIL'} (${requestId})`);
|
||||
} else {
|
||||
log.toolResult(requestId, result.success, result.content, operationId);
|
||||
log.toolResult(requestId, result.success, result.content);
|
||||
}
|
||||
|
||||
client.sendToolCallResponse({
|
||||
@@ -306,31 +288,6 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle generic server-internal device RPCs (git / workspace / file ops).
|
||||
// Shares the `@lobechat/device-control` dispatcher with the desktop app so the
|
||||
// CLI exposes the same remote-device control surface. File preview / index use
|
||||
// the package's portable defaults (no preview-protocol approval on the CLI).
|
||||
const deviceControlDeps: DeviceControlDeps = {
|
||||
getLocalFilePreview: defaultGetLocalFilePreview,
|
||||
getProjectFileIndex: defaultGetProjectFileIndex,
|
||||
};
|
||||
|
||||
client.on('rpc_request', async (request: RpcRequestMessage) => {
|
||||
const { method, params, requestId } = request;
|
||||
if (isDaemonChild) appendLog(`[RPC] ${method} (${requestId})`);
|
||||
else info(`Received rpc_request: method=${method} (${requestId})`);
|
||||
|
||||
try {
|
||||
const data = await executeDeviceRpc(method, params, deviceControlDeps);
|
||||
client.sendRpcResponse({ requestId, result: { data, success: true } });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (isDaemonChild) appendLog(`[RPC ERROR] ${method}: ${message} (${requestId})`);
|
||||
else error(`rpc_request method=${method} failed: ${message}`);
|
||||
client.sendRpcResponse({ requestId, result: { error: message, success: false } });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle gateway-dispatched agent runs (heterogeneous agents, e.g. Claude
|
||||
// Code). Mirrors the desktop app: spawn `lh hetero exec`, which owns the full
|
||||
// execution + server-ingest pipeline. Ack with the spawn outcome — `accepted`
|
||||
@@ -485,9 +442,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
if (identity) {
|
||||
try {
|
||||
// Reuse the already-resolved auth (respects `--token` mode) so we don't
|
||||
// re-discover creds and exit when none are found. Seed the default working
|
||||
// directory with the launch dir (applied only on first registration).
|
||||
await registerDevice(auth, identity, { defaultCwd: process.cwd() });
|
||||
// re-discover creds and exit when none are found.
|
||||
await registerDevice(auth, identity);
|
||||
} catch (err) {
|
||||
error(`Device registration failed (non-fatal): ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,20 +25,13 @@ export function resolveDeviceIdentity(
|
||||
* device row exists right after auth) and `lh connect` (so the row exists
|
||||
* before the WS opens). Best-effort by contract: callers should wrap this in a
|
||||
* try/catch and treat any failure as non-fatal.
|
||||
*
|
||||
* `defaultCwd` seeds the user-owned "default working directory" on the device's
|
||||
* first registration only — the server preserves any value the user has since
|
||||
* set. `lh connect` passes its launch directory so a freshly connected device
|
||||
* defaults to a sensible working directory; `lh login` omits it.
|
||||
*/
|
||||
export async function registerDevice(
|
||||
auth: { serverUrl: string; token: string; tokenType: 'apiKey' | 'jwt' | 'serviceToken' },
|
||||
identity: DeviceIdentity,
|
||||
options?: { defaultCwd?: string },
|
||||
): Promise<void> {
|
||||
const trpc = createLambdaClient(auth);
|
||||
await trpc.device.register.mutate({
|
||||
defaultCwd: options?.defaultCwd,
|
||||
deviceId: identity.deviceId,
|
||||
hostname: os.hostname(),
|
||||
identitySource: identity.identitySource,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
import pc from 'picocolors';
|
||||
|
||||
let verbose = false;
|
||||
@@ -40,20 +41,18 @@ export const log = {
|
||||
console.log(`${timestamp()} ${pc.bold('[STATUS]')} ${color(status)}`);
|
||||
},
|
||||
|
||||
toolCall: (apiName: string, requestId: string, args?: string, operationId?: string) => {
|
||||
toolCall: (apiName: string, requestId: string, args?: string) => {
|
||||
console.log(
|
||||
`${timestamp()} ${pc.magenta('[TOOL]')} ${pc.bold(apiName)}${operationId ? ` ${pc.dim(`op=${operationId}`)}` : ''} ${pc.dim(`(${requestId})`)}`,
|
||||
`${timestamp()} ${pc.magenta('[TOOL]')} ${pc.bold(apiName)} ${pc.dim(`(${requestId})`)}`,
|
||||
);
|
||||
if (args && verbose) {
|
||||
console.log(` ${pc.dim(args)}`);
|
||||
}
|
||||
},
|
||||
|
||||
toolResult: (requestId: string, success: boolean, content?: string, operationId?: string) => {
|
||||
toolResult: (requestId: string, success: boolean, content?: string) => {
|
||||
const icon = success ? pc.green('OK') : pc.red('FAIL');
|
||||
console.log(
|
||||
`${timestamp()} ${pc.magenta('[RESULT]')} ${icon}${operationId ? ` ${pc.dim(`op=${operationId}`)}` : ''} ${pc.dim(`(${requestId})`)}`,
|
||||
);
|
||||
console.log(`${timestamp()} ${pc.magenta('[RESULT]')} ${icon} ${pc.dim(`(${requestId})`)}`);
|
||||
if (content && verbose) {
|
||||
const preview = content.length > 200 ? content.slice(0, 200) + '...' : content;
|
||||
console.log(` ${pc.dim(preview)}`);
|
||||
|
||||
@@ -1,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,
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -166,6 +167,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.app.getController(LocalFileCtr);
|
||||
}
|
||||
|
||||
private get workspaceCtr() {
|
||||
return this.app.getController(WorkspaceCtr);
|
||||
}
|
||||
|
||||
private get gitCtr() {
|
||||
return this.app.getController(GitCtr);
|
||||
}
|
||||
|
||||
private get shellCommandCtr() {
|
||||
return this.app.getController(ShellCommandCtr);
|
||||
}
|
||||
@@ -344,33 +353,91 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.localSystemRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-specific handlers the shared `@lobechat/device-control` dispatcher
|
||||
* delegates to. Git + workspace-scan methods run inside device-control over
|
||||
* `@lobechat/local-file-shell`; only file preview / index (and preview
|
||||
* approval) are desktop-specific and routed back to the controllers here.
|
||||
*/
|
||||
private get deviceControlDeps(): DeviceControlDeps {
|
||||
return {
|
||||
approveProjectRoot: async (root) => {
|
||||
try {
|
||||
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to approve project preview root ${root}:`, error);
|
||||
}
|
||||
},
|
||||
getLocalFilePreview: (params) => this.localFileCtr.getLocalFilePreview(params),
|
||||
getProjectFileIndex: (params) => this.localFileCtr.getProjectFileIndex(params),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a generic server-internal device RPC (not an agent tool call) by
|
||||
* method name. The dispatch logic lives in `@lobechat/device-control` so the
|
||||
* desktop main process and the CLI daemon share one device RPC surface.
|
||||
* method name. Currently only `initWorkspace` (scan the bound project root for
|
||||
* skills + AGENTS.md); add new server-only device methods here.
|
||||
*/
|
||||
private async executeDeviceRpc(method: string, params: unknown): Promise<unknown> {
|
||||
return runDeviceRpc(method, params, this.deviceControlDeps);
|
||||
switch (method) {
|
||||
case 'initWorkspace': {
|
||||
return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams);
|
||||
}
|
||||
|
||||
case 'getGitBranch': {
|
||||
return this.gitCtr.getGitBranch((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getLinkedPullRequest': {
|
||||
return this.gitCtr.getLinkedPullRequest(params as { branch: string; path: string });
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreeStatus': {
|
||||
return this.gitCtr.getGitWorkingTreeStatus((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getGitAheadBehind': {
|
||||
return this.gitCtr.getGitAheadBehind((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'listGitBranches': {
|
||||
return this.gitCtr.listGitBranches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'checkoutGitBranch': {
|
||||
return this.gitCtr.checkoutGitBranch(
|
||||
params as { branch: string; create?: boolean; path: string },
|
||||
);
|
||||
}
|
||||
|
||||
case 'pullGitBranch': {
|
||||
return this.gitCtr.pullGitBranch(params as { path: string });
|
||||
}
|
||||
|
||||
case 'pushGitBranch': {
|
||||
return this.gitCtr.pushGitBranch(params as { path: string });
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreePatches': {
|
||||
return this.gitCtr.getGitWorkingTreePatches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreeFiles': {
|
||||
return this.gitCtr.getGitWorkingTreeFiles((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getProjectFileIndex': {
|
||||
return this.localFileCtr.getProjectFileIndex(params as { scope?: string });
|
||||
}
|
||||
|
||||
case 'getLocalFilePreview': {
|
||||
return this.localFileCtr.getLocalFilePreview(params as LocalFilePreviewUrlParams);
|
||||
}
|
||||
|
||||
case 'listProjectSkills': {
|
||||
return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams);
|
||||
}
|
||||
|
||||
case 'getGitBranchDiff': {
|
||||
return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string });
|
||||
}
|
||||
|
||||
case 'listGitRemoteBranches': {
|
||||
return this.gitCtr.listGitRemoteBranches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'revertGitFile': {
|
||||
return this.gitCtr.revertGitFile(params as { filePath: string; path: string });
|
||||
}
|
||||
|
||||
case 'statPath': {
|
||||
return this.workspaceCtr.statPath(params as { path: string });
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unknown device RPC method: ${method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeToolCall(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,7 @@ import type {
|
||||
HeteroExecImageRef,
|
||||
} from '@lobechat/heterogeneous-agents/protocol';
|
||||
import { buildHeteroExecStdinPayload } from '@lobechat/heterogeneous-agents/protocol';
|
||||
import type { AgentStreamEvent, UsageData } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import type { AgentStreamEvent } from '@lobechat/heterogeneous-agents/spawn';
|
||||
import {
|
||||
AgentStreamPipeline,
|
||||
buildAgentInput,
|
||||
@@ -188,21 +188,6 @@ interface AgentSession {
|
||||
modelVerificationLastAttemptAt?: number;
|
||||
modelVerificationLastAttemptSessionId?: string;
|
||||
process?: ChildProcess;
|
||||
/**
|
||||
* Absolute CLI path resolved by spawn preflight detection. Used for spawn()
|
||||
* when the configured command is bare: detection can find the CLI through
|
||||
* the login-shell PATH or a well-known install location (e.g. the Codex.app
|
||||
* bundled CLI) that plain spawn() with the inherited env can't resolve.
|
||||
*/
|
||||
resolvedCommandPath?: string;
|
||||
/**
|
||||
* PATH the preflight detector used to resolve `resolvedCommandPath`, set only
|
||||
* when it fell back to the login-shell PATH. Merged into the child PATH at
|
||||
* spawn so a `#!/usr/bin/env node` shim still finds its interpreter — the
|
||||
* shim resolving in preflight doesn't guarantee `node` is on the leaner
|
||||
* inherited PATH (Finder-launched Electron).
|
||||
*/
|
||||
resolvedCommandSearchPath?: string;
|
||||
resumeSessionId?: string;
|
||||
sessionId: string;
|
||||
verifiedModel?: string;
|
||||
@@ -485,20 +470,11 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
session.agentType === 'claude-code' ? 'claude-code' : 'codex',
|
||||
command,
|
||||
);
|
||||
const cliMissingError = this.buildCliMissingError(session);
|
||||
|
||||
if (!status || status.available) {
|
||||
// Spawn through the detector-resolved absolute path when the configured
|
||||
// command is bare — detection may have located the CLI somewhere plain
|
||||
// spawn() can't (login-shell PATH, Codex.app bundled CLI, …).
|
||||
const useResolvedPath = Boolean(status?.path) && !command.includes(path.sep);
|
||||
session.resolvedCommandPath = useResolvedPath ? status!.path : undefined;
|
||||
// Carry the login-shell PATH the detector resolved through, so a
|
||||
// `#!/usr/bin/env node` shim spawned by absolute path still finds `node`.
|
||||
session.resolvedCommandSearchPath = useResolvedPath ? status!.resolvedPathEnv : undefined;
|
||||
return;
|
||||
}
|
||||
if (!status || status.available || !cliMissingError) return;
|
||||
|
||||
return this.buildCliMissingError(session);
|
||||
return cliMissingError;
|
||||
}
|
||||
|
||||
private get shouldTraceCliOutput(): boolean {
|
||||
@@ -935,7 +911,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
let spawnPlan;
|
||||
let traceSession;
|
||||
let cwd: string;
|
||||
let initialCumulativeUsage: UsageData | undefined;
|
||||
let spawnEnv: NodeJS.ProcessEnv;
|
||||
try {
|
||||
const driver = getHeterogeneousAgentDriver(session.agentType);
|
||||
@@ -959,12 +934,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
// Forward the user's proxy settings to the CLI. The main-process undici
|
||||
// dispatcher doesn't reach child processes — they need env vars.
|
||||
const proxyEnv = buildProxyEnv(this.app.storeManager.get('networkProxy'));
|
||||
const inheritedEnv = buildInheritedSpawnEnv();
|
||||
// When preflight resolved the CLI via the login-shell PATH, spawn with
|
||||
// that PATH (a superset of the inherited one) so a `#!/usr/bin/env node`
|
||||
// shim finds its interpreter. `session.env` still wins if it sets PATH.
|
||||
if (session.resolvedCommandSearchPath) inheritedEnv.PATH = session.resolvedCommandSearchPath;
|
||||
spawnEnv = { ...inheritedEnv, ...proxyEnv, ...session.env };
|
||||
spawnEnv = { ...buildInheritedSpawnEnv(), ...proxyEnv, ...session.env };
|
||||
|
||||
if (session.agentType === 'codex') {
|
||||
const initialModel = await resolveCodexInitialModel({
|
||||
@@ -975,12 +945,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
session.model = initialModel.model;
|
||||
session.modelSource = initialModel.source;
|
||||
}
|
||||
|
||||
if (session.agentSessionId) {
|
||||
initialCumulativeUsage = (
|
||||
await readCodexSessionModel(session.agentSessionId, { env: spawnEnv })
|
||||
)?.cumulativeUsage;
|
||||
}
|
||||
}
|
||||
|
||||
traceSession = await this.createCliTraceSession({
|
||||
@@ -1002,10 +966,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
}
|
||||
const useStdin = spawnPlan.stdinPayload !== undefined;
|
||||
const cliArgs = spawnPlan.args;
|
||||
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(
|
||||
session.resolvedCommandPath ?? session.command,
|
||||
cliArgs,
|
||||
);
|
||||
const resolvedCliSpawnPlan = await resolveCliSpawnPlan(session.command, cliArgs);
|
||||
|
||||
logger.info(
|
||||
'Spawning agent:',
|
||||
@@ -1040,7 +1001,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
reject,
|
||||
resolve,
|
||||
session,
|
||||
initialCumulativeUsage,
|
||||
spawnEnv,
|
||||
traceSession,
|
||||
useStdin,
|
||||
@@ -1110,7 +1070,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
|
||||
private handleSpawnedAgentProcess({
|
||||
cwd,
|
||||
initialCumulativeUsage,
|
||||
intervention,
|
||||
params,
|
||||
proc,
|
||||
@@ -1129,7 +1088,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
reject: (reason?: unknown) => void;
|
||||
resolve: () => void;
|
||||
session: AgentSession;
|
||||
initialCumulativeUsage?: UsageData | undefined;
|
||||
spawnEnv: NodeJS.ProcessEnv;
|
||||
spawnPlan: HeterogeneousAgentBuildPlan;
|
||||
traceSession: CliTraceSession | undefined;
|
||||
@@ -1170,7 +1128,6 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
|
||||
const pipeline = new AgentStreamPipeline({
|
||||
agentType: session.agentType,
|
||||
cwd,
|
||||
initialCumulativeUsage,
|
||||
initialModel: session.model,
|
||||
operationId: params.operationId,
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
@@ -437,15 +437,11 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async getLocalFilePreviewUrl({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
path: filePath,
|
||||
workingDirectory,
|
||||
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
|
||||
try {
|
||||
const url = await this.app.localFileProtocolManager.createPreviewUrl({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot: workingDirectory,
|
||||
});
|
||||
@@ -463,15 +459,11 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
|
||||
@IpcMethod()
|
||||
async getLocalFilePreview({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
path: filePath,
|
||||
workingDirectory,
|
||||
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewResult> {
|
||||
try {
|
||||
const preview = await this.app.localFileProtocolManager.readPreviewFile({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot: workingDirectory,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+14
-2
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { dequoteGitPath, quoteGitPath } from '../workingTree';
|
||||
import { dequoteGitPath, quoteGitPath } from '../GitCtr';
|
||||
|
||||
vi.mock('@/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('quoteGitPath', () => {
|
||||
it('leaves plain ASCII paths unquoted (including spaces)', () => {
|
||||
@@ -24,6 +33,8 @@ describe('quoteGitPath', () => {
|
||||
});
|
||||
|
||||
it('puts the prefix inside the quotes', () => {
|
||||
// Real git output for `git diff` of a tab-containing file:
|
||||
// diff --git "a/with\there" "b/with\there"
|
||||
expect(quoteGitPath('a/', 'with\there')).toBe('"a/with\\there"');
|
||||
expect(quoteGitPath('b/', 'with\there')).toBe('"b/with\\there"');
|
||||
});
|
||||
@@ -40,6 +51,7 @@ describe('quoteGitPath', () => {
|
||||
];
|
||||
for (const original of cases) {
|
||||
const quoted = quoteGitPath('b/', original);
|
||||
// Strip the surrounding quotes + b/ prefix, then de-escape.
|
||||
expect(quoted.startsWith('"b/')).toBe(true);
|
||||
expect(quoted.endsWith('"')).toBe(true);
|
||||
const stripped = quoted.slice(1, -1).slice('b/'.length);
|
||||
@@ -480,87 +480,6 @@ describe('HeterogeneousAgentCtr', () => {
|
||||
expect(spawnCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('spawns through the detector-resolved absolute path when the bare command is off PATH', async () => {
|
||||
// Codex desktop app case: `codex` is not on PATH, but the preflight
|
||||
// detector finds the CLI bundled inside Codex.app. Spawning the bare
|
||||
// command would ENOENT — spawn must use the resolved absolute path.
|
||||
const resolvedPath = '/Applications/Codex.app/Contents/Resources/codex';
|
||||
const detect = vi.fn().mockResolvedValue({ available: true, path: resolvedPath });
|
||||
const { proc } = createFakeProc();
|
||||
nextFakeProc = proc;
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
toolDetectorManager: { detect },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: 'codex',
|
||||
});
|
||||
await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId });
|
||||
|
||||
expect(spawnCalls[0].command).toBe(resolvedPath);
|
||||
});
|
||||
|
||||
it('carries the detector login-shell PATH into the spawn env for `env node` shims', async () => {
|
||||
// `codex` resolved via the login-shell PATH (mise/nvm). Spawning the
|
||||
// absolute shim under the leaner inherited PATH would fail at its
|
||||
// `#!/usr/bin/env node` shebang — the resolved PATH must reach the child.
|
||||
const resolvedPath = '/Users/h/.local/share/mise/shims/codex';
|
||||
const searchPath = '/Users/h/.local/share/mise/shims:/usr/bin:/bin';
|
||||
const detect = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ available: true, path: resolvedPath, resolvedPathEnv: searchPath });
|
||||
const { proc } = createFakeProc();
|
||||
nextFakeProc = proc;
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
toolDetectorManager: { detect },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({ agentType: 'codex', command: 'codex' });
|
||||
await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId });
|
||||
|
||||
expect(spawnCalls[0].command).toBe(resolvedPath);
|
||||
expect(spawnCalls[0].options.env.PATH).toBe(searchPath);
|
||||
});
|
||||
|
||||
it('keeps an explicit path-like command for spawn instead of the detector result', async () => {
|
||||
// detectHeterogeneousCliCommand validates the custom path via --version.
|
||||
execFileMock.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
_args: string[],
|
||||
optionsOrCallback: unknown,
|
||||
callback?: (error: Error | null, result: { stderr: string; stdout: string }) => void,
|
||||
) => {
|
||||
const resolvedCallback =
|
||||
typeof optionsOrCallback === 'function' ? optionsOrCallback : callback;
|
||||
(resolvedCallback as any)?.(null, { stderr: '', stdout: 'codex-cli 0.99.0' });
|
||||
},
|
||||
);
|
||||
|
||||
const detect = vi.fn();
|
||||
const { proc } = createFakeProc();
|
||||
nextFakeProc = proc;
|
||||
|
||||
const ctr = new HeterogeneousAgentCtr({
|
||||
appStoragePath,
|
||||
storeManager: { get: vi.fn() },
|
||||
toolDetectorManager: { detect },
|
||||
} as any);
|
||||
const { sessionId } = await ctr.startSession({
|
||||
agentType: 'codex',
|
||||
command: '/custom/bin/codex',
|
||||
});
|
||||
await ctr.sendPrompt({ operationId: 'op-test', prompt: 'hello', sessionId });
|
||||
|
||||
expect(detect).not.toHaveBeenCalled();
|
||||
expect(spawnCalls[0].command).toBe('/custom/bin/codex');
|
||||
});
|
||||
|
||||
it('passes prompt via stdin to codex exec instead of argv', async () => {
|
||||
const prompt = '--run a shell-like prompt safely';
|
||||
const { cliArgs, command, writes } = await runSendPrompt(prompt);
|
||||
|
||||
@@ -225,8 +225,6 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -249,52 +247,6 @@ describe('LocalFileCtr', () => {
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward image-only preview URL constraints', async () => {
|
||||
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
|
||||
'localfile://file/workspace/image.png?token=abc',
|
||||
);
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreviewUrl({
|
||||
accept: 'image',
|
||||
path: '/workspace/image.png',
|
||||
workingDirectory: '/workspace',
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
accept: 'image',
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/image.png',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
url: 'localfile://file/workspace/image.png?token=abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward user-approved external preview URL access', async () => {
|
||||
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
|
||||
'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
|
||||
);
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreviewUrl({
|
||||
allowExternalFile: true,
|
||||
path: '/tmp/worktree-switcher-demo.html',
|
||||
workingDirectory: '/tmp',
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
|
||||
allowExternalFile: true,
|
||||
accept: undefined,
|
||||
filePath: '/tmp/worktree-switcher-demo.html',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
url: 'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocalFilePreview', () => {
|
||||
@@ -311,8 +263,6 @@ describe('LocalFileCtr', () => {
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
accept: undefined,
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/app.ts',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
@@ -339,64 +289,6 @@ describe('LocalFileCtr', () => {
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward image-only preview read constraints', async () => {
|
||||
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue({
|
||||
buffer: Buffer.from('image-bytes'),
|
||||
contentType: 'image/png',
|
||||
realPath: '/workspace/image.png',
|
||||
});
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreview({
|
||||
accept: 'image',
|
||||
path: '/workspace/image.png',
|
||||
workingDirectory: '/workspace',
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
accept: 'image',
|
||||
allowExternalFile: undefined,
|
||||
filePath: '/workspace/image.png',
|
||||
workspaceRoot: '/workspace',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
preview: {
|
||||
base64: Buffer.from('image-bytes').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
type: 'image',
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward user-approved external preview reads', async () => {
|
||||
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue({
|
||||
buffer: Buffer.from('<h1>Demo</h1>'),
|
||||
contentType: 'text/html',
|
||||
realPath: '/tmp/worktree-switcher-demo.html',
|
||||
});
|
||||
|
||||
const result = await localFileCtr.getLocalFilePreview({
|
||||
allowExternalFile: true,
|
||||
path: '/tmp/worktree-switcher-demo.html',
|
||||
workingDirectory: '/tmp',
|
||||
});
|
||||
|
||||
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
|
||||
allowExternalFile: true,
|
||||
accept: undefined,
|
||||
filePath: '/tmp/worktree-switcher-demo.html',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
preview: {
|
||||
content: '<h1>Demo</h1>',
|
||||
contentType: 'text/html',
|
||||
type: 'text',
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleWriteFile', () => {
|
||||
|
||||
@@ -21,7 +21,6 @@ const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
|
||||
|
||||
const logger = createLogger('core:LocalFileProtocolManager');
|
||||
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
|
||||
const EXTERNAL_PREVIEW_APPROVAL_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
const normalizeAbsolutePath = (filePath: string): string | null => {
|
||||
const normalized = path.normalize(filePath);
|
||||
@@ -55,18 +54,6 @@ export interface PreviewFileReadResult {
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
type PreviewFileAccept = 'image';
|
||||
|
||||
const normalizeContentType = (contentType: string): string =>
|
||||
contentType.split(';')[0].trim().toLowerCase();
|
||||
|
||||
const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileAccept): boolean => {
|
||||
if (!accept) return true;
|
||||
|
||||
const normalizedContentType = normalizeContentType(contentType);
|
||||
return accept === 'image' && normalizedContentType.startsWith('image/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom `localfile://` protocol for project file previews.
|
||||
*
|
||||
@@ -82,8 +69,6 @@ const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileA
|
||||
export class LocalFileProtocolManager {
|
||||
private readonly approvedWorkspaceRoots = new Set<string>();
|
||||
|
||||
private readonly externalPreviewApprovals = new Map<string, number>();
|
||||
|
||||
private readonly indexedProjectRoots = new Set<string>();
|
||||
|
||||
private handlerRegistered = false;
|
||||
@@ -228,29 +213,16 @@ export class LocalFileProtocolManager {
|
||||
}
|
||||
|
||||
async createPreviewUrl({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
accept?: PreviewFileAccept;
|
||||
allowExternalFile?: boolean;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<string | null> {
|
||||
const normalizedFilePath = normalizeAbsolutePath(filePath);
|
||||
if (!normalizedFilePath) return null;
|
||||
|
||||
const realFilePath = accept
|
||||
? (
|
||||
await this.readPreviewFile({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
})
|
||||
)?.realPath
|
||||
: await this.resolveApprovedPreviewPath({ allowExternalFile, filePath, workspaceRoot });
|
||||
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
|
||||
if (!realFilePath) return null;
|
||||
|
||||
this.cleanupExpiredTokens();
|
||||
@@ -265,38 +237,22 @@ export class LocalFileProtocolManager {
|
||||
}
|
||||
|
||||
async readPreviewFile({
|
||||
accept,
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
accept?: PreviewFileAccept;
|
||||
allowExternalFile?: boolean;
|
||||
filePath: string;
|
||||
workspaceRoot: string;
|
||||
}): Promise<PreviewFileReadResult | null> {
|
||||
const realFilePath = await this.resolveApprovedPreviewPath({
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
persistExternalApproval: false,
|
||||
workspaceRoot,
|
||||
});
|
||||
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
|
||||
if (!realFilePath) return null;
|
||||
|
||||
const fileStat = await stat(realFilePath);
|
||||
if (!fileStat.isFile()) return null;
|
||||
|
||||
const buffer = await readFile(realFilePath);
|
||||
const contentType = resolveLocalFileMimeType(realFilePath, buffer);
|
||||
if (!isAcceptedPreviewContentType(contentType, accept)) return null;
|
||||
|
||||
if (allowExternalFile) {
|
||||
this.grantExternalPreviewApproval(realFilePath);
|
||||
}
|
||||
|
||||
return {
|
||||
buffer,
|
||||
contentType,
|
||||
contentType: resolveLocalFileMimeType(realFilePath, buffer),
|
||||
realPath: realFilePath,
|
||||
};
|
||||
}
|
||||
@@ -341,14 +297,10 @@ export class LocalFileProtocolManager {
|
||||
}
|
||||
|
||||
private async resolveApprovedPreviewPath({
|
||||
allowExternalFile,
|
||||
filePath,
|
||||
persistExternalApproval = true,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
allowExternalFile?: boolean;
|
||||
filePath: string;
|
||||
persistExternalApproval?: boolean;
|
||||
workspaceRoot: string;
|
||||
}): Promise<string | null> {
|
||||
const normalizedFilePath = normalizeAbsolutePath(filePath);
|
||||
@@ -363,44 +315,15 @@ export class LocalFileProtocolManager {
|
||||
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
|
||||
|
||||
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
|
||||
const workspaceRootApproved =
|
||||
this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) ||
|
||||
this.indexedProjectRoots.has(normalizedRealWorkspaceRoot);
|
||||
if (
|
||||
workspaceRootApproved &&
|
||||
isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)
|
||||
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
|
||||
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
|
||||
) {
|
||||
return normalizedRealFilePath;
|
||||
return null;
|
||||
}
|
||||
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
|
||||
|
||||
if (this.hasExternalPreviewApproval(normalizedRealFilePath)) return normalizedRealFilePath;
|
||||
|
||||
if (allowExternalFile) {
|
||||
return this.approveExternalPreviewFile(normalizedRealFilePath, {
|
||||
persist: persistExternalApproval,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async approveExternalPreviewFile(
|
||||
realFilePath: string,
|
||||
{ persist = true }: { persist?: boolean } = {},
|
||||
): Promise<string | null> {
|
||||
const fileStat = await stat(realFilePath);
|
||||
if (!fileStat.isFile()) return null;
|
||||
|
||||
if (persist) {
|
||||
this.grantExternalPreviewApproval(realFilePath);
|
||||
}
|
||||
|
||||
return realFilePath;
|
||||
}
|
||||
|
||||
private grantExternalPreviewApproval(realFilePath: string) {
|
||||
this.cleanupExpiredExternalPreviewApprovals();
|
||||
this.externalPreviewApprovals.set(realFilePath, Date.now() + EXTERNAL_PREVIEW_APPROVAL_TTL_MS);
|
||||
return normalizedRealFilePath;
|
||||
}
|
||||
|
||||
private cleanupExpiredTokens() {
|
||||
@@ -412,15 +335,6 @@ export class LocalFileProtocolManager {
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupExpiredExternalPreviewApprovals() {
|
||||
const now = Date.now();
|
||||
for (const [realPath, expiresAt] of this.externalPreviewApprovals) {
|
||||
if (expiresAt <= now) {
|
||||
this.externalPreviewApprovals.delete(realPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private hasPreviewToken(token: string): boolean {
|
||||
const record = this.previewTokens.get(token);
|
||||
if (!record) return false;
|
||||
@@ -439,16 +353,4 @@ export class LocalFileProtocolManager {
|
||||
|
||||
return record.realPath === realResolvedPath;
|
||||
}
|
||||
|
||||
private hasExternalPreviewApproval(realFilePath: string): boolean {
|
||||
const expiresAt = this.externalPreviewApprovals.get(realFilePath);
|
||||
if (!expiresAt) return false;
|
||||
|
||||
if (expiresAt <= Date.now()) {
|
||||
this.externalPreviewApprovals.delete(realFilePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,6 @@ export interface ToolStatus {
|
||||
error?: string;
|
||||
lastChecked?: Date;
|
||||
path?: string;
|
||||
/**
|
||||
* PATH value used to resolve/validate the command, surfaced only when it
|
||||
* differs from the detector process's `process.env.PATH` (e.g. resolution
|
||||
* fell back to the login-shell PATH). A caller that spawns the resolved
|
||||
* `path` must carry this into the child's PATH, or a `#!/usr/bin/env node`
|
||||
* shim that resolved here still fails with `env: node: No such file or
|
||||
* directory` under the leaner inherited env.
|
||||
*/
|
||||
resolvedPathEnv?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -119,21 +119,6 @@ describe('LocalFileProtocolManager', () => {
|
||||
expect(response.headers.get('Content-Type')).toBe('text/plain; charset=utf-8');
|
||||
});
|
||||
|
||||
it('does not mint image-only preview URLs for text files', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveWorkspaceRoot('/Users/alice/project');
|
||||
mockReadFile.mockResolvedValue(Buffer.from('const value = 1;'));
|
||||
|
||||
const url = await manager.createPreviewUrl({
|
||||
accept: 'image',
|
||||
filePath: '/Users/alice/project/App.tsx',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
|
||||
expect(url).toBeNull();
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
|
||||
});
|
||||
|
||||
it('decodes percent-encoded characters in the path', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
manager.registerHandler();
|
||||
@@ -263,31 +248,6 @@ describe('LocalFileProtocolManager', () => {
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('mints preview URLs for user-approved external files only', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
|
||||
const url = await manager.createPreviewUrl({
|
||||
allowExternalFile: true,
|
||||
filePath: '/tmp/worktree-switcher-demo.html',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
if (!url) throw new Error('Expected external local file preview URL');
|
||||
|
||||
expect(url).toContain('token=');
|
||||
|
||||
const repeatedUrl = await manager.createPreviewUrl({
|
||||
filePath: '/tmp/worktree-switcher-demo.html',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
expect(repeatedUrl).toContain('token=');
|
||||
|
||||
const neighborUrl = await manager.createPreviewUrl({
|
||||
filePath: '/tmp/other.html',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
expect(neighborUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('can approve a project root derived from an already approved nested scope', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveWorkspaceRoot('/Users/alice/project/packages/app');
|
||||
@@ -336,41 +296,6 @@ describe('LocalFileProtocolManager', () => {
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/App.tsx');
|
||||
});
|
||||
|
||||
it('does not return text payloads for image-only preview reads', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveIndexedProjectRoot('/Users/alice/project');
|
||||
mockReadFile.mockResolvedValue(Buffer.from('SECRET=value'));
|
||||
|
||||
const result = await manager.readPreviewFile({
|
||||
accept: 'image',
|
||||
filePath: '/Users/alice/project/.env',
|
||||
workspaceRoot: '/Users/alice/project',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/.env');
|
||||
});
|
||||
|
||||
it('does not keep external approval when an image-only external preview rejects text', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
mockReadFile.mockResolvedValue(Buffer.from('SECRET=value'));
|
||||
|
||||
const result = await manager.readPreviewFile({
|
||||
accept: 'image',
|
||||
allowExternalFile: true,
|
||||
filePath: '/tmp/secret.txt',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
|
||||
const repeatedUrl = await manager.createPreviewUrl({
|
||||
filePath: '/tmp/secret.txt',
|
||||
workspaceRoot: '/tmp',
|
||||
});
|
||||
expect(repeatedUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('does not read preview payloads outside the approved workspace root', async () => {
|
||||
const manager = new LocalFileProtocolManager();
|
||||
await manager.approveIndexedProjectRoot('/Users/alice/project');
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as childProcess from 'node:child_process';
|
||||
import * as os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -181,76 +180,6 @@ describe('cliAgentDetectors', () => {
|
||||
expect(status.path).toBe('/usr/local/bin/claude');
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2);
|
||||
// Resolved on the inherited PATH — nothing extra to carry into spawn.
|
||||
expect(status.resolvedPathEnv).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the Codex.app bundled CLI when `codex` is not on any PATH', async () => {
|
||||
const originalPath = process.env.PATH;
|
||||
const originalShell = process.env.SHELL;
|
||||
// Deterministic env: no SHELL → no login-shell lookup, merged PATH
|
||||
// equals process.env.PATH → no second `which` attempt.
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
delete process.env.SHELL;
|
||||
|
||||
try {
|
||||
callExecFileError(new Error('not found')); // which codex
|
||||
callExecFile('codex-cli 0.138.0'); // bundled CLI --version
|
||||
|
||||
const { codexDetector } = await import('../cliAgentDetectors');
|
||||
const status = await codexDetector.detect();
|
||||
|
||||
expect(status.available).toBe(true);
|
||||
expect(status.path).toBe('/Applications/Codex.app/Contents/Resources/codex');
|
||||
expect(status.version).toBe('codex-cli 0.138.0');
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2);
|
||||
expect(execFileMock.mock.calls[0]![0]).toBe('which');
|
||||
expect(execFileMock.mock.calls[1]![0]).toBe(
|
||||
'/Applications/Codex.app/Contents/Resources/codex',
|
||||
);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalShell === undefined) delete process.env.SHELL;
|
||||
else process.env.SHELL = originalShell;
|
||||
}
|
||||
});
|
||||
|
||||
it('stays unavailable when neither PATH nor the well-known locations have codex', async () => {
|
||||
const originalPath = process.env.PATH;
|
||||
const originalShell = process.env.SHELL;
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
delete process.env.SHELL;
|
||||
|
||||
try {
|
||||
callExecFileError(new Error('not found')); // which codex
|
||||
callExecFileError(new Error('ENOENT')); // /Applications candidate
|
||||
callExecFileError(new Error('ENOENT')); // ~/Applications candidate
|
||||
|
||||
const { codexDetector } = await import('../cliAgentDetectors');
|
||||
const status = await codexDetector.detect();
|
||||
|
||||
expect(status.available).toBe(false);
|
||||
expect(execFileMock).toHaveBeenCalledTimes(3);
|
||||
expect(execFileMock.mock.calls[2]![0]).toBe(
|
||||
path.join(os.homedir(), 'Applications', 'Codex.app', 'Contents', 'Resources', 'codex'),
|
||||
);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalShell === undefined) delete process.env.SHELL;
|
||||
else process.env.SHELL = originalShell;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not probe well-known locations for an explicit path-like command', async () => {
|
||||
callExecFileError(new Error('ENOENT')); // /custom/bin/codex --version
|
||||
|
||||
const { detectHeterogeneousCliCommand } = await import('../cliAgentDetectors');
|
||||
const status = await detectHeterogeneousCliCommand('codex', '/custom/bin/codex');
|
||||
|
||||
expect(status.available).toBe(false);
|
||||
// Only the explicit path's --version attempt — no fallback probing.
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the login shell PATH for tools installed by shell setup', async () => {
|
||||
@@ -271,12 +200,6 @@ describe('cliAgentDetectors', () => {
|
||||
expect(status.available).toBe(true);
|
||||
expect(status.path).toBe('/Users/Hanam/.local/share/mise/shims/gemini');
|
||||
expect(status.version).toBe('gemini 0.2.0');
|
||||
// The login-shell PATH that resolved the shim must be surfaced so the
|
||||
// spawn site can carry it into the child env (mise/nvm `node` lives
|
||||
// there, not on the leaner inherited PATH).
|
||||
expect(status.resolvedPathEnv).toBe(
|
||||
'/opt/homebrew/bin:/Users/Hanam/.local/share/mise/shims:/usr/bin:/bin',
|
||||
);
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(4);
|
||||
expect(execFileMock.mock.calls[0]![0]).toBe('which');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { exec, execFile } from 'node:child_process';
|
||||
import { homedir, platform } from 'node:os';
|
||||
import { platform } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
@@ -190,11 +190,6 @@ const detectValidatedCommand = async (
|
||||
return {
|
||||
available: true,
|
||||
path: resolvedPath,
|
||||
// `env` is set only when resolution fell back to the login-shell PATH.
|
||||
// Surface that PATH so the spawn site can carry it into the child env —
|
||||
// otherwise a `#!/usr/bin/env node` shim resolved here can't find `node`
|
||||
// under the leaner inherited PATH (Finder-launched Electron).
|
||||
resolvedPathEnv: env?.PATH,
|
||||
version: output.split(/\r?\n/)[0],
|
||||
};
|
||||
} catch {
|
||||
@@ -214,27 +209,6 @@ const HETEROGENEOUS_CLI_AGENT_OPTIONS = {
|
||||
Pick<ValidatedDetectorOptions, 'validateKeywords'>
|
||||
>;
|
||||
|
||||
// Well-known absolute install locations probed when a bare command isn't on
|
||||
// PATH. The Codex desktop app bundles a fully functional CLI inside Codex.app
|
||||
// (sharing ~/.codex auth/config) but never symlinks it into PATH, so
|
||||
// `which codex` misses an otherwise working install.
|
||||
const getWellKnownCommandPaths = (agentType: HeterogeneousCliAgentType): string[] => {
|
||||
if (platform() !== 'darwin') return [];
|
||||
|
||||
switch (agentType) {
|
||||
case 'codex': {
|
||||
const bundledCli = path.join('Codex.app', 'Contents', 'Resources', 'codex');
|
||||
return [
|
||||
path.join('/Applications', bundledCli),
|
||||
path.join(homedir(), 'Applications', bundledCli),
|
||||
];
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const detectHeterogeneousCliCommand = async (
|
||||
agentType: HeterogeneousCliAgentType,
|
||||
command: string,
|
||||
@@ -242,20 +216,7 @@ export const detectHeterogeneousCliCommand = async (
|
||||
const validator = HETEROGENEOUS_CLI_AGENT_OPTIONS[agentType];
|
||||
if (!validator) return { available: false };
|
||||
|
||||
const status = await detectValidatedCommand(command, validator);
|
||||
if (status.available) return status;
|
||||
|
||||
// A bare command missing from PATH may still live at a well-known install
|
||||
// location (e.g. the Codex desktop app's bundled CLI). Don't second-guess
|
||||
// an explicit user-configured path.
|
||||
if (!command.trim().includes(path.sep)) {
|
||||
for (const candidate of getWellKnownCommandPaths(agentType)) {
|
||||
const fallbackStatus = await detectValidatedCommand(candidate, validator);
|
||||
if (fallbackStatus.available) return fallbackStatus;
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
return detectValidatedCommand(command, validator);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -300,17 +261,14 @@ export const claudeCodeDetector: IToolDetector = createValidatedDetector({
|
||||
/**
|
||||
* OpenAI Codex CLI
|
||||
* @see https://github.com/openai/codex
|
||||
*
|
||||
* Goes through `detectHeterogeneousCliCommand` so the Codex.app bundled-CLI
|
||||
* fallback applies here too, keeping the manager path and the custom-command
|
||||
* path in sync.
|
||||
*/
|
||||
export const codexDetector: IToolDetector = {
|
||||
export const codexDetector: IToolDetector = createValidatedDetector({
|
||||
candidates: ['codex'],
|
||||
description: 'Codex - OpenAI agentic coding CLI',
|
||||
detect: () => detectHeterogeneousCliCommand('codex', 'codex'),
|
||||
name: 'codex',
|
||||
priority: 2,
|
||||
};
|
||||
validateKeywords: ['codex'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Google Gemini CLI
|
||||
|
||||
@@ -15,21 +15,13 @@ const mocks = vi.hoisted(() => ({
|
||||
),
|
||||
}));
|
||||
|
||||
interface MockGlobalConfigOptions {
|
||||
agentGatewayUrl?: string;
|
||||
enableAgentGateway?: boolean;
|
||||
}
|
||||
|
||||
const mockGlobalConfigDependencies = (
|
||||
enableBusinessFeatures: boolean,
|
||||
options: MockGlobalConfigOptions = {},
|
||||
) => {
|
||||
const mockGlobalConfigDependencies = (enableBusinessFeatures: boolean) => {
|
||||
vi.doMock('@lobechat/business-const', () => ({
|
||||
ENABLE_BUSINESS_FEATURES: enableBusinessFeatures,
|
||||
}));
|
||||
|
||||
vi.doMock('@/config/composio', () => ({
|
||||
composioEnv: {},
|
||||
vi.doMock('@/config/klavis', () => ({
|
||||
klavisEnv: {},
|
||||
}));
|
||||
|
||||
vi.doMock('@/const/version', () => ({
|
||||
@@ -37,12 +29,7 @@ const mockGlobalConfigDependencies = (
|
||||
}));
|
||||
|
||||
vi.doMock('@/envs/app', () => ({
|
||||
appEnv: {
|
||||
...(options.agentGatewayUrl ? { AGENT_GATEWAY_URL: options.agentGatewayUrl } : {}),
|
||||
...(options.enableAgentGateway === undefined
|
||||
? {}
|
||||
: { ENABLE_AGENT_GATEWAY: options.enableAgentGateway }),
|
||||
},
|
||||
appEnv: {},
|
||||
getAppConfig: vi.fn(() => ({
|
||||
DEFAULT_AGENT_CONFIG: '',
|
||||
})),
|
||||
@@ -126,18 +113,6 @@ const loadCapturedProviderConfig = async (enableBusinessFeatures: boolean) => {
|
||||
>;
|
||||
};
|
||||
|
||||
const loadServerConfig = async (
|
||||
enableBusinessFeatures: boolean,
|
||||
options?: MockGlobalConfigOptions,
|
||||
) => {
|
||||
vi.resetModules();
|
||||
mocks.genServerAiProvidersConfig.mockClear();
|
||||
mockGlobalConfigDependencies(enableBusinessFeatures, options);
|
||||
|
||||
const { getServerGlobalConfig } = await import('./index');
|
||||
return getServerGlobalConfig();
|
||||
};
|
||||
|
||||
describe('getServerGlobalConfig', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -164,36 +139,4 @@ describe('getServerGlobalConfig', () => {
|
||||
expect(providerConfig[ModelProvider.OpenAI]).toBeUndefined();
|
||||
expect(providerConfig[ModelProvider.DeepSeek].enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable gateway mode for business builds', async () => {
|
||||
await expect(loadServerConfig(true)).resolves.toMatchObject({
|
||||
enableGatewayMode: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable gateway mode for self-hosted builds only when explicitly enabled with a gateway url', async () => {
|
||||
await expect(
|
||||
loadServerConfig(false, {
|
||||
agentGatewayUrl: 'https://gateway.test.com',
|
||||
enableAgentGateway: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
agentGatewayUrl: 'https://gateway.test.com',
|
||||
enableGatewayMode: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadServerConfig(false, {
|
||||
agentGatewayUrl: 'https://gateway.test.com',
|
||||
enableAgentGateway: false,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
agentGatewayUrl: 'https://gateway.test.com',
|
||||
enableGatewayMode: false,
|
||||
});
|
||||
|
||||
await expect(loadServerConfig(false, { enableAgentGateway: true })).resolves.toMatchObject({
|
||||
enableGatewayMode: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
|
||||
import { ModelProvider } from 'model-bank';
|
||||
|
||||
import { composioEnv } from '@/config/composio';
|
||||
import { klavisEnv } from '@/config/klavis';
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { appEnv, getAppConfig } from '@/envs/app';
|
||||
import { authEnv } from '@/envs/auth';
|
||||
@@ -104,9 +104,7 @@ export const getServerGlobalConfig = async () => {
|
||||
disableEmailPassword: authEnv.AUTH_DISABLE_EMAIL_PASSWORD,
|
||||
enableBusinessFeatures: ENABLE_BUSINESS_FEATURES,
|
||||
enableEmailVerification: authEnv.AUTH_EMAIL_VERIFICATION,
|
||||
enableComposio: !!composioEnv.COMPOSIO_API_KEY,
|
||||
enableGatewayMode:
|
||||
ENABLE_BUSINESS_FEATURES || (!!appEnv.ENABLE_AGENT_GATEWAY && !!appEnv.AGENT_GATEWAY_URL),
|
||||
enableKlavis: !!klavisEnv.KLAVIS_API_KEY,
|
||||
enableLobehubSkill: !!(appEnv.MARKET_TRUSTED_CLIENT_SECRET && appEnv.MARKET_TRUSTED_CLIENT_ID),
|
||||
enableMagicLink: authEnv.AUTH_ENABLE_MAGIC_LINK,
|
||||
enableMarketTrustedClient: !!(
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
} from '@lobechat/agent-runtime';
|
||||
import { LobeActivatorIdentifier } from '@lobechat/builtin-tool-activator';
|
||||
import {
|
||||
type ComposioServiceSummary,
|
||||
type CredSummary,
|
||||
generateComposioServicesList,
|
||||
generateCredsList,
|
||||
generateKlavisServicesList,
|
||||
type KlavisServiceSummary,
|
||||
} from '@lobechat/builtin-tool-creds';
|
||||
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
|
||||
import { BRANDING_PROVIDER } from '@lobechat/business-const';
|
||||
import { COMPOSIO_APP_TYPES } from '@lobechat/const';
|
||||
import { KLAVIS_SERVER_TYPES } from '@lobechat/const';
|
||||
import {
|
||||
type AgentContextDocument,
|
||||
type AgentGroupConfig,
|
||||
@@ -38,12 +38,7 @@ import {
|
||||
ToolResolver,
|
||||
} from '@lobechat/context-engine';
|
||||
import { parse } from '@lobechat/conversation-flow';
|
||||
import {
|
||||
applyModelExtendParams,
|
||||
type ChatStreamPayload,
|
||||
consumeStreamUntilDone,
|
||||
type ModelExtendParams,
|
||||
} from '@lobechat/model-runtime';
|
||||
import { consumeStreamUntilDone } from '@lobechat/model-runtime';
|
||||
import {
|
||||
context as otelContext,
|
||||
SpanKind,
|
||||
@@ -66,15 +61,13 @@ import { chainCompressContext } from '@lobechat/prompts';
|
||||
import {
|
||||
type ChatToolPayload,
|
||||
type ExecSubAgentParams,
|
||||
type ExecVirtualSubAgentParams,
|
||||
type MessageToolCall,
|
||||
type UIChatMessage,
|
||||
} from '@lobechat/types';
|
||||
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
|
||||
import debug from 'debug';
|
||||
import { type ExtendParamsType, 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 +79,6 @@ import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/type
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { AgentDocumentsService } from '@/server/services/agentDocuments';
|
||||
import type { HookDispatcher } from '@/server/services/agentRuntime/hooks/HookDispatcher';
|
||||
import type {
|
||||
ExecGroupMemberParams,
|
||||
ExecGroupMemberResult,
|
||||
} from '@/server/services/agentRuntime/types';
|
||||
import {
|
||||
type DeviceAccessReason,
|
||||
isDeviceToolIdentifier,
|
||||
@@ -99,7 +88,6 @@ import { FileService } from '@/server/services/file';
|
||||
import { MessageService } from '@/server/services/message';
|
||||
import { OnboardingService } from '@/server/services/onboarding';
|
||||
import {
|
||||
type ServerAgentMemberRunner,
|
||||
type ServerSubAgentRunner,
|
||||
type ToolExecutionResultResponse,
|
||||
type ToolExecutionService,
|
||||
@@ -335,7 +323,7 @@ const buildPostProcessUrl = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the per-tool-call server virtual sub-agent runner injected into the tool
|
||||
* Build the per-tool-call server sub-agent runner injected into the tool
|
||||
* execution context. Closes over the current tool payload + parent message so
|
||||
* the `callSubAgent` server tool can fork a child op without re-deriving the
|
||||
* message anchor (which it cannot do correctly from its own context).
|
||||
@@ -343,18 +331,17 @@ const buildPostProcessUrl = (
|
||||
* The runner creates the pending placeholder tool message that anchors the
|
||||
* isolation thread (so the UI shows a loading state and the completion bridge
|
||||
* has a message to backfill), then kicks off the child op asynchronously and
|
||||
* returns immediately. Returns `undefined` when virtual sub-agent execution is
|
||||
* not available (no `execVirtualSubAgent` callback, or missing agent/topic
|
||||
* context).
|
||||
* returns immediately. Returns `undefined` when sub-agent execution is not
|
||||
* available (no `execSubAgent` callback, or missing agent/topic context).
|
||||
*/
|
||||
const buildServerVirtualSubAgentRunner = (
|
||||
const buildServerSubAgentRunner = (
|
||||
ctx: RuntimeExecutorContext,
|
||||
state: AgentState,
|
||||
chatToolPayload: ChatToolPayload,
|
||||
parentMessageId: string,
|
||||
): ServerSubAgentRunner | undefined => {
|
||||
const execVirtualSubAgent = ctx.execVirtualSubAgent;
|
||||
if (!execVirtualSubAgent) return undefined;
|
||||
const execSubAgent = ctx.execSubAgent;
|
||||
if (!execSubAgent) return undefined;
|
||||
|
||||
const agentId = state.metadata?.agentId;
|
||||
const topicId = ctx.topicId ?? state.metadata?.topicId;
|
||||
@@ -377,15 +364,16 @@ const buildServerVirtualSubAgentRunner = (
|
||||
topicId,
|
||||
});
|
||||
|
||||
// 2. Fork the virtual child op anchored to the placeholder. The virtual
|
||||
// entry marks the child as `isSubAgent` and registers the completion
|
||||
// bridge that backfills this tool message and resumes the parent op.
|
||||
const result = (await execVirtualSubAgent({
|
||||
// 2. Fork the child op anchored to the placeholder. `resumeParentOnComplete`
|
||||
// tells execSubAgent to register the completion bridge that
|
||||
// backfills this tool message and resumes the parent op.
|
||||
const result = (await execSubAgent({
|
||||
agentId: targetAgentId ?? agentId,
|
||||
groupId: state.metadata?.groupId ?? undefined,
|
||||
instruction,
|
||||
parentMessageId: placeholder.id,
|
||||
parentOperationId: ctx.operationId,
|
||||
resumeParentOnComplete: true,
|
||||
timeout,
|
||||
title: description,
|
||||
topicId,
|
||||
@@ -399,7 +387,7 @@ const buildServerVirtualSubAgentRunner = (
|
||||
await ctx.messageModel.deleteMessage(placeholder.id);
|
||||
} catch (error) {
|
||||
log(
|
||||
'buildServerVirtualSubAgentRunner: failed to clean up placeholder %s: %O',
|
||||
'buildServerSubAgentRunner: failed to clean up placeholder %s: %O',
|
||||
placeholder.id,
|
||||
error,
|
||||
);
|
||||
@@ -416,147 +404,6 @@ const buildServerVirtualSubAgentRunner = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the per-tool "call agent member" runner for the group orchestration
|
||||
* server tool (`lobe-group-management`). Mirrors {@link buildServerVirtualSubAgentRunner}
|
||||
* but for group members: it owns the group tool message (the parked tool call)
|
||||
* and the per-member anchors that drive the K=N member barrier.
|
||||
*
|
||||
* For each `agentMember.run(...)` it:
|
||||
* 1. creates the group tool placeholder (`tool_call_id` = the group-management
|
||||
* call id) stamped with the barrier target + finish disposition;
|
||||
* 2. for a single member uses that placeholder as the member anchor; for
|
||||
* multiple members creates one child anchor per member under it;
|
||||
* 3. forks each member via `ctx.execGroupMember` (in-group or isolated);
|
||||
* 4. backfills anchors for members that failed to start so the barrier can
|
||||
* still complete, and tears everything down when none started.
|
||||
*
|
||||
* Returns `undefined` when group-member execution is unavailable (no
|
||||
* `execGroupMember` callback, or missing agent/topic/group context).
|
||||
*/
|
||||
const buildServerAgentMemberRunner = (
|
||||
ctx: RuntimeExecutorContext,
|
||||
state: AgentState,
|
||||
chatToolPayload: ChatToolPayload,
|
||||
parentMessageId: string,
|
||||
): ServerAgentMemberRunner | undefined => {
|
||||
const execGroupMember = ctx.execGroupMember;
|
||||
if (!execGroupMember) return undefined;
|
||||
|
||||
const agentId = state.metadata?.agentId;
|
||||
const topicId = ctx.topicId ?? state.metadata?.topicId;
|
||||
const groupId = state.metadata?.groupId ?? undefined;
|
||||
if (!agentId || !topicId || !groupId) return undefined;
|
||||
|
||||
return {
|
||||
run: async ({ members, mode, onComplete, disableTools, timeout }) => {
|
||||
const expectedMembers = members.length;
|
||||
if (expectedMembers === 0) return { started: false, startedCount: 0 };
|
||||
|
||||
// 1. Group tool placeholder — the parked tool call the supervisor op waits
|
||||
// on. Stamped with the barrier target + finish disposition so the resume
|
||||
// path (and verify watchdog) resolve resume-vs-finish on their own.
|
||||
const groupTool = await ctx.messageModel.create({
|
||||
agentId,
|
||||
content: '',
|
||||
parentId: parentMessageId,
|
||||
plugin: chatToolPayload as any,
|
||||
pluginState: { expectedMembers, onComplete, status: 'pending' },
|
||||
role: 'tool',
|
||||
threadId: state.metadata?.threadId,
|
||||
tool_call_id: chatToolPayload.id,
|
||||
topicId,
|
||||
});
|
||||
|
||||
// 2. Per-member anchors. A single member collapses onto the group tool
|
||||
// message; multiple members each get a child anchor under it.
|
||||
const anchorIds: string[] = [];
|
||||
if (expectedMembers === 1) {
|
||||
anchorIds.push(groupTool.id);
|
||||
} else {
|
||||
for (let i = 0; i < expectedMembers; i += 1) {
|
||||
const memberToolCallId = `${chatToolPayload.id}::m${i}`;
|
||||
const anchor = await ctx.messageModel.create({
|
||||
agentId,
|
||||
content: '',
|
||||
parentId: groupTool.id,
|
||||
plugin: { ...(chatToolPayload as any), id: memberToolCallId },
|
||||
pluginState: { status: 'pending' },
|
||||
role: 'tool',
|
||||
threadId: state.metadata?.threadId,
|
||||
tool_call_id: memberToolCallId,
|
||||
topicId,
|
||||
});
|
||||
anchorIds.push(anchor.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fork members.
|
||||
let startedCount = 0;
|
||||
await Promise.all(
|
||||
members.map(async (member, i) => {
|
||||
const anchorMessageId = anchorIds[i];
|
||||
try {
|
||||
const result = await execGroupMember({
|
||||
agentId: member.agentId,
|
||||
anchorMessageId,
|
||||
disableTools,
|
||||
expectedMembers,
|
||||
groupId,
|
||||
groupToolMessageId: groupTool.id,
|
||||
instruction: member.instruction,
|
||||
mode,
|
||||
onComplete,
|
||||
parentOperationId: ctx.operationId,
|
||||
timeout,
|
||||
topicId,
|
||||
});
|
||||
if (result?.started) {
|
||||
startedCount += 1;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
log(
|
||||
'buildServerAgentMemberRunner: member %s failed to start: %O',
|
||||
member.agentId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
// Member failed to start — its completion bridge will never fire, so
|
||||
// backfill the anchor as errored to keep the K=N barrier reachable.
|
||||
try {
|
||||
await ctx.messageModel.updateToolMessage(anchorMessageId, {
|
||||
content: `Agent member "${member.agentId}" failed to start.`,
|
||||
pluginState: { status: 'error' },
|
||||
});
|
||||
} catch (error) {
|
||||
log(
|
||||
'buildServerAgentMemberRunner: failed to mark anchor %s as errored: %O',
|
||||
anchorMessageId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// None started — no bridge will ever fire, so tear down the placeholders
|
||||
// and let the caller surface an inline tool error instead of parking.
|
||||
if (startedCount === 0) {
|
||||
for (const id of new Set([...anchorIds, groupTool.id])) {
|
||||
try {
|
||||
await ctx.messageModel.deleteMessage(id);
|
||||
} catch (error) {
|
||||
log('buildServerAgentMemberRunner: cleanup failed for %s: %O', id, error);
|
||||
}
|
||||
}
|
||||
return { started: false, startedCount: 0 };
|
||||
}
|
||||
|
||||
return { started: true, startedCount };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const shouldRetryLLM = (kind: LLMErrorKind, attempt: number, maxRetries: number) =>
|
||||
kind === 'retry' && attempt <= maxRetries;
|
||||
|
||||
@@ -675,23 +522,11 @@ export interface RuntimeExecutorContext {
|
||||
discordContext?: any;
|
||||
evalContext?: EvalContext;
|
||||
/**
|
||||
* Callback to fork a group member ("call agent member") under a
|
||||
* `lobe-group-management` tool call. Injected by AiAgentService; powers the
|
||||
* per-tool `agentMember` runner (in-group + isolated members, K=N barrier).
|
||||
*/
|
||||
execGroupMember?: (params: ExecGroupMemberParams) => Promise<ExecGroupMemberResult>;
|
||||
/**
|
||||
* Callback to run a legacy agent invocation server-side.
|
||||
* Callback to spawn a sub-agent task server-side.
|
||||
* Injected by AiAgentService so exec_sub_agent / exec_sub_agents executors
|
||||
* can dispatch callAgent-triggered runs without a circular import.
|
||||
* can dispatch callAgent-triggered tasks without a circular import.
|
||||
*/
|
||||
execSubAgent?: (params: ExecSubAgentParams) => Promise<unknown>;
|
||||
/**
|
||||
* Callback to fork a `lobe-agent.callSubAgent` virtual child run. Unlike
|
||||
* execSubAgent, this path installs the async completion bridge and marks the
|
||||
* child operation as a sub-agent.
|
||||
*/
|
||||
execVirtualSubAgent?: (params: ExecVirtualSubAgentParams) => Promise<unknown>;
|
||||
hookDispatcher?: HookDispatcher;
|
||||
loadAgentState?: (operationId: string) => Promise<AgentState | null>;
|
||||
messageModel: MessageModel;
|
||||
@@ -879,7 +714,6 @@ export const createRuntimeExecutors = (
|
||||
type ContentPart = { text: string; type: 'text' } | { image: string; type: 'image' };
|
||||
let shouldReplayAssistantReasoning = false;
|
||||
let preserveThinkingForPayload: boolean | undefined;
|
||||
let resolvedExtendParams: ModelExtendParams | undefined;
|
||||
|
||||
// Process messages through serverMessagesEngine to inject system role, knowledge, etc.
|
||||
// Rebuild params from agentConfig at execution time (capabilities built dynamically)
|
||||
@@ -895,39 +729,19 @@ export const createRuntimeExecutors = (
|
||||
: undefined;
|
||||
const preserveThinkingRequested = preserveThinkingConfigured === true;
|
||||
|
||||
const readExtendParams = (
|
||||
card: (typeof builtinModels)[number] | undefined,
|
||||
): string[] | undefined =>
|
||||
card &&
|
||||
'settings' in card &&
|
||||
card.settings &&
|
||||
typeof card.settings === 'object' &&
|
||||
'extendParams' in card.settings
|
||||
? (card.settings as { extendParams?: string[] }).extendParams
|
||||
: undefined;
|
||||
|
||||
const modelCard = builtinModels.find(
|
||||
(item) =>
|
||||
item.providerId === provider &&
|
||||
(item.id === model || item.config?.deploymentName === model),
|
||||
);
|
||||
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 +756,6 @@ export const createRuntimeExecutors = (
|
||||
modelSupportsPreserveThinking && typeof preserveThinkingConfigured === 'boolean'
|
||||
? preserveThinkingConfigured
|
||||
: undefined;
|
||||
|
||||
// Resolve model extend params (thinkingLevel, reasoning effort, urlContext, …)
|
||||
// from the agent chat config so the server-side agent runtime forwards the same
|
||||
// runtime params the client chat service does. Without this, e.g. Gemini 3 Pro's
|
||||
// `thinkingLevel` never reaches the request and thought summaries come back empty.
|
||||
if (agentConfig.chatConfig) {
|
||||
resolvedExtendParams = applyModelExtendParams({
|
||||
chatConfig: agentConfig.chatConfig,
|
||||
extendParams: modelExtendParams as ExtendParamsType[] | undefined,
|
||||
model,
|
||||
});
|
||||
}
|
||||
|
||||
const messagesForContext = shouldReplayAssistantReasoning
|
||||
? (llmPayload.messages as UIChatMessage[])
|
||||
: stripAssistantReasoningForReplay(llmPayload.messages as UIChatMessage[]);
|
||||
@@ -1191,38 +992,39 @@ export const createRuntimeExecutors = (
|
||||
}
|
||||
}
|
||||
|
||||
// {{COMPOSIO_SERVICES_LIST}} — used by lobe-creds system role (Composio integrations section).
|
||||
let composioServicesListStr = '';
|
||||
if (ctx.serverDB && ctx.userId && !!composioEnv.COMPOSIO_API_KEY) {
|
||||
// {{KLAVIS_SERVICES_LIST}} — used by lobe-creds system role (Klavis integrations section).
|
||||
// Mirrors client-side: klavisStoreSelectors.getServers() filtered by connection status.
|
||||
let klavisServicesListStr = '';
|
||||
if (ctx.serverDB && ctx.userId && !!klavisEnv.KLAVIS_API_KEY) {
|
||||
try {
|
||||
const { PluginModel } = await import('@/database/models/plugin');
|
||||
const pluginModel = new PluginModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
|
||||
const allPlugins = await pluginModel.query();
|
||||
const validComposioIds = new Set(COMPOSIO_APP_TYPES.map((t) => t.identifier));
|
||||
const validKlavisIds = new Set(KLAVIS_SERVER_TYPES.map((t) => t.identifier));
|
||||
const connectedIds = new Set(
|
||||
allPlugins
|
||||
.filter(
|
||||
(p) =>
|
||||
validComposioIds.has(p.identifier) &&
|
||||
(p.customParams as any)?.composio?.status === 'ACTIVE',
|
||||
validKlavisIds.has(p.identifier) &&
|
||||
(p.customParams as any)?.klavis?.isAuthenticated === true,
|
||||
)
|
||||
.map((p) => p.identifier),
|
||||
);
|
||||
const connected: ComposioServiceSummary[] = COMPOSIO_APP_TYPES.filter((t) =>
|
||||
const connected: KlavisServiceSummary[] = KLAVIS_SERVER_TYPES.filter((t) =>
|
||||
connectedIds.has(t.identifier),
|
||||
).map((t) => ({ identifier: t.identifier, name: t.label }));
|
||||
const available: ComposioServiceSummary[] = COMPOSIO_APP_TYPES.filter(
|
||||
const available: KlavisServiceSummary[] = KLAVIS_SERVER_TYPES.filter(
|
||||
(t) => !connectedIds.has(t.identifier),
|
||||
).map((t) => ({ identifier: t.identifier, name: t.label }));
|
||||
composioServicesListStr = generateComposioServicesList(connected, available);
|
||||
klavisServicesListStr = generateKlavisServicesList(connected, available);
|
||||
log(
|
||||
'Fetched Composio services for {{COMPOSIO_SERVICES_LIST}}: connected=%d, available=%d',
|
||||
'Fetched Klavis services for {{KLAVIS_SERVICES_LIST}}: connected=%d, available=%d',
|
||||
connected.length,
|
||||
available.length,
|
||||
);
|
||||
} catch (error) {
|
||||
log(
|
||||
'Failed to fetch Composio services for {{COMPOSIO_SERVICES_LIST}} substitution: %O',
|
||||
'Failed to fetch Klavis services for {{KLAVIS_SERVICES_LIST}} substitution: %O',
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -1246,18 +1048,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 +1099,6 @@ export const createRuntimeExecutors = (
|
||||
},
|
||||
messages: messagesForContext,
|
||||
model,
|
||||
modelKnowledgeCutoff,
|
||||
provider,
|
||||
systemRole: agentConfig.systemRole ?? undefined,
|
||||
toolDiscoveryConfig,
|
||||
@@ -1403,9 +1198,6 @@ export const createRuntimeExecutors = (
|
||||
model,
|
||||
stream,
|
||||
tools,
|
||||
// ModelExtendParams keeps provider-specific effort/thinking values as loose
|
||||
// strings (e.g. hy3's 'no_think'); the runtime payload narrows them, so cast.
|
||||
...(resolvedExtendParams as Partial<ChatStreamPayload>),
|
||||
...(typeof preserveThinkingForPayload === 'boolean' && {
|
||||
preserveThinking: preserveThinkingForPayload,
|
||||
}),
|
||||
@@ -2647,7 +2439,7 @@ export const createRuntimeExecutors = (
|
||||
execution = { attempts: 1, result: dispatchResult };
|
||||
} else {
|
||||
// Inject source from sourceMap so BuiltinToolsExecutor can route
|
||||
// lobehubSkill / composio tools correctly (LLM responses don't carry source)
|
||||
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
|
||||
if (toolSource && !chatToolPayload.source) {
|
||||
chatToolPayload.source = toolSource;
|
||||
}
|
||||
@@ -2664,14 +2456,7 @@ export const createRuntimeExecutors = (
|
||||
toolExecutionService.executeTool(chatToolPayload, {
|
||||
activeDeviceId: state.metadata?.activeDeviceId,
|
||||
agentId: state.metadata?.agentId,
|
||||
agentMember: buildServerAgentMemberRunner(
|
||||
ctx,
|
||||
state,
|
||||
chatToolPayload,
|
||||
payload.parentMessageId,
|
||||
),
|
||||
documentId: state.metadata?.documentId,
|
||||
editingAgentId: state.metadata?.editingAgentId,
|
||||
execSubAgent: ctx.execSubAgent,
|
||||
executionTimeoutMs: timeoutMs,
|
||||
groupId: state.metadata?.groupId,
|
||||
@@ -2691,7 +2476,7 @@ export const createRuntimeExecutors = (
|
||||
scope: state.metadata?.scope,
|
||||
serverDB: ctx.serverDB,
|
||||
skipResultTruncation: true,
|
||||
subAgent: buildServerVirtualSubAgentRunner(
|
||||
subAgent: buildServerSubAgentRunner(
|
||||
ctx,
|
||||
state,
|
||||
chatToolPayload,
|
||||
@@ -2704,10 +2489,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,
|
||||
}),
|
||||
{
|
||||
@@ -2937,15 +2718,14 @@ export const createRuntimeExecutors = (
|
||||
|
||||
log('[%s:%d] Tool execution completed', operationId, stepIndex);
|
||||
|
||||
// When a legacy callAgent task result carries execSubAgent / execSubAgents
|
||||
// state, the GeneralChatAgent needs `stop: true` in the payload to detect
|
||||
// it and emit the matching exec_sub_agent / exec_sub_agents instruction.
|
||||
// Without this flag the agent falls through to the normal LLM-call path
|
||||
// and the background agent run is never spawned.
|
||||
const legacyAgentInvocationStateType = executionResult.state?.type as string | undefined;
|
||||
const isLegacyAgentInvocationState =
|
||||
legacyAgentInvocationStateType === 'execSubAgent' ||
|
||||
legacyAgentInvocationStateType === 'execSubAgents';
|
||||
// When the tool result carries an execSubAgent / execSubAgents state the
|
||||
// GeneralChatAgent needs `stop: true` in the payload to detect it and
|
||||
// emit the matching exec_sub_agent / exec_sub_agents instruction. Without
|
||||
// this flag the agent falls through to the normal LLM-call path and the
|
||||
// sub-agent is never spawned.
|
||||
const execTaskStateType = executionResult.state?.type as string | undefined;
|
||||
const isExecTaskState =
|
||||
execTaskStateType === 'execSubAgent' || execTaskStateType === 'execSubAgents';
|
||||
|
||||
executeToolSpan.setAttributes(
|
||||
buildExecuteToolResultAttributes({ attempts: execution.attempts, success: isSuccess }),
|
||||
@@ -2961,7 +2741,7 @@ export const createRuntimeExecutors = (
|
||||
isSuccess,
|
||||
// Pass tool message ID as parentMessageId for the next LLM call
|
||||
parentMessageId: toolMessageId,
|
||||
...(isLegacyAgentInvocationState && { stop: true }),
|
||||
...(isExecTaskState && { stop: true }),
|
||||
toolCall: chatToolPayload,
|
||||
toolCallId: chatToolPayload.id,
|
||||
},
|
||||
@@ -3238,7 +3018,7 @@ export const createRuntimeExecutors = (
|
||||
execution = { attempts: 1, result: dispatchResult };
|
||||
} else {
|
||||
// Inject source from sourceMap so BuiltinToolsExecutor can route
|
||||
// lobehubSkill / composio tools correctly (LLM responses don't carry source)
|
||||
// lobehubSkill / klavis tools correctly (LLM responses don't carry source)
|
||||
const batchToolSource =
|
||||
state.operationToolSet?.sourceMap?.[chatToolPayload.identifier] ??
|
||||
state.toolSourceMap?.[chatToolPayload.identifier];
|
||||
@@ -3257,12 +3037,6 @@ export const createRuntimeExecutors = (
|
||||
toolExecutionService.executeTool(chatToolPayload, {
|
||||
activeDeviceId: state.metadata?.activeDeviceId,
|
||||
agentId: state.metadata?.agentId,
|
||||
agentMember: buildServerAgentMemberRunner(
|
||||
ctx,
|
||||
state,
|
||||
chatToolPayload,
|
||||
payload.parentMessageId,
|
||||
),
|
||||
documentId: state.metadata?.documentId,
|
||||
execSubAgent: ctx.execSubAgent,
|
||||
executionTimeoutMs: timeoutMs,
|
||||
@@ -3274,7 +3048,7 @@ export const createRuntimeExecutors = (
|
||||
scope: state.metadata?.scope,
|
||||
serverDB: ctx.serverDB,
|
||||
skipResultTruncation: true,
|
||||
subAgent: buildServerVirtualSubAgentRunner(
|
||||
subAgent: buildServerSubAgentRunner(
|
||||
ctx,
|
||||
state,
|
||||
chatToolPayload,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -132,14 +132,6 @@ describe('formatErrorForState', () => {
|
||||
expect(result.countAsFailure).toBeUndefined();
|
||||
expect(result.numericId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('classifies a raw Drizzle "Failed query" Error via its message instead of a bare 500', () => {
|
||||
const result = formatErrorForState(new Error('Failed query: rollback\nparams: '));
|
||||
|
||||
expect(result.type).toBe(AgentRuntimeErrorType.DatabasePersistError);
|
||||
expect(result.numericId).toBe(7004);
|
||||
expect(result.attribution).toBe('harness');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProviderBizError refinement', () => {
|
||||
|
||||
@@ -659,59 +659,6 @@ describe('createServerAgentToolsEngine', () => {
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
|
||||
it('should disable RemoteDevice when a device is explicitly bound (locked to the selection)', () => {
|
||||
// A user-selected (bound) device locks the run to that device — the
|
||||
// activate-device tool is never offered, so the model cannot switch.
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
|
||||
canUseDevice: true,
|
||||
deviceContext: {
|
||||
autoActivated: true,
|
||||
boundDeviceId: 'device-001',
|
||||
deviceOnline: true,
|
||||
gatewayConfigured: true,
|
||||
},
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [RemoteDeviceManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
|
||||
it('should disable RemoteDevice when the bound device is OFFLINE — no silent hop to another machine', () => {
|
||||
// The bound device going offline makes the plan device-unrouted, so
|
||||
// `autoActivated` is false. Without the `boundDeviceId` gate the tool
|
||||
// would resurface and let the model activate a *different* online device.
|
||||
// The explicit selection must keep the run locked instead.
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
|
||||
canUseDevice: true,
|
||||
deviceContext: {
|
||||
boundDeviceId: 'device-001',
|
||||
deviceOnline: true,
|
||||
gatewayConfigured: true,
|
||||
},
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [RemoteDeviceManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
|
||||
it('should enable RemoteDevice in bot conversations when caller is trusted (canUseDevice=true)', () => {
|
||||
// The `!isBotConversation` clause was dropped in — the
|
||||
// confused-deputy concern that motivated it is now handled at a
|
||||
|
||||
@@ -28,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,
|
||||
@@ -90,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];
|
||||
@@ -161,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
|
||||
@@ -174,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';
|
||||
|
||||
@@ -233,20 +231,12 @@ export const createServerAgentToolsEngine = (
|
||||
// Only auto-enable in bot conversations; otherwise let user's plugin selection take effect
|
||||
...(isBotConversation && { [MessageManifest.identifier]: true }),
|
||||
// Remote-device proxy: shown only for device-capable targets when the
|
||||
// server has a proxy, no specific device is auto-activated yet, AND the
|
||||
// user has NOT explicitly selected a device. Once a device is explicitly
|
||||
// selected (`boundDeviceId`), the run is locked to it: we never expose the
|
||||
// activate-device tool, so the model can never switch to another machine —
|
||||
// not even when the selected device is offline (the run stays unrouted
|
||||
// until that device comes back, rather than silently hopping elsewhere).
|
||||
// External bot senders never reach it: the plan degrades denied targets to
|
||||
// `none` (→ not deviceCapable) and the physical manifest walls drop it for
|
||||
// `canUseDevice=false` turns.
|
||||
// server has a proxy but no specific device is auto-activated yet (user
|
||||
// must pick). External bot senders never reach it: the plan degrades
|
||||
// denied targets to `none` (→ not deviceCapable) and the physical
|
||||
// manifest walls drop it for `canUseDevice=false` turns.
|
||||
[RemoteDeviceManifest.identifier]:
|
||||
deviceCapable &&
|
||||
hasDeviceProxy &&
|
||||
!deviceContext?.autoActivated &&
|
||||
!deviceContext?.boundDeviceId,
|
||||
deviceCapable && hasDeviceProxy && !deviceContext?.autoActivated,
|
||||
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
|
||||
[WebBrowsingManifest.identifier]: isSearchEnabled,
|
||||
};
|
||||
@@ -266,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 .
|
||||
*/
|
||||
|
||||
-19
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('aiChatRouter', () => {
|
||||
expect(mockCreateUserAndAssistantMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateUserAndAssistantMessages).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.not.objectContaining({ touchTopicUpdatedAt: expect.anything() }),
|
||||
expect.objectContaining({ touchTopicUpdatedAt: false }),
|
||||
);
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
@@ -161,7 +161,7 @@ describe('aiChatRouter', () => {
|
||||
expect(mockCreateMessage).toHaveBeenCalled();
|
||||
expect(mockCreateUserAndAssistantMessages).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.not.objectContaining({ touchTopicUpdatedAt: expect.anything() }),
|
||||
expect.objectContaining({ touchTopicUpdatedAt: true }),
|
||||
);
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
-289
@@ -1,289 +0,0 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* Integration test for the server `lobe-agent-management.callAgent` deferred
|
||||
* execution flow.
|
||||
*
|
||||
* Verifies the full lifecycle end-to-end on the in-memory runtime:
|
||||
* 1. Parent op LLM emits a `lobe-agent-management____callAgent` tool call.
|
||||
* 2. The real server executor parks the parent, creates a pending tool
|
||||
* placeholder, and forks the target agent as a child op.
|
||||
* 3. The child op completes.
|
||||
* 4. The completion bridge backfills the placeholder and resumes the parent.
|
||||
* 5. The parent reaches `done`.
|
||||
*/
|
||||
import { type LobeChatDatabase } from '@lobechat/database';
|
||||
import { agentOperations, agents, messagePlugins, messages } from '@lobechat/database/schemas';
|
||||
import { getTestDB } from '@lobechat/database/test-utils';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import OpenAI from 'openai';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { inMemoryAgentStateManager } from '@/server/modules/AgentRuntime/InMemoryAgentStateManager';
|
||||
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
|
||||
|
||||
import { aiAgentRouter } from '../../../aiAgent';
|
||||
import { cleanupTestUser, createTestUser } from '../setup';
|
||||
import { createMockResponsesStream, waitForOperationComplete } from './helpers';
|
||||
|
||||
process.env.OPENAI_API_KEY = 'sk-test-fake-api-key-for-testing';
|
||||
|
||||
let testDB: LobeChatDatabase;
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: vi.fn(() => testDB),
|
||||
}));
|
||||
|
||||
vi.mock('@/server/services/file', () => ({
|
||||
FileService: vi.fn().mockImplementation(() => ({
|
||||
getFullFileUrl: vi.fn().mockImplementation((path: string) => (path ? `/files${path}` : null)),
|
||||
})),
|
||||
}));
|
||||
|
||||
let mockResponsesCreate: any;
|
||||
let serverDB: LobeChatDatabase;
|
||||
let userId: string;
|
||||
let parentAgentId: string;
|
||||
let targetAgentId: string;
|
||||
|
||||
const TARGET_ANSWER = 'The target agent completed the delegated callAgent work.';
|
||||
const PARENT_FINAL = 'I received the target agent result and the delegated work is complete.';
|
||||
|
||||
const createTestContext = () => ({ jwtPayload: { userId }, userId });
|
||||
|
||||
const createCallAgentResponse = () => {
|
||||
const responseId = `resp_call_agent_${Date.now()}`;
|
||||
const msgItemId = `msg_call_agent_${Date.now()}`;
|
||||
const callId = 'call_agent_1';
|
||||
const fnCall = {
|
||||
arguments: JSON.stringify({
|
||||
agentId: targetAgentId,
|
||||
instruction: 'Handle the delegated backend integration task.',
|
||||
runAsTask: true,
|
||||
taskTitle: 'Delegated backend integration task',
|
||||
timeout: 30_000,
|
||||
}),
|
||||
call_id: callId,
|
||||
name: 'lobe-agent-management____callAgent',
|
||||
type: 'function_call',
|
||||
};
|
||||
|
||||
return createMockResponsesStream([
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [],
|
||||
status: 'in_progress',
|
||||
},
|
||||
type: 'response.created',
|
||||
},
|
||||
{
|
||||
item: {
|
||||
content: [],
|
||||
id: msgItemId,
|
||||
role: 'assistant',
|
||||
status: 'in_progress',
|
||||
type: 'message',
|
||||
},
|
||||
output_index: 0,
|
||||
type: 'response.output_item.added',
|
||||
},
|
||||
{
|
||||
content_index: 0,
|
||||
delta: 'I will delegate this to the target agent.',
|
||||
item_id: msgItemId,
|
||||
output_index: 0,
|
||||
type: 'response.output_text.delta',
|
||||
},
|
||||
{ item: fnCall, output_index: 1, type: 'response.output_item.added' },
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [
|
||||
{
|
||||
content: [{ text: 'I will delegate this to the target agent.', type: 'output_text' }],
|
||||
id: msgItemId,
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
type: 'message',
|
||||
},
|
||||
fnCall,
|
||||
],
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 30, output_tokens: 20, total_tokens: 50 },
|
||||
},
|
||||
type: 'response.completed',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const createFinalTextResponse = (content: string) => {
|
||||
const responseId = `resp_final_${Date.now()}_${content.length}`;
|
||||
const msgItemId = `msg_final_${Date.now()}_${content.length}`;
|
||||
|
||||
return createMockResponsesStream([
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [],
|
||||
status: 'in_progress',
|
||||
},
|
||||
type: 'response.created',
|
||||
},
|
||||
{
|
||||
content_index: 0,
|
||||
delta: content,
|
||||
item_id: msgItemId,
|
||||
output_index: 0,
|
||||
type: 'response.output_text.delta',
|
||||
},
|
||||
{
|
||||
response: {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
id: responseId,
|
||||
model: 'gpt-5-pro',
|
||||
object: 'response',
|
||||
output: [
|
||||
{
|
||||
content: [{ text: content, type: 'output_text' }],
|
||||
id: msgItemId,
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
type: 'message',
|
||||
},
|
||||
],
|
||||
status: 'completed',
|
||||
usage: { input_tokens: 40, output_tokens: 20, total_tokens: 60 },
|
||||
},
|
||||
type: 'response.completed',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
serverDB = await getTestDB();
|
||||
testDB = serverDB;
|
||||
userId = await createTestUser(serverDB);
|
||||
|
||||
const insertedAgents = await serverDB
|
||||
.insert(agents)
|
||||
.values([
|
||||
{
|
||||
chatConfig: {},
|
||||
model: 'gpt-5-pro',
|
||||
plugins: ['lobe-agent-management'],
|
||||
provider: 'openai',
|
||||
systemRole: 'You are a supervisor that delegates work to other agents.',
|
||||
title: 'callAgent Supervisor',
|
||||
userId,
|
||||
},
|
||||
{
|
||||
chatConfig: {},
|
||||
model: 'gpt-5-pro',
|
||||
plugins: [],
|
||||
provider: 'openai',
|
||||
systemRole: 'You are the target agent. Return a concise result.',
|
||||
title: 'callAgent Target',
|
||||
userId,
|
||||
},
|
||||
])
|
||||
.returning();
|
||||
|
||||
parentAgentId = insertedAgents[0].id;
|
||||
targetAgentId = insertedAgents[1].id;
|
||||
|
||||
// `create` is overloaded (streaming / non-streaming); its precise spy type
|
||||
// isn't assignable to the generic MockInstance fallback, so widen via unknown.
|
||||
mockResponsesCreate = vi.spyOn(
|
||||
OpenAI.Responses.prototype,
|
||||
'create',
|
||||
) as unknown as typeof mockResponsesCreate;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestUser(serverDB, userId);
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
inMemoryAgentStateManager.clear();
|
||||
inMemoryStreamEventManager.clear();
|
||||
});
|
||||
|
||||
describe('Server callAgent deferred execution', () => {
|
||||
it('parks the parent, runs the target agent, backfills the tool message and resumes', async () => {
|
||||
let callCount = 0;
|
||||
mockResponsesCreate.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return Promise.resolve(createCallAgentResponse() as any);
|
||||
if (callCount === 2) return Promise.resolve(createFinalTextResponse(TARGET_ANSWER) as any);
|
||||
return Promise.resolve(createFinalTextResponse(PARENT_FINAL) as any);
|
||||
});
|
||||
|
||||
const caller = aiAgentRouter.createCaller(createTestContext());
|
||||
|
||||
const createResult = await caller.execAgent({
|
||||
agentId: parentAgentId,
|
||||
prompt: 'Delegate this work to the target agent and report back.',
|
||||
userInterventionConfig: { approvalMode: 'headless' },
|
||||
});
|
||||
expect(createResult.success).toBe(true);
|
||||
|
||||
const finalState = await waitForOperationComplete(
|
||||
inMemoryAgentStateManager,
|
||||
createResult.operationId,
|
||||
{ maxWaitTime: 20_000 },
|
||||
);
|
||||
|
||||
expect(finalState.status).toBe('done');
|
||||
expect(finalState.pendingToolsCalling ?? []).toHaveLength(0);
|
||||
expect(mockResponsesCreate).toHaveBeenCalledTimes(3);
|
||||
|
||||
const childOps = await serverDB
|
||||
.select()
|
||||
.from(agentOperations)
|
||||
.where(eq(agentOperations.parentOperationId, createResult.operationId));
|
||||
expect(childOps).toHaveLength(1);
|
||||
expect(childOps[0]).toMatchObject({
|
||||
agentId: targetAgentId,
|
||||
status: 'done',
|
||||
});
|
||||
|
||||
const toolMessages = await serverDB
|
||||
.select({
|
||||
content: messages.content,
|
||||
role: messages.role,
|
||||
state: messagePlugins.state,
|
||||
identifier: messagePlugins.identifier,
|
||||
apiName: messagePlugins.apiName,
|
||||
toolCallId: messagePlugins.toolCallId,
|
||||
})
|
||||
.from(messages)
|
||||
.innerJoin(messagePlugins, eq(messagePlugins.id, messages.id))
|
||||
.where(
|
||||
and(
|
||||
eq(messages.userId, userId),
|
||||
eq(messagePlugins.identifier, 'lobe-agent-management'),
|
||||
eq(messagePlugins.apiName, 'callAgent'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(toolMessages).toHaveLength(1);
|
||||
expect(toolMessages[0]).toMatchObject({
|
||||
apiName: 'callAgent',
|
||||
content: TARGET_ANSWER,
|
||||
identifier: 'lobe-agent-management',
|
||||
role: 'tool',
|
||||
toolCallId: 'call_agent_1',
|
||||
});
|
||||
expect(toolMessages[0].state).toMatchObject({
|
||||
status: 'completed',
|
||||
threadId: childOps[0].threadId,
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { topicRouter } from '../../topic';
|
||||
import { cleanupTestUser, createTestAgent, createTestContext, createTestUser } from './setup';
|
||||
import { cleanupTestUser, createTestContext, createTestUser } from './setup';
|
||||
|
||||
// We need to mock getServerDB to return our test database instance
|
||||
let testDB: LobeChatDatabase;
|
||||
@@ -332,79 +332,31 @@ describe('Topic Router Integration Tests', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// BM25 search requires pg_search extension (ParadeDB), not available in the
|
||||
// default integration test DB (PGlite). Run with TEST_SERVER_DB=1 +
|
||||
// DATABASE_TEST_URL pointing at a ParadeDB instance to exercise these.
|
||||
// BM25 search requires pg_search extension (ParadeDB), not available in integration test DB
|
||||
describe.skip('searchTopics', () => {
|
||||
it('should search topics using agentId', async () => {
|
||||
const caller = topicRouter.createCaller(createTestContext(userId));
|
||||
|
||||
// Topics are agent-native: stored with agentId directly.
|
||||
await serverDB.insert(topics).values([
|
||||
{ agentId: testAgentId, title: 'TypeScript Discussion', userId },
|
||||
{ agentId: testAgentId, title: 'JavaScript Basics', userId },
|
||||
]);
|
||||
// Create test topics
|
||||
await caller.createTopic({
|
||||
title: 'TypeScript Discussion',
|
||||
sessionId: testSessionId,
|
||||
});
|
||||
|
||||
await caller.createTopic({
|
||||
title: 'JavaScript Basics',
|
||||
sessionId: testSessionId,
|
||||
});
|
||||
|
||||
// Search using agentId
|
||||
const result = await caller.searchTopics({
|
||||
agentId: testAgentId,
|
||||
keywords: 'TypeScript',
|
||||
agentId: testAgentId,
|
||||
});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0].title).toContain('TypeScript');
|
||||
});
|
||||
|
||||
// Regression for the "No topics match these filters" bug: topics created by
|
||||
// the new agent system carry `agentId` directly with a NULL `sessionId`.
|
||||
// The old search resolved agentId -> sessionId and filtered by the
|
||||
// container only, so these rows were never matched even though the topics
|
||||
// list (which filters by agentId) showed them.
|
||||
it('should find agentId-scoped topics that have no sessionId', async () => {
|
||||
const caller = topicRouter.createCaller(createTestContext(userId));
|
||||
|
||||
// Insert a topic the way the agent runtime does: agentId set, sessionId null.
|
||||
await serverDB.insert(topics).values({
|
||||
agentId: testAgentId,
|
||||
sessionId: null,
|
||||
title: 'rinabrown84@gmail.com',
|
||||
userId,
|
||||
});
|
||||
|
||||
const result = await caller.searchTopics({
|
||||
agentId: testAgentId,
|
||||
keywords: 'rinabrown84@gmail.com',
|
||||
});
|
||||
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0].title).toBe('rinabrown84@gmail.com');
|
||||
});
|
||||
|
||||
// The agent scope mirrors the topics list exactly (agentId only). A row that
|
||||
// shares this agent's resolved session but is owned by a DIFFERENT agent
|
||||
// must not leak in — the bug the constrained-session-fallback review flagged.
|
||||
it('should not leak another agent topic that shares the session mapping', async () => {
|
||||
const caller = topicRouter.createCaller(createTestContext(userId));
|
||||
|
||||
const otherAgentId = await createTestAgent(serverDB, userId);
|
||||
|
||||
await serverDB.insert(topics).values([
|
||||
{ agentId: testAgentId, title: 'mine rinabrown84@gmail.com', userId },
|
||||
// Same session, different agent — used to leak via the session fallback.
|
||||
{
|
||||
agentId: otherAgentId,
|
||||
sessionId: testSessionId,
|
||||
title: 'theirs rinabrown84@gmail.com',
|
||||
userId,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.searchTopics({
|
||||
agentId: testAgentId,
|
||||
keywords: 'rinabrown84@gmail.com',
|
||||
});
|
||||
|
||||
expect(result.map((t) => t.title)).toEqual(['mine rinabrown84@gmail.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTopic', () => {
|
||||
@@ -767,7 +719,7 @@ describe('Topic Router Integration Tests', () => {
|
||||
sessionId: testSessionId,
|
||||
});
|
||||
|
||||
const allTopics = await caller.queryTopics();
|
||||
const allTopics = await caller.getAllTopics();
|
||||
|
||||
expect(allTopics).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -4,15 +4,12 @@ import { pushTokenRouter } from '@/server/routers/lambda/pushToken';
|
||||
|
||||
const mockUpsert = vi.fn();
|
||||
const mockUnregister = vi.fn();
|
||||
const mockDeleteByExpoTokenAndDevice = vi.fn();
|
||||
|
||||
vi.mock('@/database/models/pushToken', () => ({
|
||||
PushTokenModel: vi.fn(() => ({
|
||||
unregister: mockUnregister,
|
||||
upsert: mockUpsert,
|
||||
})),
|
||||
deletePushTokenByExpoTokenAndDevice: (...args: unknown[]) =>
|
||||
mockDeleteByExpoTokenAndDevice(...args),
|
||||
}));
|
||||
|
||||
const createCaller = (ctxOverrides: Partial<any> = {}) => {
|
||||
@@ -94,90 +91,18 @@ describe('pushTokenRouter', () => {
|
||||
});
|
||||
|
||||
describe('unregister', () => {
|
||||
it('should delete by (expoToken, deviceId) when expoToken is provided', async () => {
|
||||
mockDeleteByExpoTokenAndDevice.mockResolvedValueOnce(undefined);
|
||||
const caller = createCaller();
|
||||
|
||||
const result = await caller.unregister({
|
||||
deviceId: 'device-1',
|
||||
expoToken: 'ExponentPushToken[abc]',
|
||||
});
|
||||
|
||||
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalledWith(expect.anything(), {
|
||||
deviceId: 'device-1',
|
||||
expoToken: 'ExponentPushToken[abc]',
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
// Legacy (userId, deviceId) path must not fire when expoToken is present
|
||||
expect(mockUnregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to (userId, deviceId) for legacy clients with a session', async () => {
|
||||
// Path B — v1.0.7 only sends deviceId; if the request still carries a
|
||||
// valid session we MUST delete the row, otherwise PushChannel keeps
|
||||
// notifying a signed-out device (Expo DeviceNotRegistered only fires on
|
||||
// uninstall, not logout).
|
||||
it('should call model.unregister with deviceId', async () => {
|
||||
mockUnregister.mockResolvedValueOnce(undefined);
|
||||
const caller = createCaller();
|
||||
|
||||
const result = await caller.unregister({ deviceId: 'device-1' });
|
||||
await caller.unregister({ deviceId: 'device-1' });
|
||||
|
||||
expect(mockUnregister).toHaveBeenCalledWith('device-1');
|
||||
expect(mockDeleteByExpoTokenAndDevice).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should silently succeed without expoToken AND without session', async () => {
|
||||
// Path C — v1.0.7 + dead session: the only safe move is silent OK.
|
||||
// Orphan row will be cleaned up by the process-push-receipts worker via
|
||||
// Expo DeviceNotRegistered receipts. Returning 200 here stops the storm.
|
||||
const caller = createCaller({ userId: undefined });
|
||||
|
||||
const result = await caller.unregister({ deviceId: 'device-1' });
|
||||
|
||||
expect(mockDeleteByExpoTokenAndDevice).not.toHaveBeenCalled();
|
||||
expect(mockUnregister).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should succeed for an unauthenticated caller carrying expoToken', async () => {
|
||||
// New clients (>=1.0.8) hit Path A regardless of session.
|
||||
const caller = createCaller({ userId: undefined });
|
||||
|
||||
const result = await caller.unregister({
|
||||
deviceId: 'device-1',
|
||||
expoToken: 'ExponentPushToken[abc]',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalled();
|
||||
expect(mockUnregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer expoToken precision over the legacy userId fallback', async () => {
|
||||
// If both are available, always take Path A — the (expoToken, deviceId)
|
||||
// pair is more precise and doesn't risk deleting a wrong row.
|
||||
const caller = createCaller();
|
||||
|
||||
await caller.unregister({
|
||||
deviceId: 'device-1',
|
||||
expoToken: 'ExponentPushToken[abc]',
|
||||
});
|
||||
|
||||
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalled();
|
||||
expect(mockUnregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject empty deviceId', async () => {
|
||||
const caller = createCaller();
|
||||
await expect(caller.unregister({ deviceId: '' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should reject empty expoToken when provided', async () => {
|
||||
const caller = createCaller();
|
||||
await expect(
|
||||
caller.unregister({ deviceId: 'device-1', expoToken: '' }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ const mockFindById = vi.fn();
|
||||
|
||||
const mockCountTopicsForMemoryExtractor = vi.fn();
|
||||
const mockDeleteAll = vi.fn();
|
||||
const mockDeletePersona = vi.fn();
|
||||
const { mockTriggerProcessUsers } = vi.hoisted(() => ({
|
||||
mockTriggerProcessUsers: vi.fn(),
|
||||
}));
|
||||
@@ -44,12 +43,6 @@ vi.mock('@/database/models/userMemory', () => ({
|
||||
UserMemoryPreferenceModel: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('@/database/models/userMemory/persona', () => ({
|
||||
UserPersonaModel: vi.fn(() => ({
|
||||
deletePersona: mockDeletePersona,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/envs/app', () => ({
|
||||
appEnv: {
|
||||
APP_URL: 'https://example.com',
|
||||
@@ -308,13 +301,11 @@ describe('userMemoryRouter.deleteAll', () => {
|
||||
|
||||
it('purges all user memories through the aggregate model', async () => {
|
||||
mockDeleteAll.mockResolvedValue(undefined);
|
||||
mockDeletePersona.mockResolvedValue(undefined);
|
||||
|
||||
const caller = createCaller();
|
||||
const result = await caller.deleteAll();
|
||||
|
||||
expect(mockDeleteAll).toHaveBeenCalledOnce();
|
||||
expect(mockDeletePersona).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,12 +36,10 @@ export const compareDocumentHistoryItemsInputSchema = z.object({
|
||||
});
|
||||
|
||||
export const updateDocumentInputSchema = z.object({
|
||||
breakAutosaveWindow: z.boolean().optional(),
|
||||
content: z.string().optional(),
|
||||
editorData: z.string().optional(),
|
||||
fileType: z.string().optional(),
|
||||
id: z.string(),
|
||||
lockOwnerId: z.string().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
restoreFromHistoryId: z.string().optional(),
|
||||
@@ -52,7 +50,6 @@ export const updateDocumentInputSchema = z.object({
|
||||
export const saveDocumentHistoryInputSchema = z.object({
|
||||
documentId: z.string(),
|
||||
editorData: z.string(),
|
||||
lockOwnerId: z.string().optional(),
|
||||
saveSource: documentHistorySaveSourceSchema,
|
||||
});
|
||||
|
||||
@@ -61,7 +58,6 @@ export interface DocumentHistoryListItem {
|
||||
isCurrent: boolean;
|
||||
savedAt: string;
|
||||
saveSource: DocumentHistorySaveSource;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ListHistoryOutput {
|
||||
@@ -100,8 +96,6 @@ export interface UpdateDocumentOutput {
|
||||
export interface SaveDocumentHistoryInput {
|
||||
documentId: string;
|
||||
editorData: string;
|
||||
/** Edit-session id proving the client still holds the workspace page lease. */
|
||||
lockOwnerId?: string;
|
||||
saveSource: DocumentHistorySaveSource;
|
||||
}
|
||||
|
||||
@@ -129,12 +123,10 @@ export interface CompareHistoryItemsInput {
|
||||
}
|
||||
|
||||
export interface UpdateDocumentInput {
|
||||
breakAutosaveWindow?: boolean;
|
||||
content?: string;
|
||||
editorData?: string;
|
||||
fileType?: string;
|
||||
id: string;
|
||||
lockOwnerId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
parentId?: string | null;
|
||||
restoreFromHistoryId?: string;
|
||||
|
||||
@@ -17,8 +17,6 @@ import { workspaceMembers } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { AgentService } from '@/server/services/agent';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { TransferErrorCode } from '@/types/transferError';
|
||||
|
||||
const agentProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
@@ -30,7 +28,6 @@ const agentProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
agentService: new AgentService(ctx.serverDB, ctx.userId, wsId),
|
||||
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
|
||||
editLockService: new EditLockService(ctx.userId),
|
||||
fileModel: new FileModel(ctx.serverDB, ctx.userId, wsId),
|
||||
knowledgeBaseModel: new KnowledgeBaseModel(ctx.serverDB, ctx.userId, wsId),
|
||||
sessionModel: new SessionModel(ctx.serverDB, ctx.userId, wsId),
|
||||
@@ -443,19 +440,6 @@ export const agentRouter = router({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Collaborative edit lock: reject writes to a workspace agent another
|
||||
// member is actively editing. Inert until a client acquires the lock.
|
||||
if (ctx.workspaceId) {
|
||||
const blockedBy = await ctx.editLockService.getBlockingHolder('agent', input.agentId);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Agent is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Use AgentService to update and return the updated agent data
|
||||
return ctx.agentService.updateAgentConfig(input.agentId, input.value);
|
||||
}),
|
||||
@@ -474,48 +458,4 @@ export const agentRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return ctx.agentModel.update(input.id, { pinned: input.pinned });
|
||||
}),
|
||||
|
||||
acquireAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const prev = await ctx.editLockService.getActiveHolder('agent', input.agentId);
|
||||
const result = await ctx.editLockService.acquire('agent', input.agentId);
|
||||
if ((result.holderId ?? null) !== (prev ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id: input.agentId, type: 'agent' },
|
||||
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
getAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await ctx.editLockService.getActiveHolder('agent', input.agentId);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== ctx.userId,
|
||||
};
|
||||
}),
|
||||
|
||||
releaseAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease expired and another member took over, the lock is still held.
|
||||
const released = await ctx.editLockService.release('agent', input.agentId);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id: input.agentId, type: 'agent' },
|
||||
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import { z } from 'zod';
|
||||
|
||||
import { withScopedPermission } from '@/business/server/trpc-middlewares/rbacPermission';
|
||||
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
|
||||
import { TopicTrigger } from '@/const/topic';
|
||||
import { AgentDocumentModel } from '@/database/models/agentDocuments';
|
||||
import { TopicModel } from '@/database/models/topic';
|
||||
import { TopicDocumentModel } from '@/database/models/topicDocument';
|
||||
@@ -255,56 +254,6 @@ export const agentDocumentRouter = router({
|
||||
return ctx.agentDocumentService.getDocument(input.agentId, input.filename);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Return the chat topic that anchors the doc-scoped conversation for this
|
||||
* `(documentId, agentId)` pair, creating it idempotently on the first call.
|
||||
*
|
||||
* Topics are marked with `trigger='document'` so they stay out of the main
|
||||
* sidebar history (`MAIN_SIDEBAR_EXCLUDE_TRIGGERS` already excludes them).
|
||||
* The mapping is persisted through `topic_documents`, so subsequent calls
|
||||
* resolve the same topic id.
|
||||
*/
|
||||
getOrCreateChatTopic: agentDocumentProcedure
|
||||
.input(
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
documentId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.topicModel.findByAgentAndDocumentTrigger({
|
||||
agentId: input.agentId,
|
||||
documentId: input.documentId,
|
||||
trigger: TopicTrigger.Document,
|
||||
});
|
||||
if (existing) return { topicId: existing.id };
|
||||
|
||||
const document = await ctx.agentDocumentService.findRowByDocumentId(
|
||||
input.agentId,
|
||||
input.documentId,
|
||||
);
|
||||
if (!document) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: `Document not found for agentId=${input.agentId}`,
|
||||
});
|
||||
}
|
||||
|
||||
const title = document.title || document.filename || 'Document chat';
|
||||
const topic = await ctx.topicModel.create({
|
||||
agentId: input.agentId,
|
||||
title,
|
||||
trigger: TopicTrigger.Document,
|
||||
});
|
||||
|
||||
await ctx.topicDocumentModel.associate({
|
||||
documentId: input.documentId,
|
||||
topicId: topic.id,
|
||||
});
|
||||
|
||||
return { topicId: topic.id };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create or update a document
|
||||
*/
|
||||
@@ -423,16 +372,12 @@ export const agentDocumentRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
// Reveal the auto-created `.tool-results` archive. Off by default so
|
||||
// user-facing lists stay clean; the agent document-listing tool opts in.
|
||||
includeArchivedToolResults: z.boolean().optional().default(false),
|
||||
scope: z.enum(['agent', 'currentTopic']).optional().default('agent'),
|
||||
sourceType: z.enum(['all', 'file', 'web']).optional().default('all'),
|
||||
topicId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { includeArchivedToolResults } = input;
|
||||
if (input.scope === 'currentTopic') {
|
||||
if (!input.topicId) throw new Error('topicId is required to list current topic documents');
|
||||
|
||||
@@ -440,13 +385,10 @@ export const agentDocumentRouter = router({
|
||||
input.agentId,
|
||||
input.topicId,
|
||||
input.sourceType,
|
||||
{ includeArchivedToolResults },
|
||||
);
|
||||
}
|
||||
|
||||
return ctx.agentDocumentService.listDocuments(input.agentId, input.sourceType, {
|
||||
includeArchivedToolResults,
|
||||
});
|
||||
return ctx.agentDocumentService.listDocuments(input.agentId, input.sourceType);
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,8 +14,6 @@ import { type ChatGroupConfig } from '@/database/types/chatGroup';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { AgentGroupService } from '@/server/services/agentGroup';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { TransferErrorCode } from '@/types/transferError';
|
||||
|
||||
/**
|
||||
@@ -57,7 +55,6 @@ const agentGroupProcedure = wsCompatProcedure.use(serverDatabase).use(async (opt
|
||||
agentGroupService: new AgentGroupService(ctx.serverDB, ctx.userId, wsId),
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
|
||||
editLockService: new EditLockService(ctx.userId),
|
||||
userModel: new UserModel(ctx.serverDB, ctx.userId),
|
||||
},
|
||||
});
|
||||
@@ -405,19 +402,6 @@ export const agentGroupRouter = router({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Collaborative edit lock: reject writes to a workspace group another
|
||||
// member is actively editing. Inert until a client acquires the lock.
|
||||
if (ctx.workspaceId) {
|
||||
const blockedBy = await ctx.editLockService.getBlockingHolder('chatGroup', input.id);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Group is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.chatGroupModel.update(input.id, {
|
||||
...input.value,
|
||||
config: ctx.agentGroupService.normalizeGroupConfig(
|
||||
@@ -425,47 +409,6 @@ export const agentGroupRouter = router({
|
||||
),
|
||||
});
|
||||
}),
|
||||
|
||||
acquireGroupLock: agentGroupProcedureWrite
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const prev = await ctx.editLockService.getActiveHolder('chatGroup', input.id);
|
||||
const result = await ctx.editLockService.acquire('chatGroup', input.id);
|
||||
if ((result.holderId ?? null) !== (prev ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id: input.id, type: 'chatGroup' },
|
||||
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
getGroupLock: agentGroupProcedureWrite
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await ctx.editLockService.getActiveHolder('chatGroup', input.id);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== ctx.userId,
|
||||
};
|
||||
}),
|
||||
|
||||
releaseGroupLock: agentGroupProcedureWrite
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease expired and another member took over, the lock is still held.
|
||||
const released = await ctx.editLockService.release('chatGroup', input.id);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id: input.id, type: 'chatGroup' },
|
||||
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
export type AgentGroupRouter = typeof agentGroupRouter;
|
||||
|
||||
@@ -85,7 +85,6 @@ export const agentSignalRouter = router({
|
||||
return enqueueAgentSignalSourceEvent(sourceEvent, {
|
||||
agentId: input.agentId,
|
||||
userId: ctx.userId,
|
||||
workspaceId: ctx.workspaceId ?? undefined,
|
||||
});
|
||||
}),
|
||||
listReceipts: agentSignalProcedure
|
||||
|
||||
@@ -139,8 +139,6 @@ const ExecAgentSchema = z
|
||||
.object({
|
||||
defaultTaskAssigneeAgentId: z.string().optional(),
|
||||
documentId: z.string().optional().nullable(),
|
||||
/** The agent being edited when scope is 'agent_builder' (not the builder builtin itself). */
|
||||
editingAgentId: z.string().optional(),
|
||||
groupId: z.string().optional().nullable(),
|
||||
initialTopicMetadata: z
|
||||
.object({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user